@avi2dg/checks 0.2.0 → 0.3.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/README.md +66 -3
- package/package.json +7 -2
- package/scripts/mutation-compare.ts +173 -0
- package/stryker.preset.js +13 -0
package/README.md
CHANGED
|
@@ -5,8 +5,9 @@ Deterministic checks shared across my TypeScript repos. One package,
|
|
|
5
5
|
Effect language-service block, the shared commitlint config, the shared
|
|
6
6
|
dependency-cruiser base, the test-layout check with its bunfig preset,
|
|
7
7
|
the commit-identity check with its workflow, the comment gate with its
|
|
8
|
-
workflow and backtest,
|
|
9
|
-
|
|
8
|
+
workflow and backtest, the Stryker mutation-testing preset with its
|
|
9
|
+
no-regression comparator, and the Effect error-channel plugin compiled
|
|
10
|
+
to JavaScript.
|
|
10
11
|
|
|
11
12
|
Published as `@avi2dg/checks` on the public npm registry.
|
|
12
13
|
|
|
@@ -64,6 +65,18 @@ cp node_modules/@avi2dg/checks/bunfig.toml bunfig.toml
|
|
|
64
65
|
`commit-identity.ts` refuses a commit with an author other than the
|
|
65
66
|
repository owner; see "Commit identity" below.
|
|
66
67
|
|
|
68
|
+
A repo that runs mutation testing installs `@stryker-mutator/core` and
|
|
69
|
+
`@hughescr/stryker-bun-runner`, then spreads the shipped preset in
|
|
70
|
+
`stryker.conf.mjs`:
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
import preset from "@avi2dg/checks/stryker.preset.js";
|
|
74
|
+
|
|
75
|
+
export default {
|
|
76
|
+
...preset,
|
|
77
|
+
};
|
|
78
|
+
```
|
|
79
|
+
|
|
67
80
|
The registry version is pinned by the consumer's lockfile; bump
|
|
68
81
|
`@avi2dg/checks` to adopt a new release.
|
|
69
82
|
|
|
@@ -277,6 +290,52 @@ comment text as a share of added lines.
|
|
|
277
290
|
`generated/`, `vendor/`, `repos/`, `node_modules/` and `dist/` are out
|
|
278
291
|
of reach, so the figures are authored code.
|
|
279
292
|
|
|
293
|
+
## Mutation compare
|
|
294
|
+
|
|
295
|
+
`checks-mutation-compare` gates a pull request on no-regression rather
|
|
296
|
+
than an absolute threshold: the head mutation score may not fall below
|
|
297
|
+
the score at the merge-base.
|
|
298
|
+
|
|
299
|
+
```sh
|
|
300
|
+
checks-mutation-compare <base-report> <head-report> [--advisory]
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Both reports are Stryker `mutation.json` files. It prints the overall
|
|
304
|
+
score of each report and the per-file scores that differ, and exits 1
|
|
305
|
+
when the head score is below the base score. The score is Stryker's:
|
|
306
|
+
`Killed` and `Timeout` over those plus `Survived` and `NoCoverage`, so
|
|
307
|
+
`CompileError`, `RuntimeError`, `Ignored` and `Pending` mutants leave
|
|
308
|
+
it. Every file in a report counts toward that report's score, including
|
|
309
|
+
files present in only one of the two.
|
|
310
|
+
|
|
311
|
+
`--advisory` prints the same verdict and always exits 0. That is how
|
|
312
|
+
consumers run it for the first month; after that they drop the flag and
|
|
313
|
+
it blocks.
|
|
314
|
+
|
|
315
|
+
Enforcement runs on pull requests, comparing the head report against a
|
|
316
|
+
report built at the merge-base:
|
|
317
|
+
|
|
318
|
+
```yaml
|
|
319
|
+
jobs:
|
|
320
|
+
mutation-compare:
|
|
321
|
+
runs-on: ubuntu-latest
|
|
322
|
+
steps:
|
|
323
|
+
- uses: actions/checkout@v5
|
|
324
|
+
with:
|
|
325
|
+
fetch-depth: 0
|
|
326
|
+
- uses: oven-sh/setup-bun@v2
|
|
327
|
+
- run: bun install --frozen-lockfile
|
|
328
|
+
- run: bunx stryker run
|
|
329
|
+
- run: |
|
|
330
|
+
base="$(git merge-base HEAD origin/main)"
|
|
331
|
+
git worktree add /tmp/mutation-base "$base"
|
|
332
|
+
(cd /tmp/mutation-base && bun install --frozen-lockfile && bunx stryker run)
|
|
333
|
+
- run: bun run checks-mutation-compare --advisory /tmp/mutation-base/reports/mutation/mutation.json reports/mutation/mutation.json
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
The shared Stryker preset's `json` reporter writes
|
|
337
|
+
`reports/mutation/mutation.json` in each worktree.
|
|
338
|
+
|
|
280
339
|
## Why it is shaped this way
|
|
281
340
|
|
|
282
341
|
- `plugins` does not inherit through oxlint `extends`. `rules`,
|
|
@@ -307,6 +366,10 @@ of reach, so the figures are authored code.
|
|
|
307
366
|
consumer's copy is compared key by key against the installed one
|
|
308
367
|
instead. `[test] pathIgnorePatterns` is a real bunfig key, and an empty
|
|
309
368
|
`--path-ignore-patterns` flag overrides the file's own list.
|
|
369
|
+
- The Stryker preset is a JavaScript module, not JSON: Stryker 10 does
|
|
370
|
+
not resolve `extends` in a JSON config, but a `.mjs` config that
|
|
371
|
+
spreads an imported object consumes it. Keys the consumer sets after
|
|
372
|
+
the spread win.
|
|
310
373
|
- Each runnable script ships a `checks-` bin entry, so consumer
|
|
311
374
|
`package.json` scripts call the short name, which the package manager
|
|
312
375
|
puts on `PATH` only there; a shell runs it through `bun run`, which
|
|
@@ -346,7 +409,7 @@ a tag off `main` and reruns the build, `dist/` check, lint, typecheck
|
|
|
346
409
|
and tests before it publishes:
|
|
347
410
|
|
|
348
411
|
```sh
|
|
349
|
-
git tag v0.
|
|
412
|
+
git tag v0.3.0 && git push origin v0.3.0
|
|
350
413
|
```
|
|
351
414
|
|
|
352
415
|
The `release` workflow publishes the tagged version through npm
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Deterministic checks shared across the captain's TypeScript repos",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -18,10 +18,12 @@
|
|
|
18
18
|
"scripts/lint-coverage.sh",
|
|
19
19
|
"scripts/test-layout.ts",
|
|
20
20
|
"scripts/commit-identity.ts",
|
|
21
|
+
"scripts/mutation-compare.ts",
|
|
21
22
|
"scripts/comments.ts",
|
|
22
23
|
"scripts/comment-gate.ts",
|
|
23
24
|
"scripts/backtest.ts",
|
|
24
25
|
"oxlintrc.json",
|
|
26
|
+
"stryker.preset.js",
|
|
25
27
|
"tsconfig.effect.json",
|
|
26
28
|
"dist/index.js"
|
|
27
29
|
],
|
|
@@ -32,10 +34,12 @@
|
|
|
32
34
|
"./scripts/lint-coverage.sh": "./scripts/lint-coverage.sh",
|
|
33
35
|
"./scripts/test-layout.ts": "./scripts/test-layout.ts",
|
|
34
36
|
"./scripts/commit-identity.ts": "./scripts/commit-identity.ts",
|
|
37
|
+
"./scripts/mutation-compare.ts": "./scripts/mutation-compare.ts",
|
|
35
38
|
"./scripts/comments.ts": "./scripts/comments.ts",
|
|
36
39
|
"./scripts/comment-gate.ts": "./scripts/comment-gate.ts",
|
|
37
40
|
"./scripts/backtest.ts": "./scripts/backtest.ts",
|
|
38
41
|
"./oxlintrc.json": "./oxlintrc.json",
|
|
42
|
+
"./stryker.preset.js": "./stryker.preset.js",
|
|
39
43
|
"./tsconfig.effect.json": "./tsconfig.effect.json",
|
|
40
44
|
"./dist/index.js": "./dist/index.js"
|
|
41
45
|
},
|
|
@@ -43,12 +47,13 @@
|
|
|
43
47
|
"checks-lint-coverage": "scripts/lint-coverage.sh",
|
|
44
48
|
"checks-test-layout": "scripts/test-layout.ts",
|
|
45
49
|
"checks-commit-identity": "scripts/commit-identity.ts",
|
|
50
|
+
"checks-mutation-compare": "scripts/mutation-compare.ts",
|
|
46
51
|
"checks-comment-gate": "scripts/comment-gate.ts",
|
|
47
52
|
"checks-backtest": "scripts/backtest.ts"
|
|
48
53
|
},
|
|
49
54
|
"scripts": {
|
|
50
55
|
"build": "bun build effect-channel/index.ts --outdir dist --target node --format esm",
|
|
51
|
-
"lint": "oxlint --type-aware && ./scripts/lint-coverage.sh && bun scripts/test-layout.ts && bun scripts/commit-identity.ts HEAD && bun scripts/comment-gate.ts HEAD && depcruise --config .dependency-cruiser.cjs effect-channel scripts tests commitlint.config.js dependency-cruiser.config.js .dependency-cruiser.cjs",
|
|
56
|
+
"lint": "oxlint --type-aware && ./scripts/lint-coverage.sh && bun scripts/test-layout.ts && bun scripts/commit-identity.ts HEAD && bun scripts/comment-gate.ts HEAD && depcruise --config .dependency-cruiser.cjs effect-channel scripts tests commitlint.config.js dependency-cruiser.config.js stryker.preset.js .dependency-cruiser.cjs",
|
|
52
57
|
"typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
|
|
53
58
|
"test": "bun test --randomize"
|
|
54
59
|
},
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
export type Tally = {
|
|
5
|
+
readonly killed: number;
|
|
6
|
+
readonly total: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type FileComparison = {
|
|
10
|
+
readonly path: string;
|
|
11
|
+
readonly base: Tally;
|
|
12
|
+
readonly head: Tally;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type Comparison = {
|
|
16
|
+
readonly base: Tally;
|
|
17
|
+
readonly head: Tally;
|
|
18
|
+
readonly files: readonly FileComparison[];
|
|
19
|
+
readonly regression: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type Options = {
|
|
23
|
+
readonly basePath: string;
|
|
24
|
+
readonly headPath: string;
|
|
25
|
+
readonly advisory: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export class ReportError extends Error {}
|
|
29
|
+
|
|
30
|
+
const DETECTED = new Set(["Killed", "Timeout"]);
|
|
31
|
+
const UNDETECTED = new Set(["Survived", "NoCoverage"]);
|
|
32
|
+
const USAGE = "usage: mutation-compare.ts [--advisory] <base-report> <head-report>";
|
|
33
|
+
|
|
34
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
35
|
+
return typeof value === "object" && value !== null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parseReport(text: string): Map<string, readonly string[]> {
|
|
39
|
+
let parsed: unknown;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(text);
|
|
42
|
+
} catch {
|
|
43
|
+
throw new ReportError("mutation-compare: report is not valid JSON");
|
|
44
|
+
}
|
|
45
|
+
if (!isRecord(parsed) || !isRecord(parsed["files"])) {
|
|
46
|
+
throw new ReportError("mutation-compare: report has no files table");
|
|
47
|
+
}
|
|
48
|
+
const files = new Map<string, readonly string[]>();
|
|
49
|
+
for (const [path, entry] of Object.entries(parsed["files"])) {
|
|
50
|
+
if (!isRecord(entry) || !Array.isArray(entry["mutants"])) {
|
|
51
|
+
throw new ReportError(`mutation-compare: ${path} has no mutants list`);
|
|
52
|
+
}
|
|
53
|
+
const statuses: string[] = [];
|
|
54
|
+
for (const mutant of entry["mutants"]) {
|
|
55
|
+
if (!isRecord(mutant) || typeof mutant["status"] !== "string") {
|
|
56
|
+
throw new ReportError(`mutation-compare: ${path} carries a mutant without a status`);
|
|
57
|
+
}
|
|
58
|
+
statuses.push(mutant["status"]);
|
|
59
|
+
}
|
|
60
|
+
files.set(path, statuses);
|
|
61
|
+
}
|
|
62
|
+
return files;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function tally(statuses: readonly string[]): Tally {
|
|
66
|
+
let killed = 0;
|
|
67
|
+
let total = 0;
|
|
68
|
+
for (const status of statuses) {
|
|
69
|
+
if (DETECTED.has(status)) killed += 1;
|
|
70
|
+
if (DETECTED.has(status) || UNDETECTED.has(status)) total += 1;
|
|
71
|
+
}
|
|
72
|
+
return { killed, total };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const EMPTY: Tally = { killed: 0, total: 0 };
|
|
76
|
+
|
|
77
|
+
export function compareReports(
|
|
78
|
+
baseFiles: ReadonlyMap<string, readonly string[]>,
|
|
79
|
+
headFiles: ReadonlyMap<string, readonly string[]>,
|
|
80
|
+
): Comparison {
|
|
81
|
+
const paths = [...new Set([...baseFiles.keys(), ...headFiles.keys()])].sort();
|
|
82
|
+
const files = paths.map((path) => {
|
|
83
|
+
const baseStatuses = baseFiles.get(path);
|
|
84
|
+
const headStatuses = headFiles.get(path);
|
|
85
|
+
return {
|
|
86
|
+
path,
|
|
87
|
+
base: baseStatuses === undefined ? EMPTY : tally(baseStatuses),
|
|
88
|
+
head: headStatuses === undefined ? EMPTY : tally(headStatuses),
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
const base = tally([...baseFiles.values()].flat());
|
|
92
|
+
const head = tally([...headFiles.values()].flat());
|
|
93
|
+
return { base, head, files, regression: regressed(base, head) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function regressed(base: Tally, head: Tally): boolean {
|
|
97
|
+
if (base.total === 0) return head.total > 0 && head.killed < head.total;
|
|
98
|
+
if (head.total === 0) return false;
|
|
99
|
+
return head.killed * base.total < base.killed * head.total;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function points(count: Tally): number {
|
|
103
|
+
return count.total === 0 ? 100 : (100 * count.killed) / count.total;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function percent(count: Tally): string {
|
|
107
|
+
return count.total === 0 ? "n/a" : `${points(count).toFixed(2)}%`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function describe(count: Tally): string {
|
|
111
|
+
return `${percent(count)} (${count.killed}/${count.total})`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function deltaPoints(base: Tally, head: Tally): string {
|
|
115
|
+
const delta = points(head) - points(base);
|
|
116
|
+
return `${delta < 0 ? "-" : "+"}${Math.abs(delta).toFixed(2)}pp`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function formatComparison(comparison: Comparison, advisory: boolean): string {
|
|
120
|
+
const changed = comparison.files.filter(
|
|
121
|
+
(file) => file.base.killed !== file.head.killed || file.base.total !== file.head.total,
|
|
122
|
+
);
|
|
123
|
+
const unchanged = comparison.files.length - changed.length;
|
|
124
|
+
const lines = [
|
|
125
|
+
`mutation-compare: base ${describe(comparison.base)} head ${describe(comparison.head)} delta ${deltaPoints(comparison.base, comparison.head)}`,
|
|
126
|
+
];
|
|
127
|
+
for (const file of changed) {
|
|
128
|
+
lines.push(` ${file.path}: ${describe(file.base)} -> ${describe(file.head)}`);
|
|
129
|
+
}
|
|
130
|
+
if (unchanged > 0) lines.push(` ${unchanged} unchanged file(s)`);
|
|
131
|
+
if (comparison.regression) {
|
|
132
|
+
lines.push(`mutation-compare: REGRESSION (${deltaPoints(comparison.base, comparison.head)})${advisory ? " in advisory mode, exit 0" : ""}`);
|
|
133
|
+
} else {
|
|
134
|
+
lines.push(`mutation-compare: no regression${advisory ? " (advisory mode, exit 0)" : ""}`);
|
|
135
|
+
}
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function parseArgs(argv: readonly string[]): Options {
|
|
140
|
+
let advisory = false;
|
|
141
|
+
const paths: string[] = [];
|
|
142
|
+
for (const arg of argv) {
|
|
143
|
+
if (arg === "--advisory") advisory = true;
|
|
144
|
+
else if (arg.startsWith("--")) throw new ReportError(`${USAGE}: unknown flag ${arg}`);
|
|
145
|
+
else paths.push(arg);
|
|
146
|
+
}
|
|
147
|
+
if (paths.length !== 2 || paths[0] === undefined || paths[1] === undefined) throw new ReportError(USAGE);
|
|
148
|
+
return { basePath: paths[0], headPath: paths[1], advisory };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function exitFor(comparison: Comparison, advisory: boolean): number {
|
|
152
|
+
return comparison.regression && !advisory ? 1 : 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function load(path: string): Promise<string> {
|
|
156
|
+
try {
|
|
157
|
+
return await readFile(path, "utf8");
|
|
158
|
+
} catch {
|
|
159
|
+
throw new ReportError(`mutation-compare: cannot read ${path}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (import.meta.main) {
|
|
164
|
+
try {
|
|
165
|
+
const options = parseArgs(process.argv.slice(2));
|
|
166
|
+
const comparison = compareReports(parseReport(await load(options.basePath)), parseReport(await load(options.headPath)));
|
|
167
|
+
console.log(formatComparison(comparison, options.advisory));
|
|
168
|
+
process.exit(exitFor(comparison, options.advisory));
|
|
169
|
+
} catch (error) {
|
|
170
|
+
console.error(error instanceof ReportError ? error.message : `mutation-compare: ${String(error)}`);
|
|
171
|
+
process.exit(2);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
packageManager: "npm",
|
|
3
|
+
plugins: ["@stryker-mutator/*", "@hughescr/stryker-bun-runner"],
|
|
4
|
+
testRunner: "bun",
|
|
5
|
+
bun: { timeout: 60000 },
|
|
6
|
+
inPlace: true,
|
|
7
|
+
tempDirName: "../.stryker-tmp",
|
|
8
|
+
coverageAnalysis: "perTest",
|
|
9
|
+
reporters: ["html", "json", "clear-text", "progress"],
|
|
10
|
+
timeoutMS: 60000,
|
|
11
|
+
concurrency: 8,
|
|
12
|
+
thresholds: { high: 85, low: 70, break: null },
|
|
13
|
+
};
|