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/CHANGELOG.md +104 -0
- package/README.md +84 -1097
- package/package.json +1 -1
- package/spec/GATE-SEMANTICS.md +90 -24
- package/spec/README.md +4 -2
- package/spec/lib/origins.mjs +19 -0
- package/spec/lib/validate.mjs +22 -1
- package/spec/schemas/claims.schema.json +1 -15
- package/spec/schemas/common.schema.json +16 -0
- package/spec/schemas/evidence.schema.json +3 -16
- package/spec/schemas/ignore.schema.json +2 -2
- package/spec/schemas/status.schema.json +23 -0
- package/src/brief/brief.mjs +18 -0
- package/src/cli.mjs +34 -10
- package/src/commands/admit.mjs +8 -1
- package/src/commands/baseline.mjs +16 -2
- package/src/commands/brief.mjs +2 -2
- package/src/commands/claims.mjs +1 -1
- package/src/commands/concerns.mjs +2 -10
- package/src/commands/gate.mjs +2 -4
- package/src/commands/init.mjs +12 -2
- package/src/commands/probe.mjs +13 -5
- package/src/commands/replay.mjs +9 -2
- package/src/commands/runner-options.mjs +28 -0
- package/src/commands/sweep.mjs +8 -1
- package/src/gate/changed.mjs +11 -0
- package/src/git.mjs +12 -2
- package/src/init/init.mjs +169 -74
- package/src/probe/attribution.mjs +5 -0
- package/src/probe/contention.mjs +14 -3
- package/src/probe/cost.mjs +7 -1
- package/src/probe/policy-freshness.mjs +5 -1
- package/src/probe/probe.mjs +31 -5
- package/src/probe/runners/python.mjs +19 -5
- package/src/probe/universe.mjs +14 -2
- package/src/replay/replay.mjs +9 -8
- package/src/supply/concerns.mjs +14 -8
- package/src/sweep/sweep.mjs +2 -0
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
|
-
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
98
|
-
github
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
BRANCH
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 (
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/probe/contention.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
94
|
-
|
|
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
|
-
|
|
108
|
+
contentionAdvice(policy);
|
package/src/probe/cost.mjs
CHANGED
|
@@ -109,7 +109,13 @@ export function costReport(records, { run } = {}) {
|
|
|
109
109
|
};
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
|
|
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
|
-
|
|
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());
|
package/src/probe/probe.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
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 [{
|
|
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);
|
package/src/probe/universe.mjs
CHANGED
|
@@ -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
|
|
45
|
-
const
|
|
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
|
}
|