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

Chapter 07 — Fifty-Four Tests, Only Decorators

The first fit criterion of REQ-DOG-FOOD is a ripgrep invocation. A passing test suite is one where the grep returns nothing.

The fit criterion reads, verbatim, from requirements/requirements/req-dog-food.ts:

readonly fitCriteria = [
  { kind: 'quality-gate' as const, tool: 'rg',
    rule: '`rg "\\b(describe|it)\\(" packages/requirements/test` must return zero matches' },
  ...
];

It is, as fit criteria go, unusually literal. Most fit criteria in this codebase are narrative, or point at a coverage threshold, or name a unit test that must exist and pass. This one names a process, a tool, and a value: run rg, match a regex, count lines, expect zero. There is nothing to interpret. The grep is the gate.

This chapter is the proof, by example, that the gate holds — that across the 54 test classes currently in packages/requirements/test/unit/, plus the scenario replays under test/unit/scenario/, not one calls describe( or it( directly. Every test is a class. Every class carries @FeatureTest. Every method carries @Verifies. That is the entire surface.

The chapter is also the place where I explain why this is not cosmetic. A rule that forbade describe/it for style reasons would be worth a one-line lint entry and nothing more. The reason the rule is encoded as a Critical-priority Requirement with its own @Satisfies chain is that the two functions exist at a different stratum than the DSL they are used inside. describe binds a string to a test group. @FeatureTest(TraceExplorerFeature) binds a class to a class. Every other consequence of the rule — the compiler catching typos, the AST scanner finding gaps, the refactor that renames an AC and breaks exactly the tests that need rewriting — follows from that single structural difference. The rest of this chapter walks each of those consequences on real code.

Why zero describe, zero it

It is tempting to read the rule as aesthetic. The typed-specs series already had its own style argument — abstract methods as ACs, keyof T-checked strings, the decorator wall — and a reader who has seen that argument could easily place this one in the same register: here is another author who thinks his decorators are prettier than your describe blocks. That reading is wrong, and it is worth being precise about why.

Four reasons, in decreasing order of how easy they are to falsify.

First — the compile-time link. A @Verifies<FooFeature>('doesX') call is, at the type level, an assertion that the string 'doesX' is a key of FooFeature. If FooFeature declares ten abstract methods, ten strings are valid. Any other string is a TypeScript error at the site of the decorator, before any test runs, before the file is even bundled. A describe('does X', () => …) call is a function that takes a string. Any string. The compiler has nothing to say about it. The entire point of the decorator is not that it looks cleaner — it is that the call carries a typed obligation.

Second — the scanner visibility. The compliance scanner in src/analysis/ walks the AST of every file under test/unit/** and collects three things: the class name of each @FeatureTest-decorated class, the Feature argument passed to the decorator, and the AC strings passed to every @Verifies method decorator on that class. From those three it builds the coverage matrix that feeds requirements trace matrix <feature> and the --strict gate in requirements compliance. If the tests were in describe/it form, there would be no Feature argument, no typed AC string, nothing structural to collect. The scanner would have to resort to fragile heuristics on test names, of the kind every team that has ever run a coverage-by-name script has suffered through. The decorator is the hook the scanner needs.

Third — refactor safety. Rename the AC method on the Feature class, and every @Verifies<Feat>('oldName') turns into a red underline in the editor before you save the file. The rename is mechanical — your editor's rename-symbol command will offer to update the decorator strings along with the method. Rename a describe('old description', …) block, and nothing tells you the rename is needed; the link was always a string, and the string continues to compile fine. In a codebase where ACs are renamed routinely — because naming is part of the discipline, and the scaffolder in requirement-new-core.ts nudges you to rename when the EARS pattern shifts — this single guarantee is what makes the rule worth encoding.

Fourth — discoverability. A grep for @Verifies across test/unit/ produces a manifest: every line is one Feature, one AC, one test class, one test method. There is nothing to parse, no regex to tune, no false positives. A grep for it( produces a pile of strings, each of which may or may not correspond to a feature, may or may not be current, may or may not be dead code someone forgot to delete. The same tool, applied to the same directory, returns structure in one case and soup in the other.

None of these reasons is cosmetic. Each one is the consequence of a single decision: the binding between a test and the thing it verifies is a typed reference, not a string. The four reasons are what that decision buys.

The canonical test class shape

Here is the smallest complete test class from the package, verbatim from test/unit/decorators.test.ts:

import { expect } from 'vitest';
import {
  FeatureTest,
  Verifies,
  getRequirementRegistry,
  getExcludedMethods,
} from '../../src/decorators';
import { DecoratorsFeature } from '../../requirements/features/decorators';

@FeatureTest(DecoratorsFeature)
class RegistryAccessorTests {
  @Verifies<DecoratorsFeature>('getRequirementRegistryReturnsRegisteredRefs')
  'getRequirementRegistry exposes the live array of refs registered via @Verifies'() {
    const refs = getRequirementRegistry();
    expect(Array.isArray(refs)).toBe(true);
    expect(refs.length).toBeGreaterThan(0);
    const self = refs.find(
      r => r.testClass === 'RegistryAccessorTests'
        && r.ac === 'getRequirementRegistryReturnsRegisteredRefs',
    );
    expect(self).toBeDefined();
    expect(self!.feature).toBe('DecoratorsFeature');
  }

  @Verifies<DecoratorsFeature>('getExcludedMethodsReturnsExcludedSet')
  'getExcludedMethods exposes the live Set of excluded "Class.method" keys'() {
    const set = getExcludedMethods();
    expect(set).toBeInstanceOf(Set);
  }
}

Five things deserve annotation.

The imports. expect from vitest, and the decorators from ../../src/decorators. No describe, no it, no test. The vitest functional API is not imported by name in any test file under test/unit/ — the rule holds at the import level, not merely at the call-site level, which makes it one grep cheaper to check.

The class decorator. @FeatureTest(DecoratorsFeature) is applied to RegistryAccessorTests. DecoratorsFeature is an abstract class from requirements/features/decorators.ts (under the package's own dog-food requirements/ tree); it extends Feature and declares the abstract ACs that this test class is expected to verify. The decorator stores the feature name on the class (target.__feature = 'DecoratorsFeature') and — this is the interesting part — internally calls vitest's describe and it globals so the test file never has to.

The bridge is the decorator itself. Look again at src/decorators.ts lines 53–67:

const g = globalThis as Record<string, unknown>;
if (typeof g.describe === 'function' && typeof g.it === 'function') {
  const vDescribe = g.describe as (name: string, fn: () => void) => void;
  const vIt = g.it as (name: string, fn: () => unknown, timeout?: number) => void;
  const instance = new target();
  vDescribe(target.name, () => {
    for (const method of Object.getOwnPropertyNames(target.prototype)) {
      if (method === 'constructor') continue;
      if (excludedMethods.has(`${target.name}.${method}`)) continue;
      vIt(method, () => (instance as unknown as Record<string, () => unknown>)[method]!(), opts?.timeout);
    }
  });
}

Vitest's functional API wants a function that calls describe to group tests and it to declare them. The class-based pattern wants one method per test. The adaptation is not a separate framework, not a base class with a magic run() method, not a scanner that rewrites source at build time. It is nine lines of plain TypeScript inside the decorator factory: if the vitest globals are present, walk the prototype, and for every method that is neither the constructor nor @Exclude-marked, emit a describe with one it per method. The test file never touches describe or it. The decorator does, exactly once per class, at class-declaration time.

The reason this works without a separate runner is that vitest's globals: true config option puts describe and it on globalThis while the test file is being evaluated. The decorator, being applied at class-declaration time during that same evaluation, sees them. Outside a vitest run, the typeof check falls through and the decorator is a metadata-only no-op — which is what the compliance scanner wants when it imports the test files to read their AST.

The method decorators. @Verifies<DecoratorsFeature>('getRequirementRegistryReturnsRegisteredRefs') is a method decorator, applied to the method whose body is the actual test. The method name — 'getRequirementRegistry exposes the live array of refs registered via @Verifies' — is a quoted string, not an identifier. This is a quiet but load-bearing choice: quoted method names can contain spaces, punctuation, apostrophes, anything. They render in vitest's output exactly as written, so the CLI reporter shows sentences instead of camelCase identifiers. The test name is human-readable; the link is machine-readable; both live in the same method.

The test body. Ordinary vitest assertions. expect(...).toBe(...), expect(...).toBeDefined(), find on an array. Nothing about the class-based shape changes the assertion library or the async story — a test method can be async, can return a Promise, can accept a timeout through @FeatureTest(Feature, { timeout: 30_000 }). The shape is strictly additive: everything vitest would let you write inside an it body, you can write inside a method body.

The one thing that is not in the canonical shape, and that newcomers look for, is a void RegistryAccessorTests statement at the end of the file. You will see those in longer test files:

void RegistryAccessorTests;
void FeatureTestDecoratorTests;
void VerifiesTests;

That is not framework magic. It is a small courtesy to TypeScript's unused-variable check. When the file declares a class that is never instantiated or exported — because all its work happens at class-declaration time — TypeScript's noUnusedLocals will flag it. The void X statement is a no-op that tells the checker the class is intentionally declared. Some repositories use a linter exception instead; this one uses void. Either is fine; neither is load-bearing.

The compiler catches typos

Three variants. Each compiles into a different kind of error.

Correct. Assume DecoratorsFeature declares an abstract method named getRequirementRegistryReturnsRegisteredRefs. Then:

@Verifies<DecoratorsFeature>('getRequirementRegistryReturnsRegisteredRefs')
'getRequirementRegistry exposes the live array of refs'() { ... }

compiles. The string literal is constrained by the decorator's generic parameter: Verifies<T extends Feature>(ac: keyof T & string). keyof DecoratorsFeature enumerates every public member of the class, including the abstract method names. The string literal is checked at the site of the call.

Typo. A single extra character:

@Verifies<DecoratorsFeature>('getRequirementRegistryReturnsRegisteredRefss')
'…'() { ... }

TypeScript emits:

error TS2345: Argument of type '"getRequirementRegistryReturnsRegisteredRefss"' is not assignable to parameter of type 'keyof DecoratorsFeature & string'.

The error surfaces at the decorator call, not in the test body. It surfaces before the file is transpiled, before vitest is invoked, before npm test produces any output. You see the red underline in the editor the instant you finish typing.

What happens without the generic? Consider the equivalent in a describe/it codebase:

it('getRequirementRegistryReturnsRegisteredRefss exposes the array', () => {
  // typo here ──────────────────^^ silent
});

The typo is silent. The test still compiles. It still runs. It still passes, because its body never referred to the method name as anything but a string. The string is free-floating rhetoric; the compiler has no grounds to object. Six months later someone greps for the non-typoed form and finds nothing, concludes the test does not exist, writes it again, and the duplicate now covers the same AC under two names. This is the failure mode the decorator's generic makes impossible.

Wrong Feature. Swapping the type parameter:

@Verifies<TraceCoreFeature>('getRequirementRegistryReturnsRegisteredRefs')
'…'() { ... }

If TraceCoreFeature does not declare an AC named getRequirementRegistryReturnsRegisteredRefs, TypeScript emits:

error TS2345: Argument of type '"getRequirementRegistryReturnsRegisteredRefs"' is not assignable to parameter of type 'keyof TraceCoreFeature & string'.

Same error class, different ergonomic effect. The first typo was a misspelling; this one is a category mistake — the developer thought they were writing a test for a different Feature than they were. The compiler still catches it, because the generic and the class decorator's argument have to agree. In practice this error is what you see when you copy-paste a test class and forget to update the generic along with the method names.

Missing import. A subtler version of the same mistake:

@FeatureTest(DecoratorsFeature)  // no import for DecoratorsFeature
class RegistryAccessorTests { ... }

TypeScript emits the usual:

error TS2304: Cannot find name 'DecoratorsFeature'.

Unremarkable as compiler errors go, but worth naming because it closes a specific attack surface: a test file cannot silently "belong" to a Feature it does not import. The Feature class has to be visible at the site of the decorator. The compliance scanner in turn reads the imports to resolve the Feature by name, so import structure and traceability structure are kept in lockstep.

All three errors are static. None of them requires running the tests. The rule write tests as classes with typed decorators is not a convention that becomes true when someone remembers to lint for it; it is a consequence of the decorator's type signature.

The scanner catches gaps

Compile-time guarantees cover wrong tests — typo'd AC names, wrong Feature generic, missing imports. They say nothing about missing tests. A Feature with ten ACs and @Verifies decorators for only seven of them compiles fine. Seven compile-time-correct tests is not the same as ten tests.

That gap is the scanner's job, and the scanner is why the decorators need to carry structural information in the first place.

The scanner lives in src/analysis/scan-test-files.ts. It walks the AST of every *.test.ts file under test/unit/ and test/unit/scenario/, looks for class declarations with a FeatureTest decorator, and for each one collects:

  • The class name.
  • The single positional argument to @FeatureTest — the Feature class reference, resolved through the file's imports.
  • Every method with a @Verifies decorator, and the string literal passed to it.
  • Optional level tags via @Expects, which widen the expected test coverage matrix (unit + e2e, unit + a11y, etc.).

The output is a flat list of RequirementRef records: { feature, ac, testClass, testMethod }. That list joined against the list of Features and their ACs — collected by a sibling scanner in src/analysis/load-features.ts — is the coverage matrix.

If DecoratorsFeature declares ten ACs and the scanner finds @Verifies bindings for seven of them, three ACs are uncovered. requirements trace gaps prints them in a table. requirements compliance --strict exits non-zero if any uncovered AC belongs to a Feature with Priority.Critical. Chapter 13 — the compliance gate is a Requirement — walks the strict-mode logic end to end; for the purposes of this chapter, the point is narrower: the decorator chain is what lets gap detection exist at all. A describe/it codebase could write the same scanner, but it would be running on strings and hoping the strings are current.

The gap between can compile and can cover is the gap that makes dog-food not merely decorative. The tests compile either way. Only the decorators let the scanner tell you which parts of the Feature remain unverified.

The test class for FEATURE-TRACE-EXPLORER-TUI

The running example of this series, FEATURE-TRACE-EXPLORER-TUI, currently lives in enabled: false / Priority.Low state. It is not shipped; the explore command is a stub. What is shipped, and what will remain shipped when the command lands, is the core of the navigation FSM and the graph builder — the pure, port-free logic that underlies the interactive browser. The test file test/unit/explore-core.test.ts exercises that core. It is 718 lines and eight @FeatureTest classes. Every AC declared on the Feature is verified by at least one test method on one of those classes.

The full class structure of the file, by AC cluster:

@FeatureTest(FeatureTraceExplorerTuiFeature)
class BuildsGraphTeststraceExplorerBuildsGraph
class ArrowKeyNavigationTeststraceExplorerHandlesArrowKeyNavigation
class DrillDownTeststraceExplorerDrillsDownFromAnyNode
class HelpOverlayTeststraceExplorerOpensHelpOverlayOnQuestionMark
class BackspaceTeststraceExplorerJumpsBackUpWithBackspace
class ExitTeststraceExplorerExitsCleanlyOnCtrlC
class NonTtyRefusalTeststraceExplorerRefusesToStartOnNonTty
class FileSystemPortTeststraceExplorerUsesFileSystemPortForDiscovery
class PromptPortTeststraceExplorerUsesPromptPortForInteraction
class EndToEndTestsendToEndNavigatesReqToFeatToAcToTest
class KeybindingsTests          — (shared across four ACs: arrow nav, drill, back, help)

Ten ACs declared on the Feature, eleven test classes in the file, because one class — KeybindingsTests — spans more than one AC and carries four separate @Verifies decorators, one per key cluster. The Feature-to-class relation is one-to-many; the class-to-AC relation is also many-to-many through the @Verifies method decorators.

The file opens, after the fixture helpers, with the graph-builder tests:

@FeatureTest(FeatureTraceExplorerTuiFeature)
class BuildsGraphTests {
  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerBuildsGraph')
  'buildTraceGraph assembles reqToFeatures, featureToAcs, acToTests'() {
    const g = mkGraph();
    expect(g.requirements.map((r) => r.id)).toEqual(['REQ-A', 'REQ-B']);
    expect(g.reqToFeatures.get(requirementId('REQ-A'))).toEqual(['FEAT-1', 'FEAT-2']);
    expect(g.reqToFeatures.get(requirementId('REQ-B'))).toEqual([]);
    expect(g.featureToAcs.get(featureId('FEAT-1'))!.map((a) => a.ac as string)).toEqual(['alpha', 'beta']);
    expect(g.featureToAcs.get(featureId('FEAT-2'))).toEqual([]);
    expect(g.acToTests.get('FEAT-1/alpha')).toHaveLength(2);
    expect(g.acToTests.get('FEAT-1/beta')).toBeUndefined();
    expect(g.acs).toHaveLength(2);
    expect(g.tests).toHaveLength(2);
  }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerBuildsGraph')
  'buildTraceGraph dedupes when spec + runtime registry overlap'() { ... }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerBuildsGraph')
  'buildTraceGraph tolerates Features with missing satisfies[] field'() { ... }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerBuildsGraph')
  'readTestRegistry returns a TestRef[] view of the runtime registry'() { ... }
}
void BuildsGraphTests;

Four methods, all verifying the same AC (traceExplorerBuildsGraph). The scanner records four RequirementRef entries with the same ac field and four different testMethod fields. From the gap-detection perspective, one would have been enough — the AC is covered as soon as any method binds to it. The other three exist because the AC asks for a graph builder, but the implementation has four distinct branches (happy path, deduplication, missing-field tolerance, registry view) and each branch deserves a test. The compliance matrix cares about AC-level coverage; the developer cares about branch-level coverage. The decorators serve the compliance matrix; the method count serves the branch coverage. Both are honest.

The arrow-key cluster is smaller:

@FeatureTest(FeatureTraceExplorerTuiFeature)
class ArrowKeyNavigationTests {
  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerHandlesArrowKeyNavigation')
  'SelectNode down advances cursor, clamped at list end'() {
    const g = mkGraph();
    let s: ExploreState = initialState();
    s = step(s, { type: 'SelectNode', delta: 1 }, g);
    expect(s.cursor).toBe(1);
    s = step(s, { type: 'SelectNode', delta: 1 }, g);
    expect(s.cursor).toBe(1); // clamped (2 reqs → max 1)
  }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerHandlesArrowKeyNavigation')
  'SelectNode up never goes below zero'() { ... }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerHandlesArrowKeyNavigation')
  'SelectNode on empty list resets cursor to zero'() { ... }
}
void ArrowKeyNavigationTests;

Three methods, all the same AC, three distinct clamp-direction cases (up-boundary, down-boundary, empty-list). The same pattern as before: one AC, one @Verifies target per method, one test class that collects the methods by topic.

The drill-down cluster is where the AC boundary gets interesting. traceExplorerDrillsDownFromAnyNode verifies seven methods:

@FeatureTest(FeatureTraceExplorerTuiFeature)
class DrillDownTests {
  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerDrillsDownFromAnyNode')
  'DrillDown Req → Feat → Ac → Test'() { ... }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerDrillsDownFromAnyNode')
  'DrillDown on empty list is a no-op'() { ... }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerDrillsDownFromAnyNode')
  'DrillDown Feat → Ac on Feature without ACs is a no-op'() { ... }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerDrillsDownFromAnyNode')
  'DrillDown ViewingFeats with crumb pointing at an unknown REQ returns state unchanged'() { ... }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerDrillsDownFromAnyNode')
  'DrillDown ViewingAcs with crumb pointing at unknown FEAT returns state unchanged'() { ... }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerDrillsDownFromAnyNode')
  'DrillDown guards against cursor past end of list'() { ... }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerDrillsDownFromAnyNode')
  'DrillDown Ac → Tests on AC without tests produces empty Tests view'() { ... }
}
void DrillDownTests;

Seven methods on a single AC. Why? Because the AC reads drills down from any node, and "any node" covers the happy path plus six defensive branches. The compliance matrix sees one AC, covered. The developer sees the seven branches, each with its own method, each with a one-sentence name that survives as the vitest reporter line. The decorator is the link to the AC; the method name is the description of the branch. The two channels of information do not fight.

The help overlay cluster is the one I find most instructive. HelpOverlayTests has seven methods, covering the toggle, the memory of the previous view, the double-open no-op, the close, the close-outside-overlay no-op, the Back-as-close alias, and the drill-in-help no-op. The seventh method — CloseHelp falls back to ViewingReqs when no previousView recorded — is a defensive branch: it exercises a state shape the FSM can in principle reach but that normal input can never produce. It is there because the state transition function has a fallback clause, and without a test the fallback would be uncovered, and without 100% branch coverage the gate would fail. The decorator says this verifies the overlay AC; the method body says this verifies a specific fallback clause of the implementation. One AC, seven methods, 100% branch coverage.

Then the port-exercising clusters. FileSystemPortTests verifies traceExplorerUsesFileSystemPortForDiscovery with four methods, each of which injects an in-memory FileSystem port (via the mkFs helper at the top of the file) and asserts that the loader walks only through the port — never require('fs'), never fs.promises, never a hidden side channel:

@FeatureTest(FeatureTraceExplorerTuiFeature)
class FileSystemPortTests {
  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerUsesFileSystemPortForDiscovery')
  async 'loadSpecsViaFileSystem reads spec.json files via the port only'() {
    const reqSpec = JSON.stringify(mkReq('REQ-FS'));
    const featSpec = JSON.stringify(mkFeat('FF', ['a'], ['REQ-FS']));
    const fs = mkFs(
      {
        '/reqs/req-a.spec.json': reqSpec,
        '/reqs/not-a-spec.txt': 'ignored',
        '/reqs/malformed.spec.json': '{not json',
        '/feats/f.spec.json': featSpec,
      },
      {
        '/reqs': ['req-a.spec.json', 'not-a-spec.txt', 'malformed.spec.json'],
        '/feats': ['f.spec.json'],
      },
    );
    const { features, requirements } = await loadSpecsViaFileSystem(fs, '/feats', '/reqs');
    expect(requirements.map((r) => r.id)).toEqual(['REQ-FS']);
    expect(features.map((f) => f.id)).toEqual(['FF']);
  }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerUsesFileSystemPortForDiscovery')
  async 'loadSpecsViaFileSystem returns empty when a directory is absent'() { ... }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerUsesFileSystemPortForDiscovery')
  async 'loadSpecsViaFileSystem filters specs whose kind does not match the dir'() { ... }

  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerUsesFileSystemPortForDiscovery')
  async 'loadSpecsViaFileSystem handles trailing-slash directories'() { ... }
}
void FileSystemPortTests;

The methods are async, because the FileSystem port is promise-shaped, and the methods await the port through the loader. The decorators do not care that the methods are async — vitest handles the Promise return, and @FeatureTest bridges the method to it with the same one-liner. The shape of the test is identical whether the work is synchronous or asynchronous. That is one of the invisible affordances of the class pattern: the decorators are orthogonal to the test body, and the test body can be whatever vitest accepts.

The prompt-port cluster is similar, with a single method that drives the full interactive loop through a scripted Prompt:

@FeatureTest(FeatureTraceExplorerTuiFeature)
class PromptPortTests {
  @Verifies<FeatureTraceExplorerTuiFeature>('traceExplorerUsesPromptPortForInteraction')
  async 'runExploreInteractive drives state via the injected Prompt port only'() {
    const { runExploreInteractive } = await import('../../src/cli/explore-shell');
    const prompt = mkPrompt(['down', 'enter', '?', 'backspace', 'q']);
    ...
    await runExploreInteractive(
      {
        fs,
        stdout: { write(s: string) { output += s; }, isTty: () => true },
        stderr: { write() {}, isTty: () => false },
        prompt,
      },
      { featuresDir: '/feats', requirementsDir: '/reqs' },
    );
    expect(output).toMatch(/Requirements/);
    expect(output).toMatch(/bye\./);
  }
}
void PromptPortTests;

One method, one AC, one end-to-end scripted run through the shell layer. The scripted keys — ['down', 'enter', '?', 'backspace', 'q'] — tell the whole story in one line: arrow down, enter to drill, ? to open help, backspace to go back, q to quit. The test does not mock the FSM. It does not mock the renderer. It injects the two ports the shell depends on (FileSystem, Prompt), runs the real loop, and asserts on the captured stdout. The AC reads uses the Prompt port for interaction; the test verifies exactly that — there is no call to readline, no raw process.stdin handler, no side channel.

Finally the end-to-end cluster, which verifies the AC that crosses all views:

@FeatureTest(FeatureTraceExplorerTuiFeature)
class EndToEndTests {
  @Verifies<FeatureTraceExplorerTuiFeature>('endToEndNavigatesReqToFeatToAcToTest')
  'Drill REQ-A → FEAT-1 → alpha → AlphaTests, then Back all the way'() {
    const g = mkGraph();
    let s = initialState();

    expect(listAt(s, g)).toEqual(['REQ-A', 'REQ-B']);
    expect(viewTitle(s)).toBe('Requirements');

    s = step(s, { type: 'DrillDown' }, g);
    expect(listAt(s, g)).toEqual(['FEAT-1', 'FEAT-2']);
    expect(viewTitle(s)).toContain('REQ-A');

    s = step(s, { type: 'DrillDown' }, g);
    expect(listAt(s, g)).toEqual(['alpha', 'beta']);
    expect(viewTitle(s)).toContain('FEAT-1');

    s = step(s, { type: 'DrillDown' }, g);
    expect(listAt(s, g)).toEqual(['AlphaTests.works', 'AlphaTests.alsoWorks']);
    expect(viewTitle(s)).toContain('FEAT-1.alpha');

    s = step(s, { type: 'Back' }, g);
    expect(s.view).toBe('ViewingAcs');
    s = step(s, { type: 'Back' }, g);
    expect(s.view).toBe('ViewingFeats');
    s = step(s, { type: 'Back' }, g);
    expect(s.view).toBe('ViewingReqs');
    expect(s.path).toEqual([]);
  }

  @Verifies<FeatureTraceExplorerTuiFeature>('endToEndNavigatesReqToFeatToAcToTest')
  'viewTitle produces a meaningful label for every view including HelpOverlay'() { ... }
}
void EndToEndTests;

The first method is the canonical end-to-end walk — four drills down, three Backs back, path empty at root. The second confirms the view title function returns something meaningful at every stop including the help overlay. Both decorate to endToEndNavigatesReqToFeatToAcToTest. The scanner sees one AC, covered by two methods, both in the same class.

Eleven test classes. Twenty-odd @Verifies decorators. Ten ACs on the Feature. Zero describe. Zero it. The file compiles; the scanner finds every AC covered; the coverage report records 100% of every branch of explore-core.ts; requirements trace matrix FEATURE-TRACE-EXPLORER-TUI prints a full matrix with a green tick in every cell. The Feature is enabled: false because the shell command is a stub, but the core is fully tested, and the scanner knows it.

Patterns across the 54 test classes

The explore-core file is representative but not exhaustive. Across the 54 classes under test/unit/ there are five recurring archetypes. All five use the same two decorators.

Pure unit. The most common shape. A class with a handful of methods, each exercising a single function or small function cluster. decorators.test.ts, base.test.ts, audit-hooks.test.ts, types-smart-constructors.test.ts, rename-core.test.ts — all fall into this shape. No ports, no fixtures beyond local const data, no async. The method body is three to thirty lines of expect calls. This is the baseline: most ACs in the package are verified by pure unit tests.

Port-exercise. A class that injects an in-memory implementation of one of the ports in src/ports/ (FileSystem, PromptShell, Watcher, WebSocketServer, Spawn) and drives the code under test through the port. compliance-core.fs.test.ts, watch-core.test.ts, prompt-shell.test.ts, bidi-sync-core.test.ts are in this cluster. The injected port is usually a mk… helper at the top of the file (as with mkFs and mkPrompt above). The class's test bodies call the real core with the fake port and assert on the observable output. This is how the package keeps its analysis core free of require('fs') even though most of what it does touches files.

Property-based. A class whose methods wrap fc.assert(fc.property(...)) from the fast-check library. property.test.ts is the canonical example: sixteen invariants, each tested with 200 generated inputs, producing 3 200 property executions per run. The scanner sees the class as a normal @FeatureTest(TypesSmartConstructorsFeature) binding; it does not know or care that the method body runs fuzzy generators. From the compliance matrix's point of view, a property test is a test like any other; the 200-run fanout is opaque. @Verifies still links to the AC it targets, and the AC is the invariant being verified.

Scenario replay. The test/unit/scenario/ subdirectory holds ten classes that stitch together multiple FSMs in one test body. orchestrator.test.ts, per-ac-fsm.test.ts, project-lifecycle-fsm.test.ts, event-bridge.test.ts, ui.test.ts, commands.test.ts, builtin-scenarios.test.ts, orchestrator-port.test.ts, per-feature-fsm.test.ts, ai-adapter.test.ts. Each feeds a sequence of events through the orchestrator and asserts on the resulting state of each FSM. The @Verifies decorators point at ACs of the orchestration Feature; the methods are longer than a unit test but shorter than an end-to-end test. Chapter 15 — scenario replays as Requirements — covers this cluster in depth.

End-to-end. Rare in this package, because there is no UI to end-to-end. EndToEndTests on FEATURE-TRACE-EXPLORER-TUI is one of a handful of end-to-end classes, and it is synthetic — it exercises the pure FSM over its full range, not a real terminal. The genuinely end-to-end commands (feature new, requirement new, compliance --strict) have their end-to-end coverage in a separate harness outside test/unit/. Within test/unit/, end-to-end is the smallest cluster; the package's trust-in-the-whole story lives more in the scenario replays and the --strict compliance gate than in literal end-to-end tests.

Every one of the five archetypes uses the same two decorators. No archetype has special base classes. No archetype imports a helper beyond what vitest and the test's own fixtures provide. The rule holds uniformly: class with @FeatureTest, methods with @Verifies, body of whatever shape the test needs.

The coverage story

The raw numbers, from packages/requirements/CLAUDE.md and the last CI run:

  • 778 tests total, across test/unit/ and test/unit/scenario/.
  • 100% line coverage on src/**. 100% branches. 100% functions. 100% statements. All four thresholds enforced per-file, not per-package, so no single file can average its way past.
  • 16 property-based invariants via fast-check, each run 200 times per test invocation → 3 200 property executions per npm test run.
  • Zero describe, zero it — the ripgrep gate from REQ-DOG-FOOD.
  • npx requirements compliance --strict = PASS, self-audited.

Per the user-memory rule feedback_vitest_always_coverage, every vitest run in this repository passes --coverage. The gate is not an optional --coverage flag that sometimes runs in CI and sometimes not; it is the default and the only path. The per-file threshold is the mechanism: a module that slips below 100% on any of the four axes fails the entire run, and the contributor has to write a test or justify an /* v8 ignore */ marker in code review.

The property-test layer stacks on top of the unit layer without competing with it. fast-check runs inside a test method body; the method body is inside an @FeatureTest-decorated class; the class is inside a *.test.ts file; the file is picked up by the same vitest run that collects line coverage. The decorators are indifferent to whether the body is a single expect or a fuzzer loop; the coverage tool is indifferent to which kind of test touched which line. The 100% line coverage is the intersection of everything the 778 tests and 3 200 property executions together reach. The fact that the fuzzy layer exists at all is recorded on a separate Requirement — REQ-PROPERTY-TEST-DENSITY — but the mechanism that runs those tests is the same mechanism that runs the unit tests.

Diagram — test to feature coverage

Diagram
The coverage shape. 54 test classes bind to 25 Features via @FeatureTest; the classes' methods bind to 200+ ACs via @Verifies. The decorators are the only edges.

The diagram is a simplification — there are more edges than it shows, and the fan-out through the method-level @Verifies calls is much denser than the five representative edges drawn here. What it captures is the shape: two decorator kinds, two edge kinds, three strata. Everything the scanner computes and everything requirements trace matrix renders starts from this graph.

Running-example recap

FEATURE-TRACE-EXPLORER-TUI has ten abstract ACs on the Feature class and eleven test classes in its test file. Every AC is bound to at least one @Verifies method; most ACs are bound to several, one per branch. The file opens with fixtures (mkReq, mkFeat, mkTestRef, mkGraph, mkPrompt, mkFs), then declares one @FeatureTest-decorated class per AC cluster, each with three to seven methods. No describe. No it. No base class to extend. No runner to register with. The decorator is the bridge; the scanner reads the decorator; the Feature is the link to the two Requirements (REQ-DISCOVERABLE-TRACEABILITY, REQ-DOG-FOOD) the tests transitively verify. The command explore is a stub; the core it depends on is at 100% coverage, and the compliance report says so.


Previous: Chapter 06 — The Scaffolder Registry Next: Chapter 08 — Styles, a Plural Rhetoric — five built-in styles, one registry, every Requirement typed on its Style.

⬇ Download