testguard-cli 0.18.3 → 0.20.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/src/init/init.mjs CHANGED
@@ -1,22 +1,36 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync, realpathSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, realpathSync, chmodSync } from 'node:fs';
2
2
  import { join, dirname, relative, resolve, sep } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { spawnSync } from 'node:child_process';
5
+ import { PreconditionError } from '../probe/worktree.mjs';
5
6
 
6
7
  const TEMPLATES = join(dirname(fileURLToPath(import.meta.url)), 'templates');
7
8
  const AGENTS_BEGIN = '<!-- testguard:begin -->';
8
9
  const AGENTS_END = '<!-- testguard:end -->';
9
- export const GITIGNORE_LINES = ['.testguard/evidence.json', '.testguard/evidence-provisional.json', '.testguard/evidence-partial.json', '.testguard/brief.json', '.testguard/gate.json', '.testguard/scaffold-*.json', '.testguard/replay.json', '.testguard/calibration.json', '.testguard/ci-self-evidence.json', '.testguard/ci/'];
10
+ /** Where fetch-ci-evidence.sh downloads CI's artifact, relative to the project. */
11
+ export const CI_DOWNLOAD_DIR = '.testguard/ci';
12
+ const GITIGNORE_HEADER = '# TestGuard: regenerated per run (baseline.json IS committed)';
13
+ // Every regenerated file a command writes under .testguard/. test/init.test.mjs
14
+ // derives that set from the code's own path joins and fails when one is missing
15
+ // here, so a new artifact cannot ship without its ignore line.
16
+ export const GITIGNORE_LINES = ['.testguard/evidence.json', '.testguard/evidence-provisional.json', '.testguard/evidence-partial.json', '.testguard/brief.json', '.testguard/gate.json', '.testguard/scaffold-*.json', '.testguard/replay.json', '.testguard/calibration.json', '.testguard/ci-self-evidence.json', '.testguard/ci/', '.testguard/sweep.json', '.testguard/sweep-evidence.json'];
10
17
 
11
18
  /**
12
- * The `.testguard/` files that ARE committed: the frozen contract, its
13
- * snapshot, and the CI-fetch helper the user runs by hand. Everything else in
14
- * the directory is regenerated. Named here so the two places that reason about
15
- * the directory — the lines init writes and the advice baseline prints — cannot
16
- * disagree about which of them is which.
19
+ * The `.testguard/` files that are NOT ignored: the frozen contract
20
+ * (baseline.json), the CI-fetch helper `init --ci-evidence` writes for the user
21
+ * to run by hand, and status.json — a name reserved for a status snapshot a
22
+ * project may choose to keep (`status --json > .testguard/status.json`). No
23
+ * command writes status.json; it is listed so that both ignore forms leave it
24
+ * trackable alike: init's per-file lines never name it, and the `.testguard/*`
25
+ * form baseline advises negates it. Everything else in the directory is
26
+ * regenerated. Named here so the two places that reason about the directory —
27
+ * the lines init writes and the advice baseline prints — cannot disagree.
17
28
  */
18
29
  export const COMMITTED_OUTPUTS = ['.testguard/baseline.json', '.testguard/status.json', '.testguard/fetch-ci-evidence.sh'];
19
30
 
31
+ /** A bad option to init: the CLI reports it as a usage error (exit 3). */
32
+ export class InitUsageError extends Error {}
33
+
20
34
  /**
21
35
  * The session-start hook command. It runs from the git root (where the agent
22
36
  * session lives) and must never fetch from the network: a local install of
@@ -78,6 +92,63 @@ export function isGitIgnored(root, file) {
78
92
  return spawnSync('git', ['check-ignore', '-q', '--', file], { cwd: root }).status === 0;
79
93
  }
80
94
 
95
+ /**
96
+ * The directory a TestGuard session-start hook briefs, or null when the
97
+ * command is not a TestGuard hook.
98
+ *
99
+ * Recognised by what every release's hook has run — `testguard brief --text`,
100
+ * through `npx -y testguard-cli`, `npx --no-install testguard`, a local
101
+ * `node_modules/.bin/testguard` or a `testguard` on PATH — not by the exact
102
+ * text of the current hookCommand(). Matching only the current text (and two
103
+ * named npx forms) meant any change to hookCommand() made the next `init`
104
+ * append a second hook beside the first, and every session briefed twice.
105
+ * The project is the token after `--text` when it is an argument rather than
106
+ * a redirect or a shell operator.
107
+ */
108
+ export function briefHookDir(command) {
109
+ if (typeof command !== 'string') return null;
110
+ const m = /(?:^|[\s/])testguard(?:-cli)?(?:@[^\s"|;&]+)?\s+brief\s+--text\b(?:\s+([^\s"|;&]+))?/.exec(command);
111
+ if (!m) return null;
112
+ const token = m[1];
113
+ return !token || token.startsWith('2>') || token.startsWith('>') ? '.' : token;
114
+ }
115
+
116
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
117
+
118
+ /**
119
+ * Read `.claude/settings.json` for the hook, or refuse. A file init cannot
120
+ * safely merge into is a precondition failure naming the file — never a crash,
121
+ * and never after another file has been written.
122
+ */
123
+ function readSettings(settingsPath) {
124
+ if (!existsSync(settingsPath)) return {};
125
+ let settings;
126
+ try {
127
+ settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
128
+ } catch (e) {
129
+ throw new PreconditionError(`${settingsPath} is not valid JSON (${e.message}); fix it before init can add the hook. Nothing was written.`);
130
+ }
131
+ const refuse = (what) => { throw new PreconditionError(`${settingsPath}: ${what}; fix it before init can add the hook. Nothing was written.`); };
132
+ if (!isPlainObject(settings)) refuse('the settings must be a JSON object');
133
+ if (settings.hooks !== undefined && !isPlainObject(settings.hooks)) refuse('"hooks" must be an object');
134
+ if (settings.hooks?.SessionStart !== undefined && !Array.isArray(settings.hooks.SessionStart)) refuse('"hooks.SessionStart" must be an array');
135
+ return settings;
136
+ }
137
+
138
+ /**
139
+ * The `.gitignore` text with init's lines in it, or null when nothing is
140
+ * missing. Lines a previous init wrote stay where they are; lines a newer
141
+ * release added go directly after the last of them, so re-running init after
142
+ * an upgrade extends the one block instead of appending a second header.
143
+ */
144
+ function gitignoreWithLines(gi, missing) {
145
+ const lines = gi.split('\n');
146
+ let last = -1;
147
+ lines.forEach((l, i) => { if (GITIGNORE_LINES.includes(l.trim())) last = i; });
148
+ if (last === -1) return (gi ? gi.replace(/\s*$/, '\n') : '') + GITIGNORE_HEADER + '\n' + missing.join('\n') + '\n';
149
+ return [...lines.slice(0, last + 1), ...missing, ...lines.slice(last + 1)].join('\n');
150
+ }
151
+
81
152
  /**
82
153
  * Install the operating layer.
83
154
  *
@@ -93,35 +164,59 @@ export function isGitIgnored(root, file) {
93
164
  * hook line and an AGENTS.md bullet, never duplicates. Every written file is
94
165
  * checked against .gitignore; an ignored file is reported as ignored, never
95
166
  * as something to commit.
167
+ *
168
+ * All or nothing: every input is read and every refusal raised before the
169
+ * first write, so a bad option or an unmergeable settings.json leaves the
170
+ * project exactly as it was.
96
171
  */
97
- const CI_EVIDENCE_HELPERS = {
98
- github: `#!/bin/sh
99
- # Fetch the evidence CI wrote on a branch, then brief from it. Run it when you
100
- # want it: the session-start hook never touches the network, and this script
101
- # exits 0 with a message whenever anything is missing.
102
- BRANCH="\${1:-main}"
103
- OUT=.testguard/ci
104
- command -v gh >/dev/null 2>&1 || { echo "gh is not installed; see README > Read CI's evidence locally" >&2; exit 0; }
105
- command -v testguard >/dev/null 2>&1 || { echo "testguard is not on PATH; install it or run npx testguard-cli brief --evidence <file>" >&2; exit 0; }
106
- mkdir -p "$OUT" || exit 0
107
- gh run download --name "testguard-evidence-$BRANCH" --dir "$OUT" >/dev/null 2>&1 || { echo "no testguard-evidence-$BRANCH artifact to download" >&2; exit 0; }
108
- exec testguard brief . --text --evidence "$OUT/ci-self-evidence.json"
109
- `,
110
- gitlab: `#!/bin/sh
172
+ const CI_EVIDENCE_HELPER = (platform) => {
173
+ const github = platform === 'github';
174
+ const tool = github ? 'gh' : 'glab';
175
+ const download = github
176
+ ? 'gh run download --name "testguard-evidence-$BRANCH" --dir "$OUT"'
177
+ : 'glab ci artifact "$BRANCH" testguard:probe --path "$OUT/"';
178
+ const missing = github ? 'no testguard-evidence-$BRANCH artifact to download' : 'no testguard:probe artifact to download for $BRANCH';
179
+ // Where the evidence sits in the downloaded artifact. The action's
180
+ // `evidence` output names the one file its run wrote, uploaded at the
181
+ // artifact's root; ci-self-evidence.json is what an earlier workflow example
182
+ // uploaded. The GitLab template keeps the project's path from the repository
183
+ // root, so a project in a subdirectory finds its own evidence under that
184
+ // prefix — and never another project's from the same artifact.
185
+ const candidates = github
186
+ ? ['evidence.json', 'evidence-provisional.json', 'ci-self-evidence.json']
187
+ : ['${PREFIX}.testguard/evidence.json', '${PREFIX}.testguard/evidence-provisional.json'];
188
+ return `#!/bin/sh
111
189
  # Fetch the evidence CI wrote on a branch, then brief from it. Run it when you
112
190
  # want it: the session-start hook never touches the network, and this script
113
191
  # exits 0 with a message whenever anything is missing.
114
192
  BRANCH="\${1:-main}"
115
- OUT=.testguard/ci
116
- command -v glab >/dev/null 2>&1 || { echo "glab is not installed; see README > Read CI's evidence locally" >&2; exit 0; }
117
- command -v testguard >/dev/null 2>&1 || { echo "testguard is not on PATH; install it or run npx testguard-cli brief --evidence <file>" >&2; exit 0; }
118
- mkdir -p "$OUT" || exit 0
119
- glab ci artifact "$BRANCH" testguard:probe --path "$OUT/" >/dev/null 2>&1 || { echo "no testguard:probe artifact to download for $BRANCH" >&2; exit 0; }
120
- exec testguard brief . --text --evidence "$OUT/.testguard/evidence.json"
121
- `,
193
+ # Run from the project this helper belongs to, whatever the caller's cwd.
194
+ case "$0" in */*) cd "\${0%/*}/.." || exit 0 ;; *) cd .. || exit 0 ;; esac
195
+ OUT=${CI_DOWNLOAD_DIR}
196
+ command -v ${tool} >/dev/null 2>&1 || { echo "${tool} is not installed; see https://github.com/raccioly/testguard/blob/main/docs/guides/ci/${github ? 'github-actions' : 'gitlab'}.md#read-cis-evidence-locally" >&2; exit 0; }
197
+ # The CLI the session-start hook would run: this project's install, then the
198
+ # repository root's, then a testguard on PATH. Never a package-runner fetch.
199
+ TG=
200
+ ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
201
+ ${github ? '' : '# This project\'s path from the repository root, with a trailing slash; empty at the root.\nPREFIX=$(git rev-parse --show-prefix 2>/dev/null)\n'}for c in ./node_modules/.bin/testguard "\${ROOT:+$ROOT/node_modules/.bin/testguard}"; do
202
+ if [ -n "$c" ] && [ -x "$c" ]; then TG=$c; break; fi
203
+ done
204
+ [ -n "$TG" ] || TG=$(command -v testguard 2>/dev/null)
205
+ [ -n "$TG" ] || { echo "no testguard CLI found (./node_modules/.bin, the repository root's node_modules/.bin, or PATH); install it, or run npx testguard-cli brief . --text --evidence <file>" >&2; exit 0; }
206
+ # A fresh download directory, so an earlier download is never briefed as this one.
207
+ rm -rf "$OUT" && mkdir -p "$OUT" || exit 0
208
+ ${download} >/dev/null 2>&1 || { echo "${missing}" >&2; exit 0; }
209
+ for f in ${candidates.map((c) => `"$OUT/${c}"`).join(' ')}; do
210
+ [ -f "$f" ] && exec "$TG" brief . --text --evidence "$f"
211
+ done
212
+ echo "the downloaded artifact holds no evidence for this project (looked for ${candidates.join(', ')})" >&2
213
+ exit 0
214
+ `;
122
215
  };
216
+ const CI_PLATFORMS = ['github', 'gitlab'];
123
217
 
124
218
  export function initProject({ projectDir, force = false, here = false, ciEvidence, mcp = false }) {
219
+ if (ciEvidence !== undefined && !CI_PLATFORMS.includes(ciEvidence)) throw new InitUsageError(`--ci-evidence must be github or gitlab, not ${JSON.stringify(ciEvidence)}`);
125
220
  projectDir = realpathSync(resolve(projectDir));
126
221
  const root = here ? null : gitRoot(projectDir);
127
222
  const agentRoot = root ?? projectDir;
@@ -132,65 +227,56 @@ export function initProject({ projectDir, force = false, here = false, ciEvidenc
132
227
  let mcpConfig;
133
228
  const skipped = [];
134
229
  const warnings = [];
230
+ const writes = [];
135
231
  const rel = (p) => relative(agentRoot, p).split(sep).join('/');
136
232
  const note = (p) => { if (isGitIgnored(ignoreRoot, p)) warnings.push(`${rel(p)} is ignored by .gitignore — it was written, but git will not track it and agents in a fresh clone will never see it; unignore it or move it`); };
233
+ const write = (path, content, { mode, checkIgnored = true } = {}) => writes.push({ path, content, mode, checkIgnored });
234
+
235
+ // ---- preflight: read everything, refuse here, write nothing yet ----
236
+ const settingsPath = join(agentRoot, '.claude', 'settings.json');
237
+ const settings = readSettings(settingsPath);
137
238
 
138
239
  const skillDir = join(agentRoot, '.claude', 'skills', 'testguard');
139
240
  const skillPath = join(skillDir, 'SKILL.md');
140
241
  if (!existsSync(skillPath) || force) {
141
242
  const replaced = force && existsSync(skillPath);
142
- mkdirSync(skillDir, { recursive: true });
143
- writeFileSync(skillPath, readFileSync(join(TEMPLATES, 'SKILL.md'), 'utf8'));
243
+ write(skillPath, readFileSync(join(TEMPLATES, 'SKILL.md'), 'utf8'));
144
244
  done.push(`${rel(skillPath)}${replaced ? ' (replaced)' : ''}`);
145
- note(skillPath);
146
245
  } else {
147
246
  skipped.push(`${rel(skillPath)} exists (use --force to replace)`);
148
247
  }
149
248
 
150
- const settingsPath = join(agentRoot, '.claude', 'settings.json');
151
- let settings = {};
152
- if (existsSync(settingsPath)) {
153
- try {
154
- settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
155
- } catch (e) {
156
- throw new Error(`${rel(settingsPath)} is not valid JSON (${e.message}); fix it before init can add the hook`);
157
- }
158
- }
159
249
  settings.hooks ??= {};
160
250
  settings.hooks.SessionStart ??= [];
251
+ const groups = settings.hooks.SessionStart;
161
252
  const cmd = hookCommand(dir);
162
- const hasCmd = settings.hooks.SessionStart.some((e) => JSON.stringify(e).includes(cmd));
163
- // Any earlier hook that reached for npx — `-y` fetched outright, and
164
- // `--no-install` still resolves the packument from the registry — is replaced.
165
- // Which project a hook is for is the token right after `brief --text`, if
166
- // that token is an argument rather than a redirect or a shell operator.
167
- // Testing for any non-space there mistook `brief --text 2>/dev/null` for a
168
- // hook belonging to a directory, so a legacy root hook was never replaced.
169
- const hookDir = (text) => {
170
- const m = /brief --text(?:\s+([^\s"|;&]+))?/.exec(text);
171
- const token = m?.[1];
172
- return !token || token.startsWith('2>') || token.startsWith('>') ? '.' : token;
173
- };
174
- const legacy = settings.hooks.SessionStart.findIndex((e) => {
175
- const text = JSON.stringify(e);
176
- return /npx\s+(-y|--no-install)[^"]*brief --text/.test(text) && hookDir(text) === dir;
177
- });
253
+ // Every TestGuard hook already briefing this project, whichever release wrote it.
254
+ const ours = [];
255
+ groups.forEach((g, gi) => { if (Array.isArray(g?.hooks)) g.hooks.forEach((h, hi) => { if (briefHookDir(h?.command) === dir) ours.push({ gi, hi, command: h.command }); }); });
256
+ const hasCmd = ours.length === 1 && ours[0].command === cmd;
178
257
  if (hasCmd) {
179
258
  skipped.push(`SessionStart hook for ${dir === '.' ? 'this project' : dir} already present in ${rel(settingsPath)}`);
180
259
  } else {
181
- if (legacy !== -1) {
182
- settings.hooks.SessionStart.splice(legacy, 1);
183
- done.push(`${rel(settingsPath)}: replaced the network-reaching npx hook with a local-then-PATH one`);
260
+ if (ours.length) {
261
+ // Replace the first in place, keeping its position and any other keys
262
+ // (a timeout, say); drop the rest, which are duplicates an older init left.
263
+ const [first, ...extra] = ours;
264
+ groups[first.gi].hooks[first.hi] = { ...groups[first.gi].hooks[first.hi], command: cmd };
265
+ for (const { gi, hi } of extra.reverse()) groups[gi].hooks.splice(hi, 1);
266
+ settings.hooks.SessionStart = groups.filter((g, gi) => !extra.some((x) => x.gi === gi) || g.hooks.length > 0);
267
+ const npx = ours.some((o) => /\bnpx\b/.test(o.command));
268
+ done.push(npx
269
+ ? `${rel(settingsPath)}: replaced the network-reaching npx hook with a local-then-PATH one`
270
+ : `${rel(settingsPath)}: replaced an earlier TestGuard hook for ${dir === '.' ? 'this project' : dir} in place`);
271
+ } else {
272
+ groups.push({ hooks: [{ type: 'command', command: cmd }] });
184
273
  }
185
- settings.hooks.SessionStart.push({ hooks: [{ type: 'command', command: cmd }] });
186
- mkdirSync(dirname(settingsPath), { recursive: true });
187
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
274
+ write(settingsPath, JSON.stringify(settings, null, 2) + '\n');
188
275
  // Describes hookCommand() above, which emits no npx of any kind: a local
189
276
  // install, then a testguard on PATH, then nothing. The claim
190
277
  // TG-README-HOOK-MATCHES-THE-CODE holds this sentence to that, here and in
191
278
  // every other surface that describes the hook.
192
279
  done.push(`${rel(settingsPath)}: SessionStart hook → brief --text${dir === '.' ? '' : ` ${dir}`} (local install first, then a testguard on PATH, never a fetch)`);
193
- note(settingsPath);
194
280
  }
195
281
 
196
282
  const agentsPath = join(agentRoot, 'AGENTS.md');
@@ -204,41 +290,50 @@ export function initProject({ projectDir, force = false, here = false, ciEvidenc
204
290
  if (entries.includes(dir)) {
205
291
  skipped.push(`AGENTS.md already has the TestGuard section for ${dir === '.' ? 'this project' : dir}`);
206
292
  } else {
207
- writeFileSync(agentsPath, agents.slice(0, begin) + agentsBlock([...entries, dir]) + agents.slice(end + AGENTS_END.length));
293
+ write(agentsPath, agents.slice(0, begin) + agentsBlock([...entries, dir]) + agents.slice(end + AGENTS_END.length));
208
294
  done.push(legacyBlock ? `AGENTS.md: TestGuard section rewritten to list ${dir}` : `AGENTS.md: TestGuard section now lists ${dir}`);
209
- note(agentsPath);
210
295
  }
211
296
  } else {
212
- writeFileSync(agentsPath, (agents ? agents.replace(/\s*$/, '\n\n') : '# Agent instructions\n\n') + agentsBlock([dir]) + '\n');
297
+ write(agentsPath, (agents ? agents.replace(/\s*$/, '\n\n') : '# Agent instructions\n\n') + agentsBlock([dir]) + '\n');
213
298
  done.push(agents ? 'AGENTS.md: TestGuard section appended' : 'AGENTS.md created with the TestGuard section');
214
- note(agentsPath);
215
299
  }
216
300
 
217
301
  const giPath = join(projectDir, '.gitignore');
218
302
  const gi = existsSync(giPath) ? readFileSync(giPath, 'utf8') : '';
219
303
  const entries = gi.split('\n').map((x) => x.trim());
220
304
  const dirIgnored = entries.some((x) => /^\/?\.testguard\/?$/.test(x));
221
- const missing = dirIgnored ? [] : GITIGNORE_LINES.filter((l) => !entries.includes(l));
305
+ // `.testguard/*` with negations is the form `baseline` advises: it already
306
+ // covers every output, present and future, so per-file lines would be noise.
307
+ const starForm = entries.some((x) => /^\/?\.testguard\/\*$/.test(x));
308
+ const missing = dirIgnored || starForm ? [] : GITIGNORE_LINES.filter((l) => !entries.includes(l));
222
309
  if (dirIgnored) {
223
310
  skipped.push(`${rel(giPath)} ignores .testguard/ entirely — note that baseline.json should be committed; ignore only the regenerated files if you adopt a baseline`);
311
+ } else if (starForm) {
312
+ skipped.push(`${rel(giPath)} ignores .testguard/* — every regenerated output is covered; keep ${COMMITTED_OUTPUTS.map((p) => `!${p}`).join(', ')} beside it`);
224
313
  } else if (missing.length) {
225
- writeFileSync(giPath, (gi ? gi.replace(/\s*$/, '\n') : '') + '# TestGuard: regenerated per run (baseline.json IS committed)\n' + missing.join('\n') + '\n');
314
+ write(giPath, gitignoreWithLines(gi, missing), { checkIgnored: false });
226
315
  done.push(`${rel(giPath)}: ${missing.length} line${missing.length === 1 ? '' : 's'} added`);
227
316
  } else {
228
317
  skipped.push(`${rel(giPath)} already ignores the regenerated files`);
229
318
  }
230
- if (ciEvidence) {
319
+ if (ciEvidence !== undefined) {
231
320
  const helperPath = join(projectDir, '.testguard', 'fetch-ci-evidence.sh');
232
- const body = CI_EVIDENCE_HELPERS[ciEvidence];
233
- if (!body) throw new Error(`--ci-evidence must be github or gitlab, not ${ciEvidence}`);
234
321
  if (!existsSync(helperPath) || force) {
235
- mkdirSync(dirname(helperPath), { recursive: true });
236
- writeFileSync(helperPath, body, { mode: 0o755 });
322
+ write(helperPath, CI_EVIDENCE_HELPER(ciEvidence), { mode: 0o755, checkIgnored: false });
237
323
  done.push(`.testguard/fetch-ci-evidence.sh (${ciEvidence}) — run it on demand; the session-start hook stays offline`);
238
324
  } else {
239
325
  skipped.push('.testguard/fetch-ci-evidence.sh exists (use --force to replace)');
240
326
  }
241
327
  }
328
+
329
+ // ---- every check passed: write ----
330
+ for (const w of writes) {
331
+ mkdirSync(dirname(w.path), { recursive: true });
332
+ writeFileSync(w.path, w.content, w.mode === undefined ? undefined : { mode: w.mode });
333
+ if (w.mode !== undefined) chmodSync(w.path, w.mode); // an existing file keeps its old mode otherwise
334
+ if (w.checkIgnored) note(w.path);
335
+ }
336
+
242
337
  if (mcp) {
243
338
  // Printed, never written: a harness's own config is the person's file
244
339
  // (and often global), so init shows the snippet and lets them place it.
@@ -131,6 +131,11 @@ export function isReusable(prior, current) {
131
131
  // A record that exists because the probe threw is a statement about the run,
132
132
  // not about the code. Reusing it would make one disturbed run permanent.
133
133
  if (prior.detail?.reason === 'probe-error') return false;
134
+ // Defenders that never loaded say something about the environment — a
135
+ // broken --runner-cmd, a missing dependency, an interpreter without the test
136
+ // requirements — and none of that is hashed below. Fixing it changes no
137
+ // input, so the failure would keep being reported as the current answer.
138
+ if (prior.detail?.reason === 'defenders-failed-to-load') return false;
134
139
  const origin = prior.defenders.selectionSource ?? (prior.defenders.requested.length ? 'claim' : 'discovery');
135
140
  if (current.selectionSource && origin !== current.selectionSource) return false;
136
141
  const same = (x, y) => JSON.stringify(x) === JSON.stringify(y);
@@ -90,8 +90,19 @@ export function detectContention({ self = process.pid, alive = defaultAlive, ...
90
90
  return { detected: runners.length > 0, runners };
91
91
  }
92
92
 
93
- /** One line for the operator: what is running, and what it costs. */
94
- export const contentionWarning = (c) =>
93
+ /**
94
+ * What would actually change this run. `--serial` is the same as `--workers 1`,
95
+ * which is the default, so advising it to a run that is already serial offers
96
+ * a flag that changes nothing; a custom command's concurrency is its own.
97
+ */
98
+ function contentionAdvice({ serial = true, workers = 1, custom = false }) {
99
+ if (custom) return 'Wait for it to finish; a --runner-cmd manages its own concurrency, so make that command run one test file at a time if it does not already.';
100
+ if (serial || workers <= 1) return 'Wait for it to finish: this probe already runs one test file at a time (--workers 1), so --serial would change nothing.';
101
+ return `Wait for it to finish, or lower --workers ${workers} (or pass --serial) to run one test file at a time.`;
102
+ }
103
+
104
+ /** One line for the operator: what is running, what it costs, and what would help. */
105
+ export const contentionWarning = (c, policy = {}) =>
95
106
  `${c.runners.length} test runner${c.runners.length === 1 ? ' is' : 's are'} already running (${c.runners.map((r) => `pid ${r.pid}`).join(', ')}); ` +
96
107
  '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. ' +
97
- 'Wait for it to finish, or pass --serial to run one test file at a time.';
108
+ contentionAdvice(policy);
@@ -109,7 +109,13 @@ export function costReport(records, { run } = {}) {
109
109
  };
110
110
  }
111
111
 
112
- const secs = (ms) => (ms >= 10_000 ? `${Math.round(ms / 1000)}s` : ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`);
112
+ // Measured durations are performance.now() differences, so they are fractional:
113
+ // round before choosing the unit, or 659.1493330000001ms prints verbatim and
114
+ // 999.6 prints as "1000ms".
115
+ const secs = (ms) => {
116
+ const whole = Math.round(ms);
117
+ return whole >= 10_000 ? `${Math.round(ms / 1000)}s` : whole >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${whole}ms`;
118
+ };
113
119
  /** Exported so a caller can format a duration the same way the report does. */
114
120
  export const formatMs = secs;
115
121
 
@@ -36,7 +36,11 @@ export function originPolicyInputsFresh({ projectDir, claims, evidence, universe
36
36
  if (methodOf(evidence.run) !== 'fault-injection' || evidence.run.provisional || evidence.run.confirmRuns < 3) return false;
37
37
  const testUniverseHash = hashNativeTestUniverse(universe.manifests);
38
38
  if (universe.testUniverseHash !== testUniverseHash) return false;
39
- const matchesRunner = (recorded, current) => recorded?.name === current?.name && recorded?.version === current?.version;
39
+ // Where the runner came from is part of its identity: a global vitest of
40
+ // the same version is a different binary from the project's, the same
41
+ // rule verdict reuse applies (sameRunner in probe.mjs).
42
+ const matchesRunner = (recorded, current) => recorded?.name === current?.name && recorded?.version === current?.version
43
+ && (recorded?.source ?? null) === (current?.source ?? null);
40
44
  if (!universe.primaryRunner || !matchesRunner(evidence.run.runner, universe.primaryRunner)) return false;
41
45
  if ((evidence.run.runners ?? []).some((recorded) => !universe.runners?.some((current) => matchesRunner(recorded, current)))) return false;
42
46
  const allTests = Object.freeze([...new Set([...universe.manifests.values()].flatMap((m) => m.files))].sort());
@@ -21,7 +21,7 @@ import { hashFile, sha256 } from '../util/hash.mjs';
21
21
  import { fingerprint } from '../../spec/lib/fingerprint.mjs';
22
22
  import { recordedOriginSummary } from '../../spec/lib/origins.mjs';
23
23
  import { persistenceSignalsFor } from '../supply/persistence.mjs';
24
- import { collectOwnedManifests, hashNativeTestUniverse } from './universe.mjs';
24
+ import { collectOwnedManifests, hashNativeTestUniverse, hashCustomCommandUniverse } from './universe.mjs';
25
25
 
26
26
 
27
27
  /** Is `child` the same file as `root`, or under it? Both must already be real paths. */
@@ -62,6 +62,27 @@ export function sameWorkerPolicy(previousRun, { workers = 1, serial = workers ==
62
62
  return previousRun?.workers === (serial ? 1 : workers) && Boolean(previousRun?.serial) === serial;
63
63
  }
64
64
 
65
+ /**
66
+ * What the evidence says ran: the engine's name, the version it reported, and
67
+ * where it was resolved from (`project`, `path`, `builtin`). A custom command
68
+ * resolves nothing, so it claims no source.
69
+ */
70
+ export function runnerRecord(name, version, source) {
71
+ return { name, ...(version ? { version } : {}), ...(source ? { source } : {}) };
72
+ }
73
+
74
+ /**
75
+ * Reuse cannot stand in for a measurement by a different runner. pytest and
76
+ * unittest are one adapter and two engines; a global vitest of the same
77
+ * version is a different binary from the project's. Evidence that recorded no
78
+ * runner cannot vouch for this one.
79
+ */
80
+ export function sameRunner(previousRun, current) {
81
+ const recorded = previousRun?.runner;
82
+ if (!recorded) return false;
83
+ return recorded.name === current.name && recorded.version === current.version && recorded.source === current.source;
84
+ }
85
+
65
86
  /**
66
87
  * Probe every fault of every claim. Returns a spec-conformant evidence
67
88
  * document; writing it is the caller's job.
@@ -205,13 +226,14 @@ export async function probe({
205
226
  // Contention: name it before the first run, and record it, so a later
206
227
  // reader of a TIMEOUT or FLAKY-DEFENDER knows the machine was loaded.
207
228
  const contention = detectContention();
208
- if (contention.detected) onWarn(contentionWarning(contention));
229
+ if (contention.detected) onWarn(contentionWarning(contention, { serial, workers, custom: Boolean(runnerCommand) }));
209
230
 
210
231
  const iso = mode === 'worktree' ? createScratch({ repoRoot: root, projectDir, ref: snapshot ?? ref, scratchBase, nodeModules }) : inPlace({ repoRoot: root, projectDir });
211
232
  const isoReal = realpathSync(iso.projectDir);
212
233
  const records = [];
213
234
  let runnerVersion;
214
235
  let runnerSource;
236
+ let recordedRunner;
215
237
  let primaryManifest;
216
238
  let runner = RUNNERS[runnerName === 'auto' ? 'vitest' : runnerName];
217
239
  // A runner with more than one engine (Python: pytest or unittest) is recorded
@@ -236,7 +258,8 @@ export async function probe({
236
258
  primaryManifest = sel.manifest;
237
259
  if (sel.engine) engines.set(sel.runner, sel.engine);
238
260
  }
239
- runnersUsed.set(labelOf(runner), runnerVersion ?? readRunnerVersion(projectDir, runner.name));
261
+ recordedRunner = runnerRecord(labelOf(runner), runnerVersion ?? readRunnerVersion(projectDir, runner.name), runnerSource);
262
+ runnersUsed.set(recordedRunner.name, recordedRunner.version);
240
263
  // Files an owning runner (Playwright) claims run under it, whatever the
241
264
  // project runner is; it must resolve before the first such defender runs.
242
265
  const owned = OWNED_RUNNERS.filter((r) => r !== runner
@@ -283,7 +306,7 @@ export async function probe({
283
306
  ? [...new Set([...runner.tests(iso.projectDir).filter((t) => !owned.some((r) => r.owns(iso.projectDir, t))), ...owned.flatMap((r) => r.tests(iso.projectDir))])]
284
307
  : [...ownerByFile.keys()]).sort());
285
308
  const testUniverseHash = commandTemplate
286
- ? sha256(JSON.stringify({ schemaVersion: 1, source: 'custom-command-static', files: allTests }))
309
+ ? hashCustomCommandUniverse(allTests, commandTemplate)
287
310
  : hashNativeTestUniverse(manifests);
288
311
  const baselineCache = new Map();
289
312
  const discoveryHashCache = new Map();
@@ -295,6 +318,7 @@ export async function probe({
295
318
  const fatalEditFor = (file) => runnerFor(iso.projectDir, file, runner).fatalEdit?.();
296
319
  const prior = previous && previous.run.confirmRuns === confirmRuns
297
320
  && sameWorkerPolicy(previous.run, { workers, serial, runnerCommand })
321
+ && sameRunner(previous.run, recordedRunner)
298
322
  ? new Map(previous.records.map((r) => [`${r.claim.id}/${r.subject.id}`, r]))
299
323
  : new Map();
300
324
  const partitionConfigured = (files) => {
@@ -403,7 +427,9 @@ export async function probe({
403
427
  startedAt,
404
428
  finishedAt: new Date().toISOString(),
405
429
  repo: { head, dirty, ...(snapshot ? { snapshot } : {}), ...(ignoredDirty.length ? { ignoredDirty } : {}) },
406
- runner: { name: labelOf(runner), ...((runnerVersion ?? readRunnerVersion(projectDir, runner.name)) ? { version: runnerVersion ?? readRunnerVersion(projectDir, runner.name) } : {}), ...(runnerSource === 'builtin' ? { source: runnerSource } : {}) },
430
+ // Where the runner came from is part of what was measured: recorded for
431
+ // every resolved runner, as the schema documents, not only the builtin.
432
+ runner: recordedRunner,
407
433
  ...(runnersUsed.size > 1 ? { runners: [...runnersUsed].map(([n, v]) => ({ name: n, ...(v ? { version: v } : {}) })) } : {}),
408
434
  confirmRuns,
409
435
  measurements,
@@ -2,7 +2,7 @@ import { performance } from 'node:perf_hooks';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { registerChild } from './lifecycle.mjs';
4
4
  import { spawn } from 'node:child_process';
5
- import { dirname, join } from 'node:path';
5
+ import { dirname, join, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { runProcess, listTestFiles, firstInformativeLine, terminateProcessTree } from './shared.mjs';
8
8
 
@@ -69,6 +69,20 @@ export const testGlobs = ['**/test_*.py', '**/test*.py', '**/*_test.py'];
69
69
  const PY_FILE = /\.py$/;
70
70
  const TIMEOUT = /\bTimeout >|\btimed out\b|\bTimeoutError\b/i;
71
71
 
72
+ /**
73
+ * The interpreter the operator named, with `--python` or `TESTGUARD_PYTHON` —
74
+ * both the same way, as a shell would. A bare command name (`python3`, no path
75
+ * separator) is looked up on PATH: resolving it against the working directory
76
+ * produced `<cwd>/python3`, which is never an interpreter. Anything with a
77
+ * separator is a path, resolved once against the working directory. An empty
78
+ * value names nothing.
79
+ */
80
+ export function explicitInterpreter(value) {
81
+ if (typeof value !== 'string' || value === '') return undefined;
82
+ const bare = !value.includes('/') && !(process.platform === 'win32' && value.includes('\\'));
83
+ return bare ? { path: value, source: 'path' } : { path: resolve(value), source: 'project' };
84
+ }
85
+
72
86
  /**
73
87
  * Interpreters to try, best first: the active virtualenv, the project's own,
74
88
  * then PATH.
@@ -82,8 +96,8 @@ function candidates(projectDir, python) {
82
96
  const out = [];
83
97
  const win = process.platform === 'win32';
84
98
  const binOf = (root) => (win ? join(root, 'Scripts', 'python.exe') : join(root, 'bin', 'python'));
85
- const explicit = python ?? process.env.TESTGUARD_PYTHON;
86
- if (explicit) return [{ path: explicit, source: 'project', explicit: true }];
99
+ const explicit = explicitInterpreter(python ?? process.env.TESTGUARD_PYTHON);
100
+ if (explicit) return [{ ...explicit, explicit: true }];
87
101
  if (process.env.VIRTUAL_ENV) out.push({ path: binOf(process.env.VIRTUAL_ENV), source: 'project' });
88
102
  for (const dir of ['.venv', 'venv', '.env']) out.push({ path: binOf(join(projectDir, dir)), source: 'project' });
89
103
  out.push({ path: win ? 'python.exe' : 'python3', source: 'path' });
@@ -159,9 +173,9 @@ export function resolveInterpreter({ projectDir, python, budgetMs = 30_000, budg
159
173
  if (answer) return { ...candidate, ...answer };
160
174
  tried.push(candidate.path);
161
175
  }
162
- const explicit = python ?? process.env.TESTGUARD_PYTHON;
176
+ const explicit = explicitInterpreter(python ?? process.env.TESTGUARD_PYTHON);
163
177
  return { error: explicit
164
- ? `${explicit} is not a usable Python interpreter`
178
+ ? `${explicit.path} is not a usable Python interpreter${explicit.source === 'path' ? ' (looked up on PATH)' : ''}`
165
179
  : `no usable Python interpreter (tried ${tried.join(', ') || 'nothing'}); install Python 3.8+, or point --python / TESTGUARD_PYTHON at one` };
166
180
  })();
167
181
  interpreters.set(key, task);
@@ -18,6 +18,17 @@ export function hashNativeTestUniverse(manifests) {
18
18
  return sha256(JSON.stringify(bound));
19
19
  }
20
20
 
21
+ /**
22
+ * A custom `--runner-cmd` has no native listing, so its universe is the static
23
+ * file set — and the command itself. The files alone cannot tell a broken
24
+ * command from the fixed one, and a verdict measured by one is not an answer
25
+ * from the other. The parsed argv is hashed, so reformatting whitespace is not
26
+ * a change and the command text never appears in the evidence.
27
+ */
28
+ export function hashCustomCommandUniverse(files, commandTemplate) {
29
+ return sha256(JSON.stringify({ schemaVersion: 2, source: 'custom-command-static', command: commandTemplate, files }));
30
+ }
31
+
21
32
  /** Shared owning-adapter collection; check failures retain existing probe semantics. */
22
33
  export async function collectOwnedManifests(runner, primaryManifest, { projectDir, sourceDir, python, budgetMs, budgetFor, assertOpen = () => {} }) {
23
34
  const owned = OWNED_RUNNERS.filter((r) => r !== runner
@@ -41,7 +52,8 @@ export async function readNativeTestUniverse(options) {
41
52
  options.assertOpen?.();
42
53
  if (selected.error) throw new DiscoveryError(selected.error);
43
54
  const collection = await collectOwnedManifests(selected.runner, selected.manifest, options);
44
- const primaryRunner = { name: selected.engine ?? selected.runner.name, version: selected.version };
45
- const runners = [primaryRunner, ...[...collection.ownedChecked].filter(([, check]) => check.ok).map(([runner, check]) => ({ name: check.engine ?? runner.name, version: check.version }))];
55
+ const identity = (name, version, source) => ({ name, version, ...(source ? { source } : {}) });
56
+ const primaryRunner = identity(selected.engine ?? selected.runner.name, selected.version, selected.source);
57
+ const runners = [primaryRunner, ...[...collection.ownedChecked].filter(([, check]) => check.ok).map(([runner, check]) => identity(check.engine ?? runner.name, check.version, check.source))];
46
58
  return { ...collection, primary: selected.runner, primaryRunner, runners, testUniverseHash: hashNativeTestUniverse(collection.manifests) };
47
59
  }