testguard-cli 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +628 -0
  2. package/README.md +536 -42
  3. package/package.json +2 -2
  4. package/spec/GATE-SEMANTICS.md +166 -2
  5. package/spec/README.md +29 -10
  6. package/spec/lib/validate.mjs +140 -8
  7. package/spec/schemas/baseline.schema.json +13 -5
  8. package/spec/schemas/brief.schema.json +12 -12
  9. package/spec/schemas/calibration.schema.json +5 -5
  10. package/spec/schemas/claims.schema.json +122 -25
  11. package/spec/schemas/common.schema.json +131 -29
  12. package/spec/schemas/evidence.schema.json +476 -69
  13. package/spec/schemas/gate.schema.json +12 -12
  14. package/spec/schemas/ignore.schema.json +4 -4
  15. package/spec/schemas/replay.schema.json +85 -0
  16. package/spec/schemas/status.schema.json +258 -56
  17. package/src/admit/admit.mjs +17 -0
  18. package/src/baseline/baseline.mjs +31 -2
  19. package/src/brief/brief.mjs +71 -7
  20. package/src/claims/removed.mjs +161 -0
  21. package/src/cli.mjs +68 -10
  22. package/src/commands/admit.mjs +116 -0
  23. package/src/commands/baseline.mjs +17 -3
  24. package/src/commands/brief.mjs +13 -7
  25. package/src/commands/claims.mjs +55 -6
  26. package/src/commands/init.mjs +19 -6
  27. package/src/commands/mcp.mjs +12 -0
  28. package/src/commands/probe.mjs +35 -7
  29. package/src/commands/replay.mjs +63 -0
  30. package/src/commands/status.mjs +3 -1
  31. package/src/gate/changed.mjs +9 -2
  32. package/src/git.mjs +5 -0
  33. package/src/init/init.mjs +182 -29
  34. package/src/init/templates/SKILL.md +3 -3
  35. package/src/mcp/server.mjs +104 -0
  36. package/src/mcp/tools.mjs +114 -0
  37. package/src/probe/attribution.mjs +123 -0
  38. package/src/probe/classify.mjs +18 -2
  39. package/src/probe/contention.mjs +60 -0
  40. package/src/probe/cost.mjs +145 -0
  41. package/src/probe/discover.mjs +20 -3
  42. package/src/probe/independence.mjs +59 -0
  43. package/src/probe/inject.mjs +41 -9
  44. package/src/probe/mocks.mjs +122 -0
  45. package/src/probe/probe.mjs +331 -40
  46. package/src/probe/progress.mjs +87 -0
  47. package/src/probe/pyimports.mjs +287 -0
  48. package/src/probe/rank.mjs +75 -15
  49. package/src/probe/runners/index.mjs +79 -8
  50. package/src/probe/runners/jest.mjs +15 -3
  51. package/src/probe/runners/playwright.mjs +142 -0
  52. package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
  53. package/src/probe/runners/python/_testguard_report.py +141 -0
  54. package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
  55. package/src/probe/runners/python.mjs +316 -0
  56. package/src/probe/runners/shared.mjs +80 -14
  57. package/src/probe/runners/vitest.mjs +16 -3
  58. package/src/render.mjs +2 -2
  59. package/src/replay/label.mjs +64 -0
  60. package/src/replay/replay.mjs +310 -0
  61. package/src/scaffold/producers.mjs +217 -6
  62. package/src/scaffold/producers.python.mjs +209 -0
  63. package/src/scaffold/scaffold.mjs +90 -30
  64. package/src/status/status.mjs +47 -6
  65. package/src/util/glob.mjs +7 -1
@@ -6,8 +6,13 @@
6
6
  * encodes GATE-SEMANTICS.md: nothing to run → nocover; nothing to apply →
7
7
  * unverifiable; defenders not green → flaky-defender; then, and only then,
8
8
  * does the fault's own result count.
9
+ *
10
+ * `subjectReached` is the negative control's answer and is consulted at one
11
+ * point only: a would-be `survived`. It is never asked about a kill — a kill
12
+ * already proves the defenders reached the code — and `undefined` (the control
13
+ * was not run) never changes a verdict.
9
14
  */
10
- export function classify({ defenders, anchor, baselineRuns, probeRuns, confirmRuns }) {
15
+ export function classify({ defenders, anchor, baselineRuns, probeRuns, confirmRuns, subjectReached }) {
11
16
  if (defenders.length === 0) return { verdict: 'nocover' };
12
17
  if (anchor && anchor.status !== 'ok') return { verdict: 'unverifiable', reason: anchor.status };
13
18
  if (baselineRuns.length === 0 || baselineRuns.some((r) => r.outcome !== 'pass')) {
@@ -24,7 +29,18 @@ export function classify({ defenders, anchor, baselineRuns, probeRuns, confirmRu
24
29
  const killedRuns = probeRuns.filter((r) => r.outcome === 'fail' && r.assertionFailures > 0);
25
30
  const passedRuns = probeRuns.filter((r) => r.outcome === 'pass');
26
31
  if (probeRuns.length === confirmRuns && killedRuns.length === confirmRuns) return { verdict: 'killed' };
27
- if (probeRuns.length === confirmRuns && passedRuns.length === confirmRuns) return { verdict: 'survived' };
32
+ if (probeRuns.length === confirmRuns && passedRuns.length === confirmRuns) {
33
+ // The negative control. A green baseline proves the defenders can PASS; it
34
+ // says nothing about whether they can fail because of THIS file. If the
35
+ // subject was replaced with something that cannot compile and the
36
+ // defenders still went green, they never execute it — and then `survived`
37
+ // is a statement about their reach, not their assertions, and reads as a
38
+ // devastating audit finding while being entirely false.
39
+ // `undefined` means the control was not run, which is not evidence either
40
+ // way and leaves the verdict alone.
41
+ if (subjectReached === false) return { verdict: 'unverifiable', reason: 'subject-not-executed' };
42
+ return { verdict: 'survived' };
43
+ }
28
44
  // Some runs killed, some passed: the defenders' response to this fault is
29
45
  // nondeterministic. Reporting it as killed would be the optimistic bias
30
46
  // the research warned about; reporting survived would be a lie the other way.
@@ -0,0 +1,60 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ /**
4
+ * Concurrent test runners (issue #26).
5
+ *
6
+ * A probe runs the defenders 3× clean and 3× with each fault applied. Heavy
7
+ * suites time out under parallel CPU load, and a timeout is a verdict —
8
+ * `TIMEOUT` or `FLAKY-DEFENDER` — that says nothing about the claim. A field
9
+ * report serialised everything by hand for exactly this reason. We cannot
10
+ * prevent the contention, but we can name it: warn before the first run, and
11
+ * record it on the evidence so a later reader of a flaky verdict knows the
12
+ * run was contended.
13
+ *
14
+ * Best effort by design: the detector never fails a probe, and a process list
15
+ * it cannot read is simply no detection.
16
+ */
17
+
18
+ const RUNNER_RE = /\b(vitest|jest|playwright|mocha|ava|karma|cypress)\b/i;
19
+ // Our own child processes and this process are not contention.
20
+ const SELF_RE = /testguard/i;
21
+
22
+ /** Rows of the process table as `{ pid, command }`, or [] when it cannot be read. */
23
+ export function processList({ platform = process.platform, run = spawnSync } = {}) {
24
+ try {
25
+ const r = platform === 'win32'
26
+ ? run('wmic', ['process', 'get', 'ProcessId,CommandLine', '/format:csv'], { encoding: 'utf8', timeout: 5000 })
27
+ : run('ps', ['-Ao', 'pid=,args='], { encoding: 'utf8', timeout: 5000 });
28
+ if (r.status !== 0 || !r.stdout) return [];
29
+ return r.stdout.split('\n').map((line) => {
30
+ const m = platform === 'win32' ? /^[^,]*,(.*),(\d+)\s*$/.exec(line) : /^\s*(\d+)\s+(.*)$/.exec(line);
31
+ if (!m) return null;
32
+ const [pid, command] = platform === 'win32' ? [m[2], m[1]] : [m[1], m[2]];
33
+ return { pid: Number(pid), command: command.trim() };
34
+ }).filter(Boolean);
35
+ } catch {
36
+ return [];
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Test runners already running, other than ours. Returns
42
+ * `{ detected, runners: [{ pid, command }] }`; `detected` is false when
43
+ * nothing was found or the process list could not be read.
44
+ */
45
+ export function detectContention({ self = process.pid, ...opts } = {}) {
46
+ const rows = processList(opts);
47
+ const runners = rows
48
+ .filter((p) => p.pid !== self && RUNNER_RE.test(p.command) && !SELF_RE.test(p.command))
49
+ // `ps` lists the grep/ps itself and any shell wrapper; a runner's command
50
+ // line always names its own binary, so require it to look like an exec.
51
+ .filter((p) => !/^\s*(ps|grep|wmic)\b/.test(p.command))
52
+ .map((p) => ({ pid: p.pid, command: p.command.length > 200 ? p.command.slice(0, 197) + '…' : p.command }));
53
+ return { detected: runners.length > 0, runners };
54
+ }
55
+
56
+ /** One line for the operator: what is running, and what it costs. */
57
+ export const contentionWarning = (c) =>
58
+ `${c.runners.length} test runner${c.runners.length === 1 ? ' is' : 's are'} already running (${c.runners.map((r) => `pid ${r.pid}`).join(', ')}); ` +
59
+ 'defenders run N times each here, and a suite that times out under parallel load yields TIMEOUT or FLAKY-DEFENDER — verdicts about the load, not the claim. ' +
60
+ 'Wait for it to finish, or pass --serial to run one test file at a time.';
@@ -0,0 +1,145 @@
1
+ /**
2
+ * What a probe costs, derived from evidence that already exists (issue #67).
3
+ *
4
+ * A probe's wall clock is not a function of how many claims there are. It is
5
+ * `(baseline runs + faults × confirm runs) × the cost of the claim's defender
6
+ * SET`, per claim. One slow acceptance test named as a defender by five claims
7
+ * is therefore paid for thirty times, and nothing in the output says so — the
8
+ * only symptom is that the gate takes twenty-four minutes and nobody knows
9
+ * which line to blame.
10
+ *
11
+ * Every number here is read back out of `durationMs` on the runs the probe
12
+ * already recorded. Nothing re-measures, nothing spawns, nothing guesses: a
13
+ * cost report costs the price of reading one JSON file.
14
+ *
15
+ * ATTRIBUTION, HONESTLY. Runs execute a claim's whole defender set together,
16
+ * so the runner never says how much of a run belonged to which file. Per-file
17
+ * `ms` is therefore the time of every run that *included* that file: an upper
18
+ * bound on what removing it could save, and, when a claim names several
19
+ * defenders, a number that overlaps with its siblings. The per-file figures do
20
+ * not sum to the total, by construction. `totalMs` is the only additive one.
21
+ */
22
+
23
+ /** Every run a record paid for, in the order the probe made them. */
24
+ export const runsOf = (record) => [
25
+ ...(record.detail?.baselineRuns ?? []),
26
+ ...(record.detail?.probeRuns ?? []),
27
+ ...(record.detail?.escalationRuns ?? []),
28
+ ];
29
+
30
+ /**
31
+ * What one record cost. A reused record reports the duration of the runs it
32
+ * reused, which is the right number for planning — it is what the record costs
33
+ * whenever reuse does not apply — and `reused` marks it so a reader does not
34
+ * read it as wall clock just spent.
35
+ */
36
+ export function recordCost(record) {
37
+ const runs = runsOf(record);
38
+ return {
39
+ ms: runs.reduce((a, r) => a + (r.durationMs ?? 0), 0),
40
+ runs: runs.length,
41
+ reused: Boolean(record.reusedFrom),
42
+ };
43
+ }
44
+
45
+ /** Per claim: what its faults cost in total, most expensive first. */
46
+ export function claimCosts(records) {
47
+ const by = new Map();
48
+ for (const r of records) {
49
+ const c = recordCost(r);
50
+ const e = by.get(r.claim.id) ?? { claimId: r.claim.id, ms: 0, runs: 0, faults: 0, reused: 0, defenders: new Set() };
51
+ e.ms += c.ms;
52
+ e.runs += c.runs;
53
+ e.faults += 1;
54
+ if (c.reused) e.reused += 1;
55
+ for (const d of r.defenders?.resolved ?? []) e.defenders.add(d);
56
+ by.set(r.claim.id, e);
57
+ }
58
+ return [...by.values()]
59
+ .map((e) => ({ ...e, defenders: [...e.defenders].sort() }))
60
+ .sort((a, b) => b.ms - a.ms || a.claimId.localeCompare(b.claimId));
61
+ }
62
+
63
+ /**
64
+ * Per defender file: the time of every run that included it, and the claims
65
+ * that named it. See the attribution note above — this is a ceiling, not a
66
+ * share, and the figures overlap.
67
+ */
68
+ export function defenderCosts(records) {
69
+ const by = new Map();
70
+ for (const r of records) {
71
+ const c = recordCost(r);
72
+ for (const file of r.defenders?.resolved ?? []) {
73
+ const e = by.get(file) ?? { file, ms: 0, runs: 0, claims: new Set() };
74
+ e.ms += c.ms;
75
+ e.runs += c.runs;
76
+ e.claims.add(r.claim.id);
77
+ by.set(file, e);
78
+ }
79
+ }
80
+ return [...by.values()]
81
+ .map((e) => ({ ...e, claims: [...e.claims].sort() }))
82
+ .sort((a, b) => b.ms - a.ms || a.file.localeCompare(b.file));
83
+ }
84
+
85
+ /**
86
+ * The whole cost model for one evidence document.
87
+ *
88
+ * `sharedDefenders` is the actionable part: a file named by more than one
89
+ * claim, whose cost is multiplied by every claim that names it. Splitting the
90
+ * behaviour it defends into a unit test that runs in milliseconds is what
91
+ * turns a twenty-four-minute gate into a one-minute one, and until a report
92
+ * names the file nobody knows which test to split.
93
+ */
94
+ export function costReport(records) {
95
+ const claims = claimCosts(records);
96
+ const defenders = defenderCosts(records);
97
+ return {
98
+ totalMs: claims.reduce((a, c) => a + c.ms, 0),
99
+ totalRuns: claims.reduce((a, c) => a + c.runs, 0),
100
+ reusedRecords: records.filter((r) => r.reusedFrom).length,
101
+ records: records.length,
102
+ claims,
103
+ defenders,
104
+ sharedDefenders: defenders.filter((d) => d.claims.length > 1),
105
+ };
106
+ }
107
+
108
+ const secs = (ms) => (ms >= 10_000 ? `${Math.round(ms / 1000)}s` : ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`);
109
+ /** Exported so a caller can format a duration the same way the report does. */
110
+ export const formatMs = secs;
111
+
112
+ /**
113
+ * The cost report as text. `limit` caps each list; the totals always describe
114
+ * everything, so a truncated list never misrepresents the whole.
115
+ */
116
+ export function renderCost(report, { limit = 10 } = {}) {
117
+ const out = [];
118
+ if (!report.records) return 'no evidence records — run `testguard probe` first; cost is read back from the runs it records.';
119
+ out.push(`${report.records} fault record${report.records === 1 ? '' : 's'} cost ${secs(report.totalMs)} across ${report.totalRuns} defender runs${report.reusedRecords ? ` (${report.reusedRecords} reused; their runs are what they cost when reuse does not apply)` : ''}.`);
120
+ out.push('');
121
+ out.push('most expensive claims');
122
+ for (const c of report.claims.slice(0, limit)) {
123
+ out.push(` ${secs(c.ms).padStart(7)} ${c.claimId.padEnd(34)} ${c.faults} fault${c.faults === 1 ? ' ' : 's'} · ${c.runs} runs${c.reused ? ` · ${c.reused} reused` : ''}`);
124
+ }
125
+ if (report.claims.length > limit) out.push(` … ${report.claims.length - limit} more`);
126
+ out.push('');
127
+ out.push('defender files, by the time of the runs that included them');
128
+ out.push(' (a claim runs its whole defender set at once, so these overlap and do not sum to the total)');
129
+ for (const d of report.defenders.slice(0, limit)) {
130
+ out.push(` ${secs(d.ms).padStart(7)} ${d.file.padEnd(44)} ${d.claims.length} claim${d.claims.length === 1 ? '' : 's'}`);
131
+ }
132
+ if (report.defenders.length > limit) out.push(` … ${report.defenders.length - limit} more`);
133
+ if (report.sharedDefenders.length) {
134
+ out.push('');
135
+ out.push('shared defenders — each claim pays the file\'s full cost again');
136
+ for (const d of report.sharedDefenders.slice(0, limit)) {
137
+ out.push(` ${secs(d.ms).padStart(7)} ${d.file}`);
138
+ out.push(` named by ${d.claims.join(', ')}`);
139
+ }
140
+ out.push('');
141
+ out.push(' To make a probe cheaper, split the behaviour a shared defender proves into a');
142
+ out.push(' test that does not need the expensive setup, and point the claim at that.');
143
+ }
144
+ return out.join('\n');
145
+ }
@@ -1,12 +1,29 @@
1
1
  import { join } from 'node:path';
2
2
  import { listTestFiles } from './runners/shared.mjs';
3
+ import { testGlobs as PY_TEST_GLOBS } from './runners/python.mjs';
3
4
  import { fileImports } from './rank.mjs';
5
+ import { pyFileImports } from './pyimports.mjs';
6
+ import { classifyDefenders } from './mocks.mjs';
7
+
8
+ /** A target's language decides how its importers are found. Nothing else about it matters here. */
9
+ export const isPython = (rel) => rel.endsWith('.py');
4
10
 
5
11
  /**
6
12
  * When a claim declares no defenders: the test files that import the fault's
7
- * target file, directly, by relative path or through a resolved alias. This
8
- * is what `nocover` measures against — no test file even imports the source.
13
+ * target file — directly, by relative path or through a resolved alias in
14
+ * JavaScript, by module name in Python — and do NOT replace it wholesale. A
15
+ * file that mocks the target cannot detect any fault in it; counting it would
16
+ * make `nocover` under-report and waste probe runs on files that cannot fail.
17
+ * `nocover` therefore means exactly "no test file imports this source without
18
+ * mocking it".
9
19
  */
20
+ export function discoverDefendersDetailed(projectDir, targetRel) {
21
+ const importing = isPython(targetRel)
22
+ ? listTestFiles(projectDir, PY_TEST_GLOBS).filter((t) => pyFileImports(projectDir, join(projectDir, t), targetRel, t))
23
+ : listTestFiles(projectDir).filter((t) => fileImports(projectDir, join(projectDir, t), targetRel));
24
+ return { importing, ...classifyDefenders(projectDir, targetRel, importing) };
25
+ }
26
+
10
27
  export function discoverDefenders(projectDir, targetRel) {
11
- return listTestFiles(projectDir).filter((t) => fileImports(projectDir, join(projectDir, t), targetRel));
28
+ return discoverDefendersDetailed(projectDir, targetRel).canDetect;
12
29
  }
@@ -0,0 +1,59 @@
1
+ import { git, GitError } from '../git.mjs';
2
+
3
+ /**
4
+ * Maturity-ladder L3: independence.
5
+ *
6
+ * `probe` measures POWER — would this test notice if the code were wrong.
7
+ * It says nothing about who wrote the test. An agent that writes the code and
8
+ * its defender in one change encodes whatever it believed, and a kill by such
9
+ * a test is weaker evidence than a kill by a test written separately
10
+ * (SpecBench: agents saturate the tests they can see).
11
+ *
12
+ * This is a SIGNAL, never a verdict. A co-authored kill is legitimate — a bug
13
+ * fix *should* ship with its regression test — but a repository where every
14
+ * kill is co-authored has no independent verification, whatever its claim
15
+ * verification rate says. Ranking may read it; `classify()` never does.
16
+ */
17
+ const CLASSES = ['co-authored', 'separate-change', 'unknown'];
18
+
19
+ /** `git log -1` for one path at a commit → {commit, author} or null when there is no history for it. */
20
+ function lastCommit(dir, ref, path) {
21
+ try {
22
+ const out = git(['log', '-1', '--format=%H%x09%ae', ref, '--', path], dir);
23
+ if (!out) return null;
24
+ const [commit, author] = out.split('\t');
25
+ return commit && author ? { commit, author } : null;
26
+ } catch (e) {
27
+ if (e instanceof GitError) return null;
28
+ throw e;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Was the killing defender last touched by the same change as the fault's
34
+ * target? `defenders` are the files whose tests failed on the fault.
35
+ *
36
+ * `co-authored` — same commit, or the same author identity within the
37
+ * commits the caller considers one change (`range`).
38
+ * `separate-change` — different commits by different identities.
39
+ * `unknown` — no history for one of them: a working-tree snapshot of
40
+ * untracked files, a shallow clone, or no commits.
41
+ */
42
+ export function classifyIndependence({ dir, ref = 'HEAD', targetFile, defenders = [] }) {
43
+ const target = lastCommit(dir, ref, targetFile);
44
+ if (!target || defenders.length === 0) return { class: 'unknown' };
45
+
46
+ let sameAuthor = false;
47
+ let worst = null; // the most independent defender decides: one separate author is independence
48
+ for (const d of defenders) {
49
+ const def = lastCommit(dir, ref, d);
50
+ if (!def) return { class: 'unknown', targetCommit: target.commit };
51
+ const co = def.commit === target.commit || def.author === target.author;
52
+ if (def.author === target.author) sameAuthor = true;
53
+ if (!co) return { class: 'separate-change', defenderCommit: def.commit, targetCommit: target.commit, sameAuthor: false };
54
+ worst ??= def;
55
+ }
56
+ return { class: 'co-authored', defenderCommit: worst.commit, targetCommit: target.commit, sameAuthor };
57
+ }
58
+
59
+ export { CLASSES as INDEPENDENCE_CLASSES };
@@ -63,24 +63,56 @@ export function mutate(source, fault) {
63
63
  }
64
64
 
65
65
  /**
66
- * Apply a fault to a file on disk. Returns a handle whose `restore()` puts the
67
- * original back; restore is idempotent and also runs on process exit/signal.
66
+ * Replace a file's contents on disk. Returns a handle whose `restore()` puts
67
+ * the original back; restore is idempotent and also runs on process
68
+ * exit/signal. `content` is the new text, or a function of the original.
69
+ *
70
+ * `inPlace` decides what a *missing* target means at restore time, and the two
71
+ * modes are opposites. In worktree mode the mutation only ever existed inside a
72
+ * scratch worktree that is discarded at the end of the run, so a target that has
73
+ * vanished is already restored in every sense that matters — a parallel session
74
+ * pruning its own leaked worktrees is enough to cause it, and throwing there
75
+ * converts a harmless cleanup race into a probe that dies with no evidence at
76
+ * all. In `--in-place` mode the same condition means the user's own file is
77
+ * gone, and silence would be the worst possible answer.
68
78
  */
69
- export function applyFault(projectDir, fault) {
79
+ export function applyContent(projectDir, file, content, { inPlace = false } = {}) {
70
80
  installHandlers();
71
- const path = join(projectDir, fault.file);
81
+ const path = join(projectDir, file);
72
82
  const original = readFileSync(path, 'utf8');
73
- const anchor = locate(original, fault);
74
- if (anchor.status !== 'ok') return { anchor, applied: false, restore: () => {} };
75
83
 
76
84
  let restored = false;
85
+ const handle = { applied: true, restore: null };
77
86
  const restore = () => {
78
87
  if (restored) return;
79
88
  restored = true;
80
89
  live.delete(restore);
81
- writeFileSync(path, original);
90
+ try {
91
+ writeFileSync(path, original);
92
+ } catch (err) {
93
+ // Only a target that is no longer there. A permission failure or a
94
+ // directory where a file should be can still mean a mutated file left on
95
+ // disk, and those must stay loud in either mode.
96
+ if (inPlace || err.code !== 'ENOENT') throw err;
97
+ handle.restoreSkipped = 'target-missing';
98
+ }
82
99
  };
100
+ handle.restore = restore;
83
101
  live.add(restore);
84
- writeFileSync(path, mutate(original, fault));
85
- return { anchor, applied: true, restore };
102
+ writeFileSync(path, typeof content === 'function' ? content(original) : content);
103
+ return handle;
104
+ }
105
+
106
+ /**
107
+ * Apply a fault to a file on disk. Returns a handle whose `restore()` puts the
108
+ * original back; `applied` is false and nothing is written when the anchor does
109
+ * not hit exactly as declared.
110
+ */
111
+ export function applyFault(projectDir, fault, opts = {}) {
112
+ const anchor = locate(readFileSync(join(projectDir, fault.file), 'utf8'), fault);
113
+ if (anchor.status !== 'ok') return { anchor, applied: false, restore: () => {} };
114
+ // The same object, never a spread: `restoreSkipped` is set on it later.
115
+ const handle = applyContent(projectDir, fault.file, (src) => mutate(src, fault), opts);
116
+ handle.anchor = anchor;
117
+ return handle;
86
118
  }
@@ -0,0 +1,122 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { specifierResolvesTo } from './rank.mjs';
4
+ import { classifyPythonDefenders } from './pyimports.mjs';
5
+
6
+ /**
7
+ * Mock-awareness for defenders (issues #20, #21).
8
+ *
9
+ * A test file that `vi.mock`s / `jest.mock`s the target cannot detect any
10
+ * fault in it, yet it imports the target, so the import rule would count it
11
+ * as a defender. Two consequences the field reports measured: `nocover`
12
+ * under-reports (a module with eighteen "defenders" that is effectively
13
+ * uncovered) and every probe wastes runs on files that cannot fail. The
14
+ * mocked count is itself the strongest blindness signal.
15
+ *
16
+ * And the cheapest static signal of all: a file that mocks the target and
17
+ * never `expect(...)`s anything from it. That single fact explained the
18
+ * seam behind an escaped bug without injecting anything.
19
+ */
20
+
21
+ const MOCK_CALL = /\b(?:vi|jest)\.(?:mock|doMock|unstable_mockModule)\(\s*(['"])([^'"]+)\1/g;
22
+ const IMPORT_STMT = /import\s+([^'";]+?)\s+from\s*(['"])([^'"]+)\2|import\s*(['"])([^'"]+)\4|(?:const|let|var)\s+([^=;]+?)\s*=\s*(?:await\s+)?(?:require|import)\(\s*(['"])([^'"]+)\7\s*\)/g;
23
+ const UNASSERTED = /unasserted:\s*(.+?)\s*(?:\*\/)?\s*$/;
24
+
25
+ /** The specifiers this source mocks, with their line numbers. */
26
+ export function mockSpecifiers(source) {
27
+ const out = [];
28
+ const lines = source.split('\n');
29
+ lines.forEach((text, i) => {
30
+ for (const m of text.matchAll(MOCK_CALL)) out.push({ specifier: m[2], line: i + 1 });
31
+ });
32
+ return out;
33
+ }
34
+
35
+ /** Local names bound to a module specifier by import/require statements. `*` marks a namespace/default object binding. */
36
+ function bindingsFor(source, matchesTarget) {
37
+ const names = [];
38
+ for (const m of source.matchAll(IMPORT_STMT)) {
39
+ const spec = m[3] ?? m[5] ?? m[8];
40
+ if (!spec || !matchesTarget(spec)) continue;
41
+ const clause = (m[1] ?? m[6] ?? '').trim();
42
+ if (!clause) continue; // side-effect import
43
+ const ns = /\*\s+as\s+([\w$]+)/.exec(clause);
44
+ if (ns) names.push(ns[1]);
45
+ const braces = /\{([^}]*)\}/.exec(clause);
46
+ if (braces) for (const part of braces[1].split(',')) { const local = part.trim().split(/\s+as\s+|\s*:\s*/).pop()?.trim(); if (local && /^[\w$]+$/.test(local)) names.push(local); }
47
+ const def = clause.replace(/\{[^}]*\}/, '').replace(/\*\s+as\s+[\w$]+/, '').replace(/,/g, '').trim();
48
+ if (def && /^[\w$]+$/.test(def)) names.push(def);
49
+ }
50
+ return [...new Set(names)];
51
+ }
52
+
53
+ /** Does any `expect(...)` argument mention one of the bindings (as an identifier or a member root)? */
54
+ function expectMentions(source, bindings) {
55
+ if (bindings.length === 0) return false;
56
+ const re = /\bexpect\s*\(((?:[^()]|\((?:[^()]|\([^()]*\))*\))*)\)/g;
57
+ for (const m of source.matchAll(re)) {
58
+ const arg = m[1];
59
+ for (const b of bindings) if (new RegExp(`(^|[^\\w$.])${b.replace(/\$/g, '\\$')}(?![\\w$])`).test(arg)) return true;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ /**
65
+ * How one test file relates to one target: does it import it, does it mock it,
66
+ * does it ever assert on what it imported from it, and did the author annotate
67
+ * an unasserted mock with a reason (`// unasserted: <why>` on or above the
68
+ * mock line).
69
+ */
70
+ export function analyzeDefender(projectDir, testRel, targetRel) {
71
+ const abs = join(projectDir, testRel);
72
+ let source;
73
+ try {
74
+ source = readFileSync(abs, 'utf8');
75
+ } catch {
76
+ return { file: testRel, imports: false, mocks: false, asserted: false, annotation: null };
77
+ }
78
+ const matchesTarget = (spec) => specifierResolvesTo(projectDir, abs, spec, targetRel);
79
+ const mocks = mockSpecifiers(source).filter((m) => matchesTarget(m.specifier));
80
+ const bindings = bindingsFor(source, matchesTarget);
81
+ const asserted = expectMentions(source, bindings);
82
+ let annotation = null;
83
+ if (mocks.length) {
84
+ const lines = source.split('\n');
85
+ for (const m of mocks) {
86
+ for (let i = m.line - 1; i >= Math.max(0, m.line - 3) && !annotation; i--) {
87
+ const a = UNASSERTED.exec(lines[i]);
88
+ if (a) annotation = a[1];
89
+ }
90
+ }
91
+ }
92
+ return { file: testRel, imports: bindings.length > 0 || matchesTargetAnyImport(source, matchesTarget), mocks: mocks.length > 0, asserted, annotation };
93
+ }
94
+
95
+ function matchesTargetAnyImport(source, matchesTarget) {
96
+ for (const m of source.matchAll(/(?:from\s*|import\s*\(?\s*|require\s*\(\s*)['"]([^'"]+)['"]/g)) if (matchesTarget(m[1])) return true;
97
+ return false;
98
+ }
99
+
100
+ /**
101
+ * Split candidate test files for one target into the ones that can detect a
102
+ * fault in it and the ones that mock it, plus the static signals. A file that
103
+ * mocks the target is never a defender; a mocking file that never asserts on
104
+ * the target carries `mocked-never-asserted`, unless annotated, in which case
105
+ * the annotation is recorded (`unasserted-annotated`) — silenced, never hidden.
106
+ */
107
+ export function classifyDefenders(projectDir, targetRel, candidates) {
108
+ if (targetRel.endsWith('.py')) return classifyPythonDefenders(projectDir, targetRel, candidates);
109
+ const canDetect = [];
110
+ const mocking = [];
111
+ const signals = [];
112
+ for (const file of candidates) {
113
+ const a = analyzeDefender(projectDir, file, targetRel);
114
+ if (a.mocks) {
115
+ mocking.push(file);
116
+ if (!a.asserted) signals.push(a.annotation ? { file, signal: 'unasserted-annotated', reason: a.annotation } : { file, signal: 'mocked-never-asserted' });
117
+ } else {
118
+ canDetect.push(file);
119
+ }
120
+ }
121
+ return { canDetect, mocking, signals };
122
+ }