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

03 — LanguageIR as contract

Article 01 wrote the decorators. Article 02 wrote the extractor. Both converged — from two sides — on the same object: a LanguageIR carrying the language header, tokens, rules, snippets, LSP-feature bindings, and executor declarations. Everything downstream in the series lives on that object. The seven emitters read it; the LSP server handlers read it; the golden snapshot tests compare to it; the .vsix comes out of it. If the IR is a loose collection of interfaces, every emitter that reads it inherits the looseness and multiplies it. If the IR is a contract — versioned, schema-validated, typed with branded primitives whose construction goes through a single funnel — then the emitters can be narrow strategies that trust the input they see.

That is the thesis of this article: LanguageIR is not a data structure, it is a contract. Four ingredients make it one. First, a single $schemaVersion string, dated, that increments only on breaking change. Second, branded primitives whose only entry point is a smart constructor that runs the same validation the schema runs, so the TypeScript shape and the JSON shape agree at every boundary. Third, a JSON Schema file generated from the TypeScript types by ts-json-schema-generator, committed to the repository, consumed by ajv at every read and write point. Fourth, golden snapshot tests pinning a handful of spec.ts fixtures to the exact IR JSON they extract to — the kind of test that catches a regression in the extractor, an off-by-one in the emitter, or a schema drift, all with the same red output.

The design-series companion is 05 — DRY and the IR as contract: that article laid out the argument for why the IR must be a contract. This article lays out how to write it.

REQ-IDEDSL-IR-VERSIONED-CONTRACT — the Requirement

REQ-IDEDSL-IR-VERSIONED-CONTRACTThe LanguageIR type shall be a versioned contract: (a) each IR instance carries a $schemaVersion field whose value is a date string YYYY-MM-DD incremented only when a field is removed, renamed, or its semantics change; (b) a JSON Schema file, generated from the TypeScript definition of LanguageIR and committed to packages/ide-dsl/schemas/ide-ir.schema.json, shall validate every IR instance read or written across the chain; (c) every branded primitive — LanguageId, TokenName, RuleName, SnippetTrigger, ExecutorId, LspFeatureId — shall be constructible only through a smart constructor that enforces the same validation rules the schema enforces; (d) the TypeScript type and the JSON Schema shall be proven equivalent by a round-trip test: the schema generated from the type, re-parsed, and compared to the committed schema, shall match byte-for-byte on every build.

Rationale: seven emitters in later articles read the IR; the LSP server handlers read it at runtime; the traceability reports in @frenchexdev/requirements read it when auditing coverage. If any of those reads disagrees about the shape — nullable vs required, string vs branded, number vs string — the discrepancy shows up as a runtime exception in a VSCode extension, which is the worst place to notice it. A dated schema version keeps the project honest about breaking changes (breaking changes are rare but must be visible in diffs); smart constructors keep the runtime honest about what a TokenName is allowed to look like; ajv validation keeps the file-system-backed boundary honest about what JSON it will accept. The equivalence round-trip prevents the classic drift where the generator stops producing the committed schema and nobody notices for months.

Fit criteria: the date format is matched by /^\d{4}-\d{2}-\d{2}$/; every branded primitive constructor returns Result<Branded, ParseError> and rejects at least three malformed inputs (empty, whitespace-only, schema-violating characters) in its unit suite; ajv.compile(ideIrSchema).validate(ir) === true holds for every fixture in packages/ide-dsl/test/fixtures/; npx ts-json-schema-generator -p ir.ts -t LanguageIR | diff schemas/ide-ir.schema.json - exits with zero on a fresh clone.

Verification: Test. Refines REQ-IDEDSL-DECORATORS-STANDARD and REQ-IDEDSL-EXTRACTOR-DETERMINISTIC.

The version field is a date string, not a semver. This is the same convention the @frenchexdev/requirements package uses — see packages/requirements/src/base.ts — and for the same reason: breaking changes to the IR shape are infrequent enough that day resolution is adequate, and a date reads unambiguously in review without forcing a judgment call between minor and patch. The first production shape is '2026-04-14'; if a later article in this series has to rename IRExecutor.command to something else, the version bumps to the date of that change and the emitters that have not yet migrated see a type error.

The smart-constructor constraint is where the article earns its keep. TypeScript's branded-type pattern is a compile-time trick — the __brand property never exists at runtime — which means nothing prevents a caller from writing '' as TokenName and handing it downstream. That cast is legal TypeScript. Every emitter would then have to re-validate the input to guard against it, which is the duplication the DRY lens warns against. Running every branded primitive through a smart constructor with a Result<T, ParseError> return type closes that door: the only way to get a TokenName is to call TokenName.of(raw), which returns Err for empty or malformed input and Ok(branded) otherwise. The cast '' as TokenName still compiles, but the fixture loader and the extractor never produce one, and a linter rule can flag the cast at source.

FEAT-IDEDSL-03 — the satisfying Feature

// packages/ide-dsl/requirements/features/language-ir-contract.ts
import { Feature, Priority, Satisfies, type ACResult } from '@frenchexdev/requirements';
import { ReqIdeDslIrVersionedContractRequirement } from '../requirements/req-idedsl-ir-versioned-contract.js';

@Satisfies(ReqIdeDslIrVersionedContractRequirement)
export abstract class LanguageIrContractFeature extends Feature {
  readonly id = 'FEAT-IDEDSL-03';
  readonly title = 'LanguageIR as versioned, schema-validated, branded-primitive contract';
  readonly priority = Priority.Critical;

  // ── Versioning ──
  abstract irCarriesASchemaVersionFieldMatchingYyyyMmDd(): ACResult;
  abstract schemaVersionIsATypeLiteralNotAnArbitraryString(): ACResult;

  // ── Branded primitives ──
  abstract brandedPrimitiveOfReturnsOkForValidInput(): ACResult;
  abstract brandedPrimitiveOfReturnsErrForEmptyInput(): ACResult;
  abstract brandedPrimitiveOfReturnsErrForSchemaViolatingInput(): ACResult;

  // ── JSON Schema ──
  abstract committedSchemaValidatesEveryFixture(): ACResult;
  abstract committedSchemaRejectsAMissingRequiredField(): ACResult;
  abstract committedSchemaRejectsAnUnknownField(): ACResult;

  // ── Equivalence round-trip ──
  abstract regeneratedSchemaEqualsCommittedSchema(): ACResult;

  // ── Golden snapshots ──
  abstract goldenFixtureProducesExpectedIrJson(): ACResult;
}

Ten ACs in four clusters. The versioning cluster nails the shape of $schemaVersion. The branded-primitive cluster nails the smart-constructor funnel. The JSON Schema cluster nails the runtime boundary. The equivalence cluster nails the no-drift property. The tenth AC — the golden snapshot — is the one that catches regressions end-to-end.

Article 12 (testing the full stack) will show these ACs written out as concrete @FeatureTest methods; this article shows the three or four whose shape is most load-bearing for the contract argument.

The full IR, laid out

Article 01 sketched the IR in seven or eight lines. Here it is in full — the shape every subsequent article assumes. The branded primitives come first because nothing else compiles without them.

// packages/ide-dsl/src/ir.ts

export const IDE_IR_SCHEMA_VERSION = '2026-04-14' as const;
export type IdeIrSchemaVersion = typeof IDE_IR_SCHEMA_VERSION;

// ─── Branded primitives ────────────────────────────────────────────────────
// Each is declared as an intersection with a brand, so a raw `string` cannot
// substitute for it at call sites that expect the branded form.

export type LanguageId      = string & { readonly __brand: 'LanguageId' };
export type TokenName       = string & { readonly __brand: 'TokenName' };
export type RuleName        = string & { readonly __brand: 'RuleName' };
export type SnippetTrigger  = string & { readonly __brand: 'SnippetTrigger' };
export type ExecutorId      = string & { readonly __brand: 'ExecutorId' };
export type LspFeatureId    = string & { readonly __brand: 'LspFeatureId' };

// ─── IR node shapes ────────────────────────────────────────────────────────

export interface IRToken {
  readonly name: TokenName;
  readonly pattern: string;              // anchored TextMate regex
  readonly scope: string;                // TextMate scope, e.g. "keyword.control.ide-dsl"
}

export interface IRRule {
  readonly name: RuleName;
  readonly produces: readonly TokenName[];
  readonly description?: string;         // captured from the class JSDoc
}

export interface IRSnippet {
  readonly trigger: SnippetTrigger;
  readonly body: readonly string[];      // one element per line, tab-indented
  readonly description?: string;
}

export interface IRLspFeature {
  readonly id: LspFeatureId;
  readonly kind:
    | 'diagnostics'
    | 'completion'
    | 'hover'
    | 'definition'
    | 'references'
    | 'rename';
}

export interface IRExecutor {
  readonly id: ExecutorId;
  readonly command: string;              // shell form, not yet argv-split
  readonly title: string;                // surfaces in the command palette
}

// ─── Top-level IR ──────────────────────────────────────────────────────────

export interface LanguageIR {
  readonly $schemaVersion: IdeIrSchemaVersion;
  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[];
}

Two micro-decisions carry weight. The top-level field is $schemaVersion, prefixed with a dollar sign — the convention JSON Schema itself uses for $schema, $ref, $id. That prefix marks metadata, not payload, and it keeps downstream consumers from treating the version as a semantic field. The IdeIrSchemaVersion type is typeof IDE_IR_SCHEMA_VERSION, not string: a spec file claiming $schemaVersion: '2024-07-01' fails compilation against this type, which is exactly the signal a stale fixture should send.

The IRLspFeature.kind field uses a string literal union with six members. That is not a placeholder — those are the six handler classes article 07 will describe. Adding a seventh kind (e.g. 'codeAction') is a breaking change in the narrow sense (old emitters have not written a case for it) and a non-breaking change in the IR-schema sense (the field is still a string literal). Whether to bump $schemaVersion for that addition is a judgment call, resolved in the versioning-policy section below.

Smart constructors — one funnel per primitive

The brands above are compile-time only. Without a runtime funnel, any string can be cast into any brand. The funnel is a small module per primitive, each exposing exactly one constructor:

// packages/ide-dsl/src/primitives.ts

import type { LanguageId, TokenName, RuleName, SnippetTrigger, ExecutorId, LspFeatureId } from './ir.js';
import { type Result, ok, err } from './result.js';

export interface ParseError {
  readonly file?: string;
  readonly range?: { start: number; end: number };
  readonly code: 'E_EMPTY' | 'E_PATTERN' | 'E_LENGTH';
  readonly message: string;
}

const IDENT = /^[a-z][a-z0-9-]{0,62}$/;   // lowercase, kebab-case, 1..63 chars

function parseIdentLike<B extends string>(
  raw: string,
  brand: string,
): Result<string & { readonly __brand: B }, ParseError> {
  if (raw.length === 0) {
    return err({ code: 'E_EMPTY', message: `${brand} must not be empty` });
  }
  if (raw.length > 63) {
    return err({ code: 'E_LENGTH', message: `${brand} exceeds 63 chars (got ${raw.length})` });
  }
  if (!IDENT.test(raw)) {
    return err({ code: 'E_PATTERN', message: `${brand} must match ${IDENT.source}, got '${raw}'` });
  }
  return ok(raw as string & { readonly __brand: B });
}

export const LanguageId = {
  of: (raw: string): Result<LanguageId, ParseError> => parseIdentLike<'LanguageId'>(raw, 'LanguageId'),
} as const;

export const TokenName = {
  of: (raw: string): Result<TokenName, ParseError> => parseIdentLike<'TokenName'>(raw, 'TokenName'),
} as const;

// RuleName, ExecutorId, LspFeatureId: same shape, different brand
// SnippetTrigger: different rule — alphanumeric + underscore, no hyphen, 1..32 chars

Five of the six primitives share the same identifier rule (lowercase kebab-case, 63 chars max). The sixth, SnippetTrigger, has a different rule because .code-snippets files restrict triggers to alphanumeric + underscore — hyphens collide with the prefix-matching heuristic VSCode's snippet matcher uses. Making that distinction explicit at the smart-constructor level means the emitter in article 06 never has to sanitise a trigger; the input is either a valid trigger or it never entered the system.

The return type is Result<Branded, ParseError>, imported from an in-package result.ts. Article 02 already introduced ParseError with {file, range, code}; the same shape carries through here, minus the file and range when the primitive is constructed outside an extraction context (fixture loader, test, CLI input). The code field is a union of three symbolic constants, which is enough for an emitter to render a helpful diagnostic without string-matching the message.

Why return Result rather than throw? The answer is the one the requirements package has been making for a year: throwing couples control flow to error presentation, while a Result decouples them. The extractor (article 02) collects errors and reports them with source ranges; a CLI caller wraps the Err case in a non-zero exit code; the LSP server (article 07) turns an Err into a Diagnostic with DiagnosticSeverity.Error. Each consumer shapes presentation without the funnel having to predict which presentation it serves. The design is explicitly borrowed from packages/requirements; pointing at an existing, in-production example is how the article keeps from re-arguing a decided question.

JSON Schema as the external boundary

The TypeScript shape is the internal contract. The JSON Schema is the external one. Every I/O point — reading a committed fixture, caching the extractor's output, sending IR across a worker boundary, shipping IR inside a generated .vsix — passes JSON, and the JSON must validate. Generating the schema by hand is a maintenance trap; re-typing the IR shape in two places is the DRY violation the article opened with. ts-json-schema-generator is the tool that closes the loop.

# packages/ide-dsl/scripts/generate-schema.sh
npx ts-json-schema-generator \
  --path src/ir.ts \
  --type LanguageIR \
  --tsconfig tsconfig.json \
  --expose all \
  --additional-properties false \
  > schemas/ide-ir.schema.json

--additional-properties false is the lever that turns permissive schemas into strict ones — without it, a stray field in an IR JSON file would validate silently, which is the exact drift the contract forbids. --expose all names each nested type separately (#/definitions/IRToken, #/definitions/IRRule, …), which makes the schema diffable field-by-field when a change lands.

The schema is committed. That is not an optimisation — it is the whole point. Committing the output means every pull-request diff carries both the type change and its schema counterpart side-by-side, which is exactly the pair a reviewer needs to judge whether a change is breaking. A generator that ran in CI and discarded its output would lose the diff; a generator whose output is checked in raises a pull-request comment every time the types drift.

The equivalence round-trip is the test that keeps generator and committed file honest:

// packages/ide-dsl/test/unit/schema-equivalence.feature.ts
import { Feature, FeatureTest, Verifies, type ACResult } from '@frenchexdev/requirements';
import { expect } from 'vitest';
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { LanguageIrContractFeature } from '../../requirements/features/language-ir-contract.js';

@FeatureTest(LanguageIrContractFeature)
export class SchemaEquivalenceTest {
  @Verifies('regeneratedSchemaEqualsCommittedSchema')
  regeneratedSchemaEqualsCommitted(): ACResult {
    const committed = JSON.parse(readFileSync('schemas/ide-ir.schema.json', 'utf-8'));
    const regenerated = JSON.parse(
      execSync(
        'npx ts-json-schema-generator --path src/ir.ts --type LanguageIR ' +
          '--tsconfig tsconfig.json --expose all --additional-properties false',
        { encoding: 'utf-8' },
      ),
    );
    expect(regenerated).toEqual(committed);
    return { passed: true };
  }
}

One test, one invariant, one failure mode: if the committed schema ever lags the types, this test fires. Running it in the unit suite (not just on CI, which the project has deliberately removed — see feedback_no_cloud_cicd) means a local npm test catches the drift before the commit.

Ajv validation at every boundary

ajv is the runtime enforcer. The pattern is standard: compile the schema once at module load, export a validator that narrows unknown to LanguageIR:

// packages/ide-dsl/src/validate.ts
import Ajv, { type ValidateFunction } from 'ajv';
import schema from '../schemas/ide-ir.schema.json' with { type: 'json' };
import type { LanguageIR } from './ir.js';
import type { Result } from './result.js';
import { ok, err } from './result.js';

const ajv = new Ajv({ allErrors: true, strict: true });
const validate = ajv.compile<LanguageIR>(schema) as ValidateFunction<LanguageIR>;

export function parseLanguageIR(raw: unknown): Result<LanguageIR, readonly string[]> {
  if (validate(raw)) {
    return ok(raw);
  }
  const messages = (validate.errors ?? []).map(
    (e) => `${e.instancePath || '<root>'}: ${e.message ?? 'invalid'}`,
  );
  return err(messages);
}

Two calls cover every consumer in the series: the fixture loader (article 12) calls parseLanguageIR on every JSON file it reads; each of the seven emitters (articles 11–15) accepts a LanguageIR typed argument and trusts it, because the validator has already run at the fixture boundary. The trust is what justifies the emitters' narrowness: without ajv, every emitter would have to defensively re-check the input; with ajv, they read fields directly.

Diagram
Figure 1 — Validation flow. The TypeScript definition of `LanguageIR` is the single source of truth; `ts-json-schema-generator` projects it to a JSON Schema, `ajv` compiles the schema into a validator, and every JSON boundary — fixture, cache, worker payload, vsix resource — passes through the validator before any emitter or LSP handler reads it.

Versioning policy — what a date bump means

The version field is '2026-04-14', a string literal type. Bumping it is a deliberate act, and the article is explicit about when:

  • Bump on any field removal or rename.
  • Bump on any semantic change to an existing field (e.g. IRToken.pattern going from TextMate-regex to OnigRegexp-specific).
  • Bump on any tightening of a union (removing an IRLspFeature.kind variant).
  • Do not bump on adding a new optional field (a permissive additive change).
  • Do not bump on adding a new variant to a union (additive; consumers with default: or exhaustive checks catch it at compile time).
  • Do not bump on tightening a runtime validation that was already implied by the schema.

The rule "bump on remove/rename/semantic-change" maps to the classical notion of a breaking change. The rule "don't bump on additive change" is less classical but more useful here: VSCode extensions ship in batches, and forcing every consumer of the IR to migrate every additive change would turn $schemaVersion into a version number churned for marketing rather than for safety. Article 15 (what stayed open) revisits this when the series considers a JetBrains or Monaco target, where additive changes may look different from the other side of the contract.

Proposal (write-in-public). The SemVer-vs-CalVer debate for schema versions is not neutral territory. Kubernetes API versions (v1, v1beta1, v2alpha1), Ethereum's hard-fork names, and the Rust edition system (2015, 2018, 2021, 2024) are all examples of calendar-like versioning chosen because SemVer's implicit compatibility promise is false for shapes that mix additive and breaking changes. The @frenchexdev/requirements package chose the dated form for the same reason; the Ide.Dsl IR inherits it for consistency. The question left open is whether visualising version history in the TOC (e.g. an auto-generated changelog page) is worth its weight — that is deferred to a later article if it surfaces as a concrete need.

Diagram
Figure 2 — Versioning decision. Only removals, renames, semantic changes, and union tightenings bump `$schemaVersion`. Additive changes do not — they are caught by exhaustiveness at the type level and by additional-properties=false at the schema level without rewriting history.

Golden snapshot tests — pinning spec.ts to IR JSON

The last cluster of ACs is the one that catches everything. A golden snapshot test stores the expected IR JSON for a known spec.ts fixture; any change in extractor logic, in branded-primitive constructors, in schema shape, or in canonicalisation rules surfaces as a diff against the stored snapshot. The test is trivial to write; its usefulness is that it turns every change into a reviewable patch.

// packages/ide-dsl/test/unit/golden-snapshots.feature.ts
import { FeatureTest, Verifies, type ACResult } from '@frenchexdev/requirements';
import { expect } from 'vitest';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { extractLanguageIR } from '../../src/extractor.js';
import { LanguageIrContractFeature } from '../../requirements/features/language-ir-contract.js';

@FeatureTest(LanguageIrContractFeature)
export class GoldenSnapshotsTest {
  @Verifies('goldenFixtureProducesExpectedIrJson')
  everyFixtureMatchesItsExpectedIr(): ACResult {
    const dir = 'test/fixtures';
    const cases = readdirSync(dir)
      .filter((f) => f.endsWith('.spec.ts'))
      .map((specFile) => ({
        spec: join(dir, specFile),
        expected: join(dir, specFile.replace(/\.spec\.ts$/, '.ir.json')),
      }));

    for (const { spec, expected } of cases) {
      const extracted = extractLanguageIR({ readText: (p) => readFileSync(p, 'utf-8'), /* … */ } as any, spec);
      const expectedIr = JSON.parse(readFileSync(expected, 'utf-8'));
      expect(extracted).toEqual(expectedIr);
    }
    return { passed: true };
  }
}

The fixtures live under test/fixtures/*.spec.ts, paired with *.ir.json files carrying the expected extraction. The test iterates every pair and deep-equals. A fixture covering the full decorator set — @Language, one @Token, one @Rule, one @Snippet, one @LspFeature, one @Executor — is the minimum coverage that gives the snapshot real teeth; adding a second fixture that exercises an edge case (e.g. a JSDoc-captured rule description, a multi-line snippet body) raises that floor further.

The fixture .ir.json files are not regenerated automatically. The update path is explicit: a developer sees the test fail, inspects the diff, decides whether the new IR shape is intended, and commits the updated JSON alongside the code change. Automating the regeneration would remove the review step, which is the whole point — a golden snapshot's value is precisely the friction it introduces when the shape shifts. Vitest's toMatchInlineSnapshot is deliberately not used here, because the JSON is large enough that inline snapshots would drown the test file; file-backed snapshots keep each fixture readable in its own right.

SOLID lens

Open/Closed. The IR is the open/closed axis of the entire series. Emitters (articles 11–15) and LSP handlers (articles 16–18) are closed for modification — the base emitter interface and the handler base class never change — and open for extension: adding a new emitter means writing a new Emitter implementation and registering it, without touching any existing emitter or the IR itself. The classical shape of OCP is "extend by subclassing, don't modify existing code", and the seven emitters are seven subclasses of the same abstraction, each reading the same IR.

Liskov substitution. Every emitter consumes LanguageIR and produces Promise<void> (writing to an injected file system). Any emitter is substitutable for any other at the registry boundary — the EmitterRegistry of article 04 has no case analysis on emitter kind. Substitutability is what makes the registry a pure collection: adding an emitter is a register(emitter) call, not a switch update.

Dependency inversion. The IR is the abstraction that decouples the extractor from the emitters. The extractor depends on the IR shape; the emitters depend on the IR shape; neither depends on the other. This is the same decoupling article 02 made between the SourceReader port and the ts-morph implementation — a recurring pattern, because it is the one that pays.

Interface segregation. Each emitter reads only the fields of the IR it needs. The grammar emitter reads tokens and rules; the manifest emitter reads id, displayName, fileExtensions, plus a few others. No emitter is forced to depend on fields it does not use, because TypeScript's structural typing lets each one narrow its local parameter to just the slice it reads. Article 04 will make that narrowing explicit (a Pick<LanguageIR, 'tokens' | 'rules'> at the grammar-emitter boundary, for example) so that the test surface of each emitter is the minimum shape it cares about.

DRY lens

The IR is the single source of truth for seven emitters. Without it, each emitter would have its own bespoke data model — the grammar emitter would read a regex-and-scope map, the manifest emitter would read a key-value bag, the snippet emitter would read its own snippet shape. Every decorator would need to populate seven of those bespoke models, and every change to a decorator would require seven updates. The IR is the pivot that collapses those seven models to one.

The JSON Schema is the second DRY instance: the same types, projected to JSON once, validate every I/O point. Without it, each consumer would have its own hand-coded validator, and the hand-coded validators would drift. Generation removes the drift-surface.

The smart constructors are the third DRY instance: every branded primitive runs through one validator, so there is one place in the code that knows what a TokenName is allowed to look like. An emitter reading IRToken.name does not re-validate; a fixture loader reading IRToken.name out of JSON does not re-validate (ajv did it); a runtime LSP handler reading IRToken.name from the validated IR does not re-validate. One funnel, three consumers. Without it, each consumer would have its own sanitiser, and the sanitisers would disagree.

The golden snapshots are the fourth DRY instance, subtler than the others: the snapshot files stand in for a property test of the whole chain. Without them, there would be thirty or forty smaller tests — one per decorator-kind per emitter per field — and every time a field moves, each of those tests would need a manual update. The snapshot collapses the thirty tests into one: when the shape changes, the snapshot JSON changes, and the change is reviewable as a patch. The snapshot is the test infrastructure's DRY.

  • Design counterpart: 05 — DRY and the IR as contract — the design-series article that laid out why the IR must be a contract.
  • Previous build article: 02 — The extractor — the static path that produces the IR.
  • Next build article: 11 — EmitterRegistry + grammar emitter — the first consumer of the contract this article just wrote.

That is the IR as contract. Every emitter in Part II reads it; every handler in Part III reads it; every test in Part IV pins it. The rest of the series does not argue with its shape.

⬇ Download