The coverage gate divided traced counts by hardcoded literals (UR/39, IR/24, DR/48, JA/3, TOTAL_REQS=114) that had fallen out of date as requirements grew to 211. It reported 158% coverage — JA alone printed 800% — so the 50% threshold was mathematically unreachable and the job could not fail. Coverage could have collapsed to 30% and CI would still have printed a green tick. Real coverage is 86%. The number was fine; the gate was dead. extract-traces.ts now owns both sides of the fraction: - countDefinedRequirements() counts an ID only where it leads a markdown table row, ignoring the "Traces To" column and prose. IDs are deduplicated because requirements.md lists every UR twice (§1 definition + §3 matrix), which would otherwise report UR as 121/61. - computeCoverage() uses the intersection of traced and defined IDs, so a TRACES comment naming a deleted or typo'd requirement is reported as `orphaned` rather than inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. - CI reads .coverage.percent and fails on <50% or >100%; a >100% reading is now a hard error rather than the condition that hid this bug. - New `bun run traces:coverage` runs the same computation locally. - scripts/ added to the scan roots — the coverage tool was invisible to the matrix it generates. Tests written first (15, over fixtures so they don't drift as requirements are added). vitest include widened to scripts/** so build tooling is covered by the normal suite. Verified empirically rather than by inspection: forcing the threshold to 99% fails; adding a requirement lowers coverage 86%→85%; a TRACES: DR-999 lands in `orphaned` without changing `covered`. traceability-ci.md documented the same stale numbers and would have let the broken arithmetic be reconstructed — replaced with a pointer to the live command.
463 lines
13 KiB
TypeScript
463 lines
13 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;
|
|
}
|
|
|
|
// 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;
|
|
ids: 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 ROW_ID = /^\|\s*(UR|IR|DR|JA)-(\d{3})\s*\|/;
|
|
|
|
for (const line of markdown.split("\n")) {
|
|
const match = line.match(ROW_ID);
|
|
if (match) ids.add(`${match[1]}-${match[2]}`);
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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.");
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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);
|
|
|
|
if (format === "json") {
|
|
console.log(generateJson(data));
|
|
} else if (format === "coverage") {
|
|
process.exit(reportCoverage(data, 50));
|
|
} else {
|
|
console.log(generateMarkdown(data));
|
|
}
|
|
|
|
console.error(
|
|
`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`
|
|
);
|
|
}
|