fix(traces): make generated matrix links resolve from docs/

docs/traceability.md emitted each trace's file link with the
repo-root-relative path as the href, but the file is written to docs/ —
so every one of the 2,793 links resolved to docs/src-tauri/... or
docs/src/... and 404'd, in the Gitea repo browser and on the published
mdBook site alike. The matrix is the artefact the whole TRACES system
exists to produce, and it was unnavigable.

The href now carries a ../ prefix; the visible link text stays
repo-root-relative, since that is the path a developer greps for.

This survived because the markdown generator had no test at all — the
existing suite covers counting, coverage and dangling IDs only. UT-202
now generates a link for a file that really exists, resolves the href
against docs/, and asserts the target is on disk; it fails against the
old output. Watched red before the fix, per the red-green rule.

The live-requirements counts move with the rows added in the previous
commit: UR 75 -> 76, DR 194 -> 200, total 337 -> 344.
This commit is contained in:
2026-08-20 19:30:37 +02:00
parent 662cb3cd85
commit 1518d92ef4
3 changed files with 7125 additions and 7006 deletions
+79 -4
View File
@@ -11,6 +11,7 @@
*
* @req-test: UT-089 - Requirement definitions parsed from requirements.md
* @req-test: UT-090 - Coverage is the intersection of traced and defined IDs
* @req-test: UT-202 - Generated matrix links resolve from docs/
*/
import { describe, it, expect } from "vitest";
@@ -20,7 +21,10 @@ import {
countDefinedRequirements,
computeCoverage,
findDanglingIds,
formatMatrixFileLink,
generateMarkdown,
MIN_COVERAGE_PERCENT,
type TracesData,
} from "./extract-traces";
// import.meta.dir is Bun-only; derive from import.meta.url under vitest.
@@ -250,6 +254,73 @@ describe("computeCoverage", () => {
});
});
describe("generated matrix file links", () => {
// Regression: the generator emitted the repo-root-relative path as the href
// (`](src-tauri/src/…)`), but writes its output to docs/traceability.md — so
// every one of the ~2,800 links resolved to docs/src-tauri/… and 404'd, in
// the repo browser and on the published mdBook site. The markdown generator
// had no test at all, which is why it survived. UT-202.
//
// @req-test: UT-202
/** A minimal TracesData whose single entry points at a file that really exists. */
function fixture(file: string, line = 12): TracesData {
return {
timestamp: new Date().toISOString(),
totalFiles: 1,
totalTraces: 1,
requirements: {
"DR-093": [{ file, line, context: "export function x() {}" }],
},
byType: { UR: [], IR: [], DR: ["DR-093"], JA: [] },
} as TracesData;
}
/** Pull the href out of the first `- **File:** [`x`](href)` line. */
function firstHref(md: string): string {
const m = md.match(/^- \*\*File:\*\* \[`[^`]+`\]\(([^)]+)\)/m);
expect(m).not.toBeNull();
return m![1];
}
it("emits an href that resolves, from docs/, to a file that exists", () => {
// Use a real repo file so "exists on disk" is a genuine assertion.
const target = "scripts/extract-traces.ts";
const md = generateMarkdown(fixture(target));
const href = firstHref(md);
const [relPath] = href.split("#");
// traceability.md is written to docs/, so links resolve from there.
const resolved = path.resolve(HERE, "../docs", relPath);
expect(fs.existsSync(resolved)).toBe(true);
expect(resolved).toBe(path.resolve(HERE, "..", target));
});
it("keeps the repo-root-relative path as the visible link text", () => {
// The text is what a developer copies into an editor or a grep; only the
// href is rewritten for the docs/ location.
const md = generateMarkdown(fixture("src-tauri/src/lib.rs"));
expect(md).toContain("[`src-tauri/src/lib.rs`]");
expect(md).not.toContain("[`../src-tauri/src/lib.rs`]");
});
it("keeps the #Lnn line anchor on the href", () => {
const link = formatMatrixFileLink("scripts/extract-traces.ts", 427);
expect(link).toBe(
"[`scripts/extract-traces.ts`](../scripts/extract-traces.ts#L427)"
);
});
it("does not produce a bare repo-root href, which resolves to docs/<path>", () => {
const md = generateMarkdown(fixture("scripts/extract-traces.ts"));
const href = firstHref(md);
expect(href.startsWith("../")).toBe(true);
// The pre-fix output — the exact shape that produced docs/scripts/….
expect(href.startsWith("scripts/")).toBe(false);
});
});
describe("live requirements.md", () => {
it("parses the real file to the counts the CI gate must use", () => {
// Guards the specific regression: CI hardcoded UR/39, IR/24, DR/48, JA/3
@@ -262,7 +333,7 @@ describe("live requirements.md", () => {
);
const defined = countDefinedRequirements(md);
expect(defined.UR).toBe(75);
expect(defined.UR).toBe(76);
expect(defined.IR).toBe(32);
// 192 = 187 + four requirements added independently on four audit branches,
// plus DR-201 (lockscreen skip resolution). Originally 191 = 187 + four
@@ -272,9 +343,13 @@ describe("live requirements.md", () => {
// merge, where it collided). Each branch bumped for its own — merged,
// they sum. Resolve this by summing, never by taking one side. 193 adds
// DR-202 (video keeps the display awake), 194 DR-203 (the handoff
// transcode refusing the player's own load-error retry).
expect(defined.DR).toBe(194);
// transcode refusing the player's own load-error retry). 200 adds the
// six tooling/quality requirements DR-204..DR-209 (logging facade, lint
// gate, pinned toolchain, pre-commit hook, doc-link check, server-side
// library folder exclusion); UR rises to 76 with UR-076, which DR-209
// serves.
expect(defined.DR).toBe(200);
expect(defined.JA).toBe(36);
expect(defined.total).toBe(337);
expect(defined.total).toBe(344);
});
});