Repository navigation
Expand file tree
/
Copy pathmodel.ts
More file actions
524 lines (476 loc) · 23.3 KB
/
Copy pathmodel.ts
File metadata and controls
524 lines (476 loc) · 23.3 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
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
// Pure pieces of the coverage-based CRAP verdict: which Istanbul file records fallow can trust,
// whether the two reports of one coverage run agree about what it measured, and how the scored
// functions are rendered. run.ts owns the git, fallow, config and file I/O.
import path from 'node:path';
import type { ChangedFileDiff } from '../coverage-changed/model.ts';
type IstanbulCounters = Readonly<{
s?: Readonly<Record<string, unknown>>;
f?: Readonly<Record<string, unknown>>;
b?: Readonly<Record<string, unknown>>;
}>;
export type IstanbulCoverageMap = Readonly<Record<string, IstanbulCounters>>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* The Istanbul map `pnpm test:coverage` writes, or a failure naming what is wrong with it.
*
* A PR gate decides from this file, so an unreadable one has to be its own outcome rather than an
* empty findings list: `[]` and `{}` both read as "nothing here is complex enough to block".
*/
export function parseIstanbulCoverageMap(text: string): IstanbulCoverageMap {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (error: unknown) {
throw new Error(
`coverage map is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (!isRecord(parsed)) {
throw new Error(
`coverage map is ${Array.isArray(parsed) ? 'an array' : typeof parsed}, not a file -> counters object`,
);
}
for (const [file, record] of Object.entries(parsed)) {
if (!isRecord(record)) {
throw new Error(`coverage map record for ${file} is ${typeof record}, not a counters object`);
}
for (const counter of ['s', 'f', 'b'] as const) {
const counts = record[counter];
if (counts !== undefined && !isRecord(counts)) {
throw new Error(
`coverage map record for ${file} has ${counter} as ${typeof counts}, not a count object`,
);
}
}
}
return parsed as IstanbulCoverageMap;
}
/**
* A coverage map's keys as repo-relative paths. Vitest writes absolute ones, so the record's home
* is part of reading the map: a report from another checkout relativizes outside this root and
* matches none of its files, which must not read as "that file was never measured".
*/
export function coverageMapPaths(map: IstanbulCoverageMap, root: string): Set<string> {
const toPosix = (value: string): string => value.split(path.sep).join('/');
return new Set(
Object.keys(map).map((key) => toPosix(path.isAbsolute(key) ? path.relative(root, key) : key)),
);
}
/**
* The lcov enumeration if it can serve as the record of what the run instrumented, or null.
*
* Zero files is that case rather than a trivially-satisfied one: every completeness check reads this
* list, so an empty parse of an existing file silences them all at once — `map_partial` finds nothing
* unrecorded, `unscored_touched` finds no hit, and a touched file missing from the map reads as clean.
*/
export function usableEnumeration(files: Iterable<string>): Set<string> | null {
const enumerated = new Set(files);
return enumerated.size > 0 ? enumerated : null;
}
export type SanitizedCoverage = Readonly<{
map: IstanbulCoverageMap;
/** Records dropped because a hit count is not a non-negative integer. */
dropped: readonly string[];
}>;
function isHitCount(value: unknown): boolean {
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
}
function countsAreValid(record: IstanbulCounters): boolean {
const scalars = [...Object.values(record.s ?? {}), ...Object.values(record.f ?? {})];
const branches = Object.values(record.b ?? {}).flatMap((counts) =>
Array.isArray(counts) ? counts : [counts],
);
return [...scalars, ...branches].every(isHitCount);
}
/**
* Drops file records with a negative, null, or non-integer hit count, which fallow refuses the
* whole map over.
*
* Two upstream defects produced those counts on real runs, both fixed by the patches under
* `patches/`: the v8 provider resolved a module's script wrapper length from whichever coverage
* file it read last, so an unmeasured one shifted every offset off its source, and
* `ast-v8-to-istanbul` subtracted located branch paths from a region that did not contain them,
* which an awaited continuation can make negative. Either can also merge into a record measured
* correctly and pull its counts down, so a record is dropped whole rather than repaired; zeroed
* counts from the same shift are indistinguishable from untested code and stay. A dropped file
* gets no CRAP score rather than a wrong one.
*/
export function sanitizeIstanbulCoverage(map: IstanbulCoverageMap): SanitizedCoverage {
const kept: Record<string, IstanbulCounters> = {};
const dropped: string[] = [];
for (const [file, record] of Object.entries(map)) {
if (countsAreValid(record)) kept[file] = record;
else dropped.push(file);
}
return { map: kept, dropped: dropped.sort() };
}
export type CrapFinding = Readonly<{
path: string;
name: string;
line: number;
crap: number;
cyclomatic: number;
coveragePct: number | null;
}>;
function readFinding(value: unknown): CrapFinding | null {
if (!isRecord(value)) return null;
const { path, name, line, crap, cyclomatic } = value;
// Outside the coverage map (tests, scripts, dropped records) fallow estimates from imports.
if (value.coverage_source !== 'istanbul') return null;
if (typeof path !== 'string' || typeof name !== 'string') return null;
if (typeof line !== 'number' || typeof crap !== 'number' || typeof cyclomatic !== 'number') {
return null;
}
const coveragePct = typeof value.coverage_pct === 'number' ? value.coverage_pct : null;
return { path, name, line, crap, cyclomatic, coveragePct };
}
/** The JSON report from fallow's stdout, past any line it prints before the report. */
export function parseFallowJsonReport(stdout: string): unknown {
const start = stdout.indexOf('{');
if (start === -1) throw new Error(`fallow printed no JSON report:\n${stdout.slice(0, 2000)}`);
return JSON.parse(stdout.slice(start));
}
function readFindings(report: unknown): readonly Record<string, unknown>[] {
const findings = isRecord(report) ? report.findings : undefined;
if (!Array.isArray(findings)) throw new Error('fallow health report has no findings array');
return findings.filter((entry): entry is Record<string, unknown> => isRecord(entry));
}
/** The measured CRAP findings of one `fallow health --format json` report, highest score first. */
export function readCrapFindings(report: unknown, threshold: number): CrapFinding[] {
return readFindings(report)
.map(readFinding)
.filter((finding): finding is CrapFinding => finding !== null && finding.crap >= threshold)
.sort((left, right) => right.crap - left.crap || left.path.localeCompare(right.path));
}
/** A function at or above the threshold that fallow scored without an Istanbul record. */
export type EstimatedFinding = Readonly<{
path: string;
name: string;
line: number;
crap: number;
}>;
/**
* The functions reported at or above the threshold that fallow scored from complexity alone: it
* emits `coverage_source: 'estimated'` when a function's definition is not in the coverage data, so
* in a file the map *does* record the map predates the function. Such a score is not a measurement,
* and cannot settle whether a touched function is affordable.
*/
export function readEstimatedFindings(report: unknown, threshold: number): EstimatedFinding[] {
return readFindings(report)
.filter((entry) => entry.coverage_source !== 'istanbul')
.filter((entry) => typeof entry.crap === 'number' && entry.crap >= threshold)
.filter((entry) => typeof entry.path === 'string' && typeof entry.name === 'string')
.map((entry) => ({
path: entry.path as string,
name: entry.name as string,
line: typeof entry.line === 'number' ? entry.line : 0,
crap: entry.crap as number,
}))
.sort((left, right) => left.path.localeCompare(right.path) || left.line - right.line);
}
function formatCoverage(pct: number | null): string {
return pct === null ? 'n/a' : `${pct.toFixed(0)}%`;
}
/** `touched` scores the functions a diff changes; `all` scores every function in the map. */
export type CrapScope = 'touched' | 'all';
/** A source file the diff adds lines to, and how many. */
export type TouchedFile = Readonly<{ path: string; addedLines: number }>;
/**
* The files whose lines this change edits. A pure rename, delete, or mode change adds no lines, so
* it contributes none — which is also exactly the set of files fallow's `--diff-file` scoping can
* charge a finding to, since that filter indexes the same added lines.
*/
export function readTouchedFiles(diffs: readonly ChangedFileDiff[]): TouchedFile[] {
return diffs
.filter((diff) => !diff.deleted && diff.added.length > 0)
.map((diff) => ({ path: diff.path, addedLines: diff.added.length }));
}
/** A touched file the coverage run enumerated but recorded no counters for. */
export type UnscoredTouchedFile = Readonly<{ path: string; addedLines: number }>;
/**
* Why this run cannot trust what it scored. Keyed, never inferred from error text: the report and
* the remediation both branch on `kind`, and prose is the one place a reason could be reworded
* away. A missing or unreadable map is not one of these — run.ts fails before computing a verdict,
* because there is no report left to reason about.
*/
export type CrapProblem =
| Readonly<{ kind: 'enumeration_missing'; detail: string }>
| Readonly<{ kind: 'map_partial'; mapFiles: number; enumeratedFiles: number }>
| Readonly<{ kind: 'corrupt_touched'; files: readonly string[] }>
| Readonly<{ kind: 'unscored_touched'; files: readonly UnscoredTouchedFile[] }>
| Readonly<{ kind: 'unmeasured_touched'; files: readonly EstimatedFinding[] }>;
const PROBLEM_MESSAGES: Readonly<Record<CrapProblem['kind'], string>> = {
enumeration_missing:
'the lcov report that says which files the coverage run enumerated is missing or unreadable',
map_partial: 'the Istanbul coverage map records fewer files than the same run enumerated',
corrupt_touched: 'touched file(s) were dropped from the map for impossible counts',
unscored_touched: 'touched file(s) with changed lines have no record in the coverage map',
unmeasured_touched:
'touched function(s) are absent from the coverage map, so their score is estimated from complexity alone',
};
const PROBLEM_FIXES: Readonly<Record<CrapProblem['kind'], string>> = {
enumeration_missing:
'Run `pnpm test:coverage` again: it writes coverage/lcov.info beside the map.',
map_partial: 'Run `pnpm test:coverage` again on this commit; coverage/ mixes reports.',
corrupt_touched:
'Run `pnpm test:coverage` again on this commit. If the counts stay impossible, that file is unscored, not clean.',
unscored_touched:
'Run `pnpm test:coverage` again on this commit. A file the run never loaded has no measured score.',
unmeasured_touched:
'Run `pnpm test:coverage` again: the map predates these functions, so only their complexity is known.',
};
function describeProblemFile(file: string | UnscoredTouchedFile | EstimatedFinding): string {
if (typeof file === 'string') return `\`${file}\``;
return 'addedLines' in file
? `\`${file.path}\` (${file.addedLines} changed line(s))`
: `\`${file.path}:${file.line}\` ${file.name}`;
}
function renderProblems(problems: readonly CrapProblem[]): string[] {
return problems.map((problem) => {
const detail = 'detail' in problem ? ` (${problem.detail})` : '';
const counts =
'mapFiles' in problem
? ` (${problem.mapFiles} recorded, ${problem.enumeratedFiles} enumerated)`
: '';
const files =
'files' in problem ? `: ${problem.files.map(describeProblemFile).join(', ')}` : '';
return `- **${PROBLEM_MESSAGES[problem.kind]}**${detail}${counts}${files}\n ${PROBLEM_FIXES[problem.kind]}`;
});
}
export type CrapVerdict = Readonly<{
blocking: boolean;
problems: readonly CrapProblem[];
/** Estimates the report cannot measure, listed for the reader without blocking on them. */
unscoped: readonly EstimatedFinding[];
offenders: readonly CrapFinding[];
}>;
/**
* The verdict for one report. `touched` scope blocks on a function at or above the threshold and on
* any reason the report cannot be trusted to answer, because `[]` from a partial map looks exactly
* like a clean branch. `all` scope is the backlog report on `main`, which carries debt on purpose:
* it lists what it could not verify without blocking on it.
*/
export function decideCrapVerdict(input: {
scope: CrapScope;
findings: readonly CrapFinding[];
estimated: readonly EstimatedFinding[];
touched: readonly TouchedFile[];
dropped: ReadonlySet<string>;
mapPaths: ReadonlySet<string>;
enumeratedPaths: ReadonlySet<string>;
ioProblems: readonly CrapProblem[];
}): CrapVerdict {
const problems = [...input.ioProblems];
const corrupt = input.touched
.filter((file) => input.dropped.has(file.path))
.map((file) => file.path);
if (corrupt.length > 0) problems.push({ kind: 'corrupt_touched', files: corrupt });
// lcov and the Istanbul map are two reports of one coverage run, so every file the run enumerated
// has to be recorded in the map. The test is per path rather than a count: a map of the right size
// holding the wrong paths — one file measured, another dropped, both reported as 1,724 entries — is
// exactly what a report mixed from two coverage runs looks like, and counting would trust it.
const unrecorded = [...input.enumeratedPaths].filter((file) => !input.mapPaths.has(file));
if (unrecorded.length > 0) {
problems.push({
kind: 'map_partial',
mapFiles: input.mapPaths.size,
enumeratedFiles: input.enumeratedPaths.size,
});
}
// Of those, the ones this diff touches are what the verdict was going to score. Records the
// sanitizer dropped are named as corrupt above and stay in `mapPaths`, so they are not also here.
const missing = new Set(unrecorded);
const unscored = input.touched
.filter((file) => missing.has(file.path))
.map((file) => ({ path: file.path, addedLines: file.addedLines }));
if (unscored.length > 0) problems.push({ kind: 'unscored_touched', files: unscored });
// A touched file the map does record, with a function in it that the map does not: the map is
// older than the code and fallow is estimating that function from its complexity.
const touchedPaths = new Set(input.touched.map((file) => file.path));
const unmeasured = input.estimated.filter(
(finding) => touchedPaths.has(finding.path) && input.mapPaths.has(finding.path),
);
if (unmeasured.length > 0) problems.push({ kind: 'unmeasured_touched', files: unmeasured });
// The same estimate for a file neither report knows is a different fact: it is outside what coverage
// measures at all (scripts, a worker entry, a package the coverage config excludes), which no re-run
// will change and which is not debt this PR owes. It is still not a measurement, so the report says
// so instead of letting the headline imply the function was checked and cleared. Files the run
// enumerated but did not record are already named above as unscored, and stay out of this list.
//
// Only while the enumeration exists to consult: without it, "coverage does not instrument this" is
// a claim this run cannot make, since the file could equally be instrumented-but-unrecorded — the
// gap enumeration_missing already names.
const unscoped =
input.enumeratedPaths.size === 0
? []
: input.estimated.filter(
(finding) =>
touchedPaths.has(finding.path) &&
!input.mapPaths.has(finding.path) &&
!input.enumeratedPaths.has(finding.path),
);
// In `touched` scope fallow has already narrowed the findings to functions whose lines the diff
// adds, so nothing is left to re-filter here.
return {
blocking: input.scope === 'touched' && (input.findings.length > 0 || problems.length > 0),
problems,
unscoped,
offenders: input.scope === 'touched' ? input.findings : [],
};
}
/** A file-wide `maxCrap` override of `fallow-crap.json`, read from the resolved config. */
export type CrapExemption = Readonly<{ files: readonly string[]; maxCrap: number; reason: string }>;
/**
* The file-wide CRAP exemptions of a resolved fallow config: the overrides that raise `maxCrap` for
* named files, so every function inside them is exempt — not only the out-of-process ones that
* motivated the override.
*/
export function readCrapExemptions(report: unknown): CrapExemption[] {
const overrides =
isRecord(report) && isRecord(report.health) ? report.health.thresholdOverrides : undefined;
if (!Array.isArray(overrides)) return [];
return overrides
.filter((override): override is Record<string, unknown> => isRecord(override))
.filter((override) => typeof override.maxCrap === 'number' && override.maxCrap > 30)
.map((override) => ({
files: Array.isArray(override.files)
? override.files.filter((file): file is string => typeof file === 'string')
: [],
maxCrap: override.maxCrap as number,
reason: typeof override.reason === 'string' ? override.reason : '',
}));
}
/** The exemptions, or how they failed to load — "none" and "unknown" are different claims. */
export type CrapExemptions =
| Readonly<{ available: true; exemptions: readonly CrapExemption[] }>
| Readonly<{ available: false; detail: string }>;
/**
* Resolved-config stdout as the exemption listing, admitting failure instead of throwing.
*
* Which files are exempt is something the report states or admits it cannot state; it is not a gate,
* so a fallow that exits 0 and prints nothing parseable must not turn a clean `--all` run red.
*/
export function readConfiguredExemptions(stdout: string): CrapExemptions {
try {
return { available: true, exemptions: readCrapExemptions(parseFallowJsonReport(stdout)) };
} catch (error: unknown) {
return {
available: false,
detail: error instanceof Error ? error.message.split('\n')[0]! : String(error),
};
}
}
export function renderCrapReport(input: {
findings: readonly CrapFinding[];
dropped: readonly string[];
threshold: number;
scope: CrapScope;
verdict: CrapVerdict;
exemptions?: CrapExemptions;
}): string {
const { findings, dropped, threshold, scope, verdict } = input;
// A PR fails for two different reasons and the reader must not confuse them: debt a touched
// function owes, and a report that cannot answer. Only the second hides the count.
const unanswerable = scope === 'touched' && verdict.problems.length > 0;
const mode = scope === 'touched' ? `blocking at ${threshold}` : 'report-only';
const headline = unanswerable
? 'no trustworthy verdict'
: `${findings.length === 0 ? 'no' : findings.length} ${scope === 'touched' ? 'touched function(s)' : 'function(s)'} at or above ${threshold}`;
return `${[
`## Coverage-based CRAP (${mode}): ${headline}`,
...renderProblemSection(verdict.problems, unanswerable),
...renderFindingSection(findings),
...renderUnscopedSection(verdict.unscoped),
...renderDroppedSection(dropped),
...renderExemptionSection(input.exemptions),
].join('\n')}\n`;
}
/**
* Estimates the coverage run could not measure because the file is outside what it instruments. Not a
* problem — a re-run changes nothing — but not a measurement either, and a bare "no touched
* function(s)" headline would imply the reader was checked.
*/
function renderUnscopedSection(unscoped: readonly EstimatedFinding[]): readonly string[] {
if (unscoped.length === 0) return [];
return section([
`Not measured, and not blocking: ${unscoped.length} touched function(s) in file(s) coverage does not instrument, scored from complexity alone: ${unscoped
.map(
(finding) =>
`\`${finding.path}:${finding.line}\` ${finding.name} (CRAP ${finding.crap.toFixed(1)})`,
)
.join(', ')}`,
]);
}
/** A section of the report, or nothing. Each owns the blank line that separates it above. */
function section(content: readonly string[]): readonly string[] {
return content.length === 0 ? [] : ['', ...content];
}
function renderProblemSection(problems: readonly CrapProblem[], unanswerable: boolean) {
if (problems.length === 0) return [];
return section([
...(unanswerable
? ['The reports this verdict would rest on do not answer the question:', '']
: ['Not gating, but the report is incomplete:', '']),
...renderProblems(problems),
]);
}
function renderFindingSection(findings: readonly CrapFinding[]): readonly string[] {
if (findings.length === 0) return [];
return section([
'| Function | CRAP | Cyclomatic | Coverage |',
'| --- | ---: | ---: | ---: |',
...findings.map(
(finding) =>
`| \`${finding.path}:${finding.line}\` ${finding.name} | ${finding.crap.toFixed(1)} | ${finding.cyclomatic} | ${formatCoverage(finding.coveragePct)} |`,
),
'',
'Add tests for the uncovered branches, or split the function. Code that only runs out of process (workers, browser-evaluated scripts, child processes) measures 0%.',
]);
}
function renderDroppedSection(dropped: readonly string[]): readonly string[] {
if (dropped.length === 0) return [];
return section([
`Unscored: ${dropped.length} file(s) with counts no execution can produce ` +
`(for example, an unresolved script wrapper offset or a negative branch path count, ` +
`both fixed under patches/): ${dropped.map((file) => `\`${file}\``).join(', ')}`,
]);
}
function renderExemptionSection(exemptions: CrapExemptions | undefined): readonly string[] {
if (!exemptions) return [];
if (!exemptions.available) {
return section([
`File-wide CRAP exemptions could not be read from the config (${exemptions.detail}), so this report cannot say which files are exempt.`,
]);
}
return section([
`File-wide CRAP exemptions in \`fallow-crap.json\` (${exemptions.exemptions.length}). Each exempts every function in its files, including helpers Node could test:`,
...exemptions.exemptions.map(
(exemption) =>
`- ${exemption.files.map((file) => `\`${file}\``).join(', ')} — \`maxCrap: ${exemption.maxCrap.toExponential(0)}\`${exemption.reason ? `: ${exemption.reason}` : ''}`,
),
]);
}
// Workflow-command escaping (actions/toolkit `command.ts`): data may not carry `%`, CR, or LF, and
// a property additionally may not carry `:` or `,`, or a path or name could end the command early.
function escapeCommandData(value: string): string {
return value.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A');
}
function escapeCommandProperty(value: string): string {
return escapeCommandData(value).replaceAll(':', '%3A').replaceAll(',', '%2C');
}
/** GitHub Actions warning annotations, one per finding, so the score shows on the diff. */
export function renderCrapAnnotations(findings: readonly CrapFinding[]): string {
return findings
.map((finding) => {
const properties = [
`file=${escapeCommandProperty(finding.path)}`,
`line=${finding.line}`,
`title=${escapeCommandProperty(`CRAP ${finding.crap.toFixed(1)}`)}`,
].join(',');
const message = `${finding.name} has cyclomatic ${finding.cyclomatic} at ${formatCoverage(finding.coveragePct)} coverage.`;
return `::warning ${properties}::${escapeCommandData(message)}\n`;
})
.join('');
}