testguard-cli 0.5.0 → 0.7.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 +628 -0
- package/README.md +536 -42
- package/package.json +2 -2
- package/spec/GATE-SEMANTICS.md +166 -2
- package/spec/README.md +29 -10
- package/spec/lib/validate.mjs +140 -8
- package/spec/schemas/baseline.schema.json +13 -5
- package/spec/schemas/brief.schema.json +12 -12
- package/spec/schemas/calibration.schema.json +5 -5
- package/spec/schemas/claims.schema.json +122 -25
- package/spec/schemas/common.schema.json +131 -29
- package/spec/schemas/evidence.schema.json +476 -69
- package/spec/schemas/gate.schema.json +12 -12
- package/spec/schemas/ignore.schema.json +4 -4
- package/spec/schemas/replay.schema.json +85 -0
- package/spec/schemas/status.schema.json +258 -56
- 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 +68 -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 +55 -6
- package/src/commands/init.mjs +19 -6
- package/src/commands/mcp.mjs +12 -0
- package/src/commands/probe.mjs +35 -7
- package/src/commands/replay.mjs +63 -0
- package/src/commands/status.mjs +3 -1
- package/src/gate/changed.mjs +9 -2
- 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/attribution.mjs +123 -0
- package/src/probe/classify.mjs +18 -2
- package/src/probe/contention.mjs +60 -0
- package/src/probe/cost.mjs +145 -0
- package/src/probe/discover.mjs +20 -3
- package/src/probe/independence.mjs +59 -0
- package/src/probe/inject.mjs +41 -9
- package/src/probe/mocks.mjs +122 -0
- package/src/probe/probe.mjs +331 -40
- package/src/probe/progress.mjs +87 -0
- package/src/probe/pyimports.mjs +287 -0
- package/src/probe/rank.mjs +75 -15
- package/src/probe/runners/index.mjs +79 -8
- package/src/probe/runners/jest.mjs +15 -3
- package/src/probe/runners/playwright.mjs +142 -0
- package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
- package/src/probe/runners/python/_testguard_report.py +141 -0
- package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
- package/src/probe/runners/python.mjs +316 -0
- package/src/probe/runners/shared.mjs +80 -14
- package/src/probe/runners/vitest.mjs +16 -3
- package/src/render.mjs +2 -2
- package/src/replay/label.mjs +64 -0
- package/src/replay/replay.mjs +310 -0
- package/src/scaffold/producers.mjs +217 -6
- package/src/scaffold/producers.python.mjs +209 -0
- package/src/scaffold/scaffold.mjs +90 -30
- package/src/status/status.mjs +47 -6
- package/src/util/glob.mjs +7 -1
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { runProcess, listTestFiles, firstInformativeLine } from './shared.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The Python runner. Selected **per file**: any `.py` defender runs here
|
|
9
|
+
* whatever the project runner is, which is what makes a repository holding
|
|
10
|
+
* both a JavaScript and a Python package probeable in one run. Selected for
|
|
11
|
+
* the whole project by `--runner python|pytest|unittest`, or by `auto` when
|
|
12
|
+
* neither vitest nor jest resolves.
|
|
13
|
+
*
|
|
14
|
+
* Two engines, one adapter. `pytest` when the project's interpreter can import
|
|
15
|
+
* it, stdlib `unittest` otherwise — and `unittest` is not a fallback for the
|
|
16
|
+
* poor: it is the only engine that runs on a project whose test dependencies
|
|
17
|
+
* are the standard library, which is a deliberate choice in exactly the
|
|
18
|
+
* codebases most worth probing. The engine that ran is what the evidence
|
|
19
|
+
* records as `runner.name`; TestGuard never says pytest ran when it did not.
|
|
20
|
+
*
|
|
21
|
+
* Neither engine is installed into the project. The reporters live in
|
|
22
|
+
* `runners/python/` inside this package and reach the interpreter through
|
|
23
|
+
* PYTHONPATH — `-p _testguard_pytest_plugin` for pytest, `-m
|
|
24
|
+
* _testguard_unittest_main` for unittest. The project's dependency set is
|
|
25
|
+
* never touched.
|
|
26
|
+
*
|
|
27
|
+
* Report protocol: TestGuard's own JSON shape (see `python/_testguard_report.py`),
|
|
28
|
+
* written to a file named by `TESTGUARD_REPORT`, never parsed from stdout —
|
|
29
|
+
* `unittest` writes its summary to stderr where it interleaves with whatever
|
|
30
|
+
* the tests print, and reading that stream is how hand-rolled harnesses come
|
|
31
|
+
* to report a red suite as green.
|
|
32
|
+
*
|
|
33
|
+
* Python outcomes map onto the spec's testRun as follows:
|
|
34
|
+
*
|
|
35
|
+
* passed → passed
|
|
36
|
+
* failed → failed, counts toward assertionFailures (can kill).
|
|
37
|
+
* An `AssertionError` and a `TypeError` raised by the
|
|
38
|
+
* test body are alike here, as they are under jest:
|
|
39
|
+
* both are the suite rejecting the behaviour. The
|
|
40
|
+
* exception class is reported, never scored.
|
|
41
|
+
* failed, timed out → failed, counts toward timeouts (never a kill)
|
|
42
|
+
* xfail → neither passed nor failed
|
|
43
|
+
* xpass → the suite's own expectation is wrong, so the run is
|
|
44
|
+
* NOT green — counted as failed with no assertion
|
|
45
|
+
* failure, so it reaches FLAKY-DEFENDER through the
|
|
46
|
+
* ordinary N-run rules rather than killing anything
|
|
47
|
+
* skipped → neither passed nor failed
|
|
48
|
+
* collection error → the suite could not load: `error`, never a kill.
|
|
49
|
+
* This is what a fault whose replacement does not
|
|
50
|
+
* parse looks like from Python, and it is how
|
|
51
|
+
* `fault-invalid` is reached.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
55
|
+
export const PYTHON_DIR = join(HERE, 'python');
|
|
56
|
+
|
|
57
|
+
export const name = 'python';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* pytest's default `python_files`, plus unittest's default discovery pattern
|
|
61
|
+
* (`test*.py`). A helper named `testutils.py` matches and collects nothing,
|
|
62
|
+
* which costs a little; a test file that did not match would be invisible to
|
|
63
|
+
* discovery and report `nocover`, which costs the truth.
|
|
64
|
+
*/
|
|
65
|
+
export const testGlobs = ['**/test_*.py', '**/test*.py', '**/*_test.py'];
|
|
66
|
+
|
|
67
|
+
const PY_FILE = /\.py$/;
|
|
68
|
+
const TIMEOUT = /\bTimeout >|\btimed out\b|\bTimeoutError\b/i;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Interpreters to try, best first: the active virtualenv, the project's own,
|
|
72
|
+
* then PATH.
|
|
73
|
+
*
|
|
74
|
+
* An explicit `--python` is NOT one of these. It is the only candidate, and it
|
|
75
|
+
* is returned alone: an interpreter the user named and TestGuard quietly
|
|
76
|
+
* replaced with another would change which dependencies the tests ran against
|
|
77
|
+
* without changing anything the evidence says.
|
|
78
|
+
*/
|
|
79
|
+
function candidates(projectDir, python) {
|
|
80
|
+
const out = [];
|
|
81
|
+
const win = process.platform === 'win32';
|
|
82
|
+
const binOf = (root) => (win ? join(root, 'Scripts', 'python.exe') : join(root, 'bin', 'python'));
|
|
83
|
+
const explicit = python ?? process.env.TESTGUARD_PYTHON;
|
|
84
|
+
if (explicit) return [{ path: explicit, source: 'project', explicit: true }];
|
|
85
|
+
if (process.env.VIRTUAL_ENV) out.push({ path: binOf(process.env.VIRTUAL_ENV), source: 'project' });
|
|
86
|
+
for (const dir of ['.venv', 'venv', '.env']) out.push({ path: binOf(join(projectDir, dir)), source: 'project' });
|
|
87
|
+
out.push({ path: win ? 'python.exe' : 'python3', source: 'path' });
|
|
88
|
+
out.push({ path: win ? 'py.exe' : 'python', source: 'path' });
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const PROBE = [
|
|
93
|
+
'import json,sys',
|
|
94
|
+
'v=".".join(map(str,sys.version_info[:3]))',
|
|
95
|
+
'try:',
|
|
96
|
+
' import pytest; p=pytest.__version__',
|
|
97
|
+
'except Exception: p=None',
|
|
98
|
+
'print(json.dumps({"python":v,"pytest":p}))',
|
|
99
|
+
].join('\n');
|
|
100
|
+
|
|
101
|
+
const interpreters = new Map(); // projectDir -> resolution promise
|
|
102
|
+
|
|
103
|
+
/** Forget resolved interpreters (tests create and delete projects at the same paths). */
|
|
104
|
+
export const resetInterpreterCache = () => interpreters.clear();
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Ask an interpreter what it is and whether it can import pytest.
|
|
108
|
+
*
|
|
109
|
+
* Deliberately NOT run with `-I`. Isolated mode ignores `PYTHONPATH` and the
|
|
110
|
+
* user site directory, so it would answer "no pytest" for an interpreter that
|
|
111
|
+
* will import pytest perfectly well during the actual run — and TestGuard
|
|
112
|
+
* would silently choose the stdlib engine for a suite written in pytest, which
|
|
113
|
+
* then collects nothing. The check has to see what the run will see.
|
|
114
|
+
*/
|
|
115
|
+
function ask(path, budgetMs) {
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
let child;
|
|
118
|
+
try {
|
|
119
|
+
child = spawn(path, ['-c', PROBE], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, CI: '1' } });
|
|
120
|
+
} catch {
|
|
121
|
+
return resolve(null);
|
|
122
|
+
}
|
|
123
|
+
let out = '';
|
|
124
|
+
child.on('error', () => resolve(null));
|
|
125
|
+
child.stdout.on('data', (d) => (out += d));
|
|
126
|
+
child.stderr.on('data', () => {});
|
|
127
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), budgetMs);
|
|
128
|
+
child.on('close', (code) => {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
if (code !== 0) return resolve(null);
|
|
131
|
+
try {
|
|
132
|
+
resolve(JSON.parse(out.trim().split('\n').pop()));
|
|
133
|
+
} catch {
|
|
134
|
+
resolve(null);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The interpreter this project's tests run under, and whether it can import
|
|
142
|
+
* pytest. Resolved against the **original** project directory, never the
|
|
143
|
+
* scratch worktree: a virtualenv is gitignored, so it is not in the worktree
|
|
144
|
+
* checkout, and it is referenced by absolute path the way virtualenvs are
|
|
145
|
+
* meant to be — never copied and never symlinked, which would break the
|
|
146
|
+
* `pyvenv.cfg` prefix detection that makes one work at all.
|
|
147
|
+
*/
|
|
148
|
+
export function resolveInterpreter({ projectDir, python, budgetMs = 30_000 }) {
|
|
149
|
+
const key = `${projectDir}\0${python ?? ''}`;
|
|
150
|
+
if (interpreters.has(key)) return interpreters.get(key);
|
|
151
|
+
const task = (async () => {
|
|
152
|
+
const tried = [];
|
|
153
|
+
for (const candidate of candidates(projectDir, python)) {
|
|
154
|
+
if (!candidate.explicit && candidate.source === 'project' && !existsSync(candidate.path)) continue;
|
|
155
|
+
const answer = await ask(candidate.path, budgetMs);
|
|
156
|
+
if (answer) return { ...candidate, ...answer };
|
|
157
|
+
tried.push(candidate.path);
|
|
158
|
+
}
|
|
159
|
+
const explicit = python ?? process.env.TESTGUARD_PYTHON;
|
|
160
|
+
return { error: explicit
|
|
161
|
+
? `${explicit} is not a usable Python interpreter`
|
|
162
|
+
: `no usable Python interpreter (tried ${tried.join(', ') || 'nothing'}); install Python 3.8+, or point --python / TESTGUARD_PYTHON at one` };
|
|
163
|
+
})();
|
|
164
|
+
interpreters.set(key, task);
|
|
165
|
+
return task;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Resolvable = an interpreter answers, and — when an engine is pinned — that
|
|
170
|
+
* engine is available. `--runner pytest` on a machine that has no pytest is a
|
|
171
|
+
* precondition failure, never a silent fall back to unittest: which engine ran
|
|
172
|
+
* changes what the evidence means.
|
|
173
|
+
*/
|
|
174
|
+
/**
|
|
175
|
+
* Which engine runs, given what the interpreter answered.
|
|
176
|
+
*
|
|
177
|
+
* Pure, and separate from the spawn, because the interesting branch is the one
|
|
178
|
+
* that is hard to stage: an interpreter that resolves but cannot import
|
|
179
|
+
* pytest. Testing that through `check()` would mean building a bare virtualenv
|
|
180
|
+
* inside a unit test, so the decision is lifted out and the spawn is left with
|
|
181
|
+
* nothing to decide.
|
|
182
|
+
*/
|
|
183
|
+
export function chooseEngine(pinned, found) {
|
|
184
|
+
const engine = pinned ?? (found.pytest ? 'pytest' : 'unittest');
|
|
185
|
+
if (engine === 'pytest' && !found.pytest) {
|
|
186
|
+
return { ok: false, message: `pytest is not importable from ${found.path} (pip install pytest, or use --runner unittest for the stdlib runner)` };
|
|
187
|
+
}
|
|
188
|
+
return { ok: true, engine, version: engine === 'pytest' ? found.pytest : `CPython ${found.python}` };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function makeCheck(pinned) {
|
|
192
|
+
return async ({ projectDir, sourceDir, python, budgetMs = 30_000 }) => {
|
|
193
|
+
// Always the ORIGINAL project directory when there is one: a virtualenv is
|
|
194
|
+
// gitignored, so the scratch worktree does not contain it.
|
|
195
|
+
const found = await resolveInterpreter({ projectDir: sourceDir ?? projectDir, python, budgetMs });
|
|
196
|
+
if (found.error) return { ok: false, message: found.error };
|
|
197
|
+
const chosen = chooseEngine(pinned, found);
|
|
198
|
+
if (!chosen.ok) return chosen;
|
|
199
|
+
return { ...chosen, interpreter: found.path, source: found.source };
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Any `.py` file. A Python test can never run under vitest, so ownership needs no configuration to read. */
|
|
204
|
+
export const owns = (projectDir, file) => PY_FILE.test(file);
|
|
205
|
+
|
|
206
|
+
export const tests = (projectDir) => listTestFiles(projectDir, testGlobs).filter((f) => owns(projectDir, f));
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* TestGuard's Python report → the spec's testRun. Pure; exported for the
|
|
210
|
+
* sample-driven tests. `provenance` rides along for the import check that
|
|
211
|
+
* `probe` performs once the fault is live; it is not part of the testRun.
|
|
212
|
+
*/
|
|
213
|
+
export function parseReport(report, durationMs) {
|
|
214
|
+
const all = report.tests ?? [];
|
|
215
|
+
const collectionErrors = report.collectionErrors ?? [];
|
|
216
|
+
const failed = all.filter((t) => t.outcome === 'failed');
|
|
217
|
+
const timedOut = failed.filter((t) => TIMEOUT.test(t.message ?? ''));
|
|
218
|
+
const xpass = all.filter((t) => t.outcome === 'xpass');
|
|
219
|
+
const passed = all.filter((t) => t.outcome === 'passed').length;
|
|
220
|
+
const total = all.length;
|
|
221
|
+
const summary = { total, passed, failed: failed.length + xpass.length };
|
|
222
|
+
|
|
223
|
+
let outcome;
|
|
224
|
+
if (collectionErrors.length > 0 || total === 0) outcome = 'error';
|
|
225
|
+
else if (failed.length > 0 || xpass.length > 0) outcome = 'fail';
|
|
226
|
+
else outcome = 'pass';
|
|
227
|
+
|
|
228
|
+
const loadMessage = outcome === 'error'
|
|
229
|
+
? (collectionErrors.length
|
|
230
|
+
? firstInformativeLine(collectionErrors.map((e) => e.message ?? '').join('\n'))
|
|
231
|
+
: 'python collected no tests')
|
|
232
|
+
: undefined;
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
run: { outcome, tests: summary, assertionFailures: failed.length - timedOut.length, durationMs },
|
|
236
|
+
timeouts: timedOut.length,
|
|
237
|
+
loadMessage,
|
|
238
|
+
failedTests: [...failed, ...xpass].map((t) => t.id),
|
|
239
|
+
provenance: report.provenance ?? {},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Environment for one run. Three things matter beyond finding the reporter:
|
|
245
|
+
*
|
|
246
|
+
* PYTHONDONTWRITEBYTECODE + PYTHONPYCACHEPREFIX — `.pyc` files are validated
|
|
247
|
+
* by the source's mtime **in whole seconds** and its size. A fault that
|
|
248
|
+
* preserves the byte length and is applied within the same second as the
|
|
249
|
+
* file it replaces can therefore be served from a stale cache and never
|
|
250
|
+
* execute: a green run that proves nothing. The prefix moves every cache
|
|
251
|
+
* lookup out of the tree, so an `--in-place` run cannot read the user's
|
|
252
|
+
* existing `__pycache__` either, and DONTWRITEBYTECODE means the prefix
|
|
253
|
+
* directory is never actually created.
|
|
254
|
+
*
|
|
255
|
+
* TESTGUARD_TARGETS — the fault's file, so the reporter can say which file
|
|
256
|
+
* the interpreter really loaded for it.
|
|
257
|
+
*/
|
|
258
|
+
export function environment(targets) {
|
|
259
|
+
const existing = process.env.PYTHONPATH;
|
|
260
|
+
return {
|
|
261
|
+
PYTHONPATH: existing ? `${PYTHON_DIR}${process.platform === 'win32' ? ';' : ':'}${existing}` : PYTHON_DIR,
|
|
262
|
+
PYTHONDONTWRITEBYTECODE: '1',
|
|
263
|
+
PYTHONPYCACHEPREFIX: '{out}.pycache',
|
|
264
|
+
PYTHONUNBUFFERED: '1',
|
|
265
|
+
TESTGUARD_REPORT: '{out}',
|
|
266
|
+
TESTGUARD_TARGETS: JSON.stringify(targets ?? []),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* `--maxfail=0` is appended on every pytest run, and is not optional: a
|
|
272
|
+
* project whose `addopts` carries `-x` would otherwise stop at the first
|
|
273
|
+
* failure. The verdict would survive that (one assertion failure is still a
|
|
274
|
+
* kill) but the list of failing tests would not, and that list is what names
|
|
275
|
+
* an undeclared killer during escalation. `0` means no limit and overrides
|
|
276
|
+
* the configured value; the rest of the project's `addopts` is left alone,
|
|
277
|
+
* because TestGuard runs a project's tests the way the project runs them.
|
|
278
|
+
*/
|
|
279
|
+
export function argvFor({ engine, interpreter, files, serial }) {
|
|
280
|
+
if (engine === 'pytest') {
|
|
281
|
+
return [interpreter, '-m', 'pytest', '-p', '_testguard_pytest_plugin', '-p', 'no:cacheprovider',
|
|
282
|
+
'--maxfail=0', '-q', ...(serial ? ['-p', 'no:xdist'] : []), ...files];
|
|
283
|
+
}
|
|
284
|
+
return [interpreter, '-m', '_testguard_unittest_main', ...files];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function makeRun(pinned) {
|
|
288
|
+
const check = makeCheck(pinned);
|
|
289
|
+
return async (opts) => {
|
|
290
|
+
const resolved = await check({ projectDir: opts.sourceDir ?? opts.projectDir, python: opts.python });
|
|
291
|
+
if (!resolved.ok) {
|
|
292
|
+
return { run: { outcome: 'error', tests: { total: 0, passed: 0, failed: 0 }, assertionFailures: 0, durationMs: 0 }, timeouts: 0, loadMessage: resolved.message, failedTests: [], provenance: {} };
|
|
293
|
+
}
|
|
294
|
+
return runProcess({
|
|
295
|
+
...opts,
|
|
296
|
+
env: environment(opts.targets),
|
|
297
|
+
parse: parseReport,
|
|
298
|
+
argv: (files) => argvFor({ engine: resolved.engine, interpreter: resolved.interpreter, files, serial: opts.serial }),
|
|
299
|
+
});
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export const check = makeCheck(null);
|
|
304
|
+
export const run = makeRun(null);
|
|
305
|
+
|
|
306
|
+
/** `--runner pytest` / `--runner unittest`: the same adapter with the engine pinned. */
|
|
307
|
+
export function pinned(engine) {
|
|
308
|
+
return { name: engine, testGlobs, owns, tests, parseReport, fatalEdit, check: makeCheck(engine), run: makeRun(engine) };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The negative control's edit, for Python: an unterminated group, which is a
|
|
313
|
+
* `SyntaxError` at import time whatever the module contained. See the
|
|
314
|
+
* JavaScript runners for why this exists.
|
|
315
|
+
*/
|
|
316
|
+
export const fatalEdit = () => '# testguard negative control: this file must not parse\n(\n';
|
|
@@ -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,12 +130,19 @@ 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
|
}
|
|
85
137
|
|
|
86
138
|
export function parseReport(report, durationMs) {
|
|
139
|
+
// A report that parsed as JSON but carries no `testResults` is not a suite
|
|
140
|
+
// that ran zero tests — it is a report of a shape this parser does not
|
|
141
|
+
// understand, which is what `--runner-cmd` pointed at a non-jest reporter
|
|
142
|
+
// produces. The verdict is already safe (a non-green baseline makes the
|
|
143
|
+
// claim unverifiable rather than passing), but without this the user is told
|
|
144
|
+
// `defenders-failed-to-load` with no reason at all.
|
|
145
|
+
const unknownShape = report == null || typeof report !== 'object' || !Array.isArray(report.testResults);
|
|
87
146
|
const files = report.testResults ?? [];
|
|
88
147
|
const loadFailed = files.some((f) => f.status === 'failed' && (f.assertionResults?.length ?? 0) === 0);
|
|
89
148
|
const results = files.flatMap((f) => (f.assertionResults ?? []).map((t) => ({ ...t, id: `${f.name}::${t.fullName ?? t.title ?? ''}` }))).filter((t) => t.status === 'failed');
|
|
@@ -101,7 +160,9 @@ export function parseReport(report, durationMs) {
|
|
|
101
160
|
// jest opens a load failure with "● Test suite failed to run" and puts the
|
|
102
161
|
// cause lines below; vitest puts it on the first line. Take the first line
|
|
103
162
|
// that names an error, else the first non-empty one.
|
|
104
|
-
const loadMessage =
|
|
163
|
+
const loadMessage = unknownShape
|
|
164
|
+
? 'the runner produced a report with no `testResults` array: this parser reads the jest-compatible JSON report, so a --runner-cmd must emit that shape (vitest --reporter=json, jest --json)'
|
|
165
|
+
: loadFailed ? firstInformativeLine(files.find((f) => f.message)?.message ?? '') : undefined;
|
|
105
166
|
return { run, timeouts, loadMessage, failedTests };
|
|
106
167
|
}
|
|
107
168
|
|
|
@@ -113,16 +174,21 @@ export function parseReport(report, durationMs) {
|
|
|
113
174
|
* runner's command line; `command` (tests) or `commandTemplate` (--runner-cmd)
|
|
114
175
|
* override it.
|
|
115
176
|
*/
|
|
116
|
-
export function runProcess({ projectDir, files, budgetMs = 120_000, command, commandTemplate, argv }) {
|
|
177
|
+
export function runProcess({ projectDir, files, budgetMs = 120_000, command, commandTemplate, argv, env = {}, parse = parseReport }) {
|
|
117
178
|
const outFile = join(tmpdir(), `testguard-run-${randomBytes(6).toString('hex')}.json`);
|
|
118
179
|
const [cmd, ...args] = command
|
|
119
180
|
?? (commandTemplate ? expandCommand(commandTemplate, files, outFile) : argv(files, outFile));
|
|
181
|
+
// A runner that names its report through the environment (Playwright) gets `{out}` substituted there too.
|
|
182
|
+
const extraEnv = Object.fromEntries(Object.entries(env).map(([k, v]) => [k, String(v).replaceAll('{out}', outFile)]));
|
|
120
183
|
const started = Date.now();
|
|
121
184
|
|
|
122
185
|
return new Promise((resolve) => {
|
|
123
|
-
const child = spawn(cmd, args, { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, CI: '1', FORCE_COLOR: '0' } });
|
|
186
|
+
const child = spawn(cmd, args, { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, CI: '1', FORCE_COLOR: '0', ...extraEnv } });
|
|
124
187
|
let stderr = '';
|
|
125
|
-
|
|
188
|
+
// Node prints MODULE_TYPELESS_PACKAGE_JSON (and friends) for the PROBED
|
|
189
|
+
// project's config, not for anything the fault did. Keep it out of the
|
|
190
|
+
// stream and out of `loadMessage`, which names the cause of a load error.
|
|
191
|
+
child.stderr.on('data', (d) => (stderr += String(d).split('\n').filter((l) => !NODE_NOISE.test(l)).join('\n')));
|
|
126
192
|
child.on('error', (e) => (stderr += e.message));
|
|
127
193
|
let killed = false;
|
|
128
194
|
const timer = setTimeout(() => {
|
|
@@ -140,7 +206,7 @@ export function runProcess({ projectDir, files, budgetMs = 120_000, command, com
|
|
|
140
206
|
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
207
|
} else {
|
|
142
208
|
try {
|
|
143
|
-
result =
|
|
209
|
+
result = parse(JSON.parse(readFileSync(outFile, 'utf8')), durationMs);
|
|
144
210
|
} catch (e) {
|
|
145
211
|
result = { run: { outcome: 'error', tests: { total: 0, passed: 0, failed: 0 }, assertionFailures: 0, durationMs }, timeouts: 0, loadMessage: `unreadable report: ${e.message}`, failedTests: [] };
|
|
146
212
|
}
|
|
@@ -1,7 +1,20 @@
|
|
|
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
|
+
/** The command line, as data. Exported so it is falsifiable without spawning anything. */
|
|
9
|
+
export const argvFor = (projectDir, files, outFile, { serial = false } = {}) =>
|
|
10
|
+
[...runnerArgv(projectDir, 'vitest', 'vitest'), 'run', ...files, '--reporter=json', `--outputFile=${outFile}`, ...(serial ? ['--no-file-parallelism'] : [])];
|
|
11
|
+
export const run = (opts) => runProcess({ ...opts, argv: (files, outFile) => argvFor(opts.projectDir, files, outFile, opts) });
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The negative control's edit: content this runner's loader cannot possibly
|
|
15
|
+
* parse. Used to prove the defenders actually execute a file before a
|
|
16
|
+
* `survived` verdict about it is allowed to stand. An unterminated group is
|
|
17
|
+
* a syntax error at every JavaScript/TypeScript parser, whatever the file
|
|
18
|
+
* contained before.
|
|
19
|
+
*/
|
|
20
|
+
export const fatalEdit = () => '/* testguard negative control: this file must not parse */\n(\n';
|
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
|
+
}
|