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.
Files changed (65) hide show
  1. package/CHANGELOG.md +628 -0
  2. package/README.md +536 -42
  3. package/package.json +2 -2
  4. package/spec/GATE-SEMANTICS.md +166 -2
  5. package/spec/README.md +29 -10
  6. package/spec/lib/validate.mjs +140 -8
  7. package/spec/schemas/baseline.schema.json +13 -5
  8. package/spec/schemas/brief.schema.json +12 -12
  9. package/spec/schemas/calibration.schema.json +5 -5
  10. package/spec/schemas/claims.schema.json +122 -25
  11. package/spec/schemas/common.schema.json +131 -29
  12. package/spec/schemas/evidence.schema.json +476 -69
  13. package/spec/schemas/gate.schema.json +12 -12
  14. package/spec/schemas/ignore.schema.json +4 -4
  15. package/spec/schemas/replay.schema.json +85 -0
  16. package/spec/schemas/status.schema.json +258 -56
  17. package/src/admit/admit.mjs +17 -0
  18. package/src/baseline/baseline.mjs +31 -2
  19. package/src/brief/brief.mjs +71 -7
  20. package/src/claims/removed.mjs +161 -0
  21. package/src/cli.mjs +68 -10
  22. package/src/commands/admit.mjs +116 -0
  23. package/src/commands/baseline.mjs +17 -3
  24. package/src/commands/brief.mjs +13 -7
  25. package/src/commands/claims.mjs +55 -6
  26. package/src/commands/init.mjs +19 -6
  27. package/src/commands/mcp.mjs +12 -0
  28. package/src/commands/probe.mjs +35 -7
  29. package/src/commands/replay.mjs +63 -0
  30. package/src/commands/status.mjs +3 -1
  31. package/src/gate/changed.mjs +9 -2
  32. package/src/git.mjs +5 -0
  33. package/src/init/init.mjs +182 -29
  34. package/src/init/templates/SKILL.md +3 -3
  35. package/src/mcp/server.mjs +104 -0
  36. package/src/mcp/tools.mjs +114 -0
  37. package/src/probe/attribution.mjs +123 -0
  38. package/src/probe/classify.mjs +18 -2
  39. package/src/probe/contention.mjs +60 -0
  40. package/src/probe/cost.mjs +145 -0
  41. package/src/probe/discover.mjs +20 -3
  42. package/src/probe/independence.mjs +59 -0
  43. package/src/probe/inject.mjs +41 -9
  44. package/src/probe/mocks.mjs +122 -0
  45. package/src/probe/probe.mjs +331 -40
  46. package/src/probe/progress.mjs +87 -0
  47. package/src/probe/pyimports.mjs +287 -0
  48. package/src/probe/rank.mjs +75 -15
  49. package/src/probe/runners/index.mjs +79 -8
  50. package/src/probe/runners/jest.mjs +15 -3
  51. package/src/probe/runners/playwright.mjs +142 -0
  52. package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
  53. package/src/probe/runners/python/_testguard_report.py +141 -0
  54. package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
  55. package/src/probe/runners/python.mjs +316 -0
  56. package/src/probe/runners/shared.mjs +80 -14
  57. package/src/probe/runners/vitest.mjs +16 -3
  58. package/src/render.mjs +2 -2
  59. package/src/replay/label.mjs +64 -0
  60. package/src/replay/replay.mjs +310 -0
  61. package/src/scaffold/producers.mjs +217 -6
  62. package/src/scaffold/producers.python.mjs +209 -0
  63. package/src/scaffold/scaffold.mjs +90 -30
  64. package/src/status/status.mjs +47 -6
  65. 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
- * Can `bin` be resolved at all from this directory? Run before any verdict
48
- * is attempted: an unresolvable runner is a precondition failure, never a
49
- * statement about the tests.
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 checkBinary({ projectDir, bin, budgetMs = 60_000, env = process.env }) {
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
- const child = spawn(npx, ['--no-install', bin, '--version'], { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...env, CI: '1' } });
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', (e) => resolve({ ok: false, message: e.message }));
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 ? { ok: true, version } : { ok: false, message: (err || out).trim().split('\n').filter(Boolean).slice(-1)[0] ?? `exit ${code}` });
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 = loadFailed ? firstInformativeLine(files.find((f) => f.message)?.message ?? '') : undefined;
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
- child.stderr.on('data', (d) => (stderr += d));
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 = parseReport(JSON.parse(readFileSync(outFile, 'utf8')), durationMs);
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, npx, runProcess, checkBinary, listTestFiles } from './shared.mjs';
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) => checkBinary({ ...opts, bin: 'vitest' });
5
+ export const check = (opts) => checkRunner({ ...opts, pkg: 'vitest', bin: 'vitest' });
6
6
  export const tests = (projectDir) => listTestFiles(projectDir, testGlobs);
7
- export const run = (opts) => runProcess({ ...opts, argv: (files, outFile) => [npx, 'vitest', 'run', ...files, '--reporter=json', `--outputFile=${outFile}`] });
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
+ }