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.
- package/CHANGELOG.md +255 -0
- package/README.md +275 -31
- package/package.json +1 -1
- package/spec/GATE-SEMANTICS.md +58 -1
- package/spec/README.md +1 -0
- package/spec/lib/validate.mjs +57 -1
- package/spec/schemas/baseline.schema.json +8 -0
- package/spec/schemas/common.schema.json +5 -1
- package/spec/schemas/evidence.schema.json +374 -63
- package/spec/schemas/replay.schema.json +85 -0
- package/spec/schemas/status.schema.json +256 -54
- package/src/admit/admit.mjs +17 -0
- package/src/baseline/baseline.mjs +31 -2
- package/src/brief/brief.mjs +71 -7
- package/src/claims/removed.mjs +161 -0
- package/src/cli.mjs +53 -10
- package/src/commands/admit.mjs +116 -0
- package/src/commands/baseline.mjs +17 -3
- package/src/commands/brief.mjs +13 -7
- package/src/commands/claims.mjs +35 -6
- package/src/commands/init.mjs +19 -6
- package/src/commands/mcp.mjs +12 -0
- package/src/commands/probe.mjs +11 -3
- package/src/commands/replay.mjs +63 -0
- package/src/commands/status.mjs +3 -1
- package/src/git.mjs +5 -0
- package/src/init/init.mjs +182 -29
- package/src/init/templates/SKILL.md +3 -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 +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.';
|
package/src/probe/discover.mjs
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
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
|
|
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
|
+
}
|