testguard-cli 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +291 -0
- package/README.md +321 -27
- package/package.json +1 -1
- package/spec/GATE-SEMANTICS.md +109 -1
- package/spec/README.md +4 -2
- package/spec/lib/validate.mjs +75 -1
- package/spec/schemas/baseline.schema.json +8 -0
- package/spec/schemas/brief.schema.json +11 -1
- package/spec/schemas/common.schema.json +32 -1
- package/spec/schemas/evidence.schema.json +374 -63
- package/spec/schemas/gate.schema.json +52 -0
- package/spec/schemas/replay.schema.json +85 -0
- package/spec/schemas/status.schema.json +269 -45
- package/src/admit/admit.mjs +17 -0
- package/src/baseline/baseline.mjs +31 -2
- package/src/brief/brief.mjs +96 -8
- package/src/claims/removed.mjs +161 -0
- package/src/cli.mjs +69 -12
- package/src/commands/admit.mjs +116 -0
- package/src/commands/baseline.mjs +17 -3
- package/src/commands/brief.mjs +26 -12
- package/src/commands/claims.mjs +35 -6
- package/src/commands/gate.mjs +47 -0
- package/src/commands/init.mjs +19 -6
- package/src/commands/mcp.mjs +12 -0
- package/src/commands/probe.mjs +12 -3
- package/src/commands/replay.mjs +63 -0
- package/src/commands/status.mjs +4 -1
- package/src/gate/changed.mjs +222 -0
- package/src/git.mjs +5 -0
- package/src/init/init.mjs +182 -29
- package/src/init/templates/SKILL.md +21 -3
- package/src/mcp/server.mjs +104 -0
- package/src/mcp/tools.mjs +114 -0
- package/src/probe/contention.mjs +60 -0
- package/src/probe/discover.mjs +12 -3
- package/src/probe/independence.mjs +59 -0
- package/src/probe/mocks.mjs +120 -0
- package/src/probe/probe.mjs +126 -18
- package/src/probe/rank.mjs +73 -15
- package/src/probe/runners/index.mjs +49 -5
- package/src/probe/runners/jest.mjs +3 -3
- package/src/probe/runners/playwright.mjs +133 -0
- package/src/probe/runners/shared.mjs +70 -13
- package/src/probe/runners/vitest.mjs +4 -3
- package/src/render.mjs +2 -2
- package/src/replay/label.mjs +64 -0
- package/src/replay/replay.mjs +309 -0
- package/src/scaffold/producers.mjs +217 -6
- package/src/scaffold/scaffold.mjs +9 -3
- package/src/status/status.mjs +88 -7
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { TEST_GLOBS,
|
|
1
|
+
import { TEST_GLOBS, runProcess, checkRunner, runnerArgv, listTestFiles } from './shared.mjs';
|
|
2
2
|
|
|
3
3
|
export const name = 'jest';
|
|
4
4
|
// jest's default testMatch also collects anything under __tests__/.
|
|
5
5
|
export const testGlobs = [...TEST_GLOBS, '**/__tests__/**/*.js', '**/__tests__/**/*.mjs', '**/__tests__/**/*.cjs', '**/__tests__/**/*.ts', '**/__tests__/**/*.tsx', '**/__tests__/**/*.jsx'];
|
|
6
|
-
export const check = (opts) =>
|
|
6
|
+
export const check = (opts) => checkRunner({ ...opts, pkg: 'jest', bin: 'jest' });
|
|
7
7
|
export const tests = (projectDir) => listTestFiles(projectDir, testGlobs);
|
|
8
8
|
// --runTestsByPath: positionals are exact paths, not regexes — a path with `+` or `(` would otherwise silently match nothing.
|
|
9
|
-
export const run = (opts) => runProcess({ ...opts, argv: (files, outFile) => [
|
|
9
|
+
export const run = (opts) => runProcess({ ...opts, argv: (files, outFile) => [...runnerArgv(opts.projectDir, 'jest', 'jest'), '--ci', '--json', `--outputFile=${outFile}`, ...(opts.serial ? ['--runInBand'] : []), '--runTestsByPath', ...files] });
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { npx, runProcess, listTestFiles, firstInformativeLine } from './shared.mjs';
|
|
5
|
+
import { globToRegExp } from '../../util/glob.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Playwright runner. Selected **per file**, not per project: a defender that
|
|
9
|
+
* lives under Playwright's `testDir` runs here, everything else runs under
|
|
10
|
+
* the project runner (vitest/jest). One claim may list both kinds. Selected
|
|
11
|
+
* for the whole project only with `--runner playwright`.
|
|
12
|
+
*
|
|
13
|
+
* Report protocol: `--reporter=json` with the JSON written to a file we name
|
|
14
|
+
* (PLAYWRIGHT_JSON_OUTPUT_FILE; the older PLAYWRIGHT_JSON_OUTPUT_NAME is set
|
|
15
|
+
* too), never parsed from stdout. Playwright's test statuses map onto the
|
|
16
|
+
* spec's testRun as follows, and the mapping is the point of the adapter:
|
|
17
|
+
*
|
|
18
|
+
* expected → passed
|
|
19
|
+
* unexpected/failed → failed, counts toward assertionFailures (can kill)
|
|
20
|
+
* unexpected/timedOut → failed, counts toward timeouts (never a kill)
|
|
21
|
+
* flaky → failed on an attempt and passed on retry: the run
|
|
22
|
+
* is NOT green. Counted as failed with neither an
|
|
23
|
+
* assertion failure nor a timeout, so a green-looking
|
|
24
|
+
* exit code becomes FLAKY-DEFENDER through the
|
|
25
|
+
* ordinary N-run rules, exactly as GATE-SEMANTICS says.
|
|
26
|
+
* skipped → neither passed nor failed
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const name = 'playwright';
|
|
30
|
+
|
|
31
|
+
const CONFIG_FILES = ['playwright.config.ts', 'playwright.config.mts', 'playwright.config.js', 'playwright.config.mjs', 'playwright.config.cjs'];
|
|
32
|
+
const SPEC_GLOBS = ['**/*.spec.js', '**/*.spec.mjs', '**/*.spec.cjs', '**/*.spec.ts', '**/*.spec.mts', '**/*.spec.tsx', '**/*.spec.jsx',
|
|
33
|
+
'**/*.test.js', '**/*.test.mjs', '**/*.test.cjs', '**/*.test.ts', '**/*.test.mts', '**/*.test.tsx', '**/*.test.jsx'];
|
|
34
|
+
|
|
35
|
+
const configCache = new Map();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The project's Playwright config, read as text (it may be TypeScript, so it
|
|
39
|
+
* is never imported): `{ path, testDir }` or null when there is none.
|
|
40
|
+
* `testDir` defaults to the config's own directory, as Playwright does.
|
|
41
|
+
*/
|
|
42
|
+
export function loadConfig(projectDir) {
|
|
43
|
+
if (configCache.has(projectDir)) return configCache.get(projectDir);
|
|
44
|
+
let out = null;
|
|
45
|
+
for (const f of CONFIG_FILES) {
|
|
46
|
+
const path = join(projectDir, f);
|
|
47
|
+
if (!existsSync(path)) continue;
|
|
48
|
+
const text = readFileSync(path, 'utf8');
|
|
49
|
+
const m = /\btestDir\s*:\s*(['"`])([^'"`]+)\1/.exec(text);
|
|
50
|
+
const testDir = relative(projectDir, resolve(projectDir, m ? m[2] : '.')).split(sep).join('/') || '.';
|
|
51
|
+
out = { path: f, testDir };
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
configCache.set(projectDir, out);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Clear the config cache (tests create and delete projects at the same paths). */
|
|
59
|
+
export const resetConfigCache = () => configCache.clear();
|
|
60
|
+
|
|
61
|
+
export const testGlobs = SPEC_GLOBS;
|
|
62
|
+
const SPEC_RES = SPEC_GLOBS.map(globToRegExp);
|
|
63
|
+
|
|
64
|
+
/** Does this test file belong to Playwright: is there a config, and does the file live under its testDir? */
|
|
65
|
+
export function owns(projectDir, file) {
|
|
66
|
+
const cfg = loadConfig(projectDir);
|
|
67
|
+
if (!cfg) return false;
|
|
68
|
+
if (!SPEC_RES.some((re) => re.test(file))) return false;
|
|
69
|
+
return cfg.testDir === '.' ? true : file.startsWith(cfg.testDir + '/');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolvable = a config exists AND `@playwright/test` resolves from the
|
|
74
|
+
* project. Deliberately not `npx --no-install playwright --version`: the npx
|
|
75
|
+
* cache and a Python `playwright` on PATH both answer that from a project
|
|
76
|
+
* that has no Playwright at all, and `playwright test` would then run
|
|
77
|
+
* against nothing. The package `playwright test` needs is the precondition.
|
|
78
|
+
*/
|
|
79
|
+
export async function check({ projectDir }) {
|
|
80
|
+
const cfg = loadConfig(projectDir);
|
|
81
|
+
if (!cfg) return { ok: false, message: 'no playwright.config.* in the project' };
|
|
82
|
+
try {
|
|
83
|
+
const pkg = JSON.parse(readFileSync(createRequire(join(projectDir, 'noop.js')).resolve('@playwright/test/package.json'), 'utf8'));
|
|
84
|
+
return { ok: true, version: pkg.version };
|
|
85
|
+
} catch {
|
|
86
|
+
return { ok: false, message: '@playwright/test is not installed in the project (npm i -D @playwright/test)' };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const tests = (projectDir) => listTestFiles(projectDir, SPEC_GLOBS).filter((f) => owns(projectDir, f));
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Playwright's JSON report → the spec's testRun. Pure; exported for the
|
|
94
|
+
* sample-driven tests. `durationMs` is the wall clock measured by the runner.
|
|
95
|
+
*/
|
|
96
|
+
export function parseReport(report, durationMs) {
|
|
97
|
+
const tests = [];
|
|
98
|
+
const walk = (suite) => {
|
|
99
|
+
for (const spec of suite.specs ?? []) {
|
|
100
|
+
for (const t of spec.tests ?? []) {
|
|
101
|
+
const results = t.results ?? [];
|
|
102
|
+
const last = results[results.length - 1];
|
|
103
|
+
tests.push({ id: `${suite.file ?? spec.file ?? ''}::${spec.title}`, status: t.status, finalResult: last?.status, error: results.find((r) => r.error)?.error?.message });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const s of suite.suites ?? []) walk(s);
|
|
107
|
+
};
|
|
108
|
+
for (const s of report.suites ?? []) walk(s);
|
|
109
|
+
|
|
110
|
+
const expected = tests.filter((t) => t.status === 'expected').length;
|
|
111
|
+
const unexpected = tests.filter((t) => t.status === 'unexpected');
|
|
112
|
+
const flaky = tests.filter((t) => t.status === 'flaky').length;
|
|
113
|
+
const timeouts = unexpected.filter((t) => t.finalResult === 'timedOut' || t.finalResult === 'interrupted').length;
|
|
114
|
+
const assertionFailures = unexpected.length - timeouts;
|
|
115
|
+
const failedTests = [...unexpected, ...tests.filter((t) => t.status === 'flaky')].map((t) => t.id);
|
|
116
|
+
const total = tests.length;
|
|
117
|
+
const summary = { total, passed: expected, failed: unexpected.length + flaky };
|
|
118
|
+
|
|
119
|
+
const globalErrors = report.errors ?? [];
|
|
120
|
+
let outcome;
|
|
121
|
+
if (total === 0) outcome = 'error';
|
|
122
|
+
else if (unexpected.length > 0 || flaky > 0) outcome = 'fail';
|
|
123
|
+
else outcome = 'pass';
|
|
124
|
+
const loadMessage = outcome === 'error'
|
|
125
|
+
? (globalErrors.length ? firstInformativeLine(globalErrors.map((e) => e.message ?? '').join('\n')) : 'playwright found no tests')
|
|
126
|
+
: undefined;
|
|
127
|
+
return { run: { outcome, tests: summary, assertionFailures, durationMs }, timeouts, loadMessage, failedTests, flaky };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const run = (opts) => {
|
|
131
|
+
const env = { PLAYWRIGHT_JSON_OUTPUT_FILE: '{out}', PLAYWRIGHT_JSON_OUTPUT_NAME: '{out}' };
|
|
132
|
+
return runProcess({ ...opts, env, parse: parseReport, argv: (files) => [npx, 'playwright', 'test', '--reporter=json', ...(opts.serial ? ['--workers=1'] : []), ...files] });
|
|
133
|
+
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
3
5
|
import { tmpdir } from 'node:os';
|
|
4
6
|
import { join } from 'node:path';
|
|
5
7
|
import { randomBytes } from 'node:crypto';
|
|
@@ -9,6 +11,9 @@ export const TEST_GLOBS = ['**/*.test.js', '**/*.test.mjs', '**/*.test.cjs', '**
|
|
|
9
11
|
'**/*.spec.js', '**/*.spec.mjs', '**/*.spec.cjs', '**/*.spec.ts', '**/*.spec.mts', '**/*.spec.tsx', '**/*.spec.jsx'];
|
|
10
12
|
export const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
11
13
|
|
|
14
|
+
/** Node's own advisory warnings about the probed project's configuration; never about the fault. */
|
|
15
|
+
export const NODE_NOISE = /MODULE_TYPELESS_PACKAGE_JSON|ExperimentalWarning|--trace-warnings|Reparsing as ES module|To eliminate this warning/;
|
|
16
|
+
|
|
12
17
|
/**
|
|
13
18
|
* Split a runner command template into argv. Supports double and single
|
|
14
19
|
* quotes; `{files}` expands to the test files (one argv entry each) and
|
|
@@ -43,28 +48,75 @@ export function parseCommandTemplate(template) {
|
|
|
43
48
|
/** `{files}` must be a word of its own (one argv entry per file); `{out}` may be embedded, e.g. `--outputFile={out}`. */
|
|
44
49
|
export const expandCommand = (words, files, outFile) => words.flatMap((w) => (w === '{files}' ? files : [w.replaceAll('{out}', outFile)]));
|
|
45
50
|
|
|
51
|
+
const resolvedRunners = new Map(); // `${projectDir}\0${pkg}` → { argv, version, source }
|
|
52
|
+
|
|
46
53
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
54
|
+
* Where does the runner come from? The PROJECT first: the runner package
|
|
55
|
+
* resolved from `projectDir` (its version, its `bin` script run with this
|
|
56
|
+
* node). Only when no package resolves, an executable on PATH — a global
|
|
57
|
+
* runner is a legitimate setup for some monorepos, and the evidence says so
|
|
58
|
+
* (`source: "path"`). Never `npx`: the npx cache and a same-named executable
|
|
59
|
+
* (a Python `playwright` shim, say) both answer `npx --no-install <bin>
|
|
60
|
+
* --version` from a project that has no such runner at all (#41).
|
|
50
61
|
*/
|
|
51
|
-
export function
|
|
62
|
+
export function resolveRunner({ projectDir, pkg, bin }) {
|
|
63
|
+
const key = `${projectDir}\0${pkg}`;
|
|
64
|
+
if (resolvedRunners.has(key)) return resolvedRunners.get(key);
|
|
65
|
+
let result = null;
|
|
66
|
+
try {
|
|
67
|
+
const req = createRequire(join(projectDir, 'noop.js'));
|
|
68
|
+
const pkgJsonPath = req.resolve(`${pkg}/package.json`);
|
|
69
|
+
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
|
|
70
|
+
const binField = typeof pkgJson.bin === 'string' ? pkgJson.bin : pkgJson.bin?.[bin];
|
|
71
|
+
if (binField) result = { argv: [process.execPath, join(dirname(pkgJsonPath), binField)], version: pkgJson.version, source: 'project' };
|
|
72
|
+
} catch {}
|
|
73
|
+
resolvedRunners.set(key, result);
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Forget cached resolutions (tests create and delete projects at the same paths). */
|
|
78
|
+
export const resetRunnerCache = () => resolvedRunners.clear();
|
|
79
|
+
|
|
80
|
+
/** The argv prefix that runs `pkg`'s `bin` for this project: the resolved local script, else the PATH executable. */
|
|
81
|
+
export function runnerArgv(projectDir, pkg, bin) {
|
|
82
|
+
return resolveRunner({ projectDir, pkg, bin })?.argv ?? [bin];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Can the runner be resolved at all from this directory? Run before any
|
|
87
|
+
* verdict is attempted: an unresolvable runner is a precondition failure,
|
|
88
|
+
* never a statement about the tests. Project package first; PATH second;
|
|
89
|
+
* npx never.
|
|
90
|
+
*/
|
|
91
|
+
export function checkRunner({ projectDir, pkg, bin, budgetMs = 60_000, env = process.env }) {
|
|
92
|
+
const local = resolveRunner({ projectDir, pkg, bin });
|
|
93
|
+
if (local) return Promise.resolve({ ok: true, version: local.version, source: 'project' });
|
|
52
94
|
return new Promise((resolve) => {
|
|
53
|
-
|
|
95
|
+
let child;
|
|
96
|
+
try {
|
|
97
|
+
child = spawn(process.platform === 'win32' ? `${bin}.cmd` : bin, ['--version'], { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...env, CI: '1' } });
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return resolve({ ok: false, message: e.message });
|
|
100
|
+
}
|
|
54
101
|
let out = '';
|
|
55
102
|
let err = '';
|
|
56
|
-
child.on('error', (
|
|
103
|
+
child.on('error', () => resolve({ ok: false, message: `${pkg} is not installed in the project and \`${bin}\` is not on PATH (npm i -D ${pkg})` }));
|
|
57
104
|
child.stdout.on('data', (d) => (out += d));
|
|
58
105
|
child.stderr.on('data', (d) => (err += d));
|
|
59
106
|
const timer = setTimeout(() => child.kill('SIGKILL'), budgetMs);
|
|
60
107
|
child.on('close', (code) => {
|
|
61
108
|
clearTimeout(timer);
|
|
62
|
-
const version = out.trim().replace(/^vitest\//, '').split('\n').pop();
|
|
63
|
-
resolve(code === 0 && version
|
|
109
|
+
const version = out.trim().replace(/^vitest\//, '').replace(/^Version\s+/i, '').split('\n').pop();
|
|
110
|
+
resolve(code === 0 && version
|
|
111
|
+
? { ok: true, version, source: 'path' }
|
|
112
|
+
: { ok: false, message: `${pkg} is not installed in the project; \`${bin}\` on PATH answered: ${(err || out).trim().split('\n').filter(Boolean).slice(-1)[0] ?? `exit ${code}`}` });
|
|
64
113
|
});
|
|
65
114
|
});
|
|
66
115
|
}
|
|
67
116
|
|
|
117
|
+
/** @deprecated name kept for older callers; resolves the project package first, never npx. */
|
|
118
|
+
export const checkBinary = ({ bin, ...rest }) => checkRunner({ ...rest, pkg: bin, bin });
|
|
119
|
+
|
|
68
120
|
/** Defender globs → existing files. Empty result is the `nocover` signal. */
|
|
69
121
|
export const resolveDefenders = (projectDir, globs) => matchGlobs(projectDir, globs ?? []);
|
|
70
122
|
|
|
@@ -78,7 +130,7 @@ export const listTestFiles = (projectDir, globs = TEST_GLOBS) => matchGlobs(proj
|
|
|
78
130
|
* can kill a fault. A suite that fails to load, or a test that times out, is
|
|
79
131
|
* not evidence that the suite defends the claim.
|
|
80
132
|
*/
|
|
81
|
-
function firstInformativeLine(message) {
|
|
133
|
+
export function firstInformativeLine(message) {
|
|
82
134
|
const lines = message.split('\n').map((l) => l.replace(/\x1b\[[0-9;]*m/g, '').trim()).filter(Boolean);
|
|
83
135
|
return lines.find((l) => /error|syntax|unexpected|expected .+ but found|cannot find|failed to parse|transform failed/i.test(l) && !/^●/.test(l)) ?? lines[0] ?? '';
|
|
84
136
|
}
|
|
@@ -113,16 +165,21 @@ export function parseReport(report, durationMs) {
|
|
|
113
165
|
* runner's command line; `command` (tests) or `commandTemplate` (--runner-cmd)
|
|
114
166
|
* override it.
|
|
115
167
|
*/
|
|
116
|
-
export function runProcess({ projectDir, files, budgetMs = 120_000, command, commandTemplate, argv }) {
|
|
168
|
+
export function runProcess({ projectDir, files, budgetMs = 120_000, command, commandTemplate, argv, env = {}, parse = parseReport }) {
|
|
117
169
|
const outFile = join(tmpdir(), `testguard-run-${randomBytes(6).toString('hex')}.json`);
|
|
118
170
|
const [cmd, ...args] = command
|
|
119
171
|
?? (commandTemplate ? expandCommand(commandTemplate, files, outFile) : argv(files, outFile));
|
|
172
|
+
// A runner that names its report through the environment (Playwright) gets `{out}` substituted there too.
|
|
173
|
+
const extraEnv = Object.fromEntries(Object.entries(env).map(([k, v]) => [k, String(v).replaceAll('{out}', outFile)]));
|
|
120
174
|
const started = Date.now();
|
|
121
175
|
|
|
122
176
|
return new Promise((resolve) => {
|
|
123
|
-
const child = spawn(cmd, args, { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, CI: '1', FORCE_COLOR: '0' } });
|
|
177
|
+
const child = spawn(cmd, args, { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, CI: '1', FORCE_COLOR: '0', ...extraEnv } });
|
|
124
178
|
let stderr = '';
|
|
125
|
-
|
|
179
|
+
// Node prints MODULE_TYPELESS_PACKAGE_JSON (and friends) for the PROBED
|
|
180
|
+
// project's config, not for anything the fault did. Keep it out of the
|
|
181
|
+
// stream and out of `loadMessage`, which names the cause of a load error.
|
|
182
|
+
child.stderr.on('data', (d) => (stderr += String(d).split('\n').filter((l) => !NODE_NOISE.test(l)).join('\n')));
|
|
126
183
|
child.on('error', (e) => (stderr += e.message));
|
|
127
184
|
let killed = false;
|
|
128
185
|
const timer = setTimeout(() => {
|
|
@@ -140,7 +197,7 @@ export function runProcess({ projectDir, files, budgetMs = 120_000, command, com
|
|
|
140
197
|
result = { run: { outcome: 'error', tests: { total: 0, passed: 0, failed: 0 }, assertionFailures: 0, durationMs }, timeouts: 0, loadMessage: stderr.trim().split('\n').filter(Boolean).slice(-1)[0] ?? 'runner produced no report', failedTests: [] };
|
|
141
198
|
} else {
|
|
142
199
|
try {
|
|
143
|
-
result =
|
|
200
|
+
result = parse(JSON.parse(readFileSync(outFile, 'utf8')), durationMs);
|
|
144
201
|
} catch (e) {
|
|
145
202
|
result = { run: { outcome: 'error', tests: { total: 0, passed: 0, failed: 0 }, assertionFailures: 0, durationMs }, timeouts: 0, loadMessage: `unreadable report: ${e.message}`, failedTests: [] };
|
|
146
203
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { TEST_GLOBS,
|
|
1
|
+
import { TEST_GLOBS, runProcess, checkRunner, runnerArgv, listTestFiles } from './shared.mjs';
|
|
2
2
|
|
|
3
3
|
export const name = 'vitest';
|
|
4
4
|
export const testGlobs = TEST_GLOBS;
|
|
5
|
-
export const check = (opts) =>
|
|
5
|
+
export const check = (opts) => checkRunner({ ...opts, pkg: 'vitest', bin: 'vitest' });
|
|
6
6
|
export const tests = (projectDir) => listTestFiles(projectDir, testGlobs);
|
|
7
|
-
|
|
7
|
+
// --serial: one file at a time in one process, so a contended machine cannot turn a slow suite into a TIMEOUT verdict.
|
|
8
|
+
export const run = (opts) => runProcess({ ...opts, argv: (files, outFile) => [...runnerArgv(opts.projectDir, 'vitest', 'vitest'), 'run', ...files, '--reporter=json', `--outputFile=${outFile}`, ...(opts.serial ? ['--no-file-parallelism'] : [])] });
|
package/src/render.mjs
CHANGED
|
@@ -5,7 +5,7 @@ export const formatVerdict = (v, provisional = false) => (v === 'killed' ? 'kill
|
|
|
5
5
|
|
|
6
6
|
export const PROVISIONAL_WARNING = (n) => `PROVISIONAL — confirmRuns ${n} (< 3): nothing below is confirmed. Verdicts carry a "?"; this evidence cannot be frozen into a baseline. Re-run with --confirm 3 before trusting it.`;
|
|
7
7
|
|
|
8
|
-
export function renderRecord(r, { provisional = false } = {}) {
|
|
8
|
+
export function renderRecord(r, { provisional = false, showDiscovered = true } = {}) {
|
|
9
9
|
const head = `${formatVerdict(r.verdict, provisional).padEnd(15)} ${r.claim.id}/${r.subject.id}`.padEnd(38);
|
|
10
10
|
let why = '';
|
|
11
11
|
if (r.detail.reason === 'killed-by-undeclared-tests' && r.detail.undeclaredKillers?.length) {
|
|
@@ -16,7 +16,7 @@ export function renderRecord(r, { provisional = false } = {}) {
|
|
|
16
16
|
} else if (r.detail.reason) {
|
|
17
17
|
why = ` [${r.detail.reason}]`;
|
|
18
18
|
}
|
|
19
|
-
if (r.defenders.discovered) why += ' (defenders discovered by import)';
|
|
19
|
+
if (r.defenders.discovered && showDiscovered) why += ' (defenders discovered by import)';
|
|
20
20
|
return `${head} ${r.claim.severity.padEnd(8)} ${r.subject.file} ${r.subject.description}${why}`;
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which injected-fault class does a real fix diff most resemble?
|
|
3
|
+
*
|
|
4
|
+
* This is the join key between ground truth and the fault model: a replayed
|
|
5
|
+
* bug is a real defect a human confirmed, so labelling it by fault class is
|
|
6
|
+
* what lets P(a surviving fault of this class is a real bug) be computed.
|
|
7
|
+
* Without it, a mutation score is a number nobody can interpret.
|
|
8
|
+
*
|
|
9
|
+
* Deterministic and shape-based, in the same spirit as the scaffold
|
|
10
|
+
* producers read in reverse: a fix ADDS what the bug lacked, so the lines a
|
|
11
|
+
* fix adds are the lines a fault would delete. No LLM, and `other` whenever
|
|
12
|
+
* the shape is not one the model has — an honest gap is worth more than a
|
|
13
|
+
* confident wrong label.
|
|
14
|
+
*/
|
|
15
|
+
const GUARD_RE = /^\s*if\s*\(.*\)\s*\{?\s*$/;
|
|
16
|
+
const RETURN_RE = /^\s*return\s+/;
|
|
17
|
+
const CALL_RE = /^\s*(?:await\s+)?(?:[\w.]*\.)?(?:verify|validate|check|assert|authorize|authorise|ensure|require)\w*\s*\(/i;
|
|
18
|
+
const FIELD_RE = /^\s*['"`]?[\w$]+['"`]?\s*:\s*.+,\s*$/;
|
|
19
|
+
const LITERAL_RE = /\b(?:httpOnly|secure|sameSite|rounds|cost|ttl|timeout|tolerance|window|limit|maxAge|expiresIn)\b\s*[:=]/i;
|
|
20
|
+
const ASSIGN_RE = /^\s*(?:const |let |var )?[\w.[\]$]+\s*=\s*[^=]/;
|
|
21
|
+
|
|
22
|
+
/** Most specific first; a tie between equally specific shapes is not a label. */
|
|
23
|
+
const SPECIFICITY = ['literal-changed', 'call-removed', 'exception-swallowed', 'field-dropped', 'guard-removed', 'return-altered', 'statement-deleted'];
|
|
24
|
+
|
|
25
|
+
function kind(line) {
|
|
26
|
+
if (LITERAL_RE.test(line)) return 'literal-changed';
|
|
27
|
+
if (/^\s*(?:\}\s*)?catch\b/.test(line) || /^\s*throw\b/.test(line)) return 'exception-swallowed';
|
|
28
|
+
if (GUARD_RE.test(line)) return 'guard-removed';
|
|
29
|
+
if (RETURN_RE.test(line)) return 'return-altered';
|
|
30
|
+
if (CALL_RE.test(line)) return 'call-removed';
|
|
31
|
+
if (FIELD_RE.test(line)) return 'field-dropped';
|
|
32
|
+
if (ASSIGN_RE.test(line)) return 'statement-deleted';
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Label a unified diff of the fix's SOURCE changes (never its tests).
|
|
38
|
+
*
|
|
39
|
+
* Added lines carry the signal at double weight: the fix put back the guard,
|
|
40
|
+
* the field or the call that was missing. Removed lines are read too, for the
|
|
41
|
+
* fixes that delete a wrong condition rather than add a right one.
|
|
42
|
+
*/
|
|
43
|
+
export function labelDiff(diff) {
|
|
44
|
+
const added = [];
|
|
45
|
+
const removed = [];
|
|
46
|
+
for (const raw of diff.split('\n')) {
|
|
47
|
+
if (raw.startsWith('+++') || raw.startsWith('---')) continue;
|
|
48
|
+
if (raw.startsWith('+')) added.push(raw.slice(1));
|
|
49
|
+
else if (raw.startsWith('-')) removed.push(raw.slice(1));
|
|
50
|
+
}
|
|
51
|
+
const tally = new Map();
|
|
52
|
+
const add = (k, weight) => {
|
|
53
|
+
if (k) tally.set(k, (tally.get(k) ?? 0) + weight);
|
|
54
|
+
};
|
|
55
|
+
for (const l of added) add(kind(l), 2);
|
|
56
|
+
for (const l of removed) add(kind(l), 1);
|
|
57
|
+
if (tally.size === 0) return { faultClass: 'other', confident: false };
|
|
58
|
+
|
|
59
|
+
const ranked = [...tally].sort((a, b) => b[1] - a[1] || SPECIFICITY.indexOf(a[0]) - SPECIFICITY.indexOf(b[0]));
|
|
60
|
+
const [top, topWeight] = ranked[0];
|
|
61
|
+
const tiedSameSpecificity = ranked.length > 1 && ranked[1][1] === topWeight && SPECIFICITY.indexOf(ranked[1][0]) === SPECIFICITY.indexOf(top);
|
|
62
|
+
if (tiedSameSpecificity) return { faultClass: 'other', confident: false };
|
|
63
|
+
return { faultClass: top, confident: topWeight >= 2 };
|
|
64
|
+
}
|