# Spec: Repair the traceability coverage gate **Status:** Implemented **Requirements:** DR-093 → supports the traceability practice described in CLAUDE.md **UX spec:** n/a — developer tooling, no user-facing surface. **Supersedes / revises:** n/a ## Summary The CI traceability gate has been passing unconditionally for an unknown length of time because it divides traced-requirement counts by **hardcoded denominators that no longer match [requirements.md](../requirements.md)**. It currently reports **158% overall coverage** (and `JA 24 / 3 = 800%`), so the 50% threshold is mathematically unreachable and the job cannot fail. This spec makes the gate derive its denominators from `requirements.md` at run time, so it reports the real number (**85%** today) and can actually fail again. ## Motivation `.gitea/workflows/traceability-check.yml` hardcodes `UR/39, IR/24, DR/48, JA/3` and `TOTAL_REQS=114`. The real counts are **UR 61, IR 29, DR 89, JA 32 — 211 total**. Requirements were added over time; the divisors were never updated. The consequence is not a cosmetic reporting bug. The gate is the *only* automated defence for the traceability practice, and it is dead: ``` CI today: 181 / 114 = 158% → threshold 50% can never trip Reality: 181 / 211 = 85% → healthy, but unguarded ``` Coverage could collapse to 30% and CI would still print a green "✅ Coverage is acceptable". An audit of the design principles found that every principle with a *working* automated check is in good shape, and the ones that drifted are exactly the ones whose checks were broken or too narrow — this is the clearest instance. A second, related defect is handled in a sibling spec: `scripts/check-req-coverage.sh` is separately broken and orphaned (see [req-coverage-script-removal.md](req-coverage-script-removal.md)). ## Layer assignment This spec touches only CI/build tooling — no application logic crosses the Rust/Svelte boundary. The table is filled in for completeness. | Logic / responsibility | Layer | Why it belongs there | |------------------------|-------|----------------------| | Counting requirement IDs defined in `requirements.md` | Build tooling (`scripts/`) | Neither runtime layer; it is repo metadata analysis. Belongs beside `extract-traces.ts`, not in the workflow YAML, so it is runnable and testable locally. | | Counting *traced* requirement IDs | Build tooling — existing `extract-traces.ts` | Already implemented and correct; this spec consumes it rather than duplicating it. | | Threshold policy (the 50% number) | CI workflow | Deployment policy, not analysis. Keeping it in YAML lets it be tuned without touching the script. | No frontend or Rust logic is added, so no taxonomy leak is possible. ## Design ### 1. Denominators come from `requirements.md`, not literals `requirements.md` defines requirements in markdown tables with a stable leading cell, e.g.: ``` | DR-001 | Player state machine (idle, loading, …) | Player | UR-005 | Done | | UR-002 | Access media when online or offline | High | Done | ``` Extend [scripts/extract-traces.ts](../../scripts/extract-traces.ts) to also emit the *defined* counts, so one tool owns both sides of the fraction and CI does no arithmetic on stale literals. Add a `defined` key to the JSON report: ```jsonc { "byType": { "UR": [...], "IR": [...], "DR": [...], "JA": [...] }, // traced (existing) "defined": { "UR": 61, "IR": 29, "DR": 89, "JA": 32 }, // NEW "coverage": { "covered": 181, "total": 211, "percent": 85 }, // NEW "requirements": { ... }, // existing "totalTraces": 318, "totalFiles": …, "timestamp": "…" // existing } ``` Parsing rule for a *defined* requirement: a line in `docs/requirements.md` matching `^\|\s*(UR|IR|DR|JA)-\d{3}\s*\|` — the ID must be the table's first cell. This deliberately does **not** count IDs mentioned in the `Traces To` column or in prose, which is why a naive `grep -o` over the whole file overcounts. `defined` counts IDs that exist in the spec; `byType` counts IDs that appear in a `TRACES:` comment somewhere in the source. Coverage is `|byType ∩ defined| / |defined|`. > **Intersection, not raw length.** A `TRACES:` comment naming an ID that > `requirements.md` does not define (a typo, or a requirement later deleted) > must **not** inflate the numerator — that is how a ratio exceeds 100% in the > first place. Such IDs are reported separately as `orphaned` so they get fixed > rather than silently counted or silently dropped. ```jsonc "orphaned": ["DR-097"] // traced in code but not defined in requirements.md ``` ### 2. The workflow consumes the computed number Replace the arithmetic in `.gitea/workflows/traceability-check.yml` (lines 46–76) with reads of the precomputed fields: ```sh COVERAGE=$(jq '.coverage.percent' traces-report.json) COVERED=$(jq '.coverage.covered' traces-report.json) TOTAL_REQS=$(jq '.coverage.total' traces-report.json) for T in UR IR DR JA; do TRACED=$(jq --arg t "$T" '.byType[$t] | length' traces-report.json) DEFINED=$(jq --arg t "$T" '.defined[$t]' traces-report.json) echo " $T: $TRACED / $DEFINED" done MIN_THRESHOLD=50 [ "$COVERAGE" -lt "$MIN_THRESHOLD" ] && { echo "❌ …"; exit 1; } ``` No hardcoded denominator survives anywhere in the workflow. ### 3. A self-check so this cannot silently rot again The root cause was a number that drifted with nothing watching it. Add a guard that fails the job on an arithmetically impossible result: ```sh if [ "$COVERAGE" -gt 100 ]; then echo "❌ Coverage > 100% — the gate is miscomputing; orphaned IDs: $(jq -c '.orphaned' traces-report.json)" exit 1 fi ``` A >100% reading is now a hard failure rather than a green tick. ### 4. Local parity Add a script so the gate is runnable outside CI: ```jsonc "traces:coverage": "bun run scripts/extract-traces.ts --format coverage" ``` Prints the same table CI prints and exits non-zero below threshold. ### Threshold Keep `MIN_THRESHOLD=50` in this spec. Real coverage is 85%, so raising the bar is tempting, but doing it in the same change that repairs the gate conflates "restore the safety net" with "tighten the policy" — if the build then fails, it is ambiguous which change caused it. Ratcheting is deliberately deferred to follow-up work once the honest number has been observed on `master` for a few builds. ## Out of scope - Raising `MIN_THRESHOLD` above 50 (see above). - Fixing/removing `scripts/check-req-coverage.sh` — [req-coverage-script-removal.md](req-coverage-script-removal.md). - Adding TRACES comments to raise the actual coverage number. - Changing the `TRACES:` comment format or the extractor's parsing of it. - The PR "modified files missing TRACES" step (lines 78–126), which is advisory by design and stays advisory. ## Acceptance criteria - [ ] `bun run traces:json` emits `defined`, `coverage`, and `orphaned` keys. - [ ] `coverage.total` equals the count of requirement IDs defined in `requirements.md` (**211** at time of writing), not a literal. - [ ] `coverage.percent` reports **85** (±1 for rounding) on the current tree — i.e. the honest number, not 158. - [ ] No hardcoded requirement denominator (`39`, `24`, `48`, `3`, `114`) remains in `.gitea/workflows/traceability-check.yml`. Verify: `grep -nE '/ *(39|24|48|3|114)\b' .gitea/workflows/traceability-check.yml` returns nothing. - [ ] Adding a new requirement row to `requirements.md` **lowers** reported coverage until it is traced (proves the denominator is live). - [ ] A `TRACES:` comment naming an undefined ID appears in `orphaned` and does **not** raise `coverage.percent`. - [ ] The job fails if coverage is forced below 50% (test by temporarily raising `MIN_THRESHOLD` to 99 locally) — proving the gate can fail again. - [ ] The job fails if coverage computes >100%. - [ ] `bun run check` and `bun run test` pass. - [ ] `bun run check:boundary` passes. - [ ] New requirement-implementing code carries `// TRACES:` comments. - [ ] No Rust types changed, so no `bindings.ts` regeneration needed. ## Testing `extract-traces.ts` currently has no test coverage. Add `scripts/extract-traces.test.ts` (vitest) over fixture strings rather than the live `requirements.md`, so the tests do not change meaning as requirements are added: - **UT:** counts a well-formed table row as a defined requirement. - **UT:** does **not** count an ID appearing only in the `Traces To` column or in prose — the specific overcounting bug this parse rule avoids. - **UT:** coverage is the intersection — a traced-but-undefined ID lands in `orphaned` and does not inflate the numerator. - **UT:** coverage of an empty trace set is 0%, not a divide-by-zero. - **UT:** all-traced fixture reports exactly 100%, never above. CI behaviour is verified by the acceptance criteria above (the forced-failure check is the important one — a gate nobody has watched fail is not known to work). ## TRACES Allocate in `requirements.md`: - **DR-093** — "Traceability coverage gate derives requirement denominators from `requirements.md` at run time (not hardcoded literals), computes coverage as the intersection of traced and defined IDs, reports IDs traced but undefined as orphaned, and fails on an impossible >100% result." Category: Tooling. Status: Done on merge. Tag: ```typescript // scripts/extract-traces.ts // TRACES: | DR-093 ``` Tests carry `@req-test: UT-089 …` onward (next free UT is **UT-089**). ## Notes for the implementer - A parallel Claude session may be active in this repo — run `git diff` before "repairing" unexpected changes (CLAUDE.md §Gotchas). - **Do not add tooling to the CI image for this.** `jq` and `bun` are already in `jellytau-builder`; this spec needs nothing else. Installing a system package in a workflow step violates the hard CI rule in CLAUDE.md. - Keep `traces:json`'s existing keys intact — `release-notes.ts` and `traces:markdown` consume the same report, and the CI workflow uploads it as an artifact. This is an additive change. - The `head -50 docs/traceability.md` and artifact-upload steps are unaffected. - Expect the first green build after this change to print a *lower* number than before (85% vs 158%). That is the fix working, not a regression.