Repository navigation
Expand file tree
/
Copy pathrun.ts
More file actions
286 lines (264 loc) · 10.7 KB
/
Copy pathrun.ts
File metadata and controls
286 lines (264 loc) · 10.7 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
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
// Entry point for the coverage-based CRAP verdict: `pnpm check:coverage-crap [--base <ref> | --all]`.
//
// Reuses the Istanbul map `pnpm test:coverage` already wrote (never runs coverage a second time),
// drops records with impossible counts, and asks fallow to score the functions the diff against
// <base> touches, or every function with --all.
//
// `--base` (a PR) blocks: it exits 1 when a touched function scores at or above the threshold, and
// also when the reports cannot be trusted to answer — a missing or malformed map, a map with fewer
// files than the same run enumerated, or a touched file the map dropped or never recorded. `--all`
// (the `main` report) stays report-only, because `main` carries debt on purpose: it exits 0 once it
// has a report to show. Attribution is rename-aware — `readChangedLinesDiff` pairs a pure move at
// 90%, so relocating a file owes nothing for the functions that moved with it.
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 { parseLcov, parseUnifiedDiff } from '../coverage-changed/model.ts';
import { readChangedLinesDiff } from '../lib/repo-git.ts';
import {
coverageMapPaths,
decideCrapVerdict,
parseFallowJsonReport,
parseIstanbulCoverageMap,
readConfiguredExemptions,
readCrapFindings,
readEstimatedFindings,
readTouchedFiles,
renderCrapAnnotations,
renderCrapReport,
sanitizeIstanbulCoverage,
usableEnumeration,
type CrapExemptions,
type CrapFinding,
type CrapProblem,
type CrapScope,
type IstanbulCoverageMap,
type TouchedFile,
} from './model.ts';
const USAGE = 'Usage: pnpm check:coverage-crap [--base <ref> | --all]\n';
const COVERAGE_PATH = 'coverage/coverage-istanbul.json';
const LCOV_PATH = 'coverage/lcov.info';
const SANITIZED_PATH = 'coverage/fallow-crap-coverage.json';
const DIFF_PATH = 'coverage/fallow-crap.diff';
const FALLOW_CONFIG = 'fallow-crap.json';
const CRAP_THRESHOLD = 30;
const FALLOW_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
// This repository's own pinned fallow, beside the checkout this script was loaded from. Resolving
// it from the analyzed root instead would let a second checkout supply a different version.
const FALLOW_BIN = path.resolve(
import.meta.dirname,
'..',
'..',
'node_modules',
'fallow',
'bin',
'fallow',
);
function firstLine(text: string): string {
return text.trim().split('\n')[0] ?? 'no output';
}
/** Drops an `AppError` prefix so the report names the cause rather than the wrapper. */
function describe(error: unknown): string {
return error instanceof Error ? firstLine(error.message) : String(error);
}
/** Absolute coverage-record keys, as the repo-relative paths fallow and the diff both use. */
function relativeToRoot(root: string, files: readonly string[]): string[] {
return files.map((file) =>
path.isAbsolute(file) ? path.relative(root, file).split('\\').join('/') : file,
);
}
/**
* 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-crap: no trustworthy verdict — ${reason}\n`);
return 1;
}
/**
* The diff this verdict is scoped to, written where fallow reads it. Without it there is nothing to
* scope to, and a `fallow-crap.diff` left by an earlier run would silently supply one, so a failure
* here is fatal rather than a scoring of the wrong lines.
*/
function readScopedDiff(root: string, base: string): readonly TouchedFile[] {
const diff = readChangedLinesDiff(root, base);
fs.writeFileSync(path.resolve(root, DIFF_PATH), diff);
return readTouchedFiles(parseUnifiedDiff(diff));
}
/** The files the coverage run enumerated, per lcov, or null when it wrote no such report. */
function readEnumeratedPaths(root: string): Set<string> | null {
const lcovPath = path.resolve(root, LCOV_PATH);
if (!fs.existsSync(lcovPath)) return null;
return usableEnumeration(parseLcov(fs.readFileSync(lcovPath, 'utf8'), root).keys());
}
/** Says which of the two ways the enumeration went missing, because the reader checks a different thing. */
function describeMissingEnumeration(root: string): string {
return fs.existsSync(path.resolve(root, LCOV_PATH))
? `${LCOV_PATH} lists no files`
: `no ${LCOV_PATH}`;
}
/** What one coverage run and one diff say, as far as they can be read at all. */
type CrapInputs = Readonly<{
/** Every file the run recorded counters for, including records dropped as corrupt below. */
mapPaths: ReadonlySet<string>;
/** Every file the run listed in lcov, or empty when it wrote none. */
enumeratedPaths: ReadonlySet<string>;
touched: readonly TouchedFile[];
/** Records the sanitizer refused, as repo-relative paths. */
dropped: readonly string[];
/** Reasons the inputs themselves are incomplete, carried into the verdict. */
problems: readonly CrapProblem[];
}>;
/**
* Reads the two reports of one coverage run plus this change's diff, or names the reason there is
* nothing to decide from. A verdict computed from an unreadable map would read as "no findings",
* which is indistinguishable from a clean branch, so those cases stop here.
*/
function gatherCrapInputs(
root: string,
base: string,
scope: CrapScope,
): CrapInputs | { refusal: string } {
const coveragePath = path.resolve(root, COVERAGE_PATH);
if (!fs.existsSync(coveragePath)) {
return {
refusal: `no Istanbul coverage map at ${coveragePath}. Run \`pnpm test:coverage\` first.`,
};
}
let map: IstanbulCoverageMap;
try {
map = parseIstanbulCoverageMap(fs.readFileSync(coveragePath, 'utf8'));
} catch (error: unknown) {
return { refusal: `${coveragePath}: ${describe(error)}` };
}
let touched: readonly TouchedFile[] = [];
if (scope === 'touched') {
try {
touched = readScopedDiff(root, base);
} catch (error: unknown) {
return { refusal: `the diff against ${base} failed: ${describe(error)}` };
}
}
const { map: sanitized, dropped } = sanitizeIstanbulCoverage(map);
fs.writeFileSync(path.resolve(root, SANITIZED_PATH), JSON.stringify(sanitized));
// lcov is the same coverage run's own list of the files it enumerated, which is what makes a
// touched file absent from the map detectable. Only the per-file verdict needs it: the whole-repo
// backlog is scored from the map alone.
const enumerated = readEnumeratedPaths(root);
const problems: CrapProblem[] = [];
if (scope === 'touched' && enumerated === null) {
problems.push({ kind: 'enumeration_missing', detail: describeMissingEnumeration(root) });
}
return {
mapPaths: coverageMapPaths(map, root),
enumeratedPaths: enumerated ?? new Set<string>(),
touched,
dropped: relativeToRoot(root, dropped),
problems,
};
}
function runFallowHealth(root: string, scope: CrapScope): { stdout: string } | { failure: string } {
const fallow = runCmdSync(
process.execPath,
[
FALLOW_BIN,
'health',
'--config',
FALLOW_CONFIG,
'--complexity',
'--max-crap',
String(CRAP_THRESHOLD),
'--coverage',
SANITIZED_PATH,
...(scope === 'touched' ? ['--diff-file', DIFF_PATH] : []),
'--report-only',
'--format',
'json',
'--quiet',
],
{ cwd: root, allowFailure: true, maxBuffer: FALLOW_MAX_BUFFER_BYTES },
);
return fallow.exitCode === 0
? { stdout: fallow.stdout }
: { failure: `fallow health failed:\n${fallow.stderr}` };
}
/**
* The exemptions come from the resolved config, not from a copy in this diff: the report then states
* the scope fallow actually applied. Reading them is reporting, not gating, so a failure says so.
*/
function readExemptions(root: string): CrapExemptions {
const config = runCmdSync(
process.execPath,
[FALLOW_BIN, 'config', '--config', FALLOW_CONFIG, '--format', 'json'],
{ cwd: root, allowFailure: true, maxBuffer: FALLOW_MAX_BUFFER_BYTES },
);
return config.exitCode === 0
? readConfiguredExemptions(config.stdout)
: { available: false, detail: firstLine(config.stderr) };
}
/** Scores what was gathered, prints the verdict, and returns the process exit code. */
function reportCrapVerdict(root: string, scope: CrapScope, inputs: CrapInputs): number {
const health = runFallowHealth(root, scope);
if ('failure' in health) {
process.stderr.write(`check:coverage-crap: ${health.failure}\n`);
return 1;
}
const report = parseFallowJsonReport(health.stdout);
const findings = readCrapFindings(report, CRAP_THRESHOLD);
const verdict = decideCrapVerdict({
scope,
findings,
estimated: readEstimatedFindings(report, CRAP_THRESHOLD),
touched: inputs.touched,
dropped: new Set(inputs.dropped),
// Pre-sanitizer on purpose: a record the sanitizer dropped is reported as corrupt, and a file
// the run measured is not also missing from the map.
mapPaths: inputs.mapPaths,
enumeratedPaths: inputs.enumeratedPaths,
ioProblems: inputs.problems,
});
const rendered = renderCrapReport({
findings,
dropped: inputs.dropped,
threshold: CRAP_THRESHOLD,
scope,
verdict,
exemptions: readExemptions(root),
});
publish(rendered, scope, verdict.offenders);
return verdict.blocking ? 1 : 0;
}
/** The report goes to stdout, to the job summary, and — the debt only — onto the PR's diff. */
function publish(rendered: string, scope: CrapScope, offenders: readonly CrapFinding[]): void {
process.stdout.write(rendered);
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
if (summaryPath) fs.appendFileSync(summaryPath, rendered);
if (scope === 'touched' && process.env.GITHUB_ACTIONS === 'true') {
process.stdout.write(renderCrapAnnotations(offenders));
}
}
export function run(argv: readonly string[], cwd?: string): number {
const values = parseScriptArgs(argv, USAGE, {
base: { type: 'string', default: 'origin/main' },
all: { type: 'boolean', default: false },
});
const base = values.base ?? 'origin/main';
const scope: CrapScope = values.all ? 'all' : 'touched';
const root = cwd ?? runCmdSync('git', ['rev-parse', '--show-toplevel']).stdout.trim();
const inputs = gatherCrapInputs(root, base, scope);
if ('refusal' in inputs) return refuse(inputs.refusal);
return reportCrapVerdict(root, scope, inputs);
}
// exitCode, not exit(): a large report written to a pipe must drain before the process ends.
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-crap: ${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
}
}