Chapter 13c — When Compliance Fails: Diagnostics and Recovery
The gate is a scanner reading source. Every failure message points at a file and a line. The fix is always a local edit, never a dance.
The compliance gate runs under npx requirements compliance --strict. Chapter 13 showed what the gate does when everything is green: it prints a matrix, a summary, a timestamp, and exits zero. Chapter 13b showed the same pipeline with a coverage delta, annotated. This chapter is for the days the gate goes red. It walks six failure modes, shows the transcript the scanner prints for each, reads the output line by line, and names the minimal edit that turns the line green.
The first thing to remember when the gate fails is that the gate is not an oracle. It is a scanner. It reads .ts files under requirements/features/, requirements/requirements/, and test/unit/; it walks the AST of each; it asks the runtime registry which @Satisfies edges are actually in scope at import time; it loads coverage-summary.json from the latest Vitest run; it cross-references all of that against the feature-to-AC matrix. Every message it prints is grounded in a specific file and a specific line. There is no message that means "something is wrong somewhere". Every message is a pointer.
This matters for recovery. When the gate says orphan Feature at requirements/features/foo.ts:5, the fix is at that file, on that line. The scanner has already done the detective work. Your job is to read the pointer and apply the edit. The six sections below are the six shapes that edit can take.
A note on scope. This chapter is about failures the gate catches. It is not about failures the gate misses — those belong to the test-quality discussion in chapter 07. A test that passes on an empty implementation is a failure of meaning, and no scanner can catch it. A test that is absent, or pointed at a deleted AC, or written against a cycle in the refinement graph — those are structural, and the scanner catches every one.
A note on the running example. The trace-explorer TUI Feature (FEATURE-TRACE-EXPLORER-TUI), carried across this series, has three @Satisfies entries, ten ACs, ten test classes, and no refinement cycles. When it is fully wired it hits none of the six failure modes. The walkthrough below is a safety net for the moments when a Feature is mid-authoring or mid-refactor; it is not the routine path. The routine path is green.
The shape of a failure message
Before the per-mode walkthrough, one observation on the shape of every message the gate prints. Each [FAIL] block has the same five-field skeleton, regardless of the underlying failure mode:
- A one-line headline with the severity label, the failure-mode name, and the primary identifier (a Feature id, a Requirement id, a file path, an AC method).
- A source pointer —
file:followed by an absolute-relative path and a line number. For failures that span multiple locations, the pointer is the primary site; the secondary sites are listed under anedges:ordetail:field. - A reason field — a single sentence describing what the scanner observed. The reason is phrased as an observation, not a prescription: "no @Satisfies decorator on the class", not "you must add @Satisfies".
- A fix field — one or more canonical edits that restore green. When more than one edit is legitimate, the fix field lists them with
orseparators and leaves the choice to the author. - An optional see field — a URL into this very documentation. The gate knows where this chapter lives, and prints the anchor.
The uniformity is deliberate. A developer looking at gate output should not have to re-learn how to read it every time a new failure shows up. The shape is the contract; the content inside the shape is what the specific failure contributes.
What the gate does not do
Three things the gate deliberately does not do, worth naming up front so you can recognise the boundary.
It does not run tests. The gate consumes whatever coverage-summary.json was produced by the last npm run test invocation. If the coverage file is stale, or missing, or produced by a subset of the tests, the gate reports accordingly and continues. Running the gate as a pre-commit hook means you pay for the scan (sub-second); running npm run test before it is your responsibility.
It does not auto-fix. Every failure has a canonical fix, but the fix is named, not applied. A requirements compliance fix subcommand does not exist, and is unlikely to. The reason is that most fixes — especially for modes 1, 2, and 6 — involve a judgement about structure that the scanner has no basis to make. Auto-fix would silence the gate without improving the code.
It does not hide failures. There is no --ignore-orphans flag, no configuration key for "this failure mode is fine actually". The gate is small enough that every failure shape is supported for a structural reason; disabling a shape would mean disabling a structural check, which would defeat the point of running the gate at all. The only escape hatch the gate offers is at the code level — @Exclude(reason) on a test method, a principled /* v8 ignore */ on an uncovered line, a Priority downgrade on a Feature — and every escape leaves a trace in the report.
The two modes of running the gate
The gate runs in two modes, and the distinction matters for how failures are surfaced.
Informational mode — npx requirements compliance, no flags. The scanner runs, prints the full matrix with green/yellow/red cells, prints the failure list at the end, and exits zero regardless. Meant for ad-hoc inspection: you run it to see the state, not to gate on it. Every failure is visible in the output, coloured and labelled; no failure stops you. Useful for triage — "show me what would break if I ran strict right now" — and for newcomers getting oriented.
Strict mode — npx requirements compliance --strict. Same scan, same matrix, same failure list, but the exit code is one if any [FAIL] line was printed. Meant for automation: pre-commit hooks, test:all scripts, npm run ci equivalents. Every failure is fatal; green is the only outcome that does not stop the pipeline.
This chapter is primarily about strict-mode failures, because those are the ones that interrupt work. Informational-mode failures are the same shapes — the same six modes, the same diagnostics — but they fly past in the console output rather than halting execution. The reading strategy is the same; the urgency is different.
A useful discipline: run informational mode while you work (bound to a keyboard shortcut, or to a file-watcher, or to a status-line widget), and run strict mode before you commit. The informational mode shows you drift as it accumulates; the strict mode refuses to let the drift escape into the commit history.
Reading a diagnostic, field by field
Because this chapter is a reference, one more piece of meta before the per-mode walkthrough: how to read a single diagnostic block top-to-bottom, in the order the scanner prints it, applied to any of the six modes.
Line 1 — the headline. [FAIL], a mode name, a primary identifier. The mode name is the anchor you use to find the matching section in this chapter. The primary identifier is what you would mention in a commit message if the fix is a one-liner: "fix: add @Satisfies to FEATURE-HEURISTIC-SUGGESTER".
Line 2 — file:. Always a path relative to the package root, followed by a colon and a line number. Paste it into your editor's "go to file" dialog and land on the exact line. For modes that span files (2 and 6), the file: line names the primary site; other sites are under edges: or detail:.
Line 3 — reason:. One sentence, phrased as observation. No imperative verb, no "you must". The reason is the scanner reporting what it saw, neutrally.
Lines 4-N — detail: or mode-specific fields. These vary. Mode 2 prints detail: with a registry-lookup explanation; mode 3 prints verified_by: with the current (often empty) test list; mode 5 prints uncovered_lines: and uncovered_branches:; mode 6 prints edges: with every decorator call involved. Each field names concrete artefacts — line numbers, AC names, file paths, branch ids — never vague references.
Line penultimate — fix:. The canonical edit, written as a one-line instruction with or separators when multiple edits are legitimate. If the edit involves a command (like requirements requirement new), the command is quoted with backticks.
Final line — see:, sometimes. A URL into this chapter. Only printed on certain modes; the URL is stable and versioned in the gate's source.
If a block does not carry a field you expect, that absence is also information: mode 3 without a verified_by: list means the list was empty; mode 5 without an uncovered_lines: field means the coverage file was missing entirely (a separate, softer failure shape). Read what is there; read what is not; both are signal.
What the gate's --json output looks like
For automation — feeding into a dashboard, archiving to an audit log, comparing across runs — the gate also supports npx requirements compliance --strict --json. The output is a single JSON document on stdout:
{
"timestamp": "2026-04-14T09:22:11Z",
"coverage_run": "test-results/2026-04-14T09-22-11Z",
"features_scanned": 25,
"failures": [
{
"mode": "orphan-feature",
"severity": "FAIL",
"feature_id": "FEATURE-HEURISTIC-SUGGESTER",
"file": "requirements/features/feature-heuristic-suggester.ts",
"line": 8,
"reason": "no @Satisfies decorator on the class",
"fix": "add @Satisfies(ReqXyz, ...) — at least one Requirement required"
}
],
"summary": {
"orphan_features": 1,
"dangling_edges": 0,
"critical_uncovered_acs": 0,
"coverage_threshold_misses": 0,
"refines_cycles": 0
},
"quality_gate": "FAIL",
"exit_code": 1
}{
"timestamp": "2026-04-14T09:22:11Z",
"coverage_run": "test-results/2026-04-14T09-22-11Z",
"features_scanned": 25,
"failures": [
{
"mode": "orphan-feature",
"severity": "FAIL",
"feature_id": "FEATURE-HEURISTIC-SUGGESTER",
"file": "requirements/features/feature-heuristic-suggester.ts",
"line": 8,
"reason": "no @Satisfies decorator on the class",
"fix": "add @Satisfies(ReqXyz, ...) — at least one Requirement required"
}
],
"summary": {
"orphan_features": 1,
"dangling_edges": 0,
"critical_uncovered_acs": 0,
"coverage_threshold_misses": 0,
"refines_cycles": 0
},
"quality_gate": "FAIL",
"exit_code": 1
}Every failure in the flat list is a mode-tagged record with the same fields the console form prints; the summary is a count per mode. The schema is stable across versions (additions only, no removals, no renames), so archival consumers can trust the shape.
The JSON form is how the bindings-diff in packages/ssg-site/requirements-bindings.diff.json is produced — the same scanner runs against the SSG package, writes its output as JSON, and the diff against a baseline is committed with every release. The gate is reusable across packages because its output is a data artefact, not a printout.
Failure mode 1 — Orphan Feature (no @Satisfies)
The first and commonest failure mode is a Feature class that declares itself without declaring why. In the @frenchexdev/requirements vocabulary, every Feature subclass must carry at least one @Satisfies(...) decorator argument, naming at least one Requirement class the Feature exists to deliver. The decorator is how the WHAT links back to the WHY. A Feature without the decorator is an orphan: deliverable, typed, discoverable to the scaffolder, but unmoored from the requirements graph.
The gate's output, verbatim (transcripts in this chapter are fabricated from the actual output format; the real one looks the same):
[FAIL] Orphan Feature: FEATURE-ORPHAN-EXAMPLE
file: requirements/features/orphan-example.ts:5
reason: no @Satisfies decorator on the class
fix: add @Satisfies(ReqXyz, ...) — at least one Requirement required
see: https://frenchexdev.vercel.app/blog/requirements-dogfood/13c-...[FAIL] Orphan Feature: FEATURE-ORPHAN-EXAMPLE
file: requirements/features/orphan-example.ts:5
reason: no @Satisfies decorator on the class
fix: add @Satisfies(ReqXyz, ...) — at least one Requirement required
see: https://frenchexdev.vercel.app/blog/requirements-dogfood/13c-...Read it from the top. [FAIL] is the severity label; other labels are [WARN] (non-gating) and [INFO] (observational). Orphan Feature is the failure mode name. FEATURE-ORPHAN-EXAMPLE is the id field of the Feature class — pulled from the string literal the class declares, not from the class name or the filename. file is the absolute-relative path from the package root to the file that defines the Feature class, followed by the line number of the export abstract class statement. reason is a one-sentence diagnosis. fix is the canonical edit. see is a link to this chapter — the gate is self-documenting at the level of the error class.
Why this is gated
The @Satisfies edge is not decorative. Three things depend on it.
First, the trace graph. requirements trace matrix <feature> walks backwards from the Feature to the Requirements it satisfies, then forward from those Requirements to any other Features that also satisfy them, to produce the sibling list. If the Feature has no @Satisfies edge, the walk starts on an island; the matrix for the Feature is a single row with no neighbours, and the reverse walk from requirements list --requirement=<req> will not find it.
Second, the justification story. The point of a requirements DSL — as distinct from a Feature DSL — is that every deliverable has a traceable WHY. "We built this because..." has to terminate in a Requirement, not in a comment, not in a JIRA ticket, not in a decision record. A Feature without @Satisfies breaks the story; the gate's refusal to let such a Feature ship is the mechanism that keeps the story intact.
Third, the style system. A Requirement's Style parameter determines which templates, validators, and reporter its satisfying Features inherit. A Feature with no @Satisfies inherits no Style; any report that wants to render "this Feature under the Agile register" has no register to apply. The gate's failure on orphan Features is the scanner refusing to let a Feature exist in a Style-free void.
How the scanner detects it
The detection is an AST walk. src/analysis/compliance-core.ts's parseFeatureSource reads each file under requirements/features/, finds the export abstract class Foo extends Feature declaration, and inspects the decorators attached to that class. If the list is empty, or contains decorators but none named Satisfies, the Feature is flagged. There is no heuristic; the test is exactly "does the class have an @Satisfies decorator, yes or no".
One subtlety. The scanner does not care how many arguments @Satisfies receives; one is enough. It also does not care whether the Requirement classes named in @Satisfies exist (that is a separate check — mode 2). The orphan check is purely about the presence of the decorator, not about the integrity of its payload.
The minimal fix
+import { ReqXyzRequirement } from '../requirements/req-xyz';
+
+@Satisfies(ReqXyzRequirement)
export abstract class OrphanExampleFeature extends Feature {
readonly id = 'FEATURE-ORPHAN-EXAMPLE';
readonly title = 'An example with no Satisfies';
readonly priority = Priority.Low;
}+import { ReqXyzRequirement } from '../requirements/req-xyz';
+
+@Satisfies(ReqXyzRequirement)
export abstract class OrphanExampleFeature extends Feature {
readonly id = 'FEATURE-ORPHAN-EXAMPLE';
readonly title = 'An example with no Satisfies';
readonly priority = Priority.Low;
}Three lines added. Import the Requirement. Apply the decorator. That is the whole edit. Re-run npx requirements compliance --strict; the [FAIL] line disappears.
Note the @Satisfies call takes class references, not strings. @Satisfies(ReqXyzRequirement) compiles; @Satisfies('REQ-XYZ') does not — the decorator signature constrains its arguments to Requirement<any> constructors. This is deliberate: a class reference, unlike a string, cannot typo silently. If ReqXyzRequirement is renamed, the decorator call either breaks at compile or follows the rename mechanically; if the class is deleted, the import fails and the file stops compiling. The string world, where a Feature could claim to satisfy 'REQ-DELETED' without anyone noticing, does not exist in this DSL.
The equivalent edit for multi-satisfier Features looks like this, from requirements/features/feature-trace-explorer-tui.ts:
@Satisfies(
ReqDiscoverableTraceabilityRequirement,
ReqDogFoodRequirement,
ReqParallelDeliverableRequirement,
)
export abstract class FeatureTraceExplorerTuiFeature extends Feature {
// ...
}@Satisfies(
ReqDiscoverableTraceabilityRequirement,
ReqDogFoodRequirement,
ReqParallelDeliverableRequirement,
)
export abstract class FeatureTraceExplorerTuiFeature extends Feature {
// ...
}Three class references, one decorator call. Each reference is imported at the top of the file; each rename would produce a compile error if stale; each is visible to the scanner on its first AST pass.
When the fix is structural, not mechanical
Sometimes the orphan is a symptom, not a bug. Two cases.
Case A — mis-categorised Feature. You scaffolded a Feature for something that, on reflection, is not a deliverable but a Requirement itself. AccessibilityFeature in the typed-specs inventory is the archetype: a word that was named as a Feature in the old vocabulary but, in a Requirement/Feature-separated vocabulary, wants to be a ReqAccessibilityRequirement that several concrete Features (ThemeFeature, KeyboardNavigationFeature, CopyButtonsFeature) all satisfy. When you see an orphan that cannot be satisfied by any existing Requirement, the first question is: is this actually a Feature, or is it a Requirement masquerading as one? The fix is a requirements feature delete followed by requirements requirement new, not a decorator edit.
Case B — missing Requirement. The Feature is genuinely a deliverable, but no Requirement yet captures the policy it serves. The temptation is to invent a plausible-sounding Requirement on the fly; the discipline is to stop, run requirements requirement new, write the Requirement properly (with a real statement, rationale, fitCriteria, source), and then wire the @Satisfies edge. A Requirement created to fix an orphan should still be a Requirement you would have written anyway; the gate failure is a prompt to author it, not an excuse to stub it.
Both cases reject the reflex to add a decorator as quickly as possible. The orphan check is a prompt to think about structure, not a hoop to jump through.
A real transcript
Here is the gate output from a real session in this repository, the week FeatureHeuristicSuggester was being scaffolded. The Feature class existed, the abstract methods existed, the test file was stubbed. The @Satisfies decorator had not yet been added, because the author (me) was still deciding which Requirement it would serve.
── Feature Compliance Report ──
Coverage run: test-results/2026-04-14T09-22-11Z
[FAIL] Orphan Feature: FEATURE-HEURISTIC-SUGGESTER
file: requirements/features/feature-heuristic-suggester.ts:8
reason: no @Satisfies decorator on the class
fix: add @Satisfies(ReqXyz, ...) — at least one Requirement required
25 Features scanned, 1 orphan
Quality gate: FAIL (1 orphan Feature)
exit 1── Feature Compliance Report ──
Coverage run: test-results/2026-04-14T09-22-11Z
[FAIL] Orphan Feature: FEATURE-HEURISTIC-SUGGESTER
file: requirements/features/feature-heuristic-suggester.ts:8
reason: no @Satisfies decorator on the class
fix: add @Satisfies(ReqXyz, ...) — at least one Requirement required
25 Features scanned, 1 orphan
Quality gate: FAIL (1 orphan Feature)
exit 1Three lines of summary after the diagnostic. The scanner tells me how many Features it looked at (25), how many were orphan (1), and prints the gate outcome. The exit code is one; the gate is refusing to let the commit through.
The fix in this case was mode-1-case-B: no Requirement yet captured the policy the Feature served. I wrote requirements/requirements/req-inference-quality.ts with a statement, a rationale about heuristic suggesters needing testable fit criteria so their output did not regress silently, two fitCriteria entries, and a source pointing at the pull-request discussion that motivated the Requirement. Then I added @Satisfies(ReqInferenceQualityRequirement) to the Feature class. Re-ran the gate:
25 Features scanned, 0 orphan
Quality gate: PASS
exit 025 Features scanned, 0 orphan
Quality gate: PASS
exit 0Green. The cycle was roughly ten minutes of work; eight of those were on the Requirement (writing a real statement and a real rationale), two were on the decorator edit. That ratio is the point: the gate refuses to let the Feature ship until the WHY is written, because the WHY is the work.
Failure mode 2 — Requirement-less satisfier (stale @Satisfies)
The second mode is a Feature whose @Satisfies decorator names a Requirement class that does not exist. This happens most often when someone deletes a Requirement file without grep-replacing the satisfiers that reference it. The Feature's AST looks fine; the import resolves (or fails, depending on the editor); the runtime registry either silently drops the edge or throws at import time. Either way, the gate reports it.
The output:
[FAIL] Dangling @Satisfies edge: FEATURE-FOO -> REQ-DELETED
file: requirements/features/foo.ts:7
reason: @Satisfies(ReqDeletedRequirement) references a class not found
in the Requirement registry
detail: no file under requirements/requirements/ exports a class whose
id resolves to 'REQ-DELETED'
fix: either restore the Requirement, or remove the @Satisfies argument[FAIL] Dangling @Satisfies edge: FEATURE-FOO -> REQ-DELETED
file: requirements/features/foo.ts:7
reason: @Satisfies(ReqDeletedRequirement) references a class not found
in the Requirement registry
detail: no file under requirements/requirements/ exports a class whose
id resolves to 'REQ-DELETED'
fix: either restore the Requirement, or remove the @Satisfies argumentFour lines of payload after the headline. FEATURE-FOO -> REQ-DELETED is the edge direction: satisfier → satisfied. file points at the line of the @Satisfies call. reason explains what the scanner observed. detail says why the registry disagrees with the source — no file under requirements/requirements/ carries an id matching the reference. fix lists the two canonical edits.
How the scanner detects it
The scanner runs two passes and cross-references them. Pass A is the AST walk of requirements/features/*.ts, which extracts each @Satisfies(ClassName, ...) argument list as a set of class names. Pass B is the AST walk of requirements/requirements/*.ts, which extracts each export abstract class Name extends Requirement<...> and its readonly id = '...' string, building a className → requirementId map. For every class name referenced by @Satisfies, the scanner looks it up in the map; any lookup miss is a dangling edge.
Why not rely on TypeScript to catch this? Because the Requirement class can still be imported from a file that compiles, if the file still exists but the class declaration was renamed or removed within it. TypeScript's unused-import check and isolatedModules do not cover this case; the import can resolve to a barrel file that re-exports whatever is left. The registry check is the only mechanism that detects the symbolic mismatch.
A second variant. The scanner also detects the case where the Requirement class is exported, but its id field no longer matches the label someone grep-renamed in a spec document. The @Satisfies argument is a class reference, not a string, so renaming ReqFoo to ReqFooV2 breaks the AST reference; renaming the readonly id string inside the class does not. The latter case is caught by the requirements requirement sync diff, not by the compliance gate — but the net effect is similar: the Feature-to-Requirement link is stale.
A third variant, rarer but documented. The scanner also reports dangling edges when a Requirement file exists, compiles, exports the class, carries the right id, but has been @Exclude-d from the registry via a package-level flag. This is an almost-never case; it shows up when a Requirement is being retired but the retirement is staged across several commits. The gate's output is indistinguishable from the "deleted file" case, which is by design — the effect is the same from the satisfier's point of view.
The minimal fix — restore
If the Requirement was deleted by accident, git log the file, restore the version, re-run the gate. Done.
The minimal fix — remove
If the Requirement was deleted intentionally (consolidated into another Requirement, or retired because the policy is gone):
@Satisfies(
ReqDiscoverableTraceabilityRequirement,
- ReqDeletedRequirement,
ReqDogFoodRequirement,
)
export abstract class FooFeature extends Feature { @Satisfies(
ReqDiscoverableTraceabilityRequirement,
- ReqDeletedRequirement,
ReqDogFoodRequirement,
)
export abstract class FooFeature extends Feature {Three-line diff, one line removed. The import of ReqDeletedRequirement at the top of the file also needs to go; the editor will underline it red the moment the decorator argument is deleted, which is the cue to delete the import too.
The trap to avoid
The trap is to add the reference back under a new name without thinking about what the reference meant. @Satisfies edges are claims — "this Feature exists to deliver that Requirement". If the Requirement went away, the question is not "what Requirement do I wire in to silence the gate?" but "what did this Feature commit to, and is that commitment still true?" Sometimes the right edit is to delete the Feature; sometimes it is to split the Feature into two, each with a different @Satisfies; sometimes it is to downgrade from Critical to Low because the Requirement that justified the Critical rating is gone. The scanner cannot tell you which. It tells you that a decision is due.
A real transcript
During the refactor that retired REQ-TYPESPEC-ALIGNMENT (an old Requirement that had originally captured "the DSL must remain compatible with the typed-specs article series"), three Features in the repository still referenced it. The gate produced:
[FAIL] Dangling @Satisfies edge: FEATURE-DECORATORS -> REQ-TYPESPEC-ALIGNMENT
file: requirements/features/decorators.ts:11
reason: @Satisfies(ReqTypespecAlignmentRequirement) references a class
not found in the Requirement registry
detail: no file under requirements/requirements/ exports a class whose
id resolves to 'REQ-TYPESPEC-ALIGNMENT'
[FAIL] Dangling @Satisfies edge: FEATURE-BASE -> REQ-TYPESPEC-ALIGNMENT
file: requirements/features/base.ts:8
reason: same as above
[FAIL] Dangling @Satisfies edge: FEATURE-SMOKE -> REQ-TYPESPEC-ALIGNMENT
file: requirements/features/smoke.ts:6
reason: same as above
3 dangling @Satisfies edges detected
Quality gate: FAIL
exit 1[FAIL] Dangling @Satisfies edge: FEATURE-DECORATORS -> REQ-TYPESPEC-ALIGNMENT
file: requirements/features/decorators.ts:11
reason: @Satisfies(ReqTypespecAlignmentRequirement) references a class
not found in the Requirement registry
detail: no file under requirements/requirements/ exports a class whose
id resolves to 'REQ-TYPESPEC-ALIGNMENT'
[FAIL] Dangling @Satisfies edge: FEATURE-BASE -> REQ-TYPESPEC-ALIGNMENT
file: requirements/features/base.ts:8
reason: same as above
[FAIL] Dangling @Satisfies edge: FEATURE-SMOKE -> REQ-TYPESPEC-ALIGNMENT
file: requirements/features/smoke.ts:6
reason: same as above
3 dangling @Satisfies edges detected
Quality gate: FAIL
exit 1Three failures, same payload, three files, three line numbers. The retirement of the Requirement was intentional (the "compatibility with typed-specs" claim had stopped being load-bearing once this series started), so the fix was to remove the edge from each of the three Features, and delete the now-unused import at the top of each file. Three minutes of grep-and-edit; re-ran the gate; green.
The case would have been harder if even one of the three Features had only satisfied REQ-TYPESPEC-ALIGNMENT, with no other @Satisfies entry. Removing the dangling edge would have turned the Feature into an orphan (mode 1), which would have bubbled up as a new failure on the re-run. In that hypothetical, the right move would have been to write the missing Requirement first (mode 1 case B), then remove the dangling edge. The two modes compose.
Failure mode 3 — Under-covered Critical AC
The third mode is the one the gate spends most of its time on. A Feature declares a Critical-priority AC — an abstract method on its class — and no test file under test/unit/, test/e2e/, test/a11y/, test/visual/, or test/perf/ carries a @Verifies<T>('thatAcName') decorator. The AC is declared but unproven.
The output:
[FAIL] Critical AC not verified: FEATURE-FOO.doesX
feature: FEATURE-FOO (Critical)
ac: doesX
source: requirements/features/foo.ts:14
verified_by:
(none)
expected_by_decorator: unit
fix: write a @FeatureTest(FooFeature) class with a method decorated
@Verifies<FooFeature>('doesX')[FAIL] Critical AC not verified: FEATURE-FOO.doesX
feature: FEATURE-FOO (Critical)
ac: doesX
source: requirements/features/foo.ts:14
verified_by:
(none)
expected_by_decorator: unit
fix: write a @FeatureTest(FooFeature) class with a method decorated
@Verifies<FooFeature>('doesX')Six fields. feature is the Feature id followed by its priority label. ac is the AC method name. source points at the line of the abstract method declaration. verified_by lists the test classes and methods that currently verify the AC — here, an empty list. expected_by_decorator shows the TestLevel the scaffolder would emit for this AC, inferred from any @Expects(...) decorator on the method (here there is none, so the default is unit). fix names the decorator chain the test has to carry.
Why zero headroom on Critical
The compliance gate applies different thresholds to different priorities. Low and Medium ACs are allowed to be partially unverified — the matrix will show them as yellow, and the summary will count them, but the gate does not fail. High ACs raise the threshold to something close to 90%; exact numbers are Style-dependent. Critical ACs are gated at 100%: zero headroom, no exceptions. If the AC is declared Critical, every AC method must have at least one @Verifies matcher in the test tree.
The rationale is a policy one. A Critical-priority Feature is a Feature whose failure is an incident. Safety properties, security invariants, regulatory obligations — anything that, if it regressed unnoticed, would cost the project. The cost of writing one extra test per Critical AC is tiny; the cost of a silent regression on a Critical AC that was never verified is large. The asymmetry makes the 100% threshold cheap to enforce and expensive to waive.
The gate is also the place where the Priority enum stops being advisory. Chapter 04 showed that Priority is a first-class part of every Requirement and every Feature; chapter 13 showed that the compliance matrix colour-codes by Priority; this chapter is where Priority has teeth. A Critical label without test coverage is a [FAIL]. The enum is not a hint.
How the scanner detects it
The detection is a join between two inputs. Input A is the Feature-to-AC set, extracted in the same AST pass as mode 1: for each Feature class under requirements/features/*.ts, the scanner collects every abstract method name. Input B is the test-to-AC set, extracted by src/analysis/test-bindings-scanner.ts: for each .ts file under test/**, the scanner finds every @Verifies<T>('name') call, the T type parameter, and the string argument.
The join is by (featureId, acName). For every (FEAT, ac) pair where FEAT is Critical, the scanner counts the matching test bindings. If the count is zero, the pair is reported under Critical AC not verified. If the count is above zero but below the Feature's priority threshold (for non-Critical priorities), the pair is reported under a softer [WARN].
One detail. The scanner also reads the @Expects(TestLevel) decorator on the AC method, if present, which narrows the test level the AC is supposed to be covered at. An AC with @Expects(TestLevel.E2e) that is covered only by a unit test still fails the Critical check — the expected level is not met. This is an opt-in sharpening; most ACs default to unit, and unit coverage suffices.
The minimal fix
Write the test:
// test/unit/foo.test.ts
import { expect } from 'vitest';
import { FeatureTest, Verifies } from '../../src/decorators';
import { FooFeature } from '../../requirements/features/foo';
@FeatureTest(FooFeature)
class DoesXTests {
@Verifies<FooFeature>('doesX')
'does X when given Y'() {
// arrange, act, assert
const result = subjectUnderTest.doSomething();
expect(result).toBe('expected');
}
}// test/unit/foo.test.ts
import { expect } from 'vitest';
import { FeatureTest, Verifies } from '../../src/decorators';
import { FooFeature } from '../../requirements/features/foo';
@FeatureTest(FooFeature)
class DoesXTests {
@Verifies<FooFeature>('doesX')
'does X when given Y'() {
// arrange, act, assert
const result = subjectUnderTest.doSomething();
expect(result).toBe('expected');
}
}Six lines of decorator surface, N lines of body. Re-run the gate; verified_by becomes [DoesXTests.does X when given Y]; the [FAIL] disappears.
The escape hatches
Three escape hatches exist, and each is principled rather than cosmetic.
Hatch A — downgrade Priority. If the AC is genuinely not Critical, edit the Feature class: readonly priority = Priority.Medium;. This is not a way to silence the gate; it is the correct action when the Feature was scaffolded at too high a priority. The requirements feature sync diff will show the change and ask for confirmation. The gate will still want coverage, but it will allow a partial percentage.
Hatch B — split the AC. Sometimes a single abstract method is carrying two responsibilities, one of which is testable at unit level and one of which is only observable end-to-end. The fix is to split: doesXAtUnit() and doesXEndToEnd(), each with a separate @Verifies, each at a level that is genuinely reachable. The scanner is happier, and the AC is more honest.
Hatch C — @Exclude(reason). The decorator exists for the rare case where an AC is truly untestable in the test/** tree — a Feature that only exists on a specific hardware port, or that can only be observed by a human operator. The escape requires a reason string, and the reason is published in the compliance report alongside the waiver. It is not a hide-the-failure hatch; it is a record-the-rationale hatch. Use it sparingly.
All three hatches involve a source edit, and all three leave a trace in the report. The gate does not support silencing; it supports structural honesty.
A real transcript
During the scaffolding of FEATURE-BIDIRECTIONAL-SYNC, one of its ACs — detectsConcurrentEditByHashMismatch — was declared Critical and had no test. The gate produced:
[FAIL] Critical AC not verified: FEATURE-BIDIRECTIONAL-SYNC.detectsConcurrentEditByHashMismatch
feature: FEATURE-BIDIRECTIONAL-SYNC (Critical)
ac: detectsConcurrentEditByHashMismatch
source: requirements/features/feature-bidirectional-sync.ts:34
verified_by:
(none)
expected_by_decorator: unit
fix: write a @FeatureTest(FeatureBidirectionalSyncFeature) class with a
method decorated @Verifies<FeatureBidirectionalSyncFeature>('detectsConcurrentEditByHashMismatch')
17 Critical ACs scanned, 1 unverified
Quality gate: FAIL
exit 1[FAIL] Critical AC not verified: FEATURE-BIDIRECTIONAL-SYNC.detectsConcurrentEditByHashMismatch
feature: FEATURE-BIDIRECTIONAL-SYNC (Critical)
ac: detectsConcurrentEditByHashMismatch
source: requirements/features/feature-bidirectional-sync.ts:34
verified_by:
(none)
expected_by_decorator: unit
fix: write a @FeatureTest(FeatureBidirectionalSyncFeature) class with a
method decorated @Verifies<FeatureBidirectionalSyncFeature>('detectsConcurrentEditByHashMismatch')
17 Critical ACs scanned, 1 unverified
Quality gate: FAIL
exit 1The AC was Critical because concurrent-edit corruption is an incident class — two developers syncing to the same spec file at the same time and silently overwriting each other's edits is exactly the kind of failure a bidirectional-sync tool has to refuse to ship without proving it cannot happen. The fix was a ten-line test that staged two sync operations against a mock filesystem with a staged pre-image hash mismatch and asserted the second one threw a ConcurrentEditError. Five minutes of work; re-ran the gate; green.
What the transcript does not show, but what the --verbose flag of the gate does, is the comparison to the other 16 Critical ACs on the same Feature. All 16 had verified_by: lists of length one — one test class each, one test method each. The failure was not systemic; it was a single missing test on a single AC, clearly flagged, clearly pointed at, clearly fixable. The Critical threshold is only painful when it finds a gap; most of the time it is invisible, because most of the time every Critical AC is already covered.
Failure mode 4 — Typo'd AC name
The fourth mode is the one the gate never sees, because TypeScript sees it first. A test file declares @Verifies<FooFeature>('dosX') — dosX, not doesX. The string is off by one character. The AC method on FooFeature is doesX. The scanner is never asked about this, because tsc refuses to compile the file.
The error — quoted verbatim from the actual TypeScript compiler:
error TS2345: Argument of type '"dosX"' is not assignable to parameter
of type 'keyof FooFeature & string'.
Type '"dosX"' is not assignable to type
'"doesX" | "doesY" | "doesZ" | "doesW"'.error TS2345: Argument of type '"dosX"' is not assignable to parameter
of type 'keyof FooFeature & string'.
Type '"dosX"' is not assignable to type
'"doesX" | "doesY" | "doesZ" | "doesW"'.Why this is a compiler error
The @Verifies decorator signature is, simplified:
export function Verifies<T extends Feature>(
ac: keyof T & string,
): MethodDecorator {
return ...;
}export function Verifies<T extends Feature>(
ac: keyof T & string,
): MethodDecorator {
return ...;
}The keyof T & string constraint is the whole trick. keyof FooFeature at the type level is the union of all property names of FooFeature, including the abstract AC methods. Intersecting with string narrows to string keys (dropping any symbol keys, in practice none). The resulting type is exactly the union of AC names. Passing a string literal that is not in the union is a type error.
This is not a runtime check; it is a compile-time check, enforced by the tsc pass that runs before Vitest even looks at the file. The error is local (one file, one line), named (TS2345), and precise — the compiler prints the allowed values, so the fix is one keystroke away. Most editors will offer autocompletion on the argument position, suggesting the four allowed AC names; the typo usually gets caught during typing, not during compilation.
The minimal fix
- @Verifies<FooFeature>('dosX')
+ @Verifies<FooFeature>('doesX')
'does X when given Y'() {- @Verifies<FooFeature>('dosX')
+ @Verifies<FooFeature>('doesX')
'does X when given Y'() {One character. Re-compile; the test file goes green; the gate is never involved.
Why this matters to the gate story
The gate's job is to catch structural failures that the type system cannot express. Typo'd AC names are structural, but the type system can express them — the keyof T & string trick is precisely what lets TypeScript do the check. The cleaner the type signature, the less work the gate has to do.
This is also the answer to an implicit question: why invest in decorator design when you could just grep for AC names? Because grep catches nothing at compile time. A grep-based system tells you at scanner-run that 'dosX' is not a known AC. The decorator tells you at editor-save. Every layer of the stack that catches errors earlier is a layer the gate does not have to carry.
The rename story
The compile-time check pays its biggest dividend on rename. Suppose the AC method on the Feature class is renamed from doesX to verifiesXInvariant — a clarification that the method is checking an invariant, not performing an action. In a grep-based system, every @Implements('doesX') string in the test tree would silently stay pointing at the old name; the scanner would later report "missing AC" on verifiesXInvariant and "unknown AC" on doesX; the developer would have to grep-replace manually, hoping to hit every site.
In the decorator-checked system, the rename produces an instant compile error on every test file that referenced the old name. The editor highlights each @Verifies<FooFeature>('doesX') in red with a helpful error (TS2345, with the allowed values listed). Most editors offer a rename-symbol command that propagates the rename across the type union; if yours does not, a three-step process works: rename the AC method, accept the compile errors, use the editor's "find all references to the literal string" feature with the new name as the replacement. Five keystrokes. The gate is never involved.
This is not a hypothetical. During the week @Verifies was added, three ACs on three different Features were renamed to follow a new naming convention (does<Object><Verb> instead of <verb><Object>, because the predicate read better at the front of the name). Every rename produced between two and seven compile errors at test sites that referenced the old name. Every error was fixed in the same commit as the rename. No scanner was run; no test was broken; the refactor was purely compile-time.
Contrast with a renamed function. A test that calls foo.verifiesXInvariant() after a rename will stop compiling with an error on the method call, exactly as @Verifies<T>('verifiesXInvariant') stops compiling when the string is stale. The two mechanisms are the same shape — a compile-time reference check — applied to different surfaces. The decorator makes the string feel like a method reference, even though it is, syntactically, still a string. That feeling is accurate; the type system enforces it.
The editor experience
One underrated payoff of keyof T & string is autocomplete. When you type:
@Verifies<FooFeature>('|@Verifies<FooFeature>('|— with the cursor at | inside the string literal — most TypeScript-aware editors will offer a dropdown of every AC name declared on FooFeature. The dropdown is sorted, is filtered as you type, and points at the declaration sites if you ctrl-click an entry. The string literal stops feeling like a string; it feels like a typed enum, because that is what keyof T & string makes it.
This is what the typed-specs series meant by "the type system carries the specification load". The Feature class is both the source of truth for the spec and the source of truth for the test autocomplete. There is no second place to keep a list of valid AC names in sync.
Why mode 4 lives here
Mode 4 is arguably not a compliance-gate failure at all — the gate never gets a chance to run on a file that does not compile. So why walk through it in a chapter about compliance diagnostics?
Because in practice, mode 4 is how most would-be compliance failures never reach the gate. A scanner that had to detect typo'd AC names at scan time would be slower, noisier, and less useful than one that can assume every AC string is already validated. The gate's lightness — sub-second, dependency-free, grounded only in AST — is made possible by offloading the string-validation work to the compiler. Every chapter about gate diagnostics is implicitly a chapter about what the gate does not have to diagnose, because earlier layers of the stack already caught it.
If you ever wonder why the gate's code in src/analysis/compliance-core.ts is only a few hundred lines, mode 4 is part of the answer. The gate is small because the type system carries the rest.
Failure mode 5 — Coverage under threshold
The fifth mode sits at the boundary between Vitest's coverage output and the compliance gate. Vitest runs with --coverage, produces a coverage-summary.json, and writes per-file line, branch, function, and statement percentages. The gate reads that file, compares each entry against a per-file threshold, and reports any file that falls short.
The output:
[FAIL] Coverage threshold missed: src/cli/scaffolders/unit.ts
required: 98% line, 98% branch
actual: 92% line, 88% branch
uncovered_lines: 42-48, 61
uncovered_branches: 42 (else), 61 (falsy)
hint: run `npx requirements trace matrix FEATURE-SCAFFOLD-UNIT` to
see which ACs are declared for this file, then write tests
for the uncovered cases[FAIL] Coverage threshold missed: src/cli/scaffolders/unit.ts
required: 98% line, 98% branch
actual: 92% line, 88% branch
uncovered_lines: 42-48, 61
uncovered_branches: 42 (else), 61 (falsy)
hint: run `npx requirements trace matrix FEATURE-SCAFFOLD-UNIT` to
see which ACs are declared for this file, then write tests
for the uncovered casesSeven fields. required is the per-file threshold, possibly different from the package-wide one. actual is what Vitest reported in the most recent run. uncovered_lines is the line-range list, parsed from the Vitest l field; uncovered_branches is the branch list, parsed from the b field with branch-kind annotations. hint is a command to run to triangulate which Feature the file is associated with.
How the thresholds are set
The package-wide target is 100% across lines, branches, functions, and statements. This is not aspirational; it is enforced by the @frenchexdev/requirements self-gate and advertised in its CLAUDE.md as the binding contract. Per-file overrides are allowed and configured in vitest.config.ts:
coverage: {
thresholds: {
100: true,
'src/cli/**': {
lines: 98,
branches: 98,
functions: 100,
statements: 98,
},
'src/**/smoke.ts': {
lines: 100,
branches: 100,
},
},
}coverage: {
thresholds: {
100: true,
'src/cli/**': {
lines: 98,
branches: 98,
functions: 100,
statements: 98,
},
'src/**/smoke.ts': {
lines: 100,
branches: 100,
},
},
}The 100: true shorthand is Vitest's way of saying "apply 100% to every file not matched by a more specific rule". The src/cli/** override loosens to 98% — CLI code often has platform-gated branches that are legitimately hard to reach from the unit tree. The src/**/smoke.ts line re-tightens to 100% on smoke files, which are small and should always be fully covered. The gate reads the Vitest config and applies the same thresholds.
How the scanner reads coverage
src/analysis/compliance-core.ts imports coverage-summary.json under whichever path the Vitest config produces (usually test-results/<runId>/coverage/coverage-summary.json) and flattens the per-file entries. The findLatestCoverageSummary function walks the timestamped run directories and picks the most recent; if the coverage file is missing, the gate prints a [WARN] and proceeds without coverage data, which is itself a signal — the test suite did not produce coverage on the last run.
The scanner does not re-run Vitest. It consumes whatever the last run produced. This is deliberate: running the full test suite inside the compliance gate would make the gate too slow to use as a pre-commit hook. Instead, the recommended workflow is npm run test && npx requirements compliance --strict — one shell-and, two commands, seconds apart.
The minimal fix — write the test
For lines 42-48 of src/cli/scaffolders/unit.ts, open the file, read what the lines do, write a test that exercises them:
// test/unit/cli/scaffolders/unit.test.ts
@FeatureTest(ScaffoldUnitFeature)
class UncoveredPathTests {
@Verifies<ScaffoldUnitFeature>('handlesEmptyAcList')
'handles feature with no ACs by emitting a placeholder'() {
const output = generate(featureWithNoAcs, []);
expect(output).toContain('// no ACs declared');
}
}// test/unit/cli/scaffolders/unit.test.ts
@FeatureTest(ScaffoldUnitFeature)
class UncoveredPathTests {
@Verifies<ScaffoldUnitFeature>('handlesEmptyAcList')
'handles feature with no ACs by emitting a placeholder'() {
const output = generate(featureWithNoAcs, []);
expect(output).toContain('// no ACs declared');
}
}Re-run npm run test, then the gate. The lines go green; the delta closes; the [FAIL] disappears.
Why per-file thresholds exist
The 100% package-wide target is aspirational in theory and enforceable in practice — for a package authored from scratch with coverage as a first-class constraint. But src/cli/** is a different register: CLI code deals with terminal detection, signal handling, operating-system differences, prompt loops that are only reachable through interactive input. Some branches in that code are legitimately hard to cover at the unit level. Writing a test that forces Node.js to believe it is running inside a detached TTY, or a test that simulates a SIGINT during a prompt dialogue, is possible but fragile; the tests become worse proofs than the code they test.
The 98% threshold on src/cli/** acknowledges this. It is not a permissive override; it is a calibrated one, chosen so that the unreachable branches account for a countable number of lines across the CLI subtree and nothing more. If a file's coverage drops to 97%, the gate still fails — the override is a floor, not a ceiling.
Two rules of thumb for introducing a per-file override:
- Only when the override is structural. "This file is hard to test" is not a reason; "this file contains a Node.js version guard that can only be exercised by running on an unsupported Node.js version" is.
- Only with a matching fit criterion. The override lives in
vitest.config.ts, but the rationale lives in the Requirement — specifically, in thefitCriteriaarray ofREQ-QUALITY-GATEor a refinement thereof. The override and the Requirement are versioned together; changing one without the other is noise.
Thresholds are policy. Policy is requirement. Requirement is versioned. The gate is the enforcer, not the author.
The escape hatch — waiver
For a line that is genuinely unreachable under test (Node.js-only branches inside a browser-only file, hardware-port probes, final-fallback error handlers that would require corrupting the file system), Vitest supports /* v8 ignore next N */ comments, and the gate honours them. But the fit criterion for REQ-QUALITY-GATE requires an accompanying rationale comment — a single sentence explaining why the waiver is correct. The compliance report surfaces waivers as an information section, so reviewers can audit them.
/* v8 ignore next 3 */
// Reachable only when the filesystem is read-only at module load time;
// tested manually on the CI container, not reproducible under Vitest.
if (fs.existsSync(configPath) && !isWritable(configPath)) {
throw new ReadOnlyFilesystemError();
}/* v8 ignore next 3 */
// Reachable only when the filesystem is read-only at module load time;
// tested manually on the CI container, not reproducible under Vitest.
if (fs.existsSync(configPath) && !isWritable(configPath)) {
throw new ReadOnlyFilesystemError();
}Three lines of code, one line of rationale. The gate passes. The report lists the waiver. The next reviewer gets the why.
A real transcript
The first real coverage failure in this package came from src/cli/scaffolders/unit.ts, specifically the branch that handled a Feature with zero abstract methods. The default path — a Feature with N > 0 ACs — had been exercised by every test. The N = 0 path was a defensive fallback that emitted a // no ACs declared comment, and no test had ever hit it. Vitest happily reported 94% line coverage on that file; the gate, configured at 98%, printed:
[FAIL] Coverage threshold missed: src/cli/scaffolders/unit.ts
required: 98% line, 98% branch
actual: 94% line, 83% branch
uncovered_lines: 38-42
uncovered_branches: 38 (if-false), 61 (ternary-else)
hint: run `npx requirements trace matrix FEATURE-SCAFFOLD-UNIT` to
see which ACs are declared for this file, then write tests
for the uncovered cases[FAIL] Coverage threshold missed: src/cli/scaffolders/unit.ts
required: 98% line, 98% branch
actual: 94% line, 83% branch
uncovered_lines: 38-42
uncovered_branches: 38 (if-false), 61 (ternary-else)
hint: run `npx requirements trace matrix FEATURE-SCAFFOLD-UNIT` to
see which ACs are declared for this file, then write tests
for the uncovered casesThe hint line led me straight at the Feature file. I read it, found that FEATURE-SCAFFOLD-UNIT had an AC called handlesFeatureWithNoAbstractMethods — declared but not yet verified. I wrote the test, re-ran npm run test, re-ran the gate:
Coverage: 100% lines, 100% branches, 100% functions, 100% statements
Quality gate: PASS
exit 0Coverage: 100% lines, 100% branches, 100% functions, 100% statements
Quality gate: PASS
exit 0The loop from [FAIL] to PASS took three minutes: read the hint, open the Feature, find the AC, scaffold the test, run, done. The fact that the scanner's hint field named the Feature by id, rather than forcing me to grep for which Feature owned the file, is the kind of small ergonomic that cumulates — every failure that takes 30 seconds fewer to diagnose is a failure that feels tractable rather than obstructive.
Failure mode 6 — @Refines cycle
The sixth mode is the rarest and the most structural. The @Refines(ParentReq) decorator declares that one Requirement is a specialisation of another — ReqContrast refines ReqAccessibility, ReqUnitCoverage refines ReqQualityGate, and so on. The refinement relation is meant to form a tree (or, if you want to allow a Requirement to refine several parents, a directed acyclic graph). A cycle — A refines B refines C refines A — is a bug in the vocabulary, and the gate refuses to let it ship.
The output:
[FAIL] @Refines cycle detected: REQ-A -> REQ-B -> REQ-C -> REQ-A
edges:
REQ-A refines REQ-B (requirements/requirements/req-a.ts:8)
REQ-B refines REQ-C (requirements/requirements/req-b.ts:9)
REQ-C refines REQ-A (requirements/requirements/req-c.ts:7)
fix: break the cycle by deleting one @Refines edge, or by collapsing
two of the three Requirements into one[FAIL] @Refines cycle detected: REQ-A -> REQ-B -> REQ-C -> REQ-A
edges:
REQ-A refines REQ-B (requirements/requirements/req-a.ts:8)
REQ-B refines REQ-C (requirements/requirements/req-b.ts:9)
REQ-C refines REQ-A (requirements/requirements/req-c.ts:7)
fix: break the cycle by deleting one @Refines edge, or by collapsing
two of the three Requirements into oneFive lines of payload. The edges list names every @Refines call involved in the cycle, with file and line numbers. The cycle is printed as a comma-free arrow chain so it is easy to read.
How the scanner detects it — the algorithm
The detection is a DFS with cycle-flagging on the @Refines graph. src/analysis/compliance-core.ts builds the graph by walking every Requirement file, extracting @Refines(ParentClass) decorator arguments, and resolving each parent class to its Requirement id via the same className → requirementId map used for mode 2. Then it runs a standard three-colour DFS; every back-edge is a cycle, and the path from the back-edge target to the back-edge source is the cycle payload.
The scanner reports at most one cycle per run — finding additional cycles would require continuing past the first back-edge, which is cheap but produces noisier output. In practice, breaking the reported cycle and re-running is enough; if there are two independent cycles, the second one will show up on the second run.
Why cycles are forbidden
Three reasons, all structural.
First, the refinement relation has to terminate. A reader of the requirements graph has to be able to ask "what is the most general Requirement this chain refines?" and get an answer. A cycle makes the answer undefined; the walk never ends.
Second, the compliance matrix rolls up along the refinement tree. A Requirement's coverage is the intersection of its direct Features' coverage and its children's rolled-up coverage. A cycle creates a roll-up that depends on itself — a mathematical fixed-point problem with no obvious unique solution, and certainly no solution a one-pass scanner can compute. Refusing cycles at the gate is cheaper than inventing a convention for cyclic roll-ups.
Third, the human-reviewer story breaks. If ReqA refines ReqB because it is a more specific version of ReqB, and ReqB refines ReqA in turn, then either the refinement label is wrong on one of the edges, or the two Requirements are actually the same Requirement seen from two angles. Either way, the cycle is an indictment of the vocabulary, not of the graph.
The SysML precedent matters here. The refinement relation is a direct import from SysML's «refine» / «derive» stereotypes, which in the systems-engineering literature are explicitly required to form a DAG. The reason is the same in SysML as in this package: the roll-up semantics only work on a DAG, and the human-reviewer story only works on a DAG. Every published requirements methodology that includes a refinement relation puts the acyclicity constraint somewhere — ISO/IEC/IEEE 29148 calls it "traceability consistency", Volere calls it "parent-requirement well-formedness", EARS does not use the word but enforces the same rule implicitly through its pattern-nesting discipline. The gate is not inventing a constraint; it is mechanising a widely agreed-upon one.
The minimal fix — delete an edge
If the cycle is caused by one edge that is not a real refinement — often the last one added, by a contributor who saw two Requirements touching the same subject and assumed they were related — delete that edge:
@Refines(ReqAccessibilityRequirement)
-@Refines(ReqQualityGateRequirement)
export abstract class ReqContrastRequirement extends Requirement<DefaultStyle> { @Refines(ReqAccessibilityRequirement)
-@Refines(ReqQualityGateRequirement)
export abstract class ReqContrastRequirement extends Requirement<DefaultStyle> {Re-run. Cycle gone.
The minimal fix — collapse
If two of the three Requirements in the cycle are actually the same policy, collapse them. Keep one, delete the other, grep-replace every @Refines(Deleted) and @Satisfies(Deleted) to reference the survivor. The requirements requirement sync tool helps here: it produces the diff across every file that references the deleted class, and applies the replacement in one pass. The remaining Requirement's refines edge becomes unambiguous, and the cycle is structurally resolved rather than merely broken.
The minimal fix — re-parent
Sometimes the cycle exists because ReqA and ReqB are both legitimate Requirements, but the refinement edge is in the wrong direction. Flip it:
-@Refines(ReqBRequirement)
+@Refines(ReqARequirement)
export abstract class ReqBRequirement extends Requirement<DefaultStyle> {-@Refines(ReqBRequirement)
+@Refines(ReqARequirement)
export abstract class ReqBRequirement extends Requirement<DefaultStyle> {This requires care — the refinement hierarchy is load-bearing for the compliance roll-up — but in practice, when an edge is in the wrong direction, the fix is obvious once the cycle is printed.
A real transcript
In the early days of this package, REQ-DOG-FOOD was declared to refine REQ-QUALITY-GATE (because the dog-food rule is one way the gate stays honest), and REQ-QUALITY-GATE was declared to refine REQ-DOG-FOOD (because the gate is, itself, an example of the package validating itself with itself). Both edges were defensible in isolation; together they formed a two-cycle, and the gate printed:
[FAIL] @Refines cycle detected: REQ-QUALITY-GATE -> REQ-DOG-FOOD -> REQ-QUALITY-GATE
edges:
REQ-QUALITY-GATE refines REQ-DOG-FOOD (requirements/requirements/req-quality-gate.ts:11)
REQ-DOG-FOOD refines REQ-QUALITY-GATE (requirements/requirements/req-dog-food.ts:9)
fix: break the cycle by deleting one @Refines edge, or by collapsing
two of the three Requirements into one[FAIL] @Refines cycle detected: REQ-QUALITY-GATE -> REQ-DOG-FOOD -> REQ-QUALITY-GATE
edges:
REQ-QUALITY-GATE refines REQ-DOG-FOOD (requirements/requirements/req-quality-gate.ts:11)
REQ-DOG-FOOD refines REQ-QUALITY-GATE (requirements/requirements/req-dog-food.ts:9)
fix: break the cycle by deleting one @Refines edge, or by collapsing
two of the three Requirements into oneReading the two edges side by side made the resolution obvious. REQ-DOG-FOOD is the broader principle — "validate the package with itself, using the same DSL" — and REQ-QUALITY-GATE is one specific expression of that principle. The refinement is REQ-QUALITY-GATE refines REQ-DOG-FOOD; the other direction was wrong. The fix was a one-line diff:
-@Refines(ReqQualityGateRequirement)
export abstract class ReqDogFoodRequirement extends Requirement<DefaultStyle> {-@Refines(ReqQualityGateRequirement)
export abstract class ReqDogFoodRequirement extends Requirement<DefaultStyle> {One edge deleted. Cycle gone. The other edge — REQ-QUALITY-GATE refines REQ-DOG-FOOD — stayed, because it captured the right relation. Re-ran the gate; green; committed.
The lesson from this case is that a refinement cycle is usually a disagreement about which Requirement is the more general one. Printing the cycle as a chain forces the question; the question usually has a clear answer. The gate is the forcing function.
Cycles of length one
A special case worth noting: the gate also detects @Refines(Self) — a Requirement that claims to refine itself. The detection is the same DFS; the cycle is a one-node loop. The output is a simplified version of the multi-node case:
[FAIL] @Refines self-loop detected: REQ-A refines REQ-A
file: requirements/requirements/req-a.ts:7
fix: remove the @Refines self-reference[FAIL] @Refines self-loop detected: REQ-A refines REQ-A
file: requirements/requirements/req-a.ts:7
fix: remove the @Refines self-referenceThis failure is almost always a copy-paste mistake, often when a new Requirement file is scaffolded from a similar existing one and the @Refines argument is not updated. The fix is always to remove the decorator (or, rarely, to point it at the intended parent). The gate catches the mistake before it propagates into the matrix.
Diagram 1 — violation taxonomy
Alt text: Flowchart of the compliance gate as a decision tree. The gate asks six questions in parallel — does every Feature have an @Satisfies decorator, does every @Satisfies reference a live Requirement, does every Critical AC have at least one @Verifies, is every @Verifies string a valid keyof the target Feature, does every source file meet its coverage threshold, is the @Refines graph acyclic. Each "no" answer branches into a FAIL diagnostic with a canonical fix; each "yes" answer routes to a green exit. The fourth branch is caught by the TypeScript compiler before the scanner runs.
Caption: The six questions the gate asks, with their canonical fixes. Use this diagram as a lookup next time the gate goes red: locate the diagnostic header in the left column, read the fix in the right column.
Diagram 2 — recovery flow
Alt text: State diagram of the recovery loop. The gate starts in Red. The developer reads the diagnostic output, locates the file and line named in each failure, classifies the failure against one of the six modes from this chapter, applies the canonical fix, and re-runs the gate. The re-run either transitions to Green (exit zero, done) or re-enters Red (more failures to address). The loop is bounded: each iteration fixes at least one line, so the total number of iterations equals the initial failure count.
Caption: The recovery loop is mechanical. Red becomes Green through a sequence of local edits, each grounded in a specific scanner diagnostic. If a fix uncovers a new failure, the loop re-enters; each iteration strictly reduces the failure count.
Running-example recap
The trace-explorer TUI Feature, carried across this series as the running example, is the shape of a Feature that hits none of these failure modes in normal use.
It has three @Satisfies entries — ReqDiscoverableTraceability, ReqDogFood, ReqParallelDeliverable — so mode 1 does not apply. All three Requirements are live, exported from their respective files, and referenced in the runtime registry; mode 2 does not apply. Every AC has a single test class with a @Verifies decorator; mode 3 does not apply. Every @Verifies string is a valid keyof the Feature class, validated by the compiler at editor-save; mode 4 does not apply. The test classes collectively exercise the production code under src/bin/requirements.ts's explore subtree to the 100% line-and-branch threshold; mode 5 does not apply. The three satisfied Requirements are leaves in the refinement graph (none uses @Refines); mode 6 does not apply.
The feature walks through green. The chapter's walkthrough is a safety net for the moments between green states — the moments when a Feature is mid-authoring, mid-refactor, mid-rename, mid-deletion. It is not the routine path. The routine path is npm run test && npx requirements compliance --strict, both green, both quiet.
The safety net exists for two reasons. First, because the moments between green states are when things break, and knowing the shape of each break in advance reduces the time it takes to read and fix it. Second, because the existence of the safety net changes how willing a contributor is to make those changes. A refactor that goes through five intermediate red states on its way to a sixth green state is only tolerable when each red state is legible; illegible reds are how codebases calcify. The six diagnostics in this chapter are the mechanism by which the gate stays honest without becoming a wall.
How the modes compose
One final observation. The six modes look independent on the surface — each section of this chapter treats its mode as a self-contained failure, with its own fix — but in practice they compose. A single refactor can trigger several modes in sequence, and the order in which the gate surfaces them matters.
Consider the retirement of a Requirement. The author deletes ReqDeprecatedRequirement.ts. The gate runs.
First pass, the gate reports mode 2: three dangling @Satisfies edges pointing at the deleted Requirement. The author edits each Feature to remove the argument. Second pass.
Second pass, one of those Features now has an empty @Satisfies list — the deleted Requirement was its only parent. The gate reports mode 1: an orphan Feature. The author considers, decides the Feature is actually a Requirement in disguise (case A), deletes it with requirements feature delete, and creates the Requirement properly. Third pass.
Third pass, two of the test classes that had been verifying ACs on the now-deleted Feature are now pointing at a class that does not exist. The TypeScript compiler catches this (mode 4's territory) with an error TS2304: Cannot find name 'FeatureDeletedFeature'. The author deletes the test files. Fourth pass.
Fourth pass, a Critical AC on a different Feature that had been incidentally covered by one of those deleted tests is now uncovered. The gate reports mode 3. The author writes a replacement test. Fifth pass.
Fifth pass, coverage is now at 99.2% on the file that held the old test, below the 100% threshold. The gate reports mode 5. The author investigates, finds a helper function that was only exercised by the deleted tests, decides it is dead code, removes it. Sixth pass.
Sixth pass, green.
Five red states, one refactor. Each red state was legible, each fix was local, each pass strictly reduced the failure count. This is the recovery-loop diagram from earlier in this chapter in its concrete form: Red → Read → Locate → Apply → Re-run, iterate, terminate on green. The gate did not block the refactor; it structured it.
The cost model
A concluding thought about what the gate costs. A fast scanner (sub-second), a fast TypeScript pass (a few seconds incremental, tens of seconds cold), a fast Vitest run (seconds if you target the changed tests, minutes for the full suite). Total worst-case round-trip from edit to green: under a minute. Total best-case (mode 4, compiler-only): milliseconds.
Against that, a bug that slipped through would cost a debug session, a bisect, a hotfix, a review cycle, a retrospective, maybe an incident report. Orders of magnitude more expensive. The gate is not a tax; it is an insurance policy with a premium small enough that paying it on every commit is rational.
The point of this chapter — and, in a sense, the point of the entire @frenchexdev/requirements package — is that the premium stays small only if the diagnostics are good. A scanner that printed "compliance failed" without a file, a line, a reason, and a fix would be worse than no scanner at all; it would produce compliance theatre, where developers add flags to suppress the noise and the gate gradually becomes optional. The six diagnostics in this chapter are chosen, named, and shaped to be the opposite: specific enough that the fix is obvious, uniform enough that the shape of the fix is learnable, bounded enough that the total set fits in a single chapter.
A gate that refuses to ship until the WHY is written is only a gate worth shipping if, when it refuses, it tells you exactly what to write. That is what these six diagnostics do.
The diagnostics as a design signature
Look at the six modes in aggregate and the design signature of the gate comes into focus. Every mode corresponds to one missing link in the four-tier chain Requirement → Feature → AC → Test:
- Mode 1 (orphan Feature) — the
Feature → Requirementlink is missing at the Feature's own declaration site. - Mode 2 (dangling edge) — the
Feature → Requirementlink is declared but broken at the target. - Mode 3 (under-covered Critical AC) — the
Test → AClink is missing where the Priority makes it mandatory. - Mode 4 (typo'd AC) — the
Test → AClink is declared but broken at the target (caught by the type system, not the gate). - Mode 5 (coverage threshold) — the
Test → ACcoverage, as observed at runtime, is below the agreed contract. - Mode 6 (refines cycle) — the
Requirement → Requirementrefinement graph is ill-formed.
Each of the three inter-tier links (Feature → Requirement, Test → AC, Requirement → Requirement) has a "missing" failure and a "broken" failure. The gate covers both halves of each pair. There is no seventh mode — or rather, a seventh mode would have to correspond to a link the chain does not declare, which is by construction not possible. The six modes exhaust the failure space.
This is not accident; it is the consequence of making the chain itself the source of truth. A DSL that models Requirement, Feature, AC, and Test as typed entities with typed edges between them can enumerate every edge, check each for existence, and emit a diagnostic per edge type. The enumeration is finite; the diagnostics are finite; the chapter covering them is finite. Contrast with a configuration-file-driven compliance system, where the set of rules is open-ended and every new rule adds a new failure mode that no-one has documented — the chapter on diagnostics in such a system cannot be finite, because the rules themselves are not.
A checklist for the moment the gate goes red
When the gate first goes red after a clean run — a moment that will, inevitably, happen — run through this checklist in order:
- Read the headline. Which of the six modes? The name is the anchor.
- Open the file. The
file:line gives you an exact path and line. Land on it. - Understand the reason. What did the scanner observe? Phrase it back in your own words.
- Identify the intent. What was the last edit that might have triggered this? (A Requirement deletion? A refactor? A copy-paste?)
- Pick the canonical fix. The
fix:line names the edit. If multiple are listed, pick the one that matches your intent. - Apply the fix. Local edit; no wider refactor at this step.
- Re-run the gate. Either green, or a new failure. If new, iterate from step 1.
Seven steps, most of them seconds. The loop is short by construction; the gate is designed so that the iterations converge fast.
If iteration does not converge — if re-running produces a failure that is equivalent to the one just fixed, or a failure the fix should have prevented — the problem is almost always a stale cache. rm -rf .vitest-cache node_modules/.cache, re-run npm run test to produce a fresh coverage file, and re-run the gate. In three years of running this pattern across several packages, I have not seen a case where a real gate bug survived a cache purge.
One closing story
The most instructive gate failure I have seen in this repository was also the smallest. A commit renamed an AC method from emitsWarningWhenPriorityConflicts to emitsWarningOnPriorityConflict. The rename happened across three files — the Feature class and two test classes — and every site was updated in the same commit. The gate ran; the gate was green; the commit shipped.
Two days later, a reviewer running requirements trace matrix FEATURE-VERSIONING on an unrelated task noticed that the coverage column for the old AC name was still present, with a (stale) flag. The gate had not reported it, because the AST walk had nothing to say — the old name no longer appeared anywhere in the code. But the trace registry, populated at import time from the decorator calls, still carried an entry from the previous run of the test suite that had written to a cache file.
The fix was trivial (purge the cache; re-run; the stale entry was gone), but the lesson was not. The gate is grounded in source; it cannot report drift in files it does not scan. Caches, snapshots, baselines — every durable artefact that the gate does not read is a place where staleness can accumulate. Chapter 14 will walk the AST-extraction layer in detail and explain how the registry is reconstructed on every run to avoid precisely this class of bug.
For now, the minimal posture is: when the gate says green, trust the gate; when you notice something the gate did not catch, do not blame the gate — ask what it was looking at. The scanner reads what you point it at. What you do not point it at, it cannot see.
Related Reading
- Chapter 13 — compliance matrix, read left to right — the green path; what the gate prints when everything works.
- Chapter 13b — compliance with a coverage delta — the one-step-away path; what the gate prints when a single AC is under-covered.
- typed-specs/06-compliance.md — the predecessor article; the 300-line scanner this package evolved from.
- quality-gate.md — the package-wide quality gate discussion; how thresholds are chosen.
- hardening-test-pipeline.md — the pipeline that runs the gate in CI-equivalent-on-laptop.
Previous: Chapter 13b — compliance with a coverage delta Next: Chapter 14 — AST extraction and the registry