@fairfox/polly 0.79.0 → 0.81.0
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/dist/cli/polly.js +52 -1
- package/dist/cli/polly.js.map +3 -3
- package/dist/tools/mutate/src/args.d.ts +22 -0
- package/dist/tools/mutate/src/cli.d.ts +2 -0
- package/dist/tools/mutate/src/cli.js +743 -0
- package/dist/tools/mutate/src/cli.js.map +19 -0
- package/dist/tools/mutate/src/config.d.ts +13 -0
- package/dist/tools/mutate/src/decisions.d.ts +34 -0
- package/dist/tools/mutate/src/index.d.ts +13 -0
- package/dist/tools/mutate/src/index.js +471 -0
- package/dist/tools/mutate/src/index.js.map +14 -0
- package/dist/tools/mutate/src/ingest.d.ts +47 -0
- package/dist/tools/mutate/src/init.d.ts +12 -0
- package/dist/tools/mutate/src/report.d.ts +15 -0
- package/dist/tools/mutate/src/run.d.ts +11 -0
- package/dist/tools/mutate/src/verify-matrix.d.ts +49 -0
- package/dist/tools/test/src/coverage-policy/cli.d.ts +19 -0
- package/dist/tools/test/src/coverage-policy/cli.js +339 -0
- package/dist/tools/test/src/coverage-policy/cli.js.map +13 -0
- package/dist/tools/test/src/coverage-policy/discover.d.ts +23 -0
- package/dist/tools/test/src/coverage-policy/enforce.d.ts +54 -0
- package/dist/tools/test/src/coverage-policy/index.d.ts +10 -0
- package/dist/tools/test/src/coverage-policy/index.js +242 -0
- package/dist/tools/test/src/coverage-policy/index.js.map +13 -0
- package/dist/tools/test/src/coverage-policy/mutate-targets.d.ts +30 -0
- package/dist/tools/test/src/coverage-policy/types.d.ts +35 -0
- package/dist/tools/test/src/e2e-mesh/index.js +18 -2
- package/dist/tools/test/src/e2e-mesh/index.js.map +5 -4
- package/dist/tools/test/src/e2e-mesh/wait-for-convergence.d.ts +8 -0
- package/dist/tools/test/src/e2e-relay/index.d.ts +1 -1
- package/dist/tools/test/src/e2e-relay/index.js +1421 -0
- package/dist/tools/test/src/e2e-relay/index.js.map +30 -0
- package/dist/tools/test/src/e2e-relay/wait-for-relay-convergence.d.ts +8 -0
- package/dist/tools/test/src/e2e-relay/with-repo-server.d.ts +9 -0
- package/dist/tools/test/src/e2e-shared/index.d.ts +1 -0
- package/dist/tools/test/src/e2e-shared/timeout-context.d.ts +17 -0
- package/dist/tools/test/src/index.d.ts +1 -0
- package/dist/tools/test/src/index.js +16 -1
- package/dist/tools/test/src/index.js.map +5 -4
- package/package.json +15 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { MutateConfig } from "./config.ts";
|
|
2
|
+
/** Run Stryker against the resolved config. Inherits stdio for the live reporter; returns the exit code. */
|
|
3
|
+
export declare function runStryker(cfg: MutateConfig): Promise<number>;
|
|
4
|
+
/** Build the useless-test report from an existing mutation.json (no Stryker run). */
|
|
5
|
+
export declare function reportFromFile(cfg: MutateConfig): Promise<string>;
|
|
6
|
+
/**
|
|
7
|
+
* If the Stryker config doesn't already load polly's verify-primitive ignorer,
|
|
8
|
+
* return a one-line tip. The ignorer must be in the config before the run (it's
|
|
9
|
+
* read at Stryker startup), so this advises rather than mutating the user's config.
|
|
10
|
+
*/
|
|
11
|
+
export declare function presetAdvisory(cfg: MutateConfig): Promise<string | null>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verification artefact for the kill-matrix contract.
|
|
3
|
+
*
|
|
4
|
+
* The whole useless-test analysis rests on one fact: the patched bun runner
|
|
5
|
+
* emits a COMPLETE kill matrix — every test that detects a mutant appears in
|
|
6
|
+
* killedBy, not just the first. That depends on a pinned pair: (Bun version,
|
|
7
|
+
* plugin version + our patch). A Bun upgrade that changes JUnit output, or a
|
|
8
|
+
* patch that stops applying, would silently collapse killedBy to one id each —
|
|
9
|
+
* a green board with the redundancy signal quietly dead (the classic trap).
|
|
10
|
+
*
|
|
11
|
+
* `verifyMatrix` runs the full contract and returns the result; `isMatrixComplete`
|
|
12
|
+
* is the side-effect-free gate the report path consults to decide whether the
|
|
13
|
+
* redundancy/subsumption sections are trustworthy. Run the CLI form after any
|
|
14
|
+
* bump of Bun or the plugin:
|
|
15
|
+
*
|
|
16
|
+
* polly mutate verify # uses the existing report
|
|
17
|
+
* polly mutate verify --run # runs stryker first
|
|
18
|
+
*/
|
|
19
|
+
import { type MutationReport } from "./ingest.ts";
|
|
20
|
+
export interface MatrixCheck {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
checks: {
|
|
23
|
+
name: string;
|
|
24
|
+
ok: boolean;
|
|
25
|
+
detail: string;
|
|
26
|
+
}[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Side-effect-free gate: is the kill matrix trustworthy for redundancy /
|
|
30
|
+
* subsumption analysis? True only when
|
|
31
|
+
* (a) some killed mutant has >1 killer — proof the no-bail patched runner
|
|
32
|
+
* recorded EVERY killer, not just the first (verifyMatrix check #4), and
|
|
33
|
+
* (b) coverage was collected — not every mutant is NoCoverage, which would
|
|
34
|
+
* mean the coverage dump broke (verifyMatrix check #5).
|
|
35
|
+
* Without both, REDUNDANCY/SUBSUMED would be computed off a partial matrix and
|
|
36
|
+
* lie, so the report path falls back to score + gaps + theatre only.
|
|
37
|
+
*/
|
|
38
|
+
export declare function isMatrixComplete(report: MutationReport): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Run the full kill-matrix contract (all five checks), optionally running a
|
|
41
|
+
* fresh Stryker pass first. Returns the result rather than exiting, so callers
|
|
42
|
+
* decide what to do with it; the `import.meta.main` wrapper prints and sets the
|
|
43
|
+
* exit code.
|
|
44
|
+
*/
|
|
45
|
+
export declare function verifyMatrix(opts?: {
|
|
46
|
+
reportPath?: string;
|
|
47
|
+
run?: boolean;
|
|
48
|
+
strykerConfig?: string;
|
|
49
|
+
}): Promise<MatrixCheck>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* @fairfox/polly/test/coverage — consumer-facing `polly coverage`.
|
|
4
|
+
*
|
|
5
|
+
* Zero-config: run it in any Polly project and it reports per-file coverage,
|
|
6
|
+
* orphan source files, and (if a Stryker config is present) dead mutate/test
|
|
7
|
+
* globs. Add a `coverage.config.ts` with a `defaultThreshold` to turn the
|
|
8
|
+
* report into an enforced gate, and `exempt` entries to record which
|
|
9
|
+
* higher-tier test covers a unit-thin file.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* polly coverage # report (zero-config) or enforce (with config)
|
|
13
|
+
* polly coverage --strict-orphans # fail on source no unit test imports
|
|
14
|
+
* polly coverage --orphans # list the orphan files
|
|
15
|
+
* polly coverage --no-mutate # skip the Stryker target check
|
|
16
|
+
* polly coverage --config <path> # explicit coverage.config.ts
|
|
17
|
+
* bun test --coverage | polly coverage --stdin
|
|
18
|
+
*/
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __returnValue = (v) => v;
|
|
5
|
+
function __exportSetter(name, newValue) {
|
|
6
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
7
|
+
}
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, {
|
|
11
|
+
get: all[name],
|
|
12
|
+
enumerable: true,
|
|
13
|
+
configurable: true,
|
|
14
|
+
set: __exportSetter.bind(all, name)
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
18
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
19
|
+
|
|
20
|
+
// tools/test/src/coverage-policy/enforce.ts
|
|
21
|
+
var exports_enforce = {};
|
|
22
|
+
__export(exports_enforce, {
|
|
23
|
+
runCoverage: () => runCoverage,
|
|
24
|
+
parseCoverageTable: () => parseCoverageTable,
|
|
25
|
+
hasFailure: () => hasFailure,
|
|
26
|
+
evaluateCoverage: () => evaluateCoverage,
|
|
27
|
+
enforceCoverage: () => enforceCoverage
|
|
28
|
+
});
|
|
29
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
30
|
+
import { join as join2, resolve as resolve2 } from "node:path";
|
|
31
|
+
import { Glob } from "bun";
|
|
32
|
+
function normalizePath(raw) {
|
|
33
|
+
return raw.replace(/^(?:\.\.\/)+/, "");
|
|
34
|
+
}
|
|
35
|
+
function parseCoverageTable(text, srcDir) {
|
|
36
|
+
const prefix = `${srcDir}/`;
|
|
37
|
+
const rows = [];
|
|
38
|
+
for (const line of text.split(`
|
|
39
|
+
`)) {
|
|
40
|
+
if (!line.includes("|"))
|
|
41
|
+
continue;
|
|
42
|
+
if (line.includes("All files") || line.includes("% Funcs"))
|
|
43
|
+
continue;
|
|
44
|
+
if (line.trim().startsWith("---"))
|
|
45
|
+
continue;
|
|
46
|
+
const cells = line.split("|").map((c) => c.trim());
|
|
47
|
+
if (cells.length < 3)
|
|
48
|
+
continue;
|
|
49
|
+
const file = normalizePath(cells[0] ?? "");
|
|
50
|
+
const funcs = Number(cells[1]);
|
|
51
|
+
const lines = Number(cells[2]);
|
|
52
|
+
if (!file.startsWith(prefix) || Number.isNaN(funcs) || Number.isNaN(lines))
|
|
53
|
+
continue;
|
|
54
|
+
rows.push({ file, funcs, lines });
|
|
55
|
+
}
|
|
56
|
+
return rows;
|
|
57
|
+
}
|
|
58
|
+
async function runCoverage(root, testCwd) {
|
|
59
|
+
const proc = Bun.spawn(["bun", "test", "--coverage"], {
|
|
60
|
+
cwd: join2(root, testCwd),
|
|
61
|
+
stdout: "pipe",
|
|
62
|
+
stderr: "pipe"
|
|
63
|
+
});
|
|
64
|
+
const [out, err] = await Promise.all([
|
|
65
|
+
new Response(proc.stdout).text(),
|
|
66
|
+
new Response(proc.stderr).text()
|
|
67
|
+
]);
|
|
68
|
+
await proc.exited;
|
|
69
|
+
if (proc.exitCode !== 0) {
|
|
70
|
+
throw new Error(`bun test --coverage exited ${proc.exitCode}
|
|
71
|
+
${err}`);
|
|
72
|
+
}
|
|
73
|
+
return `${out}
|
|
74
|
+
${err}`;
|
|
75
|
+
}
|
|
76
|
+
function evaluateRows(rows, config) {
|
|
77
|
+
const t = config.defaultThreshold;
|
|
78
|
+
const exempt = config.exempt ?? {};
|
|
79
|
+
const violations = [];
|
|
80
|
+
const staleExempts = [];
|
|
81
|
+
if (!t)
|
|
82
|
+
return { violations, staleExempts };
|
|
83
|
+
for (const row of rows) {
|
|
84
|
+
if (exempt[row.file]) {
|
|
85
|
+
if (row.lines >= t.lines && row.funcs >= t.funcs)
|
|
86
|
+
staleExempts.push(row.file);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (row.lines < t.lines) {
|
|
90
|
+
violations.push({ file: row.file, metric: "lines", observed: row.lines, required: t.lines });
|
|
91
|
+
}
|
|
92
|
+
if (row.funcs < t.funcs) {
|
|
93
|
+
violations.push({ file: row.file, metric: "funcs", observed: row.funcs, required: t.funcs });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { violations, staleExempts };
|
|
97
|
+
}
|
|
98
|
+
function validateExemptions(root, config) {
|
|
99
|
+
const missingExemptFiles = [];
|
|
100
|
+
const missingClaimedBy = [];
|
|
101
|
+
for (const [file, entry] of Object.entries(config.exempt ?? {})) {
|
|
102
|
+
if (!existsSync2(resolve2(root, file)))
|
|
103
|
+
missingExemptFiles.push(file);
|
|
104
|
+
const claimedBy = entry.claimedBy.trim();
|
|
105
|
+
if (!claimedBy.startsWith("n/a") && !existsSync2(resolve2(root, claimedBy))) {
|
|
106
|
+
missingClaimedBy.push({ file, claimedBy });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { missingExemptFiles, missingClaimedBy };
|
|
110
|
+
}
|
|
111
|
+
async function findOrphans(root, srcDir, covered, config) {
|
|
112
|
+
const exempt = config.exempt ?? {};
|
|
113
|
+
const orphans = [];
|
|
114
|
+
const glob = new Glob(`${srcDir}/**/*.{ts,tsx}`);
|
|
115
|
+
for await (const file of glob.scan({ cwd: root, onlyFiles: true })) {
|
|
116
|
+
if (file.endsWith(".d.ts") || /\.test\.tsx?$/.test(file) || file.includes("/__tests__/")) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (covered.has(file) || exempt[file])
|
|
120
|
+
continue;
|
|
121
|
+
orphans.push(file);
|
|
122
|
+
}
|
|
123
|
+
return orphans.sort();
|
|
124
|
+
}
|
|
125
|
+
async function evaluateCoverage(root, rows, config) {
|
|
126
|
+
const srcDir = config.srcDir ?? DEFAULT_SRC;
|
|
127
|
+
const { violations, staleExempts } = evaluateRows(rows, config);
|
|
128
|
+
const { missingExemptFiles, missingClaimedBy } = validateExemptions(root, config);
|
|
129
|
+
const orphans = await findOrphans(root, srcDir, new Set(rows.map((r) => r.file)), config);
|
|
130
|
+
return {
|
|
131
|
+
rowCount: rows.length,
|
|
132
|
+
violations,
|
|
133
|
+
staleExempts,
|
|
134
|
+
missingExemptFiles,
|
|
135
|
+
missingClaimedBy,
|
|
136
|
+
orphans,
|
|
137
|
+
enforced: config.defaultThreshold !== undefined
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
async function enforceCoverage(root, config, coverageText) {
|
|
141
|
+
const srcDir = config.srcDir ?? DEFAULT_SRC;
|
|
142
|
+
const text = coverageText ?? await runCoverage(root, config.testCwd ?? ".");
|
|
143
|
+
const rows = parseCoverageTable(text, srcDir);
|
|
144
|
+
return evaluateCoverage(root, rows, config);
|
|
145
|
+
}
|
|
146
|
+
function hasFailure(findings, strictOrphans) {
|
|
147
|
+
return findings.violations.length > 0 || findings.staleExempts.length > 0 || findings.missingExemptFiles.length > 0 || findings.missingClaimedBy.length > 0 || strictOrphans && findings.orphans.length > 0;
|
|
148
|
+
}
|
|
149
|
+
var DEFAULT_SRC = "src";
|
|
150
|
+
var init_enforce = () => {};
|
|
151
|
+
|
|
152
|
+
// tools/test/src/coverage-policy/discover.ts
|
|
153
|
+
import { existsSync } from "node:fs";
|
|
154
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
155
|
+
function isCoverageConfig(value) {
|
|
156
|
+
return typeof value === "object" && value !== null;
|
|
157
|
+
}
|
|
158
|
+
async function importConfig(path) {
|
|
159
|
+
const mod = await import(path);
|
|
160
|
+
const candidate = mod["config"] ?? mod["default"];
|
|
161
|
+
if (!isCoverageConfig(candidate)) {
|
|
162
|
+
throw new Error(`${path} must export \`config\` (a CoverageConfig object)`);
|
|
163
|
+
}
|
|
164
|
+
return candidate;
|
|
165
|
+
}
|
|
166
|
+
async function loadCoverageConfig(root, explicitPath) {
|
|
167
|
+
if (explicitPath) {
|
|
168
|
+
const abs = isAbsolute(explicitPath) ? explicitPath : resolve(root, explicitPath);
|
|
169
|
+
if (!existsSync(abs))
|
|
170
|
+
throw new Error(`coverage config not found: ${abs}`);
|
|
171
|
+
return { config: await importConfig(abs), source: abs };
|
|
172
|
+
}
|
|
173
|
+
for (const name of ["coverage.config.ts", "coverage.config.js"]) {
|
|
174
|
+
const abs = join(root, name);
|
|
175
|
+
if (existsSync(abs))
|
|
176
|
+
return { config: await importConfig(abs), source: abs };
|
|
177
|
+
}
|
|
178
|
+
return { config: {}, source: null };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// tools/test/src/coverage-policy/cli.ts
|
|
182
|
+
init_enforce();
|
|
183
|
+
|
|
184
|
+
// tools/test/src/coverage-policy/mutate-targets.ts
|
|
185
|
+
import { existsSync as existsSync3, readFileSync } from "node:fs";
|
|
186
|
+
import { join as join3, resolve as resolve3 } from "node:path";
|
|
187
|
+
import { Glob as Glob2 } from "bun";
|
|
188
|
+
async function findStrykerConfigs(root) {
|
|
189
|
+
const found = [];
|
|
190
|
+
const single = join3(root, "stryker.conf.json");
|
|
191
|
+
if (existsSync3(single))
|
|
192
|
+
found.push(single);
|
|
193
|
+
const glob = new Glob2("stryker/*.{json,conf.json}");
|
|
194
|
+
for await (const rel of glob.scan({ cwd: root, onlyFiles: true })) {
|
|
195
|
+
found.push(join3(root, rel));
|
|
196
|
+
}
|
|
197
|
+
return found.sort();
|
|
198
|
+
}
|
|
199
|
+
function isGlob(pattern) {
|
|
200
|
+
return pattern.includes("*") || pattern.includes("?") || pattern.includes("[");
|
|
201
|
+
}
|
|
202
|
+
async function resolvesToFile(pattern, cwd) {
|
|
203
|
+
if (pattern.startsWith("!"))
|
|
204
|
+
return true;
|
|
205
|
+
if (!isGlob(pattern))
|
|
206
|
+
return existsSync3(resolve3(cwd, pattern));
|
|
207
|
+
const glob = new Glob2(pattern);
|
|
208
|
+
for await (const _ of glob.scan({ cwd, onlyFiles: true }))
|
|
209
|
+
return true;
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
async function checkField(configPath, field, patterns, cwd) {
|
|
213
|
+
const issues = [];
|
|
214
|
+
for (const pattern of patterns ?? []) {
|
|
215
|
+
if (!await resolvesToFile(pattern, cwd)) {
|
|
216
|
+
issues.push({ config: configPath, field, pattern });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return issues;
|
|
220
|
+
}
|
|
221
|
+
async function validateMutateTargets(root) {
|
|
222
|
+
const configs = await findStrykerConfigs(root);
|
|
223
|
+
const issues = [];
|
|
224
|
+
for (const configPath of configs) {
|
|
225
|
+
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
226
|
+
const testFiles = config.testFiles ?? config.bun?.testFiles;
|
|
227
|
+
issues.push(...await checkField(configPath, "mutate", config.mutate, root));
|
|
228
|
+
issues.push(...await checkField(configPath, "testFiles", testFiles, root));
|
|
229
|
+
}
|
|
230
|
+
return { configs, issues };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// tools/test/src/coverage-policy/cli.ts
|
|
234
|
+
function parseArgs(argv) {
|
|
235
|
+
const flag = (name) => argv.includes(name);
|
|
236
|
+
const argValue = (name) => {
|
|
237
|
+
const i = argv.indexOf(name);
|
|
238
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
239
|
+
};
|
|
240
|
+
const strictOrphans = flag("--strict-orphans");
|
|
241
|
+
return {
|
|
242
|
+
root: process.cwd(),
|
|
243
|
+
configPath: argValue("--config"),
|
|
244
|
+
strictOrphans,
|
|
245
|
+
listOrphans: strictOrphans || flag("--orphans"),
|
|
246
|
+
stdin: flag("--stdin"),
|
|
247
|
+
mutate: !flag("--no-mutate"),
|
|
248
|
+
help: flag("--help") || flag("-h")
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
async function readStdin() {
|
|
252
|
+
const chunks = [];
|
|
253
|
+
for await (const chunk of Bun.stdin.stream())
|
|
254
|
+
chunks.push(new TextDecoder().decode(chunk));
|
|
255
|
+
return chunks.join("");
|
|
256
|
+
}
|
|
257
|
+
function reportPolicy(f) {
|
|
258
|
+
for (const m of f.missingExemptFiles) {
|
|
259
|
+
process.stderr.write(`❌ exempt source missing: ${m}
|
|
260
|
+
`);
|
|
261
|
+
}
|
|
262
|
+
for (const { file, claimedBy } of f.missingClaimedBy) {
|
|
263
|
+
process.stderr.write(`❌ ${file} → claimedBy missing: ${claimedBy}
|
|
264
|
+
`);
|
|
265
|
+
}
|
|
266
|
+
for (const s of f.staleExempts) {
|
|
267
|
+
process.stderr.write(`❌ exempt file now meets the floor — promote it: ${s}
|
|
268
|
+
`);
|
|
269
|
+
}
|
|
270
|
+
for (const v of f.violations) {
|
|
271
|
+
process.stderr.write(`❌ ${v.file} ${v.metric}=${v.observed.toFixed(2)}% (need ≥ ${v.required}%)
|
|
272
|
+
`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function reportOrphans(f, args) {
|
|
276
|
+
if (f.orphans.length === 0)
|
|
277
|
+
return;
|
|
278
|
+
process.stderr.write(`${args.strictOrphans ? "❌" : "⚠️ "} ${f.orphans.length} src file(s) no unit test imports
|
|
279
|
+
`);
|
|
280
|
+
if (args.listOrphans)
|
|
281
|
+
for (const o of f.orphans)
|
|
282
|
+
process.stderr.write(` ${o}
|
|
283
|
+
`);
|
|
284
|
+
else
|
|
285
|
+
process.stderr.write(` --orphans to list, --strict-orphans to fail
|
|
286
|
+
`);
|
|
287
|
+
}
|
|
288
|
+
function reportMutate(report) {
|
|
289
|
+
if (report.issues.length === 0)
|
|
290
|
+
return;
|
|
291
|
+
process.stderr.write(`❌ ${report.issues.length} Stryker target(s) resolve to no files:
|
|
292
|
+
`);
|
|
293
|
+
for (const i of report.issues) {
|
|
294
|
+
process.stderr.write(` ${i.config} [${i.field}] ${i.pattern}
|
|
295
|
+
`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function showHelp() {
|
|
299
|
+
process.stdout.write(`polly coverage — coverage policy, orphan detection, Stryker target validation
|
|
300
|
+
|
|
301
|
+
` + ` --strict-orphans fail on source no unit test imports
|
|
302
|
+
` + ` --orphans list orphan files
|
|
303
|
+
` + ` --no-mutate skip the Stryker mutate/testFiles check
|
|
304
|
+
` + ` --config <path> explicit coverage.config.ts
|
|
305
|
+
` + " --stdin read a `bun test --coverage` table from stdin\n");
|
|
306
|
+
}
|
|
307
|
+
async function main() {
|
|
308
|
+
const args = parseArgs(process.argv.slice(2));
|
|
309
|
+
if (args.help) {
|
|
310
|
+
showHelp();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const { config, source } = await loadCoverageConfig(args.root, args.configPath);
|
|
314
|
+
const srcDir = config.srcDir ?? "src";
|
|
315
|
+
const findings = args.stdin ? await Promise.resolve().then(() => (init_enforce(), exports_enforce)).then(async (m) => {
|
|
316
|
+
const rows = parseCoverageTable(await readStdin(), srcDir);
|
|
317
|
+
return m.evaluateCoverage(args.root, rows, config);
|
|
318
|
+
}) : await enforceCoverage(args.root, config);
|
|
319
|
+
reportPolicy(findings);
|
|
320
|
+
reportOrphans(findings, args);
|
|
321
|
+
let mutate = { configs: [], issues: [] };
|
|
322
|
+
if (args.mutate) {
|
|
323
|
+
mutate = await validateMutateTargets(args.root);
|
|
324
|
+
reportMutate(mutate);
|
|
325
|
+
}
|
|
326
|
+
const failed = hasFailure(findings, args.strictOrphans) || mutate.issues.length > 0;
|
|
327
|
+
if (!failed) {
|
|
328
|
+
const mode = findings.enforced ? `floor enforced` : "report-only (no defaultThreshold)";
|
|
329
|
+
const where = source ? "" : " — zero-config";
|
|
330
|
+
const orphanNote = findings.orphans.length ? `, ${findings.orphans.length} orphan` : "";
|
|
331
|
+
const mutateNote = mutate.configs.length ? `, ${mutate.configs.length} stryker config(s) ok` : "";
|
|
332
|
+
process.stdout.write(`✅ coverage ok — ${findings.rowCount} src files, ${mode}${where}${orphanNote}${mutateNote}
|
|
333
|
+
`);
|
|
334
|
+
}
|
|
335
|
+
process.exit(failed ? 1 : 0);
|
|
336
|
+
}
|
|
337
|
+
await main();
|
|
338
|
+
|
|
339
|
+
//# debugId=7B6414401C7DC44464756E2164756E21
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../tools/test/src/coverage-policy/enforce.ts", "../tools/test/src/coverage-policy/discover.ts", "../tools/test/src/coverage-policy/cli.ts", "../tools/test/src/coverage-policy/mutate-targets.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/**\n * @fairfox/polly/test/coverage — the coverage-policy engine.\n *\n * Parses a `bun test --coverage` table and applies a {@link CoverageConfig}.\n * It fails on four conditions, not just low numbers: a non-exempt file below\n * the floor, an exempt key whose source no longer exists, an exemption whose\n * `claimedBy` test no longer exists, and an exempt file that has climbed back\n * over the floor (promote it). It also reports orphans — source files no unit\n * test imports, the blind spot a coverage table can't show.\n *\n * Everything here is parameterised by the project root, so the same engine\n * backs Polly's own gate and the consumer-facing `polly coverage` command.\n */\n\nimport { existsSync } from \"node:fs\";\nimport { join, resolve } from \"node:path\";\nimport { Glob } from \"bun\";\nimport type { CoverageConfig } from \"./types\";\n\nexport interface CoverageRow {\n /** Project-relative, e.g. `src/shared/lib/state.ts`. */\n file: string;\n funcs: number;\n lines: number;\n}\n\nexport interface Violation {\n file: string;\n metric: \"lines\" | \"funcs\";\n observed: number;\n required: number;\n}\n\nexport interface CoverageFindings {\n rowCount: number;\n violations: Violation[];\n staleExempts: string[];\n missingExemptFiles: string[];\n missingClaimedBy: Array<{ file: string; claimedBy: string }>;\n orphans: string[];\n /** True when a floor was configured; false means report-only. */\n enforced: boolean;\n}\n\nconst DEFAULT_SRC = \"src\";\n\n/** `../src/foo.ts` (run from a subdir) → `src/foo.ts` (project-relative). */\nfunction normalizePath(raw: string): string {\n return raw.replace(/^(?:\\.\\.\\/)+/, \"\");\n}\n\n/**\n * Parse the `bun test --coverage` table. Only rows under `srcDir` are\n * policy-bearing; the `All files` summary and test-infra rows are skipped.\n * Column order is `File | % Funcs | % Lines | Uncovered`.\n */\nexport function parseCoverageTable(text: string, srcDir: string): CoverageRow[] {\n const prefix = `${srcDir}/`;\n const rows: CoverageRow[] = [];\n for (const line of text.split(\"\\n\")) {\n if (!line.includes(\"|\")) continue;\n if (line.includes(\"All files\") || line.includes(\"% Funcs\")) continue;\n if (line.trim().startsWith(\"---\")) continue;\n\n const cells = line.split(\"|\").map((c) => c.trim());\n if (cells.length < 3) continue;\n\n const file = normalizePath(cells[0] ?? \"\");\n const funcs = Number(cells[1]);\n const lines = Number(cells[2]);\n if (!file.startsWith(prefix) || Number.isNaN(funcs) || Number.isNaN(lines)) continue;\n\n rows.push({ file, funcs, lines });\n }\n return rows;\n}\n\n/** Run `bun test --coverage` in the configured cwd and return combined output. */\nexport async function runCoverage(root: string, testCwd: string): Promise<string> {\n const proc = Bun.spawn([\"bun\", \"test\", \"--coverage\"], {\n cwd: join(root, testCwd),\n stdout: \"pipe\",\n stderr: \"pipe\",\n });\n const [out, err] = await Promise.all([\n new Response(proc.stdout).text(),\n new Response(proc.stderr).text(),\n ]);\n await proc.exited;\n if (proc.exitCode !== 0) {\n throw new Error(`bun test --coverage exited ${proc.exitCode}\\n${err}`);\n }\n return `${out}\\n${err}`;\n}\n\nfunction evaluateRows(\n rows: CoverageRow[],\n config: CoverageConfig\n): { violations: Violation[]; staleExempts: string[] } {\n const t = config.defaultThreshold;\n const exempt = config.exempt ?? {};\n const violations: Violation[] = [];\n const staleExempts: string[] = [];\n if (!t) return { violations, staleExempts };\n\n for (const row of rows) {\n if (exempt[row.file]) {\n if (row.lines >= t.lines && row.funcs >= t.funcs) staleExempts.push(row.file);\n continue;\n }\n if (row.lines < t.lines) {\n violations.push({ file: row.file, metric: \"lines\", observed: row.lines, required: t.lines });\n }\n if (row.funcs < t.funcs) {\n violations.push({ file: row.file, metric: \"funcs\", observed: row.funcs, required: t.funcs });\n }\n }\n return { violations, staleExempts };\n}\n\nfunction validateExemptions(\n root: string,\n config: CoverageConfig\n): { missingExemptFiles: string[]; missingClaimedBy: Array<{ file: string; claimedBy: string }> } {\n const missingExemptFiles: string[] = [];\n const missingClaimedBy: Array<{ file: string; claimedBy: string }> = [];\n for (const [file, entry] of Object.entries(config.exempt ?? {})) {\n if (!existsSync(resolve(root, file))) missingExemptFiles.push(file);\n const claimedBy = entry.claimedBy.trim();\n if (!claimedBy.startsWith(\"n/a\") && !existsSync(resolve(root, claimedBy))) {\n missingClaimedBy.push({ file, claimedBy });\n }\n }\n return { missingExemptFiles, missingClaimedBy };\n}\n\n/** Source files no row covers and no exemption names — the coverage blind spot. */\nasync function findOrphans(\n root: string,\n srcDir: string,\n covered: Set<string>,\n config: CoverageConfig\n): Promise<string[]> {\n const exempt = config.exempt ?? {};\n const orphans: string[] = [];\n const glob = new Glob(`${srcDir}/**/*.{ts,tsx}`);\n for await (const file of glob.scan({ cwd: root, onlyFiles: true })) {\n if (file.endsWith(\".d.ts\") || /\\.test\\.tsx?$/.test(file) || file.includes(\"/__tests__/\")) {\n continue;\n }\n if (covered.has(file) || exempt[file]) continue;\n orphans.push(file);\n }\n return orphans.sort();\n}\n\n/** Apply the policy to a parsed table. Pure — no spawning. */\nexport async function evaluateCoverage(\n root: string,\n rows: CoverageRow[],\n config: CoverageConfig\n): Promise<CoverageFindings> {\n const srcDir = config.srcDir ?? DEFAULT_SRC;\n const { violations, staleExempts } = evaluateRows(rows, config);\n const { missingExemptFiles, missingClaimedBy } = validateExemptions(root, config);\n const orphans = await findOrphans(root, srcDir, new Set(rows.map((r) => r.file)), config);\n return {\n rowCount: rows.length,\n violations,\n staleExempts,\n missingExemptFiles,\n missingClaimedBy,\n orphans,\n enforced: config.defaultThreshold !== undefined,\n };\n}\n\n/** Run the suite under coverage and apply the policy. */\nexport async function enforceCoverage(\n root: string,\n config: CoverageConfig,\n coverageText?: string\n): Promise<CoverageFindings> {\n const srcDir = config.srcDir ?? DEFAULT_SRC;\n const text = coverageText ?? (await runCoverage(root, config.testCwd ?? \".\"));\n const rows = parseCoverageTable(text, srcDir);\n return evaluateCoverage(root, rows, config);\n}\n\n/** True when the findings represent a policy failure (orphans are advisory\n * unless `strictOrphans`). */\nexport function hasFailure(findings: CoverageFindings, strictOrphans: boolean): boolean {\n return (\n findings.violations.length > 0 ||\n findings.staleExempts.length > 0 ||\n findings.missingExemptFiles.length > 0 ||\n findings.missingClaimedBy.length > 0 ||\n (strictOrphans && findings.orphans.length > 0)\n );\n}\n",
|
|
6
|
+
"/**\n * @fairfox/polly/test/coverage — zero-config loading.\n *\n * `polly coverage` runs without any config: it looks for a `coverage.config.ts`\n * (or `.js`) at the project root, and if there is none it returns an empty\n * config, which the engine treats as report-only — it prints the numbers and\n * the orphan count but never fails the build. A consumer opts into enforcement\n * by adding a `defaultThreshold`, and into legible tiering by adding `exempt`\n * entries. This mirrors how `polly test --tier` discovers tiers by convention.\n */\n\nimport { existsSync } from \"node:fs\";\nimport { isAbsolute, join, resolve } from \"node:path\";\nimport type { CoverageConfig } from \"./types\";\n\nexport interface LoadedConfig {\n config: CoverageConfig;\n /** Absolute path the config was loaded from, or null for the zero-config default. */\n source: string | null;\n}\n\nfunction isCoverageConfig(value: unknown): value is CoverageConfig {\n return typeof value === \"object\" && value !== null;\n}\n\nasync function importConfig(path: string): Promise<CoverageConfig> {\n const mod: Record<string, unknown> = await import(path);\n const candidate = mod[\"config\"] ?? mod[\"default\"];\n if (!isCoverageConfig(candidate)) {\n throw new Error(`${path} must export \\`config\\` (a CoverageConfig object)`);\n }\n return candidate;\n}\n\n/**\n * Resolve the coverage config. With `explicitPath` (Polly's own front-end\n * passes scripts/coverage.config.ts) that file must exist. Otherwise look for\n * coverage.config.{ts,js} at the root; absent → the zero-config report-only\n * default.\n */\nexport async function loadCoverageConfig(\n root: string,\n explicitPath?: string\n): Promise<LoadedConfig> {\n if (explicitPath) {\n const abs = isAbsolute(explicitPath) ? explicitPath : resolve(root, explicitPath);\n if (!existsSync(abs)) throw new Error(`coverage config not found: ${abs}`);\n return { config: await importConfig(abs), source: abs };\n }\n\n for (const name of [\"coverage.config.ts\", \"coverage.config.js\"]) {\n const abs = join(root, name);\n if (existsSync(abs)) return { config: await importConfig(abs), source: abs };\n }\n return { config: {}, source: null };\n}\n",
|
|
7
|
+
"#!/usr/bin/env bun\n\n/**\n * @fairfox/polly/test/coverage — consumer-facing `polly coverage`.\n *\n * Zero-config: run it in any Polly project and it reports per-file coverage,\n * orphan source files, and (if a Stryker config is present) dead mutate/test\n * globs. Add a `coverage.config.ts` with a `defaultThreshold` to turn the\n * report into an enforced gate, and `exempt` entries to record which\n * higher-tier test covers a unit-thin file.\n *\n * Usage:\n * polly coverage # report (zero-config) or enforce (with config)\n * polly coverage --strict-orphans # fail on source no unit test imports\n * polly coverage --orphans # list the orphan files\n * polly coverage --no-mutate # skip the Stryker target check\n * polly coverage --config <path> # explicit coverage.config.ts\n * bun test --coverage | polly coverage --stdin\n */\n\nimport { loadCoverageConfig } from \"./discover\";\nimport { type CoverageFindings, enforceCoverage, hasFailure, parseCoverageTable } from \"./enforce\";\nimport { type MutateTargetReport, validateMutateTargets } from \"./mutate-targets\";\n\ninterface Args {\n root: string;\n configPath?: string;\n strictOrphans: boolean;\n listOrphans: boolean;\n stdin: boolean;\n mutate: boolean;\n help: boolean;\n}\n\nfunction parseArgs(argv: string[]): Args {\n const flag = (name: string) => argv.includes(name);\n const argValue = (name: string): string | undefined => {\n const i = argv.indexOf(name);\n return i >= 0 ? argv[i + 1] : undefined;\n };\n const strictOrphans = flag(\"--strict-orphans\");\n return {\n root: process.cwd(),\n configPath: argValue(\"--config\"),\n strictOrphans,\n listOrphans: strictOrphans || flag(\"--orphans\"),\n stdin: flag(\"--stdin\"),\n mutate: !flag(\"--no-mutate\"),\n help: flag(\"--help\") || flag(\"-h\"),\n };\n}\n\nasync function readStdin(): Promise<string> {\n const chunks: string[] = [];\n for await (const chunk of Bun.stdin.stream()) chunks.push(new TextDecoder().decode(chunk));\n return chunks.join(\"\");\n}\n\nfunction reportPolicy(f: CoverageFindings): void {\n for (const m of f.missingExemptFiles) {\n process.stderr.write(`❌ exempt source missing: ${m}\\n`);\n }\n for (const { file, claimedBy } of f.missingClaimedBy) {\n process.stderr.write(`❌ ${file} → claimedBy missing: ${claimedBy}\\n`);\n }\n for (const s of f.staleExempts) {\n process.stderr.write(`❌ exempt file now meets the floor — promote it: ${s}\\n`);\n }\n for (const v of f.violations) {\n process.stderr.write(\n `❌ ${v.file} ${v.metric}=${v.observed.toFixed(2)}% (need ≥ ${v.required}%)\\n`\n );\n }\n}\n\nfunction reportOrphans(f: CoverageFindings, args: Args): void {\n if (f.orphans.length === 0) return;\n process.stderr.write(\n `${args.strictOrphans ? \"❌\" : \"⚠️ \"} ${f.orphans.length} src file(s) no unit test imports\\n`\n );\n if (args.listOrphans) for (const o of f.orphans) process.stderr.write(` ${o}\\n`);\n else process.stderr.write(\" --orphans to list, --strict-orphans to fail\\n\");\n}\n\nfunction reportMutate(report: MutateTargetReport): void {\n if (report.issues.length === 0) return;\n process.stderr.write(`❌ ${report.issues.length} Stryker target(s) resolve to no files:\\n`);\n for (const i of report.issues) {\n process.stderr.write(` ${i.config} [${i.field}] ${i.pattern}\\n`);\n }\n}\n\nfunction showHelp(): void {\n process.stdout.write(\n \"polly coverage — coverage policy, orphan detection, Stryker target validation\\n\\n\" +\n \" --strict-orphans fail on source no unit test imports\\n\" +\n \" --orphans list orphan files\\n\" +\n \" --no-mutate skip the Stryker mutate/testFiles check\\n\" +\n \" --config <path> explicit coverage.config.ts\\n\" +\n \" --stdin read a `bun test --coverage` table from stdin\\n\"\n );\n}\n\nasync function main(): Promise<void> {\n const args = parseArgs(process.argv.slice(2));\n if (args.help) {\n showHelp();\n return;\n }\n\n const { config, source } = await loadCoverageConfig(args.root, args.configPath);\n const srcDir = config.srcDir ?? \"src\";\n\n const findings = args.stdin\n ? await import(\"./enforce\").then(async (m) => {\n const rows = parseCoverageTable(await readStdin(), srcDir);\n return m.evaluateCoverage(args.root, rows, config);\n })\n : await enforceCoverage(args.root, config);\n\n reportPolicy(findings);\n reportOrphans(findings, args);\n\n let mutate: MutateTargetReport = { configs: [], issues: [] };\n if (args.mutate) {\n mutate = await validateMutateTargets(args.root);\n reportMutate(mutate);\n }\n\n const failed = hasFailure(findings, args.strictOrphans) || mutate.issues.length > 0;\n if (!failed) {\n const mode = findings.enforced ? `floor enforced` : \"report-only (no defaultThreshold)\";\n const where = source ? \"\" : \" — zero-config\";\n const orphanNote = findings.orphans.length ? `, ${findings.orphans.length} orphan` : \"\";\n const mutateNote = mutate.configs.length\n ? `, ${mutate.configs.length} stryker config(s) ok`\n : \"\";\n process.stdout.write(\n `✅ coverage ok — ${findings.rowCount} src files, ${mode}${where}${orphanNote}${mutateNote}\\n`\n );\n }\n process.exit(failed ? 1 : 0);\n}\n\nawait main();\n",
|
|
8
|
+
"/**\n * @fairfox/polly/test/coverage — Stryker mutate-/test-target validation.\n *\n * A Stryker config's `mutate` and `testFiles` lists are hand-curated. A path\n * that is renamed, or a glob whose directory moves, silently resolves to\n * nothing — Stryker mutates fewer files (or none) and the only signal is a\n * long mutation run whose score quietly drops. This asserts, in milliseconds,\n * that every entry still resolves to at least one file.\n *\n * Discovers configs two ways, covering both layouts in the wild: a single\n * `stryker.conf.json` at the root, and a `stryker/` directory of per-package\n * shards.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { join, resolve } from \"node:path\";\nimport { Glob } from \"bun\";\n\ninterface StrykerConfig {\n mutate?: string[];\n testFiles?: string[];\n bun?: { testFiles?: string[] };\n}\n\nexport interface MutateTargetIssue {\n config: string;\n field: \"mutate\" | \"testFiles\";\n pattern: string;\n}\n\nexport interface MutateTargetReport {\n configs: string[];\n issues: MutateTargetIssue[];\n}\n\n/** Locate every Stryker config under the project root. */\nexport async function findStrykerConfigs(root: string): Promise<string[]> {\n const found: string[] = [];\n const single = join(root, \"stryker.conf.json\");\n if (existsSync(single)) found.push(single);\n\n const glob = new Glob(\"stryker/*.{json,conf.json}\");\n for await (const rel of glob.scan({ cwd: root, onlyFiles: true })) {\n found.push(join(root, rel));\n }\n return found.sort();\n}\n\nfunction isGlob(pattern: string): boolean {\n return pattern.includes(\"*\") || pattern.includes(\"?\") || pattern.includes(\"[\");\n}\n\nasync function resolvesToFile(pattern: string, cwd: string): Promise<boolean> {\n if (pattern.startsWith(\"!\")) return true; // negations are filters, not targets\n if (!isGlob(pattern)) return existsSync(resolve(cwd, pattern));\n const glob = new Glob(pattern);\n for await (const _ of glob.scan({ cwd, onlyFiles: true })) return true;\n return false;\n}\n\nasync function checkField(\n configPath: string,\n field: \"mutate\" | \"testFiles\",\n patterns: string[] | undefined,\n cwd: string\n): Promise<MutateTargetIssue[]> {\n const issues: MutateTargetIssue[] = [];\n for (const pattern of patterns ?? []) {\n if (!(await resolvesToFile(pattern, cwd))) {\n issues.push({ config: configPath, field, pattern });\n }\n }\n return issues;\n}\n\n/**\n * Validate every Stryker config under `root`. Paths in a Stryker config are\n * relative to that config's directory; for the monorepo-root configs that is\n * the root, so we resolve globs against `root`.\n */\nexport async function validateMutateTargets(root: string): Promise<MutateTargetReport> {\n const configs = await findStrykerConfigs(root);\n const issues: MutateTargetIssue[] = [];\n for (const configPath of configs) {\n const config = JSON.parse(readFileSync(configPath, \"utf8\")) as unknown as StrykerConfig;\n const testFiles = config.testFiles ?? config.bun?.testFiles;\n issues.push(...(await checkField(configPath, \"mutate\", config.mutate, root)));\n issues.push(...(await checkField(configPath, \"testFiles\", testFiles, root)));\n }\n return { configs, issues };\n}\n"
|
|
9
|
+
],
|
|
10
|
+
"mappings": "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",
|
|
11
|
+
"debugId": "7B6414401C7DC44464756E2164756E21",
|
|
12
|
+
"names": []
|
|
13
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fairfox/polly/test/coverage — zero-config loading.
|
|
3
|
+
*
|
|
4
|
+
* `polly coverage` runs without any config: it looks for a `coverage.config.ts`
|
|
5
|
+
* (or `.js`) at the project root, and if there is none it returns an empty
|
|
6
|
+
* config, which the engine treats as report-only — it prints the numbers and
|
|
7
|
+
* the orphan count but never fails the build. A consumer opts into enforcement
|
|
8
|
+
* by adding a `defaultThreshold`, and into legible tiering by adding `exempt`
|
|
9
|
+
* entries. This mirrors how `polly test --tier` discovers tiers by convention.
|
|
10
|
+
*/
|
|
11
|
+
import type { CoverageConfig } from "./types";
|
|
12
|
+
export interface LoadedConfig {
|
|
13
|
+
config: CoverageConfig;
|
|
14
|
+
/** Absolute path the config was loaded from, or null for the zero-config default. */
|
|
15
|
+
source: string | null;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the coverage config. With `explicitPath` (Polly's own front-end
|
|
19
|
+
* passes scripts/coverage.config.ts) that file must exist. Otherwise look for
|
|
20
|
+
* coverage.config.{ts,js} at the root; absent → the zero-config report-only
|
|
21
|
+
* default.
|
|
22
|
+
*/
|
|
23
|
+
export declare function loadCoverageConfig(root: string, explicitPath?: string): Promise<LoadedConfig>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fairfox/polly/test/coverage — the coverage-policy engine.
|
|
3
|
+
*
|
|
4
|
+
* Parses a `bun test --coverage` table and applies a {@link CoverageConfig}.
|
|
5
|
+
* It fails on four conditions, not just low numbers: a non-exempt file below
|
|
6
|
+
* the floor, an exempt key whose source no longer exists, an exemption whose
|
|
7
|
+
* `claimedBy` test no longer exists, and an exempt file that has climbed back
|
|
8
|
+
* over the floor (promote it). It also reports orphans — source files no unit
|
|
9
|
+
* test imports, the blind spot a coverage table can't show.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is parameterised by the project root, so the same engine
|
|
12
|
+
* backs Polly's own gate and the consumer-facing `polly coverage` command.
|
|
13
|
+
*/
|
|
14
|
+
import type { CoverageConfig } from "./types";
|
|
15
|
+
export interface CoverageRow {
|
|
16
|
+
/** Project-relative, e.g. `src/shared/lib/state.ts`. */
|
|
17
|
+
file: string;
|
|
18
|
+
funcs: number;
|
|
19
|
+
lines: number;
|
|
20
|
+
}
|
|
21
|
+
export interface Violation {
|
|
22
|
+
file: string;
|
|
23
|
+
metric: "lines" | "funcs";
|
|
24
|
+
observed: number;
|
|
25
|
+
required: number;
|
|
26
|
+
}
|
|
27
|
+
export interface CoverageFindings {
|
|
28
|
+
rowCount: number;
|
|
29
|
+
violations: Violation[];
|
|
30
|
+
staleExempts: string[];
|
|
31
|
+
missingExemptFiles: string[];
|
|
32
|
+
missingClaimedBy: Array<{
|
|
33
|
+
file: string;
|
|
34
|
+
claimedBy: string;
|
|
35
|
+
}>;
|
|
36
|
+
orphans: string[];
|
|
37
|
+
/** True when a floor was configured; false means report-only. */
|
|
38
|
+
enforced: boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Parse the `bun test --coverage` table. Only rows under `srcDir` are
|
|
42
|
+
* policy-bearing; the `All files` summary and test-infra rows are skipped.
|
|
43
|
+
* Column order is `File | % Funcs | % Lines | Uncovered`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function parseCoverageTable(text: string, srcDir: string): CoverageRow[];
|
|
46
|
+
/** Run `bun test --coverage` in the configured cwd and return combined output. */
|
|
47
|
+
export declare function runCoverage(root: string, testCwd: string): Promise<string>;
|
|
48
|
+
/** Apply the policy to a parsed table. Pure — no spawning. */
|
|
49
|
+
export declare function evaluateCoverage(root: string, rows: CoverageRow[], config: CoverageConfig): Promise<CoverageFindings>;
|
|
50
|
+
/** Run the suite under coverage and apply the policy. */
|
|
51
|
+
export declare function enforceCoverage(root: string, config: CoverageConfig, coverageText?: string): Promise<CoverageFindings>;
|
|
52
|
+
/** True when the findings represent a policy failure (orphans are advisory
|
|
53
|
+
* unless `strictOrphans`). */
|
|
54
|
+
export declare function hasFailure(findings: CoverageFindings, strictOrphans: boolean): boolean;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fairfox/polly/test/coverage — programmatic API behind `polly coverage`.
|
|
3
|
+
*
|
|
4
|
+
* Use the CLI (`polly coverage`) for the common case; import these to script
|
|
5
|
+
* the policy yourself or to add a coverage tier to a custom runner.
|
|
6
|
+
*/
|
|
7
|
+
export { type LoadedConfig, loadCoverageConfig } from "./discover";
|
|
8
|
+
export { type CoverageFindings, type CoverageRow, enforceCoverage, evaluateCoverage, hasFailure, parseCoverageTable, runCoverage, type Violation, } from "./enforce";
|
|
9
|
+
export { findStrykerConfigs, type MutateTargetIssue, type MutateTargetReport, validateMutateTargets, } from "./mutate-targets";
|
|
10
|
+
export type { CoverageConfig, ExemptEntry, FileThreshold } from "./types";
|