@cosyte/script-utils 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ratchet.js ADDED
@@ -0,0 +1,696 @@
1
+ // @cosyte/script-utils/ratchet
2
+ //
3
+ // THE COVERAGE AND MUTATION RATCHET. A repository keeps a floor file at its root,
4
+ // `quality-floor.json`, with each package's coverage (lines, branches, functions, statements) and
5
+ // mutation score. `check` fails when a measured score is below its floor and prints every metric's
6
+ // gap to 100; `raise` moves a floor up to what was measured and never moves one down.
7
+ //
8
+ // With `--base <ref>`, `check` also holds a pull request's own code to 100: every changed line that
9
+ // coverage counts must have run, and every mutant in changed code must be killed, unless an inline
10
+ // comment names a reason for the exception. `check` lists each exception it finds in changed code,
11
+ // so the pull request can carry the list.
12
+ //
13
+ // Exit codes, asserted by its tests: 0 nothing dropped and nothing in changed code is unreached;
14
+ // 1 a drop, an uncovered changed line, a surviving mutant in changed code, or an exception with no
15
+ // reason; 2 an input could not be read (the floor file, a report the floor needs, git), which is
16
+ // never a pass. Zero dependencies, like the rest of this package.
17
+
18
+ import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
19
+ import { dirname, join, relative, resolve, sep } from "node:path";
20
+
21
+ import { ChangesUnreadable, readChanges, readFileAtCommit } from "./changes.js";
22
+
23
+ /** The floor file's name, at a repository's root. */
24
+ export const FLOOR_FILE = "quality-floor.json";
25
+
26
+ /** The four coverage metrics, in the order they print. */
27
+ export const COVERAGE_METRICS = Object.freeze(["lines", "branches", "functions", "statements"]);
28
+
29
+ /** Where each report is read from, relative to a package's directory. */
30
+ export const REPORTS = Object.freeze({
31
+ summary: "coverage/coverage-summary.json",
32
+ lcov: "coverage/lcov.info",
33
+ mutation: "reports/mutation/mutation.json",
34
+ changed: "reports/mutation/changed.json",
35
+ });
36
+
37
+ /** The reason Stryker records for a mutant a comment disabled without naming one. */
38
+ const STRYKER_DEFAULT_REASON = "Ignored using a comment";
39
+
40
+ /** Thrown when an input cannot be read or is not what it must be. Exit 2. */
41
+ export class RatchetUnreadable extends Error {
42
+ /**
43
+ * @param {string} message What could not be read.
44
+ */
45
+ constructor(message) {
46
+ super(message);
47
+ this.name = "RatchetUnreadable";
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Is this a score: a finite number from 0 to 100?
53
+ *
54
+ * @param {unknown} value The value.
55
+ * @returns {boolean} Whether it is.
56
+ */
57
+ function isScore(value) {
58
+ return typeof value === "number" && value >= 0 && value <= 100;
59
+ }
60
+
61
+ /**
62
+ * Read a JSON file, refusing a missing or malformed one.
63
+ *
64
+ * @param {string} path The file.
65
+ * @param {string} what What it is, for the message.
66
+ * @returns {unknown} The parsed value.
67
+ */
68
+ function readJson(path, what) {
69
+ let text;
70
+ try {
71
+ text = String(readFileSync(path));
72
+ } catch {
73
+ throw new RatchetUnreadable(`${what} ${path} could not be read`);
74
+ }
75
+ try {
76
+ return JSON.parse(text);
77
+ } catch {
78
+ throw new RatchetUnreadable(`${what} ${path} is not JSON`);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Check a floor file's shape. Every key is known: a misspelled metric would otherwise be a floor
84
+ * nobody checks.
85
+ *
86
+ * @param {unknown} value The parsed floor file.
87
+ * @returns {{ packages: Record<string, { coverage?: Record<string, number>, mutation?: number }> }}
88
+ * The floor.
89
+ */
90
+ export function parseFloor(value) {
91
+ const fail = (why) => {
92
+ throw new RatchetUnreadable(`the floor file ${why}`);
93
+ };
94
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
95
+ if (!isObject(value) || !isObject(value.packages)) fail("has no `packages` object");
96
+ for (const key of Object.keys(value)) {
97
+ if (key !== "packages" && key !== "$comment") fail(`has an unknown key ${JSON.stringify(key)}`);
98
+ }
99
+ for (const [dir, entry] of Object.entries(value.packages)) {
100
+ if (!isObject(entry)) fail(`entry ${JSON.stringify(dir)} is not an object`);
101
+ for (const key of Object.keys(entry)) {
102
+ if (key !== "coverage" && key !== "mutation") {
103
+ fail(`entry ${JSON.stringify(dir)} has an unknown key ${JSON.stringify(key)}`);
104
+ }
105
+ }
106
+ if ("mutation" in entry && !isScore(entry.mutation)) {
107
+ fail(`entry ${JSON.stringify(dir)} has a mutation floor that is not a score from 0 to 100`);
108
+ }
109
+ if ("coverage" in entry) {
110
+ if (!isObject(entry.coverage)) fail(`entry ${JSON.stringify(dir)} coverage is not an object`);
111
+ for (const [metric, floor] of Object.entries(entry.coverage)) {
112
+ if (!COVERAGE_METRICS.includes(metric) || !isScore(floor)) {
113
+ fail(`entry ${JSON.stringify(dir)} has a bad coverage floor ${JSON.stringify(metric)}`);
114
+ }
115
+ }
116
+ }
117
+ }
118
+ return value;
119
+ }
120
+
121
+ /**
122
+ * Compare a floor file with the one it replaces: every floor the earlier one held must still be
123
+ * there, at the same value or higher. A raised floor, and a new package or metric, are not findings.
124
+ *
125
+ * @param {ReturnType<typeof parseFloor>} before The earlier floor file.
126
+ * @param {ReturnType<typeof parseFloor>} after The floor file now.
127
+ * @returns {{ dir: string, metric: string, before: number, after: number | null }[]} Each floor
128
+ * lowered (`after` the lower value) or removed (`after` null), in directory and metric order.
129
+ */
130
+ export function compareFloors(before, after) {
131
+ const findings = [];
132
+ for (const dir of Object.keys(before.packages).sort()) {
133
+ const was = before.packages[dir];
134
+ const now = after.packages[dir];
135
+ for (const metric of [...COVERAGE_METRICS, "mutation"]) {
136
+ const held = metric === "mutation" ? was.mutation : was.coverage?.[metric];
137
+ if (held === undefined) continue;
138
+ const value = metric === "mutation" ? now?.mutation : now?.coverage?.[metric];
139
+ if (value === undefined) findings.push({ dir, metric, before: held, after: null });
140
+ else if (value < held) findings.push({ dir, metric, before: held, after: value });
141
+ }
142
+ }
143
+ return findings;
144
+ }
145
+
146
+ /**
147
+ * Cut a percentage to two decimals, toward zero, so a floor raised from it is never above what was
148
+ * measured.
149
+ *
150
+ * @param {number} value A percentage.
151
+ * @returns {number} The same, to two decimals.
152
+ */
153
+ export function toFloor(value) {
154
+ return Math.floor(Math.round(value * 1e6) / 1e4) / 100;
155
+ }
156
+
157
+ /**
158
+ * Read the four coverage percentages from Istanbul's `json-summary` report.
159
+ *
160
+ * @param {string} path The report.
161
+ * @returns {Record<string, number>} Each metric's percentage.
162
+ */
163
+ export function readCoverageSummary(path) {
164
+ const report = /** @type {any} */ (readJson(path, "the coverage summary"));
165
+ /** @type {Record<string, number>} */
166
+ const out = {};
167
+ for (const metric of COVERAGE_METRICS) {
168
+ const pct = report?.total?.[metric]?.pct;
169
+ if (!isScore(pct)) {
170
+ throw new RatchetUnreadable(`the coverage summary ${path} has no total ${metric} percentage`);
171
+ }
172
+ out[metric] = pct;
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /**
178
+ * Count a Stryker JSON report's mutants by status and work out the mutation score the way Stryker
179
+ * does: killed and timed-out mutants over every mutant that ran or had no test (compile and runtime
180
+ * errors and ignored mutants are not counted).
181
+ *
182
+ * @param {any} report The parsed report.
183
+ * @param {string} path Where it came from, for the message.
184
+ * @returns {{ score: number, counts: Record<string, number> }} The score, cut to two decimals, and
185
+ * the counts.
186
+ */
187
+ export function mutationScore(report, path) {
188
+ if (typeof report?.files !== "object" || report.files === null) {
189
+ throw new RatchetUnreadable(`the mutation report ${path} has no files`);
190
+ }
191
+ /** @type {Record<string, number>} */
192
+ const counts = {};
193
+ for (const file of Object.values(report.files)) {
194
+ for (const mutant of file.mutants ?? []) {
195
+ counts[mutant.status] = (counts[mutant.status] ?? 0) + 1;
196
+ }
197
+ }
198
+ const detected = (counts.Killed ?? 0) + (counts.Timeout ?? 0);
199
+ const valid = detected + (counts.Survived ?? 0) + (counts.NoCoverage ?? 0);
200
+ if (valid === 0) {
201
+ throw new RatchetUnreadable(`the mutation report ${path} has no mutant to score`);
202
+ }
203
+ return { score: toFloor((detected / valid) * 100), counts };
204
+ }
205
+
206
+ /**
207
+ * Read lcov's line records: for each source file, how often each line ran.
208
+ *
209
+ * @param {string} text The lcov file.
210
+ * @param {string} baseDir The directory relative `SF:` paths are read from.
211
+ * @returns {Map<string, Map<number, number>>} Absolute file path to line to hits.
212
+ */
213
+ export function parseLcov(text, baseDir) {
214
+ /** @type {Map<string, Map<number, number>>} */
215
+ const files = new Map();
216
+ /** @type {Map<number, number> | null} */
217
+ let current = null;
218
+ for (const line of text.split(/\r?\n/)) {
219
+ if (line.startsWith("SF:")) {
220
+ const file = resolve(baseDir, line.slice(3));
221
+ current = files.get(file) ?? new Map();
222
+ files.set(file, current);
223
+ } else if (line.startsWith("DA:") && current !== null) {
224
+ const [n, hits] = line.slice(3).split(",").map(Number);
225
+ current.set(n, Math.max(current.get(n) ?? 0, hits));
226
+ }
227
+ }
228
+ return files;
229
+ }
230
+
231
+ /**
232
+ * Find the inline exceptions in a source file: coverage hints (`v8 ignore`, and its `c8` and
233
+ * `istanbul` spellings) and Stryker directives (`Stryker disable`). A reason is the text after
234
+ * ` -- ` in a coverage hint and after `:` in a Stryker directive.
235
+ *
236
+ * @param {string} text The source.
237
+ * @returns {{ line: number, tool: "coverage" | "mutation", directive: string, reason: string | null }[]}
238
+ * Each exception, with its 1-based line.
239
+ */
240
+ export function findExceptionComments(text) {
241
+ const found = [];
242
+ const lines = text.split("\n");
243
+ for (const [i, source] of lines.entries()) {
244
+ const coverage =
245
+ /(?:\/\*|\/\/)\s*(?:v8|c8|istanbul)\s+ignore\s+(next|if|else|start|file)\b(.*?)(?:\*\/|$)/.exec(
246
+ source,
247
+ );
248
+ if (coverage !== null) {
249
+ // Read without a regular expression, so a long run of spaces cannot make it slow.
250
+ const after = coverage[2].trim();
251
+ const reason = after.startsWith("--") ? after.slice(2).trim() : "";
252
+ found.push({
253
+ line: i + 1,
254
+ tool: "coverage",
255
+ directive: `ignore ${coverage[1]}`,
256
+ reason: reason === "" ? null : reason,
257
+ });
258
+ }
259
+ // Stryker's own reading: `Stryker disable [next-line] <mutators>[: reason]`. The prefix is
260
+ // matched, then the rest is split at its first colon, so no pattern can backtrack over it.
261
+ const prefix = /\/\/\s?Stryker disable(?: next-line)? /.exec(source);
262
+ if (prefix !== null) {
263
+ const rest = source.slice(prefix.index + prefix[0].length);
264
+ const colon = rest.indexOf(":");
265
+ const mutators = (colon === -1 ? rest : rest.slice(0, colon)).trim();
266
+ const reason = colon === -1 ? "" : rest.slice(colon + 1).trim();
267
+ if (/^[a-zA-Z, ]+$/.test(mutators)) {
268
+ found.push({
269
+ line: i + 1,
270
+ tool: "mutation",
271
+ directive: `disable ${mutators}`,
272
+ reason: reason === "" ? null : reason,
273
+ });
274
+ }
275
+ }
276
+ }
277
+ return found;
278
+ }
279
+
280
+ /**
281
+ * The lines a coverage hint takes out of the count: for `start`, up to its `stop`; for `file`, all
282
+ * of it; for `next`, `if` and `else`, up to the next line coverage still counts, because the hint
283
+ * covers the whole statement after it and lcov does not say where that ends.
284
+ *
285
+ * @param {{ line: number, directive: string }} hint The hint.
286
+ * @param {string[]} lines The file's lines.
287
+ * @param {Map<number, number> | undefined} counted The lines coverage counts in this file.
288
+ * @returns {[number, number]} The first and last line it reaches.
289
+ */
290
+ export function hintRange(hint, lines, counted) {
291
+ if (hint.directive === "ignore file") return [1, lines.length];
292
+ if (hint.directive === "ignore start") {
293
+ const stop = lines.findIndex(
294
+ (text, i) => i >= hint.line && /(?:v8|c8|istanbul)\s+ignore\s+stop\b/.test(text),
295
+ );
296
+ return [hint.line, stop === -1 ? lines.length : stop + 1];
297
+ }
298
+ if (counted === undefined) return [hint.line, lines.length];
299
+ const after = [...counted.keys()].filter((n) => n > hint.line);
300
+ return [hint.line, after.length === 0 ? lines.length : Math.min(...after) - 1];
301
+ }
302
+
303
+ /**
304
+ * Judge a change's own code: its changed lines that coverage counts, and the mutants in them.
305
+ *
306
+ * @param {object} input The inputs.
307
+ * @param {Map<string, Set<number>>} input.changed Absolute file path to its changed lines.
308
+ * @param {Map<string, Map<number, number>>} input.lines Absolute file path to line to hits, from
309
+ * every package's lcov together (a line any suite ran counts as run); empty when coverage is
310
+ * skipped.
311
+ * @param {Map<string, any[]> | null} input.mutants Absolute file path to the mutants of the
312
+ * changed-files runs, or `null` when mutation is skipped.
313
+ * @param {(file: string) => string} input.read Reads a source file.
314
+ * @returns {{ uncovered: { file: string, line: number }[], survived: { file: string, line: number, mutator: string, status: string }[], exceptions: { file: string, line: number, tool: string, what: string, reason: string | null }[], unmutated: string[] }}
315
+ * What failed, and every exception found.
316
+ */
317
+ export function judgeChangedCode({ changed, lines, mutants, read }) {
318
+ const result = { uncovered: [], survived: [], exceptions: [], unmutated: [] };
319
+ for (const [file, changedLines] of [...changed].sort(([a], [b]) => a.localeCompare(b))) {
320
+ const counted = lines.get(file);
321
+ const fileMutants = mutants?.get(file);
322
+ if (counted === undefined && fileMutants === undefined) continue;
323
+ const touched = (first, last) => [...changedLines].some((n) => n >= first && n <= last);
324
+ for (const n of [...changedLines].sort((a, b) => a - b)) {
325
+ if (counted?.get(n) === 0) result.uncovered.push({ file, line: n });
326
+ }
327
+ const text = read(file).split("\n");
328
+ for (const hint of findExceptionComments(text.join("\n"))) {
329
+ const [first, last] =
330
+ hint.tool === "coverage" ? hintRange(hint, text, counted) : [hint.line, hint.line];
331
+ if (!touched(first, last)) continue;
332
+ result.exceptions.push({
333
+ file,
334
+ line: hint.line,
335
+ tool: hint.tool,
336
+ what: hint.directive,
337
+ reason: hint.reason,
338
+ });
339
+ }
340
+ if (mutants === null) continue;
341
+ if (fileMutants === undefined) {
342
+ if ([...changedLines].some((n) => counted.has(n))) result.unmutated.push(file);
343
+ continue;
344
+ }
345
+ for (const mutant of fileMutants) {
346
+ const { start, end } = mutant.location;
347
+ if (!touched(start.line, end.line)) continue;
348
+ if (mutant.status === "Survived" || mutant.status === "NoCoverage") {
349
+ result.survived.push({
350
+ file,
351
+ line: start.line,
352
+ mutator: mutant.mutatorName,
353
+ status: mutant.status,
354
+ });
355
+ } else if (mutant.status === "Ignored") {
356
+ const reason = (mutant.statusReason ?? "").trim();
357
+ result.exceptions.push({
358
+ file,
359
+ line: start.line,
360
+ tool: "mutation",
361
+ what: `${mutant.mutatorName} mutant ignored`,
362
+ reason: reason === "" || reason === STRYKER_DEFAULT_REASON ? null : reason,
363
+ });
364
+ }
365
+ }
366
+ }
367
+ return result;
368
+ }
369
+
370
+ /**
371
+ * Read a changed-files mutation report into absolute file path to mutants.
372
+ *
373
+ * @param {string} path The report.
374
+ * @param {string} packageDir Where relative paths in it are read from when it names no project root.
375
+ * @returns {Map<string, any[]>} The mutants of each file.
376
+ */
377
+ export function readMutants(path, packageDir) {
378
+ const report = /** @type {any} */ (readJson(path, "the changed-files mutation report"));
379
+ if (typeof report?.files !== "object" || report.files === null) {
380
+ throw new RatchetUnreadable(`the changed-files mutation report ${path} has no files`);
381
+ }
382
+ const base = typeof report.projectRoot === "string" ? report.projectRoot : packageDir;
383
+ /** @type {Map<string, any[]>} */
384
+ const out = new Map();
385
+ for (const [file, entry] of Object.entries(report.files)) {
386
+ out.set(resolve(base, file), entry.mutants ?? []);
387
+ }
388
+ return out;
389
+ }
390
+
391
+ /**
392
+ * Compare the floor file with the one at the merge base, so a change cannot lower a floor, or
393
+ * delete one, to hide a drop.
394
+ *
395
+ * @param {object} input The inputs.
396
+ * @param {string} input.floorPath The floor file, absolute.
397
+ * @param {ReturnType<typeof parseFloor>} input.floor The floor file now.
398
+ * @param {{ root: string, mergeBase: string }} input.changes The change, as read from git.
399
+ * @param {import("./changes.js").GitRunner} [input.git] Runs git; replaced in tests.
400
+ * @returns {ReturnType<typeof compareFloors> | null} Each floor lowered or removed, or `null` when
401
+ * the merge base has no floor file.
402
+ */
403
+ function floorsAgainstMergeBase({ floorPath, floor, changes, git }) {
404
+ const name = relative(changes.root, realpathSync(floorPath)).split(sep).join("/");
405
+ if (name.startsWith("../")) {
406
+ throw new RatchetUnreadable(
407
+ `the floor file ${floorPath} is outside the repository at ${changes.root}`,
408
+ );
409
+ }
410
+ const text = readFileAtCommit({ commit: changes.mergeBase, path: name, cwd: changes.root, git });
411
+ if (text === null) return null;
412
+ const where = `the floor file ${name} at the merge base ${changes.mergeBase}`;
413
+ let before;
414
+ try {
415
+ before = JSON.parse(text);
416
+ } catch {
417
+ throw new RatchetUnreadable(`${where} is not JSON`);
418
+ }
419
+ try {
420
+ parseFloor(before);
421
+ } catch (error) {
422
+ throw new RatchetUnreadable(`${where} ${error.message.slice("the floor file ".length)}`);
423
+ }
424
+ return compareFloors(before, floor);
425
+ }
426
+
427
+ const HELP = `cosyte-ratchet: hold coverage and mutation score to a floor that only rises.
428
+
429
+ cosyte-ratchet check [--floor <file>] [--base <ref>] [--skip coverage|mutation]...
430
+ cosyte-ratchet raise [--floor <file>] [--skip coverage|mutation]...
431
+
432
+ The floor file (default ${FLOOR_FILE}) names each package directory, relative to the file, with
433
+ its coverage floors (lines, branches, functions, statements) and its mutation score floor. Reports
434
+ are read from each package directory: ${REPORTS.summary}, ${REPORTS.lcov},
435
+ ${REPORTS.mutation} (a full run) and ${REPORTS.changed} (a changed-files run).
436
+
437
+ check compares each measured metric with its floor and prints the gap to 100. With --base it
438
+ also requires every changed line coverage counts to have run and every mutant in changed
439
+ code to be killed, and lists each inline exception in changed code with its reason; a
440
+ full mutation report is then compared only when one is there. It also reads the floor
441
+ file at the merge base and fails when the change lowered or removed any floor in it.
442
+ raise moves each floor up to what was measured. It never moves one down.
443
+ --skip leaves a kind of metric out of this run, saying so; use it where that report is not made.
444
+
445
+ Exit 0 when nothing dropped and changed code is reached, 1 on a drop, a lowered or removed floor
446
+ or unreached changed code, 2 when an input could not be read.
447
+ `;
448
+
449
+ /**
450
+ * Parse the command line.
451
+ *
452
+ * @param {string[]} argv The arguments after the bin's name.
453
+ * @returns {{ command: string, floor: string, base: string | null, skip: Set<string> }} The options.
454
+ */
455
+ export function parseArgs(argv) {
456
+ const [command, ...rest] = argv;
457
+ if (command !== "check" && command !== "raise") {
458
+ throw new RatchetUnreadable(
459
+ `the command must be check or raise, not ${JSON.stringify(command)}`,
460
+ );
461
+ }
462
+ const options = { command, floor: FLOOR_FILE, base: null, skip: new Set() };
463
+ for (let i = 0; i < rest.length; i += 1) {
464
+ const flag = rest[i];
465
+ const value = rest[i + 1];
466
+ if (!["--floor", "--base", "--skip"].includes(flag)) {
467
+ throw new RatchetUnreadable(`unknown argument ${JSON.stringify(flag)}`);
468
+ }
469
+ if (value === undefined || value.startsWith("--")) {
470
+ throw new RatchetUnreadable(`${flag} needs a value`);
471
+ }
472
+ i += 1;
473
+ if (flag === "--floor") options.floor = value;
474
+ if (flag === "--base") options.base = value;
475
+ if (flag === "--skip") {
476
+ if (value !== "coverage" && value !== "mutation") {
477
+ throw new RatchetUnreadable(
478
+ `--skip takes coverage or mutation, not ${JSON.stringify(value)}`,
479
+ );
480
+ }
481
+ options.skip.add(value);
482
+ }
483
+ }
484
+ if (options.command === "raise" && options.base !== null) {
485
+ throw new RatchetUnreadable(
486
+ "raise takes no --base: it moves floors, it does not judge a change",
487
+ );
488
+ }
489
+ return options;
490
+ }
491
+
492
+ /**
493
+ * Format a percentage for the table.
494
+ *
495
+ * @param {number} value The percentage.
496
+ * @returns {string} It, to two decimals, right-aligned.
497
+ */
498
+ function pct(value) {
499
+ return value.toFixed(2).padStart(8);
500
+ }
501
+
502
+ /**
503
+ * Run the ratchet. Every option but `git` is given by the caller: the bin passes the process's own
504
+ * arguments, directory and streams, and a test passes its own.
505
+ *
506
+ * @param {object} opts Options.
507
+ * @param {string[]} opts.argv The arguments after the bin's name.
508
+ * @param {string} opts.cwd The directory the floor path is read from.
509
+ * @param {(text: string) => void} opts.out Writes to stdout.
510
+ * @param {(text: string) => void} opts.err Writes to stderr.
511
+ * @param {import("./changes.js").GitRunner} [opts.git] Runs git; replaced in tests. Left out,
512
+ * the change reader's own runner is used.
513
+ * @returns {number} The exit code.
514
+ */
515
+ export function runRatchet({ argv, cwd, out, err, git }) {
516
+ if (argv.includes("--help") || argv.includes("-h")) {
517
+ out(HELP);
518
+ return 0;
519
+ }
520
+ try {
521
+ return ratchet(parseArgs(argv), { cwd, out, git });
522
+ } catch (error) {
523
+ if (error instanceof RatchetUnreadable || error instanceof ChangesUnreadable) {
524
+ err(`cosyte-ratchet: could not run: ${error.message}\n`);
525
+ return 2;
526
+ }
527
+ throw error;
528
+ }
529
+ }
530
+
531
+ /**
532
+ * The body of {@link runRatchet}, once the arguments are parsed.
533
+ *
534
+ * @param {ReturnType<typeof parseArgs>} options The options.
535
+ * @param {{ cwd: string, out: (text: string) => void, git?: import("./changes.js").GitRunner }} io
536
+ * Where it runs.
537
+ * @returns {number} The exit code.
538
+ */
539
+ function ratchet(options, { cwd, out, git }) {
540
+ const floorPath = resolve(cwd, options.floor);
541
+ const floor = parseFloor(readJson(floorPath, "the floor file"));
542
+ const root = dirname(floorPath);
543
+ const show = (file) => relative(root, file);
544
+ const skipped = (kind) => options.skip.has(kind);
545
+ const dirs = Object.keys(floor.packages).sort();
546
+ let drops = 0;
547
+ let raised = 0;
548
+ // What `raise` would write. Worked out on every run, so a check and a raise read the same.
549
+ const next = structuredClone(floor);
550
+ /** @type {Map<string, Map<number, number>>} Line hits from every package; empty when skipped. */
551
+ const lines = new Map();
552
+ out(`cosyte-ratchet ${options.command}: ${show(floorPath)}, ${dirs.length} package(s)\n`);
553
+
554
+ for (const dir of dirs) {
555
+ const entry = floor.packages[dir];
556
+ const pkg = resolve(root, dir);
557
+ out(`\n${dir}\n metric measured floor gap to 100\n`);
558
+ /** @type {Record<string, number>} */
559
+ const measured = {};
560
+ if (skipped("coverage")) {
561
+ out(" coverage skipped (--skip coverage)\n");
562
+ } else {
563
+ Object.assign(measured, readCoverageSummary(join(pkg, REPORTS.summary)));
564
+ const lcovPath = join(pkg, REPORTS.lcov);
565
+ if (options.base !== null) {
566
+ if (!existsSync(lcovPath))
567
+ throw new RatchetUnreadable(`the lcov report ${lcovPath} is missing`);
568
+ for (const [file, hits] of parseLcov(String(readFileSync(lcovPath)), pkg)) {
569
+ const merged = lines.get(file) ?? new Map();
570
+ for (const [n, h] of hits) merged.set(n, Math.max(merged.get(n) ?? 0, h));
571
+ lines.set(file, merged);
572
+ }
573
+ }
574
+ }
575
+ const fullReport = join(pkg, REPORTS.mutation);
576
+ if (skipped("mutation")) {
577
+ out(" mutation skipped (--skip mutation)\n");
578
+ } else if (options.base !== null && !existsSync(fullReport)) {
579
+ // A pull request runs mutation over its changed files only; its mutants are judged below.
580
+ out(" mutation no full run here (a change is judged on its own mutants below)\n");
581
+ } else if ("mutation" in entry || existsSync(fullReport)) {
582
+ measured.mutation = mutationScore(
583
+ readJson(fullReport, "the mutation report"),
584
+ fullReport,
585
+ ).score;
586
+ }
587
+ for (const metric of [...COVERAGE_METRICS, "mutation"]) {
588
+ if (!(metric in measured)) continue;
589
+ const value = measured[metric];
590
+ const held = metric === "mutation" ? entry.mutation : entry.coverage?.[metric];
591
+ const gap = pct(100 - value);
592
+ if (held === undefined) {
593
+ out(` ${metric.padEnd(12)}${pct(value)} none ${gap} (no floor yet)\n`);
594
+ } else if (value < held) {
595
+ drops += 1;
596
+ out(
597
+ ` ${metric.padEnd(12)}${pct(value)} ${pct(held)} ${gap} DROPPED ${(held - value).toFixed(2)} below the floor\n`,
598
+ );
599
+ } else {
600
+ out(` ${metric.padEnd(12)}${pct(value)} ${pct(held)} ${gap}\n`);
601
+ }
602
+ if (held === undefined || value > held) {
603
+ raised += 1;
604
+ const target = next.packages[dir];
605
+ if (metric === "mutation") target.mutation = value;
606
+ else target.coverage = { ...target.coverage, [metric]: value };
607
+ }
608
+ }
609
+ }
610
+
611
+ if (options.command === "raise") {
612
+ writeFileSync(floorPath, `${JSON.stringify(next, null, 2)}\n`);
613
+ out(`\ncosyte-ratchet: raised ${raised} floor(s); none was lowered.\n`);
614
+ if (drops > 0)
615
+ out(`cosyte-ratchet: FAIL, ${drops} metric(s) below the floor, left where they were.\n`);
616
+ return drops === 0 ? 0 : 1;
617
+ }
618
+
619
+ let failures = drops;
620
+ if (options.base !== null) {
621
+ const changes = readChanges({ base: options.base, cwd: root, git });
622
+ const changed = new Map(
623
+ [...changes.files].map(([file, set]) => [resolve(changes.root, file), set]),
624
+ );
625
+ /** @type {Map<string, any[]> | null} */
626
+ let mutants = null;
627
+ if (!skipped("mutation")) {
628
+ mutants = new Map();
629
+ for (const dir of dirs) {
630
+ const pkg = resolve(root, dir);
631
+ const path = join(pkg, REPORTS.changed);
632
+ if (!existsSync(path)) {
633
+ if ([...changed.keys()].some((file) => file.startsWith(`${pkg}${sep}`))) {
634
+ throw new RatchetUnreadable(
635
+ `${dir} has changes but no changed-files mutation report at ${path}; run cosyte-mutate changed`,
636
+ );
637
+ }
638
+ continue;
639
+ }
640
+ for (const [file, list] of readMutants(path, pkg)) mutants.set(file, list);
641
+ }
642
+ }
643
+ const judged = judgeChangedCode({
644
+ changed,
645
+ lines,
646
+ mutants,
647
+ read: (file) => readFileSync(file, "utf8"),
648
+ });
649
+ const unreasoned = judged.exceptions.filter((e) => e.reason === null);
650
+ out(`\nChanged code against ${options.base} (merge base ${changes.mergeBase.slice(0, 12)}):\n`);
651
+ if (skipped("coverage")) out(" changed lines: not judged (--skip coverage)\n");
652
+ else out(` uncovered changed lines: ${judged.uncovered.length}\n`);
653
+ for (const u of judged.uncovered) out(` ${show(u.file)}:${u.line} did not run\n`);
654
+ if (mutants === null) {
655
+ out(" mutants in changed code: not judged (--skip mutation)\n");
656
+ } else {
657
+ out(` surviving mutants in changed code: ${judged.survived.length}\n`);
658
+ for (const s of judged.survived) {
659
+ out(` ${show(s.file)}:${s.line} ${s.mutator} (${s.status})\n`);
660
+ }
661
+ for (const file of judged.unmutated) {
662
+ out(` not mutated (outside the package's mutate patterns): ${show(file)}\n`);
663
+ }
664
+ }
665
+ const lowered = floorsAgainstMergeBase({ floorPath, floor, changes, git });
666
+ out(`\nFloors in ${show(floorPath)} against the merge base:\n`);
667
+ if (lowered === null) {
668
+ out(" the floor file is new in this change; there is no earlier floor to hold\n");
669
+ } else if (lowered.length === 0) {
670
+ out(" none lowered or removed\n");
671
+ }
672
+ for (const f of lowered ?? []) {
673
+ out(
674
+ f.after === null
675
+ ? ` ${f.dir} ${f.metric} removed (was ${f.before.toFixed(2)})\n`
676
+ : ` ${f.dir} ${f.metric} lowered from ${f.before.toFixed(2)} to ${f.after.toFixed(2)}\n`,
677
+ );
678
+ }
679
+ failures += lowered?.length ?? 0;
680
+ out("\nExceptions in changed code (list each one in the pull request):\n");
681
+ if (judged.exceptions.length === 0) out(" none\n");
682
+ for (const e of judged.exceptions) {
683
+ out(
684
+ ` - ${show(e.file)}:${e.line} ${e.tool}, ${e.what}: ${e.reason ?? "NO REASON GIVEN (name one after the directive)"}\n`,
685
+ );
686
+ }
687
+ failures += judged.uncovered.length + judged.survived.length + unreasoned.length;
688
+ }
689
+
690
+ out(
691
+ failures === 0
692
+ ? "\ncosyte-ratchet: OK, nothing dropped below its floor.\n"
693
+ : `\ncosyte-ratchet: FAIL, ${failures} finding(s) above.\n`,
694
+ );
695
+ return failures === 0 ? 0 : 1;
696
+ }