veritas-kanban/scripts/check-coverage-ratchets.mjs
Brad Groux 93e946693d
test: establish critical-path coverage ratchets (#1183)
* test: add critical-path coverage ratchets

* fix: harden coverage ratchet enforcement

* fix: fetch coverage comparison history

* fix: close coverage policy downgrade paths

* fix: verify changed critical statements

* fix: ignore non-executable coverage changes

* fix: distinguish mixed type-only coverage edits

* fix: classify coverage changes by runtime output

* fix: enforce runtime-significant whitespace changes

* fix: classify multiline coverage changes as hunks

* fix: group equal-line coverage rewrites
2026-08-23 13:48:41 -05:00

538 lines
18 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
import path, { matchesGlob } from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
import { coverageExceptionErrors } from './coverage-policy-utils.mjs';
const METRICS = ['lines', 'branches', 'functions', 'statements'];
const POLICY_PATH = 'docs/testing/critical-path-coverage.json';
const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,40}$/i;
function roundPercent(covered, total) {
if (total === 0) return 100;
return Math.floor((covered / total) * 10000) / 100;
}
export function parseSelectedPackages(args, available) {
const valueIndex = args.findIndex((arg) => arg === '--packages');
const inline = args.find((arg) => arg.startsWith('--packages='));
const raw =
inline?.slice('--packages='.length) ?? (valueIndex >= 0 ? args[valueIndex + 1] : undefined);
const selected = raw
? [
...new Set(
raw
.split(',')
.map((value) => value.trim())
.filter(Boolean)
),
]
: [...available];
const unknown = selected.filter((id) => !available.includes(id));
if (unknown.length > 0) throw new Error(`Unknown coverage package(s): ${unknown.join(', ')}`);
if (selected.length === 0) throw new Error('At least one coverage package is required.');
return selected;
}
export function normalizeCoverageSummary(summary, repoRoot) {
return Object.fromEntries(
Object.entries(summary)
.filter(([file]) => file !== 'total')
.map(([file, metrics]) => {
const relative = path.isAbsolute(file) ? path.relative(repoRoot, file) : file;
return [relative.split(path.sep).join('/'), metrics];
})
);
}
export function normalizeDetailedCoverage(coverage, repoRoot) {
return Object.fromEntries(
Object.entries(coverage).map(([file, details]) => {
const relative = path.isAbsolute(file) ? path.relative(repoRoot, file) : file;
return [relative.split(path.sep).join('/'), details];
})
);
}
function validateException(exception, label, today) {
const errors = coverageExceptionErrors(exception, label, today);
if (errors.length > 0) throw new Error(errors.join('\n'));
}
function aggregate(entries) {
return Object.fromEntries(
METRICS.map((metric) => {
const total = entries.reduce((sum, [, value]) => sum + value[metric].total, 0);
const covered = entries.reduce((sum, [, value]) => sum + value[metric].covered, 0);
return [metric, { total, covered, pct: roundPercent(covered, total) }];
})
);
}
function emptyMetrics() {
return Object.fromEntries(METRICS.map((metric) => [metric, { total: 0, covered: 0, pct: 0 }]));
}
function uncoveredChangedStatements(details, changedLines) {
if (!details || changedLines.size === 0) return [];
return Object.entries(details.statementMap ?? {})
.filter(([, location]) => {
for (let line = location.start.line; line <= location.end.line; line += 1) {
if (changedLines.has(line)) return true;
}
return false;
})
.filter(([id]) => (details.s?.[id] ?? 0) === 0)
.map(([, location]) => location.start.line)
.sort((left, right) => left - right);
}
function changedTokenSpans(changedLines, changedSpans, sourceFile) {
const lineStarts = sourceFile.getLineStarts();
return new Map(
[...changedLines].flatMap((line) => {
if (line < 1 || line > lineStarts.length) return [];
const lineStart = lineStarts[line - 1];
const nextLineStart = lineStarts[line] ?? sourceFile.text.length;
const lineLength = sourceFile.text
.slice(lineStart, nextLineStart)
.replace(/[\r\n]+$/, '').length;
const spans = changedSpans.get(line) ?? [{ start: 0, end: Number.POSITIVE_INFINITY }];
return [
[
line,
spans.map(({ start, end, replacement = '', group }) => ({
start: lineStart + Math.min(start, lineLength),
end:
end === Number.POSITIVE_INFINITY
? Number.POSITIVE_INFINITY
: lineStart + Math.min(end, lineLength),
replacement,
group,
})),
],
];
})
);
}
function transpiledRuntime(source, fileName) {
const result = ts.transpileModule(source, {
compilerOptions: {
jsx: ts.JsxEmit.Preserve,
module: ts.ModuleKind.ESNext,
removeComments: true,
target: ts.ScriptTarget.ESNext,
},
fileName,
reportDiagnostics: true,
});
const hasErrors = (result.diagnostics ?? []).some(
({ category }) => category === ts.DiagnosticCategory.Error
);
return hasErrors ? undefined : result.outputText.trim();
}
export function executableChangedLineNumbers(
source,
changedLines,
previousSource = undefined,
changedSpans = new Map(),
fileName = 'coverage-source.ts'
) {
if (changedLines.size === 0) return changedLines;
const emitted = transpiledRuntime(source, fileName);
if (previousSource !== undefined) {
const previousEmitted = transpiledRuntime(previousSource, fileName);
if (emitted !== undefined && emitted === previousEmitted) return new Set();
}
const executableLines = new Set();
const sourceFile = ts.createSourceFile(
fileName,
source,
ts.ScriptTarget.Latest,
false,
ts.getScriptKindFromFileName(fileName)
);
const absoluteSpans = changedTokenSpans(changedLines, changedSpans, sourceFile);
const spansByLine = new Map([...absoluteSpans].map(([line]) => [line, []]));
const spanGroups = new Map();
for (const [line, spans] of absoluteSpans) {
for (const span of spans) {
const key = span.group ?? Symbol('changed-span');
const group = spanGroups.get(key) ?? [];
group.push({ ...span, line });
spanGroups.set(key, group);
}
}
const revertedRuntime = (spans) => {
if (emitted === undefined || spans.some(({ end }) => end === Number.POSITIVE_INFINITY)) {
return undefined;
}
let reverted = source;
for (const span of [...spans].sort((left, right) => right.start - left.start)) {
reverted = `${reverted.slice(0, span.start)}${span.replacement}${reverted.slice(span.end)}`;
}
return transpiledRuntime(reverted, fileName);
};
for (const spans of spanGroups.values()) {
const previousRuntime = revertedRuntime(spans);
if (previousRuntime !== undefined && previousRuntime === emitted) continue;
let selected = spans;
if (previousRuntime !== undefined && spans.length > 1) {
let candidatesEvaluated = 0;
const candidateLimit = 256;
for (let size = 1; size <= spans.length && candidatesEvaluated < candidateLimit; size += 1) {
const matches = [];
const visit = (start, candidate) => {
if (candidatesEvaluated >= candidateLimit) return;
if (candidate.length === size) {
candidatesEvaluated += 1;
if (revertedRuntime(candidate) === previousRuntime) matches.push([...candidate]);
return;
}
for (let index = start; index < spans.length; index += 1) {
candidate.push(spans[index]);
visit(index + 1, candidate);
candidate.pop();
}
};
visit(0, []);
if (matches.length > 0) {
selected = [...new Set(matches.flat())];
break;
}
}
}
for (const span of selected) spansByLine.get(span.line)?.push(span);
}
for (const [line, spans] of spansByLine) {
if (spans.length > 0) executableLines.add(line);
}
return executableLines;
}
export function evaluateCoverage(
policy,
summaries,
today = new Date().toISOString().slice(0, 10),
changedFiles = [],
detailedCoverage = {},
changedLineNumbers = new Map()
) {
if (policy.schemaVersion !== 'critical-path-coverage/v1') {
throw new Error(`Unsupported coverage policy schema: ${policy.schemaVersion}`);
}
const results = [];
const failures = [];
for (const packagePolicy of policy.packages) {
const summary = summaries[packagePolicy.id];
if (!summary) continue;
const details = detailedCoverage[packagePolicy.id] ?? {};
for (const boundary of packagePolicy.boundaries) {
const failureCountBeforeBoundary = failures.length;
const exceptions = boundary.exceptions ?? [];
for (const exception of exceptions) {
validateException(exception, `${packagePolicy.id}/${boundary.id}`, today);
}
const entries = Object.entries(summary).filter(([file]) => {
const included = boundary.include.some((pattern) => matchesGlob(file, pattern));
const excepted = exceptions.some((exception) => file === exception.path);
return included && !excepted;
});
if (entries.length === 0) {
failures.push(`${packagePolicy.id}/${boundary.id} matched no reported source files`);
results.push({
package: packagePolicy.id,
boundary: boundary.id,
description: boundary.description,
files: [],
metrics: emptyMetrics(),
thresholds: boundary.thresholds,
status: 'fail',
});
continue;
}
const metrics = aggregate(entries);
const regressions = METRICS.filter(
(metric) => metrics[metric].pct < boundary.thresholds[metric]
);
for (const metric of regressions) {
failures.push(
`${packagePolicy.id}/${boundary.id} ${metric} ${metrics[metric].pct}% is below ${boundary.thresholds[metric]}%`
);
}
for (const changedFile of changedFiles) {
const included = boundary.include.some((pattern) => matchesGlob(changedFile, pattern));
const excepted = exceptions.some((exception) => exception.path === changedFile);
if (!included || excepted) continue;
const fileCoverage = summary[changedFile];
if (!fileCoverage || fileCoverage.lines.total === 0) {
failures.push(
`${packagePolicy.id}/${boundary.id} changed critical file ${changedFile} has no executable coverage entry`
);
} else if (fileCoverage.lines.covered === 0) {
failures.push(
`${packagePolicy.id}/${boundary.id} changed critical file ${changedFile} has no covered lines`
);
}
const changedLines = changedLineNumbers.get(changedFile) ?? new Set();
const detailedFileCoverage = details[changedFile];
if (changedLines.size > 0 && !detailedFileCoverage) {
failures.push(
`${packagePolicy.id}/${boundary.id} changed critical file ${changedFile} has no statement coverage entry`
);
} else {
const uncoveredLines = uncoveredChangedStatements(detailedFileCoverage, changedLines);
if (uncoveredLines.length > 0) {
failures.push(
`${packagePolicy.id}/${boundary.id} changed critical file ${changedFile} has uncovered executable statements on line(s) ${uncoveredLines.join(', ')}`
);
}
}
}
results.push({
package: packagePolicy.id,
boundary: boundary.id,
description: boundary.description,
files: entries.map(([file]) => file).sort(),
metrics,
thresholds: boundary.thresholds,
status: failures.length === failureCountBeforeBoundary ? 'pass' : 'fail',
});
}
}
return { results, failures };
}
export function changedFilesFromBase(repoRoot, baselineRef) {
if (!baselineRef) return [];
if (!COMMIT_SHA_PATTERN.test(baselineRef)) {
throw new Error('COVERAGE_BASE_REF must be a hexadecimal commit ID.');
}
return execFileSync(
'git',
['diff', '--name-only', '--diff-filter=ACMR', `${baselineRef}...HEAD`],
{
cwd: repoRoot,
encoding: 'utf8',
}
)
.split('\n')
.map((file) => file.trim())
.filter(Boolean);
}
export function changedLineNumbersFromBase(repoRoot, baselineRef, changedFiles) {
if (!baselineRef || changedFiles.length === 0) return new Map();
const changedLines = new Map();
for (const file of changedFiles) {
const diff = execFileSync(
'git',
['diff', '--unified=0', '--no-color', `${baselineRef}...HEAD`, '--', file],
{ cwd: repoRoot, encoding: 'utf8' }
);
const parsedLines = parseChangedLineNumbers(diff);
const parsedSpans = parseChangedLineSpans(diff);
if (!/\.[cm]?[jt]sx?$/.test(file)) {
changedLines.set(file, parsedLines);
continue;
}
const source = readFileSync(path.join(repoRoot, file), 'utf8');
let previousSource;
try {
previousSource = execFileSync('git', ['show', `${baselineRef}:${file}`], {
cwd: repoRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
} catch {
previousSource = '';
}
changedLines.set(
file,
executableChangedLineNumbers(source, parsedLines, previousSource, parsedSpans, file)
);
}
return changedLines;
}
export function parseChangedLineNumbers(diff) {
const lines = new Set();
for (const match of diff.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
const start = Number(match[1]);
const count = match[2] === undefined ? 1 : Number(match[2]);
for (let line = start; line < start + count; line += 1) lines.add(line);
}
return lines;
}
export function parseChangedLineSpans(diff) {
const spans = new Map();
const diffLines = diff.split('\n');
for (let index = 0; index < diffLines.length; index += 1) {
const header = diffLines[index].match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
if (!header) continue;
const hunkGroup = `hunk-${index}`;
let newLine = Number(header[3]);
const removed = [];
const added = [];
for (index += 1; index < diffLines.length && !diffLines[index].startsWith('@@ '); index += 1) {
const line = diffLines[index];
if (line.startsWith('-')) removed.push(line.slice(1));
if (line.startsWith('+')) {
added.push({ line: newLine, text: line.slice(1) });
newLine += 1;
}
}
index -= 1;
for (let addedIndex = 0; addedIndex < added.length; addedIndex += 1) {
const current = added[addedIndex];
const previous = removed.length === added.length ? removed[addedIndex] : undefined;
if (previous === undefined) {
spans.set(current.line, [
{
start: 0,
end: current.text.length,
replacement: addedIndex === 0 ? removed.join('\n') : '',
group: hunkGroup,
},
]);
continue;
}
let start = 0;
while (
start < previous.length &&
start < current.text.length &&
previous[start] === current.text[start]
) {
start += 1;
}
let previousEnd = previous.length;
let currentEnd = current.text.length;
while (
previousEnd > start &&
currentEnd > start &&
previous[previousEnd - 1] === current.text[currentEnd - 1]
) {
previousEnd -= 1;
currentEnd -= 1;
}
spans.set(current.line, [
{
start,
end: currentEnd,
replacement: previous.slice(start, previousEnd),
group: hunkGroup,
},
]);
}
}
return spans;
}
export function markdownSummary(evaluation, longTermTarget) {
const lines = [
'### Critical-path coverage ratchets',
'',
'| Package | Boundary | Lines | Branches | Functions | Statements | Result |',
'| --- | --- | ---: | ---: | ---: | ---: | --- |',
];
for (const result of evaluation.results) {
lines.push(
`| ${result.package} | ${result.boundary} | ${result.metrics.lines.pct}% | ${result.metrics.branches.pct}% | ${result.metrics.functions.pct}% | ${result.metrics.statements.pct}% | ${result.status} |`
);
}
lines.push(
'',
`Long-term critical-path target: ${longTermTarget.lines}% lines, ${longTermTarget.branches}% branches, ${longTermTarget.functions}% functions, and ${longTermTarget.statements}% statements.`,
''
);
return lines.join('\n');
}
export async function runCoverageRatchets({
repoRoot = process.cwd(),
args = process.argv.slice(2),
} = {}) {
const policy = JSON.parse(await readFile(path.join(repoRoot, POLICY_PATH), 'utf8'));
const available = policy.packages.map(({ id }) => id);
const selected = parseSelectedPackages(args, available);
const summaries = {};
const detailedCoverage = {};
for (const packagePolicy of policy.packages) {
if (!selected.includes(packagePolicy.id)) continue;
const reportPath = path.join(repoRoot, packagePolicy.report);
const [summary, details] = await Promise.all([
readFile(reportPath, 'utf8').then(JSON.parse),
readFile(path.join(path.dirname(reportPath), 'coverage-final.json'), 'utf8').then(JSON.parse),
]);
summaries[packagePolicy.id] = normalizeCoverageSummary(summary, repoRoot);
detailedCoverage[packagePolicy.id] = normalizeDetailedCoverage(details, repoRoot);
}
const changedFiles = changedFilesFromBase(repoRoot, process.env.COVERAGE_BASE_REF);
const changedLineNumbers = changedLineNumbersFromBase(
repoRoot,
process.env.COVERAGE_BASE_REF,
changedFiles
);
const evaluation = evaluateCoverage(
policy,
summaries,
undefined,
changedFiles,
detailedCoverage,
changedLineNumbers
);
const machineReport = {
schemaVersion: 'critical-path-coverage-report/v1',
longTermTarget: policy.longTermTarget,
packages: selected,
results: evaluation.results,
failures: evaluation.failures,
};
const markdown = markdownSummary(evaluation, policy.longTermTarget);
const outputDir = path.join(repoRoot, 'coverage');
await mkdir(outputDir, { recursive: true });
await writeFile(
path.join(outputDir, 'critical-path-summary.json'),
`${JSON.stringify(machineReport, null, 2)}\n`
);
await writeFile(path.join(outputDir, 'critical-path-summary.md'), markdown);
if (process.env.GITHUB_STEP_SUMMARY) {
await appendFile(process.env.GITHUB_STEP_SUMMARY, markdown);
}
console.log(markdown);
if (evaluation.failures.length > 0) {
throw new Error(`Coverage ratchet failed:\n- ${evaluation.failures.join('\n- ')}`);
}
return machineReport;
}
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isMain) {
runCoverageRatchets().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}