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 19 — Generator micro-DSL: Concept → output via ts-codegen-pipeline

The Generator micro-DSL closes the loop from Concept declarations to target artefacts. A Requirements DSL Concept generates a typed validator class, a builder, a runtime registry, a compliance report row. A Workflow DSL Concept generates a state machine, transition guards, a Mermaid diagram of the workflow. The Generator micro-DSL is, intentionally, the thinnest in the suite: it does not own a runtime, it does not own a fixpoint algorithm, it does not own an idempotence pattern. All three exist already in @frenchexdev/ts-codegen-pipeline — production-stable, well-tested, in daily use across the monorepo. The Generator micro-DSL's contribution is the binding: it scans @Generator declarations on Concepts and registers each as a SourceGenerator with the existing pipeline.

The article spends a section on a deliberate non-port. MPS' generator language uses $property$ and $$node$$ macros to substitute values in template strings — a domain-specific syntax MPS authors learn. The convention exists because MPS predates first-class templating in its target languages. TypeScript has tagged template literals and ts-morph; both are strictly more expressive than the macro convention, both are already in production use across this monorepo. Importing MPS' macro syntax for nostalgia would re-introduce a parser MPS had to write because Java did not provide one.

Concern

The author has declared a Feature Concept. From it, three artefacts should be generated automatically: FeatureValidator.ts (a TypeScript class that validates a Feature instance against its declared constraints), features.index.ts (a barrel exporting every Feature in the workspace), compliance.json (one row per Feature, used by the existing compliance reporter). On every save of a .req.ts file, the artefacts should regenerate; idempotent regeneration should not produce diffs in unchanged files; the user should also be able to invoke ide-dsl.regenerate on demand from the command palette.

Hand-writing this requires implementing a watcher, walking the AST, formatting TypeScript output, computing hashes, comparing against existing files, writing only when changed. The pipeline already does all of this. The Generator micro-DSL's job is to declare the binding from Feature to "emit FeatureValidator.ts using this template".

The Surface

import { Generator } from '@frenchexdev/ide-dsl-generator';
import { Concept, Property, ChildLink } from '@frenchexdev/ide-dsl-kernel';
import { Project } from 'ts-morph';

@Concept({ id: 'cmf.req.Feature' })
export class Feature {
  @Property({ type: 'string', constraint: /^FEATURE-\d+$/ })
  id!: string;

  @Property({ type: 'string' })
  title!: string;

  @ChildLink({ target: 'cmf.req.AcceptanceCriterion', card: '1..*' })
  acceptance!: AcceptanceCriterion[];

  @Generator({
    id: 'cmf.req.feature-validator',
    outputPath: (self) => `generated/${self.id}.validator.ts`,
  })
  static emitValidator(self: Feature, project: Project) {
    const file = project.createSourceFile(`${self.id}.validator.ts`, `
export class ${self.id}Validator {
  validate(feature: { id: string; title: string; acceptance: unknown[] }) {
    if (!/^FEATURE-\\d+$/.test(feature.id)) throw new Error('Invalid id');
    if (feature.acceptance.length < 1) throw new Error('Need at least one AC');
    return true;
  }
}
    `.trim());
    return file;
  }

  @Generator({
    id: 'cmf.req.feature-compliance-row',
    outputPath: 'generated/compliance.json',
    aggregate: true,  // multiple Features contribute to one file
  })
  static emitComplianceRow(self: Feature) {
    return {
      id: self.id,
      title: self.title,
      acceptance: self.acceptance.length,
    };
  }
}

@Generator declares: a stable id (the generator's name in the pipeline), an outputPath (static or computed per-instance), an optional aggregate flag (multiple invocations write to the same file, which the pipeline merges). The decorated method receives the Concept instance and, optionally, a Project from ts-morph for code emission. The return value is either a SourceFile (for TypeScript output) or an arbitrary serialisable value (for JSON / aggregated outputs); the pipeline handles emission, banner-stamping, and idempotence.

The micro-DSL is therefore the smallest of the eight LSP-shaped or host-shaped micro-DSLs. Its job is the binding; the work is the pipeline's.

Kernel boundary

Reads:

  • The Structure Model — for @Generator discovery.
  • The current AST — for the per-Concept invocation.

Writes:

  • Through ts-codegen-pipeline's virtFS, which is the equivalent of PatchBus for output files: an in-memory virtual file system that accumulates writes, runs through a fixpoint to converge, then commits to disk atomically with hash-based no-op detection.

The kernel and the pipeline meet at the Banner: both use the same hash format, both recognise each other's bannered files, drift detection works across both directions (a hand-edited generated file is flagged whether the editor opened it through the kernel or through the pipeline's CLI).

Emitted artefacts

// pipeline registration (generated, in sourcegen.config.ts)
import { defineConfig } from '@frenchexdev/ts-codegen-pipeline';
import { generatorRegistry } from './ide-dsl/_registry.generated';

export default defineConfig({
  tsconfigPath: './tsconfig.json',
  consumerPackageRoot: import.meta.dirname,
  generators: generatorRegistry.asPipelineGenerators(),
});
// VSCode command implementation (generated, in extension.ts via the Commands micro-DSL)
import * as vscode from 'vscode';
import { runFixpoint } from '@frenchexdev/ts-codegen-pipeline';

vscode.commands.registerCommand('ide-dsl.regenerate', async () => {
  const config = await import(`${vscode.workspace.workspaceFolders[0].uri.fsPath}/sourcegen.config.ts`);
  const result = await runFixpoint(config.default);
  // ... surface result via Output channel ...
});

The pipeline's existing CLI (npx sourcegen run) and the new VSCode command both invoke the same runFixpoint entry point with the same configuration. Output is consistent regardless of trigger.

Composition with peers

  • Commands (article 13) — ide-dsl.regenerate is a Command exposed through the Commands micro-DSL.
  • Diagnostics (article 11) — surfaces drift warnings on hand-edited generated files.
  • Refactoring (article 17) — refactorings can trigger regeneration as a follow-up step.
  • Formatter (article 15) — for DSL-shaped output (rare; most output is TypeScript via ts-morph), the Generator can invoke the Formatter to produce canonical text before writing.
  • ts-codegen-pipeline — the runtime. The Generator micro-DSL is the only kernel-aware wrapper around it.

MPS aspect referent

MPS Generator aspect. MPS' generator runs as a series of root mapping + reduction + weaving rules over the AST, with property macros ($prop$) and node macros ($$loop$$) substituting values in template strings. We adopt the per-Concept declarative binding (@Generator parallels MPS' root mapping rule) and the multi-stage convergence (the pipeline's fixpoint runner mirrors MPS' iterative generation), and reject the macro syntax for the reasons named in the article opener — TypeScript's tagged templates and ts-morph are strictly more expressive.

Boundary justification

Why not in the kernel? The Generator micro-DSL imports ts-codegen-pipeline (a peer dependency the kernel could in principle adopt) but more importantly it produces VSCode-host integration (the ide-dsl.regenerate command). The host integration is what disqualifies the kernel hosting under criterion 3. Beyond that, generation is a consumer-facing concern: each DSL declares what to emit; the kernel does not need to know.

Why not in Formatter? Formatter produces canonical text from an AST in the DSL's own language. Generator produces target artefacts in arbitrary languages — TypeScript validators, JSON reports, Mermaid diagrams. The output language is the distinction. A generator that emits the source DSL itself (rare, but possible) might invoke the Formatter; that is the only overlap, and the overlap is by composition, not coupling.

Requirements

FEAT-MICRODSL-19 in assets/features.ts:

  • generatorReusesTsCodegenPipelineFixpoint — the Concern and Emitted artefacts sections name the reuse, the registration through defineConfig, the shared runFixpoint entry.
  • mpsMacrosDollarReplacedByTsMorphRationaleStated — the article opener spells out the non-port; the MPS aspect referent section repeats the rationale.
  • bannerIdempotenceInheritedFromKernelExplained — the Kernel boundary section names the shared Banner format and the cross-direction drift detection.
  • boundaryAgainstFormatterJustified — the Boundary justification section names the output-language argument.

Part III closes here. Part IV (articles 20–22) describes the hosts that compose the micro-DSLs and walks the Requirements IDE end to end.

⬇ Download