Files
jellytau/scripts/extract-traces.ts
T
dtourolle b9dab56379 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.
2026-08-16 22:51:44 +02:00

563 lines
17 KiB
TypeScript

#!/usr/bin/env bun
/**
* Extract TRACES from source code and generate requirement mapping
*
* Usage:
* bun run scripts/extract-traces.ts
* bun run scripts/extract-traces.ts --format json
* bun run scripts/extract-traces.ts --format markdown > docs/traceability.md
*/
import * as fs from "fs";
import * as path from "path";
import { execSync } from "child_process";
interface TraceEntry {
file: string;
line: number;
context: string;
requirements: string[];
}
interface RequirementMapping {
[reqId: string]: TraceEntry[];
}
interface TracesData {
timestamp: string;
totalFiles: number;
totalTraces: number;
requirements: RequirementMapping;
byType: {
UR: string[];
IR: string[];
DR: string[];
JA: string[];
};
/** 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.
//
// `import.meta.dir` is a Bun extension and is undefined when this module is
// imported by vitest (which runs it as an ordinary ESM module), so fall back to
// import.meta.url — this file must stay importable for extract-traces.test.ts.
const SCRIPT_DIR =
import.meta.dir ?? path.dirname(new URL(import.meta.url).pathname);
const BASE_DIR = path.resolve(SCRIPT_DIR, "..");
const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
const REQ_ID_PATTERN = /([A-Z]{2})-(\d{3})/g;
function extractRequirementIds(tracesString: string): string[] {
const matches = [...tracesString.matchAll(REQ_ID_PATTERN)];
return matches.map((m) => `${m[1]}-${m[2]}`);
}
function getAllSourceFiles(): string[] {
const baseDir = BASE_DIR;
// `scripts` is scanned too: build tooling implements requirements (e.g.
// DR-093, the coverage engine itself) and would otherwise be invisible to the
// very matrix it generates.
const patterns = ["src", "src-tauri/src", "scripts"];
const files: string[] = [];
function walkDir(dir: string) {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relativePath = path.relative(baseDir, fullPath);
// Skip node_modules, target, build
if (
relativePath.includes("node_modules") ||
relativePath.includes("target") ||
relativePath.includes("build") ||
relativePath.includes(".git")
) {
continue;
}
if (entry.isDirectory()) {
walkDir(fullPath);
} else if (
entry.name.endsWith(".ts") ||
entry.name.endsWith(".svelte") ||
entry.name.endsWith(".rs")
) {
files.push(fullPath);
}
}
} catch (error) {
// Skip directories we can't read
}
}
for (const pattern of patterns) {
const dir = path.join(baseDir, pattern);
if (fs.existsSync(dir)) {
walkDir(dir);
}
}
return files;
}
function extractTraces(): TracesData {
const requirementMap: RequirementMapping = {};
const byType: Record<string, Set<string>> = {
UR: new Set(),
IR: new Set(),
DR: new Set(),
JA: new Set(),
};
let totalTraces = 0;
const baseDir = BASE_DIR;
const files = getAllSourceFiles();
for (const fullPath of files) {
try {
const content = fs.readFileSync(fullPath, "utf-8");
const lines = content.split("\n");
const relativePath = path.relative(baseDir, fullPath);
let match;
TRACES_PATTERN.lastIndex = 0;
while ((match = TRACES_PATTERN.exec(content)) !== null) {
const tracesStr = match[1];
const reqIds = extractRequirementIds(tracesStr);
if (reqIds.length === 0) continue;
// Find line number
const beforeMatch = content.substring(0, match.index);
const lineNum = beforeMatch.split("\n").length - 1;
// Get context (function/class name if available)
let context = "Unknown";
for (let i = lineNum; i >= Math.max(0, lineNum - 10); i--) {
const line = lines[i];
if (
line.includes("function ") ||
line.includes("export const ") ||
line.includes("pub fn ") ||
line.includes("pub enum ") ||
line.includes("pub struct ") ||
line.includes("impl ") ||
line.includes("async function ") ||
line.includes("class ") ||
line.includes("export type ")
) {
context = line
.trim()
.replace(/^\s*\/\/\s*/, "")
.replace(/^\s*\/\*\*\s*/, "");
break;
}
}
const entry: TraceEntry = {
file: relativePath,
line: lineNum + 1,
context,
requirements: reqIds,
};
for (const reqId of reqIds) {
if (!requirementMap[reqId]) {
requirementMap[reqId] = [];
}
requirementMap[reqId].push(entry);
// Track by type
const type = reqId.substring(0, 2);
if (byType[type]) {
byType[type].add(reqId);
}
}
totalTraces++;
}
} catch (error) {
// Skip files we can't read
}
}
return {
timestamp: new Date().toISOString(),
totalFiles: files.length,
totalTraces,
requirements: requirementMap,
byType: {
UR: Array.from(byType["UR"]).sort(),
IR: Array.from(byType["IR"]).sort(),
DR: Array.from(byType["DR"]).sort(),
JA: Array.from(byType["JA"]).sort(),
},
};
}
// ---------------------------------------------------------------------------
// Coverage: how many *defined* requirements are actually traced.
//
// The denominators MUST be derived from requirements.md, never hardcoded. The
// CI gate previously divided by frozen literals (UR/39, IR/24, DR/48, JA/3,
// total 114) while the real file had grown to 211 requirements, so it reported
// 158% coverage and the 50% threshold became unreachable — the gate could not
// fail. See docs/specs/traceability-gate-repair.md.
//
// TRACES: | DR-093
// ---------------------------------------------------------------------------
export interface DefinedRequirements {
UR: number;
IR: number;
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 {
covered: number;
total: number;
percent: number;
/** Traced in code but not defined in requirements.md (typo, or deleted req). */
orphaned: string[];
}
/**
* A requirement is *defined* only where its ID is the leading cell of a markdown
* table row: `| DR-001 | … |`.
*
* This deliberately ignores IDs in the "Traces To" column and in prose — a
* naive scan for /DR-\d{3}/ counts those as definitions and inflates the
* denominator. IDs are deduplicated because requirements.md lists each UR twice
* (once in §1 as a definition, again in §3's traceability matrix), which would
* otherwise double the UR count from 61 to 121.
*
* TRACES: | DR-093
*/
export function countDefinedRequirements(markdown: string): DefinedRequirements {
const ids = new Set<string>();
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) 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) =>
[...ids].filter((id) => id.startsWith(`${type}-`)).length;
return {
UR: countOf("UR"),
IR: countOf("IR"),
DR: countOf("DR"),
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.
*
* Using the raw traced count as the numerator is what lets a ratio exceed 100%:
* a TRACES comment naming a requirement that no longer exists would count as
* covered. Those IDs are reported as `orphaned` so they get fixed rather than
* silently counted or silently dropped.
*
* TRACES: | DR-093
*/
export function computeCoverage(
tracedIds: string[],
defined: DefinedRequirements
): CoverageResult {
// Only the four *requirement* types participate in coverage. UT/IT are test
// identifiers defined in §4 of requirements.md — a different taxonomy, and
// flagging them as orphans would bury real typos in ~60 lines of noise.
const isRequirement = (id: string) => /^(UR|IR|DR|JA)-\d{3}$/.test(id);
const traced = new Set(tracedIds.filter(isRequirement));
const covered = [...traced].filter((id) => defined.ids.has(id));
const orphaned = [...traced].filter((id) => !defined.ids.has(id)).sort();
return {
covered: covered.length,
total: defined.total,
percent:
defined.total === 0
? 0
: Math.round((covered.length / defined.total) * 100),
orphaned,
};
}
/** Read requirements.md from the repo and count what it defines. */
export function readDefinedRequirements(): DefinedRequirements {
const reqPath = path.join(BASE_DIR, "docs", "requirements.md");
return countDefinedRequirements(fs.readFileSync(reqPath, "utf-8"));
}
function generateMarkdown(data: TracesData): string {
let md = `# Code Traceability Matrix
**Generated:** ${new Date(data.timestamp).toLocaleString()}
## Summary
- **Total Files Scanned:** ${data.totalFiles}
- **Total TRACES Found:** ${data.totalTraces}
- **Requirements Covered:**
- User Requirements (UR): ${data.byType.UR.length}
- Integration Requirements (IR): ${data.byType.IR.length}
- Development Requirements (DR): ${data.byType.DR.length}
- Jellyfin API Requirements (JA): ${data.byType.JA.length}
## Requirements by Type
### User Requirements (UR)
\`\`\`
${data.byType.UR.join(", ")}
\`\`\`
### Integration Requirements (IR)
\`\`\`
${data.byType.IR.join(", ")}
\`\`\`
### Development Requirements (DR)
\`\`\`
${data.byType.DR.join(", ")}
\`\`\`
### Jellyfin API Requirements (JA)
\`\`\`
${data.byType.JA.join(", ")}
\`\`\`
## Detailed Mapping
`;
// Sort requirements by ID
const sortedReqs = Object.keys(data.requirements).sort((a, b) => {
const typeA = a.substring(0, 2);
const typeB = b.substring(0, 2);
const typeOrder = { UR: 0, IR: 1, DR: 2, JA: 3 };
if (typeOrder[typeA] !== typeOrder[typeB]) {
return (typeOrder[typeA] || 4) - (typeOrder[typeB] || 4);
}
return a.localeCompare(b);
});
for (const reqId of sortedReqs) {
const entries = data.requirements[reqId];
md += `### ${reqId}\n\n`;
md += `**Locations:** ${entries.length} file(s)\n\n`;
for (const entry of entries) {
md += `- **File:** [\`${entry.file}\`](${entry.file}#L${entry.line})\n`;
md += ` - **Line:** ${entry.line}\n`;
const contextPreview = entry.context.substring(0, 70);
md += ` - **Context:** \`${contextPreview}${entry.context.length > 70 ? "..." : ""}\`\n`;
}
md += "\n";
}
return md;
}
function generateJson(data: TracesData): string {
return JSON.stringify(data, null, 2);
}
/**
* Human-readable coverage report; exits non-zero below the threshold so this is
* runnable as a local gate (`bun run traces:coverage`), not just in CI.
*
* TRACES: | DR-093
*/
function reportCoverage(data: TracesData, minThreshold: number): number {
const defined = data.defined!;
const cov = data.coverage!;
const definedIds = readDefinedRequirements().ids;
console.log("📋 Requirement coverage (traced / defined):");
for (const type of ["UR", "IR", "DR", "JA"] as const) {
const traced = data.byType[type].filter((id) => definedIds.has(id)).length;
console.log(` ${type}: ${traced} / ${defined[type]}`);
}
console.log("");
console.log(`📈 Overall: ${cov.covered} / ${cov.total} (${cov.percent}%)`);
if (cov.orphaned.length > 0) {
console.log("");
console.log(
`⚠️ Traced but not defined in requirements.md: ${cov.orphaned.join(", ")}`
);
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) {
console.log("");
console.log(`❌ Coverage > 100% — the gate is miscomputing.`);
return 1;
}
if (cov.percent < minThreshold) {
console.log("");
console.log(`❌ Coverage (${cov.percent}%) is below minimum (${minThreshold}%)`);
return 1;
}
console.log("");
console.log(`✅ Coverage is acceptable (${cov.percent}% >= ${minThreshold}%)`);
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);
const format = args.includes("--format")
? args[args.indexOf("--format") + 1]
: "markdown";
console.error("🔍 Extracting TRACES from codebase...");
const data = extractTraces();
const defined = readDefinedRequirements();
const allTraced = Object.keys(data.requirements);
data.defined = {
UR: defined.UR,
IR: defined.IR,
DR: defined.DR,
JA: defined.JA,
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, MIN_COVERAGE_PERCENT));
} else if (format === "validate") {
process.exit(reportDangling(data));
} else {
console.log(generateMarkdown(data));
}
console.error(
`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`
);
}