ci: enforce the checks the contributor rules already required

Four gates that were documented but unenforced, plus the flaky test that
made a full-suite run untrustworthy.

Rust lint/format: CLAUDE.md has required `cargo fmt` and `cargo clippy`
before every commit for as long as the rule existed, yet neither ran
anywhere in CI — the requirement rested on memory alone. Both now run in
build-and-test.yml and build-release.yml. rustfmt and clippy are already
baked into the builder image, so nothing is installed at job time.
`cargo fmt --all -- --check` is strict immediately (the tree is clean).
Clippy is advisory for now: ~51 pre-existing warnings mean `-D warnings`
would fail on unrelated work, so the step carries a TODO to flip the flag
once the backlog clears. A compile error still fails it, so it is not a
no-op.

Traceability threshold: MIN_THRESHOLD sat at 50 while real coverage was
86%, so nearly half the matrix could rot before the gate objected.
Ratcheted to 82 with the policy written down — it only ever goes up, and
is never lowered to make a red build pass. The same figure lives in
MIN_COVERAGE_PERCENT so `traces:coverage` gates locally on the same bar,
and a test fails if the two drift.

Dangling IDs: a TRACES comment could name any well-formed ID and the
extractor accepted it silently, so typos and renames that missed a call
site passed unnoticed. `bun run traces:validate` cross-checks every
traced ID against the table rows in requirements.md and fails with the
referencing files listed. It spans UT/IT as well, which the coverage
orphan list ignores by design. This currently reports DR-189 and UT-188,
which are being defined separately.

Flaky offlineCatalog test: the first dynamic import of the service paid
~1s to transform its dependency graph, charged to a test body against
vitest's 5s default. Alone it passed; under suite-wide contention it
timed out. The import is now warmed at collection time, so no test is
timing the compiler — the timeout is deliberately unchanged. The store
shim also drops subscribers from module instances discarded by
resetModules, which previously leaked across tests.
This commit is contained in:
2026-08-16 22:51:44 +02:00
parent 73641e192c
commit b9dab56379
11 changed files with 355 additions and 31 deletions
+10 -2
View File
@@ -69,7 +69,8 @@ Extract requirement IDs (TRACES) from source code and generate a traceability ma
bun run traces # Generate markdown report
bun run traces:json # Generate JSON report
bun run traces:markdown # Save to docs/traceability.md
bun run traces:coverage # Coverage gate — exits non-zero below 50%
bun run traces:coverage # Coverage gate — exits non-zero below the ratchet
bun run traces:validate # Dangling-ID gate — every traced ID must be defined
```
The script scans all TypeScript, Svelte, and Rust files (plus `scripts/`)
@@ -84,6 +85,12 @@ derived from `docs/requirements.md` at run time; they are never hardcoded. An ID
that appears in a `TRACES:` comment but is not defined in `requirements.md` is
reported as *orphaned* and does not count toward coverage (see DR-093).
**`bun run traces:validate` is the dangling-ID gate.** It fails if any traced ID
— including `UT`/`IT`, which coverage deliberately ignores — is not defined as a
table row in `requirements.md`, printing each offender with the files that
reference it. Without it the extractor accepted any well-formed ID silently, so
typos and renames that missed a call site went unreported for months.
> **Removed:** `check-req-coverage.sh`, `check-test-coverage.sh`, and
> `find-req-implementations.sh` were deleted in July 2026. They read an
> undocumented `@req:` tag convention parallel to `TRACES:`, grepped `src-tauri/`
@@ -104,7 +111,8 @@ See [docs/traceability.md](../docs/traceability.md) for the latest generated map
The traceability system is integrated with Gitea Actions CI/CD:
- Automatically validates TRACES on every push and pull request
- Enforces minimum 50% coverage threshold
- Enforces a minimum coverage threshold (a ratchet: raise it, never lower it)
- Fails on dangling IDs — traced but undefined in `requirements.md`
- Warns if new code lacks TRACES comments
- Generates traceability reports automatically
+96 -7
View File
@@ -14,7 +14,17 @@
*/
import { describe, it, expect } from "vitest";
import { countDefinedRequirements, computeCoverage } from "./extract-traces";
import * as fs from "fs";
import * as path from "path";
import {
countDefinedRequirements,
computeCoverage,
findDanglingIds,
MIN_COVERAGE_PERCENT,
} from "./extract-traces";
// import.meta.dir is Bun-only; derive from import.meta.url under vitest.
const HERE = path.dirname(new URL(import.meta.url).pathname);
describe("countDefinedRequirements", () => {
it("counts a well-formed table row as a defined requirement", () => {
@@ -82,6 +92,80 @@ Some prose explaining that UR-005 relates to DR-001 and JA-002.
expect(defined.ids.has("DR-050")).toBe(true);
expect(defined.ids.has("UR-999")).toBe(false);
});
it("collects UT/IT rows separately, out of the coverage denominator", () => {
// §4 defines the test taxonomy. Those rows must be known (so a TRACES
// comment may name them) without ever moving the coverage ratio.
const md = `
| UR-001 | A | High | Done |
| UT-001 | Player state transitions | DR-001 | Pending |
| IT-004 | Playback end-to-end | DR-002 | Pending |
`;
const defined = countDefinedRequirements(md);
expect(defined.total).toBe(1);
expect(defined.ids.has("UT-001")).toBe(false);
expect(defined.testIds.has("UT-001")).toBe(true);
expect(defined.testIds.has("IT-004")).toBe(true);
});
});
describe("findDanglingIds", () => {
const defined = {
UR: 1,
IR: 0,
DR: 1,
JA: 0,
total: 2,
ids: new Set(["UR-001", "DR-001"]),
testIds: new Set(["UT-001"]),
};
it("flags a requirement ID that requirements.md does not define", () => {
expect(findDanglingIds(["UR-001", "DR-189"], defined)).toEqual(["DR-189"]);
});
it("flags an undefined UT/IT id, which the coverage orphan list cannot", () => {
// The gap this closes: computeCoverage deliberately ignores UT/IT, so
// UT-188 sat in three source files, defined nowhere, entirely unreported.
expect(computeCoverage(["UT-188"], defined).orphaned).toEqual([]);
expect(findDanglingIds(["UT-188"], defined)).toEqual(["UT-188"]);
});
it("accepts every ID that is defined, requirement or test", () => {
expect(findDanglingIds(["UR-001", "DR-001", "UT-001"], defined)).toEqual([]);
});
it("deduplicates and sorts, so one typo is reported once", () => {
expect(
findDanglingIds(["DR-189", "DR-189", "UR-999", "DR-189"], defined)
).toEqual(["DR-189", "UR-999"]);
});
it("ignores IDs whose prefix is not a known trace type", () => {
// e.g. an unrelated "AB-123" caught by the loose ID regex.
expect(findDanglingIds(["AB-123"], defined)).toEqual([]);
});
});
describe("coverage threshold", () => {
it("matches MIN_THRESHOLD in the Gitea traceability workflow", () => {
// Two files must agree on the gate: the script (local `traces:coverage`)
// and the workflow. Drift means the local gate and CI disagree about what
// passes, which is how the 50%-while-actually-86% slack went unnoticed.
const workflow = fs.readFileSync(
path.resolve(HERE, "../.gitea/workflows/traceability-check.yml"),
"utf-8"
);
const match = workflow.match(/^\s*MIN_THRESHOLD=(\d+)\s*$/m);
expect(match).not.toBeNull();
expect(Number(match![1])).toBe(MIN_COVERAGE_PERCENT);
});
it("is a ratchet: never lower it to make a red build pass", () => {
// Sanity bound. If coverage genuinely climbs, raise both numbers together.
expect(MIN_COVERAGE_PERCENT).toBeGreaterThanOrEqual(82);
expect(MIN_COVERAGE_PERCENT).toBeLessThanOrEqual(100);
});
});
describe("computeCoverage", () => {
@@ -92,6 +176,7 @@ describe("computeCoverage", () => {
JA: 0,
total: 4,
ids: new Set(["UR-001", "UR-002", "DR-001", "DR-002"]),
testIds: new Set<string>(),
};
it("computes coverage as traced ∩ defined over defined", () => {
@@ -138,7 +223,15 @@ describe("computeCoverage", () => {
});
it("reports 0% rather than NaN when nothing is defined", () => {
const empty = { UR: 0, IR: 0, DR: 0, JA: 0, total: 0, ids: new Set<string>() };
const empty = {
UR: 0,
IR: 0,
DR: 0,
JA: 0,
total: 0,
ids: new Set<string>(),
testIds: new Set<string>(),
};
const cov = computeCoverage([], empty);
expect(cov.percent).toBe(0);
expect(Number.isNaN(cov.percent)).toBe(false);
@@ -163,12 +256,8 @@ describe("live requirements.md", () => {
// (total 114) while the real file had grown to 211. Update these numbers
// deliberately when requirements are added — that edit is the signal the
// denominator is live rather than frozen.
const fs = require("fs");
const path = require("path");
// import.meta.dir is Bun-only; derive from import.meta.url under vitest.
const here = path.dirname(new URL(import.meta.url).pathname);
const md = fs.readFileSync(
path.resolve(here, "../docs/requirements.md"),
path.resolve(HERE, "../docs/requirements.md"),
"utf-8"
);
const defined = countDefinedRequirements(md);
+103 -3
View File
@@ -37,8 +37,27 @@ interface TracesData {
/** Requirements *defined* in requirements.md — the coverage denominators. */
defined?: { UR: number; IR: number; DR: number; JA: number; total: number };
coverage?: CoverageResult;
/** Traced IDs of any type that requirements.md does not define. */
dangling?: string[];
}
/**
* Minimum overall requirement coverage the traceability gate accepts.
*
* **Ratchet policy: this number only ever goes up.** It is set a few points
* below the coverage actually achieved, so a real regression trips it instead of
* being absorbed by slack. It sat at 50 while true coverage was 86%, which meant
* half the matrix could rot before CI noticed. When coverage rises durably,
* raise this to sit just under the new figure. Do **not** lower it to make a
* failing build pass — add the missing TRACES comments instead.
*
* `.gitea/workflows/traceability-check.yml` carries the same number as
* `MIN_THRESHOLD`; `scripts/extract-traces.test.ts` fails if the two drift.
*
* TRACES: | DR-093
*/
export const MIN_COVERAGE_PERCENT = 82;
// Repo root, derived from this script's location (scripts/ -> repo root).
// Must NOT be hardcoded to a developer's machine, or CI checkouts see no files.
//
@@ -222,7 +241,10 @@ export interface DefinedRequirements {
DR: number;
JA: number;
total: number;
/** Requirement IDs (UR/IR/DR/JA) — the coverage denominator. */
ids: Set<string>;
/** Test IDs (UT/IT) from §4. A separate taxonomy: never part of coverage. */
testIds: Set<string>;
}
export interface CoverageResult {
@@ -247,11 +269,18 @@ export interface CoverageResult {
*/
export function countDefinedRequirements(markdown: string): DefinedRequirements {
const ids = new Set<string>();
const ROW_ID = /^\|\s*(UR|IR|DR|JA)-(\d{3})\s*\|/;
const testIds = new Set<string>();
const ROW_ID = /^\|\s*(UR|IR|DR|JA|UT|IT)-(\d{3})\s*\|/;
for (const line of markdown.split("\n")) {
const match = line.match(ROW_ID);
if (match) ids.add(`${match[1]}-${match[2]}`);
if (!match) continue;
const id = `${match[1]}-${match[2]}`;
// UT/IT rows live in §4 and are collected separately: they must not enter
// the coverage denominator, but they still need to exist for a `TRACES:`
// comment to be allowed to name them (see findDanglingIds).
if (match[1] === "UT" || match[1] === "IT") testIds.add(id);
else ids.add(id);
}
const countOf = (type: string) =>
@@ -264,9 +293,39 @@ export function countDefinedRequirements(markdown: string): DefinedRequirements
JA: countOf("JA"),
total: ids.size,
ids,
testIds,
};
}
/**
* Every traced ID that requirements.md defines nowhere — a typo, a rename that
* missed a call site, or a reference to a deleted requirement.
*
* This is broader than `CoverageResult.orphaned`, which only ever considers the
* four requirement types because a UT/IT entry among the orphans would corrupt
* the coverage ratio's reporting. Dangling detection has no such constraint, so
* it checks all six ID types against both defined sets. Before it existed, the
* extractor accepted any well-formed ID silently: `DR-189` and `UT-188` were
* referenced from `controlsVisibility.ts` and `VideoPlayer.svelte` for months
* without being defined anywhere, and nothing reported it.
*
* TRACES: | DR-093
*/
export function findDanglingIds(
tracedIds: string[],
defined: DefinedRequirements
): string[] {
const KNOWN_TYPE = /^(UR|IR|DR|JA|UT|IT)-\d{3}$/;
const dangling = new Set(
tracedIds
.filter((id) => KNOWN_TYPE.test(id))
.filter((id) => !defined.ids.has(id) && !defined.testIds.has(id))
);
return [...dangling].sort();
}
/**
* Coverage is the *intersection* of traced and defined IDs over defined IDs.
*
@@ -408,6 +467,13 @@ function reportCoverage(data: TracesData, minThreshold: number): number {
console.log(" Fix the TRACES comment or add the requirement.");
}
if (data.dangling && data.dangling.length > 0) {
console.log("");
console.log(
`⚠️ Dangling IDs (incl. UT/IT): ${data.dangling.join(", ")} — run \`bun run traces:validate\`.`
);
}
// A ratio above 100% means the computation is broken (the condition that hid
// the stale-denominator bug for so long). Fail loudly rather than report it.
if (cov.percent > 100) {
@@ -427,6 +493,37 @@ function reportCoverage(data: TracesData, minThreshold: number): number {
return 0;
}
/**
* Hard gate on dangling IDs: a `TRACES:` comment may only name an ID that
* requirements.md actually defines. Prints every offender with the files that
* reference it, so the fix is mechanical.
*
* TRACES: | DR-093
*/
function reportDangling(data: TracesData): number {
const dangling = data.dangling ?? [];
if (dangling.length === 0) {
console.log("✅ All traced IDs are defined in docs/requirements.md");
return 0;
}
console.log("❌ TRACES reference IDs that docs/requirements.md does not define:");
console.log("");
for (const id of dangling) {
const files = [
...new Set((data.requirements[id] ?? []).map((e) => e.file)),
].sort();
console.log(` ${id}`);
for (const file of files) console.log(` ${file}`);
}
console.log("");
console.log("Fix each one by either:");
console.log(" • correcting the ID in the TRACES comment (typo/rename), or");
console.log(" • adding the requirement as a table row in docs/requirements.md.");
return 1;
}
// Main — guarded so this module stays importable from extract-traces.test.ts.
if (import.meta.main) {
const args = process.argv.slice(2);
@@ -447,11 +544,14 @@ if (import.meta.main) {
total: defined.total,
};
data.coverage = computeCoverage(allTraced, defined);
data.dangling = findDanglingIds(allTraced, defined);
if (format === "json") {
console.log(generateJson(data));
} else if (format === "coverage") {
process.exit(reportCoverage(data, 50));
process.exit(reportCoverage(data, MIN_COVERAGE_PERCENT));
} else if (format === "validate") {
process.exit(reportDangling(data));
} else {
console.log(generateMarkdown(data));
}