testguard-cli 0.5.0 → 0.6.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/CHANGELOG.md +255 -0
- package/README.md +275 -31
- package/package.json +1 -1
- package/spec/GATE-SEMANTICS.md +58 -1
- package/spec/README.md +1 -0
- package/spec/lib/validate.mjs +57 -1
- package/spec/schemas/baseline.schema.json +8 -0
- package/spec/schemas/common.schema.json +5 -1
- package/spec/schemas/evidence.schema.json +374 -63
- package/spec/schemas/replay.schema.json +85 -0
- package/spec/schemas/status.schema.json +256 -54
- package/src/admit/admit.mjs +17 -0
- package/src/baseline/baseline.mjs +31 -2
- package/src/brief/brief.mjs +71 -7
- package/src/claims/removed.mjs +161 -0
- package/src/cli.mjs +53 -10
- package/src/commands/admit.mjs +116 -0
- package/src/commands/baseline.mjs +17 -3
- package/src/commands/brief.mjs +13 -7
- package/src/commands/claims.mjs +35 -6
- package/src/commands/init.mjs +19 -6
- package/src/commands/mcp.mjs +12 -0
- package/src/commands/probe.mjs +11 -3
- package/src/commands/replay.mjs +63 -0
- package/src/commands/status.mjs +3 -1
- package/src/git.mjs +5 -0
- package/src/init/init.mjs +182 -29
- package/src/init/templates/SKILL.md +3 -3
- package/src/mcp/server.mjs +104 -0
- package/src/mcp/tools.mjs +114 -0
- package/src/probe/contention.mjs +60 -0
- package/src/probe/discover.mjs +12 -3
- package/src/probe/independence.mjs +59 -0
- package/src/probe/mocks.mjs +120 -0
- package/src/probe/probe.mjs +126 -18
- package/src/probe/rank.mjs +73 -15
- package/src/probe/runners/index.mjs +49 -5
- package/src/probe/runners/jest.mjs +3 -3
- package/src/probe/runners/playwright.mjs +133 -0
- package/src/probe/runners/shared.mjs +70 -13
- package/src/probe/runners/vitest.mjs +4 -3
- package/src/render.mjs +2 -2
- package/src/replay/label.mjs +64 -0
- package/src/replay/replay.mjs +309 -0
- package/src/scaffold/producers.mjs +217 -6
- package/src/scaffold/scaffold.mjs +9 -3
- package/src/status/status.mjs +47 -6
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
3
5
|
import { tmpdir } from 'node:os';
|
|
4
6
|
import { join } from 'node:path';
|
|
5
7
|
import { randomBytes } from 'node:crypto';
|
|
@@ -9,6 +11,9 @@ export const TEST_GLOBS = ['**/*.test.js', '**/*.test.mjs', '**/*.test.cjs', '**
|
|
|
9
11
|
'**/*.spec.js', '**/*.spec.mjs', '**/*.spec.cjs', '**/*.spec.ts', '**/*.spec.mts', '**/*.spec.tsx', '**/*.spec.jsx'];
|
|
10
12
|
export const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
11
13
|
|
|
14
|
+
/** Node's own advisory warnings about the probed project's configuration; never about the fault. */
|
|
15
|
+
export const NODE_NOISE = /MODULE_TYPELESS_PACKAGE_JSON|ExperimentalWarning|--trace-warnings|Reparsing as ES module|To eliminate this warning/;
|
|
16
|
+
|
|
12
17
|
/**
|
|
13
18
|
* Split a runner command template into argv. Supports double and single
|
|
14
19
|
* quotes; `{files}` expands to the test files (one argv entry each) and
|
|
@@ -43,28 +48,75 @@ export function parseCommandTemplate(template) {
|
|
|
43
48
|
/** `{files}` must be a word of its own (one argv entry per file); `{out}` may be embedded, e.g. `--outputFile={out}`. */
|
|
44
49
|
export const expandCommand = (words, files, outFile) => words.flatMap((w) => (w === '{files}' ? files : [w.replaceAll('{out}', outFile)]));
|
|
45
50
|
|
|
51
|
+
const resolvedRunners = new Map(); // `${projectDir}\0${pkg}` → { argv, version, source }
|
|
52
|
+
|
|
46
53
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
54
|
+
* Where does the runner come from? The PROJECT first: the runner package
|
|
55
|
+
* resolved from `projectDir` (its version, its `bin` script run with this
|
|
56
|
+
* node). Only when no package resolves, an executable on PATH — a global
|
|
57
|
+
* runner is a legitimate setup for some monorepos, and the evidence says so
|
|
58
|
+
* (`source: "path"`). Never `npx`: the npx cache and a same-named executable
|
|
59
|
+
* (a Python `playwright` shim, say) both answer `npx --no-install <bin>
|
|
60
|
+
* --version` from a project that has no such runner at all (#41).
|
|
50
61
|
*/
|
|
51
|
-
export function
|
|
62
|
+
export function resolveRunner({ projectDir, pkg, bin }) {
|
|
63
|
+
const key = `${projectDir}\0${pkg}`;
|
|
64
|
+
if (resolvedRunners.has(key)) return resolvedRunners.get(key);
|
|
65
|
+
let result = null;
|
|
66
|
+
try {
|
|
67
|
+
const req = createRequire(join(projectDir, 'noop.js'));
|
|
68
|
+
const pkgJsonPath = req.resolve(`${pkg}/package.json`);
|
|
69
|
+
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
|
|
70
|
+
const binField = typeof pkgJson.bin === 'string' ? pkgJson.bin : pkgJson.bin?.[bin];
|
|
71
|
+
if (binField) result = { argv: [process.execPath, join(dirname(pkgJsonPath), binField)], version: pkgJson.version, source: 'project' };
|
|
72
|
+
} catch {}
|
|
73
|
+
resolvedRunners.set(key, result);
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Forget cached resolutions (tests create and delete projects at the same paths). */
|
|
78
|
+
export const resetRunnerCache = () => resolvedRunners.clear();
|
|
79
|
+
|
|
80
|
+
/** The argv prefix that runs `pkg`'s `bin` for this project: the resolved local script, else the PATH executable. */
|
|
81
|
+
export function runnerArgv(projectDir, pkg, bin) {
|
|
82
|
+
return resolveRunner({ projectDir, pkg, bin })?.argv ?? [bin];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Can the runner be resolved at all from this directory? Run before any
|
|
87
|
+
* verdict is attempted: an unresolvable runner is a precondition failure,
|
|
88
|
+
* never a statement about the tests. Project package first; PATH second;
|
|
89
|
+
* npx never.
|
|
90
|
+
*/
|
|
91
|
+
export function checkRunner({ projectDir, pkg, bin, budgetMs = 60_000, env = process.env }) {
|
|
92
|
+
const local = resolveRunner({ projectDir, pkg, bin });
|
|
93
|
+
if (local) return Promise.resolve({ ok: true, version: local.version, source: 'project' });
|
|
52
94
|
return new Promise((resolve) => {
|
|
53
|
-
|
|
95
|
+
let child;
|
|
96
|
+
try {
|
|
97
|
+
child = spawn(process.platform === 'win32' ? `${bin}.cmd` : bin, ['--version'], { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...env, CI: '1' } });
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return resolve({ ok: false, message: e.message });
|
|
100
|
+
}
|
|
54
101
|
let out = '';
|
|
55
102
|
let err = '';
|
|
56
|
-
child.on('error', (
|
|
103
|
+
child.on('error', () => resolve({ ok: false, message: `${pkg} is not installed in the project and \`${bin}\` is not on PATH (npm i -D ${pkg})` }));
|
|
57
104
|
child.stdout.on('data', (d) => (out += d));
|
|
58
105
|
child.stderr.on('data', (d) => (err += d));
|
|
59
106
|
const timer = setTimeout(() => child.kill('SIGKILL'), budgetMs);
|
|
60
107
|
child.on('close', (code) => {
|
|
61
108
|
clearTimeout(timer);
|
|
62
|
-
const version = out.trim().replace(/^vitest\//, '').split('\n').pop();
|
|
63
|
-
resolve(code === 0 && version
|
|
109
|
+
const version = out.trim().replace(/^vitest\//, '').replace(/^Version\s+/i, '').split('\n').pop();
|
|
110
|
+
resolve(code === 0 && version
|
|
111
|
+
? { ok: true, version, source: 'path' }
|
|
112
|
+
: { ok: false, message: `${pkg} is not installed in the project; \`${bin}\` on PATH answered: ${(err || out).trim().split('\n').filter(Boolean).slice(-1)[0] ?? `exit ${code}`}` });
|
|
64
113
|
});
|
|
65
114
|
});
|
|
66
115
|
}
|
|
67
116
|
|
|
117
|
+
/** @deprecated name kept for older callers; resolves the project package first, never npx. */
|
|
118
|
+
export const checkBinary = ({ bin, ...rest }) => checkRunner({ ...rest, pkg: bin, bin });
|
|
119
|
+
|
|
68
120
|
/** Defender globs → existing files. Empty result is the `nocover` signal. */
|
|
69
121
|
export const resolveDefenders = (projectDir, globs) => matchGlobs(projectDir, globs ?? []);
|
|
70
122
|
|
|
@@ -78,7 +130,7 @@ export const listTestFiles = (projectDir, globs = TEST_GLOBS) => matchGlobs(proj
|
|
|
78
130
|
* can kill a fault. A suite that fails to load, or a test that times out, is
|
|
79
131
|
* not evidence that the suite defends the claim.
|
|
80
132
|
*/
|
|
81
|
-
function firstInformativeLine(message) {
|
|
133
|
+
export function firstInformativeLine(message) {
|
|
82
134
|
const lines = message.split('\n').map((l) => l.replace(/\x1b\[[0-9;]*m/g, '').trim()).filter(Boolean);
|
|
83
135
|
return lines.find((l) => /error|syntax|unexpected|expected .+ but found|cannot find|failed to parse|transform failed/i.test(l) && !/^●/.test(l)) ?? lines[0] ?? '';
|
|
84
136
|
}
|
|
@@ -113,16 +165,21 @@ export function parseReport(report, durationMs) {
|
|
|
113
165
|
* runner's command line; `command` (tests) or `commandTemplate` (--runner-cmd)
|
|
114
166
|
* override it.
|
|
115
167
|
*/
|
|
116
|
-
export function runProcess({ projectDir, files, budgetMs = 120_000, command, commandTemplate, argv }) {
|
|
168
|
+
export function runProcess({ projectDir, files, budgetMs = 120_000, command, commandTemplate, argv, env = {}, parse = parseReport }) {
|
|
117
169
|
const outFile = join(tmpdir(), `testguard-run-${randomBytes(6).toString('hex')}.json`);
|
|
118
170
|
const [cmd, ...args] = command
|
|
119
171
|
?? (commandTemplate ? expandCommand(commandTemplate, files, outFile) : argv(files, outFile));
|
|
172
|
+
// A runner that names its report through the environment (Playwright) gets `{out}` substituted there too.
|
|
173
|
+
const extraEnv = Object.fromEntries(Object.entries(env).map(([k, v]) => [k, String(v).replaceAll('{out}', outFile)]));
|
|
120
174
|
const started = Date.now();
|
|
121
175
|
|
|
122
176
|
return new Promise((resolve) => {
|
|
123
|
-
const child = spawn(cmd, args, { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, CI: '1', FORCE_COLOR: '0' } });
|
|
177
|
+
const child = spawn(cmd, args, { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, CI: '1', FORCE_COLOR: '0', ...extraEnv } });
|
|
124
178
|
let stderr = '';
|
|
125
|
-
|
|
179
|
+
// Node prints MODULE_TYPELESS_PACKAGE_JSON (and friends) for the PROBED
|
|
180
|
+
// project's config, not for anything the fault did. Keep it out of the
|
|
181
|
+
// stream and out of `loadMessage`, which names the cause of a load error.
|
|
182
|
+
child.stderr.on('data', (d) => (stderr += String(d).split('\n').filter((l) => !NODE_NOISE.test(l)).join('\n')));
|
|
126
183
|
child.on('error', (e) => (stderr += e.message));
|
|
127
184
|
let killed = false;
|
|
128
185
|
const timer = setTimeout(() => {
|
|
@@ -140,7 +197,7 @@ export function runProcess({ projectDir, files, budgetMs = 120_000, command, com
|
|
|
140
197
|
result = { run: { outcome: 'error', tests: { total: 0, passed: 0, failed: 0 }, assertionFailures: 0, durationMs }, timeouts: 0, loadMessage: stderr.trim().split('\n').filter(Boolean).slice(-1)[0] ?? 'runner produced no report', failedTests: [] };
|
|
141
198
|
} else {
|
|
142
199
|
try {
|
|
143
|
-
result =
|
|
200
|
+
result = parse(JSON.parse(readFileSync(outFile, 'utf8')), durationMs);
|
|
144
201
|
} catch (e) {
|
|
145
202
|
result = { run: { outcome: 'error', tests: { total: 0, passed: 0, failed: 0 }, assertionFailures: 0, durationMs }, timeouts: 0, loadMessage: `unreadable report: ${e.message}`, failedTests: [] };
|
|
146
203
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { TEST_GLOBS,
|
|
1
|
+
import { TEST_GLOBS, runProcess, checkRunner, runnerArgv, listTestFiles } from './shared.mjs';
|
|
2
2
|
|
|
3
3
|
export const name = 'vitest';
|
|
4
4
|
export const testGlobs = TEST_GLOBS;
|
|
5
|
-
export const check = (opts) =>
|
|
5
|
+
export const check = (opts) => checkRunner({ ...opts, pkg: 'vitest', bin: 'vitest' });
|
|
6
6
|
export const tests = (projectDir) => listTestFiles(projectDir, testGlobs);
|
|
7
|
-
|
|
7
|
+
// --serial: one file at a time in one process, so a contended machine cannot turn a slow suite into a TIMEOUT verdict.
|
|
8
|
+
export const run = (opts) => runProcess({ ...opts, argv: (files, outFile) => [...runnerArgv(opts.projectDir, 'vitest', 'vitest'), 'run', ...files, '--reporter=json', `--outputFile=${outFile}`, ...(opts.serial ? ['--no-file-parallelism'] : [])] });
|
package/src/render.mjs
CHANGED
|
@@ -5,7 +5,7 @@ export const formatVerdict = (v, provisional = false) => (v === 'killed' ? 'kill
|
|
|
5
5
|
|
|
6
6
|
export const PROVISIONAL_WARNING = (n) => `PROVISIONAL — confirmRuns ${n} (< 3): nothing below is confirmed. Verdicts carry a "?"; this evidence cannot be frozen into a baseline. Re-run with --confirm 3 before trusting it.`;
|
|
7
7
|
|
|
8
|
-
export function renderRecord(r, { provisional = false } = {}) {
|
|
8
|
+
export function renderRecord(r, { provisional = false, showDiscovered = true } = {}) {
|
|
9
9
|
const head = `${formatVerdict(r.verdict, provisional).padEnd(15)} ${r.claim.id}/${r.subject.id}`.padEnd(38);
|
|
10
10
|
let why = '';
|
|
11
11
|
if (r.detail.reason === 'killed-by-undeclared-tests' && r.detail.undeclaredKillers?.length) {
|
|
@@ -16,7 +16,7 @@ export function renderRecord(r, { provisional = false } = {}) {
|
|
|
16
16
|
} else if (r.detail.reason) {
|
|
17
17
|
why = ` [${r.detail.reason}]`;
|
|
18
18
|
}
|
|
19
|
-
if (r.defenders.discovered) why += ' (defenders discovered by import)';
|
|
19
|
+
if (r.defenders.discovered && showDiscovered) why += ' (defenders discovered by import)';
|
|
20
20
|
return `${head} ${r.claim.severity.padEnd(8)} ${r.subject.file} ${r.subject.description}${why}`;
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which injected-fault class does a real fix diff most resemble?
|
|
3
|
+
*
|
|
4
|
+
* This is the join key between ground truth and the fault model: a replayed
|
|
5
|
+
* bug is a real defect a human confirmed, so labelling it by fault class is
|
|
6
|
+
* what lets P(a surviving fault of this class is a real bug) be computed.
|
|
7
|
+
* Without it, a mutation score is a number nobody can interpret.
|
|
8
|
+
*
|
|
9
|
+
* Deterministic and shape-based, in the same spirit as the scaffold
|
|
10
|
+
* producers read in reverse: a fix ADDS what the bug lacked, so the lines a
|
|
11
|
+
* fix adds are the lines a fault would delete. No LLM, and `other` whenever
|
|
12
|
+
* the shape is not one the model has — an honest gap is worth more than a
|
|
13
|
+
* confident wrong label.
|
|
14
|
+
*/
|
|
15
|
+
const GUARD_RE = /^\s*if\s*\(.*\)\s*\{?\s*$/;
|
|
16
|
+
const RETURN_RE = /^\s*return\s+/;
|
|
17
|
+
const CALL_RE = /^\s*(?:await\s+)?(?:[\w.]*\.)?(?:verify|validate|check|assert|authorize|authorise|ensure|require)\w*\s*\(/i;
|
|
18
|
+
const FIELD_RE = /^\s*['"`]?[\w$]+['"`]?\s*:\s*.+,\s*$/;
|
|
19
|
+
const LITERAL_RE = /\b(?:httpOnly|secure|sameSite|rounds|cost|ttl|timeout|tolerance|window|limit|maxAge|expiresIn)\b\s*[:=]/i;
|
|
20
|
+
const ASSIGN_RE = /^\s*(?:const |let |var )?[\w.[\]$]+\s*=\s*[^=]/;
|
|
21
|
+
|
|
22
|
+
/** Most specific first; a tie between equally specific shapes is not a label. */
|
|
23
|
+
const SPECIFICITY = ['literal-changed', 'call-removed', 'exception-swallowed', 'field-dropped', 'guard-removed', 'return-altered', 'statement-deleted'];
|
|
24
|
+
|
|
25
|
+
function kind(line) {
|
|
26
|
+
if (LITERAL_RE.test(line)) return 'literal-changed';
|
|
27
|
+
if (/^\s*(?:\}\s*)?catch\b/.test(line) || /^\s*throw\b/.test(line)) return 'exception-swallowed';
|
|
28
|
+
if (GUARD_RE.test(line)) return 'guard-removed';
|
|
29
|
+
if (RETURN_RE.test(line)) return 'return-altered';
|
|
30
|
+
if (CALL_RE.test(line)) return 'call-removed';
|
|
31
|
+
if (FIELD_RE.test(line)) return 'field-dropped';
|
|
32
|
+
if (ASSIGN_RE.test(line)) return 'statement-deleted';
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Label a unified diff of the fix's SOURCE changes (never its tests).
|
|
38
|
+
*
|
|
39
|
+
* Added lines carry the signal at double weight: the fix put back the guard,
|
|
40
|
+
* the field or the call that was missing. Removed lines are read too, for the
|
|
41
|
+
* fixes that delete a wrong condition rather than add a right one.
|
|
42
|
+
*/
|
|
43
|
+
export function labelDiff(diff) {
|
|
44
|
+
const added = [];
|
|
45
|
+
const removed = [];
|
|
46
|
+
for (const raw of diff.split('\n')) {
|
|
47
|
+
if (raw.startsWith('+++') || raw.startsWith('---')) continue;
|
|
48
|
+
if (raw.startsWith('+')) added.push(raw.slice(1));
|
|
49
|
+
else if (raw.startsWith('-')) removed.push(raw.slice(1));
|
|
50
|
+
}
|
|
51
|
+
const tally = new Map();
|
|
52
|
+
const add = (k, weight) => {
|
|
53
|
+
if (k) tally.set(k, (tally.get(k) ?? 0) + weight);
|
|
54
|
+
};
|
|
55
|
+
for (const l of added) add(kind(l), 2);
|
|
56
|
+
for (const l of removed) add(kind(l), 1);
|
|
57
|
+
if (tally.size === 0) return { faultClass: 'other', confident: false };
|
|
58
|
+
|
|
59
|
+
const ranked = [...tally].sort((a, b) => b[1] - a[1] || SPECIFICITY.indexOf(a[0]) - SPECIFICITY.indexOf(b[0]));
|
|
60
|
+
const [top, topWeight] = ranked[0];
|
|
61
|
+
const tiedSameSpecificity = ranked.length > 1 && ranked[1][1] === topWeight && SPECIFICITY.indexOf(ranked[1][0]) === SPECIFICITY.indexOf(top);
|
|
62
|
+
if (tiedSameSpecificity) return { faultClass: 'other', confident: false };
|
|
63
|
+
return { faultClass: top, confident: topWeight >= 2 };
|
|
64
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { existsSync, rmSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { git, GitError, repoRoot, headSha } from '../git.mjs';
|
|
5
|
+
import { createScratch, PreconditionError } from '../probe/worktree.mjs';
|
|
6
|
+
import { selectRunner, RUNNERS } from '../probe/runners/index.mjs';
|
|
7
|
+
import { parseCommandTemplate } from '../probe/runners/shared.mjs';
|
|
8
|
+
import { fileImports } from '../probe/rank.mjs';
|
|
9
|
+
import { labelDiff } from './label.mjs';
|
|
10
|
+
|
|
11
|
+
const TEST_RE = /(^|\/)(__tests__|tests?)\//;
|
|
12
|
+
const IS_TEST = (f) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(f) || TEST_RE.test(f);
|
|
13
|
+
const SOURCE_EXT = /\.[cm]?[jt]sx?$/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fix commits in a range: those that change source AND a test together.
|
|
17
|
+
*
|
|
18
|
+
* That pairing is the signal a human confirmed a defect — someone changed
|
|
19
|
+
* behaviour and shipped a test for it — which is what makes bug replay ground
|
|
20
|
+
* truth rather than a synthetic mutant of arguable relevance.
|
|
21
|
+
*/
|
|
22
|
+
export function findFixCommits({ dir, range, projectDir }) {
|
|
23
|
+
// In a monorepo a fix commit routinely touches several packages. Only the
|
|
24
|
+
// files under THIS project can be reverted or run here, so scope to them
|
|
25
|
+
// and drop a commit that has no source-and-test pair inside the project —
|
|
26
|
+
// otherwise most of the corpus comes back `revert-did-not-apply`, which
|
|
27
|
+
// looks like a tooling failure and hides the real verdicts.
|
|
28
|
+
const scope = projectDir && projectDir !== dir ? relative(dir, projectDir).split('\\').join('/') : '';
|
|
29
|
+
const inScope = (f) => !scope || f === scope || f.startsWith(`${scope}/`);
|
|
30
|
+
let log;
|
|
31
|
+
try {
|
|
32
|
+
log = git(['log', '--no-merges', '--format=%H%x1f%s', range], dir);
|
|
33
|
+
} catch (e) {
|
|
34
|
+
if (e instanceof GitError) throw new PreconditionError(`cannot read the range ${range}: ${e.message.split('\n')[0]}`);
|
|
35
|
+
throw e;
|
|
36
|
+
}
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const line of log.split('\n').filter(Boolean)) {
|
|
39
|
+
const [commit, subject] = line.split('\x1f');
|
|
40
|
+
const files = git(['show', '--name-only', '--format=', '--diff-filter=d', commit], dir).split('\n').filter(Boolean);
|
|
41
|
+
const source = files.filter((f) => inScope(f) && SOURCE_EXT.test(f) && !IS_TEST(f));
|
|
42
|
+
const tests = files.filter((f) => inScope(f) && IS_TEST(f));
|
|
43
|
+
if (source.length === 0 || tests.length === 0) continue;
|
|
44
|
+
out.push({ commit, subject, source, tests });
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* De-duplicate by patch-id. A dual-branch topology carries the same fix under
|
|
51
|
+
* two or three shas; counting it twice corrupts the corpus the calibration is
|
|
52
|
+
* computed from, and it was a real defect in the first harness.
|
|
53
|
+
*/
|
|
54
|
+
export function dedupeByPatch({ dir, commits }) {
|
|
55
|
+
const seen = new Map();
|
|
56
|
+
const unique = [];
|
|
57
|
+
for (const c of commits) {
|
|
58
|
+
// `git patch-id` reads the patch on stdin, which git() does not do.
|
|
59
|
+
const r = spawnSync('git', ['patch-id', '--stable'], { cwd: dir, encoding: 'utf8', input: (() => { try { return git(['show', c.commit], dir); } catch { return ''; } })() });
|
|
60
|
+
const patchId = r.status === 0 ? (r.stdout.trim().split(' ')[0] || null) : null;
|
|
61
|
+
const id = patchId || c.commit;
|
|
62
|
+
if (seen.has(id)) continue;
|
|
63
|
+
seen.set(id, c.commit);
|
|
64
|
+
unique.push({ ...c, patchId: id });
|
|
65
|
+
}
|
|
66
|
+
return { unique, duplicates: commits.length - unique.length };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Would the suite as it stood have caught this bug?
|
|
71
|
+
*
|
|
72
|
+
* For each fix: check out the fix, revert ONLY its source files to the
|
|
73
|
+
* parent, delete the test files the fix shipped, and run what remains. A
|
|
74
|
+
* fix's own test proves nothing about what the suite knew before it.
|
|
75
|
+
*
|
|
76
|
+
* Runs in a scratch worktree; the caller's tree is never touched.
|
|
77
|
+
*/
|
|
78
|
+
export async function replay({
|
|
79
|
+
projectDir,
|
|
80
|
+
range,
|
|
81
|
+
confirmRuns = 3,
|
|
82
|
+
budgetMs = 120_000,
|
|
83
|
+
runnerCommand,
|
|
84
|
+
runnerName = 'auto',
|
|
85
|
+
nodeModules,
|
|
86
|
+
scratchBase,
|
|
87
|
+
limit,
|
|
88
|
+
toolVersion = '0.0.0',
|
|
89
|
+
onProgress = () => {},
|
|
90
|
+
onStage = () => {},
|
|
91
|
+
}) {
|
|
92
|
+
projectDir = realpathSync(resolve(projectDir));
|
|
93
|
+
const root = repoRoot(projectDir);
|
|
94
|
+
if (!headSha(root)) throw new PreconditionError('repository has no commits; there is nothing to replay');
|
|
95
|
+
|
|
96
|
+
const found = findFixCommits({ dir: root, range, projectDir });
|
|
97
|
+
const { unique, duplicates } = dedupeByPatch({ dir: root, commits: found });
|
|
98
|
+
const selected = limit ? unique.slice(0, limit) : unique;
|
|
99
|
+
if (selected.length === 0) {
|
|
100
|
+
throw new PreconditionError(`no fix commits in ${range}: a fix commit changes source and a test together. Widen the range, or this history has none.`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const startedAt = new Date().toISOString();
|
|
104
|
+
const records = [];
|
|
105
|
+
let runnerUsed = RUNNERS[runnerName === 'auto' ? 'vitest' : runnerName];
|
|
106
|
+
let runnerVersion;
|
|
107
|
+
|
|
108
|
+
for (const [i, fix] of selected.entries()) {
|
|
109
|
+
onStage({ commit: fix.commit, i: i + 1, n: selected.length, stage: 'prepare' });
|
|
110
|
+
const iso = createScratch({ repoRoot: root, projectDir, ref: fix.commit, scratchBase, nodeModules });
|
|
111
|
+
try {
|
|
112
|
+
const commandTemplate = runnerCommand ? parseCommandTemplate(runnerCommand) : undefined;
|
|
113
|
+
if (!commandTemplate) {
|
|
114
|
+
const sel = await selectRunner({ projectDir: iso.projectDir, name: runnerName });
|
|
115
|
+
if (sel.error) throw new PreconditionError(`test runner is not resolvable in the scratch worktree (${sel.error}). Pass --node-modules <path>.`);
|
|
116
|
+
runnerUsed = sel.runner;
|
|
117
|
+
runnerVersion = sel.version;
|
|
118
|
+
}
|
|
119
|
+
const record = await replayOne({ fix, iso, root, projectDir, confirmRuns, budgetMs, commandTemplate, runner: runnerUsed, onStage, index: i + 1, total: selected.length });
|
|
120
|
+
records.push(record);
|
|
121
|
+
onProgress(record);
|
|
122
|
+
} finally {
|
|
123
|
+
iso.cleanup();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
schemaVersion: 1,
|
|
129
|
+
tool: { name: 'testguard', version: toolVersion },
|
|
130
|
+
run: {
|
|
131
|
+
id: `replay-${startedAt.replace(/[-:.]/g, '').slice(0, 15)}`,
|
|
132
|
+
startedAt,
|
|
133
|
+
finishedAt: new Date().toISOString(),
|
|
134
|
+
range,
|
|
135
|
+
runner: { name: runnerUsed.name, ...(runnerVersion ? { version: runnerVersion } : {}) },
|
|
136
|
+
confirmRuns,
|
|
137
|
+
candidates: found.length,
|
|
138
|
+
duplicates,
|
|
139
|
+
},
|
|
140
|
+
records,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function replayOne({ fix, iso, root, projectDir, confirmRuns, budgetMs, commandTemplate, runner, onStage, index, total }) {
|
|
145
|
+
// git reports paths from the REPOSITORY root; the runner and every file
|
|
146
|
+
// operation here work inside the scratch worktree, relative to the PROJECT
|
|
147
|
+
// directory. Map once, and refuse anything that would land outside the
|
|
148
|
+
// worktree: this function deletes files, and a wrong mapping silently
|
|
149
|
+
// deleted them from the real repository.
|
|
150
|
+
const relProject = relative(root, projectDir).split('\\').join('/');
|
|
151
|
+
const rel = (repoPath) => (relProject && repoPath.startsWith(`${relProject}/`) ? repoPath.slice(relProject.length + 1) : repoPath);
|
|
152
|
+
const isoRoot = realpathSync(iso.projectDir);
|
|
153
|
+
const inside = (p) => {
|
|
154
|
+
const abs = realpathSync(dirname(p)) + '/' + basename(p);
|
|
155
|
+
if (!abs.startsWith(isoRoot + '/')) throw new PreconditionError(`refusing to touch ${p}: outside the scratch worktree ${isoRoot}`);
|
|
156
|
+
return abs;
|
|
157
|
+
};
|
|
158
|
+
const base = { commit: fix.commit, patchId: fix.patchId, subject: fix.subject, source: fix.source, removedTests: fix.tests };
|
|
159
|
+
|
|
160
|
+
// The label comes from the fix's SOURCE diff only: a test diff describes
|
|
161
|
+
// what was asserted, not what was broken.
|
|
162
|
+
let faultClass;
|
|
163
|
+
try {
|
|
164
|
+
const diff = git(['show', '--format=', '--unified=0', fix.commit, '--', ...fix.source], root);
|
|
165
|
+
faultClass = labelDiff(diff).faultClass;
|
|
166
|
+
} catch {
|
|
167
|
+
faultClass = 'other';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Revert only the source, to the fix's parent. A fix routinely ADDS a file
|
|
171
|
+
// as well as changing others, and a file that did not exist at the parent
|
|
172
|
+
// cannot be checked out of it — reverting such a file means removing it.
|
|
173
|
+
// Getting this wrong turns half a real corpus into `revert-did-not-apply`,
|
|
174
|
+
// which reads as a tooling failure and hides every real verdict behind it.
|
|
175
|
+
const existedBefore = [];
|
|
176
|
+
const addedByTheFix = [];
|
|
177
|
+
for (const f of fix.source) {
|
|
178
|
+
try {
|
|
179
|
+
git(['cat-file', '-e', `${fix.commit}^:${f}`], iso.projectDir);
|
|
180
|
+
existedBefore.push(f);
|
|
181
|
+
} catch {
|
|
182
|
+
addedByTheFix.push(f);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Every source file is new: this commit ADDS behaviour rather than
|
|
186
|
+
// correcting it. There is no earlier version to have been blind to, so it
|
|
187
|
+
// is not a bug the suite could have caught, and it must not enter a
|
|
188
|
+
// calibration as though it were.
|
|
189
|
+
if (existedBefore.length === 0) {
|
|
190
|
+
return { ...base, faultClass, verdict: 'unverifiable', reason: 'no-prior-version', runs: [{ outcome: 'error', durationMs: 0 }] };
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
if (existedBefore.length) git(['checkout', `${fix.commit}^`, '--', ...existedBefore.map(rel)], iso.projectDir);
|
|
194
|
+
for (const f of addedByTheFix) {
|
|
195
|
+
const abs = join(iso.projectDir, rel(f));
|
|
196
|
+
if (existsSync(abs)) rmSync(inside(abs), { force: true });
|
|
197
|
+
}
|
|
198
|
+
} catch (e) {
|
|
199
|
+
return { ...base, faultClass, verdict: 'unverifiable', reason: 'revert-did-not-apply', runs: [{ outcome: 'error', durationMs: 0 }] };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// A fix's own test proves nothing about what the suite knew before it.
|
|
203
|
+
for (const t of fix.tests) {
|
|
204
|
+
const inWorktree = join(iso.projectDir, rel(t));
|
|
205
|
+
if (existsSync(inWorktree)) rmSync(inside(inWorktree), { force: true });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// What remains that could possibly notice: the test files importing any
|
|
209
|
+
// reverted source file. `nocover` means nothing does — worse than blind,
|
|
210
|
+
// and a coverage report shows it as a red line you can ignore.
|
|
211
|
+
const all = runner.tests(iso.projectDir).filter((t) => !fix.tests.some((ft) => rel(ft) === t));
|
|
212
|
+
const related = all.filter((t) => fix.source.some((sf) => fileImports(iso.projectDir, join(iso.projectDir, t), rel(sf))));
|
|
213
|
+
if (related.length === 0) {
|
|
214
|
+
return { ...base, faultClass, verdict: 'nocover', reason: 'no-test-imports-the-reverted-source', ranTests: 0, runs: [{ outcome: 'error', durationMs: 0 }] };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const runs = [];
|
|
218
|
+
for (let i = 0; i < confirmRuns; i++) {
|
|
219
|
+
onStage({ commit: fix.commit, i: index, n: total, stage: `run ${i + 1}/${confirmRuns}` });
|
|
220
|
+
const res = await runner.run({ projectDir: iso.projectDir, files: related, budgetMs, commandTemplate });
|
|
221
|
+
runs.push(res.run);
|
|
222
|
+
// A suite that cannot load says nothing about the bug.
|
|
223
|
+
if (res.run.outcome === 'error') return { ...base, faultClass, verdict: 'unverifiable', reason: 'suite-failed-to-load', ranTests: related.length, runs };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { ...base, faultClass, ...classifyReplay(runs), ranTests: related.length, runs };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Runs in, verdict out. Pure, so every branch is unit-testable — the same
|
|
231
|
+
* discipline as the probe's `classify()`, for the same reason.
|
|
232
|
+
*
|
|
233
|
+
* The ordering matters. A flaky failure reads as "the suite detected it", so
|
|
234
|
+
* flakiness biases this metric OPTIMISTICALLY and hides exactly the blind
|
|
235
|
+
* spots the replay exists to find: mixed runs are never `caught`.
|
|
236
|
+
*/
|
|
237
|
+
export function classifyReplay(runs) {
|
|
238
|
+
const isCatch = (r) => r.outcome === 'fail' && (r.assertionFailures ?? 0) > 0;
|
|
239
|
+
if (runs.length === 0) return { verdict: 'unverifiable', reason: 'no-runs' };
|
|
240
|
+
if (runs.some((r) => r.outcome === 'error')) return { verdict: 'unverifiable', reason: 'suite-failed-to-load' };
|
|
241
|
+
if (runs.every(isCatch)) return { verdict: 'caught' };
|
|
242
|
+
if (runs.every((r) => r.outcome === 'pass')) return { verdict: 'blind' };
|
|
243
|
+
if (runs.some((r) => r.outcome === 'timeout')) return { verdict: 'unverifiable', reason: 'timed-out' };
|
|
244
|
+
// A failure that is not an assertion failure is not detection either.
|
|
245
|
+
if (runs.every((r) => r.outcome === 'fail') && !runs.every(isCatch)) return { verdict: 'unverifiable', reason: 'failed-without-an-assertion' };
|
|
246
|
+
return { verdict: 'flaky', reason: 'runs-disagreed' };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Wilson score interval: the reason a label carries n rather than a vibe. */
|
|
250
|
+
export function wilson(positives, n, z = 1.96) {
|
|
251
|
+
if (n === 0) return { p: 0, ci: [0, 1] };
|
|
252
|
+
const p = positives / n;
|
|
253
|
+
const d = 1 + (z * z) / n;
|
|
254
|
+
const centre = (p + (z * z) / (2 * n)) / d;
|
|
255
|
+
const half = (z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n))) / d;
|
|
256
|
+
const round = (x) => Number(Math.min(1, Math.max(0, x)).toFixed(4));
|
|
257
|
+
return { p: Number(p.toFixed(4)), ci: [round(centre - half), round(centre + half)] };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Calibration from a replay document: per fault class, the share of real
|
|
262
|
+
* escaped bugs of that shape the suite did NOT catch.
|
|
263
|
+
*
|
|
264
|
+
* Read it as "when a fault of this class survives, how often does that
|
|
265
|
+
* correspond to a bug that really escaped" — the number that turns an
|
|
266
|
+
* uninterpretable mutation score into a statement with a sample size.
|
|
267
|
+
* `caught` and `blind` are the only outcomes that carry information;
|
|
268
|
+
* `nocover`, `flaky` and `unverifiable` are excluded from both sides.
|
|
269
|
+
*/
|
|
270
|
+
export function calibrationFrom(replayDoc, { confidence = 0.95, computedAt = new Date().toISOString(), toolVersion } = {}) {
|
|
271
|
+
const buckets = {};
|
|
272
|
+
for (const r of replayDoc.records) {
|
|
273
|
+
if (!['caught', 'blind'].includes(r.verdict)) continue;
|
|
274
|
+
const key = r.faultClass ?? 'other';
|
|
275
|
+
buckets[key] ??= { n: 0, positives: 0 };
|
|
276
|
+
buckets[key].n += 1;
|
|
277
|
+
if (r.verdict === 'blind') buckets[key].positives += 1;
|
|
278
|
+
}
|
|
279
|
+
for (const [k, b] of Object.entries(buckets)) buckets[k] = { ...b, ...wilson(b.positives, b.n) };
|
|
280
|
+
return {
|
|
281
|
+
schemaVersion: 1,
|
|
282
|
+
tool: { name: 'testguard', version: toolVersion ?? replayDoc.tool.version },
|
|
283
|
+
computedAt,
|
|
284
|
+
method: 'wilson',
|
|
285
|
+
confidence,
|
|
286
|
+
bucketBy: 'faultClass',
|
|
287
|
+
source: { kind: 'bug-replay', ref: replayDoc.run.range },
|
|
288
|
+
buckets,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function renderReplay(doc) {
|
|
293
|
+
const by = {};
|
|
294
|
+
for (const r of doc.records) by[r.verdict] = (by[r.verdict] ?? 0) + 1;
|
|
295
|
+
const lines = [];
|
|
296
|
+
for (const r of doc.records) {
|
|
297
|
+
if (r.verdict === 'caught') continue;
|
|
298
|
+
lines.push(`${r.verdict.toUpperCase().padEnd(13)} ${r.commit.slice(0, 9)} ${(r.faultClass ?? 'other').padEnd(20)} ${r.ranTests ?? 0} test${r.ranTests === 1 ? '' : 's'} ran ${r.subject ?? ''}${r.reason ? ` [${r.reason}]` : ''}`);
|
|
299
|
+
}
|
|
300
|
+
const order = ['blind', 'nocover', 'flaky', 'unverifiable', 'caught'];
|
|
301
|
+
lines.push('');
|
|
302
|
+
lines.push(`${doc.records.length} fix commit${doc.records.length === 1 ? '' : 's'} replayed from ${doc.run.candidates} candidate${doc.run.candidates === 1 ? '' : 's'}` +
|
|
303
|
+
(doc.run.duplicates ? ` (${doc.run.duplicates} dropped as the same patch)` : '') + ': ' +
|
|
304
|
+
order.filter((v) => by[v]).map((v) => `${by[v]} ${v}`).join(', ') + '.');
|
|
305
|
+
const measurable = (by.caught ?? 0) + (by.blind ?? 0);
|
|
306
|
+
if (measurable) lines.push(`${by.blind ?? 0} of ${measurable} measurable bugs were invisible to the suite — the escaped-bug replay rate is ${(((by.blind ?? 0) / measurable) * 100).toFixed(0)}%.`);
|
|
307
|
+
lines.push('A bug that reached production is by construction one the suite missed, so a high rate is expected on a first run. The number to move is this one, over time.');
|
|
308
|
+
return lines.join('\n');
|
|
309
|
+
}
|