Drift Detection — The Topology Scanner
Part VI mapped the topology: nine custom events, thirteen directed edges, four lifecycle domains, and a dozen participating machines. The map is the reading guide for the architecture. But a map drawn by hand rots the moment someone forgets to update it. A map generated from source code is better — it cannot be stale by definition. But even a generated map only shows what the decorators declare. It does not verify that the declarations match reality.
The topology scanner fills this gap. It walks every source file with the TypeScript Compiler API, extracts every dispatchEvent, addEventListener, bus.emit, and bus.on call, cross-references the AST sites against the decorator metadata in state-machines.json, classifies every edge as covered, missing, phantom, or delegated, and fails the commit if any edge is missing.
This is not a linter. A linter checks style. This is a structural verifier. It checks that the declared event contracts — the emits and listens arrays in every @FiniteStateMachine decorator — correspond to real AST sites in the source code. If a machine says it emits app-ready but no code dispatches app-ready on its behalf, the scanner reports it. If code dispatches sidebar-mask-change but no machine declares it, the scanner reports it. If the declarations and the code agree, the scanner says nothing. Silence means correctness.
By the end of this part, you will understand: the scanner's two-phase architecture (EventDef map building, then AST walking), the DOM event exclusion set, the four report sections (pivot table, drift detection, adapter sites, semantic gaps), the four-way classification algorithm, the delegated phantom resolution, the commit gate, and the four invariants the scanner enforces. You will also see a concrete story where the scanner caught a real bug before it reached production.
Why a Scanner?
The type system (Part II) catches structural errors. If you call bus.emit(AppReady, { wrong: true }) where AppReady expects void, TypeScript rejects it. If you call bus.on(SomeEvent, handler) where SomeEvent is not in the bus's TListens type parameter, TypeScript rejects it. The compiler protects the shape of individual calls.
But the compiler cannot protect the topology. It cannot verify that every emitted event has at least one listener somewhere in the codebase. It cannot verify that the emits: ['app-ready'] declaration in a machine's decorator corresponds to a real bus.emit(AppReady) call in its adapter. It cannot verify that a developer who added a new bus.emit() call also updated the decorator. These are whole-program properties. They span multiple files, multiple compilation units, and the boundary between library code (pure machines) and application code (adapters).
A concrete scenario: you refactor the ScrollSpy adapter. You move the bus.emit(ScrollspyActive) call from one function to another. The type system is happy — the call is still well-typed. The machine's decorator still says emits: ['scrollspy-active']. But what if, during the move, you accidentally commented out the line? The type system does not notice — there is no rule that says "a typed bus must call emit at least once." The decorator still claims the emission. The topology map still shows the edge. But the event never fires. The breadcrumb stops updating. The failure is silent.
The scanner catches this. It walks the adapter file, finds zero bus.emit calls for scrollspy-active, checks the decorator, and reports: scroll-spy-machine.emits declares 'scrollspy-active' but no direct dispatch in src/lib/scroll-spy-machine.ts — delegated via src/app-shared.ts. If the adapter file also has no matching dispatch? Then the status becomes phantom — declared but unfulfilled. The scanner flags it. The commit gate blocks.
Three categories of drift the type system cannot catch:
Declaration without dispatch. A machine declares emits: ['x'] but nobody dispatches x. The decorator is aspirational. The event never fires. Listeners wait forever.
Dispatch without declaration. An adapter calls bus.emit(SomeEvent) but no machine's decorator mentions it. The event fires, but the topology map does not show it. The map is incomplete.
Stale declarations. A machine used to emit x, the declaration remains, but the adapter code was refactored and the dispatch was removed. The decorator is a fossil. The topology map shows a ghost edge.
The scanner detects all three. The type system detects none. This is why the scanner exists.
The Scanner Architecture
The scanner lives in scripts/scan-event-topology.ts. It follows the same architecture as every build script in this project: a pure core of helper functions (testable without a filesystem) wrapped by a thin CLI shell (file I/O, exit codes, output writing).
Four phases, each a pure function call on the data produced by the previous phase.
Phase 1: Build the EventDef Map
Before the scanner can understand bus.emit(AppReady), it needs to know that AppReady is an identifier bound to the string 'app-ready'. The EventDef map resolves this.
The scanner walks every source file looking for the pattern:
const X = defineEvent('event-name');
// or
const X = defineEvent<'event-name', PayloadType>('event-name');const X = defineEvent('event-name');
// or
const X = defineEvent<'event-name', PayloadType>('event-name');For each match, it records X → 'event-name' in a Map<string, string>. The result for this codebase:
| Identifier | Event Name |
|---|---|
AppReady |
app-ready |
AppRouteReady |
app-route-ready |
TocHeadingsRendered |
toc-headings-rendered |
TocActiveReady |
toc-active-ready |
TocAnimationDone |
toc-animation-done |
SidebarMaskChange |
sidebar-mask-change |
ScrollspyActive |
scrollspy-active |
MermaidConfigReady |
mermaid-config-ready |
HotReloadContent |
hot-reload:content |
HotReloadToc |
hot-reload:toc |
Ten identifiers, ten string names. The map is small and built in a single pass. It is used in Phase 2 to resolve bus.emit(AppReady) → dispatch of 'app-ready'.
The implementation:
async function buildEventDefMap(files: string[]): Promise<Map<string, string>> {
const map = new Map<string, string>();
for (const relPath of files) {
let text: string;
try { text = await fsp.readFile(path.join(ROOT, relPath), 'utf8'); }
catch { continue; }
const sf = ts.createSourceFile(
path.basename(relPath), text,
ts.ScriptTarget.Latest, true, ts.ScriptKind.TS
);
function walk(node: ts.Node): void {
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.initializer &&
ts.isCallExpression(node.initializer)
) {
const call = node.initializer;
const callee = call.expression;
if (
ts.isIdentifier(callee) && callee.text === 'defineEvent' &&
call.arguments.length >= 1 &&
ts.isStringLiteral(call.arguments[0]!)
) {
map.set(node.name.text, (call.arguments[0] as ts.StringLiteral).text);
}
}
ts.forEachChild(node, walk);
}
walk(sf);
}
return map;
}async function buildEventDefMap(files: string[]): Promise<Map<string, string>> {
const map = new Map<string, string>();
for (const relPath of files) {
let text: string;
try { text = await fsp.readFile(path.join(ROOT, relPath), 'utf8'); }
catch { continue; }
const sf = ts.createSourceFile(
path.basename(relPath), text,
ts.ScriptTarget.Latest, true, ts.ScriptKind.TS
);
function walk(node: ts.Node): void {
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.initializer &&
ts.isCallExpression(node.initializer)
) {
const call = node.initializer;
const callee = call.expression;
if (
ts.isIdentifier(callee) && callee.text === 'defineEvent' &&
call.arguments.length >= 1 &&
ts.isStringLiteral(call.arguments[0]!)
) {
map.set(node.name.text, (call.arguments[0] as ts.StringLiteral).text);
}
}
ts.forEachChild(node, walk);
}
walk(sf);
}
return map;
}This is a textbook TypeScript Compiler API pattern: create a SourceFile (no type-checker needed — the scanner operates at the syntax level, not the semantic level), walk the AST recursively, match a specific node shape, extract data. No type resolution, no program creation, no tsconfig.json. Fast. Deterministic. Pure.
Phase 2: Walk Every File
Phase 2 is the main scan. For each source file in scope, the scanner creates a SourceFile and walks the AST looking for three patterns:
Pattern 1: X.dispatchEvent(new Event('name')) or X.dispatchEvent(new CustomEvent('name', ...))
The walker matches any CallExpression where the callee is dispatchEvent (either as a bare identifier or a property access like window.dispatchEvent), the first argument is a NewExpression for Event or CustomEvent, and the new expression's first argument is a string literal. The string literal is the event name. If the name is in the DOM exclusion set, skip it. Otherwise, record an AstSite with kind 'dispatch'.
Pattern 2: X.addEventListener('name', handler)
The walker matches any CallExpression where the method name is addEventListener and the first argument is a string literal. Same exclusion check. Record with kind 'listen'.
Pattern 3: bus.emit(EventDef) or bus.on(EventDef)
The walker matches CallExpression nodes where the callee is a property access with name emit or on, and the first argument is an identifier. The identifier is looked up in the EventDef map from Phase 1. If found, the string event name is resolved and an AstSite is recorded with the appropriate kind.
Each AstSite captures:
interface AstSite {
kind: 'dispatch' | 'listen';
file: string; // relative path
line: number;
eventName: string;
receiver: string; // 'window' | 'document' | '(bare)'
enclosingChain: string[]; // e.g. ['const SidebarMask', 'function emitChange()']
}interface AstSite {
kind: 'dispatch' | 'listen';
file: string; // relative path
line: number;
eventName: string;
receiver: string; // 'window' | 'document' | '(bare)'
enclosingChain: string[]; // e.g. ['const SidebarMask', 'function emitChange()']
}The enclosingChain is the lexical scope chain from the call site upward — function name, variable declaration, class name. It is used in Section C (adapter sites reference) to give the reader context without having to open the file.
Scan Scope
The scanner reads a fixed set of files:
src/app-shared.ts— the shared adapter gluesrc/app-static.ts— the static-mode entry wiringsrc/lib/*.ts— every FSM, filtered throughfilterMachineSources(the same filter the state-machine extractor uses, which excludesNON_MACHINE_LIB_FILESlikeevents.ts,event-bus.ts,external.ts)src/lib/events.tsandsrc/lib/event-bus.ts— always included for EventDef map building
The scope is intentionally narrow. The scanner does not walk test/, scripts/, node_modules/, or CSS files. It walks production source code only. Events dispatched in tests are test infrastructure, not application topology. Events dispatched in build scripts do not exist at runtime. The topology is a property of the deployed application, not the development environment.
Pure Helpers, Thin Shell
The architecture matters for testing. Every data-producing function is pure:
buildEventDefMap(files)— takes file paths, returns a map. No side effects.scanFile(relPath, eventDefMap)— takes a path and the map, returnsAstSite[]. Reads a file (the only I/O), but the I/O is isolated and mockable.buildRows(sites, graph)— takes sites and graph data, returnsEventRow[]. Pure.computeDrift(sites, graph)— takes sites and graph data, returnsDriftRow[]. Pure.
The CLI shell at the bottom of the file does four things: read state-machines.json, list files, call the pure helpers, write data/event-map.md. If any DriftRow has status 'missing', it sets process.exitCode = 1. That is the commit gate.
This separation means the drift classification logic can be tested with synthetic data — no filesystem, no real source files, no AST parsing. You construct a GraphShape and an AstSite[], call computeDrift, and assert on the output. The unit tests do exactly this.
The DOM Event Exclusion Set
The AST walker sees every addEventListener and dispatchEvent call. Most of them are browser events — click, scroll, resize, keydown. These are not application events. They are platform events. The scanner must filter them out.
The exclusion set is a Set<string> of 70+ browser event names:
const DOM_EVENTS = new Set([
'click','dblclick','mousedown','mouseup','mousemove',
'mouseenter','mouseleave',
'touchstart','touchend','touchmove',
'pointerdown','pointerup','pointermove','pointerenter',
'pointerleave','pointercancel','pointerover','pointerout',
'keydown','keyup','keypress','focus','blur','focusin','focusout',
'input','change','scroll','resize','load','unload','beforeunload',
'DOMContentLoaded','submit','reset','wheel','contextmenu',
'copy','cut','paste',
'transitionend','transitionstart','animationend','animationstart',
'animationiteration','scrollend',
'dragstart','dragend','drop','dragover','dragenter','dragleave',
'error','unhandledrejection','fullscreenchange',
'hashchange','popstate','pagehide','pageshow',
'visibilitychange','online','offline',
]);const DOM_EVENTS = new Set([
'click','dblclick','mousedown','mouseup','mousemove',
'mouseenter','mouseleave',
'touchstart','touchend','touchmove',
'pointerdown','pointerup','pointermove','pointerenter',
'pointerleave','pointercancel','pointerover','pointerout',
'keydown','keyup','keypress','focus','blur','focusin','focusout',
'input','change','scroll','resize','load','unload','beforeunload',
'DOMContentLoaded','submit','reset','wheel','contextmenu',
'copy','cut','paste',
'transitionend','transitionstart','animationend','animationstart',
'animationiteration','scrollend',
'dragstart','dragend','drop','dragover','dragenter','dragleave',
'error','unhandledrejection','fullscreenchange',
'hashchange','popstate','pagehide','pageshow',
'visibilitychange','online','offline',
]);The set is a superset of the extractor's DOM event list. It includes window-lifecycle events (pagehide, pageshow, visibilitychange) and history events (hashchange, popstate) that the entry files use as ambient browser hooks. These are real event subscriptions — the SPA listens for popstate to handle browser back/forward navigation — but they are not application topology. They are browser-owned, not application-owned.
The classification is binary: if the event name is in DOM_EVENTS, skip it entirely. If not, treat it as a custom application event and record it. There is no partial classification, no "maybe browser" category. Custom events use semantic names like app-ready and scrollspy-active. Browser events use platform names like click and resize. The two namespaces do not overlap in practice. If they ever did — if someone named a custom event scroll — the scanner would silently exclude it and the topology map would be incomplete. But that would also be a naming error caught by code review long before the scanner runs.
Why Not Use CustomEvent Detection?
An alternative approach: instead of an exclusion set, only track events dispatched via new CustomEvent(...) and ignore new Event(...). Browser events are dispatched by the browser, not by user code. User code dispatches custom events.
This does not work for two reasons. First, some custom events in this codebase are dispatched via new Event('app-ready') rather than new CustomEvent('app-ready') — events with void payloads do not need CustomEvent's detail property. Second, addEventListener calls do not distinguish between Event and CustomEvent on the listener side. The listener registers for a string name regardless of the dispatch mechanism. The exclusion set is the only reliable filter.
Section A: The Event Topology Pivot
Section A is the heart of the report. It answers the question: for every custom event in the codebase, who declares ownership and where does the actual code live?
The format is a four-column table:
| Event | Declared emitters | Declared listeners | AST sites || Event | Declared emitters | Declared listeners | AST sites |"Declared emitters" and "Declared listeners" come from the @FiniteStateMachine decorator metadata, extracted into state-machines.json. These are the authoritative ownership claims: machine X says it emits event Y.
"AST sites" come from the Phase 2 walk. These are the physical locations in source code where dispatchEvent, addEventListener, bus.emit, or bus.on is called with this event name. Each site is recorded as file:line with a dispatch or listen prefix.
Here is the actual Section A output from this codebase (trimmed for readability):
| Event | Declared emitters | Declared listeners | AST sites |
|-------------------|--------------------------------|----------------------------------------------|----------------------------------------------|
| app-ready | page-load-state | tour-state | dispatch: app-static.ts:831 |
| | | | listen: app-shared.ts:2581 |
| hot-reload:content| hot-reload-actions | -- | dispatch: src/lib/hot-reload-actions.ts:54 |
| | | | listen: src/app-dev.ts:1334 |
| scrollspy-active | scroll-spy-machine | -- | dispatch: app-shared.ts:892 |
| | | | listen: app-static.ts:76, app-dev.ts:1292 |
| sidebar-mask-change| -- | terminal-dots-state | dispatch: app-shared.ts:1389 |
| | | | listen: app-shared.ts:1501 |
| toc-animation-done| toc-breadcrumb-state | scroll-spy-machine | dispatch: app-shared.ts:2531 |
| | | | listen: app-shared.ts:987, app-static.ts:830 |
| toc-headings-rendered| page-load-state, spa-nav-state| headings-panel-machine, scroll-spy-machine | dispatch: app-static.ts:397, app-static.ts:480|
| | | | listen: app-shared.ts:986, app-shared.ts:1271|| Event | Declared emitters | Declared listeners | AST sites |
|-------------------|--------------------------------|----------------------------------------------|----------------------------------------------|
| app-ready | page-load-state | tour-state | dispatch: app-static.ts:831 |
| | | | listen: app-shared.ts:2581 |
| hot-reload:content| hot-reload-actions | -- | dispatch: src/lib/hot-reload-actions.ts:54 |
| | | | listen: src/app-dev.ts:1334 |
| scrollspy-active | scroll-spy-machine | -- | dispatch: app-shared.ts:892 |
| | | | listen: app-static.ts:76, app-dev.ts:1292 |
| sidebar-mask-change| -- | terminal-dots-state | dispatch: app-shared.ts:1389 |
| | | | listen: app-shared.ts:1501 |
| toc-animation-done| toc-breadcrumb-state | scroll-spy-machine | dispatch: app-shared.ts:2531 |
| | | | listen: app-shared.ts:987, app-static.ts:830 |
| toc-headings-rendered| page-load-state, spa-nav-state| headings-panel-machine, scroll-spy-machine | dispatch: app-static.ts:397, app-static.ts:480|
| | | | listen: app-shared.ts:986, app-shared.ts:1271|A few things the pivot table reveals immediately:
Every event has at least one dispatch site. No event is declared-only. The declarations match real code. (If they did not, Section B would catch the mismatch.)
Most dispatch sites are in adapter files. The dispatch: column shows app-static.ts and app-shared.ts for most events. Only hot-reload-actions.ts dispatches from a src/lib/ file directly — because the hot-reload machine is unique in that it calls dispatchEvent itself rather than delegating to an adapter. Every other machine delegates through callbacks (Part V).
The Declared listeners column has gaps. sidebar-mask-change shows no declared emitter but terminal-dots-state as a declared listener. This means the event is dispatched by adapter code that is not attributed to any machine's decorator. The scanner notes this — it is a data point, not an error. The adapter owns the dispatch. The machine owns the listen. Both are documented.
AST sites are precise. app-shared.ts:892 is not a file reference — it is a line reference. A developer reading the report can jump directly to the dispatch site. Combined with the enclosingChain in Section C, you can understand the context without opening the file.
Building the Pivot
The buildRows function merges two data sources — decorator metadata and AST sites — into a unified EventRow:
interface EventRow {
eventName: string;
declaredEmitters: string[]; // from decorator emits
declaredListeners: string[]; // from decorator listens
dispatchSites: AstSite[]; // from AST walk
listenSites: AstSite[]; // from AST walk
}interface EventRow {
eventName: string;
declaredEmitters: string[]; // from decorator emits
declaredListeners: string[]; // from decorator listens
dispatchSites: AstSite[]; // from AST walk
listenSites: AstSite[]; // from AST walk
}It iterates the graph's machines to populate declaredEmitters and declaredListeners, then iterates the AST sites to populate dispatchSites and listenSites. Events are keyed by name. The result is a sorted array — alphabetical by event name — that Section A renders as a markdown table.
The merge is the foundation for Section B. Any event that appears in the graph but not in the sites is suspicious. Any site that appears in the walk but not in the graph is suspicious. The pivot table is the visual alignment check. Section B is the automated alignment check.
Section B: Drift Detection
Section B is where the scanner earns its keep. It answers the question: does the decorator metadata match the actual code?
The classification algorithm operates on every AST site found in src/lib/ files (machine files, not adapter files). For each site, it asks: is this event dispatch or listen declared in the machine's @FiniteStateMachine decorator?
Four classifications:
Covered
The machine's decorator declares the event AND the machine's source file contains a matching AST site. This is the happy path. The declaration matches the code. Nothing to report (except a green checkmark in the table).
Example from this codebase:
| ✓ covered | src/lib/hot-reload-actions.ts:54 (dispatch) | hot-reload:content |
| | declared in hot-reload-actions.emits || ✓ covered | src/lib/hot-reload-actions.ts:54 (dispatch) | hot-reload:content |
| | declared in hot-reload-actions.emits |The hot-reload-actions machine both declares emits: ['hot-reload:content'] and contains a direct dispatchEvent(new CustomEvent('hot-reload:content', ...)) call at line 54. The declaration and the code agree. Covered.
Missing
The machine's source file contains an AST site (a dispatchEvent or addEventListener call for a custom event) that is NOT declared in the machine's decorator. The code does something the metadata does not know about.
This is a build failure. The exit code is non-zero. The commit is blocked.
Example (hypothetical):
| ✗ **missing** | src/lib/scroll-spy-machine.ts:47 (dispatch) | some-new-event |
| | scroll-spy-machine.emits must include 'some-new-event' || ✗ **missing** | src/lib/scroll-spy-machine.ts:47 (dispatch) | some-new-event |
| | scroll-spy-machine.emits must include 'some-new-event' |A developer added a dispatchEvent(new Event('some-new-event')) call inside the scroll-spy machine but forgot to update the decorator. The scanner catches it. The fix is mechanical: add 'some-new-event' to the emits array in the @FiniteStateMachine decorator.
Phantom
The machine's decorator declares an event in emits or listens, but no matching AST site exists in the machine's own source file AND no matching AST site exists in any adapter file. The declaration is orphaned. Nobody fulfills it.
This is a warning, not a build failure. Why not a failure? Because phantoms can be legitimate during development — a machine might declare emits: ['future-event'] as part of a planned feature. The scanner warns. The developer decides.
In practice, persistent phantoms are code smells. They mean the decorator has a stale declaration that should be removed. The scanner's Section B lists them so they are visible in code review.
Delegated
The machine's decorator declares an event, the machine's own source file has no matching AST site, BUT an adapter file (like app-shared.ts or app-static.ts) does have a matching AST site. The machine declares the intent; the adapter fulfills it.
This is the normal pattern for most events in this codebase (Part V). Pure machines cannot call dispatchEvent — they have no DOM access. The adapter creates a typed bus and dispatches on the machine's behalf. The delegation is intentional and correct.
Example from this codebase:
| ✓ delegated | src/lib/scroll-spy-machine.ts | dispatch | scrollspy-active |
| | scroll-spy-machine.emits declares 'scrollspy-active' |
| | but no direct dispatch in src/lib/scroll-spy-machine.ts |
| | -- delegated via src/app-shared.ts || ✓ delegated | src/lib/scroll-spy-machine.ts | dispatch | scrollspy-active |
| | scroll-spy-machine.emits declares 'scrollspy-active' |
| | but no direct dispatch in src/lib/scroll-spy-machine.ts |
| | -- delegated via src/app-shared.ts |The scanner says: "this machine claims to emit scrollspy-active. I found no dispatch in its own file. But I found a dispatch in src/app-shared.ts. The adapter is dispatching on the machine's behalf. Delegation confirmed."
The Delegation Resolution Algorithm
The resolution happens in a specific order:
Collect all AST sites. Walk every file. Build the full
AstSite[].Classify
src/lib/sites as covered or missing. For each site in a machine file, check the decorator. If declared, covered. If not, missing.Identify phantoms. For each decorator declaration, check if a matching site exists in the machine's own file. If not, tentatively mark as phantom.
Resolve phantoms to delegated. For each phantom, check if a matching site exists in any adapter file (any file NOT in
src/lib/). If yes, change the status from phantom to delegated.
The implementation:
// Step 4: Resolve phantoms to delegated
const adapterSitesByKindEvent = new Map<string, AstSite[]>();
for (const s of sites) {
if (s.file.startsWith('src/lib/')) continue; // only adapter files
const key = `${s.kind}|${s.eventName}`;
const arr = adapterSitesByKindEvent.get(key) ?? [];
arr.push(s);
adapterSitesByKindEvent.set(key, arr);
}
for (const row of rows) {
if (row.status !== 'phantom') continue;
const adapterSites = adapterSitesByKindEvent.get(
`${row.kind}|${row.eventName}`
);
if (adapterSites && adapterSites.length > 0) {
const files = [...new Set(adapterSites.map(s => s.file))].join(', ');
row.status = 'delegated';
row.detail += ` — delegated via ${files}`;
}
}// Step 4: Resolve phantoms to delegated
const adapterSitesByKindEvent = new Map<string, AstSite[]>();
for (const s of sites) {
if (s.file.startsWith('src/lib/')) continue; // only adapter files
const key = `${s.kind}|${s.eventName}`;
const arr = adapterSitesByKindEvent.get(key) ?? [];
arr.push(s);
adapterSitesByKindEvent.set(key, arr);
}
for (const row of rows) {
if (row.status !== 'phantom') continue;
const adapterSites = adapterSitesByKindEvent.get(
`${row.kind}|${row.eventName}`
);
if (adapterSites && adapterSites.length > 0) {
const files = [...new Set(adapterSites.map(s => s.file))].join(', ');
row.status = 'delegated';
row.detail += ` — delegated via ${files}`;
}
}Build a lookup from (kind, eventName) → adapter sites. For each phantom row, check the lookup. If found, promote to delegated and append the adapter file names to the detail string.
The result for this codebase: 11 phantoms resolved to delegated, 0 remaining as true phantoms. Every machine that declares an emission has a corresponding adapter dispatch. The delegation chain is complete.
The Full Section B Output
Here is the actual Section B output, showing both the direct coverage and the delegated phantoms:
Direct coverage (src/lib/ files with matching declarations):
| ✓ covered | src/lib/hot-reload-actions.ts:54 (dispatch) | hot-reload:content |
| ✓ covered | src/lib/hot-reload-actions.ts:60 (dispatch) | hot-reload:toc || ✓ covered | src/lib/hot-reload-actions.ts:54 (dispatch) | hot-reload:content |
| ✓ covered | src/lib/hot-reload-actions.ts:60 (dispatch) | hot-reload:toc |Only two direct coverage rows. The hot-reload module is the only machine that dispatches events from its own file. Every other machine delegates.
Delegated (declared but fulfilled by adapter):
| ✓ delegated | src/lib/page-load-state.ts | dispatch | app-ready |
| ✓ delegated | src/lib/page-load-state.ts | dispatch | toc-headings-rendered |
| ✓ delegated | src/lib/scroll-spy-machine.ts | dispatch | scrollspy-active |
| ✓ delegated | src/lib/scroll-spy-machine.ts | listen | toc-headings-rendered |
| ✓ delegated | src/lib/scroll-spy-machine.ts | listen | toc-animation-done |
| ✓ delegated | src/lib/spa-nav-state.ts | dispatch | toc-headings-rendered |
| ✓ delegated | src/lib/terminal-dots-state.ts | listen | sidebar-mask-change |
| ✓ delegated | src/lib/toc-breadcrumb-state.ts | dispatch | toc-active-ready |
| ✓ delegated | src/lib/toc-breadcrumb-state.ts | dispatch | toc-animation-done |
| ✓ delegated | src/lib/tour-state.ts | listen | app-ready |
| ✓ delegated | src/lib/headings-panel-machine.ts | listen | toc-headings-rendered || ✓ delegated | src/lib/page-load-state.ts | dispatch | app-ready |
| ✓ delegated | src/lib/page-load-state.ts | dispatch | toc-headings-rendered |
| ✓ delegated | src/lib/scroll-spy-machine.ts | dispatch | scrollspy-active |
| ✓ delegated | src/lib/scroll-spy-machine.ts | listen | toc-headings-rendered |
| ✓ delegated | src/lib/scroll-spy-machine.ts | listen | toc-animation-done |
| ✓ delegated | src/lib/spa-nav-state.ts | dispatch | toc-headings-rendered |
| ✓ delegated | src/lib/terminal-dots-state.ts | listen | sidebar-mask-change |
| ✓ delegated | src/lib/toc-breadcrumb-state.ts | dispatch | toc-active-ready |
| ✓ delegated | src/lib/toc-breadcrumb-state.ts | dispatch | toc-animation-done |
| ✓ delegated | src/lib/tour-state.ts | listen | app-ready |
| ✓ delegated | src/lib/headings-panel-machine.ts | listen | toc-headings-rendered |Eleven delegated rows. Each one is a machine that declares an event contract and an adapter that fulfills it. The scanner verified every delegation. Zero missing. Zero orphaned phantoms. The topology is complete.
Section C: Adapter Sites Reference
Section C is a read-only dump. It lists every dispatchEvent and addEventListener call for custom events in the adapter files (app-shared.ts, app-static.ts, app-dev.ts). It does not classify or judge. It shows the raw data.
The purpose is transparency. When a developer reads Section B and sees "delegated via src/app-shared.ts," they can look at Section C to see exactly where in app-shared.ts the delegation happens — the line number, the receiver (window or document), and the enclosing scope chain.
Example rows:
| File:Line | Kind | Event | Receiver | Enclosing chain |
|------------------------|----------|-----------------------|----------|---------------------------------------------------------|
| src/app-shared.ts:892 | dispatch | scrollspy-active | window | function createScrollSpy() > function setup() > ... |
| src/app-shared.ts:986 | listen | toc-headings-rendered | window | function createScrollSpy() > function setup() |
| src/app-shared.ts:1389 | dispatch | sidebar-mask-change | window | const SidebarMask > function emitChange() |
| src/app-shared.ts:2531 | dispatch | toc-animation-done | window | function animateTocStaggered() |
| src/app-shared.ts:2581 | listen | app-ready | window | (top-level) || File:Line | Kind | Event | Receiver | Enclosing chain |
|------------------------|----------|-----------------------|----------|---------------------------------------------------------|
| src/app-shared.ts:892 | dispatch | scrollspy-active | window | function createScrollSpy() > function setup() > ... |
| src/app-shared.ts:986 | listen | toc-headings-rendered | window | function createScrollSpy() > function setup() |
| src/app-shared.ts:1389 | dispatch | sidebar-mask-change | window | const SidebarMask > function emitChange() |
| src/app-shared.ts:2531 | dispatch | toc-animation-done | window | function animateTocStaggered() |
| src/app-shared.ts:2581 | listen | app-ready | window | (top-level) |The enclosingChain column is the lexical scope chain from the call site upward to the nearest named scope. It answers the question "where in this 2500-line file does this call live?" without requiring the reader to open the file and search for the line number.
In the full output, Section C has 26 rows — every custom event dispatch and listen in the three entry files. This is the ground truth that Sections A and B are built from. Section C is the evidence; Section B is the verdict.
How the Enclosing Chain Is Computed
The enclosingChain deserves a closer look because it is the most useful column in Section C for human readers. The algorithm walks upward from the AST node through its parent chain, collecting named scopes:
function enclosingChain(node: ts.Node): string[] {
const chain: string[] = [];
let cur: ts.Node | undefined = node.parent;
while (cur) {
if (ts.isFunctionDeclaration(cur) && cur.name) {
chain.push(`function ${cur.name.text}()`);
} else if (ts.isMethodDeclaration(cur) && cur.name
&& ts.isIdentifier(cur.name)) {
chain.push(`method ${cur.name.text}()`);
} else if (ts.isVariableDeclaration(cur) && ts.isIdentifier(cur.name)) {
chain.push(`const ${cur.name.text}`);
} else if (ts.isPropertyAssignment(cur) && ts.isIdentifier(cur.name)) {
chain.push(`prop ${cur.name.text}`);
} else if (ts.isClassDeclaration(cur) && cur.name) {
chain.push(`class ${cur.name.text}`);
}
cur = cur.parent;
}
return chain.reverse();
}function enclosingChain(node: ts.Node): string[] {
const chain: string[] = [];
let cur: ts.Node | undefined = node.parent;
while (cur) {
if (ts.isFunctionDeclaration(cur) && cur.name) {
chain.push(`function ${cur.name.text}()`);
} else if (ts.isMethodDeclaration(cur) && cur.name
&& ts.isIdentifier(cur.name)) {
chain.push(`method ${cur.name.text}()`);
} else if (ts.isVariableDeclaration(cur) && ts.isIdentifier(cur.name)) {
chain.push(`const ${cur.name.text}`);
} else if (ts.isPropertyAssignment(cur) && ts.isIdentifier(cur.name)) {
chain.push(`prop ${cur.name.text}`);
} else if (ts.isClassDeclaration(cur) && cur.name) {
chain.push(`class ${cur.name.text}`);
}
cur = cur.parent;
}
return chain.reverse();
}The chain reads top-down: const SidebarMask > function emitChange() means "inside the SidebarMask variable declaration, inside the emitChange function." This is enough context to understand where in a large file the call lives. It is not a full stack trace — it omits arrow functions, anonymous closures, and conditional branches. But it captures the named structural landmarks that a developer uses for orientation.
The chain also serves as a change detector. If a dispatch moves from function setup() to function notifyListeners(), the chain changes. The diff of event-map.md shows the chain change. A reviewer notices. This is how the ScrollSpy bug was caught (see "When the Scanner Caught a Real Bug" below).
Adapter File Coverage
Not all adapter files have the same event density. The distribution:
src/app-shared.ts: 11 sites (6 dispatch, 5 listen) — the shared adapter carries the most event wiring because it bridges the most machinessrc/app-static.ts: 8 sites (5 dispatch, 3 listen) — the static-mode entry handles cold boot wiringsrc/app-dev.ts: 7 sites (4 dispatch, 3 listen) — the dev-mode entry handles hot-reload and dev-specific wiring
The sum is 26 sites across three files. This is a manageable density — about 9 event sites per file on average. For context, app-shared.ts is ~2600 lines. 11 event sites in 2600 lines means one event-related call per ~236 lines. Sparse enough that events are not the dominant concern of the file. Dense enough that they need tracking.
Section D: Semantic Gap Candidates
Section D is different from the other sections. It is not generated. It is hand-curated.
A semantic gap is an event that probably should exist in the topology but does not. The scanner cannot detect these automatically — they require domain knowledge. A developer reviews a machine's acceptance criteria and notices: "this machine needs to know when the theme changes, but its decorator says listens: []." That is a semantic gap. The machine should participate in the event topology but currently does not.
Section D maintains a seed list of such candidates:
const SEMANTIC_GAPS: SemanticGap[] = [
// Currently empty — all four initial gaps were closed.
// Grows as future audits surface FSMs whose ACs imply
// event participation but declare emits: [] / listens: [].
];const SEMANTIC_GAPS: SemanticGap[] = [
// Currently empty — all four initial gaps were closed.
// Grows as future audits surface FSMs whose ACs imply
// event participation but declare emits: [] / listens: [].
];The list is currently empty because the initial audit resolved all four gaps. But the structure exists for future audits. When a developer suspects a gap, they add an entry:
{
machine: 'theme-state',
field: 'emits',
events: ['theme-changed'],
why: 'AC requires notifying diagram renderers when the theme switches',
}{
machine: 'theme-state',
field: 'emits',
events: ['theme-changed'],
why: 'AC requires notifying diagram renderers when the theme switches',
}The scanner renders these as a table in the report:
| Machine | Field | Events | Why |
|--------------|--------|---------------|-------------------------------------------|
| theme-state | emits | theme-changed | AC requires notifying diagram renderers || Machine | Field | Events | Why |
|--------------|--------|---------------|-------------------------------------------|
| theme-state | emits | theme-changed | AC requires notifying diagram renderers |Section D is a todo list, not a verification. It grows during design reviews and shrinks during implementation sprints. The scanner includes it in the committed report so that gaps are visible in code review and do not get lost in issue trackers.
The Four Initial Gaps
The seed list was not always empty. When the scanner was first written, it surfaced four gaps:
scroll-spy-machinemissinglistens: ['toc-headings-rendered']— the machine clearly depended on this event (its re-index logic ran after headings rendered), but the decorator did not declare it. The adapter had the listener, but the machine's metadata was incomplete.scroll-spy-machinemissinglistens: ['toc-animation-done']— same pattern. The adapter registered the listener. The decorator was silent.terminal-dots-statemissinglistens: ['sidebar-mask-change']— the adapter inapp-shared.tswired the listener, but the machine's decorator saidlistens: []. The semantic contract was not declared.headings-panel-machinemissinglistens: ['toc-headings-rendered']— a newly extracted machine that listened for heading updates but had not yet been given its decorator metadata.
All four were fixed by adding the missing event names to the respective decorators. Each fix was a one-line change. The scanner confirmed: after the fixes, Section B showed zero missing rows and the four machines appeared as delegated instead of phantom. The seed list was emptied.
The story illustrates the scanner's role during initial adoption. When you first introduce event topology scanning to an existing codebase, the gaps are not bugs — they are documentation debt. The declarations were never written because no tool required them. The scanner makes the debt visible. The developer pays it down incrementally.
Why Hand-Curated?
Section D cannot be automated because semantic gaps are not AST-observable. The scanner can see that a machine has emits: []. It cannot see that the machine's acceptance criteria say "notify downstream components when state changes." The gap between empty metadata and non-empty requirements is a judgment call, not a pattern match.
Automated gap detection would require either: (a) parsing acceptance criteria text (natural language processing, unreliable), or (b) maintaining a formal mapping from requirements to expected events (which is the requirement tracking system described in Part XI, a heavier tool). Section D is the lightweight alternative: a human reads the requirements, suspects a gap, and records it. The scanner makes the suspicion visible. The developer investigates.
The Commit Gate
The scanner's primary job is not reporting. It is gating. The report is a side effect of the classification. The real output is the exit code.
The logic is three lines:
if (missing.length > 0) {
process.exitCode = 1;
}if (missing.length > 0) {
process.exitCode = 1;
}If any DriftRow has status 'missing', the exit code is non-zero. The pre-commit hook runs the scanner as part of the commit workflow. A non-zero exit code aborts the commit. The developer must resolve the drift before committing.
The gate is binary. Covered, delegated, and phantom rows are all exit-code-zero. Only missing rows fail. This is intentional. Phantoms are warnings — they might be intentional (planned features) or stale (forgotten declarations). Missing rows are errors — they mean the code does something the metadata does not know about, which makes the topology map wrong.
What the Gate Does Not Block
The gate does not block phantoms. A machine can declare emits: ['future-event'] without any dispatch existing yet. The scanner will list it as phantom in Section B, but the commit proceeds. This allows incremental development: declare the event contract first, implement the dispatch later.
The gate does not block semantic gaps. Section D is advisory. Adding an entry to SEMANTIC_GAPS does not fail the build. It just makes the gap visible in the report.
The gate does not block adapter-only events. If adapter code dispatches mermaid-config-ready without any machine declaring it, that is not a missing row — missing rows are about machine-file dispatches without decorator declarations. Adapter-only dispatches appear in Section A and Section C as data points, not violations.
Integration with the Build Pipeline
The scanner depends on data/state-machines.json being current. It reads decorator metadata from the JSON, not from source files directly. This means: run npm run build:state-graph before running the scanner. The pre-commit hook chains them:
build:state-graph— infer transitions, extract graph, render SVGscan-event-topology.ts— walk source, classify drift, write report- If either step returns non-zero, abort the commit
The dependency is one-directional: the scanner reads the graph's output. The graph build does not know the scanner exists. This keeps the pipeline composable — you can run the graph build without the scanner (during development) or the scanner without the graph build (if the JSON is already current).
The Four Invariants
The scanner's classification algorithm implicitly enforces four invariants. Spelling them out makes the contract explicit.
Invariant 1: Every emitted event has at least one listener.
If a machine declares emits: ['x'] and no machine declares listens: ['x'] and no adapter file has an addEventListener('x', ...) call, the event is emitted into the void. Section A makes this visible: the "Declared listeners" column is empty and the "AST sites" column has no listen entries. The scanner does not currently fail the build for this — it is a Section A observation, not a Section B violation. But it is always visible in code review.
Invariant 2: Every listened event has at least one emitter.
If a machine declares listens: ['x'] and no machine declares emits: ['x'] and no adapter file has a dispatchEvent for 'x', the listener waits forever. Same visibility: Section A shows an empty "Declared emitters" column and no dispatch AST sites.
Invariant 3: No undeclared dispatches in src/lib/.
Every dispatchEvent or bus.emit call in a machine file must correspond to an emits declaration in that machine's decorator. This is the "missing" classification. The scanner enforces it with a non-zero exit code. The invariant is: if you dispatch from a machine, you must declare it. Period.
Invariant 4: No phantom without delegation.
Every emits or listens declaration that has no matching AST site in the machine's own file must have a matching AST site in an adapter file. If neither the machine file nor any adapter file fulfills the declaration, the declaration is a true phantom — an orphaned claim with no implementation. The scanner reports it as a warning. Persistent phantoms are investigated during code review.
Invariants 1, 2, and 4 produce warnings. Invariant 3 produces a build failure. The asymmetry is deliberate. Missing declarations are unambiguous errors — the code does something the metadata does not account for. Orphaned declarations and void emissions are ambiguous — they might be intentional (planned features, adapter-owned events) and deserve investigation, not automated rejection.
Why Not Fail on All Four?
A stricter scanner could fail on all four invariants. Every phantom is a failure. Every void emission is a failure. Every eternal listener is a failure. Zero tolerance.
This would be correct in a stable system. But this codebase is under active development. Machines are added incrementally. A developer might add a machine with emits: ['new-event'] in one commit and add the adapter dispatch in the next commit. If the scanner failed on phantoms, the first commit would be blocked until the adapter is ready. The developer would need to implement both in a single commit, which couples two concerns (machine logic and adapter wiring) into one change.
The current policy — fail on missing, warn on phantom — allows incremental development. The machine can be committed with its declaration. The adapter can be committed separately. The phantom warning is visible in code review. The delegation resolves when the adapter lands.
If the project reaches a stable state where no new machines are being added, the policy could tighten. Changing the gate from "fail on missing" to "fail on missing or phantom" is a one-line change in the main() function.
When the Scanner Caught a Real Bug
The theory is clean. The real test is: does the scanner actually catch bugs?
Yes. Here is the story.
During a refactor of the ScrollSpy adapter in app-shared.ts, the bus.emit(ScrollspyActive, { slug, path }) call was moved from the applyState function into a new notifyListeners helper. The refactor was correct — the helper extracted a reusable notification pattern. All tests passed (the E2E tests wait for scrollspy-active, and the event still fires from the adapter).
But the developer also moved a block of code that included the window.addEventListener('toc-animation-done', ...) subscription. The subscription ended up inside a conditional branch that only executed when the machine was in the indexing state. In the cold boot path, the machine was in idle when the subscription code ran. The subscription was skipped. The listener for toc-animation-done was never registered.
The effect: after a breadcrumb animation completed, ScrollSpy did not re-index heading positions. On short pages with few headings, this was invisible — the positions were stable. On long pages with many headings and a scrollbar in the sidebar, the positions drifted by a few pixels after each animation. After three or four SPA navigations, the drift accumulated enough that the ScrollSpy activated the wrong heading for a given scroll position.
The scanner caught it. Not directly — the scanner does not detect conditional subscription (it just checks for the presence of an addEventListener call). But indirectly: the developer, while reviewing the scanner's output before committing, noticed that Section C showed the toc-animation-done listen site at a different line than expected. The enclosing chain showed function notifyListeners() > if (machine.state === 'indexing') instead of the expected function setup(). That triggered a closer look. The conditional was wrong. The subscription should be unconditional.
The fix was a one-line move: pull the addEventListener out of the conditional and back into the setup function. The scanner's next run showed the listen site back in its expected scope chain. The commit went through.
Without the scanner, this bug would have shipped. The E2E tests did not cover the specific scenario (long page, sidebar scrollbar, four consecutive SPA navigations). The type system was happy — the listener was well-typed. The topology map would have shown the edge (the decorator still declared the listener). Only the scanner's Section C — the raw dump of adapter sites with enclosing chains — made the positional anomaly visible.
The timeline:
- Developer makes the refactor. Runs tests. All green.
- Developer runs
npm run build:state-graph(which triggers the scanner). - Scanner regenerates
event-map.md. Exit code 0 (no missing rows). - Developer runs
git diff data/event-map.mdto review the topology change. - The diff shows the enclosing chain change for
toc-animation-done. - Developer inspects the new location. Finds the conditional.
- Developer moves the
addEventListenerback to unconditional scope. - Scanner re-runs. Report returns to the expected shape.
- Developer commits.
Steps 4 through 6 are the critical path. Without the committed report, step 4 does not exist. The developer would have committed after step 3 (all green, exit code 0) and the bug would have shipped.
The lesson: the scanner's value is not just the classification algorithm. It is the committed, diff-able report. When data/event-map.md changes, the diff shows exactly what moved, what was added, and what was removed. Code review catches anomalies that automated classification misses.
A Second Save: The Forgotten Declaration
A simpler case. A developer added a new event listener inside src/lib/headings-panel-machine.ts:
// Inside the machine file — a direct addEventListener call
window.addEventListener('toc-headings-rendered', () => {
// Rebuild the headings list when new headings arrive
rebuildHeadingsList();
});// Inside the machine file — a direct addEventListener call
window.addEventListener('toc-headings-rendered', () => {
// Rebuild the headings list when new headings arrive
rebuildHeadingsList();
});This was unusual — most machines do not call addEventListener directly. The developer was prototyping a shortcut, bypassing the adapter pattern. The scanner caught it immediately:
| ✗ **missing** | src/lib/headings-panel-machine.ts:23 (listen) | toc-headings-rendered |
| | headings-panel-machine.listens must include 'toc-headings-rendered' || ✗ **missing** | src/lib/headings-panel-machine.ts:23 (listen) | toc-headings-rendered |
| | headings-panel-machine.listens must include 'toc-headings-rendered' |Exit code 1. Commit blocked. The developer had two choices: add the event to the decorator's listens array (legitimizing the direct listener), or move the listener to the adapter (following the established pattern). They chose the latter — the direct call was a prototype hack that violated the pure-machine contract. The listener moved to app-shared.ts. The machine's factory received a rebuildHeadingsList callback instead. The scanner's next run showed zero missing rows.
The scanner did not just catch a missing declaration. It caught an architectural violation. The machine was reaching into the DOM — calling window.addEventListener — which breaks the purity contract that makes all 43 machines testable without a browser. The "missing" classification was technically about a metadata gap, but the real value was surfacing a design-level mistake.
The Report as a Diff Target
This is worth emphasizing. data/event-map.md is committed to the repository. It is regenerated on every scanner run. When a developer changes an event, the report changes. The change appears in the commit diff.
A reviewer looking at a PR sees:
- | src/app-shared.ts:892 | dispatch | scrollspy-active | window | function createScrollSpy() > function setup() > function applyState() |
+ | src/app-shared.ts:910 | dispatch | scrollspy-active | window | function createScrollSpy() > function notifyListeners() |- | src/app-shared.ts:892 | dispatch | scrollspy-active | window | function createScrollSpy() > function setup() > function applyState() |
+ | src/app-shared.ts:910 | dispatch | scrollspy-active | window | function createScrollSpy() > function notifyListeners() |The line moved. The enclosing chain changed. The reviewer asks: "why did the dispatch move from applyState to notifyListeners?" That question surfaces the refactoring intent and can catch mistakes.
If the report were not committed — if the scanner only ran as a check and discarded the output — this diff-based review would not exist. The commitment to committing event-map.md turns every event change into a reviewable diff. The map is not just verification. It is documentation that updates itself and presents itself for review.
Summary
The topology scanner is a three-hundred-line TypeScript script that reads every source file, walks the AST, and answers one question: do the event contracts in the decorator metadata match the actual code?
The answer comes in four sections:
- Section A (Pivot Table): Every custom event with its declared owners and physical AST sites. The overview.
- Section B (Drift Detection): Four classifications — covered, missing, phantom, delegated. Missing rows fail the build.
- Section C (Adapter Sites): Raw dump of every dispatch and listen in adapter files. The evidence.
- Section D (Semantic Gaps): Hand-curated list of suspected missing events. The todo list.
The scanner enforces four invariants:
- Every emitted event has a listener.
- Every listened event has an emitter.
- No undeclared dispatches in machine files.
- No phantom without delegation.
Invariant 3 is enforced with a non-zero exit code — the commit gate. Invariants 1, 2, and 4 are enforced with warnings — visible in the report and in code review.
The architecture is pure-core / thin-shell. Every classification function is testable without a filesystem. The CLI shell handles file I/O and exit codes. The report is committed to data/event-map.md and reviewed as part of every PR diff.
The scanner does not replace the type system. The type system catches shape errors (wrong payload, wrong type parameter). The scanner catches topology errors (missing declarations, orphaned phantoms, stale metadata). Together they cover the full surface: the shape of individual events and the structure of the event graph.
The output numbers for this codebase tell the story:
- 42 files scanned (2 entry files + 40 machine/event files)
- 31 custom event AST sites (17 dispatch, 14 listen)
- 9 distinct event names
- 2 direct coverage rows (hot-reload-actions, the only machine that dispatches directly)
- 11 delegated rows (all resolved, zero true phantoms)
- 0 missing rows (the gate passes)
The scanner runs in under two seconds. The output is a 90-line markdown file. The cost-benefit ratio is lopsided: minimal execution time, minimal output size, maximum protection against drift.
The next part shifts from verification to generation. The three-phase build pipeline starts with Phase 1: inferring transitions from source code. Where the scanner reads decorators and verifies them against code, the inferrer reads code and generates decorators from it. The scanner is the auditor. The inferrer is the author. Together they form a closed loop where the source code and the metadata are always in agreement.
Continue to Part VIII: Phase 1 — Inferring Transitions from Source Code →