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.
- package/CHANGELOG.md +46 -0
- package/README.md +16 -4
- package/package.json +1 -1
- package/spec/GATE-SEMANTICS.md +65 -3
- package/spec/README.md +7 -0
- package/spec/schemas/evidence.schema.json +14 -0
- package/src/cli.mjs +4 -1
- package/src/command-budget.mjs +52 -0
- package/src/commands/probe.mjs +9 -0
- package/src/commands/replay.mjs +14 -1
- package/src/commands/sweep.mjs +15 -6
- package/src/git.mjs +4 -2
- package/src/probe/attribution.mjs +2 -0
- package/src/probe/discover.mjs +44 -8
- package/src/probe/imports.mjs +785 -0
- package/src/probe/mocks.mjs +13 -4
- package/src/probe/probe.mjs +176 -19
- package/src/probe/rank.mjs +28 -5
- package/src/probe/runners/discovery.mjs +634 -0
- package/src/probe/runners/index.mjs +51 -19
- package/src/probe/runners/jest.mjs +32 -1
- package/src/probe/runners/playwright.mjs +58 -2
- package/src/probe/runners/python.mjs +7 -7
- package/src/probe/runners/shared.mjs +55 -6
- package/src/probe/runners/vitest.mjs +21 -0
- package/src/replay/replay.mjs +108 -13
- package/src/sweep/sweep.mjs +5 -0
|
@@ -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
|
|
34
|
-
* runner
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
50
|
-
//
|
|
51
|
-
//
|
|
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
|
|
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
|
-
|
|
57
|
-
let
|
|
58
|
-
try {
|
|
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
|
-
|
|
61
|
-
messages.push(`${r.name}: resolved but matched no test files`);
|
|
96
|
+
messages.push(empty);
|
|
62
97
|
}
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
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'], {
|
|
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
|
|
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
|
|
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 } = {}) =>
|
package/src/replay/replay.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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 =
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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 };
|
package/src/sweep/sweep.mjs
CHANGED
|
@@ -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 },
|