probatio 0.0.0-stage → 0.0.2

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.
@@ -0,0 +1,175 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { SCHEMA_VERSION } from "../contract.js";
4
+ import { advisePrune } from "../matrix/report.js";
5
+ import { isBuildName } from "./kill-label.js";
6
+ import { leadSummary, TIMEOUT_NEXT, WHOLE_PROGRAM_TEST } from "./suite-decision.js";
7
+ const NOT_A_TEST = new Set(["suite", "pytest", "unittest", "command", WHOLE_PROGRAM_TEST]);
8
+ /**
9
+ * Read a finished mutate run. Name tests that killed a mutant, tests that killed
10
+ * nothing, and one gap per survivor. An unseen line is not a gap. This never deletes a file.
11
+ */
12
+ export function tallyRun(outDir) {
13
+ const resultsDir = path.join(outDir, "results");
14
+ const fullPath = path.join(outDir, "tally.json");
15
+ if (!existsSync(resultsDir)) {
16
+ return {
17
+ schemaVersion: SCHEMA_VERSION,
18
+ ok: false,
19
+ command: "mutate.tally",
20
+ summary: "No mutant results in that directory.",
21
+ next: "Run mutate run, then tally the same --out directory.",
22
+ nextCall: null,
23
+ keep: [],
24
+ drop: [],
25
+ gaps: [],
26
+ rest: 0,
27
+ pruning: { mode: "advisory", deletedTests: 0, advice: [] },
28
+ full: fullPath,
29
+ };
30
+ }
31
+ const results = readResults(resultsDir);
32
+ if (results.length === 0) {
33
+ return {
34
+ schemaVersion: SCHEMA_VERSION,
35
+ ok: false,
36
+ command: "mutate.tally",
37
+ summary: "No mutant results in that directory.",
38
+ next: "Run mutate run, then tally the same --out directory.",
39
+ nextCall: null,
40
+ keep: [],
41
+ drop: [],
42
+ gaps: [],
43
+ rest: 0,
44
+ pruning: { mode: "advisory", deletedTests: 0, advice: [] },
45
+ full: fullPath,
46
+ };
47
+ }
48
+ const killCounts = new Map();
49
+ for (const result of results) {
50
+ if (result.outcome !== "killed")
51
+ continue;
52
+ for (const name of result.killedBy ?? []) {
53
+ if (!isTestName(name))
54
+ continue;
55
+ killCounts.set(name, (killCounts.get(name) ?? 0) + 1);
56
+ }
57
+ }
58
+ const seen = new Set(killCounts.keys());
59
+ for (const name of coverageNames(path.join(outDir, "coverage-map.json"))) {
60
+ if (isTestName(name))
61
+ seen.add(name);
62
+ }
63
+ for (const result of results) {
64
+ for (const name of result.selectedTests ?? []) {
65
+ if (isTestName(name))
66
+ seen.add(name);
67
+ }
68
+ }
69
+ const counts = new Map();
70
+ for (const name of seen)
71
+ counts.set(name, countFor(name, killCounts));
72
+ const pruning = advisePrune(counts);
73
+ const keep = [...counts.entries()]
74
+ .filter(([, killed]) => killed > 0)
75
+ .map(([name]) => name)
76
+ .sort();
77
+ const drop = pruning.advice;
78
+ const noCoverage = results.filter((result) => result.outcome === "no coverage").length;
79
+ const survived = results.filter((result) => result.outcome === "survived").length;
80
+ const timeouts = results.filter((result) => result.outcome === "timeout").length;
81
+ const gaps = results
82
+ .filter((result) => result.outcome === "survived")
83
+ .map((result) => ({
84
+ id: result.id,
85
+ file: result.files?.[0]?.file ?? "",
86
+ line: result.files?.[0]?.line ?? 0,
87
+ outcome: "survived",
88
+ }))
89
+ .sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.id.localeCompare(b.id));
90
+ const shown = gaps.slice(0, 10);
91
+ const summary = leadSummary(noCoverage, survived, "survived", `${keep.length} tests to keep, ${drop.length} tests killed nothing, ${gaps.length} gaps. No test file was deleted.`);
92
+ const notes = [];
93
+ if (noCoverage > 0)
94
+ notes.push(`${noCoverage} no coverage. That line was not seen. It is not a pass and not a gap.`);
95
+ if (gaps.length > 0)
96
+ notes.push(`First gap is ${gaps[0].id} at ${gaps[0].file}:${gaps[0].line}. A survivor is not a pass.`);
97
+ if (timeouts > 0)
98
+ notes.push(TIMEOUT_NEXT);
99
+ if (notes.length === 0)
100
+ notes.push("No survivor in this run. No test file was deleted.");
101
+ const next = notes.join(" ");
102
+ const complete = {
103
+ schemaVersion: SCHEMA_VERSION,
104
+ ok: true,
105
+ command: "mutate.tally",
106
+ summary,
107
+ next,
108
+ nextCall: null,
109
+ keep,
110
+ drop,
111
+ gaps,
112
+ rest: 0,
113
+ pruning,
114
+ full: fullPath,
115
+ };
116
+ mkdirSync(outDir, { recursive: true });
117
+ writeFileSync(fullPath, `${JSON.stringify(complete, null, 2)}\n`);
118
+ return { ...complete, gaps: shown, rest: gaps.length - shown.length };
119
+ }
120
+ function readResults(dir) {
121
+ const out = [];
122
+ for (const name of readdirSync(dir)) {
123
+ if (!name.endsWith(".json"))
124
+ continue;
125
+ try {
126
+ const parsed = JSON.parse(readFileSync(path.join(dir, name), "utf8"));
127
+ if (!parsed || typeof parsed.outcome !== "string")
128
+ continue;
129
+ if (!parsed.id)
130
+ parsed.id = name.slice(0, -".json".length);
131
+ out.push(parsed);
132
+ }
133
+ catch {
134
+ continue;
135
+ }
136
+ }
137
+ return out;
138
+ }
139
+ function coverageNames(file) {
140
+ if (!existsSync(file))
141
+ return [];
142
+ try {
143
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
144
+ const names = new Set();
145
+ for (const lines of Object.values(parsed.files ?? {})) {
146
+ for (const tests of Object.values(lines ?? {})) {
147
+ for (const name of tests ?? [])
148
+ names.add(name);
149
+ }
150
+ }
151
+ return [...names];
152
+ }
153
+ catch {
154
+ return [];
155
+ }
156
+ }
157
+ function countFor(name, kills) {
158
+ let total = 0;
159
+ for (const [killed, count] of kills) {
160
+ if (namesMatch(name, killed))
161
+ total += count;
162
+ }
163
+ return total;
164
+ }
165
+ function namesMatch(a, b) {
166
+ if (a === b)
167
+ return true;
168
+ return a.endsWith(`::${b}`) || b.endsWith(`::${a}`);
169
+ }
170
+ function isTestName(name) {
171
+ if (!name || isBuildName(name))
172
+ return false;
173
+ const leaf = name.split("::").pop() ?? name;
174
+ return !NOT_A_TEST.has(leaf) && !NOT_A_TEST.has(name);
175
+ }