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 VII: Closing the Gaps — Event Topology Beyond Drift Detection

Parts I–VI built a closed loop for requirements traceability: Feature → AC → test → source file, every link AST-inferred, every gap mechanically detected. This part turns the same lens on a different axis: runtime communication — the custom DOM events that finite state machines use to coordinate across module boundaries.

This site runs 36 FSMs under src/lib/, each declared with a @FiniteStateMachine decorator that carries metadata — states, transitions, and crucially, emits and listens arrays naming the custom events the machine participates in. A build-time scanner (scan-event-topology.ts) cross-references those declarations against the actual dispatchEvent and addEventListener calls it finds by walking the AST. When a machine dispatches an event it hasn't declared, the build fails. That's the event drift detector described in Part III.

The scanner produces three committed artefacts. data/event-map.md is the human-readable report — a pivot table of every custom event with its declared emitters, declared listeners, and the AST sites where the dispatch or listen actually lives in code:

Event Declared emitters Declared listeners AST sites
app-ready page-load-state tour-state dispatch: src/app-dev.ts:258, src/app-static.ts:831 — listen: src/app-shared.ts:2581
toc-headings-rendered page-load-state, spa-nav-state scroll-spy-machine dispatch: src/app-dev.ts:844, … — listen: src/app-shared.ts:986, …
sidebar-mask-change terminal-dots-state dispatch: src/app-shared.ts:1389 — listen: src/app-shared.ts:1501

Below that, Section B flags drift (dispatches without matching declarations — build-breaking) and phantoms (declarations without matching dispatches — warnings). data/state-machines.json holds the raw decorator metadata for all 36 machines. data/fsm-composition.json holds the D3-ready graph of nodes and edges — the artefact that gained the most from the work described here.

The drift detector worked. It scanned 42 files, tracked 9 custom events, and caught undeclared dispatches. But its world model was deliberately narrow: it knew about events (declared in decorators, dispatched in code) and machines (files in src/lib/ carrying @FiniteStateMachine). Everything outside that model — three coordinator files totalling 2,928 lines of inter-FSM orchestration, all factory composition edges between machines, phantom delegation chains, feature-event cross-references, lifecycle scope — was invisible to the graph. Not because the scanner was broken, but because it was designed for drift detection, and drift detection doesn't need composition data.

Fixing the five blind spots led to a deeper insight: the right intervention wasn't five new detectors — it was extracting coordination logic into files the scanner already knew how to read. The structural improvement was the visibility improvement.

The five gaps

The scanner reads data/state-machines.json for declared metadata — what each FSM says it emits and listens — then walks the AST of every file under src/lib/ and the three entry files (app-shared.ts, app-dev.ts, app-static.ts) looking for actual dispatchEvent and addEventListener calls. It cross-references the two and flags mismatches.

The data model that drives this is minimal:

interface MachineDeclaration {
  id:      string;
  name:    string;
  file:    string;
  emits:   string[];
  listens: string[];
}

This is what the scanner knows about each machine. States, transitions, features, composition — none of that is visible here. The scanner sees a machine as a bag of event names, nothing more.

That's fine for event drift detection. But it means five categories of information are invisible to the graph.

Gap 1: Factory composition. button-tooltip-state.ts delegates its entire implementation to tooltip-state.ts via a factory import:

import { createTooltipMachine } from './tooltip-state';
import type { TooltipCallbacks, TooltipConfig, TooltipMachine } from './tooltip-state';

The scanner doesn't know this. button-tooltip-state and tooltip-state appear as independent machines in the graph, with no edge between them. A change to tooltip-state could break both consumers — button-tooltip-state and toc-tooltip-state — but the graph doesn't show the dependency.

Gap 2: Adapter dispatch ownership. page-load-state declares emits: ['app-ready', 'toc-headings-rendered'] in its decorator, but no dispatchEvent call exists in the machine's own file. The actual dispatch happens in app-dev.ts and app-static.ts — the adapter dispatches on the machine's behalf. The scanner marks these as "phantom" events with a vague "usually OK" note, but doesn't verify that a matching adapter dispatch actually exists.

Gap 3: Feature-AC-event cross-reference. Each machine can link to a Feature and acceptance criterion via feature: { id: 'PAGE-LOAD', ac: 'fullLifecycle' } in its decorator. But there's no report showing which feature-linked machines participate in the event graph and which don't. If a machine claims to implement PAGE-LOAD.fullLifecycle but declares emits: [], is that correct? Maybe. But nobody's asking the question.

Gap 4: Coordinator invisibility. Three files orchestrate multiple FSMs together — the theme switcher (1213 lines), the guided tour (1019 lines), and the state-machines explorer (696 lines). Together, 2928 lines of inter-FSM coordination. The scanner doesn't see any of it because these files live outside src/lib/ and don't carry @FiniteStateMachine decorators. They're invisible to the graph.

Gap 5: Scope declaration. Is a machine instantiated once for the page lifetime (singleton), once per host component (scoped), or freshly per use (transient)? The answer matters for composition — two singletons sharing a dependency is fine, but a transient instantiating a singleton is a bug. No mechanism existed to declare this.

Diagram
Before the fix, the scanner saw 36 decorated machines and 3 adapter files — but three coordinator files (2928 lines total), all machine-to-machine factory imports, and the feature-event cross-reference were invisible.

These five gaps share a root cause: the scanner's world model is too narrow. It knows about events (declared in decorators, dispatched in code) and machines (files with @FiniteStateMachine in src/lib/). Everything outside that model — structural composition, inter-file coordination, delegation chains, lifecycle scope — is invisible. Not because the scanner is badly written, but because the model was designed for event drift detection, and drift detection doesn't need composition or coordination data.

The naive response is to extend the model: add five detectors, one per gap, each with its own AST heuristics. The coordinator detector would walk IIFE bodies and module exports. The composition detector would match import { createX } from './x-state' patterns. The phantom resolver would cross-reference adapter dispatch sites. Each detector would be correct for the patterns it was designed to recognize, and blind to the next pattern someone introduces.

The question was: do we build five special-purpose detectors, one per gap? Or is there a single intervention that closes them all?

The decorator is the format

To answer that, I needed to understand why the three coordinator files were invisible. They all orchestrate FSMs, but they use three different structural patterns.

theme-switcher.ts uses an IIFE — an immediately-invoked function expression that creates a closure:

const ThemeSwitcher = (() => {
  const STORAGE_COLOR = 'cv-color-mode';
  // ... 1200 lines of DOM wiring + FSM orchestration
})();

tour.ts uses a module object pattern — functions defined at module scope, then assembled into an export:

const TourModule = { init, start, isActive: () => mgr !== null && mgr.isActive() };
export default TourModule;

state-machines-explorer.ts uses a plain exported function:

export function initStateMachinesExplorer(): void {
  const container = document.getElementById('state-machines-explorer');
  if (!container) return;
  // ... 640 lines
}

The existing adapter extractor (extractAdaptersFromSource) only handles the IIFE pattern — it looks for const X = (() => { ... })() or plain objects with init/create/attach/mount methods. The module object and plain function patterns are invisible to it.

The naive approach would be to extend the detector: teach it to recognize module objects and exported functions too. But each pattern requires its own heuristics, its own body-walking logic, its own edge cases. The IIFE detector is already 80 lines. Adding two more patterns would triple the complexity — and the next coordinator someone writes might use a fourth pattern.

The @FiniteStateMachine decorator solves the problem differently. Instead of teaching the scanner to read every possible file structure, make the file declare itself in a format the scanner already knows.

Here's what the decorator looks like on the theme coordinator:

@FiniteStateMachine({
  states: ['idle', 'palette-open', 'previewing'] as const,
  events: ['openPalette', 'closePalette', 'hoverSwatch', 'leaveSwatch', 'cycleDiagramMode'] as const,
  transitions: [
    { from: 'idle',          to: 'palette-open', on: 'openPalette' },
    { from: 'palette-open',  to: 'previewing',   on: 'hoverSwatch' },
    { from: 'previewing',    to: 'palette-open',  on: 'leaveSwatch' },
    { from: 'palette-open',  to: 'idle',          on: 'closePalette' },
    { from: 'previewing',    to: 'idle',          on: 'closePalette' },
  ] as const,
  description: 'Orchestrates palette and preview and diagram mode and tooltip coordination.',
  feature: { id: 'THEME-COORD', ac: 'coordinatesPalettePreview' } as const,
  scope: 'singleton',
})
export class ThemeCoordinatorFsm {}

Everything the scanner needs is in one AST node. emits, listens, feature, scope, states, transitions — all declared, all statically extractable. The property-reading loop in the extractor is ten lines:

for (const prop of arg0.properties) {
  if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue;
  const propName = prop.name.text;
  const val = unwrapAsConst(prop.initializer);

  if (propName === 'states')   states.push(...readStringArray(val));
  if (propName === 'events')   events.push(...readStringArray(val));
  if (propName === 'emits')    emits.push(...readStringArray(val));
  if (propName === 'listens')  listens.push(...readStringArray(val));
  if (propName === 'guards')   guards.push(...readStringArray(val));

No heuristics, no body-walking, no pattern matching on file structure. The decorator is the contract between the human author and the machine reader. The human declares the metadata; the machine reads it from a single, predictable AST shape.

Diagram
The decorator replaces 200+ lines of heuristic body-walking with a single AST node read. The scanner doesn't need to understand file structure — it just reads the decorator.

This led to the core insight: the refactoring and the gap fix are the same thing. Extracting coordination logic from the DOM shell into a decorated src/lib/ file doesn't just make the code testable — it makes the scanner see it. There's no separate "fix the scanner" step. The structural improvement is the visibility improvement.

The human/machine boundary

But the decorator can lie. A machine can declare emits: ['app-ready'] and never dispatch it. Or it can dispatch toc-headings-rendered without declaring it. The declaration is a human statement of intent, not a compiled contract.

This is where the drift scanner comes in. It cross-references the decorator's claims against the AST evidence:

Situation Decorator says Code does Scanner verdict
Correct emits: ['X'] dispatchEvent('X') covered
Omission nothing dispatchEvent('X') missing — build fails
Over-declaration emits: ['X'] nothing phantom — warning
Semantic error emits: ['X'] (means listen) human judgment

The scanner catches the dangerous case — undeclared dispatches — by failing the build. Over-declarations get a warning. Semantic errors (declaring an emit when you mean a listen) remain in the human domain. No amount of tooling can verify the meaning of a declaration, only its consistency with the code.

This pattern repeats at every layer of the system:

Layer Human declares Machine verifies Machine cannot verify
Features / ACs Abstract AC methods Every AC has a @Verifies test ACs cover all behavior
FSM events emits / listens in decorator Drift scanner: declaration matches AST All communication is declared
FSM states states in decorator Transitions assign declared states States cover all behavior
Coverage Files under quality gate vitest v8 measures execution Code is correct
Mutation Stryker mutates source Assertions catch mutants All mutations are meaningful

Each layer adds machine verification on a human declaration. None proves the declaration complete — that's judgment, not automation. Attempting to type-check events (e.g., a union type AppEvent = 'app-ready' | 'toc-headings-rendered') would duplicate the drift scanner's job at compile time and create a central coupling point that all FSMs import. The drift scanner already provides the same guarantee at build time, without the coupling.

The phantom-to-delegated resolution we added makes this boundary clearer. Previously, phantom events got a vague "usually OK" message. Now the scanner actively verifies: is there a matching dispatch in an adapter file?

// Resolve phantoms to delegated: if an adapter file dispatches/listens the
// same event, the machine's declaration is fulfilled by the adapter.
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}`;
  }
}

A "delegated" event is one where the machine declares intent (emits: ['app-ready']), the machine doesn't dispatch it directly (no dispatchEvent in its own file), but an adapter file does dispatch it on the machine's behalf. The scanner verifies the delegation chain instead of shrugging.

This distinction matters because phantom events were the most common source of false confidence. Before the fix, a developer reading the drift report saw 11 phantom warnings and learned to ignore them — "usually OK" is not actionable. After the fix, delegated events are positively verified: the scanner found matching AST evidence in the adapter layer. True phantoms — declarations with no matching dispatch anywhere — are a genuine code smell worth investigating: either the dispatch was removed and the declaration is stale, or the dispatch happens through an indirection the AST can't trace.

The broader point is that each verification layer in the stack operates on the same principle: the human writes a declaration, the machine checks that the declaration is consistent with the code. The machine never checks whether the declaration is complete — that's the human's responsibility. Attempting to automate completeness checking leads to either false positives (flagging intentional omissions) or an infinite regress (who checks the completeness checker?). The right boundary is consistency, not completeness.

Diagram
Each layer adds a machine check on a human declaration. The drift scanner sits between coverage and compliance, verifying that FSM event declarations match the code. No layer proves completeness — the top of the stack is always human judgment.

Regex vs AST

Before fixing the gaps, I had to fix a DRY violation. The event topology system had two independent extraction paths for the same data.

scripts/lib/event-topology.ts extracted emits and listens from @FiniteStateMachine decorators using regex:

export function extractEmits(source: string): string[] {
  const out = new Set<string>();
  const re = /@FiniteStateMachine\s*\(\s*\{([\s\S]*?)\}\s*\)/g;
  let m: RegExpExecArray | null;
  while ((m = re.exec(source)) !== null) {
    const body = m[1] ?? '';
    const emitsMatch = /emits\s*:\s*\[([\s\S]*?)\]/.exec(body);
    if (!emitsMatch || emitsMatch[1] === undefined) continue;
    for (const s of emitsMatch[1].matchAll(/['"]([^'"]+)['"]/g)) {
      if (s[1] !== undefined) out.add(s[1]);
    }
  }
  return Array.from(out).sort();
}

Meanwhile, scripts/lib/state-machine-extractor.ts extracted the same data via the TypeScript compiler API — proper AST parsing with ts.createSourceFile. Two paths, one truth.

The regex approach worked as long as the decorator object was flat. But @FiniteStateMachine decorators contain nested objects — transitions: [{ from: 'idle', to: 'loading', on: 'start' }] — and the non-greedy ([\s\S]*?)\} pattern terminates at the first closing brace it encounters. On a decorator with transitions, the regex would match the closing brace of the first transition object, not the decorator's own closing brace. The extraction would silently return garbage — or nothing.

The fix was to delete the regex entirely and delegate to the AST extractor:

export function extractEmitsFromSource(source: string): string[] {
  const sf = parseSource('<inline>', source);
  const out = new Set<string>();

  function visit(node: ts.Node): void {
    const decorators = ts.canHaveDecorators(node) ? ts.getDecorators(node) : undefined;
    if (decorators) {
      for (const dec of decorators) {
        if (!ts.isCallExpression(dec.expression)) continue;
        if (!ts.isIdentifier(dec.expression.expression)
            || dec.expression.expression.text !== 'FiniteStateMachine') continue;
        const arg0 = dec.expression.arguments[0];
        if (!arg0 || !ts.isObjectLiteralExpression(arg0)) continue;
        for (const prop of arg0.properties) {
          if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue;
          if (prop.name.text !== 'emits') continue;
          const val = unwrapAsConst(prop.initializer);
          if (ts.isArrayLiteralExpression(val)) {
            for (const el of val.elements) {
              if (ts.isStringLiteral(el)) out.add(el.text);
            }
          }
        }
      }
    }
    ts.forEachChild(node, visit);
  }

  visit(sf);
  return Array.from(out).sort();
}

event-topology.ts was rewritten to a thin wrapper that delegates to these AST functions. The public API (extractEmits, extractListens, detectDrift, renderEventMap, regenerateEventMap) didn't change — all 12 existing tests passed immediately.

Three edge-case tests were added to prove the AST handles what the regex couldn't:

@Verifies<EventTopologyFeature>('astHandlesNestedDecorators')
'extracts emits from decorator with nested transitions objects'() {
  // This pattern broke the old regex (non-greedy `}` match ended at the
  // closing brace of the first transition object, not the decorator).
  const src = `
    @FiniteStateMachine({
      states: ['idle', 'loading'] as const,
      transitions: [
        { from: 'idle', to: 'loading', on: 'start' },
        { from: 'loading', to: 'idle', on: 'done' },
      ] as const,
      emits: ['data-ready'] as const,
    })
    class LoaderFsm {}
  `;
  expect(extractEmits(src)).toEqual(['data-ready']);
}

This test would fail with the regex approach — the pattern would match the } after on: 'start' and never reach emits.

The broader lesson is that regex and AST serve different purposes. Regex excels at pattern matching in unstructured text — log files, configuration formats, flat key-value pairs. But a TypeScript decorator is a tree structure: an expression containing an object literal containing property assignments containing nested object literals. The regex ([\s\S]*?) tries to flatten a tree into a string match, and it fails the moment the tree has depth. The TypeScript compiler API handles depth naturally — ts.isObjectLiteralExpression, ts.isArrayLiteralExpression, recursive ts.forEachChild — because it was designed to walk trees, not match strings.

The DRY violation here was not just code duplication — it was an architectural mismatch. Two extraction paths meant two maintenance surfaces, two sets of bugs, and two different failure modes. The regex path would silently return empty arrays on complex decorators; the AST path would correctly extract them. Tests passing on simple fixtures masked the divergence. Unifying on AST eliminated the failure mode entirely.

Diagram
Before the fix, two independent extraction paths parsed the same decorator data — one with fragile regex, one with proper AST. After the fix, a single AST-based extraction path serves both callers.

The coordinator extraction

With the DRY violation fixed, the main work was extracting coordination logic from the three DOM shells into decorated src/lib/ files.

The analysis revealed that the "pure orchestration" in each file is thinner than expected. The FSM machines themselves already contain the state logic — createPaletteMachine handles palette open/close transitions, createTourManager handles step progression, createExplorerSelection handles hover/click state. What the coordinator files do is mostly DOM wiring: querySelector, classList, addEventListener, style.top = .... The inter-FSM coordination — "when palette opens, tell preview the baseline accent" — is a small fraction of each file.

This is actually a good sign. It means the FSM extraction done previously was thorough. The coordinators are genuinely thin orchestration layers, not hidden state machines.

Theme coordinator (src/lib/theme-coordinator.ts) captures two coordination patterns:

  1. Palette-preview sync: when the palette opens, the preview machine receives the current accent as its baseline. When the palette closes without a commit, the preview reverts. When the user hovers a swatch, the preview updates.
  2. Diagram mode-tooltip sync: when the diagram mode cycles, if the button tooltip is currently visible, push the updated mode label to it.
export function createThemeCoordinator(deps: ThemeCoordinatorDeps): ThemeCoordinator {
  const { preview } = deps;

  function onPaletteOpen(baseline: AccentKey): void {
    preview.open(baseline);
  }

  function onPaletteClose(): void {
    preview.close();
  }

  function onSwatchHover(accent: AccentKey): void {
    preview.hover(accent);
  }

  function onSwatchLeave(): void {
    preview.leave();
  }

  function shouldUpdateTooltip(
    tooltipState: string,
    newModeLabel: string,
  ): string | null {
    if (tooltipState === 'visible' || tooltipState === 'leaving') {
      return newModeLabel;
    }
    return null;
  }

  return {
    onPaletteOpen, onPaletteClose,
    onSwatchHover, onSwatchLeave,
    shouldUpdateTooltip,
  };
}

No DOM. No querySelector. No classList. Pure coordination — testable without a browser.

Explorer coordinator (src/lib/explorer-coordinator.ts) captures the reset cascade and the selection-detail coupling that were buried in DOM event handlers:

function resetAll(): void {
  filter.reset();
  selection.clear();
  detail.close();
  popover.close();
}

function computeVisibleIds(nodes: ReadonlyArray<FilterableNode>): Set<string> {
  const visible = new Set<string>();
  for (const node of nodes) {
    if (filter.matches(node)) visible.add(node.id);
  }
  return visible;
}

Previously, resetAll was a click handler on the reset button in state-machines-explorer.ts, interleaved with DOM cleanup (filterText.value = '', checkbox resets, viewport reset). Now the FSM coordination is isolated and testable; the DOM cleanup stays in the shell.

Tour coordinator (src/lib/tour-coordinator.ts) wraps the TourTocOrchestrator with a simpler interface. The test proves the orchestration works without DOM:

@Verifies<TourCoordinatorFeature>('coordinatesTocDuringTour')
'onTourBegin returns ops to close all open sections'() {
  const tocOrch = createTourTocOrchestrator();
  const coord = createTourCoordinator({ tocOrchestrator: tocOrch });
  const result = coord.onTourBegin({ openSectionIds: ['sec-a', 'sec-b'] });
  expect(result.tocOps.toClose).toEqual(['sec-a', 'sec-b']);
  expect(result.tocOps.toOpen).toEqual([]);
}

No document.querySelectorAll('.toc-section.open'). No section.classList.remove('open'). The coordinator returns a TocOps object — { toOpen, toClose } — and the DOM shell applies it. The coordination logic is tested in isolation; the DOM wiring is tested by Playwright.

What stayed in the DOM shells? Everything that touches the browser. A concrete example from theme-switcher.ts — the palette opening sequence:

onStateChange: (state: PaletteState, _prev: PaletteState) => {
  if (state.open) {
    container.classList.add('open');
    const triggerBtn = document.getElementById('btn-color-theme');
    if (triggerBtn) {
      const rect = getZoomAdjustedRect(triggerBtn);
      container.style.top = (rect.bottom + 4) + 'px';
      container.style.left = rect.left + 'px';
    }
    updatePaletteActiveState(getStoredAccent());
    previewMachine?.open(getStoredAccent());
    const first = container.querySelector('.palette-swatch') as HTMLElement | null;
    if (first) first.focus();
  } else {
    container.classList.remove('open');
    previewMachine?.close();
  }
},

This callback mixes two concerns: DOM manipulation (classList.add, style.top, querySelector, focus) and inter-FSM coordination (previewMachine?.open, previewMachine?.close). The coordinator extracts the second concern. The first stays — classList, style, focus, querySelector are inherently DOM-bound. Extracting them would mean injecting document as a dependency, which adds complexity without adding testability.

The DOM shell is the adapter in the hexagonal sense. It doesn't need to be pure — it needs to be thin. The coordinator extraction thinned it by removing the inter-FSM logic that was interleaved with DOM calls. What remains is the irreducible DOM wiring: element creation, style application, event handler registration, scroll positioning, focus management, localStorage reads/writes, Mermaid re-initialization. The Playwright e2e tests cover this layer; unit tests don't need to.

An important nuance: the coordinator files don't replace the DOM shells — they complement them. theme-switcher.ts still exists, still creates the palette DOM, still registers click handlers. But now it calls coordinator.onPaletteOpen(accent) instead of directly calling previewMachine.open(accent). The indirection is one function call deep, and it makes the coordination testable without a browser. That's the entire value proposition — one layer of indirection, one gain in testability, zero loss in functionality.

Composition edges

With the coordinators in src/lib/, the graph extractor sees them — but it doesn't know they compose other machines. A coordinator that imports createPaletteMachine from ./accent-palette-state appears as an independent machine, not as a machine that depends on another.

The fix is a new edge kind: composes. The extractor scans import declarations in machine source files via AST:

export function extractMachineCompositions(
  sources: readonly LibSource[],
  machines: ReadonlyArray<MachineNode>,
): GraphEdge[] {
  const machineNames = new Set(machines.map(m => m.name));
  const edges: GraphEdge[] = [];

  for (const src of sources) {
    const machine = machines.find(m => m.file === src.relPath);
    if (!machine) continue;
    const sf = parseSource(src.relPath, src.text);

    ts.forEachChild(sf, node => {
      if (
        ts.isImportDeclaration(node) &&
        ts.isStringLiteral(node.moduleSpecifier)
      ) {
        const spec = node.moduleSpecifier.text;
        if (spec.startsWith('./') && spec !== './finite-state-machine') {
          const target = spec.replace('./', '');
          if (machineNames.has(target) && target !== machine.name) {
            edges.push({
              from: machine.id,
              to:   'machine:' + target,
              kind: 'composes',
            });
          }
        }
      }
    });
  }
  return edges;
}

Simple: for each machine source file, check its import declarations. If it imports from another machine's file (excluding the finite-state-machine decorator definition), emit a composes edge. No heuristics needed — import declarations are the most reliable signal in TypeScript. They're explicit, statically analyzable, and impossible to miss in the AST (every import is a top-level ImportDeclaration node).

The function ignores type-only imports automatically: import type { TooltipMachine } doesn't create an ImportDeclaration with a module specifier that differs from import { createTooltipMachine }. Both use the same module specifier './tooltip-state'. The function detects the edge at the module level, not the symbol level — if any import (value or type) comes from a sibling machine file, it's a composition relationship. This is slightly over-broad (a pure type import is a weaker signal than a factory import), but in practice every machine-to-machine import in this codebase involves factory delegation, not just type reuse.

This creates a problem: a machine-to-machine pair might appear as both composes (from import scanning) and imports (from adapter detection, if an adapter happens to reference both). Edge deduplication resolves this with a priority system:

const EDGE_PRIORITY: Record<GraphEdge['kind'], number> = {
  composes: 2, event: 1, imports: 0,
};
const edgeMap = new Map<string, GraphEdge>();
for (const e of edges) {
  const key = `${e.from}-${e.to}:${e.eventName ?? ''}`;
  const existing = edgeMap.get(key);
  if (!existing || EDGE_PRIORITY[e.kind] > EDGE_PRIORITY[existing.kind]) {
    edgeMap.set(key, e);
  }
}
const dedupedEdges = Array.from(edgeMap.values());

composes wins over imports because it carries more semantic information: the machine doesn't just reference the other — it delegates to it.

The graph output format changed too. Instead of a Mermaid diagram (which would be illegible with 54 nodes and 19 edges), the build pipeline now produces data/fsm-composition.json — a D3-ready JSON graph:

export interface CompositionNode {
  id:       string;
  name:     string;
  kind:     'machine' | 'adapter';
  file:     string;
  feature?: { id: string; ac: string };
  scope?:   string;
  emits?:   string[];
  listens?: string[];
}

export interface CompositionEdge {
  from:  string;
  to:    string;
  kind:  string;
  label: string;  // always present: event name or edge kind
}

export interface CompositionGraph {
  generatedAt: string;
  nodes:       CompositionNode[];
  edges:       CompositionEdge[];
}

Every edge carries a label — either the event name (for event edges) or the edge kind (for composes and imports edges). This makes the graph renderable without a legend.

Diagram
The tooltip composition chain — button-tooltip-state and toc-tooltip-state both delegate to the generic tooltip-state factory. Event edges connect page-load-state to tour-state via the app-ready custom event.
Diagram
Coordinator fan-out — the theme coordinator orchestrates two accent machines, while the explorer coordinator orchestrates four sub-machines. Each composes edge represents a factory import that was previously invisible to the graph.

Mutation testing

Coverage gates prove the code is executed. The site has strict thresholds — 98% statements and functions, 95% branches on src/lib/. But coverage doesn't prove the tests are meaningful. A test that calls a function and asserts nothing passes coverage just fine:

'calls the function'() {
  slugify('Hello World');
  // no assertion — 100% coverage, 0% confidence
}

Mutation testing addresses this by injecting small changes (mutants) into the source code and checking whether at least one test fails for each mutant. If a mutant survives (no test catches it), the assertion suite has a blind spot.

Stryker applies dozens of mutation operators: arithmetic (+ to -), conditional (> to >=, && to ||), removal (deleting return statements, removing array elements), string mutations (emptying string literals), and more. Each operator produces a variant of the source code with one small, semantically meaningful change. A well-written test suite should kill every mutant — if slugify replaces spaces with dashes, changing - to + in the implementation should cause at least one assertion to fail.

The gap between coverage and mutation score is the gap between "the test executes this line" and "the test would notice if this line did something different." In practice, the most common survivors are:

  • Off-by-one boundary changes (> to >=) in code with no boundary-condition test
  • Boolean negation (!condition to condition) caught only by the happy path
  • Removed early returns in guard clauses that no test triggers
  • String mutations in messages or labels that no assertion checks

Stryker Mutator is the standard tool for JavaScript/TypeScript mutation testing. The integration uses the vitest runner, so it works with the existing test setup — same config, same globals, same decorator-based test pattern:

/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
  testRunner: 'vitest',
  mutate: [
    'src/lib/**/*.ts',
    '!src/lib/external.ts',
    '!src/**/*.d.ts',
  ],
  thresholds: { high: 80, low: 60, break: 50 },
  reporters: ['html', 'json', 'clear-text', 'progress'],
  htmlReporter: { fileName: 'reports/mutation/index.html' },
  jsonReporter: { fileName: 'reports/mutation/mutation-report.json' },
  incremental: true,
  incrementalFile: '.stryker-incremental.json',
  vitest: { configFile: 'vitest.config.js' },
  timeoutMS: 10000,
};

Key choices:

  • Incremental mode is on by default. The first run mutates every function in src/lib/ and will take several minutes. Subsequent runs only re-test mutants in changed files — the .stryker-incremental.json cache stores previous results.
  • break: 50 is a starting threshold. After the first run reveals the actual mutation score, this should be tightened to 5-10 points below the baseline to prevent regressions without blocking the initial integration.
  • mutate targets src/lib/ — the same files covered by the 98% line coverage gates. Test files are excluded; Stryker only mutates source code.

The relationship between coverage and mutation testing is complementary, not competitive. Coverage answers "was this line executed during testing?" Mutation testing answers "would the test suite notice if this line behaved differently?" Both questions are necessary; neither is sufficient.

The existing vitest coverage gates at 98% statements guarantee that almost every line in src/lib/ is executed by at least one test. Stryker's mutation score will reveal how many of those executions actually verify behavior. A high coverage score with a low mutation score means the tests are touching code without checking results — tests of omission. A high mutation score with high coverage means the test suite is genuinely assertive: it executes the code and notices when the code changes.

The mutation layer sits on top of the existing verification stack. Coverage proves execution. The compliance report proves every AC has a test. Mutation testing proves the tests actually catch regressions. Each layer narrows the gap between "the code runs" and "the code is correct" — without claiming to close it entirely.

The proof

The numbers:

  • 2737 tests, 148 test files, 100% pass
  • 4 new Features created: FsmCompositionFeature, ThemeCoordinatorFeature, TourCoordinatorFeature, ExplorerCoordinatorFeature
  • 12 new ACs across the new and existing features
  • 6 test files created or updated with @FeatureTest / @Verifies decorators
  • 3 new ACs on StateMachineExtractorFeature: detectsMachineComposition, readsScope, deduplicatesEdges
  • 1 new AC on EventTopologyFeature: astHandlesNestedDecorators

The composition graph (data/fsm-composition.json) contains:

{
  "generatedAt": "2026-04-12T...",
  "nodes": [
    {
      "id": "machine:theme-coordinator",
      "name": "theme-coordinator",
      "kind": "machine",
      "file": "src/lib/theme-coordinator.ts",
      "feature": { "id": "THEME-COORD", "ac": "coordinatesPalettePreview" },
      "scope": "singleton"
    },
    {
      "id": "machine:tooltip-state",
      "name": "tooltip-state",
      "kind": "machine",
      "file": "src/lib/tooltip-state.ts"
    }
  ],
  "edges": [
    {
      "from": "machine:button-tooltip-state",
      "to": "machine:tooltip-state",
      "kind": "composes",
      "label": "composes"
    },
    {
      "from": "machine:page-load-state",
      "to": "machine:tour-state",
      "kind": "event",
      "label": "app-ready"
    }
  ]
}

54 nodes (11 with event participation, 46 feature-linked), 19 edges (8 event, 11 imports/composes). Every edge carries a label. The JSON is designed for D3 force-directed visualization — filterable by edge kind, zoomable, interactive. A future page on this site could render the graph interactively, letting the reader explore machine composition and event flows without reading a static diagram.

The scope field on coordinator nodes (singleton, scoped, transient) also surfaces in the JSON. This is informational for now — no tooling enforces scope constraints. But the data is there for a future composition validator: a scoped machine composing a transient factory is fine; a transient machine composing a singleton dependency is a potential bug (the transient creates a new instance each time, but the singleton expects exactly one). The scope field makes this kind of analysis possible without re-reading the source.

The event topology report now includes Section E — a cross-reference of every feature-linked machine with its event participation:

Machine Feature AC Emits Listens Note
page-load-state PAGE-LOAD fullLifecycle app-ready, toc-headings-rendered
tour-coordinator TOUR-COORD coordinatesTocDuringTour app-ready
accent-palette-state ACCENT rightClickOpensPalette no event participation

Machines with no event participation get an explicit note. Not an error — most machines communicate via callbacks, not events. But the visibility matters: if a future change adds event dispatch to a "no event participation" machine, the drift scanner will catch the undeclared emission.

Phantom events are now reclassified as "delegated" when a matching adapter dispatch exists. page-load-state declares emits: ['app-ready'] and doesn't dispatch it directly — but app-dev.ts and app-static.ts do, on the machine's behalf. The scanner verifies the delegation chain and marks it as delegated instead of the old phantom warning.

Looking back, the five gaps were symptoms of a single architectural tension: the scanner's world model assumed that all FSM coordination happened inside src/lib/ files with @FiniteStateMachine decorators. That was true for individual machines — each machine is a pure factory in its own file, decorated, testable, visible. But the orchestration layer — how machines interact — lived outside the model. Theme-switcher coordinated six machines. Tour orchestrated three. Explorer wired five. None of them carried decorators. None of them were in src/lib/. The graph was a partial map.

The refactoring was the fix. Not a prerequisite to it — the same thing. Three coordinator files extracted into src/lib/ with @FiniteStateMachine decorators. The scanner sees them automatically. No new detectors, no new heuristics, no new edge kinds beyond composes. The decorator is the format, the AST is the reader, the drift scanner is the verifier, and the human is the author. Each layer does its job.

The total intervention: 3 coordinator files created (~300 lines of pure orchestration), 2 AST wrappers exported (replacing regex), 1 composition function added (30 lines), 1 edge dedup (10 lines), 1 phantom resolver (15 lines), 1 Section E (20 lines), 1 JSON renderer (80 lines), 1 Stryker config (20 lines). Against that: 2737 tests pass, the graph gained 3 coordinator nodes and their composition edges, phantom events are verified, and the mutation testing layer is ready to run.

The gaps are closed. The graph is complete — or rather, as complete as a human-declared, machine-verified system can be. The next gap will be found by the next developer who reads the drift report and notices something the scanner can't explain. That's not a bug. That's the system working as designed.

⬇ Download