Forward: Feature to Files
The simplest question the BindingsManifest answers: which source files does Feature X claim?
Before the AST scanner, this required a sourceFiles[] array on each Feature class — maintained by hand. After the scanner, it is a derived computation:
function deriveFilesFromManifest(manifest: BindingsManifest, featureId: string): string[] {
const acs = manifest[featureId];
const files = new Set<string>();
for (const targets of Object.values(acs ?? {})) {
for (const t of targets) files.add(t.file);
}
return [...files].sort();
}function deriveFilesFromManifest(manifest: BindingsManifest, featureId: string): string[] {
const acs = manifest[featureId];
const files = new Set<string>();
for (const targets of Object.values(acs ?? {})) {
for (const t of targets) files.add(t.file);
}
return [...files].sort();
}For the ACCENT feature, this returns three files — src/lib/accent-palette-state.ts, src/lib/accent-preview-state.ts, src/lib/accent-derive.ts — all inferred from what the accent tests actually import and call. No human told the system about accent-derive.ts. The test for accentAdaptsDarkLight imported deriveAccentPalette from it, and the scanner picked it up.
The compliance report uses this to compute runtime coverage: vitest v8 instruments these three files, and the report shows src 100% (3 files) — confirming that the code the feature claims is fully exercised.
Reverse: File to Features
The more powerful question is the inverse: which features will break if I touch this file?
Inverting the manifest is a single pass:
const fileToFeatures = new Map<string, Set<string>>();
for (const [featureId, acs] of Object.entries(manifest)) {
for (const targets of Object.values(acs)) {
for (const { file } of targets) {
if (!fileToFeatures.has(file)) fileToFeatures.set(file, new Set());
fileToFeatures.get(file)!.add(featureId);
}
}
}const fileToFeatures = new Map<string, Set<string>>();
for (const [featureId, acs] of Object.entries(manifest)) {
for (const targets of Object.values(acs)) {
for (const { file } of targets) {
if (!fileToFeatures.has(file)) fileToFeatures.set(file, new Set());
fileToFeatures.get(file)!.add(featureId);
}
}
}Impact analysis becomes a lookup: "I'm about to refactor src/lib/scroll-spy-machine.ts — which features are at risk?" Answer: SPY, NAV, TOC-SCROLL — every feature whose tests import symbols from that file.
This changes the daily workflow. Before: "I changed 3 files, I run the full suite of 2,642 tests and hope." After: work trace impact --diff tells me which features are impacted and which test files to rerun.
Orphan Detection: Code No One Claims
The reverse query reveals an unexpected bonus: orphan detection. An orphan is a source file that vitest v8 instruments for coverage but no feature claims through the manifest. It is code that exists, gets executed during tests, but has no requirement responsible for it.
Before the AST scanner: 53 orphan files. Helpers extracted during refactoring that no one wired to a feature. Utility modules copy-pasted and forgotten. Dead code that tests still touched accidentally. The compliance report showed them in yellow:
── Source files not claimed by any feature ──
src/lib/old-search-scorer.ts lines 87%
scripts/lib/unused-svg-helper.ts lines 100%
src/lib/deprecated-nav-utils.ts lines 42%
... 50 more── Source files not claimed by any feature ──
src/lib/old-search-scorer.ts lines 87%
scripts/lib/unused-svg-helper.ts lines 100%
src/lib/deprecated-nav-utils.ts lines 42%
... 50 moreAfter the AST scanner closed the loop: 0 orphans. Every instrumented file is either claimed by a feature (because a @Verifies test imports it) or excluded from coverage (because it is a thin adapter shell, not a testable core). The orphan count is the most direct measure of "does every piece of code have a business reason to exist?"
The orphan detector costs nothing to build — it falls out of the intersection between "files vitest instruments" and "files the manifest claims." If the intersection has gaps, those gaps are orphans. The system detects dead code as a side effect of tracing requirements.
work trace: The Query Engine
The BindingsManifest contains all the data, but reading a JSON file is not a workflow. The trace engine — scripts/cli/commands/lib/trace-core.ts — exposes eight sub-commands through the work trace CLI:
work trace file <path> File -> features it implements
work trace feature <id> Feature -> files that implement it
work trace file-tests <path> File -> tests that cover it
work trace feature-tests <id> Feature -> tests that verify it
work trace impact [files...] Impacted features + tests to rerun
work trace hotspots Files touching the most features
work trace fragile Features depending on the most files
work trace fsm [name] FSM -> feature/AC it is linked towork trace file <path> File -> features it implements
work trace feature <id> Feature -> files that implement it
work trace file-tests <path> File -> tests that cover it
work trace feature-tests <id> Feature -> tests that verify it
work trace impact [files...] Impacted features + tests to rerun
work trace hotspots Files touching the most features
work trace fragile Features depending on the most files
work trace fsm [name] FSM -> feature/AC it is linked toThe architecture follows the same SOLID principles as the rest of the project. A TraceDataPort abstraction loads the manifest, features, test refs, and FSM links. A TraceFormatter abstraction provides formatting methods. The core is pure functions — eight query functions, eight render functions, zero I/O.
The TraceIndex — seven ReadonlyMap instances — is built once in three passes from the loaded data:
- Manifest walk — builds
featureToFiles(forward) andfileToFeatures(reverse) - TestRef walk — builds
featureToTests, resolving class names to feature IDs - Join — derives
fileToTestsby collecting each file's features' tests
A lifecycle FSM (createTraceIndexMachine) guards against querying before the index is ready. The FSM itself is decorated with @FiniteStateMachine and linked to the TRACE feature:
@FiniteStateMachine({
states: ['idle', 'loading', 'ready', 'error'] as const,
events: ['load', 'ready', 'fail'] as const,
transitions: [
{ from: 'idle', to: 'loading', on: 'load' },
{ from: 'loading', to: 'ready', on: 'ready' },
{ from: 'loading', to: 'error', on: 'fail' },
{ from: 'error', to: 'loading', on: 'load' },
{ from: 'ready', to: 'loading', on: 'load' },
] as const,
feature: { id: 'TRACE', ac: 'indexBuildsFromManifest' } as const,
description: 'Lifecycle of the traceability index: load data, build, query.',
})
export class TraceIndexFsm {}@FiniteStateMachine({
states: ['idle', 'loading', 'ready', 'error'] as const,
events: ['load', 'ready', 'fail'] as const,
transitions: [
{ from: 'idle', to: 'loading', on: 'load' },
{ from: 'loading', to: 'ready', on: 'ready' },
{ from: 'loading', to: 'error', on: 'fail' },
{ from: 'error', to: 'loading', on: 'load' },
{ from: 'ready', to: 'loading', on: 'load' },
] as const,
feature: { id: 'TRACE', ac: 'indexBuildsFromManifest' } as const,
description: 'Lifecycle of the traceability index: load data, build, query.',
})
export class TraceIndexFsm {}Even the trace engine's own state machine is tracked via the same system: the @FiniteStateMachine decorator links it to TRACE/indexBuildsFromManifest, the state-machine-extractor reads it at build time, and work trace fsm TraceIndexFsm queries it. SOLID, state machines, and requirements — all composing in one example.
Impact Analysis: What Did I Just Break?
The most practical sub-command is work trace impact. The daily scenario:
$ work trace impact --diff --base HEAD~1
Impact Analysis (3 changed files):
src/lib/theme-state.ts
src/lib/accent-derive.ts
src/lib/scroll-spy-machine.ts
Impacted features (3):
THEME (critical) — 5 ACs at risk
ACCENT (medium) — 1 AC at risk
SPY (high) — 7 ACs at risk
Tests to rerun (8 files):
test/unit/theme-state.test.ts
test/unit/accent-palette-state.test.ts
test/unit/accent-preview-state.test.ts
test/unit/scroll-spy-machine.test.ts
...$ work trace impact --diff --base HEAD~1
Impact Analysis (3 changed files):
src/lib/theme-state.ts
src/lib/accent-derive.ts
src/lib/scroll-spy-machine.ts
Impacted features (3):
THEME (critical) — 5 ACs at risk
ACCENT (medium) — 1 AC at risk
SPY (high) — 7 ACs at risk
Tests to rerun (8 files):
test/unit/theme-state.test.ts
test/unit/accent-palette-state.test.ts
test/unit/accent-preview-state.test.ts
test/unit/scroll-spy-machine.test.ts
...Three files changed, three features impacted, eight test files to rerun — instead of 2,642 tests across 159 files. The --diff flag reads changed files from git diff --name-only. The --base flag defaults to HEAD for staged changes. The features are sorted by priority: critical first, so you know immediately if you touched something dangerous.
Before the trace engine: "I changed accent-derive.ts, which features does that affect?" Answer: open the compliance report, ctrl-F for accent-derive, hope you find it. After: work trace file src/lib/accent-derive.ts — 200ms.
Hotspots and Fragile: Risk Analysis for Free
Two sub-commands quantify risk directly from the manifest:
Hotspots ranks files by how many features they touch — the files with the largest blast radius:
$ work trace hotspots --limit 5
src/lib/external.ts ████████████████████ 15 features
src/lib/ansi.ts ██████████ 8 features
src/lib/spa-nav-state.ts ██████ 5 features
src/lib/theme-state.ts █████ 4 features
src/lib/frontmatter.ts ████ 3 features$ work trace hotspots --limit 5
src/lib/external.ts ████████████████████ 15 features
src/lib/ansi.ts ██████████ 8 features
src/lib/spa-nav-state.ts ██████ 5 features
src/lib/theme-state.ts █████ 4 features
src/lib/frontmatter.ts ████ 3 featuresexternal.ts touching 15 features is expected — it defines ports that every factory consumes. But if spa-nav-state.ts touches 5 features, that is a potential SRP signal: a state machine shared across too many concerns.
Fragile ranks features by how many files they depend on — the features most vulnerable to change:
$ work trace fragile --limit 5
BUILD ████████████████████ 12 files
REQ-TRACK ██████████████ 9 files
APP-SHELL ████████ 6 files
ACCENT █████ 4 files
THEME ████ 3 files$ work trace fragile --limit 5
BUILD ████████████████████ 12 files
REQ-TRACK ██████████████ 9 files
APP-SHELL ████████ 6 files
ACCENT █████ 4 files
THEME ████ 3 filesBUILD depending on 12 files is expected — the static build pipeline touches many modules. But a feature depending on 6+ files means any change in any of those files could break it. That is where you invest in abstraction.
Querying at Symbol Granularity
The manifest goes deeper than file-level queries. Each AC maps not just to files but to specific symbols — exported functions, constants, classes. This enables two more questions:
"Where is this AC implemented?" — work trace ac <featureId> <acName>:
$ work trace ac ACCENT swatchChangesAccent
Implemented by:
createPaletteMachine in src/lib/accent-palette-state.ts
createAccentPreviewMachine in src/lib/accent-preview-state.ts
Verified by:
test/unit/accent-palette-state.test.ts (AccentPaletteTests)
test/unit/accent-preview-state.test.ts (InitialStateTests)$ work trace ac ACCENT swatchChangesAccent
Implemented by:
createPaletteMachine in src/lib/accent-palette-state.ts
createAccentPreviewMachine in src/lib/accent-preview-state.ts
Verified by:
test/unit/accent-palette-state.test.ts (AccentPaletteTests)
test/unit/accent-preview-state.test.ts (InitialStateTests)Not "which file" — which function in which file implements this acceptance criterion. The answer is mechanically derived from the test's import graph.
"What does this symbol verify?" — work trace symbol <name>:
$ work trace symbol createPaletteMachine
Verifies 8 ACs across 1 feature:
ACCENT/swatchChangesAccent
ACCENT/accentPersists
ACCENT/defaultResets
ACCENT/toggleOpensAndCloses
ACCENT/openCloseIdempotent
ACCENT/selectColorChangesAndCloses
ACCENT/invalidColorRejected
ACCENT/getStateReturnsSnapshot$ work trace symbol createPaletteMachine
Verifies 8 ACs across 1 feature:
ACCENT/swatchChangesAccent
ACCENT/accentPersists
ACCENT/defaultResets
ACCENT/toggleOpensAndCloses
ACCENT/openCloseIdempotent
ACCENT/selectColorChangesAndCloses
ACCENT/invalidColorRejected
ACCENT/getStateReturnsSnapshotcreatePaletteMachine appears in 8 different @Verifies methods — it is the core factory that most of the ACCENT feature's ACs depend on. If you refactor it, you know exactly which 8 acceptance criteria are at risk.
The manifest is queryable at four levels of granularity:
- Feature -> files —
work trace feature ACCENT— which files does this feature claim? - File -> features —
work trace file accent-derive.ts— which features depend on this file? - AC -> symbols —
work trace ac ACCENT swatchChangesAccent— which functions implement this criterion? - Symbol -> ACs —
work trace symbol createPaletteMachine— which criteria depend on this function?
Each level is a different lens on the same graph. And each is derived mechanically from the test code — no human declared any of it.
Concrete Chain: TRACE Feature End-to-End
The trace engine itself is a tracked feature — TRACE with 11 acceptance criteria:
export abstract class TraceFeature extends Feature {
readonly id = 'TRACE';
readonly title = 'Traceability Queries';
readonly priority = Priority.Medium;
abstract indexBuildsFromManifest(): ACResult;
abstract fileToFeaturesQuery(): ACResult;
abstract featureToFilesQuery(): ACResult;
abstract fileToTestsQuery(): ACResult;
abstract featureToTestsQuery(): ACResult;
abstract impactAnalysis(): ACResult;
abstract hotspotsQuery(): ACResult;
abstract fragileQuery(): ACResult;
abstract fsmToFeatureQuery(): ACResult;
abstract normalizesFilePaths(): ACResult;
abstract fsmLifecycleGuards(): ACResult;
}export abstract class TraceFeature extends Feature {
readonly id = 'TRACE';
readonly title = 'Traceability Queries';
readonly priority = Priority.Medium;
abstract indexBuildsFromManifest(): ACResult;
abstract fileToFeaturesQuery(): ACResult;
abstract featureToFilesQuery(): ACResult;
abstract fileToTestsQuery(): ACResult;
abstract featureToTestsQuery(): ACResult;
abstract impactAnalysis(): ACResult;
abstract hotspotsQuery(): ACResult;
abstract fragileQuery(): ACResult;
abstract fsmToFeatureQuery(): ACResult;
abstract normalizesFilePaths(): ACResult;
abstract fsmLifecycleGuards(): ACResult;
}The test file (test/unit/cli/trace-core.test.ts) has 57 tests across 11 test classes, each @FeatureTest(TraceFeature) with @Verifies on every method. It imports 20+ functions from scripts/cli/commands/lib/trace-core.ts and src/lib/trace-index-state.ts:
import {
createTraceIndexMachine, buildTraceIndex, normalizePath,
queryFileToFeatures, queryFeatureToFiles, queryFileToTests,
queryFeatureToTests, queryImpact, queryHotspots, queryFragile, queryFsm,
renderFileToFeatures, renderFeatureToFiles, renderFileToTests,
renderFeatureToTests, renderImpact, renderHotspots, renderFragile, renderFsm,
} from '../../../scripts/cli/commands/lib/trace-core';import {
createTraceIndexMachine, buildTraceIndex, normalizePath,
queryFileToFeatures, queryFeatureToFiles, queryFileToTests,
queryFeatureToTests, queryImpact, queryHotspots, queryFragile, queryFsm,
renderFileToFeatures, renderFeatureToFiles, renderFileToTests,
renderFeatureToTests, renderImpact, renderHotspots, renderFragile, renderFsm,
} from '../../../scripts/cli/commands/lib/trace-core';All tests use synthetic fixtures — two fake features, three fake files, three fake test refs — zero dependency on the real filesystem.
Here is the interesting part: TRACE is not yet in requirements-bindings.json at the time of writing. The feature was just created; the AST scanner has not re-run yet. But the chain is already complete in code: the @Verifies decorators exist, the imports resolve to real source files, and the next --infer run will produce the manifest entry automatically.
This is the pedagogical point: the manifest is a cache, not a source of truth. The source of truth is the test code plus the imports. The manifest materializes it for queries and reports, but the traceability exists the moment the developer writes the @Verifies decorator and the import statement.
The @Implements to @Verifies Rename
The original decorator was named @Implements. This was wrong. A test does not implement an acceptance criterion — it verifies that the implementation satisfies it. The distinction matters:
@Implementssuggests the test IS the AC. It is not — the AC is the abstract method on the Feature class. The implementation is the source code insrc/lib/.@Verifiescorrectly names the relationship: the test verifies that the implementation satisfies the criterion.
The rename touched 2,770 occurrences across 159 test files. The AST scanner recognises @Verifies — @Implements no longer exists in the codebase. This is semantic discipline, not cosmetic: naming a decorator correctly prevents a category of confusion about who does what in the chain.
Safe Migration with diffManifests
Deleting 93 manually maintained binding files is a high-risk operation. How do you know the AST scanner captures everything the humans captured — and more?
During the migration, the scanner ran in dual-run mode. Both the legacy manual manifest and the new inferred manifest were produced side by side. A diffManifests(manual, inferred) function reported:
- Features present only in the manual manifest (orphans to investigate)
- ACs present only in the manual manifest (bindings the scanner missed)
- Symbols present only in the manual manifest (over-declarations)
- Features/ACs/symbols present only in the inferred manifest (things the scanner found that humans missed)
The result: the inferred manifest was a strict superset of the manual one. The scanner found every binding the humans had declared, plus additional symbols reached through transitive helper resolution that the humans had not bothered to list. The scanner never missed a binding that a human had explicitly declared.
This gave confidence to delete all 93 .bindings.ts files in a single commit. The diffManifests function remains in the codebase — available for anyone who needs to validate a future change to the scanner's resolution logic.
Next: Part III — Hexagonal Architecture and Coverage Hardening explains why the AST scanner needs clean import graphs to work — and how extracting 232 sync IO calls into hexagonal ports made every factory testable.