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.
Files changed (51) hide show
  1. package/CHANGELOG.md +291 -0
  2. package/README.md +321 -27
  3. package/package.json +1 -1
  4. package/spec/GATE-SEMANTICS.md +109 -1
  5. package/spec/README.md +4 -2
  6. package/spec/lib/validate.mjs +75 -1
  7. package/spec/schemas/baseline.schema.json +8 -0
  8. package/spec/schemas/brief.schema.json +11 -1
  9. package/spec/schemas/common.schema.json +32 -1
  10. package/spec/schemas/evidence.schema.json +374 -63
  11. package/spec/schemas/gate.schema.json +52 -0
  12. package/spec/schemas/replay.schema.json +85 -0
  13. package/spec/schemas/status.schema.json +269 -45
  14. package/src/admit/admit.mjs +17 -0
  15. package/src/baseline/baseline.mjs +31 -2
  16. package/src/brief/brief.mjs +96 -8
  17. package/src/claims/removed.mjs +161 -0
  18. package/src/cli.mjs +69 -12
  19. package/src/commands/admit.mjs +116 -0
  20. package/src/commands/baseline.mjs +17 -3
  21. package/src/commands/brief.mjs +26 -12
  22. package/src/commands/claims.mjs +35 -6
  23. package/src/commands/gate.mjs +47 -0
  24. package/src/commands/init.mjs +19 -6
  25. package/src/commands/mcp.mjs +12 -0
  26. package/src/commands/probe.mjs +12 -3
  27. package/src/commands/replay.mjs +63 -0
  28. package/src/commands/status.mjs +4 -1
  29. package/src/gate/changed.mjs +222 -0
  30. package/src/git.mjs +5 -0
  31. package/src/init/init.mjs +182 -29
  32. package/src/init/templates/SKILL.md +21 -3
  33. package/src/mcp/server.mjs +104 -0
  34. package/src/mcp/tools.mjs +114 -0
  35. package/src/probe/contention.mjs +60 -0
  36. package/src/probe/discover.mjs +12 -3
  37. package/src/probe/independence.mjs +59 -0
  38. package/src/probe/mocks.mjs +120 -0
  39. package/src/probe/probe.mjs +126 -18
  40. package/src/probe/rank.mjs +73 -15
  41. package/src/probe/runners/index.mjs +49 -5
  42. package/src/probe/runners/jest.mjs +3 -3
  43. package/src/probe/runners/playwright.mjs +133 -0
  44. package/src/probe/runners/shared.mjs +70 -13
  45. package/src/probe/runners/vitest.mjs +4 -3
  46. package/src/render.mjs +2 -2
  47. package/src/replay/label.mjs +64 -0
  48. package/src/replay/replay.mjs +309 -0
  49. package/src/scaffold/producers.mjs +217 -6
  50. package/src/scaffold/scaffold.mjs +9 -3
  51. package/src/status/status.mjs +88 -7
@@ -1,9 +1,9 @@
1
- import { TEST_GLOBS, npx, runProcess, checkBinary, listTestFiles } from './shared.mjs';
1
+ import { TEST_GLOBS, runProcess, checkRunner, runnerArgv, listTestFiles } from './shared.mjs';
2
2
 
3
3
  export const name = '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) => checkBinary({ ...opts, bin: 'jest' });
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) => [npx, 'jest', '--ci', '--json', `--outputFile=${outFile}`, '--runTestsByPath', ...files] });
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
- * Can `bin` be resolved at all from this directory? Run before any verdict
48
- * is attempted: an unresolvable runner is a precondition failure, never a
49
- * statement about the tests.
54
+ * Where does the runner come from? The PROJECT first: the runner package
55
+ * resolved from `projectDir` (its version, its `bin` script run with this
56
+ * node). Only when no package resolves, an executable on PATH — a global
57
+ * runner is a legitimate setup for some monorepos, and the evidence says so
58
+ * (`source: "path"`). Never `npx`: the npx cache and a same-named executable
59
+ * (a Python `playwright` shim, say) both answer `npx --no-install <bin>
60
+ * --version` from a project that has no such runner at all (#41).
50
61
  */
51
- export function checkBinary({ projectDir, bin, budgetMs = 60_000, env = process.env }) {
62
+ export function resolveRunner({ projectDir, pkg, bin }) {
63
+ const key = `${projectDir}\0${pkg}`;
64
+ if (resolvedRunners.has(key)) return resolvedRunners.get(key);
65
+ let result = null;
66
+ try {
67
+ const req = createRequire(join(projectDir, 'noop.js'));
68
+ const pkgJsonPath = req.resolve(`${pkg}/package.json`);
69
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
70
+ const binField = typeof pkgJson.bin === 'string' ? pkgJson.bin : pkgJson.bin?.[bin];
71
+ if (binField) result = { argv: [process.execPath, join(dirname(pkgJsonPath), binField)], version: pkgJson.version, source: 'project' };
72
+ } catch {}
73
+ resolvedRunners.set(key, result);
74
+ return result;
75
+ }
76
+
77
+ /** Forget cached resolutions (tests create and delete projects at the same paths). */
78
+ export const resetRunnerCache = () => resolvedRunners.clear();
79
+
80
+ /** The argv prefix that runs `pkg`'s `bin` for this project: the resolved local script, else the PATH executable. */
81
+ export function runnerArgv(projectDir, pkg, bin) {
82
+ return resolveRunner({ projectDir, pkg, bin })?.argv ?? [bin];
83
+ }
84
+
85
+ /**
86
+ * Can the runner be resolved at all from this directory? Run before any
87
+ * verdict is attempted: an unresolvable runner is a precondition failure,
88
+ * never a statement about the tests. Project package first; PATH second;
89
+ * npx never.
90
+ */
91
+ export function checkRunner({ projectDir, pkg, bin, budgetMs = 60_000, env = process.env }) {
92
+ const local = resolveRunner({ projectDir, pkg, bin });
93
+ if (local) return Promise.resolve({ ok: true, version: local.version, source: 'project' });
52
94
  return new Promise((resolve) => {
53
- const child = spawn(npx, ['--no-install', bin, '--version'], { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...env, CI: '1' } });
95
+ let child;
96
+ try {
97
+ child = spawn(process.platform === 'win32' ? `${bin}.cmd` : bin, ['--version'], { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...env, CI: '1' } });
98
+ } catch (e) {
99
+ return resolve({ ok: false, message: e.message });
100
+ }
54
101
  let out = '';
55
102
  let err = '';
56
- child.on('error', (e) => resolve({ ok: false, message: e.message }));
103
+ child.on('error', () => resolve({ ok: false, message: `${pkg} is not installed in the project and \`${bin}\` is not on PATH (npm i -D ${pkg})` }));
57
104
  child.stdout.on('data', (d) => (out += d));
58
105
  child.stderr.on('data', (d) => (err += d));
59
106
  const timer = setTimeout(() => child.kill('SIGKILL'), budgetMs);
60
107
  child.on('close', (code) => {
61
108
  clearTimeout(timer);
62
- const version = out.trim().replace(/^vitest\//, '').split('\n').pop();
63
- resolve(code === 0 && version ? { ok: true, version } : { ok: false, message: (err || out).trim().split('\n').filter(Boolean).slice(-1)[0] ?? `exit ${code}` });
109
+ const version = out.trim().replace(/^vitest\//, '').replace(/^Version\s+/i, '').split('\n').pop();
110
+ resolve(code === 0 && version
111
+ ? { ok: true, version, source: 'path' }
112
+ : { ok: false, message: `${pkg} is not installed in the project; \`${bin}\` on PATH answered: ${(err || out).trim().split('\n').filter(Boolean).slice(-1)[0] ?? `exit ${code}`}` });
64
113
  });
65
114
  });
66
115
  }
67
116
 
117
+ /** @deprecated name kept for older callers; resolves the project package first, never npx. */
118
+ export const checkBinary = ({ bin, ...rest }) => checkRunner({ ...rest, pkg: bin, bin });
119
+
68
120
  /** Defender globs → existing files. Empty result is the `nocover` signal. */
69
121
  export const resolveDefenders = (projectDir, globs) => matchGlobs(projectDir, globs ?? []);
70
122
 
@@ -78,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
- child.stderr.on('data', (d) => (stderr += d));
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 = parseReport(JSON.parse(readFileSync(outFile, 'utf8')), durationMs);
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, npx, runProcess, checkBinary, listTestFiles } from './shared.mjs';
1
+ import { TEST_GLOBS, runProcess, checkRunner, runnerArgv, listTestFiles } from './shared.mjs';
2
2
 
3
3
  export const name = 'vitest';
4
4
  export const testGlobs = TEST_GLOBS;
5
- export const check = (opts) => checkBinary({ ...opts, bin: 'vitest' });
5
+ export const check = (opts) => checkRunner({ ...opts, pkg: 'vitest', bin: 'vitest' });
6
6
  export const tests = (projectDir) => listTestFiles(projectDir, testGlobs);
7
- export const run = (opts) => runProcess({ ...opts, argv: (files, outFile) => [npx, 'vitest', 'run', ...files, '--reporter=json', `--outputFile=${outFile}`] });
7
+ // --serial: one file at a time in one process, so a contended machine cannot turn a slow suite into a TIMEOUT verdict.
8
+ 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
+ }