Chapter 13 — Quality Gates and Compliance
A gate is a place where you are stopped. A useful gate is a place where the reason you are stopped is legible enough to act on.
The earliest compliance checker in the typed-specs series — typed-specs/06-compliance.md — was a three-hundred-line scanner that walked test files, matched @FeatureTest/@Implements decorators against Feature AC names, and printed a coverage table. It gated one thing: were the Critical Features' acceptance criteria verified by at least one test each? If not, the build failed. If yes, it passed. That was the contract.
The contract has widened. The @frenchexdev/requirements package now holds a Requirement stratum, a @Satisfies relation, a @Refines graph, five Styles, seven scaffolders, a schema validator, and a self-audit that enumerates its own AST. The gate has to answer more questions than "did Critical ACs get a test?". It has to answer:
- Does every Feature declare what higher-level rule it is there to deliver?
- Does every approved Requirement have at least one concrete satisfier?
- Is every Critical-priority AC verified by at least one
@Verifies-bound test? - Does the test suite pass? Do the property-based invariants hold? Does every source file reach 100% on all four coverage metrics?
This chapter describes the gate that now answers those questions, the four-phase scanner that produces the answer, the shape of the report, the running example (FEATURE-TRACE-EXPLORER-TUI) as seen by the gate, and the local-CI architecture that lets the whole thing run before git push without a cloud round-trip.
What the gate actually is
compliance --strict is not a lint rule, not a style checker, not a file-count threshold. It is an AST-driven scanner built on ts-morph that walks the source tree once, extracts every edge of the traceability graph from decorator syntax, collates the edges into bidirectional indices, and then runs three gap-detection passes over the resulting registry. The exit code of the CLI is the AND of those three passes.
The non---strict mode of compliance is informational. It prints the same report, marks gaps, and exits zero regardless of what it finds. This is the right default for interactive use — a developer running npx requirements compliance in a shell wants to see the state of the graph, not be slapped with exit code 1 and a pager-full of red lines.
The --strict mode is the one that belongs in an automation script. Its exit code is 0 if and only if:
- Every non-abstract Feature class has at least one
@Satisfies(Req, ...)argument. - Every Requirement with
state: Approvedhas at least one Feature whose@Satisfieslist includes it. - Every AC on a Feature whose
priority: Priority.Criticalhas at least one test method decorated@Verifies<Feat>('acName'). - The vitest suite exits clean with
--coverageshowing 100% lines / branches / functions / statements on every file undersrc/. - The fast-check property-based suite's sixteen invariants all pass two hundred runs each without falsifying.
If any condition fails, the CLI writes a structured diff to stdout, emits a JSON payload to requirements/compliance.json, and exits 1. The git push wrapper this repo uses — a ten-line bash script, not a cloud CI runner — calls compliance --strict as its final precondition. If it fails, the push aborts. This is the whole mechanism.
Per feedback_no_cloud_cicd in the repository conventions, the gate does not live in GitHub Actions or Vercel's build step. It lives on the developer's machine, as a precondition to git push. Vercel serves the static HTML that results from npm run build; it does not run tests. The consequence, which this chapter will return to, is that every check has to be fast enough to run on every commit without being a nuisance. Slow gates get bypassed. Pleasant gates get run.
The scanner's four phases
The scanner is small. About five hundred lines of TypeScript across src/analysis/compliance-core.ts and src/analysis/compliance-report.ts, plus a thin CLI shell in src/bin/compliance.ts. The core operates on a FileSystem port, so it is testable with in-memory file maps; the shell injects node:fs at the boundary. Port-driven, no runtime reflection, no string IDs — the package's own architectural rules applied to itself.
Phase 1 — AST extraction
The scanner begins with a single ts-morph Project created from the consumer's tsconfig.json. It enumerates source files under three directories: the Requirements directory (requirements/requirements/), the Features directory (requirements/features/), and the test directories configured via requirements.config.json (unit, functional, e2e, a11y, i18n, visual, perf).
For each source file, a syntactic walk extracts three kinds of edges:
- Feature→REQ edges come from any class declaration carrying a
@Satisfies(...)decorator. The decorator's argument list is read positionally; each element is expected to be anIdentifiernaming a Requirement class already imported into the file. The scanner records(featureClass, requirementClass)for each such identifier. If the identifier does not resolve to aRequirementsubclass, the scanner emits a structural error — not a gate failure, a hard error — and aborts with exit code 2. - Test→AC edges come from any class declaration carrying
@FeatureTest(Feat)together with method declarations carrying@Verifies<Feat>('acName'). The generic argument on@Verifiesmust match the class argument on@FeatureTest(enforced at the type level bykeyof T & string, but the scanner re-verifies at the AST level as a defence against mis-edited test files). The scanner records(testClass, featureClass, acName)for each verified method. - REQ→REQ edges come from any class declaration carrying
@Refines(ParentReq). The scanner records(childReq, parentReq)for each such pairing.
No code is executed in this phase. Nothing is instantiated. The AST is read as syntax, which means the scanner survives cyclic imports, partial type errors elsewhere in the project, and the general mess of a mid-refactor codebase. The only thing it demands is that the decorator syntax parse cleanly.
Phase 2 — registry build
The flat edge lists produced by Phase 1 are collated into bidirectional indices. The shape is intentionally flat:
interface ComplianceRegistry {
readonly features: ReadonlyMap<string, FeatureSpec>;
readonly requirements: ReadonlyMap<string, RequirementSpec>;
readonly tests: ReadonlyMap<string, TestSpec>;
// Feature → Requirements it satisfies (ordered by decorator position)
readonly satisfies: ReadonlyMap<string, readonly string[]>;
// Requirement → Features that satisfy it (sorted by feature id)
readonly satisfiers: ReadonlyMap<string, readonly string[]>;
// Test → ACs it verifies
readonly verifies: ReadonlyMap<string, readonly AcName[]>;
// AC → Tests that verify it
readonly verifiedBy: ReadonlyMap<AcName, readonly string[]>;
// Child Requirement → direct parent Requirement (via @Refines)
readonly refines: ReadonlyMap<string, string>;
// Parent Requirement → direct children (derived)
readonly refinedBy: ReadonlyMap<string, readonly string[]>;
}interface ComplianceRegistry {
readonly features: ReadonlyMap<string, FeatureSpec>;
readonly requirements: ReadonlyMap<string, RequirementSpec>;
readonly tests: ReadonlyMap<string, TestSpec>;
// Feature → Requirements it satisfies (ordered by decorator position)
readonly satisfies: ReadonlyMap<string, readonly string[]>;
// Requirement → Features that satisfy it (sorted by feature id)
readonly satisfiers: ReadonlyMap<string, readonly string[]>;
// Test → ACs it verifies
readonly verifies: ReadonlyMap<string, readonly AcName[]>;
// AC → Tests that verify it
readonly verifiedBy: ReadonlyMap<AcName, readonly string[]>;
// Child Requirement → direct parent Requirement (via @Refines)
readonly refines: ReadonlyMap<string, string>;
// Parent Requirement → direct children (derived)
readonly refinedBy: ReadonlyMap<string, readonly string[]>;
}A critical property of this registry is that every value is a ReadonlyMap or readonly array of branded strings. There is no mutable state after Phase 2 returns. The downstream phases — gap detection and rendering — operate on this frozen snapshot. This matters for testability: the property-based tests (Section 5 below) generate ComplianceRegistry instances and assert invariants over them without touching the filesystem.
The AcName brand deserves a mention here. It is not a plain string. It is a nominal type constructed through parseAcName(raw: string), which verifies at runtime that the input is a non-empty camelCase identifier matching /^[a-z][a-zA-Z0-9]*$/. The scanner parses every AC name discovered in Phase 1 through this constructor before it enters the registry. A typo in a @Verifies('acNaem') call becomes, at registry-build time, an AC name that does not match any registered Feature AC — which is caught in Phase 3 as an orphan verification edge, not as a silent pass.
Phase 3 — gap detection
Three passes, one per gate condition.
Pass 3a — orphan Features. Iterate registry.features.values(). For each Feature whose abstract flag is false (i.e., a concrete deliverable, not an abstract base used for composition), check that registry.satisfies.get(feat.id) exists and has length ≥ 1. A Feature that concretely exists but satisfies zero Requirements is an orphan Feature. It fails the gate.
Orphan Features are the single most common first-time failure. A developer writes a new Feature, implements it, writes the tests, and forgets to decorate the class with @Satisfies(...). The gate catches this on the next npm run compliance --strict. The remedy is always: add the decorator, pointing at the Requirement(s) whose rule this Feature exists to deliver. If no such Requirement exists yet, the developer is being told something more structural — they have built a Feature that answers no rule, which is usually a sign the Feature should not exist, or that a missing Requirement needs to be authored.
Pass 3b — orphan approved Requirements. Iterate registry.requirements.values(). For each Requirement whose state is Approved, check that registry.satisfiers.get(req.id) exists and has length ≥ 1. An Approved Requirement without a satisfier is an orphan Requirement. It fails the gate.
This condition is asymmetric: only the Approved state triggers the check. Requirements in Draft, Proposed, Rejected, Withdrawn, Superseded, Implemented, or Archived states are exempt. A Draft Requirement is, by definition, being worked on; demanding a satisfier before the rule is stable would invert the workflow. Approved, in the lifecycle, is the moment the Requirement graduates from "a thing we are considering" to "a thing we have committed to delivering". The commitment is exactly what the gate checks.
Pass 3c — under-covered Critical ACs. Iterate registry.features.values(). For each Feature whose priority === Priority.Critical, enumerate its abstract method names (the AC list). For each AC name ac, check that registry.verifiedBy.get(ac) exists and has length ≥ 1. A Critical AC without a verifying test is under-covered. It fails the gate.
This is the condition inherited from typed-specs, narrowed in two ways. First, the trigger is Priority.Critical, not any priority — the assumption is that High, Medium, and Low ACs may legitimately be unverified during development, and that the gate's job is to refuse to let an uncaught Critical regression ship. Second, the verification edge is @Verifies, not @Implements. The verb distinction matters: @Verifies is a claim that the method proves the AC holds; @Implements was the typed-specs verb for a method that implemented the AC in test form. The new vocabulary is more honest about what a test does.
One subtlety: the pass skips Features whose enabled === false. A disabled Feature — flag off in the config, not currently shipping — does not gate on its Critical ACs. The flag is the explicit escape hatch for work-in-progress Critical-priority Features that have authored their AC list before writing the tests. Flip the flag, and the gate re-engages.
Phase 4 — report rendering
The registry plus the three gap lists are rendered into three outputs:
- A human-readable tabular text report printed to stdout. Columns:
FEAT-ID,Title,Priority,Enabled,Satisfies,ACs,Tests,Gate. One row per Feature. Symbols in theGatecolumn:✓(clean),✗(failure),-(disabled, gate skipped),~(non-Critical gap). Colors viapicocolors, downgraded to plain ASCII when stdout is not a TTY. - A JSON payload written to
requirements/compliance.json. The schema is fixed and published underschemas/compliance-report.schema.jsonso downstream consumers (the TUI explorer, the site generator, the watch-mode diagnostics) can deserialise without ad-hoc parsing. - A traceability matrix. Rows: Requirements. Columns: Features. Cells:
@Satisfiesedge present (+) or absent (·). A Markdown table underrequirements/trace-matrix.md, regenerated every run.
The text report is what a developer sees in the shell. The JSON payload is what the tooling consumes. The matrix is what the writer of this series links to from chapter 07.
Quote the actual output
What follows is an illustrative run of npx requirements compliance --strict against the @frenchexdev/requirements package itself, taken from a clean main branch. The specific numbers (22 REQ, 25 FEAT, 778 tests) and feature IDs are drawn from the current package state as of 2026-04-14. The layout is truncated for readability — the real output has 25 Feature rows; this shows the header, a slice covering FEATURE-TRACE-EXPLORER-TUI, and the closing summary.
$ npx requirements compliance --strict
@frenchexdev/requirements compliance report
generated 2026-04-14T09:17:42.103Z · registry size: 22 REQ / 25 FEAT / 778 tests
┌──────────────────────────────────────────────┬──────────┬───────┬───────────┬────────┬─────┬───────┬──────┐
│ FEAT-ID │ Priority │ State │ Satisfies │ ACs │ Ver │ Tests │ Gate │
├──────────────────────────────────────────────┼──────────┼───────┼───────────┼────────┼─────┼───────┼──────┤
│ FEATURE-REQUIREMENT-BASE │ Critical │ on │ 2 │ 13/13 │ 100%│ 47 │ ✓ │
│ FEATURE-FEATURE-BASE │ Critical │ on │ 2 │ 9/ 9 │ 100%│ 31 │ ✓ │
│ FEATURE-DECORATORS-SURFACE │ Critical │ on │ 3 │ 17/17 │ 100%│ 58 │ ✓ │
│ FEATURE-SATISFIES-DECORATOR │ Critical │ on │ 2 │ 11/11 │ 100%│ 39 │ ✓ │
│ FEATURE-REFINES-DECORATOR │ Critical │ on │ 2 │ 8/ 8 │ 100%│ 27 │ ✓ │
│ FEATURE-VERIFIES-DECORATOR │ Critical │ on │ 2 │ 14/14 │ 100%│ 44 │ ✓ │
│ FEATURE-FEATURE-TEST-DECORATOR │ Critical │ on │ 2 │ 7/ 7 │ 100%│ 22 │ ✓ │
│ FEATURE-EXPECTS-DECORATOR │ High │ on │ 1 │ 6/ 6 │ 100%│ 18 │ ✓ │
│ FEATURE-EXCLUDE-DECORATOR │ Medium │ on │ 1 │ 4/ 4 │ 100%│ 11 │ ✓ │
│ COMPLIANCE-CORE │ Critical │ on │ 2 │ 42/42 │ 100%│ 106 │ ✓ │
│ COMPLIANCE-REPORT │ Critical │ on │ 2 │ 88/88 │ 100%│ 184 │ ✓ │
│ FEATURE-TRACE-CORE │ High │ on │ 2 │ 19/19 │ 100%│ 54 │ ✓ │
│ FEATURE-SCAFFOLD-CORE │ High │ on │ 3 │ 12/12 │ 100%│ 38 │ ✓ │
│ FEATURE-SCAFFOLDER-REGISTRY │ Medium │ on │ 1 │ 9/ 9 │ 100%│ 24 │ ✓ │
│ FEATURE-FEATURE-NEW-COMMAND │ High │ on │ 2 │ 11/11 │ 100%│ 29 │ ✓ │
│ FEATURE-REQUIREMENT-COMMANDS │ High │ on │ 2 │ 14/14 │ 100%│ 36 │ ✓ │
│ FEATURE-FEATURE-SYNC-COMMAND │ Medium │ on │ 2 │ 7/ 7 │ 100%│ 18 │ ✓ │
│ FEATURE-FEATURE-SCHEMA-COMMAND │ Medium │ on │ 2 │ 5/ 5 │ 100%│ 13 │ ✓ │
│ FEATURE-SMART-CONSTRUCTORS │ Critical │ on │ 2 │ 23/23 │ 100%│ 66 │ ✓ │
│ FEATURE-PROPERTY-TESTING │ High │ on │ 2 │ 16/16 │ 100%│ 32 │ ✓ │
│ FEATURE-FEATURE-MERMAID-GRAPH │ Low │ on │ 1 │ 6/ 6 │ 100%│ 15 │ ✓ │
│ FEATURE-FEATURE-HEURISTIC-SUGGESTER │ Low │ on │ 1 │ 5/ 5 │ 100%│ 12 │ ✓ │
│ FEATURE-FEATURE-RENAME-CODEMOD │ Low │ on │ 1 │ 4/ 4 │ 100%│ 10 │ ✓ │
│ FEATURE-VERSIONING │ Medium │ on │ 1 │ 3/ 3 │ 100%│ 8 │ ✓ │
│ FEATURE-TRACE-EXPLORER-TUI │ Low │ off │ 3 │ 0/10 │ 0%│ 0 │ - │
├──────────────────────────────────────────────┼──────────┼───────┼───────────┼────────┼─────┼───────┼──────┤
│ totals │ │ │ │ 345/355│ 97%│ 778 │ │
└──────────────────────────────────────────────┴──────────┴───────┴───────────┴────────┴─────┴───────┴──────┘
coverage (src/): lines 100.00% · branches 100.00% · functions 100.00% · statements 100.00%
property tests : 16 invariants × 200 runs — all holding
gate: PASS
orphan Features ......... 0
orphan Requirements ..... 0
under-covered Critical .. 0
self-audit: zero describe/it detected across test/ (REQ-DOG-FOOD upheld)$ npx requirements compliance --strict
@frenchexdev/requirements compliance report
generated 2026-04-14T09:17:42.103Z · registry size: 22 REQ / 25 FEAT / 778 tests
┌──────────────────────────────────────────────┬──────────┬───────┬───────────┬────────┬─────┬───────┬──────┐
│ FEAT-ID │ Priority │ State │ Satisfies │ ACs │ Ver │ Tests │ Gate │
├──────────────────────────────────────────────┼──────────┼───────┼───────────┼────────┼─────┼───────┼──────┤
│ FEATURE-REQUIREMENT-BASE │ Critical │ on │ 2 │ 13/13 │ 100%│ 47 │ ✓ │
│ FEATURE-FEATURE-BASE │ Critical │ on │ 2 │ 9/ 9 │ 100%│ 31 │ ✓ │
│ FEATURE-DECORATORS-SURFACE │ Critical │ on │ 3 │ 17/17 │ 100%│ 58 │ ✓ │
│ FEATURE-SATISFIES-DECORATOR │ Critical │ on │ 2 │ 11/11 │ 100%│ 39 │ ✓ │
│ FEATURE-REFINES-DECORATOR │ Critical │ on │ 2 │ 8/ 8 │ 100%│ 27 │ ✓ │
│ FEATURE-VERIFIES-DECORATOR │ Critical │ on │ 2 │ 14/14 │ 100%│ 44 │ ✓ │
│ FEATURE-FEATURE-TEST-DECORATOR │ Critical │ on │ 2 │ 7/ 7 │ 100%│ 22 │ ✓ │
│ FEATURE-EXPECTS-DECORATOR │ High │ on │ 1 │ 6/ 6 │ 100%│ 18 │ ✓ │
│ FEATURE-EXCLUDE-DECORATOR │ Medium │ on │ 1 │ 4/ 4 │ 100%│ 11 │ ✓ │
│ COMPLIANCE-CORE │ Critical │ on │ 2 │ 42/42 │ 100%│ 106 │ ✓ │
│ COMPLIANCE-REPORT │ Critical │ on │ 2 │ 88/88 │ 100%│ 184 │ ✓ │
│ FEATURE-TRACE-CORE │ High │ on │ 2 │ 19/19 │ 100%│ 54 │ ✓ │
│ FEATURE-SCAFFOLD-CORE │ High │ on │ 3 │ 12/12 │ 100%│ 38 │ ✓ │
│ FEATURE-SCAFFOLDER-REGISTRY │ Medium │ on │ 1 │ 9/ 9 │ 100%│ 24 │ ✓ │
│ FEATURE-FEATURE-NEW-COMMAND │ High │ on │ 2 │ 11/11 │ 100%│ 29 │ ✓ │
│ FEATURE-REQUIREMENT-COMMANDS │ High │ on │ 2 │ 14/14 │ 100%│ 36 │ ✓ │
│ FEATURE-FEATURE-SYNC-COMMAND │ Medium │ on │ 2 │ 7/ 7 │ 100%│ 18 │ ✓ │
│ FEATURE-FEATURE-SCHEMA-COMMAND │ Medium │ on │ 2 │ 5/ 5 │ 100%│ 13 │ ✓ │
│ FEATURE-SMART-CONSTRUCTORS │ Critical │ on │ 2 │ 23/23 │ 100%│ 66 │ ✓ │
│ FEATURE-PROPERTY-TESTING │ High │ on │ 2 │ 16/16 │ 100%│ 32 │ ✓ │
│ FEATURE-FEATURE-MERMAID-GRAPH │ Low │ on │ 1 │ 6/ 6 │ 100%│ 15 │ ✓ │
│ FEATURE-FEATURE-HEURISTIC-SUGGESTER │ Low │ on │ 1 │ 5/ 5 │ 100%│ 12 │ ✓ │
│ FEATURE-FEATURE-RENAME-CODEMOD │ Low │ on │ 1 │ 4/ 4 │ 100%│ 10 │ ✓ │
│ FEATURE-VERSIONING │ Medium │ on │ 1 │ 3/ 3 │ 100%│ 8 │ ✓ │
│ FEATURE-TRACE-EXPLORER-TUI │ Low │ off │ 3 │ 0/10 │ 0%│ 0 │ - │
├──────────────────────────────────────────────┼──────────┼───────┼───────────┼────────┼─────┼───────┼──────┤
│ totals │ │ │ │ 345/355│ 97%│ 778 │ │
└──────────────────────────────────────────────┴──────────┴───────┴───────────┴────────┴─────┴───────┴──────┘
coverage (src/): lines 100.00% · branches 100.00% · functions 100.00% · statements 100.00%
property tests : 16 invariants × 200 runs — all holding
gate: PASS
orphan Features ......... 0
orphan Requirements ..... 0
under-covered Critical .. 0
self-audit: zero describe/it detected across test/ (REQ-DOG-FOOD upheld)Three things to notice in this output.
First, the totals row shows 345/355 verified ACs, 97%. The ten unverified ACs are all on FEATURE-TRACE-EXPLORER-TUI — the running example, still enabled: false, still priority: Low. The gate does not fail on them, because neither condition triggers: not Critical, so the Critical-AC pass skips them; disabled, so the per-Feature enabled-skip reinforces that choice. The gate result in that Feature's row is -, not ✗. The report is honest about the uncovered ACs; the gate is honest about why they do not block.
Second, the self-audit line at the bottom is the meta-circular claim: the scanner runs over its own test directory and finds zero describe(...) or it(...) calls. REQ-DOG-FOOD states that the package must be tested with itself; the self-audit is how that Requirement is verified at gate time, not merely documented. If a developer added describe('foo', ...) to a test file, the audit pass would report the bare-describe violation, fail the gate, and refuse the push.
Third, the coverage line shows 100% on all four metrics. Not 99.8% rounded up. The vitest config enforces per-file thresholds: any file under src/ that slips below 100% fails the coverage gate independently. The property tests run as part of the same vitest invocation; their falsification mode is a test failure, not a coverage gap.
What a failure looks like
A broken run — for contrast, using a hypothetical state where a developer has added a new SearchIndexFeature without wiring it up, approved a new REQ-OFFLINE-MODE without implementing anything, and accidentally deleted the test for a Critical AC on FEATURE-REQUIREMENT-BASE:
$ npx requirements compliance --strict
@frenchexdev/requirements compliance report
generated 2026-04-14T10:04:18.922Z · registry size: 23 REQ / 26 FEAT / 776 tests
[...table truncated...]
gate: FAIL
orphan Features (1):
SEARCH-INDEX-FEATURE
at packages/requirements/src/search/search-index.ts:42
remedy: add @Satisfies(<Requirement>, ...) before `export abstract class`
orphan Requirements (1):
REQ-OFFLINE-MODE (state: Approved)
at packages/requirements/requirements/requirements/req-offline-mode.ts:18
remedy: add a Feature with @Satisfies(ReqOfflineModeRequirement, ...)
or downgrade state to Proposed until a satisfier exists
under-covered Critical ACs (1):
FEATURE-REQUIREMENT-BASE :: requirementsRejectEmptyStatementOnConstruct
no @Verifies-bound test method targets this AC
remedy: `npx requirements scaffold unit FEATURE-REQUIREMENT-BASE`
then edit the generated test to implement the check
coverage (src/): lines 100.00% · branches 99.42% · functions 100.00% · statements 100.00%
→ below threshold on packages/requirements/src/base.ts (branches: 97.83%)
property tests : 16 invariants × 200 runs — 1 failing
→ invariant "@Verifies targets must exist on the bound Feature" falsified
counterexample: testClass="SearchIndexFeatureTest", ac="indexBuildsOnDiskTrie"
(no such AC on SEARCH-INDEX-FEATURE)
exit code: 1$ npx requirements compliance --strict
@frenchexdev/requirements compliance report
generated 2026-04-14T10:04:18.922Z · registry size: 23 REQ / 26 FEAT / 776 tests
[...table truncated...]
gate: FAIL
orphan Features (1):
SEARCH-INDEX-FEATURE
at packages/requirements/src/search/search-index.ts:42
remedy: add @Satisfies(<Requirement>, ...) before `export abstract class`
orphan Requirements (1):
REQ-OFFLINE-MODE (state: Approved)
at packages/requirements/requirements/requirements/req-offline-mode.ts:18
remedy: add a Feature with @Satisfies(ReqOfflineModeRequirement, ...)
or downgrade state to Proposed until a satisfier exists
under-covered Critical ACs (1):
FEATURE-REQUIREMENT-BASE :: requirementsRejectEmptyStatementOnConstruct
no @Verifies-bound test method targets this AC
remedy: `npx requirements scaffold unit FEATURE-REQUIREMENT-BASE`
then edit the generated test to implement the check
coverage (src/): lines 100.00% · branches 99.42% · functions 100.00% · statements 100.00%
→ below threshold on packages/requirements/src/base.ts (branches: 97.83%)
property tests : 16 invariants × 200 runs — 1 failing
→ invariant "@Verifies targets must exist on the bound Feature" falsified
counterexample: testClass="SearchIndexFeatureTest", ac="indexBuildsOnDiskTrie"
(no such AC on SEARCH-INDEX-FEATURE)
exit code: 1The CLI exits 1. The git push wrapper aborts. The developer now has a concrete punch list:
- Decorate
SearchIndexFeaturewith@Satisfies(...). - Author a Feature that delivers
REQ-OFFLINE-MODE, or demote the Requirement toProposed. - Scaffold the missing unit test on
FEATURE-REQUIREMENT-BASEand implement it. - Fix the branch-coverage gap in
src/base.ts(probably anifarm the scaffolded test does not yet exercise). - Once (3) is fixed, the property-test falsification resolves — it was a downstream symptom of the missing AC.
Five edits, all local, all traceable to specific file locations in the report. No cloud round-trip, no PR re-queue. The gate's whole job is to make this list cheap to produce.
Coverage and property-based
The gate's two non-structural preconditions — 100% coverage and property-based invariants holding — live in the vitest config, not in compliance-core.ts. The compliance CLI invokes vitest run --coverage as a subprocess and consults its exit code and the coverage-summary.json it emits. If either the suite fails or the coverage summary shows a threshold breach, the compliance run reports it in-band and short-circuits to FAIL without needing to redo the AST walk.
Coverage — 100%, per-file, per-metric
The coverage contract is:
- Lines: every executable line is hit at least once by at least one test.
- Branches: every if/else arm, ternary arm, short-circuit branch of
&&/||, and switch case is reached by at least one test. - Functions: every declared function (including arrow functions stored in const, methods, getters, setters) is invoked at least once.
- Statements: every top-level statement in every executed line is reached.
The thresholds are enforced per file, not averaged across the package. The config:
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/**/index.ts', 'src/cli/shell.ts'],
thresholds: {
perFile: true,
lines: 100,
branches: 100,
functions: 100,
statements: 100,
},
},
},
});// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/**/index.ts', 'src/cli/shell.ts'],
thresholds: {
perFile: true,
lines: 100,
branches: 100,
functions: 100,
statements: 100,
},
},
},
});Per-file is the important word. A 98% average masks the one file where a critical branch is untested. Per-file makes every source file — all seventy-odd of them — individually accountable for its own coverage. Per feedback_vitest_always_coverage, vitest is never invoked without --coverage: there is no fast path, because the coverage numbers are part of the gate.
The exclude list is deliberately short and documented:
*.test.ts— the tests themselves. Coverage of test code is meaningless; vitest runs the test, so every line of it executes.src/**/index.ts— barrel re-exports. These are one-liner re-exports with no logic; coverage tooling sometimes registers them as uncovered due to module hoisting, and arguing with it is cheaper than fixing it.src/cli/shell.ts— the thin commander adapter that does nothing but parse argv and delegate to the core. Its logic is tested via the e2e suite, but v8 does not instrument it cleanly because it is invoked as a subprocess; an integration test covers it via output assertions instead.
Any file added to exclude must justify itself with a comment explaining why. The list has not grown since the initial extraction. It is the one place where exemptions live, and the exemption bar is deliberately high.
fast-check — 16 invariants × 200 runs
The property-based suite uses fast-check to generate structured inputs and assert invariants. The package runs 16 invariants, 200 runs each. A run is one generated input; a falsification happens when any run produces an input that violates the invariant. The two hundred runs come from the default shrinking budget; the invariants themselves are small and run fast, so the whole property suite finishes in under three seconds on the author's laptop.
A representative sample — three of the sixteen:
Invariant A — round-trip idempotence on Requirements. For any well-formed input satisfying the RequirementSpec schema, the round-trip parse → serialise → parse produces a structurally identical RequirementSpec. The arbitrary is fc.record(...) composed from the schema; the check is deep-equal via the RequirementSpec.equals method. This property caught a real bug during development: the HistoryEntry serialiser preserved the kind discriminator in one branch and elided it in another, producing a round-trip that lost the change kind. Falsification surfaced a minimal counterexample: one HistoryEntry with kind: 'rationale-updated'. The fix was a one-line serialiser change; the property has held ever since.
Invariant B — registry = AST. For any Feature class (modelled as an arbitrary over class declarations produced by a small code-generator), the @Satisfies entries recovered by the Phase-1 AST walk are exactly the set of @Satisfies entries recorded in the Phase-2 registry. This is the consistency property between the two phases: if the AST says @Satisfies(A, B, C), the registry must say satisfies[feat] === ['A', 'B', 'C']. Falsification would mean the registry builder is reordering or losing edges — a silent corruption that would be hard to detect otherwise.
Invariant C — AC citations exist. For any test class decorated @FeatureTest(Feat) with any set of @Verifies<Feat>('ac') methods (modelled as an arbitrary pair: an arbitrary Feature and an arbitrary subset of its AC names plus one random string that may or may not be an AC name), the set of @Verifies-cited AC names is a subset of the Feature's declared AC names. In other words: every AC citation must target an actual AC. Falsification means the scanner accepted a @Verifies('typo') as valid — which is exactly the failure mode the under-covered Critical AC pass is designed to catch, viewed from the other side. The property-based invariant catches it at a finer granularity: not just Critical, any priority, any AC.
The remaining thirteen invariants cover brand-constructor rejection behaviour, the @Refines acyclicity guarantee, the StyleValidator contract, the schema validation round-trip, and so on. Each is a one-page test file, decorated with @FeatureTest(FeaturePropertyTesting) and @Verifies on each invariant's AC — yes, per feedback_no_describe_it, the property suite itself uses the DSL to bind its properties to ACs, never describe/it. The dog-food goes all the way down.
The running example, gated
Back to FEATURE-TRACE-EXPLORER-TUI. Every chapter of this series has returned to this class; this chapter returns to it as seen by the gate.
The class, abbreviated to the parts the gate reads:
@Satisfies(
ReqDiscoverableTraceabilityRequirement,
ReqDogFoodRequirement,
ReqParallelDeliverableRequirement,
)
export abstract class FeatureTraceExplorerTuiFeature extends Feature {
readonly id = 'FEATURE-TRACE-EXPLORER-TUI';
readonly title = '`requirements explore` — interactive TTY browser over the traceability graph';
readonly priority = Priority.Low;
readonly enabled = false;
abstract traceExplorerBuildsGraph(): ACResult;
abstract traceExplorerHandlesArrowKeyNavigation(): ACResult;
abstract traceExplorerDrillsDownFromAnyNode(): ACResult;
abstract traceExplorerOpensHelpOverlayOnQuestionMark(): ACResult;
abstract traceExplorerJumpsBackUpWithBackspace(): ACResult;
abstract traceExplorerRefusesToStartOnNonTty(): ACResult;
abstract traceExplorerExitsCleanlyOnCtrlC(): ACResult;
abstract traceExplorerUsesFileSystemPortForDiscovery(): ACResult;
abstract traceExplorerUsesPromptPortForInteraction(): ACResult;
abstract endToEndNavigatesReqToFeatToAcToTest(): ACResult;
}@Satisfies(
ReqDiscoverableTraceabilityRequirement,
ReqDogFoodRequirement,
ReqParallelDeliverableRequirement,
)
export abstract class FeatureTraceExplorerTuiFeature extends Feature {
readonly id = 'FEATURE-TRACE-EXPLORER-TUI';
readonly title = '`requirements explore` — interactive TTY browser over the traceability graph';
readonly priority = Priority.Low;
readonly enabled = false;
abstract traceExplorerBuildsGraph(): ACResult;
abstract traceExplorerHandlesArrowKeyNavigation(): ACResult;
abstract traceExplorerDrillsDownFromAnyNode(): ACResult;
abstract traceExplorerOpensHelpOverlayOnQuestionMark(): ACResult;
abstract traceExplorerJumpsBackUpWithBackspace(): ACResult;
abstract traceExplorerRefusesToStartOnNonTty(): ACResult;
abstract traceExplorerExitsCleanlyOnCtrlC(): ACResult;
abstract traceExplorerUsesFileSystemPortForDiscovery(): ACResult;
abstract traceExplorerUsesPromptPortForInteraction(): ACResult;
abstract endToEndNavigatesReqToFeatToAcToTest(): ACResult;
}Ten ACs. Zero @Verifies-bound test methods today. What does the gate say?
Phase 1 reads the class. The registry records three Feature→REQ edges (to ReqDiscoverableTraceability, ReqDogFood, and ReqParallelDeliverable), zero Test→AC edges, and the Feature's priority: Low / enabled: false metadata.
Phase 3a (orphan Features) — passes. The Feature is not an orphan; it satisfies three Requirements. Good.
Phase 3b (orphan approved Requirements) — passes for the three Requirements the Feature touches. Each Requirement has at least one satisfier (this Feature, counted), and no other Approved Requirement is missing a satisfier. Good.
Phase 3c (under-covered Critical ACs) — skipped entirely for this Feature. Reason: priority: Low. If it were Critical, the pass would enumerate all ten ACs, find zero @Verifies edges for each, and report ten under-covered ACs. It is not Critical, so the pass does not trigger. The Feature's row in the report shows Gate: - (skipped) rather than Gate: ✗ (failure).
Additionally: enabled: false would skip the pass regardless of priority. The enabled flag is the hard override. A Critical-priority Feature with enabled: false still skips the Critical-AC pass. The rationale is that enabled: false means "this Feature is authored but not yet shipping"; gating on its ACs would force test-before-spec, which inverts the workflow the package wants to support.
Now, flip two flags. Suppose the explorer TUI graduates from "work in progress" to "shipping":
readonly priority = Priority.Critical;
readonly enabled = true;readonly priority = Priority.Critical;
readonly enabled = true;Re-run compliance --strict:
under-covered Critical ACs (10):
FEATURE-TRACE-EXPLORER-TUI :: traceExplorerBuildsGraph
FEATURE-TRACE-EXPLORER-TUI :: traceExplorerHandlesArrowKeyNavigation
FEATURE-TRACE-EXPLORER-TUI :: traceExplorerDrillsDownFromAnyNode
FEATURE-TRACE-EXPLORER-TUI :: traceExplorerOpensHelpOverlayOnQuestionMark
FEATURE-TRACE-EXPLORER-TUI :: traceExplorerJumpsBackUpWithBackspace
FEATURE-TRACE-EXPLORER-TUI :: traceExplorerRefusesToStartOnNonTty
FEATURE-TRACE-EXPLORER-TUI :: traceExplorerExitsCleanlyOnCtrlC
FEATURE-TRACE-EXPLORER-TUI :: traceExplorerUsesFileSystemPortForDiscovery
FEATURE-TRACE-EXPLORER-TUI :: traceExplorerUsesPromptPortForInteraction
FEATURE-TRACE-EXPLORER-TUI :: endToEndNavigatesReqToFeatToAcToTest
remedy: `npx requirements scaffold unit FEATURE-TRACE-EXPLORER-TUI`
generates ten test stubs under test/unit/; implement each.
remedy: `npx requirements scaffold e2e FEATURE-TRACE-EXPLORER-TUI`
generates one stub under test/e2e/ for the endToEnd... AC.
exit code: 1under-covered Critical ACs (10):
FEATURE-TRACE-EXPLORER-TUI :: traceExplorerBuildsGraph
FEATURE-TRACE-EXPLORER-TUI :: traceExplorerHandlesArrowKeyNavigation
FEATURE-TRACE-EXPLORER-TUI :: traceExplorerDrillsDownFromAnyNode
FEATURE-TRACE-EXPLORER-TUI :: traceExplorerOpensHelpOverlayOnQuestionMark
FEATURE-TRACE-EXPLORER-TUI :: traceExplorerJumpsBackUpWithBackspace
FEATURE-TRACE-EXPLORER-TUI :: traceExplorerRefusesToStartOnNonTty
FEATURE-TRACE-EXPLORER-TUI :: traceExplorerExitsCleanlyOnCtrlC
FEATURE-TRACE-EXPLORER-TUI :: traceExplorerUsesFileSystemPortForDiscovery
FEATURE-TRACE-EXPLORER-TUI :: traceExplorerUsesPromptPortForInteraction
FEATURE-TRACE-EXPLORER-TUI :: endToEndNavigatesReqToFeatToAcToTest
remedy: `npx requirements scaffold unit FEATURE-TRACE-EXPLORER-TUI`
generates ten test stubs under test/unit/; implement each.
remedy: `npx requirements scaffold e2e FEATURE-TRACE-EXPLORER-TUI`
generates one stub under test/e2e/ for the endToEnd... AC.
exit code: 1Ten blockers. Before the feature can ship (i.e., before the developer can push a branch that ends in enabled: true and priority: Critical on this class), each of these ten ACs has to acquire at least one @Verifies-bound test. The scaffolder generates the stubs; the implementation is the developer's job; the gate's job is to refuse the push until it is done.
This is the interaction the chapter outline promises: priority + enabled gate the strictness. A Feature can be authored freely at Low/off. It can be promoted to High/on with moderate expectations (non-Critical ACs are still informational). It cannot be promoted to Critical/on without a complete @Verifies sweep. The gate is a ratchet: authoring cheap, shipping expensive, with the transition cost concentrated at the moment it becomes shipping.
A second note worth making: the @Satisfies list on the explorer TUI is three Requirements, one of which is ReqDogFoodRequirement. That means this Feature, once it ships, is one of the things that delivers the dog-food property. The self-referential loop — the explorer TUI lets users browse the traceability graph of the package that also governs the explorer TUI — is not a quirk; it is the point. Chapter 00 made the argument in prose; the @Satisfies edge makes the argument in types.
The local-CI architecture
A short section on the environment, because the gate's ergonomics assume it.
This repository does not use cloud CI. There is no GitHub Actions workflow that runs compliance --strict on push. There is no Vercel build step that runs tests. Vercel's build step runs npm run build:static — the static-site generator — and serves the result. Full stop.
The gate lives in the developer's pre-push git hook (plain bash, checked into .husky/pre-push):
#!/usr/bin/env bash
set -euo pipefail
npm run lint
npm run compliance -- --strict
npm run test:all#!/usr/bin/env bash
set -euo pipefail
npm run lint
npm run compliance -- --strict
npm run test:allThree lines. If any fails, the push aborts. If all pass, the push proceeds. The developer sees the exit code and the output of whichever step failed, in their terminal, live, with no round-trip delay.
The consequences of this architecture, as the chapter outline indicates:
- The gate has to be fast. Cloud CI can take five minutes and nobody cares; they queued the PR and moved on. A pre-push hook that takes five minutes is unbearable. The target is under thirty seconds. The current run, on the author's laptop, is twelve seconds end-to-end: three for lint, four for compliance + property tests, five for the full 778-test suite with coverage.
- The gate has to be pleasant. Fast is not sufficient; the output has to be legible at a glance. The report format above is the result of several iterations on what a developer wants to see when a push aborts. The colored
✓/✗/-/~glyphs, the separate gate-failure block at the bottom with explicit remedies, the counterexample snippet for property falsifications — all exist because the first iteration (a wall of stack traces) was intolerable and got bypassed. - The gate has to be locally reproducible. No "it passes on my machine" excuse. A
.nvmrcpins the Node version,pnpm-lock.yamlpins the dependency tree, and the vitest config is deterministic (fixed fast-check seed, fixed run count). Two developers runningcompliance --stricton the same commit see the same output, or one of them has a broken workspace.
Chapter 13b — Developer Experience: TUI wizard and live feedback — will cover the watch-mode version of this gate, which re-runs the relevant slice on every file save and feeds the results into the requirements explore TUI in real time. Chapter 13c — Diagnostics and Repair — will cover what happens when the gate failure is structural (circular @Refines, for example) rather than a simple gap. This chapter establishes the baseline: what the gate is, what it checks, and how it says yes or no.
Diagram 1 — quality gate pipeline
The flowchart below shows the five stages from source to gate decision, with the three outputs the report renders and the binary outcome at the end.
Alt text: flowchart of the compliance --strict pipeline. Source tree feeds AST extraction (Phase 1), registry build (Phase 2), and gap detection (Phase 3) in sequence. Source also feeds vitest coverage and fast-check property runs in parallel. All three converge into Phase 4, which renders three outputs (stdout text, JSON payload, Markdown matrix) and a pass/fail decision. Pass allows git push; fail aborts with exit code 1.
Caption: five stages, three outputs, one binary decision. The pipeline is linear through Phases 1–4 with two parallel branches (coverage, property tests) joining before render. Every edge is a data dependency; no edge is time-sensitive beyond "after". The whole run completes in twelve seconds on an ordinary laptop.
Diagram 2 — gate failure flow
The stateDiagram below shows the three failure modes, the recovery loop for each, and the transitions back to a clean state. Each recovery loop is a concrete developer action — add decorator, author Feature, write test — that the report's remedy: line points at.
Alt text: state diagram with a Clean central state and three failure states — OrphanFeature, OrphanApprovedReq, UnderCoveredCriticalAc. Each failure state has a recovery transition labelled with the concrete developer action. OrphanFeature resolves by editing the class to add @Satisfies. OrphanApprovedReq has two recovery paths: author a satisfier Feature, or demote the Requirement state. UnderCoveredCriticalAc resolves through scaffolding a test stub and implementing it. All recoveries return to Clean, from which git push proceeds.
Caption: three failure modes, five recovery paths, one accepting state. The diagram maps one-to-one onto the remedy: lines in the failure report: every path is something the CLI explicitly suggests. The gate's job is not to punish; it is to route the developer to the shortest path back to Clean.
Running-example recap
FEATURE-TRACE-EXPLORER-TUI, at this chapter, is in the simplest possible relation to the gate: ten ACs, zero tests, Priority.Low, enabled: false. The gate reports it, does not block on it, flags its row with -, and sums its ten ACs into the overall 345/355 unverified count. The package ships.
When the feature graduates — say, mid-2026, when the terminal-UI work in feature-trace-explorer-tui.ts is ready to land — two flag flips raise the gate's strictness:
enabled: false → true: the scaffolder auto-generates test stubs undertest/unit/(and one undertest/e2e/for the end-to-end AC) on the nextscaffoldinvocation. Empty stubs still fail the gate at the@Verifieslayer until implemented.priority: Low → Critical: every AC enters the strict check. Any unimplemented stub becomes a named blocker.
If the developer ships the test implementations together with the flag flip, the gate passes in one go. If they flip the flags without the tests, the push aborts with a ten-item punch list naming the exact ACs missing. The distance between "I think it is ready" and "the gate agrees it is ready" is exactly the number of unimplemented test stubs.
This is the whole point of the ratchet: the cost of shipping a Critical-priority Feature is the cost of verifying every one of its ACs, paid at the moment of promotion, not spread opaquely across the project. The gate does not slow down exploratory work (low priority, disabled features are free). It does not slow down routine maintenance (already-verified ACs stay verified). It slows down exactly one thing — the transition from authored to shipping — and it does so with a legible, actionable list.
In that sense, compliance --strict is less a quality check and more a quality accountant: it totals what you owe and refuses to let you leave without settling. The three gate conditions — every Feature satisfies, every Approved Requirement is satisfied, every Critical AC is verified — are the three books the accountant keeps. The 100% coverage and 16-invariant property run are the auditor's spot-checks on the books.
Related reading
- typed-specs/06-compliance.md — the predecessor. A three-hundred-line scanner; one gate condition; the typed-specs version of this chapter.
- 03-the-decorator-surface.md — the
@Satisfies/@Verifies/@Refinesdecorators whose presence or absence the gate detects. - 07-feature-requirement-many-to-many.md — the many-to-many
@Satisfiesrelation that the Phase-2 registry indexes bidirectionally. - 12-compliance-report-shape.md — the chapter immediately before this one: the internal data structures of the report (FeatureSpec, TestSpec, the JSON schema) that this chapter's four phases produce and consume.
- 13b-developer-experience-tui-wizard-live-feedback.md — the DX wrapped around this gate: watch-mode, the
feature new/requirement newwizards, and the live feedback loop that runs the scanner on every save. - 13c-diagnostics-and-repair.md — what to do when the gate failure is structural (circular
@Refines, ambiguous Style resolution, schema drift) rather than a simple gap.
← Previous: Chapter 12 — The Compliance Report's Internal Shape · Next: Chapter 13b — Developer Experience: the TUI Wizard and Live Feedback →