testguard-cli 0.14.1 → 0.15.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.
@@ -2,6 +2,7 @@ import * as vitest from './vitest.mjs';
2
2
  import * as jest from './jest.mjs';
3
3
  import * as playwright from './playwright.mjs';
4
4
  import * as pythonRunner from './python.mjs';
5
+ import { createDiscoveryManifest, DiscoveryError, hashDiscoveryConfigs } from './discovery.mjs';
5
6
 
6
7
  export const RUNNERS = {
7
8
  vitest,
@@ -27,15 +28,42 @@ export const RUNNER_NAMES = ['vitest', 'jest', 'playwright', 'python', 'pytest',
27
28
  */
28
29
  export const OWNED_RUNNERS = [playwright, pythonRunner];
29
30
 
31
+ const PYTHON_DISCOVERY_CONFIG_FILES = ['pyproject.toml', 'pytest.ini', 'setup.cfg', 'tox.ini', 'conftest.py'];
32
+
33
+ const immutableManifest = (manifest) => Object.freeze({
34
+ ...manifest,
35
+ runner: Object.freeze({ ...manifest.runner }),
36
+ files: Object.freeze([...manifest.files]),
37
+ configFiles: Object.freeze(manifest.configFiles.map((entry) => Object.freeze({ ...entry }))),
38
+ });
39
+
40
+ export async function discoverRunnerManifest(runner, check, projectDir, budgetMs) {
41
+ let manifest;
42
+ if (runner.discoverTests) {
43
+ manifest = await runner.discoverTests({ projectDir, version: check.version, ...(budgetMs === undefined ? {} : { timeoutMs: budgetMs }) });
44
+ return immutableManifest(manifest);
45
+ }
46
+ // Python's adapter discovery is already language-aware but is not a native
47
+ // subprocess listing yet. Bind its immutable universe to every conventional
48
+ // pytest configuration input so it cannot be reused across a config edit.
49
+ const before = hashDiscoveryConfigs(projectDir, PYTHON_DISCOVERY_CONFIG_FILES);
50
+ const files = runner.tests(projectDir);
51
+ const configFiles = hashDiscoveryConfigs(projectDir, PYTHON_DISCOVERY_CONFIG_FILES);
52
+ if (JSON.stringify(configFiles) !== JSON.stringify(before)) throw new DiscoveryError(`${runner.name} discovery config changed while tests were being listed`);
53
+ manifest = createDiscoveryManifest({ runner: runner.name, version: check.version, files, configFiles, source: 'adapter' });
54
+ return immutableManifest(manifest);
55
+ }
56
+
30
57
  /**
31
58
  * Pick the project runner: an explicit name, or the first of vitest, jest,
32
59
  * python that is resolvable from the project. Returns
33
- * { runner, version, source, engine } or { error }. `engine` is set when the
34
- * runner has more than one (Python: pytest or unittest) and names the one
35
- * that will actually run — it, not the module name, is what the evidence
36
- * records.
60
+ * { runner, version, source, engine, manifest, testFiles } only after the
61
+ * runner's immutable non-empty test universe is known, or { error }. `engine`
62
+ * is set when the runner has more than one (Python: pytest or unittest) and
63
+ * names the one that will actually run — it, not the module name, is what the
64
+ * evidence records.
37
65
  */
38
- export async function selectRunner({ projectDir, sourceDir, python, name = 'auto' }) {
66
+ export async function selectRunner({ projectDir, sourceDir, python, name = 'auto', budgetMs, budgetFor }) {
39
67
  const candidates = name === 'auto' ? [vitest, jest, pythonRunner] : [RUNNERS[name]];
40
68
  if (!candidates[0]) return { error: `unknown runner "${name}"; use ${RUNNER_NAMES.join(', ')} or auto` };
41
69
  const messages = [];
@@ -46,24 +74,28 @@ export async function selectRunner({ projectDir, sourceDir, python, name = 'auto
46
74
  // `nocover`: a damning statement about the project produced by a runner that
47
75
  // could not have seen its tests.
48
76
  //
49
- // So `auto` prefers a candidate that can actually see tests. An explicitly
50
- // named runner is still honoured, because the caller asked for it; it is
51
- // reported with `testFiles` so a caller can say what it is working with.
52
- let blind = null;
77
+ // So `auto` prefers a candidate that can actually see tests. An explicit
78
+ // runner with an empty universe is also refused: choosing it knowingly does
79
+ // not turn zero observations into evidence.
53
80
  for (const r of candidates) {
54
- const c = await r.check({ projectDir, sourceDir, python });
81
+ const checkBudget = budgetFor ? budgetFor() : budgetMs;
82
+ const c = await r.check({ projectDir, sourceDir, python, budgetFor, ...(checkBudget === undefined ? {} : { budgetMs: checkBudget }) });
55
83
  if (!c.ok) { messages.push(`${r.name}: ${c.message}`); continue; }
56
- const selected = { runner: r, version: c.version, source: c.source, engine: c.engine };
57
- let testFiles = 0;
58
- try { testFiles = r.tests(projectDir).length; } catch { testFiles = 0; }
84
+ let selected = { runner: r, version: c.version, source: c.source, engine: c.engine };
85
+ let manifest;
86
+ try {
87
+ manifest = await discoverRunnerManifest(r, c, projectDir, budgetFor ? budgetFor() : budgetMs);
88
+ } catch (error) {
89
+ return { error: `${r.name}: test discovery failed: ${error.message}` };
90
+ }
91
+ selected = { ...selected, manifest };
92
+ const testFiles = manifest.files.length;
93
+ const empty = `${r.name}: resolved but discovered no test files`;
94
+ if (name !== 'auto' && testFiles === 0) return { error: empty };
59
95
  if (testFiles > 0 || name !== 'auto') return { ...selected, testFiles };
60
- blind ??= { ...selected, testFiles: 0 };
61
- messages.push(`${r.name}: resolved but matched no test files`);
96
+ messages.push(empty);
62
97
  }
63
- // Nothing saw a test file. Return the first that resolved, and say plainly
64
- // what that means, rather than letting every verdict read as a finding.
65
- if (blind) return { ...blind, warning: `no runner matched a test file in this project; using ${blind.runner.name}, which sees none — every verdict will be nocover` };
66
- return { error: messages.join('; ') };
98
+ return { error: messages.length ? messages.join('; ') : 'no runner discovered test files' };
67
99
  }
68
100
 
69
101
  /** What the evidence calls a runner: the engine that actually ran, else the module's name. */
@@ -1,10 +1,41 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  import { TEST_GLOBS, runProcess, checkRunner, runnerArgv, listTestFiles } from './shared.mjs';
4
+ import { createDiscoveryManifest, DiscoveryError, hashDiscoveryConfigs, normalizeDiscoveredFiles, runDiscoveryProcess } from './discovery.mjs';
2
5
 
3
6
  export const name = 'jest';
4
7
  // jest's default testMatch also collects anything under __tests__/.
5
8
  export const testGlobs = [...TEST_GLOBS, '**/__tests__/**/*.js', '**/__tests__/**/*.mjs', '**/__tests__/**/*.cjs', '**/__tests__/**/*.ts', '**/__tests__/**/*.tsx', '**/__tests__/**/*.jsx'];
6
- export const check = (opts) => checkRunner({ ...opts, pkg: 'jest', bin: 'jest' });
9
+ const DISCOVERY_CONFIG_FILES = ['js', 'mjs', 'cjs', 'ts', 'cts', 'json'].map((ext) => `jest.config.${ext}`);
10
+ export async function check(opts) {
11
+ const result = await checkRunner({ ...opts, pkg: 'jest', bin: 'jest' });
12
+ if (!result.ok) return result;
13
+ const hasProjectConfig = ['package.json', ...DISCOVERY_CONFIG_FILES].some((file) => existsSync(join(opts.projectDir, file)));
14
+ return hasProjectConfig ? result : { ok: false, message: 'no package.json or jest.config.* in the project' };
15
+ }
7
16
  export const tests = (projectDir) => listTestFiles(projectDir, testGlobs);
17
+ /** Ask Jest itself to load config/projects and enumerate the files it would collect. */
18
+ export const discoveryArgvFor = (projectDir) => [...runnerArgv(projectDir, 'jest', 'jest'), '--listTests', '--json', '--runInBand'];
19
+
20
+ export function parseDiscoveryOutput(stdout) {
21
+ let files;
22
+ try {
23
+ files = JSON.parse(stdout);
24
+ } catch (error) {
25
+ throw new DiscoveryError(`Jest discovery output is not valid JSON: ${error.message}`, { cause: error });
26
+ }
27
+ if (!Array.isArray(files)) throw new DiscoveryError('Jest discovery output must be a JSON array');
28
+ return files;
29
+ }
30
+
31
+ export async function discoverTests({ projectDir, version, timeoutMs, maxOutputBytes } = {}) {
32
+ const before = hashDiscoveryConfigs(projectDir, DISCOVERY_CONFIG_FILES);
33
+ const result = await runDiscoveryProcess({ projectDir, argv: discoveryArgvFor(projectDir), timeoutMs, maxOutputBytes });
34
+ const configFiles = hashDiscoveryConfigs(projectDir, DISCOVERY_CONFIG_FILES);
35
+ if (JSON.stringify(configFiles) !== JSON.stringify(before)) throw new DiscoveryError('Jest discovery config changed while tests were being listed');
36
+ const files = normalizeDiscoveredFiles(projectDir, parseDiscoveryOutput(result.stdout));
37
+ return createDiscoveryManifest({ runner: name, version, files, configFiles });
38
+ }
8
39
  // --runTestsByPath: positionals are exact paths, not regexes — a path with `+` or `(` would otherwise silently match nothing.
9
40
  /** The command line, as data. Exported so it is falsifiable without spawning anything. */
10
41
  export const argvFor = (projectDir, files, outFile, { serial = false } = {}) =>
@@ -1,7 +1,8 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
- import { join, relative, resolve, sep } from 'node:path';
4
- import { npx, runProcess, listTestFiles, firstInformativeLine } from './shared.mjs';
3
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ import { npx, runProcess, listTestFiles, firstInformativeLine, runnerArgv } from './shared.mjs';
5
+ import { createDiscoveryManifest, DiscoveryError, hashDiscoveryConfigs, normalizeDiscoveredFiles, runDiscoveryReportProcess } from './discovery.mjs';
5
6
  import { globToRegExp } from '../../util/glob.mjs';
6
7
 
7
8
  /**
@@ -88,6 +89,61 @@ export async function check({ projectDir }) {
88
89
  }
89
90
 
90
91
  export const tests = (projectDir) => listTestFiles(projectDir, SPEC_GLOBS).filter((f) => owns(projectDir, f));
92
+ const DISCOVERY_CONFIG_FILES = ['ts', 'mts', 'cts', 'js', 'mjs', 'cjs'].map((ext) => `playwright.config.${ext}`);
93
+
94
+ /** Ask Playwright itself to load config/projects and list without executing. */
95
+ export const discoveryArgvFor = (projectDir) => [...runnerArgv(projectDir, '@playwright/test', 'playwright'), 'test', '--list', '--reporter=json'];
96
+
97
+ export function parseDiscoveryReport(report) {
98
+ if (report == null || typeof report !== 'object' || Array.isArray(report) || !Array.isArray(report.suites)) {
99
+ throw new DiscoveryError('Playwright discovery report must contain a suites array');
100
+ }
101
+ if (report.errors !== undefined && !Array.isArray(report.errors)) {
102
+ throw new DiscoveryError('Playwright discovery report errors must be an array');
103
+ }
104
+ if (Array.isArray(report.errors) && report.errors.length > 0) {
105
+ const message = report.errors.map((error) => error?.message ?? String(error)).find(Boolean);
106
+ throw new DiscoveryError(`Playwright discovery failed${message ? `: ${firstInformativeLine(message)}` : ''}`);
107
+ }
108
+ const files = [];
109
+ const walk = (suite) => {
110
+ if (typeof suite?.file === 'string' && suite.file.length > 0) files.push(suite.file);
111
+ for (const spec of suite?.specs ?? []) {
112
+ if (typeof spec?.file === 'string' && spec.file.length > 0) files.push(spec.file);
113
+ }
114
+ for (const child of suite?.suites ?? []) walk(child);
115
+ };
116
+ for (const suite of report.suites) walk(suite);
117
+ return files;
118
+ }
119
+
120
+ export async function discoverTests({ projectDir, version, timeoutMs, maxOutputBytes } = {}) {
121
+ const before = hashDiscoveryConfigs(projectDir, DISCOVERY_CONFIG_FILES);
122
+ const { report } = await runDiscoveryReportProcess({
123
+ projectDir,
124
+ argv: discoveryArgvFor(projectDir),
125
+ timeoutMs,
126
+ maxOutputBytes,
127
+ env: { PLAYWRIGHT_JSON_OUTPUT_FILE: '{out}', PLAYWRIGHT_JSON_OUTPUT_NAME: '{out}' },
128
+ });
129
+ const configFiles = hashDiscoveryConfigs(projectDir, DISCOVERY_CONFIG_FILES);
130
+ if (JSON.stringify(configFiles) !== JSON.stringify(before)) throw new DiscoveryError('Playwright discovery config changed while tests were being listed');
131
+ const cfg = loadConfig(projectDir);
132
+ const reported = parseDiscoveryReport(report).map((file) => {
133
+ if (isAbsolute(file)) return file;
134
+ const fromRoot = resolve(projectDir, file);
135
+ const fromTestDir = resolve(projectDir, cfg?.testDir ?? '.', file);
136
+ const rootExists = existsSync(fromRoot);
137
+ const testDirExists = existsSync(fromTestDir);
138
+ if (rootExists && testDirExists && fromRoot !== fromTestDir) {
139
+ throw new DiscoveryError(`Playwright discovery path is ambiguous between the project and testDir: ${file}`);
140
+ }
141
+ if (testDirExists) return fromTestDir;
142
+ return file;
143
+ });
144
+ const files = normalizeDiscoveredFiles(projectDir, reported);
145
+ return createDiscoveryManifest({ runner: name, version, files, configFiles });
146
+ }
91
147
 
92
148
  /**
93
149
  * Playwright's JSON report → the spec's testRun. Pure; exported for the
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { runProcess, listTestFiles, firstInformativeLine } from './shared.mjs';
5
+ import { runProcess, listTestFiles, firstInformativeLine, terminateProcessTree } from './shared.mjs';
6
6
 
7
7
  /**
8
8
  * The Python runner. Selected **per file**: any `.py` defender runs here
@@ -116,7 +116,7 @@ function ask(path, budgetMs) {
116
116
  return new Promise((resolve) => {
117
117
  let child;
118
118
  try {
119
- child = spawn(path, ['-c', PROBE], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, CI: '1' } });
119
+ child = spawn(path, ['-c', PROBE], { stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', env: { ...process.env, CI: '1' } });
120
120
  } catch {
121
121
  return resolve(null);
122
122
  }
@@ -124,7 +124,7 @@ function ask(path, budgetMs) {
124
124
  child.on('error', () => resolve(null));
125
125
  child.stdout.on('data', (d) => (out += d));
126
126
  child.stderr.on('data', () => {});
127
- const timer = setTimeout(() => child.kill('SIGKILL'), budgetMs);
127
+ const timer = setTimeout(() => terminateProcessTree(child), budgetMs);
128
128
  child.on('close', (code) => {
129
129
  clearTimeout(timer);
130
130
  if (code !== 0) return resolve(null);
@@ -145,14 +145,14 @@ function ask(path, budgetMs) {
145
145
  * meant to be — never copied and never symlinked, which would break the
146
146
  * `pyvenv.cfg` prefix detection that makes one work at all.
147
147
  */
148
- export function resolveInterpreter({ projectDir, python, budgetMs = 30_000 }) {
148
+ export function resolveInterpreter({ projectDir, python, budgetMs = 30_000, budgetFor }) {
149
149
  const key = `${projectDir}\0${python ?? ''}`;
150
150
  if (interpreters.has(key)) return interpreters.get(key);
151
151
  const task = (async () => {
152
152
  const tried = [];
153
153
  for (const candidate of candidates(projectDir, python)) {
154
154
  if (!candidate.explicit && candidate.source === 'project' && !existsSync(candidate.path)) continue;
155
- const answer = await ask(candidate.path, budgetMs);
155
+ const answer = await ask(candidate.path, budgetFor ? budgetFor() : budgetMs);
156
156
  if (answer) return { ...candidate, ...answer };
157
157
  tried.push(candidate.path);
158
158
  }
@@ -189,10 +189,10 @@ export function chooseEngine(pinned, found) {
189
189
  }
190
190
 
191
191
  export function makeCheck(pinned) {
192
- return async ({ projectDir, sourceDir, python, budgetMs = 30_000 }) => {
192
+ return async ({ projectDir, sourceDir, python, budgetMs = 30_000, budgetFor }) => {
193
193
  // Always the ORIGINAL project directory when there is one: a virtualenv is
194
194
  // gitignored, so the scratch worktree does not contain it.
195
- const found = await resolveInterpreter({ projectDir: sourceDir ?? projectDir, python, budgetMs });
195
+ const found = await resolveInterpreter({ projectDir: sourceDir ?? projectDir, python, budgetMs, budgetFor });
196
196
  if (found.error) return { ok: false, message: found.error };
197
197
  const chosen = chooseEngine(pinned, found);
198
198
  if (!chosen.ok) return chosen;
@@ -1,4 +1,4 @@
1
- import { spawn } from 'node:child_process';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
2
  import { existsSync, readFileSync, rmSync } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import { dirname } from 'node:path';
@@ -14,6 +14,50 @@ export const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
14
14
  /** Node's own advisory warnings about the probed project's configuration; never about the fault. */
15
15
  export const NODE_NOISE = /MODULE_TYPELESS_PACKAGE_JSON|ExperimentalWarning|--trace-warnings|Reparsing as ES module|To eliminate this warning/;
16
16
 
17
+ function descendantPids(rootPid) {
18
+ if (process.platform === 'win32') return [];
19
+ const result = spawnSync('ps', ['-axo', 'pid=,ppid='], { encoding: 'utf8' });
20
+ if (result.status !== 0) return [];
21
+ const children = new Map();
22
+ for (const line of result.stdout.split('\n')) {
23
+ const [pid, ppid] = line.trim().split(/\s+/).map(Number);
24
+ if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
25
+ if (!children.has(ppid)) children.set(ppid, []);
26
+ children.get(ppid).push(pid);
27
+ }
28
+ const out = [];
29
+ const visit = (pid) => { for (const child of children.get(pid) ?? []) { visit(child); out.push(child); } };
30
+ visit(rootPid);
31
+ return out;
32
+ }
33
+
34
+ export const TIMEOUT_CLEANUP_WARNING = 'cleanup-unverified: TestGuard hard-killed the runner process group and descendants still attributable at timeout, but an already-reparented daemon may survive; use an OS or container containment boundary for daemonizing or untrusted commands';
35
+
36
+ /**
37
+ * Best-effort runner cleanup. POSIX process groups plus a PPID snapshot cover
38
+ * ordinary descendants and a still-attributable detached child. Node exposes
39
+ * no portable cgroup/job-object/subreaper primitive, so a daemon that already
40
+ * reparented cannot be proven dead here. Callers must keep the result a
41
+ * timeout and surface TIMEOUT_CLEANUP_WARNING rather than claiming containment.
42
+ */
43
+ export function terminateProcessTree(child) {
44
+ if (!child.pid) return;
45
+ if (process.platform === 'win32') {
46
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
47
+ try { child.kill('SIGKILL'); } catch {}
48
+ return;
49
+ }
50
+ // Freeze the main group before enumerating so it cannot fork between the
51
+ // snapshot and the kill. A deliberately detached descendant is outside the
52
+ // group but remains visible through PPID and is killed explicitly.
53
+ try { process.kill(-child.pid, 'SIGSTOP'); } catch {}
54
+ for (const pid of descendantPids(child.pid)) {
55
+ try { process.kill(pid, 'SIGKILL'); } catch {}
56
+ }
57
+ try { process.kill(-child.pid, 'SIGKILL'); } catch {}
58
+ try { child.kill('SIGKILL'); } catch {}
59
+ }
60
+
17
61
  /**
18
62
  * Split a runner command template into argv. Supports double and single
19
63
  * quotes; `{files}` expands to the test files (one argv entry each) and
@@ -94,7 +138,12 @@ export function checkRunner({ projectDir, pkg, bin, budgetMs = 60_000, env = pro
94
138
  return new Promise((resolve) => {
95
139
  let child;
96
140
  try {
97
- child = spawn(process.platform === 'win32' ? `${bin}.cmd` : bin, ['--version'], { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...env, CI: '1' } });
141
+ child = spawn(process.platform === 'win32' ? `${bin}.cmd` : bin, ['--version'], {
142
+ cwd: projectDir,
143
+ stdio: ['ignore', 'pipe', 'pipe'],
144
+ detached: process.platform !== 'win32',
145
+ env: { ...env, CI: '1' },
146
+ });
98
147
  } catch (e) {
99
148
  return resolve({ ok: false, message: e.message });
100
149
  }
@@ -103,7 +152,7 @@ export function checkRunner({ projectDir, pkg, bin, budgetMs = 60_000, env = pro
103
152
  child.on('error', () => resolve({ ok: false, message: `${pkg} is not installed in the project and \`${bin}\` is not on PATH (npm i -D ${pkg})` }));
104
153
  child.stdout.on('data', (d) => (out += d));
105
154
  child.stderr.on('data', (d) => (err += d));
106
- const timer = setTimeout(() => child.kill('SIGKILL'), budgetMs);
155
+ const timer = setTimeout(() => terminateProcessTree(child), budgetMs);
107
156
  child.on('close', (code) => {
108
157
  clearTimeout(timer);
109
158
  const version = out.trim().replace(/^vitest\//, '').replace(/^Version\s+/i, '').split('\n').pop();
@@ -183,7 +232,7 @@ export function runProcess({ projectDir, files, budgetMs = 120_000, command, com
183
232
  const started = Date.now();
184
233
 
185
234
  return new Promise((resolve) => {
186
- const child = spawn(cmd, args, { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, CI: '1', FORCE_COLOR: '0', ...extraEnv } });
235
+ const child = spawn(cmd, args, { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', env: { ...process.env, CI: '1', FORCE_COLOR: '0', ...extraEnv } });
187
236
  let stderr = '';
188
237
  // Node prints MODULE_TYPELESS_PACKAGE_JSON (and friends) for the PROBED
189
238
  // project's config, not for anything the fault did. Keep it out of the
@@ -193,7 +242,7 @@ export function runProcess({ projectDir, files, budgetMs = 120_000, command, com
193
242
  let killed = false;
194
243
  const timer = setTimeout(() => {
195
244
  killed = true;
196
- child.kill('SIGKILL');
245
+ terminateProcessTree(child);
197
246
  }, budgetMs);
198
247
 
199
248
  child.on('close', () => {
@@ -201,7 +250,7 @@ export function runProcess({ projectDir, files, budgetMs = 120_000, command, com
201
250
  const durationMs = Date.now() - started;
202
251
  let result;
203
252
  if (killed) {
204
- result = { run: { outcome: 'timeout', tests: { total: 0, passed: 0, failed: 0 }, assertionFailures: 0, durationMs }, timeouts: 1, loadMessage: `budget of ${budgetMs}ms exceeded`, failedTests: [] };
253
+ result = { run: { outcome: 'timeout', tests: { total: 0, passed: 0, failed: 0 }, assertionFailures: 0, durationMs }, timeouts: 1, loadMessage: `budget of ${budgetMs}ms exceeded; ${TIMEOUT_CLEANUP_WARNING}`, failedTests: [] };
205
254
  } else if (!existsSync(outFile)) {
206
255
  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: [] };
207
256
  } else {
@@ -1,9 +1,30 @@
1
1
  import { TEST_GLOBS, runProcess, checkRunner, runnerArgv, listTestFiles } from './shared.mjs';
2
+ import { createDiscoveryManifest, DiscoveryError, hashDiscoveryConfigs, normalizeDiscoveredFiles, runDiscoveryProcess } from './discovery.mjs';
2
3
 
3
4
  export const name = 'vitest';
4
5
  export const testGlobs = TEST_GLOBS;
5
6
  export const check = (opts) => checkRunner({ ...opts, pkg: 'vitest', bin: 'vitest' });
6
7
  export const tests = (projectDir) => listTestFiles(projectDir, testGlobs);
8
+ const DISCOVERY_CONFIG_FILES = [
9
+ ...['js', 'mjs', 'cjs', 'ts', 'mts', 'cts'].flatMap((ext) => [`vitest.config.${ext}`, `vite.config.${ext}`, `vitest.workspace.${ext}`]),
10
+ 'vitest.workspace.json',
11
+ ];
12
+ /** Ask Vitest itself to load config and enumerate the files it would collect. */
13
+ export const discoveryArgvFor = (projectDir) => [...runnerArgv(projectDir, 'vitest', 'vitest'), 'list', '--filesOnly', '--passWithNoTests'];
14
+
15
+ export function parseDiscoveryOutput(stdout) {
16
+ if (typeof stdout !== 'string') throw new TypeError('Vitest discovery output must be text');
17
+ return stdout.split(/\r?\n/).filter((line) => line.length > 0);
18
+ }
19
+
20
+ export async function discoverTests({ projectDir, version, timeoutMs, maxOutputBytes } = {}) {
21
+ const before = hashDiscoveryConfigs(projectDir, DISCOVERY_CONFIG_FILES);
22
+ const result = await runDiscoveryProcess({ projectDir, argv: discoveryArgvFor(projectDir), timeoutMs, maxOutputBytes });
23
+ const configFiles = hashDiscoveryConfigs(projectDir, DISCOVERY_CONFIG_FILES);
24
+ if (JSON.stringify(configFiles) !== JSON.stringify(before)) throw new DiscoveryError('Vitest discovery config changed while tests were being listed');
25
+ const files = normalizeDiscoveredFiles(projectDir, parseDiscoveryOutput(result.stdout));
26
+ return createDiscoveryManifest({ runner: name, version, files, configFiles });
27
+ }
7
28
  // --serial: one file at a time in one process, so a contended machine cannot turn a slow suite into a TIMEOUT verdict.
8
29
  /** The command line, as data. Exported so it is falsifiable without spawning anything. */
9
30
  export const argvFor = (projectDir, files, outFile, { serial = false } = {}) =>
@@ -3,9 +3,9 @@ import { basename, dirname, join, relative, resolve } from 'node:path';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { git, GitError, repoRoot, headSha } from '../git.mjs';
5
5
  import { createScratch, PreconditionError } from '../probe/worktree.mjs';
6
- import { selectRunner, RUNNERS } from '../probe/runners/index.mjs';
6
+ import { selectRunner, discoverRunnerManifest, RUNNERS, OWNED_RUNNERS, mergeRuns } from '../probe/runners/index.mjs';
7
7
  import { parseCommandTemplate } from '../probe/runners/shared.mjs';
8
- import { fileImports } from '../probe/rank.mjs';
8
+ import { fileImportRelation } from '../probe/rank.mjs';
9
9
  import { IS_PY_TEST, pyFileImports } from '../probe/pyimports.mjs';
10
10
  import { isPython } from '../probe/discover.mjs';
11
11
  import { labelDiff } from './label.mjs';
@@ -83,6 +83,7 @@ export async function replay({
83
83
  range,
84
84
  confirmRuns = 3,
85
85
  budgetMs = 120_000,
86
+ commandBudget,
86
87
  runnerCommand,
87
88
  runnerName = 'auto',
88
89
  nodeModules,
@@ -92,6 +93,7 @@ export async function replay({
92
93
  onProgress = () => {},
93
94
  onStage = () => {},
94
95
  }) {
96
+ commandBudget?.assertOpen();
95
97
  projectDir = realpathSync(resolve(projectDir));
96
98
  const root = repoRoot(projectDir);
97
99
  if (!headSha(root)) throw new PreconditionError('repository has no commits; there is nothing to replay');
@@ -109,17 +111,38 @@ export async function replay({
109
111
  let runnerVersion;
110
112
 
111
113
  for (const [i, fix] of selected.entries()) {
114
+ commandBudget?.assertOpen();
112
115
  onStage({ commit: fix.commit, i: i + 1, n: selected.length, stage: 'prepare' });
113
116
  const iso = createScratch({ repoRoot: root, projectDir, ref: fix.commit, scratchBase, nodeModules });
114
117
  try {
115
118
  const commandTemplate = runnerCommand ? parseCommandTemplate(runnerCommand) : undefined;
119
+ let universe;
116
120
  if (!commandTemplate) {
117
- const sel = await selectRunner({ projectDir: iso.projectDir, name: runnerName });
121
+ const budgetFor = () => commandBudget?.runBudget(budgetMs) ?? budgetMs;
122
+ const sel = await selectRunner({ projectDir: iso.projectDir, sourceDir: projectDir, name: runnerName, budgetMs: budgetFor(), budgetFor });
123
+ commandBudget?.assertOpen();
118
124
  if (sel.error) throw new PreconditionError(`test runner is not resolvable in the scratch worktree (${sel.error}). Pass --node-modules <path>.`);
119
125
  runnerUsed = sel.runner;
120
126
  runnerVersion = sel.version;
127
+ const manifests = new Map([[runnerUsed, sel.manifest]]);
128
+ // A pinned Python engine is already the Python per-file adapter. Adding
129
+ // the unpinned adapter again would let manifest precedence silently
130
+ // replace `--runner unittest` with pytest (or vice versa).
131
+ const primaryIsPython = ['python', 'pytest', 'unittest'].includes(runnerUsed.name);
132
+ const owned = OWNED_RUNNERS.filter((candidate) => candidate !== runnerUsed && !(candidate.name === 'python' && primaryIsPython));
133
+ for (const candidate of owned) {
134
+ const check = await candidate.check({ projectDir: iso.projectDir, sourceDir: projectDir, budgetMs: budgetFor(), budgetFor });
135
+ commandBudget?.assertOpen();
136
+ if (!check.ok) continue;
137
+ try {
138
+ manifests.set(candidate, await discoverRunnerManifest(candidate, check, iso.projectDir, budgetFor()));
139
+ } catch (error) {
140
+ throw new PreconditionError(`${candidate.name}: test discovery failed: ${error.message}`);
141
+ }
142
+ }
143
+ universe = buildReplayRunnerUniverse(runnerUsed, manifests);
121
144
  }
122
- const record = await replayOne({ fix, iso, root, projectDir, confirmRuns, budgetMs, commandTemplate, runner: runnerUsed, onStage, index: i + 1, total: selected.length });
145
+ const record = await replayOne({ fix, iso, root, projectDir, confirmRuns, budgetMs, commandBudget, commandTemplate, runner: runnerUsed, universe, onStage, index: i + 1, total: selected.length });
123
146
  records.push(record);
124
147
  onProgress(record);
125
148
  } finally {
@@ -127,6 +150,8 @@ export async function replay({
127
150
  }
128
151
  }
129
152
 
153
+ commandBudget?.assertOpen();
154
+
130
155
  return {
131
156
  schemaVersion: 1,
132
157
  tool: { name: 'testguard', version: toolVersion },
@@ -144,7 +169,47 @@ export async function replay({
144
169
  };
145
170
  }
146
171
 
147
- async function replayOne({ fix, iso, root, projectDir, confirmRuns, budgetMs, commandTemplate, runner, onStage, index, total }) {
172
+ /**
173
+ * Exact configured test universe for replay. The project runner may list a
174
+ * broad set that overlaps a per-file runner. A file listed by one owned
175
+ * runner belongs to it; primary+owned overlap therefore resolves to owned.
176
+ * Two owned runners listing the same file is ambiguous and fails closed.
177
+ * Filename heuristics are deliberately absent: native manifest membership is
178
+ * the evidence of which configured engine collects a file.
179
+ */
180
+ export function buildReplayRunnerUniverse(primary, manifests) {
181
+ const ownerByFile = new Map();
182
+ for (const file of manifests.get(primary)?.files ?? []) ownerByFile.set(file, primary);
183
+ for (const [owner, manifest] of manifests) {
184
+ if (owner === primary) continue;
185
+ for (const file of manifest.files) {
186
+ const previous = ownerByFile.get(file);
187
+ if (previous && previous !== primary && previous !== owner) {
188
+ throw new PreconditionError(`${file} was listed by both ${previous.name} and ${owner.name}; TestGuard cannot choose which configured runner owns it.`);
189
+ }
190
+ ownerByFile.set(file, owner);
191
+ }
192
+ }
193
+ return Object.freeze({
194
+ files: Object.freeze([...ownerByFile.keys()].sort()),
195
+ ownerOf(file) { return ownerByFile.get(file); },
196
+ });
197
+ }
198
+
199
+ /** Partition only files proved to belong to the frozen configured universe. */
200
+ export function partitionReplayDefenders(files, primary, universe) {
201
+ const groups = new Map([[primary, []]]);
202
+ for (const file of files) {
203
+ const owner = universe.ownerOf(file);
204
+ if (!owner) throw new PreconditionError(`${file} is not in any resolved runner's configured test universe`);
205
+ if (!groups.has(owner)) groups.set(owner, []);
206
+ groups.get(owner).push(file);
207
+ }
208
+ if (groups.get(primary).length === 0 && groups.size > 1) groups.delete(primary);
209
+ return groups;
210
+ }
211
+
212
+ async function replayOne({ fix, iso, root, projectDir, confirmRuns, budgetMs, commandBudget, commandTemplate, runner, universe, onStage, index, total }) {
148
213
  // git reports paths from the REPOSITORY root; the runner and every file
149
214
  // operation here work inside the scratch worktree, relative to the PROJECT
150
215
  // directory. Map once, and refuse anything that would land outside the
@@ -211,7 +276,7 @@ async function replayOne({ fix, iso, root, projectDir, confirmRuns, budgetMs, co
211
276
  // What remains that could possibly notice: the test files importing any
212
277
  // reverted source file. `nocover` means nothing does — worse than blind,
213
278
  // and a coverage report shows it as a red line you can ignore.
214
- const all = runner.tests(iso.projectDir).filter((t) => !fix.tests.some((ft) => rel(ft) === t));
279
+ const all = (universe?.files ?? runner.tests(iso.projectDir)).filter((t) => !fix.tests.some((ft) => rel(ft) === t));
215
280
  // Removing the fix's test removes the whole FILE, which on a project with
216
281
  // few, large test files takes pre-existing tests with it — tests that did
217
282
  // exist before the fix and might have caught the bug. When nothing is left,
@@ -227,20 +292,50 @@ async function replayOne({ fix, iso, root, projectDir, confirmRuns, budgetMs, co
227
292
  // JavaScript resolver matches nothing — `IMPORT_RE` wants a quoted
228
293
  // specifier and `from pkg.mod import x` has none — so `related` was always
229
294
  // empty for Python and EVERY verdict was `nocover`, however good the suite.
230
- const related = all.filter((t) => fix.source.some((sf) => {
231
- const target = rel(sf);
232
- return isPython(target)
233
- ? pyFileImports(iso.projectDir, join(iso.projectDir, t), target, t)
234
- : fileImports(iso.projectDir, join(iso.projectDir, t), target);
235
- }));
295
+ const related = [];
296
+ const indeterminate = [];
297
+ for (const test of all) {
298
+ let matched = false;
299
+ for (const sf of fix.source) {
300
+ const target = rel(sf);
301
+ if (isPython(target)) {
302
+ matched ||= test.endsWith('.py') && pyFileImports(iso.projectDir, join(iso.projectDir, test), target, test);
303
+ continue;
304
+ }
305
+ if (test.endsWith('.py')) continue;
306
+ const relation = fileImportRelation(iso.projectDir, join(iso.projectDir, test), target);
307
+ if (relation.status === 'matched') matched = true;
308
+ else if (relation.status === 'indeterminate') indeterminate.push({ test, target, reason: relation.reason });
309
+ }
310
+ if (matched) related.push(test);
311
+ }
312
+ if (indeterminate.length > 0) {
313
+ return { ...base, faultClass, verdict: 'unverifiable', reason: 'defender-discovery-indeterminate', ranTests: 0, runs: [{ outcome: 'error', durationMs: 0 }] };
314
+ }
236
315
  if (related.length === 0) {
237
316
  return { ...base, faultClass, verdict: 'nocover', reason: 'no-test-imports-the-reverted-source', ranTests: 0, runs: [{ outcome: 'error', durationMs: 0 }] };
238
317
  }
239
318
 
240
319
  const runs = [];
241
320
  for (let i = 0; i < confirmRuns; i++) {
321
+ commandBudget?.assertOpen();
242
322
  onStage({ commit: fix.commit, i: index, n: total, stage: `run ${i + 1}/${confirmRuns}` });
243
- const res = await runner.run({ projectDir: iso.projectDir, files: related, budgetMs, commandTemplate });
323
+ let res;
324
+ if (commandTemplate) {
325
+ // A custom command is one explicit execution boundary. Preserve that
326
+ // contract exactly; TestGuard cannot infer several native engines from
327
+ // an opaque user command.
328
+ res = await runner.run({ projectDir: iso.projectDir, sourceDir: projectDir, files: related, budgetMs: commandBudget?.runBudget(budgetMs) ?? budgetMs, commandTemplate });
329
+ } else {
330
+ const parts = [];
331
+ for (const [configuredRunner, files] of partitionReplayDefenders(related, runner, universe)) {
332
+ commandBudget?.assertOpen();
333
+ parts.push(await configuredRunner.run({ projectDir: iso.projectDir, sourceDir: projectDir, files, budgetMs: commandBudget?.runBudget(budgetMs) ?? budgetMs }));
334
+ commandBudget?.assertOpen();
335
+ }
336
+ res = mergeRuns(parts);
337
+ }
338
+ commandBudget?.assertOpen();
244
339
  runs.push(res.run);
245
340
  // A suite that cannot load says nothing about the bug.
246
341
  if (res.run.outcome === 'error') return { ...base, faultClass, verdict: 'unverifiable', reason: 'suite-failed-to-load', ranTests: related.length, runs };
@@ -162,6 +162,7 @@ export async function sweep({
162
162
  cap,
163
163
  confirmRuns = 3,
164
164
  budgetMs = 120_000,
165
+ commandBudget,
165
166
  runnerCommand,
166
167
  runnerName,
167
168
  nodeModules,
@@ -172,6 +173,7 @@ export async function sweep({
172
173
  onStage,
173
174
  onWarn = () => {},
174
175
  }) {
176
+ commandBudget?.assertOpen();
175
177
  const gate = computeChangedGate({ projectDir, ref, includeDirty, exclude, toolVersion, claimsPath, ignorePath });
176
178
 
177
179
  // ── What the sweep is pointed at. ──
@@ -208,6 +210,7 @@ export async function sweep({
208
210
  const skipped = [];
209
211
  const drafts = [];
210
212
  for (const file of targets) {
213
+ commandBudget?.assertOpen();
211
214
  let result;
212
215
  try {
213
216
  result = scaffoldFile({ projectDir, file, existingClaims, toolVersion });
@@ -256,6 +259,7 @@ export async function sweep({
256
259
  claims: { schemaVersion: 1, claims },
257
260
  confirmRuns,
258
261
  budgetMs,
262
+ commandBudget,
259
263
  mode: 'worktree',
260
264
  includeDirty,
261
265
  // A proposed fault has no declared defenders to be missing from, so the
@@ -288,6 +292,7 @@ export async function sweep({
288
292
  }
289
293
  const ordered = sortFindings(findings);
290
294
  const gating = gatingCount(ordered);
295
+ commandBudget?.assertOpen();
291
296
  return {
292
297
  schemaVersion: 1,
293
298
  tool: { name: 'testguard', version: toolVersion },