testguard-cli 0.5.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 (47) hide show
  1. package/CHANGELOG.md +255 -0
  2. package/README.md +275 -31
  3. package/package.json +1 -1
  4. package/spec/GATE-SEMANTICS.md +58 -1
  5. package/spec/README.md +1 -0
  6. package/spec/lib/validate.mjs +57 -1
  7. package/spec/schemas/baseline.schema.json +8 -0
  8. package/spec/schemas/common.schema.json +5 -1
  9. package/spec/schemas/evidence.schema.json +374 -63
  10. package/spec/schemas/replay.schema.json +85 -0
  11. package/spec/schemas/status.schema.json +256 -54
  12. package/src/admit/admit.mjs +17 -0
  13. package/src/baseline/baseline.mjs +31 -2
  14. package/src/brief/brief.mjs +71 -7
  15. package/src/claims/removed.mjs +161 -0
  16. package/src/cli.mjs +53 -10
  17. package/src/commands/admit.mjs +116 -0
  18. package/src/commands/baseline.mjs +17 -3
  19. package/src/commands/brief.mjs +13 -7
  20. package/src/commands/claims.mjs +35 -6
  21. package/src/commands/init.mjs +19 -6
  22. package/src/commands/mcp.mjs +12 -0
  23. package/src/commands/probe.mjs +11 -3
  24. package/src/commands/replay.mjs +63 -0
  25. package/src/commands/status.mjs +3 -1
  26. package/src/git.mjs +5 -0
  27. package/src/init/init.mjs +182 -29
  28. package/src/init/templates/SKILL.md +3 -3
  29. package/src/mcp/server.mjs +104 -0
  30. package/src/mcp/tools.mjs +114 -0
  31. package/src/probe/contention.mjs +60 -0
  32. package/src/probe/discover.mjs +12 -3
  33. package/src/probe/independence.mjs +59 -0
  34. package/src/probe/mocks.mjs +120 -0
  35. package/src/probe/probe.mjs +126 -18
  36. package/src/probe/rank.mjs +73 -15
  37. package/src/probe/runners/index.mjs +49 -5
  38. package/src/probe/runners/jest.mjs +3 -3
  39. package/src/probe/runners/playwright.mjs +133 -0
  40. package/src/probe/runners/shared.mjs +70 -13
  41. package/src/probe/runners/vitest.mjs +4 -3
  42. package/src/render.mjs +2 -2
  43. package/src/replay/label.mjs +64 -0
  44. package/src/replay/replay.mjs +309 -0
  45. package/src/scaffold/producers.mjs +217 -6
  46. package/src/scaffold/scaffold.mjs +9 -3
  47. package/src/status/status.mjs +47 -6
@@ -0,0 +1,104 @@
1
+ import { TOOLS } from './tools.mjs';
2
+
3
+ /**
4
+ * A Model Context Protocol server over stdio, hand-rolled.
5
+ *
6
+ * Why not the official SDK: this tool has one exact-pinned runtime dependency
7
+ * and intends to keep it that way (AGENTS.md rule 8). The surface here is
8
+ * three methods and five read-only tools and will not grow, so the protocol
9
+ * is written out rather than imported. The version is pinned below and the
10
+ * conformance is covered by tests that speak the wire format.
11
+ *
12
+ * JSON-RPC 2.0 framing: one message per line (newline-delimited JSON), which
13
+ * is what stdio transports use. A notification (no `id`) is never answered.
14
+ */
15
+ export const PROTOCOL_VERSION = '2025-06-18';
16
+
17
+ const ERROR = { parse: -32700, invalidRequest: -32600, methodNotFound: -32601, invalidParams: -32602, internal: -32603 };
18
+
19
+ const ok = (id, result) => ({ jsonrpc: '2.0', id, result });
20
+ const err = (id, code, message, data) => ({ jsonrpc: '2.0', id, error: { code, message, ...(data ? { data } : {}) } });
21
+
22
+ /** Everything an agent may ask for, with the version stamped onto each call. */
23
+ export function handle(message, { version = '0.0.0' } = {}) {
24
+ if (message === null || typeof message !== 'object' || Array.isArray(message)) return err(null, ERROR.invalidRequest, 'a request must be a JSON object');
25
+ const { id, method, params } = message;
26
+ const isNotification = id === undefined || id === null;
27
+
28
+ switch (method) {
29
+ case 'initialize':
30
+ return isNotification ? null : ok(id, {
31
+ protocolVersion: PROTOCOL_VERSION,
32
+ // Read-only: no prompts, no resources, no sampling. Saying so is part
33
+ // of the contract — nothing here can change a claims file.
34
+ capabilities: { tools: { listChanged: false } },
35
+ serverInfo: { name: 'testguard', version },
36
+ instructions: 'Read testguard_status first: it carries the one next action. Run the command it names in your own terminal — these tools never run a probe, because a probe is long-running, budgeted, and the person should see it happen.',
37
+ });
38
+
39
+ case 'notifications/initialized':
40
+ case 'notifications/cancelled':
41
+ return null;
42
+
43
+ case 'ping':
44
+ return isNotification ? null : ok(id, {});
45
+
46
+ case 'tools/list':
47
+ return isNotification ? null : ok(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } })) });
48
+
49
+ case 'tools/call': {
50
+ if (isNotification) return null;
51
+ const tool = TOOLS.find((t) => t.name === params?.name);
52
+ if (!tool) return err(id, ERROR.invalidParams, `unknown tool: ${params?.name}`);
53
+ try {
54
+ const result = tool.handler({ ...(params?.arguments ?? {}), __version: version });
55
+ return ok(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result, isError: false });
56
+ } catch (e) {
57
+ // A tool failure is a tool result, not a protocol error: the agent
58
+ // should read the reason and act, not lose the connection.
59
+ return ok(id, { content: [{ type: 'text', text: `${tool.name} failed: ${e.message}` }], isError: true });
60
+ }
61
+ }
62
+
63
+ default:
64
+ return isNotification ? null : err(id, ERROR.methodNotFound, `unknown method: ${method}`);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Speak the protocol on a pair of streams. Resolves when input ends, so the
70
+ * caller can await a clean shutdown.
71
+ */
72
+ export function serve({ input = process.stdin, output = process.stdout, version = '0.0.0' } = {}) {
73
+ return new Promise((resolveDone) => {
74
+ let buffer = '';
75
+ const write = (msg) => {
76
+ if (msg) output.write(JSON.stringify(msg) + '\n');
77
+ };
78
+ input.setEncoding?.('utf8');
79
+ input.on('data', (chunk) => {
80
+ buffer += chunk;
81
+ let nl;
82
+ while ((nl = buffer.indexOf('\n')) !== -1) {
83
+ const line = buffer.slice(0, nl).trim();
84
+ buffer = buffer.slice(nl + 1);
85
+ if (!line) continue;
86
+ let message;
87
+ try {
88
+ message = JSON.parse(line);
89
+ } catch {
90
+ write(err(null, ERROR.parse, 'invalid JSON'));
91
+ continue;
92
+ }
93
+ try {
94
+ write(handle(message, { version }));
95
+ } catch (e) {
96
+ // The server outlives any single bad request.
97
+ write(err(message?.id ?? null, ERROR.internal, e.message));
98
+ }
99
+ }
100
+ });
101
+ input.on('end', () => resolveDone(0));
102
+ input.on('close', () => resolveDone(0));
103
+ });
104
+ }
@@ -0,0 +1,114 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { computeStatus } from '../status/status.mjs';
4
+ import { buildBrief, buildUnclaimedBrief } from '../brief/brief.mjs';
5
+ import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
6
+ import { scanAnnotations, reconcile } from '../claims/annotations.mjs';
7
+ import { computeRemovedClaims } from '../claims/removed.mjs';
8
+ import { readSpecDoc } from '../evidence/writer.mjs';
9
+ import { resolveChangedRef, withChangedRef } from '../gate/changed.mjs';
10
+
11
+ /**
12
+ * The read-only surface an agent needs, in any harness.
13
+ *
14
+ * TestGuard's operating loop currently lives in a Claude Code skill and a
15
+ * session-start hook, and both vanish the moment the harness is something
16
+ * else. These tools expose the documents the tool already computes, so the
17
+ * loop survives that change.
18
+ *
19
+ * Every tool is read-only and none of them probes. A probe is long-running
20
+ * and budgeted, and the human should see it happen: `next_command` hands back
21
+ * the exact shell line for the agent to run in its own terminal instead.
22
+ */
23
+ const projectOf = (args) => resolve(args?.dir ?? '.');
24
+ const evidenceOf = (args) => (args?.evidence ? resolve(args.evidence) : undefined);
25
+
26
+ const DIR = { type: 'string', description: 'Project directory (where testguard.claims.json lives). Defaults to the current directory.' };
27
+ const EVIDENCE = { type: 'string', description: "Read this evidence document instead of the project's own — CI's, fetched as an artifact." };
28
+ const CHANGED = { type: 'string', description: 'A git reference to measure the change against, so claim coverage of the change is included.' };
29
+
30
+ const statusFor = (args) => {
31
+ const projectDir = projectOf(args);
32
+ return withChangedRef(resolveChangedRef({ explicit: args?.changed }), (changedRef) =>
33
+ computeStatus({ projectDir, toolVersion: args?.__version ?? '0.0.0', changedRef, evidence: evidenceOf(args) }));
34
+ };
35
+
36
+ export const TOOLS = [
37
+ {
38
+ name: 'testguard_status',
39
+ description: 'Where this project stands and the ONE next action, as the machine-readable status document: state, next {action, command, why}, counts, stale inputs, faults edited since they were probed, and the ranked findings. This is the single source of truth every other rendering derives from — read it before deciding anything.',
40
+ inputSchema: { type: 'object', properties: { dir: DIR, changed: CHANGED, evidence: EVIDENCE }, additionalProperties: false },
41
+ handler: (args) => statusFor(args),
42
+ },
43
+ {
44
+ name: 'testguard_brief',
45
+ description: 'Where the test suite is blind, ranked and capped, with one actionable hint per finding and unclaimed changed files first. Read this before writing or changing code, which is what the session-start hook does for harnesses that have one.',
46
+ inputSchema: { type: 'object', properties: { dir: DIR, changed: CHANGED, evidence: EVIDENCE, max: { type: 'integer', minimum: 1, maximum: 50 } }, additionalProperties: false },
47
+ handler: (args) => {
48
+ const projectDir = projectOf(args);
49
+ const evPath = evidenceOf(args) ?? join(projectDir, '.testguard', 'evidence.json');
50
+ const status = (() => {
51
+ try {
52
+ return statusFor(args);
53
+ } catch {
54
+ return undefined; // a brief must never fail because status could not be computed
55
+ }
56
+ })();
57
+ if (!existsSync(evPath)) {
58
+ if (status?.changes?.uncovered?.length) {
59
+ return buildUnclaimedBrief({ tool: { name: 'testguard', version: args?.__version ?? '0.0.0' }, next: status.next, changes: status.changes });
60
+ }
61
+ return { note: `no evidence at ${evPath}: nothing has been probed yet`, next: status?.next };
62
+ }
63
+ const evidence = readSpecDoc('evidence', evPath);
64
+ const basePath = join(projectDir, '.testguard', 'baseline.json');
65
+ const baseline = existsSync(basePath) ? readSpecDoc('baseline', basePath) : undefined;
66
+ return buildBrief(evidence, baseline, { max: args?.max ?? 20, next: status?.next, changes: status?.changes });
67
+ },
68
+ },
69
+ {
70
+ name: 'testguard_claims',
71
+ description: "What this project claims must be true, each with its faults and defenders, plus drift against `@claim` annotations in source and — when `since` is given — every claim or fault that existed at that reference and does not now. A claim that disappeared is invisible to every other check.",
72
+ inputSchema: { type: 'object', properties: { dir: DIR, since: { type: 'string', description: 'A git reference: report claims and faults removed since it.' } }, additionalProperties: false },
73
+ handler: (args) => {
74
+ const projectDir = projectOf(args);
75
+ const path = defaultClaimsPath(projectDir);
76
+ if (!existsSync(path)) return { path, claims: [], note: 'no claims file in this project yet' };
77
+ const claims = loadClaims(path);
78
+ const drift = reconcile(claims, scanAnnotations(projectDir));
79
+ const removed = args?.since
80
+ ? computeRemovedClaims({ projectDir, ref: args.since, claimsPath: path, current: claims, evidencePath: join(projectDir, '.testguard', 'evidence.json'), toolVersion: args?.__version ?? '0.0.0' })
81
+ : undefined;
82
+ return { path, claims, drift, ...(removed ? { removed } : {}) };
83
+ },
84
+ },
85
+ {
86
+ name: 'testguard_evidence',
87
+ description: 'One run\'s findings on disk: every verdict with the runs that produced it. Pass `claim` (and optionally `fault`) for a single record instead of the whole document, which can be large.',
88
+ inputSchema: {
89
+ type: 'object',
90
+ properties: { dir: DIR, evidence: EVIDENCE, claim: { type: 'string', description: 'Return only this claim\'s records.' }, fault: { type: 'string', description: 'With `claim`: only this fault\'s record.' } },
91
+ additionalProperties: false,
92
+ },
93
+ handler: (args) => {
94
+ const projectDir = projectOf(args);
95
+ const evPath = evidenceOf(args) ?? join(projectDir, '.testguard', 'evidence.json');
96
+ if (!existsSync(evPath)) return { note: `no evidence at ${evPath}: run a probe first`, path: evPath };
97
+ const doc = readSpecDoc('evidence', evPath);
98
+ if (!args?.claim) return doc;
99
+ const records = doc.records.filter((r) => r.claim.id === args.claim && (!args.fault || r.subject.id === args.fault));
100
+ return { ...doc, records, filtered: { claim: args.claim, ...(args.fault ? { fault: args.fault } : {}) } };
101
+ },
102
+ },
103
+ {
104
+ name: 'testguard_next_command',
105
+ description: 'The exact shell command to run next, with the reason. Run it yourself in a terminal: a probe is long-running and budgeted, and the person should see it happen. This tool never runs anything.',
106
+ inputSchema: { type: 'object', properties: { dir: DIR, changed: CHANGED, evidence: EVIDENCE }, additionalProperties: false },
107
+ handler: (args) => {
108
+ const doc = statusFor(args);
109
+ return { state: doc.state, action: doc.next.action, command: doc.next.command, why: doc.next.why, ...(doc.next.target ? { target: doc.next.target } : {}), ...(doc.next.file ? { file: doc.next.file } : {}) };
110
+ },
111
+ },
112
+ ];
113
+
114
+ export const TOOL_NAMES = TOOLS.map((t) => t.name);
@@ -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.';
@@ -1,12 +1,21 @@
1
1
  import { join } from 'node:path';
2
2
  import { listTestFiles } from './runners/shared.mjs';
3
3
  import { fileImports } from './rank.mjs';
4
+ import { classifyDefenders } from './mocks.mjs';
4
5
 
5
6
  /**
6
7
  * 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.
8
+ * target file — directly, by relative path or through a resolved alias — and
9
+ * do NOT mock it. A file that mocks the target cannot detect any fault in it;
10
+ * counting it would make `nocover` under-report and waste probe runs on files
11
+ * that cannot fail. `nocover` therefore means exactly "no test file imports
12
+ * this source without mocking it".
9
13
  */
14
+ export function discoverDefendersDetailed(projectDir, targetRel) {
15
+ const importing = listTestFiles(projectDir).filter((t) => fileImports(projectDir, join(projectDir, t), targetRel));
16
+ return { importing, ...classifyDefenders(projectDir, targetRel, importing) };
17
+ }
18
+
10
19
  export function discoverDefenders(projectDir, targetRel) {
11
- return listTestFiles(projectDir).filter((t) => fileImports(projectDir, join(projectDir, t), targetRel));
20
+ return discoverDefendersDetailed(projectDir, targetRel).canDetect;
12
21
  }
@@ -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 };
@@ -0,0 +1,120 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { specifierResolvesTo } from './rank.mjs';
4
+
5
+ /**
6
+ * Mock-awareness for defenders (issues #20, #21).
7
+ *
8
+ * A test file that `vi.mock`s / `jest.mock`s the target cannot detect any
9
+ * fault in it, yet it imports the target, so the import rule would count it
10
+ * as a defender. Two consequences the field reports measured: `nocover`
11
+ * under-reports (a module with eighteen "defenders" that is effectively
12
+ * uncovered) and every probe wastes runs on files that cannot fail. The
13
+ * mocked count is itself the strongest blindness signal.
14
+ *
15
+ * And the cheapest static signal of all: a file that mocks the target and
16
+ * never `expect(...)`s anything from it. That single fact explained the
17
+ * seam behind an escaped bug without injecting anything.
18
+ */
19
+
20
+ const MOCK_CALL = /\b(?:vi|jest)\.(?:mock|doMock|unstable_mockModule)\(\s*(['"])([^'"]+)\1/g;
21
+ 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;
22
+ const UNASSERTED = /unasserted:\s*(.+?)\s*(?:\*\/)?\s*$/;
23
+
24
+ /** The specifiers this source mocks, with their line numbers. */
25
+ export function mockSpecifiers(source) {
26
+ const out = [];
27
+ const lines = source.split('\n');
28
+ lines.forEach((text, i) => {
29
+ for (const m of text.matchAll(MOCK_CALL)) out.push({ specifier: m[2], line: i + 1 });
30
+ });
31
+ return out;
32
+ }
33
+
34
+ /** Local names bound to a module specifier by import/require statements. `*` marks a namespace/default object binding. */
35
+ function bindingsFor(source, matchesTarget) {
36
+ const names = [];
37
+ for (const m of source.matchAll(IMPORT_STMT)) {
38
+ const spec = m[3] ?? m[5] ?? m[8];
39
+ if (!spec || !matchesTarget(spec)) continue;
40
+ const clause = (m[1] ?? m[6] ?? '').trim();
41
+ if (!clause) continue; // side-effect import
42
+ const ns = /\*\s+as\s+([\w$]+)/.exec(clause);
43
+ if (ns) names.push(ns[1]);
44
+ const braces = /\{([^}]*)\}/.exec(clause);
45
+ 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); }
46
+ const def = clause.replace(/\{[^}]*\}/, '').replace(/\*\s+as\s+[\w$]+/, '').replace(/,/g, '').trim();
47
+ if (def && /^[\w$]+$/.test(def)) names.push(def);
48
+ }
49
+ return [...new Set(names)];
50
+ }
51
+
52
+ /** Does any `expect(...)` argument mention one of the bindings (as an identifier or a member root)? */
53
+ function expectMentions(source, bindings) {
54
+ if (bindings.length === 0) return false;
55
+ const re = /\bexpect\s*\(((?:[^()]|\((?:[^()]|\([^()]*\))*\))*)\)/g;
56
+ for (const m of source.matchAll(re)) {
57
+ const arg = m[1];
58
+ for (const b of bindings) if (new RegExp(`(^|[^\\w$.])${b.replace(/\$/g, '\\$')}(?![\\w$])`).test(arg)) return true;
59
+ }
60
+ return false;
61
+ }
62
+
63
+ /**
64
+ * How one test file relates to one target: does it import it, does it mock it,
65
+ * does it ever assert on what it imported from it, and did the author annotate
66
+ * an unasserted mock with a reason (`// unasserted: <why>` on or above the
67
+ * mock line).
68
+ */
69
+ export function analyzeDefender(projectDir, testRel, targetRel) {
70
+ const abs = join(projectDir, testRel);
71
+ let source;
72
+ try {
73
+ source = readFileSync(abs, 'utf8');
74
+ } catch {
75
+ return { file: testRel, imports: false, mocks: false, asserted: false, annotation: null };
76
+ }
77
+ const matchesTarget = (spec) => specifierResolvesTo(projectDir, abs, spec, targetRel);
78
+ const mocks = mockSpecifiers(source).filter((m) => matchesTarget(m.specifier));
79
+ const bindings = bindingsFor(source, matchesTarget);
80
+ const asserted = expectMentions(source, bindings);
81
+ let annotation = null;
82
+ if (mocks.length) {
83
+ const lines = source.split('\n');
84
+ for (const m of mocks) {
85
+ for (let i = m.line - 1; i >= Math.max(0, m.line - 3) && !annotation; i--) {
86
+ const a = UNASSERTED.exec(lines[i]);
87
+ if (a) annotation = a[1];
88
+ }
89
+ }
90
+ }
91
+ return { file: testRel, imports: bindings.length > 0 || matchesTargetAnyImport(source, matchesTarget), mocks: mocks.length > 0, asserted, annotation };
92
+ }
93
+
94
+ function matchesTargetAnyImport(source, matchesTarget) {
95
+ for (const m of source.matchAll(/(?:from\s*|import\s*\(?\s*|require\s*\(\s*)['"]([^'"]+)['"]/g)) if (matchesTarget(m[1])) return true;
96
+ return false;
97
+ }
98
+
99
+ /**
100
+ * Split candidate test files for one target into the ones that can detect a
101
+ * fault in it and the ones that mock it, plus the static signals. A file that
102
+ * mocks the target is never a defender; a mocking file that never asserts on
103
+ * the target carries `mocked-never-asserted`, unless annotated, in which case
104
+ * the annotation is recorded (`unasserted-annotated`) — silenced, never hidden.
105
+ */
106
+ export function classifyDefenders(projectDir, targetRel, candidates) {
107
+ const canDetect = [];
108
+ const mocking = [];
109
+ const signals = [];
110
+ for (const file of candidates) {
111
+ const a = analyzeDefender(projectDir, file, targetRel);
112
+ if (a.mocks) {
113
+ mocking.push(file);
114
+ if (!a.asserted) signals.push(a.annotation ? { file, signal: 'unasserted-annotated', reason: a.annotation } : { file, signal: 'mocked-never-asserted' });
115
+ } else {
116
+ canDetect.push(file);
117
+ }
118
+ }
119
+ return { canDetect, mocking, signals };
120
+ }