Repository navigation
Expand file tree
/
Copy pathrun.ts
More file actions
145 lines (134 loc) · 6.08 KB
/
Copy pathrun.ts
File metadata and controls
145 lines (134 loc) · 6.08 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
// Entry point for the changed-line coverage gate (#1418):
// `pnpm check:coverage-changed [--base <ref>]`.
//
// Reuses the lcov report that `pnpm test:coverage` already produced (never runs
// coverage a second time), joins it with the changed-lines diff against <base>
// (`readChangedLinesDiff`, which pairs renames so a moved file owes only the
// hunks that differ from its source), and fails when changed-line coverage is
// below the threshold. A PR carrying the `coverage-waiver` label skips the
// failure but still prints every number.
// The same markdown report goes to stdout and to the GitHub job summary; it
// includes changed-branch coverage and the count of changed executable lines
// excluded from coverage, both non-gating.
//
// The report is only scored when the stamp beside it proves this checkout
// wrote it: no stamp, a stamp from another commit or another working tree, or
// a narrowed run all refuse with the reason instead of a verdict, the same as
// the CRAP gate. A report whose records name files outside this checkout is
// refused the same way — the stamp cannot catch two checkouts of one commit,
// which share HEAD and bytes. Instrumentation is selected from the verified
// stamp's include/exclude, so the gate and the CRAP gate answer from one
// declaration of what the run measured.
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { runCmdSync } from '@agent-device/host-kit/command';
import { parseScriptArgs } from '../lib/cli-args.ts';
import { parseUnifiedDiff, readChangedLinesDiff } from '../lib/repo-git.ts';
import { computeChangedCoverage, parseLcov, type ChangedCoverageResult } from './model.ts';
import {
describeStampMismatch,
findOutsideRootPath,
verifyCoverageStamp,
} from '../coverage-crap/stamp.ts';
const USAGE = 'Usage: pnpm check:coverage-changed [--base <ref>]\n';
const COVERAGE_DIR = 'coverage';
const LCOV_PATH = 'coverage/lcov.info';
function fmtPct(pct: number | null): string {
return pct === null ? 'n/a' : `${pct.toFixed(2)}%`;
}
function render(result: ChangedCoverageResult): string {
const verdict = result.waived
? 'WAIVED (`coverage-waiver` label)'
: result.passed
? 'PASS'
: 'FAIL';
const lines = [
`## Changed-line coverage gate: ${verdict}`,
'',
'| Metric | Value |',
'| --- | --- |',
`| Changed-line coverage (gating, threshold ${result.threshold}%) | ${result.coveredLines}/${result.totalLines} (${fmtPct(result.pct)}) |`,
`| Changed-branch coverage (non-gating) | ${result.branch.covered}/${result.branch.total} (${fmtPct(result.branch.pct)}) |`,
`| Changed executable lines excluded (non-gating) | ${result.excluded.totalLines} |`,
];
for (const file of result.excluded.files) {
lines.push(`- excluded \`${file.path}\` [${file.reason}]: ${file.lines.join(', ')}`);
}
if (result.offenders.length > 0) {
lines.push('', 'Uncovered changed lines:');
for (const file of result.offenders) {
lines.push(`- \`${file.path}\`: ${file.uncoveredLines.join(', ')}`);
}
}
return `${lines.join('\n')}\n`;
}
export function run(argv: readonly string[], cwd?: string): number {
const values = parseScriptArgs(argv, USAGE, {
base: { type: 'string', default: 'origin/main' },
});
const base = values.base ?? 'origin/main';
// The `coverage-waiver` PR label sets this env var in CI (and locally).
const waived = /^(1|true)$/i.test(process.env.AGENT_DEVICE_COVERAGE_WAIVER ?? '');
const root = cwd ?? runCmdSync('git', ['rev-parse', '--show-toplevel']).stdout.trim();
const coverageDir = path.resolve(root, COVERAGE_DIR);
const lcovPath = path.resolve(root, LCOV_PATH);
if (!fs.existsSync(lcovPath)) {
process.stderr.write(
`check:coverage-changed: no lcov report at ${lcovPath}. Run \`pnpm test:coverage\` first.\n`,
);
return 1;
}
const coverage = parseLcov(fs.readFileSync(lcovPath, 'utf8'), root);
// lcov writes its paths absolute, so a report from another root relativizes
// outside this one and can join none of its files. That must not read as "no
// file was measured", and the stamp cannot catch it: two checkouts of one
// commit carry the same HEAD and the same bytes.
const outside = findOutsideRootPath(coverage.keys());
if (outside !== undefined) {
return refuse(
`${lcovPath} records files outside this checkout, for example ${outside}. ` +
'It was written by a run in another root; run `pnpm test:coverage` here.',
);
}
// A verdict computed from an unreadable or unattributable report would read
// as "no findings", which is indistinguishable from a clean branch, so those
// cases stop here — before the waiver is consulted, which skips a failure
// rather than blessing a report with no numbers to print.
const verified = verifyCoverageStamp(coverageDir, root);
if ('mismatch' in verified) return refuse(describeStampMismatch(verified.mismatch));
const diff = readChangedLinesDiff(root, base);
const result = computeChangedCoverage({
diffs: parseUnifiedDiff(diff),
coverage,
stamp: verified.stamp,
waived,
fileLines: (relPath) => {
const abs = path.join(root, relPath);
return fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8').split('\n') : null;
},
});
const report = render(result);
process.stdout.write(report);
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
if (summaryPath) fs.appendFileSync(summaryPath, report);
return result.passed ? 0 : 1;
}
/**
* Says out loud that no verdict was reached. These cases exit before a report exists, so they go to
* stderr rather than heading a table of nothing, which would read as a clean result in the logs.
*/
function refuse(reason: string): number {
process.stderr.write(`check:coverage-changed: no trustworthy verdict — ${reason}\n`);
return 1;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
try {
process.exitCode = run(process.argv.slice(2));
} catch (error: unknown) {
process.stderr.write(
`check:coverage-changed: ${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
}
}