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

Part 07 — The prototype and what stays open

The series set out to design a meta-DSL without implementing it. Six articles have laid the foundations — what an IDE offers, what a DSL needs, what the prior art teaches, how SOLID and DRY pin the architecture, how port-driven tests verify it. The seventh article is about the object that holds all of that together: requirements-ide.spec.ts, a type-checked TypeScript file that type-checks standalone and does nothing at runtime. This is the artefact the design is built around. If the series succeeds, that file is small enough to read in one sitting, and the six preceding articles explain every decision visible in it.

This final article walks the artefact decorator by decorator, ties each decoration to the principles the earlier articles argued for, names the mise-en-abyme loop that closes the design on itself, lists the questions honest enough to stay open, and points at the future series where the design becomes code.

The artefact, read top to bottom

The file is 210 lines. It imports nothing — inline type stubs at the top stand in for the @frenchexdev/ide-dsl package that does not yet exist. This matters: a design artefact that depends on a non-existent package cannot type-check, and a design that cannot type-check is not a design, it is a sketch. The stubs let tsc --noEmit verify the shape of the artefact against itself.

The @Language decoration

@Language({
  id: 'requirements',
  extensions: ['.req.ts'],
  scopeName: 'source.requirements',
  aliases: ['Requirements', 'req'],
  features: ['FEAT-ARCHPAT-07', 'BASE-TYPES'],
})
export class RequirementsIde {

Five fields. Each one does exactly one thing, which is the SRP claim of Part 04 in its smallest form. The id is what VSCode calls "language identifier" — the key everything else in the manifest joins on. The extensions array declares which files VSCode should associate with this language; the .req.ts choice is deliberate: these files are still TypeScript source, so TypeScript tooling still sees them, and the meta-DSL's extension layers domain-specific services on top rather than replacing the host-language stack. The scopeName is the TextMate root scope (Part 01's TextMate-vs-semantic-tokens section), the tree that the grammar emitter will hang its rules under. The aliases array shows up in command-palette "Change Language Mode" entries. The features array is the dog-food move: it names the Feature IDs this DSL claims to implement, from the requirements DSL's own registry. FEAT-ARCHPAT-07 is this very article; BASE-TYPES is a reference into packages/requirements/requirements/features/base.ts. The meta-DSL is dog-fooding the requirements DSL by declaring, in its own spec, the Features it satisfies.

The @Token decorations

Five of them, lines 123–136 of the artefact. The sequence:

@Token('decoratorKeyword', /@(Feature|Satisfies|Refines|FeatureTest|Verifies|Expects|Exclude)\b/)
@Token('priorityLiteral', /\bPriority\.(Low|Medium|High|Critical)\b/)
@Token('testLevelLiteral', /\bTestLevel\.(Unit|Functional|E2E|A11y|I18n|Visual|Perf)\b/)
@Token('featureId', /\b[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+\b/)
@Token('earsKeyword', /\b(WHEN|IF|WHILE|WHERE|THEN|SHALL)\b/)

Each @Token is an input to the grammar emitter (Part 05's seven-emitters table). The regex is the pattern; the name becomes both the IR record's name field and, by default-derivation, the TextMate scope the pattern is tagged with. Five tokens, because the requirements DSL's notable surface reduces to exactly five token classes: the decorators the DSL uses, the two enum literals that take specific branded values (Priority and TestLevel), the FEAT-ID convention the monorepo enforces, and the EARS-style requirement keywords that the Default style's EarsStatement discriminated union uses (packages/requirements/CLAUDE.md). The grammar emitter's job, given these five records, is to write a TextMate JSON file that paints the user's .req.ts file with exactly those five distinctions — and to do so on top of the TypeScript language service's own tokenisation, which handles everything outside the five.

The thin cognitive load on the DSL author — five regexes, on properties whose names describe their intent — is the "business visible, plumbing hidden" posture of Part 03 in practice. No tmLanguage JSON was written by the author. No scope-name convention was memorised. Five decorator calls, and the grammar falls out.

The @Rule decorations

Three of them (lines 142–150), shorter in impact than the tokens but load-bearing for the outline provider and the completion-context logic. Each rule names a higher-level production — featureClass, acMethod, satisfiesLink — in a right-hand-side notation deliberately sketched rather than formalised. This is a proposal point the design-in-public label applies to most pointedly: the rule grammar notation is not fully specified in this series. Article 03's prior-art engagement with Langium names the alternative — a full grammar DSL sitting on top of the tokens — and the series has deliberately stopped short of that alternative. What the three @Rule declarations do commit to is that the DSL has a notion of structural production, that the extractor will lift those productions into ir.rules, and that the grammar emitter will use them to scope nested patterns. The shape of what "use" means in detail waits for ide-forge v0.

The @Snippet decorations

Three of them (lines 155–191): feat, ac, ftest. Each carries a prefix, a body, and a description. Read the feat snippet carefully:

@Snippet(
  'feat',
  [
    '@Satisfies(${1:RequirementClass})',
    'export abstract class ${2:NameFeature} extends Feature {',
    "  readonly id = '${3:FEAT-ID}';",
    "  readonly title = '${4:Human-readable title}';",
    '  readonly priority = Priority.${5|Low,Medium,High,Critical|};',
    '',
    '  abstract ${6:acName}(): ACResult;',
    '}',
  ].join('\n'),
  'New Feature skeleton satisfying one Requirement',
)
featureSnippet!: Snippet;

Every placeholder (${1:…}) is a tab-stop the editor will walk the user through. The ${5|Low,Medium,High,Critical|} syntax is a choice-list — VSCode's snippet grammar's way of offering a dropdown. The snippet is not a template the meta-DSL invents; it is the canonical form of a requirements-DSL Feature, with the literals the DSL's users have to remember baked in. This is the five-service baseline's snippets row (Part 02) filled by the author in roughly fifteen lines of domain knowledge per snippet.

The three snippets are not arbitrary: they are exactly the three canonical forms REQ-DOG-FOOD demands — a Feature (feat), an acceptance criterion addition (ac), and a feature-test scaffold (ftest) — which together cover the new-Feature workflow end to end. A user who learns to type feat<tab>, then ac<tab> for each AC, then ftest<tab> to scaffold the test class, has internalised the DSL's entire authoring loop. The three snippets are the DSL's onboarding.

The @LspFeature methods

Four of them (lines 197–227): diagnostics, hover, completion, definition. Each is a method body the author fills in with real logic; the meta-DSL's LSP emitter generates the plumbing that wires the method into the vscode-languageserver connection. The DIP claim of Part 04 is visible here: the method bodies depend on the TextDocument/Position/Hover/Diagnostic/CompletionItem/DefinitionLink protocol types, which are inline stubs in the artefact and would, in the real @frenchexdev/ide-dsl package, be re-exports of vscode-languageserver-types. The author never imports vscode-languageserver-protocol; they consume types the meta-DSL hands them.

Read the diagnostics stub:

@LspFeature('diagnostics')
async diagnostics(doc: TextDocument): Promise<Diagnostic[]> {
  const _text = doc.getText();
  return [];
}

Two lines of body; a comment above it (lines 199–201 of the artefact) sketches the real logic:

// Proposal (design-in-public): walk class decls, flag any Feature subclass without @Satisfies or with an id that does not match /[A-Z]+(-[A-Z0-9]+)+/.

That comment is the whole design-in-public contract — the body is a stub, the intent is stated explicitly, the reader knows which parts are proved (the wiring — the method is decorated, returns the right type, would be invoked by the generated server) and which parts are deferred (the analysis logic — what exactly constitutes an orphan Feature in the requirements DSL's semantics). The honest labelling is the posture every later article inherited from Part 03's Guardrail 5.

The @Executor method

One method (lines 233–243): runCompliance. It wraps the real CLI command the monorepo already ships:

@Executor({
  command: 'requirements.compliance',
  label: 'Requirements: run compliance --strict',
  taskKind: 'test',
})
async runCompliance(_file: string): Promise<ExecutionResult> {
  return { exitCode: 0, stdout: '', stderr: '' };
}

The command string is the command-palette id — what appears when the user hits Ctrl+Shift+P. The label is the human text. The taskKind: 'test' choice wires this executor into VSCode's test-task category, which the editor surfaces differently from build or run. The body is a stub; the intent, per the artefact's comment, is to spawn npx requirements compliance --strict at the workspace root, stream output to the task terminal, and promote non-zero exits into workspace-wide diagnostics that the monorepo's pre-commit hook already depends on.

One executor, because the requirements DSL has exactly one canonical invocation — everything else (trace, scaffold, feature new) is an authoring command, not a validation command, and authoring commands are not what an IDE extension should surface via a task-runner. A real project could add more @Executor methods; the baseline, for the DSL as it stands today, is one.

The mise-en-abyme, named

The meta-DSL is a DSL for building IDEs for DSLs. The first DSL the meta-DSL would generate an IDE for is @frenchexdev/requirements. The series is written using the requirements DSL — every article's acceptance criteria are declared in assets/features.ts as FEAT-ARCHPAT-01 through FEAT-ARCHPAT-07, one Feature subclass per article. The IDE the meta-DSL would generate, if you ran ide-forge build requirements-ide.spec.ts, would edit the very files this series is described in.

Diagram
Figure 1 — The mise-en-abyme loop. The requirements DSL describes the series' acceptance criteria; the series describes the meta-DSL; the meta-DSL generates the IDE; the IDE edits the requirements DSL files. Every arrow is dog-fooded.

The loop is four arrows long, and every arrow is a case the series has already made. The first arrow is the dog-food posture of the requirements DSL itself (Part 03's fourth guardrail). The second arrow is the series you are reading. The third arrow is the meta-DSL the series designs. The fourth arrow is the value proposition: the requirements DSL, used by more people more fluently, because the IDE around it stops being plain TypeScript with .req.ts files and starts being a first-class editing surface.

The loop is a sanity check, not a proof of concept. It answers the question would a team use this? with the honest version: the team that wrote this would be its first user, and the series itself is where the first user's experience is being rehearsed. If a meta-DSL author cannot write their own series in the meta-DSL's target DSL, the meta-DSL's target DSL is not mature enough for anyone else to use. The mise-en-abyme is the passing of that test.

Decorator ↔ principle ↔ service — the cross-reference table

A final summary table, one row per decorator, pulling together the threads.

Decorator SOLID anchor (Part 04) IDE service (Part 01) Generated output (Part 05)
@Language SRP — one-axis language metadata contributes.languages package.json, language-configuration.json
@Token SRP — lexical surface only Syntax highlighting syntaxes/<id>.tmLanguage.json
@Rule SRP — structural productions Folding, document symbols (partial) Grammar nested patterns
@Snippet OCP — register per prefix Snippets snippets/<id>.json
@LspFeature('diagnostics') DIP — method body depends on IR-level types Diagnostics src/server.ts handler
@LspFeature('hover') DIP Hover src/server.ts handler
@LspFeature('completion') DIP Completion src/server.ts handler
@LspFeature('definition') DIP Go-to-definition src/server.ts handler
@Executor ISP — minimal executor surface Task running / executor src/taskProvider.ts, manifest commands + taskDefinitions

Every cell in this table has a paragraph behind it in one of the earlier articles. The table is the map; the articles are the terrain.

Open questions, listed frankly

Design-in-public's honest close is a list of the questions the series does not answer. These are the places a future implementation series will have to make calls, and where the present series deferred rather than guessed.

The extension's own TypeScript dependency on vscode.*. The generated src/extension.ts imports vscode. The generator itself must not — the forge runs under Node.js without a vscode binding. How to type the generated extension code against vscode without pulling vscode into the forge's dependency graph is a real packaging question. The likely answer is @types/vscode as a peer of the generated project only; the forge emits code that references types it cannot see at forge-build time. That works, but it means the forge's tests for the extension emitter cannot type-check the content of the generated TypeScript — only its structure. An acceptable trade, not a free one.

Runtime reflection vs. pure codegen. An alternative architecture would use reflect-metadata (the TC39 stage-2 proposal's runtime side) to read decorator metadata at extension startup and wire the LSP server dynamically — no emitted src/server.ts, just a runtime that loads the user's spec.ts module. The trade-off is debuggability: runtime reflection puts every bug behind a layer of metaprogramming; pure codegen produces source files the user can console.log their way through. The series' leaning, stated here for the first time, is pure codegen, for debuggability. Ide-forge v0 should pick a side explicitly.

The LanguageIR shape is not frozen. Part 05 gave the IR a proposed shape. That shape will not survive contact with the seven emitters' real implementations unmodified. The versioning policy is exactly what exists for that reason; the honest statement is that the schema date will bump before v0 ships.

Catala grammar sourcing. If the meta-DSL targets Catala in a future iteration, two options exist: reuse the upstream Catala TextMate grammar with attribution (sane, bounds the scope), or derive the grammar from a Catala spec written in the meta-DSL's decorator vocabulary (clean, enormous scope increase because Catala's grammar is much larger than the requirements DSL's). The series does not decide; the requirements DSL is the first target, and the question opens when the second target is chosen.

Multi-target emitters: VSCode now, Monaco or JetBrains later. The IR was designed as if VSCode were the only target; in practice the manifest emitter and the extension-host emitter are the only ones whose output is VSCode-specific. Grammar, snippets, and the LSP server are all portable in principle. Whether the IR as defined is sufficient for a Monaco build, or whether it needs additional fields to describe browser-specific concerns, is the kind of question only a real Monaco emitter can answer.

Semantic tokens as opt-in. Part 01 named semantic tokens as the second layer of highlighting, deferred unless a spec explicitly asks. Exactly how a spec asks — whether it is a @LspFeature('semanticTokens') method, a flag on @Language({semanticTokens: true}), or a separate @SemanticToken('type', scope) decorator — is not decided. The artefact does not exercise semantic tokens, so the series does not decide.

What publishing the design before the code taught me

Two things, stated briefly.

First, the discipline of writing twenty thousand words before writing any code reveals the places where a design felt strong in the head and evaporates on paper. The LanguageIR shape went through four revisions over the course of drafting Part 05, because each emitter in the table in that article demanded something the previous shape did not provide. Had the code been written first, those revisions would have been rewrites — thrown-away work, discovered mid-implementation. Written as prose, they were paragraph-level edits. Prose is the cheaper medium for finding disagreement with yourself.

Second, the dog-food loop — the @Feature classes in assets/features.ts — forced a discipline on the articles that outlines do not. An acceptance criterion written in TypeScript as an abstract method signature is a promise in a way an outline bullet is not; when Part 03's mpsEngagedSubstantively() AC demanded three paragraphs of MPS engagement, it got three paragraphs, because the prose had a method signature to satisfy rather than an outline item to cross off. The requirements DSL works on its own authors.

The bridge to implementation

A second series, provisionally named From design to first emitter: shipping ide-forge v0, is the natural successor. Its targets:

  • Write the @frenchexdev/ide-dsl package with the decorator types this artefact stubs inline.
  • Write the @frenchexdev/ide-forge CLI with the extractor (ts-morph-backed), the seven emitters, and the registry of Part 04.
  • Pass the test suite Part 06 specified, at the ≥ 95 % per-file gate.
  • Package the generated requirements-ide extension and install it locally against the monorepo itself.
  • Run the requirements DSL's own compliance check inside the generated IDE, as the first end-to-end dog-food.

That series, when it comes, will be judged against the promises made in this one. It will find places this one was wrong — the LanguageIR date will have bumped, at least one emitter will have split into two or merged with another, the open questions above will have been answered in ways that close off some current options. The value of a design-in-public series is precisely that the later record of "we said X, we did Y, here is why" is possible. Implementation without prior design is opaque; design without later implementation is aspirational; both together, published honestly, is the record a team can learn from.

The file that holds the whole design is requirements-ide.spec.ts. The file that holds the whole series' own acceptance criteria is assets/features.ts. Two files, two hundred and ten lines between them, twenty thousand words of justification around them. That is the shape this series ships. The next one will ship code.

Build counterpart

The future implementation series that this article points to is Ide.Dsl — Build. It is the written counterpart to the code: fifteen articles that walk, in prose, the imagined production of @frenchexdev/ide-dsl, @frenchexdev/ide-forge, @frenchexdev/ide-runtime, and the requirements-ide.vsix. The bridge begins at Build 01 — Bootstrapping @frenchexdev/ide-dsl; each build article carries a cross-link back to the design article it satisfies.

⬇ Download