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 02 — The industrial weaving: a tour of the example

The previous article named the gap and located the library against ISourceGenerator. This article does the symmetric work for the running example. Every claim the rest of this series will make — about virtFS, fixpoint, backward edges, additive emission, banners, atomic commit, type-level emission, requirement-driven testing — is grounded against packages/sourcegen-example. A reader who finishes Article 02 holds the entire mental map of the curriculum: nine chapters of mechanics and example-walk-through, each anchored on one stage or one decorator family already shipping in the example.

The example is deliberately not a hello-world. It is an industrial weaving of three decorator families across ten ordered stages, on a small but realistic e-commerce vertical slice — one @Module, three @Entity classes, one @StateMachine with three states and two transitions. The pipeline reaches its fixpoint in three iterations through a single deliberate backward-edge stage. That number — three — is the property the example exists to prove. A pipeline that converged in one iteration would prove nothing about the multi-stage feedback shape this library claims to provide. A pipeline that converged in two iterations would prove the trivial case (one forward stage, one consumer). A pipeline that converged in three iterations through a stage that lex-sorts before its dependency is the textbook demonstration that virtFS-mediated cross-iteration handoff actually works.

This article walks the input, the pipeline, and the output. It does not yet argue for any of the engine's properties — those arguments are spread across Parts 03–06. It does not yet open any individual generator — those walks are in Parts 07–10. It does the orientation work that lets the rest of the series be read in any order.

The vertical slice

The input is one TypeScript file per concept, kept small enough to fit on screen. Three concepts.

One module

import { Module, Provides, Requires } from '../attributes.js';

@Module({ kind: 'module', name: 'shop', version: '1.0.0' })
@Provides({ kind: 'provides', items: ['User', 'Order', 'Product'] })
@Requires({ kind: 'requires', modules: ['Notification'] })
export class ShopModule {}

The class body is empty. The decorators carry every fact about the module that any generator will need: its identity (name, version), the entities it owns (provides.items), the modules it depends on (requires.modules). Three decorators stacked on one class declaration. Source: packages/sourcegen-example/src/modules/shop.module.ts.

Three entities

The Order entity is representative. The other two (User, Product) follow the same shape.

import { Entity, Field } from '../attributes.js';

@Entity({ kind: 'entity', name: 'order', table: 'orders', module: 'shop', lifecycle: 'OrderFsm' })
export class Order {
  @Field({ kind: 'field', name: 'id', type: 'string', pk: true })
  id!: string;

  @Field({ kind: 'field', name: 'userId', type: 'string', ref: 'user' })
  userId!: string;

  @Field({ kind: 'field', name: 'total', type: 'number' })
  total!: number;

  @Field({ kind: 'field', name: 'status', type: 'string', stateOf: 'OrderFsm' })
  status!: string;
}

@Entity carries identity (name), persistence hint (table), the module it belongs to (module), and an optional lifecycle link to the FSM that drives its state. @Field carries column-level facts: type, primary-key flag, foreign-key reference (ref), and the optional stateOf link from a status field to its FSM. Source: packages/sourcegen-example/src/entities/order.entity.ts.

One state machine

import { StateMachine, State, Transition } from '../attributes.js';

@StateMachine({ kind: 'fsm', name: 'OrderFsm', entity: 'order' })
export class OrderFsm {
  @State({ kind: 'state', name: 'pending', initial: true })  pending!: never;
  @State({ kind: 'state', name: 'paid' })                    paid!: never;
  @State({ kind: 'state', name: 'shipped', terminal: true }) shipped!: never;

  @Transition({ kind: 'transition', from: 'pending', to: 'paid', via: 'pay' })
  pay(_amount: number): void { /* declarative */ }

  @Transition({ kind: 'transition', from: 'paid', to: 'shipped', via: 'ship' })
  ship(): void { /* declarative */ }
}

@StateMachine carries identity and the entity it lifecycles. @State decorates never-typed properties — the class is a spec, not a runtime FSM, and never declares "this property has no inhabitable runtime value, only a name and metadata". @Transition decorates methods whose signatures carry the payload type of each transition — the body is empty because the method is declarative; the signature is the part the generators read. Source: packages/sourcegen-example/src/fsm/order-fsm.ts.

That is the entire input. One module, three entities, one FSM. Roughly fifty hand-written lines. Everything else in the example is generated.

The 10-stage pipeline

The configuration in sourcegen.config.ts lists ten generator instances. Their id strings carry numeric prefixes (00-, 10-, 20-, …, 99-) because the runner sorts generators lexicographically by id. The numeric prefixes encode pipeline order; gaps in the numbering reserve room for future inserts. Reading the prefix as a sequence: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 — ten stages.

The README presents the table; this article reproduces it with a column added showing the article that walks each stage.

# Producer ID Reads virtFS Emits Iter Walked in
0 00-entity.registry *.repository.generated.ts entity-registry.generated.ts 1 (backward edge) Part 10
1 10-module.scan <M>.module.generated.ts 0 Part 10
2 20-entity.repository <E>.repository.generated.ts 0 Part 08
3 30-entity.dto <E>.dto.generated.ts 0 Part 08
4 40-fsm.states <F>.states.generated.ts 0 Part 09
5 50-entity.mapper *.dto.generated.ts <E>.mapper.generated.ts 0 Part 08
6 60-entity.validator dto + fsm states <E>.validator.generated.ts 0 Part 08
7 70-fsm.dispatcher fsm states <F>.dispatcher.generated.ts 0 Part 09
8 80-module.wiring module manifests wiring.generated.ts 0 Part 10
9 99-index.barrel all *.generated.ts index.generated.ts 0 (re-emits iter 1 to absorb registry) Part 10

Reading the table top-to-bottom in iteration 0: stages 1–9 emit; stage 0 finds no repositories yet (because lex-ordering puts it before stage 2) and emits nothing. Reading the table again in iteration 1: stage 0 sees the iter-0 repository emissions in virtFS and emits the registry; stage 9 (the barrel) sees the new registry and re-emits to absorb it; stages 1–8 are stable. Iteration 2 produces no new emissions and the runner commits to disk. Three iterations. The number is asserted as a property in req-industrial-pipeline.ts and bound to the test method fixpointReachedInThreeIterations in requirements/features/industrial-weaving.feature.ts.

What "weaving three decorator families" means in practice

The phrase weaving three decorator families appears in the README and in the example's package.json description. It deserves a precise unpacking, because weave is doing real work in that sentence and not decoration.

A decorator family in this example is a set of decorators that share a domain concept and a discriminated-union kind field. The three families are:

  • Module family@Module, @Provides, @Requires. Three decorators, three different kind values ('module', 'provides', 'requires'). Stacked on a single class to declare a module manifest.
  • Entity family@Entity, @Field. Two decorators, two kind values ('entity', 'field'). One on the class, the other on each persisted field.
  • State machine family@StateMachine, @State, @Transition. Three decorators, three kind values ('fsm', 'state', 'transition'). One on the class, one per state property, one per transition method.

The decorators are marker-only — at runtime they return a no-op. Their job is to be picked up by the AST scan in scan-entities.ts, scan-modules.ts, and scan-fsms.ts. Why every decorator carries an explicit kind field — even when the decorator name itself already disambiguates it — is the load-bearing argument of Part 07.

Weaving the three families means: emissions in stages 5, 6 and 7 read across families. The mapper (stage 50) reads the DTO emitted by the entity stage (30) — same family, different stage. The validator (stage 60) reads both the DTO and the FSM-states emission (40) — cross-family. The dispatcher (stage 70) reads the FSM-states (40) — same family again. The registry (stage 0, iter 1) closes the loop by reading the repositories (20) — backward edge. Cross-family reads are what make this weaving and not three independent pipelines stitched together by a barrel. The code generators see each other's outputs in virtFS, and a generator is allowed to skip its work this iteration if its dependency hasn't landed yet — see for example 70-fsm-dispatcher.ts, where the dispatcher checks ctx.virtFs.has(statesRel) and continues only if the partner states module has been emitted.

Type-level constructs emitted

The runtime types live in src/runtime/types.ts. They are hand-written and stable; every generated artefact imports from them by string literal.

export type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
export type Branded<T, B extends string> = T & { readonly __brand: B };
export type EntityId<K extends string> = Branded<`${K}_${string}`, K>;

export interface Repository<E, Id> { /* ... */ }
export interface Mapper<E, DTO>     { /* ... */ }
export interface Validator<T>       { /* ... */ }

Three of these are non-trivial. DistributiveOmit is the standard distribution-safe variant of Omit — without it, Omit<A | B, K> collapses to Omit<A | B, K> instead of Omit<A, K> | Omit<B, K>, which the DTO generator would silently get wrong. Branded<T, B> is the canonical TS pattern for nominal typing on top of structural types. EntityId<K extends string> chains the two: it is a template-literal type `${K}_${string}` branded by K, so a User_42 value cannot accidentally flow into a slot expecting a Product_42. The DTO generator emits a ${ClassName}Key = EntityId<'name'> declaration per entity; the registry generator emits a KeyFor<K> conditional type that maps each EntityKind literal to its key type. Both are walked in Part 08.

The FSM family adds a different family of type-level emissions, all in the *.states.generated.ts per state machine:

  • A literal-union ${Prefix}State = 'pending' | 'paid' | 'shipped'.
  • A discriminated union ${Prefix}Transition = { from: 'pending'; to: 'paid'; via: 'pay'; payload: number } | { from: 'paid'; to: 'shipped'; via: 'ship'; payload: void }.
  • An event-name union ${Prefix}Event = 'pay' | 'ship'.
  • An ${Prefix}EventOf<E> type that extracts the payload type by discriminator.
  • An ${Prefix}TargetOf<E> type that extracts the destination state by discriminator.

The dispatcher generator (stage 70) consumes those last two to type the function signature dispatch${Prefix}<E extends ${Prefix}Event>(state, event, payload: ${Prefix}EventOf<E>): ${Prefix}TargetOf<E> | ${Prefix}State. The compiler narrows the destination state from the verb at the call site. That construction is the load-bearing argument of Part 09.

What is hand-written and what is generated

The boundary between human source and machine source is enforced by both convention and contract. By convention, every generated file ends in .generated.ts. By contract — see Part 04 — the engine forbids the inverse: a generator cannot write to a path not ending in .generated.ts (more precisely, it cannot write outside the configured outDir).

For the canonical example, the breakdown is:

  • Hand-written sources: src/attributes.ts, the three entity files in src/entities/, the FSM file in src/fsm/, the module file in src/modules/, the runtime types file in src/runtime/types.ts, and the ten generator files in src/generators/. Roughly four hundred lines, including comments.
  • Generated artefacts: nine files per run — three repositories (User, Order, Product), three DTOs, three mappers, three validators, one FSM-states module, one FSM-dispatcher, one module manifest, one wiring file, the entity registry, the index barrel. Roughly one thousand lines, content-hashed and bannered.
  • Hand-written tests: twenty test files in test/unit/, plus a 208-line industrial-sandbox.ts harness in test/helpers/. Asserting twenty-two acceptance criteria across five Feature classes — walked in Part 11.

The generated artefacts are not committed to source control by convention — they are reproducible from the input. They are committed in the example for didactic reasons (so the reader can browse them in the repo without running the pipeline). In a consumer project, the choice is a build-system decision, discussed briefly in Part 11.

Running the example

The README documents the three commands. Reproduced here for reference; the rest of the series treats them as familiar.

# one shot — generate + commit
npx tsx ../ts-codegen-pipeline/src/bin/sourcegen.ts run --config sourcegen.config.ts

# in-memory verify (no commit) — reports drift / hand-edits
npx tsx ../ts-codegen-pipeline/src/bin/sourcegen.ts verify --config sourcegen.config.ts

# tests (22 ACs across 5 Features)
npx vitest run --coverage

A first read of the example does not require running the pipeline. The repo includes the generated outputs for browsing. A reader who wants to confirm the convergence-in-three-iterations property is invited to: clone the repo, delete generated/, run sourcegen run, and observe outcome.iterations === 3 in the runner output. The same property is asserted in the unit tests; Part 11 walks the assertion.

Why this example, and not a smaller one

A reader who has skimmed the table above might fairly ask whether a five-stage pipeline would have served the same teaching purpose. The answer is no, and the reason is precise.

A pipeline of one stage proves nothing — single-pass codegen is the templating-engine shape (Part 01, Family 1). A pipeline of two stages where stage 1 reads stage 0's output proves the easy case — forward dependency — which any acyclic plugin chain (esbuild, webpack loaders) can do. The interesting case begins at three stages with at least one backward edge — a stage that lex-orders before its dependency and therefore needs cross-iteration handoff. A pipeline of ten stages with one backward edge is the smallest example that exercises every property the engine claims to provide:

  • Forward dependency across stages (mapper reads dto)
  • Cross-family dependency (validator reads dto + fsm states)
  • Backward edge (registry reads repository)
  • Stage that re-emits when its consumer changes (barrel re-emits to absorb the registry)
  • Stage that conditionally skips when its dependency hasn't landed (dispatcher skips if states isn't in virtFS)

A smaller pipeline would skip at least two of those properties and leave the engine's claims partially unsupported. A larger pipeline would add complication without adding new properties to demonstrate. Ten stages is the minimum.

Bridge

Part 03 opens with the convergence loop — the runner code itself, not the example. It walks through how virtFS holds emissions in memory, how the runner detects "no new emissions this iteration", and how the backward-edge in the example forces exactly three iterations. After Part 03 the reader holds the engine's mental model; from there Parts 04–06 work through the three engine invariants (additivity, banner determinism, atomic commit) one by one, and Parts 07–10 walk the example stage by stage.

The Feature for this article is FEAT-TSGEN-02 in assets/features.ts. Its acceptance criteria are: the vertical slice introduced; the ten-stage pipeline table narrated; the three decorator families introduced; the weaving claim stated and scoped to later articles. Each section above maps to one of those ACs.

⬇ Download