Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

01 — Bootstrapping @frenchexdev/ide-dsl

The design series delivered a seven-article argument for why the meta-DSL should exist and what its surface has to look like. This article opens the build series by describing — in prose, with typed snippets — the smallest package that honours that argument: @frenchexdev/ide-dsl, the user-facing half of the system. No extractor, no emitters, no LSP handlers yet; just the decorator surface, the type-level LanguageIR, and the module-load registry that holds fragments between the moment a spec file is evaluated and the moment ide-forge walks its AST with ts-morph. That is one package out of four, and exactly as much complexity as an author of a target DSL should ever have to see.

The bootstrap is worth a dedicated article because every architectural choice made here travels. If the decorators ran under experimentalDecorators: true, the emitters would have to re-derive their shapes from reflect-metadata; if the registry lived in a global mutable map, the extractor would become order-dependent on file evaluation and cease to be deterministic; if the IR were not branded, every emitter would carry its own string normalisation. The point of this article is to take those choices explicitly and tie them to one Requirement the rest of the series will refine, and one Feature whose acceptance criteria the later articles can cite without re-deriving.

REQ-IDEDSL-DECORATORS-STANDARD — the anchor Requirement

Every article in this series opens on a named Requirement. The first one is a technical-posture Requirement rather than a feature-count Requirement: it does not say "the DSL shall have five decorators", it says "the six decorators shall run under the standard TC39 protocol, with no runtime metadata channel". It sets the ground rules for everything downstream.

REQ-IDEDSL-DECORATORS-STANDARDThe six user-facing decorators @Language, @Token, @Rule, @Snippet, @LspFeature, @Executor shall run under TC39 standard decorators (experimentalDecorators=false, emitDecoratorMetadata=false), shall attach their IR fragments through a module-load registry, and shall leave the target class unchanged at runtime.

Rationale: reflect-metadata and experimentalDecorators are on a stale TS path; the standard protocol is now stable, tree-shakeable, and aligned with the compile-time extraction strategy of ide-forge. Emitters must be able to read fragments statically through ts-morph; attaching hidden metadata with reflect-metadata would create a runtime-only channel that breaks the codegen contract.

Fit criteria: packages/ide-dsl compiles with experimentalDecorators=false and emitDecoratorMetadata=false; instantiating a decorated class leaves its prototype and constructor identity intact (===). Verification: Test.

The Requirement looks terse on the page, but each phrase is load-bearing. "TC39 standard decorators" excludes the legacy experimentalDecorators dialect that TypeScript shipped in 2015 and that dominated the decorator-using ecosystem until roughly 2023 — including Angular, NestJS, typeorm, and the reflect-metadata library the meta-DSL explicitly refuses. "Module-load registry" excludes the pattern where decorators attach metadata that only becomes visible at runtime via Reflect.getMetadata(...); the emitters have to be able to read what the decorators know without ever executing them, because the extractor walks the AST, not the evaluated module. "Leave the target class unchanged" excludes the anti-pattern where a decorator wraps or proxies the constructor: the spec classes in this DSL are never instantiated in production code (they are carriers for type-level information); but the ACs still ask that their identity survive, because tests instantiate them and property-test invariants have to hold.

The companion Requirement in the design series was article 03's argument that codegen wins over reflection (see Part 03 — The trip and the guardrails, the "Roslyn source generators" section). This article is where that argument becomes a concrete tsconfig flag.

FEAT-IDEDSL-01 — the satisfying Feature

The Feature that satisfies the Requirement has ten acceptance criteria, grouped into four clusters. Each cluster maps to a file in the package, and each AC is phrased as a predicate that a later test snippet can verify.

// packages/ide-dsl/requirements/features/ide-dsl-decorators.ts
import { Feature, Priority, Satisfies, type ACResult } from '@frenchexdev/requirements';
import { ReqIdeDslDecoratorsStandardRequirement } from '../requirements/req-idedsl-decorators-standard.js';

@Satisfies(ReqIdeDslDecoratorsStandardRequirement)
export abstract class IdeDslDecoratorsFeature extends Feature {
  readonly id = 'FEAT-IDEDSL-01';
  readonly title = 'Ide.Dsl decorator surface — @Language, @Token, @Rule, @Snippet, @LspFeature, @Executor';
  readonly priority = Priority.Critical;

  // ── @Language ──
  abstract languageDecoratorRegistersHeaderOnce(): ACResult;
  abstract languageDecoratorReturnsTargetUnchanged(): ACResult;

  // ── @Token / @Rule ──
  abstract tokenDecoratorAppendsTokenFragment(): ACResult;
  abstract ruleDecoratorAppendsRuleFragment(): ACResult;

  // ── @Snippet / @Executor ──
  abstract snippetDecoratorAppendsSnippetFragment(): ACResult;
  abstract executorDecoratorAppendsExecutorFragment(): ACResult;

  // ── @LspFeature ──
  abstract lspFeatureDecoratorAppendsLspFragment(): ACResult;

  // ── Registry invariants ──
  abstract registryIsolatesFragmentsByTargetClass(): ACResult;
  abstract registryPreservesDecoratorOrder(): ACResult;
  abstract registryExposesReadonlyFragmentList(): ACResult;
}

The @Satisfies decorator is the traceability link: at compliance time, npx requirements trace chain FEAT-IDEDSL-01 languageDecoratorRegistersHeaderOnce walks from the AC method back through the satisfied Requirement to the Requirement's evidence and risk, and forward through the binding table to the tests that verify it. Nothing magic — SysML's refine/derive/satisfy vocabulary, with the DSL we are building an IDE for here.

The four clusters are worth reading as a short table of contents for the rest of the article. "@Language" pins the two structural invariants that every decorator has to respect (register once, identity preserved); "@Token/@Rule" and "@Snippet/@Executor" each exhibit the fragment-append pattern on a different IR kind, because the first time a pattern is shown it has to be convincing; "@LspFeature" stands alone because its fragment shape is richer (it carries a handler-class name that article 07 will emit against); and "Registry invariants" gives the three properties on which articles 09, 11, and 15 all rely — fragment isolation, decorator order preservation, and read-only externalisation.

The package skeleton

The shape on disk mirrors packages/typed-fsm almost one-to-one, with the decorator-config flags flipped. The package.json exposes three subpath exports — the barrel, ./decorators, and ./ir — so consumers can import just the decorators without pulling the registry into their bundle. The workspace dependency on @frenchexdev/requirements is the dog-food link: the decorators here are traced by the same DSL whose extension is the running example.

{
  "name": "@frenchexdev/ide-dsl",
  "version": "0.0.1",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".":          { "types": "./dist/index.d.ts",       "import": "./dist/index.js" },
    "./decorators": { "types": "./dist/decorators.d.ts", "import": "./dist/decorators.js" },
    "./ir":         { "types": "./dist/ir.d.ts",         "import": "./dist/ir.js" }
  },
  "dependencies": { "@frenchexdev/requirements": "workspace:*" },
  "peerDependencies": { "typescript": ">=5.4" }
}

The tsconfig is where the posture becomes visible. Two flags say "no":

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "experimentalDecorators": false,   // TC39 standard decorators
    "emitDecoratorMetadata": false,    // no reflect-metadata channel
    "noUncheckedIndexedAccess": true,
    "declaration": true,
    "composite": true
  }
}

experimentalDecorators: false is the line that enforces the posture. TypeScript 5.0 (March 2023) shipped stable support for the TC39 Stage 3 decorators proposal; experimentalDecorators: false is the default for new projects now, and the legacy dialect is entering long-tail mode. emitDecoratorMetadata: false goes further — it forbids the compiler from embedding type information that would only be readable through reflect-metadata at runtime. Neither flag blocks the decorators from running; they just narrow the surface so the only channel a decorator has to speak through is its own body, executed at module-evaluation time. That constraint is what makes the extractor's job statically tractable.

Why TC39, not experimental — prior art engaged

The choice to use the standard decorator protocol rather than the legacy experimental one is the single largest posture decision in @frenchexdev/ide-dsl, and it is worth engaging the prior art explicitly rather than handwaving at "the standard is better".

TC39 decorator history. JavaScript decorators have had one of the most protracted standardisation arcs in the history of the committee. An initial proposal, championed by Yehuda Katz and Daniel Ehrenberg around 2014, reached Stage 2 in 2016 as the so-called "legacy" or "Stage 1" decorators — the shape TypeScript and Babel implemented under experimentalDecorators: true and that Angular, NestJS, typeorm, mobx, and inversify all depend on. That shape exposed a rich set of hooks: decorators could observe and rewrite property descriptors, they received design-time type information through companion metadata, they could substitute the class entirely. The price was that the specification kept shifting — a competing "Stage 2 TC39" proposal from Ron Buckton in 2018 (revised repeatedly through 2021) proposed a fundamentally different shape where decorators were pure functions returning replacement functions, with no descriptor access and no metadata channel. That proposal, after six more years of iteration, reached Stage 3 in March 2022 and was ratified as Stage 4 in the 2024 edition of ECMAScript. Buckton also drove the TypeScript implementation — 5.0 in March 2023 was the first release that supported the ratified shape under experimentalDecorators: false.

The practical consequence for authors is that the legacy and standard shapes are not source-compatible. A decorator written against the legacy protocol (three-argument target/key/descriptor signature for methods, single-argument target signature for classes with mutable class-augmentation expected) does not run under the standard protocol (two-argument target/context signature, with context.kind discriminating method/class/accessor/field). Any project that chooses one protocol excludes the other — there is no universal decorator that covers both — and every library that hopes to stay relevant past 2026 has to port. Angular shipped preliminary standard-decorator support in v17 (late 2023) but defaulted to legacy through v18; NestJS is still legacy-first as of the latest releases; typeorm has an open issue on the migration with no roadmap. The ecosystem will be bifurcated for years.

Angular's reflect-metadata posture. Angular's dependency injection is the canonical reason the legacy decorators look attractive: @Injectable() on a service, @Inject(SOMETHING) on a constructor parameter, and the framework's runtime walks Reflect.getMetadata('design:paramtypes', SomeClass) to resolve the parameter types into providers. This works because the TypeScript compiler, under emitDecoratorMetadata: true, emits __decorate calls that stash the parameter-type list on the class as hidden metadata. It is elegant for DI — the framework never has to re-parse the source, never has to be told the types — but it has three costs the ide-dsl team explicitly rejects. First, it creates a runtime-only channel: the information is invisible to static tools. Second, it requires the reflect-metadata polyfill at bootstrap (no native Reflect.getMetadata exists; the polyfill must be loaded before any decorated class is imported). Third, it ties the project to the legacy decorator dialect, because TC39 standard decorators expose no metadata slot.

The ide-dsl bet is the opposite. The extractor in ide-forge does not want to walk Reflect.getMetadata; it wants to walk the AST. The emitters do not want to execute the spec file; they want to read it. The whole point of the meta-DSL is that the VSCode extension is generated from source, not reflected from runtime. Adopting Angular's posture would make every emitter have to either (a) execute the spec file in a subprocess and query Reflect.getMetadata from the outside — a deployment pain and a source of subtle mismatch bugs — or (b) re-implement reflection on top of the AST, effectively simulating what the runtime would have done. Either option is strictly worse than the one the series takes: write decorators whose bodies are the contract, and have the extractor read that same contract statically.

There is a legitimate counter-argument, worth naming. Under the legacy protocol, @Expects(TestLevel.Unit) on an AC method could capture the method's return type automatically via design:returntype; under the standard protocol, the same decorator has to be told the return type explicitly (or extract it from the AST when the test is generated). The series accepts that small increase in verbosity as the price of a single-channel, tool-friendly, future-compatible posture. The @frenchexdev/requirements package already made the same bet — see packages/requirements/src/decorators.ts — and has been living with the consequences comfortably.

Module-load registry — the state decorators mutate

The TC39 decorator protocol is strictly less powerful than the experimental one. It does not expose class metadata slots, it does not receive Reflect information, it does not see design-time types. What it receives is a target (the class) and a context (the kind of decoration site). Everything else a decorator wants to know or share, it has to explicitly pass or externalise. The registry is the externalisation.

Diagram
Figure 1 — Module-evaluation sequence. When a `spec.ts` file is loaded, each decorator runs top-to-bottom and appends a fragment into the registry keyed by the target class. The extractor never executes this sequence — it reads the same decorator metadata statically from the AST via ts-morph.

The registry is a module-local Map<string, LanguageFragment> — not a global, not attached to the class itself. Keying by target.name rather than by the class object is a concession to the TC39 protocol: class decorators run before the class binding is fully installed, and in some tool-chains the class identity seen from inside the decorator is a transient. The name is stable, known at compile time, and that is what the extractor will look up when it walks the AST. The concession costs nothing here: a spec file that declared two classes with the same name in the same module would already be a TS error; and two spec files that declared a class with the same name would be isolated because each file has its own registry module instance.

The registry's surface is deliberately small:

export function registerLanguage(target: Function, fragment: Omit<LanguageFragment, 'tokens' | 'rules' | 'snippets' | 'lspFeatures' | 'executors'>): void;
export function appendToken(target: Function, token: IRToken): void;
export function appendRule(target: Function, rule: IRRule): void;
export function appendSnippet(target: Function, snippet: IRSnippet): void;
export function appendLspFeature(target: Function, feature: IRLspFeature): void;
export function appendExecutor(target: Function, executor: IRExecutor): void;
export function getFragment(target: Function): LanguageFragment | undefined;
export function listFragments(): readonly LanguageFragment[];
export function __resetForTests(): void;

Eight functions, one of them test-only. The five append* functions are the exact complement of the five fragment-bearing decorators; registerLanguage handles the header; getFragment and listFragments are the read surface the extractor and the unit tests both use. There is no deregister, no clear except in test mode, no ordering API — the order in which decorators fire is the order in which fragments were appended, and that is the only order the emitters need.

The pattern is identical in shape to the BUILT_IN_STYLES / DEFAULT_STYLE_REGISTRY pair in packages/requirements/src/styles/ and to DEFAULT_REGISTRY in packages/requirements/src/cli/scaffolders/registry.ts. That is not an accident: the monorepo has already settled on module-load registration as its extensibility pattern, and the build series inherits it. A reader who has followed the Source Generators & DSLs category in previous articles will recognise the shape instantly; a reader who has not is being handed the shape here with a pointer back.

The six decorators — body shapes

The body of each decorator is three to five lines. @Language calls registerLanguage; the five fragment decorators each call one of the append* functions. The bodies do not transform the target, do not wrap it, do not add static members. They are pure registration.

export function Token(opts: TokenOptions): ClassDecorator {
  return (target) => {
    appendToken(target, {
      name: opts.name as TokenName,
      pattern: opts.pattern,
      scope: opts.scope,
    });
  };
}

Two things in this body are worth slowing down on. The first is the as TokenName cast: TokenName is a branded string type (see the IR section below), and the cast is the one place where a bare string becomes a branded one. The boundary is the decorator: everything on the author's side is a plain string (because authors write @Token({ name: 'KEYWORD', ... })), and everything on the IR's side is a branded string (because emitters rely on the brand to prevent cross-primitive confusion). The second is that the decorator returns void, not the target: under the TC39 protocol, returning void from a class decorator is the signal "I did not substitute the target", which is exactly the invariant the Feature asks for (languageDecoratorReturnsTargetUnchanged). A return target would be harmless but louder than necessary; a return class extends target {...} would violate the AC.

The six decorators together are about 110 lines of body code, or one line of body per decorator option on average. That compactness is the Requirement working — because the registry owns the state, the decorators have nothing else to do.

LanguageIR — the shape the registry carries

The registry carries fragments, not IRs; the IR is what ide-forge assembles from the fragments. But the shape of the fragment and the shape of the IR are in close correspondence, so this article is the right place to exhibit the IR skeleton. Later articles (10 for the IR contract proper, 11 for the first emitter) will pull it apart in more depth.

// packages/ide-dsl/src/ir.ts — skeleton
export const IDE_IR_SCHEMA_VERSION = '2026-04-14' as const;

export type LanguageId = string & { readonly __brand: 'LanguageId' };
export type TokenName = string & { readonly __brand: 'TokenName' };
// ... five more branded primitives

export interface IRToken { readonly name: TokenName; readonly pattern: string; readonly scope: string; }
export interface IRRule { readonly name: RuleName; readonly produces: readonly TokenName[]; readonly description?: string; }
// ... IRSnippet, IRLspFeature, IRExecutor

export interface LanguageIR {
  readonly $schemaVersion: typeof IDE_IR_SCHEMA_VERSION;
  readonly id: LanguageId;
  readonly displayName: string;
  readonly fileExtensions: readonly string[];
  readonly tokens: readonly IRToken[];
  readonly rules: readonly IRRule[];
  readonly snippets: readonly IRSnippet[];
  readonly lspFeatures: readonly IRLspFeature[];
  readonly executors: readonly IRExecutor[];
}

Four things to notice. The schema version is an ISO date string, matching the convention in packages/requirements/src/base.ts: the format is YYYY-MM-DD, one version per day-of-breaking-change, and breaking changes are unusual enough that the date resolution is adequate. The branded primitives are the compile-time-only identity markers that prevent a token name from being passed where a language id is expected; they cost nothing at runtime because the __brand property is never written. The readonly modifiers are ubiquitous: the IR is immutable once assembled, and the emitters that consume it are forbidden from mutating it in any way the compiler can catch.

The fourth thing is what is not in the IR. There is no packageJson field, no activationEvents field, no vscodeEngineVersion field. Those are emitter-level concerns — the manifest emitter (article 05) derives them from the IR plus a small ide-forge.config.ts. Keeping them out of the IR is the first application of the DRY lens: the IR is the single source of truth, and anything that is a concern of one emitter only does not belong there.

Diagram
Figure 2 — Fragment vs IR. The module-load registry carries a mutable `LanguageFragment` keyed by class name; `ide-forge` projects the fragment into a readonly `LanguageIR` once extraction is complete. The five fragment-kinds correspond one-to-one with the five non-header decorators.

Testing the Feature — the shape of one AC verification

The ACs are abstract methods; the tests concretise them through the @FeatureTest + @Verifies pattern (zero describe/it — see feedback_no_describe_it). The test runner is vitest, and expect is imported explicitly (no globals) — this keeps the snippet copy-paste-able into a fresh file without ambient-config surprises. Six of the ten ACs fit on one screen; the remaining four follow the same shape.

// packages/ide-dsl/test/unit/decorators/decorators.test.ts
import { expect } from 'vitest';
import { FeatureTest, Verifies } from '@frenchexdev/requirements';
import {
  Language, Token, getFragment, listFragments, __resetForTests,
} from '@frenchexdev/ide-dsl';
import { IdeDslDecoratorsFeature } from '../../../requirements/features/ide-dsl-decorators.js';

@FeatureTest(IdeDslDecoratorsFeature)
class IdeDslDecoratorsTests {
  setUp() { __resetForTests(); }

  @Verifies('languageDecoratorRegistersHeaderOnce')
  registersTheLanguageHeaderOnce() {
    @Language({ id: 'req', displayName: 'Requirements', fileExtensions: ['.req'] })
    class RequirementsSpec {}
    const frag = getFragment(RequirementsSpec);
    expect(frag).toBeDefined();
    expect(frag!.id).toBe('req');
    expect(frag!.displayName).toBe('Requirements');
    expect(frag!.fileExtensions).toEqual(['.req']);
  }

  @Verifies('languageDecoratorReturnsTargetUnchanged')
  returnsTheTargetClassUnchanged() {
    class Before {}
    const decorator = Language({ id: 'x', displayName: 'X', fileExtensions: ['.x'] });
    const result = decorator(Before, { kind: 'class', name: 'Before' } as never);
    expect(result).toBeUndefined();              // TC39: void === "no substitution"
    expect(new Before()).toBeInstanceOf(Before); // constructor identity preserved
  }

  @Verifies('tokenDecoratorAppendsTokenFragment')
  appendsATokenFragmentUnderTheTargetClass() {
    @Token({ name: 'KEYWORD_REQ', pattern: '\\bREQ-[A-Z0-9-]+\\b', scope: 'keyword.other.req' })
    @Language({ id: 'req', displayName: 'Requirements', fileExtensions: ['.req'] })
    class RequirementsSpec {}
    const frag = getFragment(RequirementsSpec)!;
    expect(frag.tokens).toHaveLength(1);
    expect(frag.tokens[0]!.name).toBe('KEYWORD_REQ');
    expect(frag.tokens[0]!.pattern).toBe('\\bREQ-[A-Z0-9-]+\\b');
    expect(frag.tokens[0]!.scope).toBe('keyword.other.req');
  }

  @Verifies('registryIsolatesFragmentsByTargetClass')
  keepsFragmentsIsolatedPerTargetClass() {
    @Token({ name: 'A', pattern: 'a', scope: 'x.a' })
    @Language({ id: 'alpha', displayName: 'Alpha', fileExtensions: ['.a'] })
    class AlphaSpec {}

    @Token({ name: 'B', pattern: 'b', scope: 'x.b' })
    @Language({ id: 'beta', displayName: 'Beta', fileExtensions: ['.b'] })
    class BetaSpec {}

    const a = getFragment(AlphaSpec)!;
    const b = getFragment(BetaSpec)!;
    expect(a.tokens.map(t => t.name)).toEqual(['A']);
    expect(b.tokens.map(t => t.name)).toEqual(['B']);
    expect(a).not.toBe(b);
  }

  @Verifies('registryPreservesDecoratorOrder')
  preservesTheOrderInWhichDecoratorsFire() {
    @Token({ name: 'THIRD',  pattern: '3', scope: 'x.3' })
    @Token({ name: 'SECOND', pattern: '2', scope: 'x.2' })
    @Token({ name: 'FIRST',  pattern: '1', scope: 'x.1' })
    @Language({ id: 'ord', displayName: 'Ord', fileExtensions: ['.ord'] })
    class OrderedSpec {}
    const frag = getFragment(OrderedSpec)!;
    // TC39 class decorators fire bottom-to-top around the class body; the
    // registry pushes in firing order, so this is the order the IR carries.
    expect(frag.tokens.map(t => t.name)).toEqual(['FIRST', 'SECOND', 'THIRD']);
  }

  @Verifies('registryExposesReadonlyFragmentList')
  exposesFragmentsAsAReadonlyList() {
    @Language({ id: 'z', displayName: 'Z', fileExtensions: ['.z'] })
    class ZSpec {}
    const list = listFragments();
    expect(list).toHaveLength(1);
    // Type-level readonly: `list.push(...)` is a compile-error (tsc --noEmit).
    // Runtime check: mutating does not leak into the next call.
    (list as unknown as { length: number }).length = 0;
    const again = listFragments();
    expect(again).toHaveLength(1);
  }
}

Six ACs, six methods, no describe/it. The four remaining ACs follow the same shape and belong in sibling files — one file per decorator is a legitimate layout (decorators/language.test.ts, decorators/token.test.ts, and so on) once the suite grows; a single decorators.test.ts is fine while the decorator count is six. The property-testing layer (articles 10 and 19 will tighten it) will add a seventh class, IdeDslDecoratorsFastChecks, that uses fast-check to generate random orderings and assert the last two invariants over 200 runs.

The __resetForTests hook in setUp() is the price of a module-local singleton: order tests, isolation tests, and property tests all need a clean slate between methods, and the registry provides it behind a clearly-marked test-only name. The hook is not re-exported from the package barrel — it lives in ./internal or a _resetForTests barrel that downstream consumers never import. The naming with the double-underscore and the documented test-only scope mirrors the pattern in packages/requirements/src/styles/, and it is the cheapest honest way to make module-local state testable.

Two subtleties the snippet rewards a closer read on. The as never cast on the TC39 ClassDecoratorContext argument in returnsTheTargetClassUnchanged is because a full ClassDecoratorContext is awkward to construct by hand (it has a metadata, an addInitializer, and so on); the decorator body here does not read any of those fields, so a structural cast is adequate and does not weaken the test — if the decorator started reading the context, the test would crash at runtime and have to be updated. The as unknown as { length: number } cast in the readonly-list test is deliberate: we want to demonstrate that even if a malicious consumer forces the runtime mutation through, the registry's internal state is independent. The type-level assertion (that .push(...) is a compile error) is covered by a separate tsc --noEmit expectation in a test/typing/*.ts file the way packages/requirements does it, rather than by a vitest case.

SOLID lens

Article 04 of the design series walked the five principles through the monorepo patterns at length; this bootstrap touches three of them concretely.

Single Responsibility lives at the decorator boundary. @Language registers a header; @Token appends a token; no decorator does both, and none does anything else. The alternative — a single @SpecClass decorator that receives a bag of options for all fragment kinds — would halve the line count but would also halve the type safety: a typo in an option key would be silently dropped. The six-decorator surface is more verbose and more type-safe, and the verbosity is localised to the authors' spec files where the tooling can help them.

Open/Closed lives at the registry. The registry knows nothing about the six decorators that populate it; all it knows is five append* functions that take a fragment-kind each. A future @SemanticToken decorator (article 04 will foreshadow one) would add a seventh appendSemanticToken function and a seventh IRSemanticToken kind without modifying any of the existing five. The registry is open for extension and closed for modification in the literal sense.

Liskov Substitution lives at the target-class identity invariant. The ACs languageDecoratorReturnsTargetUnchanged and the equivalents on the other five decorators all say, in aggregate, "a decorated class behaves exactly like an undecorated one at the call site". That is LSP in its strictest form: the type of the decorated class is a (trivial) subtype of the undecorated, because it is the undecorated; any code that holds a reference to the class before decoration sees the same class after. Authors who write tests that new their spec classes for property-testing purposes get to rely on this.

Interface Segregation and Dependency Inversion show up in later articles (extractor ports in 09, emitter ports in 11). They are not the shape of this article.

DRY lens

The DRY lens is where the non-obvious choices earn their keep.

Fragment vs IR. The registry holds fragments (mutable-during-module-evaluation arrays keyed by class name); ide-forge assembles them into an immutable IR (readonly object tree). The two shapes could have been one — the registry could hold assembled IRs directly, with each decorator updating the whole object. The reason to keep them separate is that the fragment is a mid-build state and the IR is a contract; separating them makes the invariants "IR is immutable" and "fragments accumulate during module eval" both enforceable at the type level, instead of commented as conventions.

Branded primitives. LanguageId, TokenName, RuleName, SnippetTrigger, ExecutorId, LspFeatureId are all string & { __brand: '...' }. The alternative — a bare string type everywhere — would let an emitter pass a TokenName where a LanguageId was expected and get no compile error. The brands cost one cast at the boundary (the decorators) and no runtime overhead, and in return they prevent a class of bugs that would otherwise only surface during manual extension-install testing. The same pattern appears in packages/requirements/src/base.ts (RequirementId, FeatureId, AcName, IsoDate, Sentence, Percentage) and is a direct borrowing.

Registry pattern shared across packages. The registerX / listX / __resetForTests surface is the same one used by StyleRegistry, ScaffolderRegistry, and the FSM emits/listens topology registry in packages/typed-fsm. A developer who has implemented one has implemented all four. The series does not invent a new extensibility pattern for the meta-DSL; it inherits the one already proved in the monorepo.

Cross-link and what article 02 picks up

The design counterpart of this bootstrap is Part 03 — The trip and the guardrails, whose "Roslyn source generators" section argued for codegen over reflection. This article is where that argument turns into experimentalDecorators: false. The further design counterpart is Part 04 — SOLID in the monorepo patterns, whose registry-pattern section is the source of the shape used here.

Article 02 picks up the extractor: the ts-morph walk that reads these same decorators from the AST, without evaluating them, and produces a LanguageIR identical (byte-for-byte) to the one the registry would hold if the module had been evaluated. That equivalence — runtime eval and static extraction producing the same object — is the deepest invariant of the whole system, and it is what article 02 has to earn.

Proposal (write-in-public). This article commits to six decorators and a fragment-based registry. Two open directions for later articles: first, whether @Rule should eventually split into @Rule (grammar rule) and @Production (semantic-tokens production), once the grammar emitter (article 04) and the semantic-tokens fallback (ibidem) disagree on what a "rule" means; second, whether the registry should be promise-returning (append* async) to allow lazy fragment materialisation for very large spec files. Both are deferred until a concrete need forces the hand; today's simpler surface buys clarity.

⬇ Download