Part 10 — Module wiring and the registry loop
The previous two articles walked seven of the ten generators (stages 20, 30, 40, 50, 60, 70 plus the shared scanners). This article finishes the pipeline by walking the three module-family generators — stages 10, 80, and 99 — and closes the registry backward-edge loop that has been the example's running motivation since Part 02. After this article every stage has been read end to end, every cross-stage dependency has been named, and the three iterations claim is no longer abstract — it is the precise output of a runner that emits at iter 0, observes feedback at iter 1, and converges at iter 2.
Three things happen in this article. First, stages 10, 80, 99 are walked individually. Stage 10 (per-module manifest) is the simplest — pure scan, like stage 20. Stage 80 (wiring aggregator) does interesting graph work — cycle detection, topological sort. Stage 99 (index barrel) is the last generator in lex order and therefore sees every prior stage's emission in the current iteration. Second, the registry backward-edge from stage 0 (walked structurally in Part 03) is now grounded against actual emitted code: the registry imports the repository types emitted at iter 0, and the barrel re-emits at iter 1 to absorb the registry. Third, iteration 2 is shown to be the convergence no-op — every generator's addSource returns newOrChanged: false, the runner detects anyEmitted === false, and the commit phase runs.
Stage 10 — ModuleScanGenerator
Per @Module, emit a typed manifest. Pure scan, no virtFS reads, emits at iteration 0. The render function is in 10-module-scan.ts:38-61:
return [
`export const ${className}Manifest = {`,
` name: '${args.name}',`,
` version: '${args.version}',`,
` provides: ${providesLiteral},`,
` requires: ${requiresLiteral},`,
`} as const;`,
``,
`export type ${className}ProvidedKinds = ${providedType};`,
`export type ${className}RequiredKinds = ${requiredType};`,
``,
].join('\n');return [
`export const ${className}Manifest = {`,
` name: '${args.name}',`,
` version: '${args.version}',`,
` provides: ${providesLiteral},`,
` requires: ${requiresLiteral},`,
`} as const;`,
``,
`export type ${className}ProvidedKinds = ${providedType};`,
`export type ${className}RequiredKinds = ${requiredType};`,
``,
].join('\n');The output for ShopModule:
export const ShopModuleManifest = {
name: 'shop',
version: '1.0.0',
provides: ['User', 'Order', 'Product'] as const,
requires: ['Notification'] as const,
} as const;
export type ShopModuleProvidedKinds = typeof ShopModuleManifest.provides[number];
export type ShopModuleRequiredKinds = typeof ShopModuleManifest.requires[number];export const ShopModuleManifest = {
name: 'shop',
version: '1.0.0',
provides: ['User', 'Order', 'Product'] as const,
requires: ['Notification'] as const,
} as const;
export type ShopModuleProvidedKinds = typeof ShopModuleManifest.provides[number];
export type ShopModuleRequiredKinds = typeof ShopModuleManifest.requires[number];Three observations.
The first is the as const assertion. Without it, TypeScript would widen the array type to string[] and the literal types 'User', 'Order', 'Product' would be lost. With it, the array is typed as a readonly tuple of literal types: readonly ['User', 'Order', 'Product']. The next two type aliases project the literal union out of the tuple by index.
The second is the typed projection: typeof ShopModuleManifest.provides[number] resolves to 'User' | 'Order' | 'Product'. This is the standard TS pattern for deriving a literal-union type from a const-asserted array. The pattern is widely used in the TS ecosystem (see for example route-typing libraries, schema validators) and the example uses it because it is the smallest expression that gives the consumer a typed kind set.
The third is the conservative empty-case handling: provides: ${providesLiteral} becomes provides: [] as const when there are no items, and ${className}ProvidedKinds = never when there are no items. The never type is the right type-level zero — a value of type never cannot exist, so the consumer cannot accidentally write code that depends on a non-existent provided kind.
Stage 80 — ModuleWiringGenerator (the graph work)
Stage 80 reads every per-module manifest from virtFS, builds the dependency graph, detects cycles, computes a topological order, and emits a single wiring.generated.ts. The execute method (80-module-wiring.ts:19-49) carries the most logic of any single stage:
async execute(ctx: GenerationContext): Promise<GenerationResult> {
const modules = scanModules(ctx.project);
if (modules.length === 0) return { emittedSomething: false, diagnostics: [] };
const allManifestsPresent = modules.every(m =>
ctx.virtFs.has(`${m.className}.module.generated.ts`),
);
if (!allManifestsPresent) return { emittedSomething: false, diagnostics: [] };
const diagnostics: Diagnostic[] = [];
const cycle = detectCycle(modules);
if (cycle) {
diagnostics.push({
severity: 'error',
code: 'SG-WIRING-CYCLE',
message: `ModuleWiringCycle: ${cycle.join(' → ')}`,
producerId: this.id,
});
return { emittedSomething: false, diagnostics };
}
const order = topologicalOrder(modules);
const body = renderWiring(modules, order);
/* ... addSource ... */
}async execute(ctx: GenerationContext): Promise<GenerationResult> {
const modules = scanModules(ctx.project);
if (modules.length === 0) return { emittedSomething: false, diagnostics: [] };
const allManifestsPresent = modules.every(m =>
ctx.virtFs.has(`${m.className}.module.generated.ts`),
);
if (!allManifestsPresent) return { emittedSomething: false, diagnostics: [] };
const diagnostics: Diagnostic[] = [];
const cycle = detectCycle(modules);
if (cycle) {
diagnostics.push({
severity: 'error',
code: 'SG-WIRING-CYCLE',
message: `ModuleWiringCycle: ${cycle.join(' → ')}`,
producerId: this.id,
});
return { emittedSomething: false, diagnostics };
}
const order = topologicalOrder(modules);
const body = renderWiring(modules, order);
/* ... addSource ... */
}Three behaviours stack here.
The first is the all-manifests-present check. The wiring file aggregates every per-module manifest, so the wiring generator must wait until every stage-10 emission has landed in virtFS. The check modules.every(m => ctx.virtFs.has(...)) is the same shape as stage 50's mapper check — if a dependency is missing, return early without emitting. In the canonical example all three modules' manifests land at iteration 0 (stage 10 lex-orders before stage 80), so the check passes and the wiring is emitted at iter 0. The defensive guard exists for the case where a future stage 10 might skip a module; the wiring generator would then defer to a later iteration when stage 10 caught up.
The second is cycle detection. The detectCycle function (80-module-wiring.ts:75-104) is a standard depth-first-search graph colour algorithm: white = not visited, grey = currently on the DFS stack, black = fully visited. A grey vertex revisit means a cycle: the function returns the path through the DFS stack from the cycle's first vertex. The detected cycle is surfaced as an error-severity SG-WIRING-CYCLE diagnostic, and the wiring file is not emitted; the run aborts at the runner's next error check. A consumer who somehow declares circular module dependencies (Shop requires Notification, Notification requires Shop) gets a fast diagnostic at codegen time, with the precise cycle in the error message.
External dependencies — modules referenced by requires: but not present in the project — are ignored by the cycle detector (80-module-wiring.ts:90: if (!byName.has(dep)) continue;). This is intentional: a module that requires Notification (not provided in the example) is allowed to declare that requirement without causing the cycle detector to falsely flag it. The wiring graph is intra-project; cross-project requirements are the consumer's responsibility to validate.
The third is topological sort (80-module-wiring.ts:106-124). The topologicalOrder function emits a Kahn-style traversal: visit each module, recursively visit its in-project dependencies first, then push the module to the output list. The result is an order in which dependencies precede their dependents — usable by a consumer's runtime to initialise modules in safe order.
The output for the canonical example:
import { ShopModuleManifest } from './ShopModule.module.generated.js';
export const ModuleGraph = {
shop: ShopModuleManifest,
} as const;
export type ModuleName = keyof typeof ModuleGraph;
export const TopologicalOrder: readonly ModuleName[] = ['shop'];import { ShopModuleManifest } from './ShopModule.module.generated.js';
export const ModuleGraph = {
shop: ShopModuleManifest,
} as const;
export type ModuleName = keyof typeof ModuleGraph;
export const TopologicalOrder: readonly ModuleName[] = ['shop'];The example has only one module so the graph is trivial. A more realistic example with three modules — Auth provides Session, Notification requires Session, Shop requires Notification — would emit a three-entry graph and the topological order ['Auth', 'Notification', 'Shop']. The pattern scales.
Stage 99 — IndexBarrelGenerator (the last lex stage)
Stage 99 lex-sorts last. It runs after every other stage in every iteration, which means it sees every prior stage's emission in virtFS. The execute method (99-index-barrel.ts:18-46):
async execute(ctx: GenerationContext): Promise<GenerationResult> {
const diagnostics: Diagnostic[] = [];
const outDirPosix = ctx.outDir.replace(/\\/g, '/');
const stems: string[] = [];
for (const sf of ctx.project.getSourceFiles()) {
const filePath = sf.getFilePath().replace(/\\/g, '/');
if (!filePath.startsWith(outDirPosix)) continue;
const base = sf.getBaseName();
if (!base.endsWith('.generated.ts')) continue;
if (base === 'index.generated.ts') continue;
stems.push(base.replace(/\.ts$/, ''));
}
if (stems.length === 0) return { emittedSomething: false, diagnostics };
const sortedStems = [...stems].sort();
const body = sortedStems.map(stem => `export * from './${stem}.js';`).join('\n') + '\n';
/* addSource as usual */
}async execute(ctx: GenerationContext): Promise<GenerationResult> {
const diagnostics: Diagnostic[] = [];
const outDirPosix = ctx.outDir.replace(/\\/g, '/');
const stems: string[] = [];
for (const sf of ctx.project.getSourceFiles()) {
const filePath = sf.getFilePath().replace(/\\/g, '/');
if (!filePath.startsWith(outDirPosix)) continue;
const base = sf.getBaseName();
if (!base.endsWith('.generated.ts')) continue;
if (base === 'index.generated.ts') continue;
stems.push(base.replace(/\.ts$/, ''));
}
if (stems.length === 0) return { emittedSomething: false, diagnostics };
const sortedStems = [...stems].sort();
const body = sortedStems.map(stem => `export * from './${stem}.js';`).join('\n') + '\n';
/* addSource as usual */
}Three observations.
The first is the enumeration via ctx.project.getSourceFiles(). Recall from Part 03 that addSource writes to both the virtFS Map and the ts-morph Project. Stage 99 enumerates the Project's source files to find every .generated.ts under outDir — this is more general than ctx.virtFs.listEmitted(), because it also includes any pre-existing generated files that happen to be loaded into the Project. In practice the result is the same: every emission in this run is in the Project, every prior-run-but-this-run-stale generated file is not in the Project (the runner excludes .generated.ts from the initial Project seed), so the barrel sees exactly the emissions of this run.
The second is the alphabetical sort of stems. Like every other generator, the barrel produces deterministic output. The re-exports are emitted in alphabetical order, so two runs produce byte-identical barrels. The sort is the same pattern used by every scanner — alphabetical, locale-independent, stable across runs.
The third is the content shape: export * from './<stem>.js' for each generated module. The barrel is a single file that re-exports every other generated file, giving consumers a single import point. A consumer can write import { OrderRepository, OrderDto, OrderState, dispatchOrder } from './generated/index.generated.js' instead of importing each from its specific file.
Closing the registry backward-edge loop
The registry generator (00-entity-registry.ts, walked in Part 03) has been the running example of a backward edge throughout the series. Now that every other stage is on the table, the registry's behaviour is fully visible.
Iteration 0
The runner sorts generators lex: 00-entity.registry, 10-module.scan, 20-entity.repository, 30-entity.dto, 40-fsm.states, 50-entity.mapper, 60-entity.validator, 70-fsm.dispatcher, 80-module.wiring, 99-index.barrel. Generators run in that order.
Stage 0 (00-entity.registry) runs first. It scans for entities (the project has three: User, Order, Product). It checks virtFS for ${className}.repository.generated.ts — at iter 0, 20-entity.repository has not yet run, so virtFS contains zero repository files. The ready filter returns empty. The generator emits nothing: return { emittedSomething: false, diagnostics: [] }.
Stages 10 through 99 then run. They emit nine files (three per-entity files for each of repository, DTO, FSM-states is one, mapper, validator is — wait, let me recount: three repositories, three DTOs, one FSM states, three mappers, three validators, one FSM dispatcher, one module manifest, one wiring, one barrel) — fourteen files in total at iter 0.
Wait — the barrel also runs at iter 0, and at that point the registry file does not exist. The barrel scans the Project for .generated.ts files, finds the thirteen non-barrel files emitted by stages 10–80, and emits an index that re-exports those thirteen. The registry is not yet in the index.
anyEmitted === true. The loop continues to iteration 1.
Iteration 1
Stage 0 runs again. It scans for entities — same three. It checks virtFS for repository files — three are now present (emitted at iter 0). The ready filter returns three entries. The generator emits entity-registry.generated.ts. The contents (rendered by renderRegistry in 00-entity-registry.ts:45-81):
import type { OrderRepository } from './Order.repository.generated.js';
import type { OrderKey } from './Order.dto.generated.js';
import type { ProductRepository } from './Product.repository.generated.js';
import type { ProductKey } from './Product.dto.generated.js';
import type { UserRepository } from './User.repository.generated.js';
import type { UserKey } from './User.dto.generated.js';
export type EntityKind = 'order' | 'product' | 'user';
export const ENTITY_KINDS: readonly EntityKind[] = ['order', 'product', 'user'] as const;
export type RepositoryFor<K extends EntityKind> =
K extends 'order' ? OrderRepository :
K extends 'product' ? ProductRepository :
K extends 'user' ? UserRepository :
never;
export type KeyFor<K extends EntityKind> =
K extends 'order' ? OrderKey :
K extends 'product' ? ProductKey :
K extends 'user' ? UserKey :
never;import type { OrderRepository } from './Order.repository.generated.js';
import type { OrderKey } from './Order.dto.generated.js';
import type { ProductRepository } from './Product.repository.generated.js';
import type { ProductKey } from './Product.dto.generated.js';
import type { UserRepository } from './User.repository.generated.js';
import type { UserKey } from './User.dto.generated.js';
export type EntityKind = 'order' | 'product' | 'user';
export const ENTITY_KINDS: readonly EntityKind[] = ['order', 'product', 'user'] as const;
export type RepositoryFor<K extends EntityKind> =
K extends 'order' ? OrderRepository :
K extends 'product' ? ProductRepository :
K extends 'user' ? UserRepository :
never;
export type KeyFor<K extends EntityKind> =
K extends 'order' ? OrderKey :
K extends 'product' ? ProductKey :
K extends 'user' ? UserKey :
never;This is the registry: a literal-union of every entity kind, an array of those kinds for runtime iteration, and two conditional-type accessors that map a kind to its repository type or key type.
Stages 10 through 80 run. They re-emit byte-identical files (their inputs haven't changed); each addSource returns newOrChanged: false (idempotent overlap, see Part 04).
Stage 99 runs. It scans the Project — fourteen .generated.ts files now (the original thirteen plus the new registry). It alphabetically sorts the stems and emits index.generated.ts. The new contents differ from iter 0's barrel (one extra re-export), so addSource returns newOrChanged: true (same producer, new contents — allowed).
anyEmitted === true. The loop continues to iteration 2.
Iteration 2 — convergence
Stage 0 runs again. Same scan, same three repositories present, same ready filter, same render, same banner hash, byte-identical contents. addSource returns newOrChanged: false (idempotent overlap).
Stages 10 through 99 run. Same idempotent behaviour. Every addSource returns newOrChanged: false.
anyEmitted === false. Fixpoint reached. Iteration count is 3. The runner moves to the commit phase (Part 06) and writes fourteen files atomically.
The number three falls out of the structure: one iteration to populate forward stages, one iteration for the backward edge to fire (and for the barrel to re-emit), one iteration to confirm convergence. Adding a second backward edge would push the count to four; adding a chain of two backward edges where one depends on the other would push it to five. The example deliberately includes one backward edge precisely to expose this number.
What this article does not claim
Three claims a reader might be tempted to make and the reasons they would be wrong.
The first non-claim is that three iterations is the maximum. With more elaborate backward-edge structures, the count is higher. The runner's MAX_ITERATIONS default of 16 is a circuit breaker, not an asserted ceiling. A pipeline that needs more than three iterations is allowed; a pipeline that needs more than 16 is bounded by the safety check.
The second non-claim is that the backward edge is necessary in any pipeline. It is necessary in this pipeline because the example deliberately includes it to demonstrate the multi-iteration property. A consumer's pipeline might have only forward edges, in which case it converges in one iteration plus one no-op iteration to confirm fixpoint — total two iterations. The fixpoint loop is robust to both shapes; the example showcases the harder shape.
The third non-claim is that cycle detection in stage 80 is a substitute for type-checking. The SG-WIRING-CYCLE diagnostic catches module dependency cycles, not type-level cycles. A consumer who writes interface Foo extends Bar; interface Bar extends Foo is making a different mistake; that one is caught by tsc --noEmit on outDir, not by stage 80. The two checks are complementary and run at different phases (Part 11 walks the type-soundness assertion).
The acceptance criteria
The Feature class for module wiring is in requirements/features/module-wiring.feature.ts, backed by req-module-wiring.ts. The fit criteria include: per-module manifest emitted (stage 10); aggregated ModuleGraph with topological order (stage 80); cycle detection surfaces SG-WIRING-CYCLE (stage 80, error case); external requires-references are tolerated (Notification is not in the project but does not break wiring).
The Feature class for industrial weaving is in requirements/features/industrial-weaving.feature.ts, backed by req-industrial-pipeline.ts. The fit criteria include: outcome.iterations === 3 after running the canonical project; every decorator family yields scan hits and emissions; the barrel re-exports the entity-registry (proving stage 0 emitted at iter 1); empty-input project returns ok: true with zero emissions and a single iteration.
The two Feature classes together carry the industrial-weaving claim that this article concludes.
Bridge
Part 11 walks the testing strategy. With every generator now visible, the dog-fooded Feature / Requirement / @Verifies framework — and the 208-line industrial-sandbox.ts test harness — fit naturally as the next chapter. The 22 ACs that have been cited piecewise across the previous nine articles get a unified treatment: how each acceptance criterion is bound to a test method, what the harness does to make tests deterministic and isolated, and how sourcegen verify plus tsc --noEmit on outDir compose into a CI gate.
The Feature for this article is FEAT-TSGEN-10 in assets/features.ts. Acceptance criteria: module-scan and wiring stages walked; provides/requires resolution explained; cycle-detection rules stated; registry backward-edge loop closed explicitly. Each section above maps to one of those ACs.