testguard-cli 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +255 -0
  2. package/README.md +275 -31
  3. package/package.json +1 -1
  4. package/spec/GATE-SEMANTICS.md +58 -1
  5. package/spec/README.md +1 -0
  6. package/spec/lib/validate.mjs +57 -1
  7. package/spec/schemas/baseline.schema.json +8 -0
  8. package/spec/schemas/common.schema.json +5 -1
  9. package/spec/schemas/evidence.schema.json +374 -63
  10. package/spec/schemas/replay.schema.json +85 -0
  11. package/spec/schemas/status.schema.json +256 -54
  12. package/src/admit/admit.mjs +17 -0
  13. package/src/baseline/baseline.mjs +31 -2
  14. package/src/brief/brief.mjs +71 -7
  15. package/src/claims/removed.mjs +161 -0
  16. package/src/cli.mjs +53 -10
  17. package/src/commands/admit.mjs +116 -0
  18. package/src/commands/baseline.mjs +17 -3
  19. package/src/commands/brief.mjs +13 -7
  20. package/src/commands/claims.mjs +35 -6
  21. package/src/commands/init.mjs +19 -6
  22. package/src/commands/mcp.mjs +12 -0
  23. package/src/commands/probe.mjs +11 -3
  24. package/src/commands/replay.mjs +63 -0
  25. package/src/commands/status.mjs +3 -1
  26. package/src/git.mjs +5 -0
  27. package/src/init/init.mjs +182 -29
  28. package/src/init/templates/SKILL.md +3 -3
  29. package/src/mcp/server.mjs +104 -0
  30. package/src/mcp/tools.mjs +114 -0
  31. package/src/probe/contention.mjs +60 -0
  32. package/src/probe/discover.mjs +12 -3
  33. package/src/probe/independence.mjs +59 -0
  34. package/src/probe/mocks.mjs +120 -0
  35. package/src/probe/probe.mjs +126 -18
  36. package/src/probe/rank.mjs +73 -15
  37. package/src/probe/runners/index.mjs +49 -5
  38. package/src/probe/runners/jest.mjs +3 -3
  39. package/src/probe/runners/playwright.mjs +133 -0
  40. package/src/probe/runners/shared.mjs +70 -13
  41. package/src/probe/runners/vitest.mjs +4 -3
  42. package/src/render.mjs +2 -2
  43. package/src/replay/label.mjs +64 -0
  44. package/src/replay/replay.mjs +309 -0
  45. package/src/scaffold/producers.mjs +217 -6
  46. package/src/scaffold/scaffold.mjs +9 -3
  47. package/src/status/status.mjs +47 -6
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { readSpecDoc, writeSpecDoc } from '../evidence/writer.mjs';
4
- import { buildBrief, buildUnclaimedBrief } from '../brief/brief.mjs';
4
+ import { buildBrief, buildUnclaimedBrief, renderBriefMarkdown, renderUnclaimedMarkdown } from '../brief/brief.mjs';
5
5
  import { computeStatus } from '../status/status.mjs';
6
6
  import { evidencePath, baselinePath } from './probe.mjs';
7
7
  import { resolveChangedRef, withChangedRef } from '../gate/changed.mjs';
@@ -13,15 +13,18 @@ export async function briefCommand({ projectDir, values, version }, io) {
13
13
  // A detected base that does not resolve is silent here: the brief is a
14
14
  // session-start hook's output and must never add noise; `status` and
15
15
  // `gate` are where that warning is printed.
16
- status = withChangedRef(resolveChangedRef({ explicit: values.changed }), (changedRef) => computeStatus({ projectDir, toolVersion: version, changedRef, includeDirty: values['include-dirty'] }));
16
+ status = withChangedRef(resolveChangedRef({ explicit: values.changed }), (changedRef) => computeStatus({ projectDir, toolVersion: version, changedRef, includeDirty: values['include-dirty'], evidence: values.evidence ? evPath : undefined }));
17
17
  } catch {
18
18
  status = undefined; // a brief must never fail because status could not be computed
19
19
  }
20
20
  if (!existsSync(evPath)) {
21
21
  // A missing brief must never break an agent's session start — but unclaimed
22
22
  // changes are still said, because the claim comes before the probe.
23
- if (values.text) {
24
- if (status?.changes?.uncovered?.length) io.out(buildUnclaimedBrief({ tool: { name: 'testguard', version }, next: status.next, changes: status.changes }).text.trimEnd());
23
+ if (values.text || values.markdown) {
24
+ if (status?.changes?.uncovered?.length) {
25
+ const args = { tool: { name: 'testguard', version }, next: status.next, changes: status.changes };
26
+ io.out((values.markdown ? renderUnclaimedMarkdown(args) : buildUnclaimedBrief(args).text).trimEnd());
27
+ }
25
28
  return 0;
26
29
  }
27
30
  io.err(`no evidence at ${evPath}; run \`testguard probe\` first`);
@@ -35,8 +38,11 @@ export async function briefCommand({ projectDir, values, version }, io) {
35
38
  io.err('--max must be an integer from 1 to 50');
36
39
  return 3;
37
40
  }
38
- const brief = buildBrief(evidence, baseline, { max, next: status?.next, changes: status?.changes });
39
- if (!values.text) writeSpecDoc('brief', values.out ? resolve(values.out) : join(projectDir, '.testguard', 'brief.json'), brief);
40
- io.out(values.json ? JSON.stringify(brief, null, 2) : brief.text.trimEnd());
41
+ // Where this binary came from, so a stale install is visible in the session-start context.
42
+ const install = /[\\/]node_modules[\\/]/.test(process.argv[1] ?? '') ? 'local install' : 'global';
43
+ const brief = buildBrief(evidence, baseline, { max, next: status?.next, changes: status?.changes, install });
44
+ if (!values.text && !values.markdown) writeSpecDoc('brief', values.out ? resolve(values.out) : join(projectDir, '.testguard', 'brief.json'), brief);
45
+ if (values.markdown) io.out(renderBriefMarkdown({ ...brief, provisional: Boolean(evidence.run.provisional) }, { hasBaseline: Boolean(baseline), total: evidence.records.length, install }).trimEnd());
46
+ else io.out(values.json ? JSON.stringify(brief, null, 2) : brief.text.trimEnd());
41
47
  return 0;
42
48
  }
@@ -2,29 +2,58 @@ import { resolve } from 'node:path';
2
2
  import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
3
3
  import { scanAnnotations, reconcile } from '../claims/annotations.mjs';
4
4
  import { resolveDefenders } from '../probe/runners/shared.mjs';
5
- import { discoverDefenders } from '../probe/discover.mjs';
5
+ import { discoverDefendersDetailed } from '../probe/discover.mjs';
6
+ import { classifyDefenders } from '../probe/mocks.mjs';
7
+ import { computeRemovedClaims, renderRemoved } from '../claims/removed.mjs';
8
+ import { evidencePath } from './probe.mjs';
6
9
 
7
- export async function claimsCommand({ projectDir, values }, io) {
10
+ export async function claimsCommand({ projectDir, values, version }, io) {
8
11
  const path = values.claims ? resolve(values.claims) : defaultClaimsPath(projectDir);
9
12
  const claims = loadClaims(path);
10
13
  const annotations = scanAnnotations(projectDir);
11
14
  const drift = reconcile(claims, annotations);
12
15
 
16
+ // A claim that disappeared is invisible to every other command; compare
17
+ // identities against a reference when one is given.
18
+ const removed = values.since
19
+ ? computeRemovedClaims({ projectDir, ref: values.since, claimsPath: path, current: claims, ignorePath: values.ignore ? resolve(values.ignore) : undefined, evidencePath: evidencePath(projectDir), toolVersion: version })
20
+ : undefined;
21
+
13
22
  if (values.json) {
14
- io.out(JSON.stringify({ path, claims, annotations, drift }, null, 2));
23
+ io.out(JSON.stringify({ path, claims, annotations, drift, ...(removed ? { removed } : {}) }, null, 2));
15
24
  } else {
16
25
  const annotated = new Set(drift.annotated);
17
26
  io.out(`${claims.claims.length} claims in ${path} — ${annotated.size} carry a @claim annotation in source (test files are not scanned)`);
18
27
  io.out('');
28
+ const signalLines = [];
19
29
  for (const c of claims.claims) {
20
30
  const declared = c.defendedBy?.length > 0;
21
- const defenders = declared ? resolveDefenders(projectDir, c.defendedBy) : [...new Set(c.faults.flatMap((f) => discoverDefenders(projectDir, f.file)))];
22
- const cover = defenders.length ? `${defenders.length} ${declared ? 'defender' : 'discovered'}${defenders.length === 1 ? '' : 's'}` : 'NO DEFENDER';
31
+ const targets = [...new Set(c.faults.map((f) => f.file))];
32
+ let cover;
33
+ if (declared) {
34
+ const defenders = resolveDefenders(projectDir, c.defendedBy);
35
+ const mocking = new Set();
36
+ for (const t of targets) { const m = classifyDefenders(projectDir, t, defenders); m.mocking.forEach((f) => mocking.add(f)); for (const s of m.signals) signalLines.push({ claim: c.id, target: t, ...s }); }
37
+ cover = defenders.length ? `${defenders.length} defender${defenders.length === 1 ? '' : 's'}${mocking.size ? ` (${mocking.size} mock the target)` : ''}` : 'NO DEFENDER';
38
+ } else {
39
+ const importing = new Set(); const mocking = new Set(); const can = new Set();
40
+ for (const t of targets) { const d = discoverDefendersDetailed(projectDir, t); d.importing.forEach((f) => importing.add(f)); d.mocking.forEach((f) => mocking.add(f)); d.canDetect.forEach((f) => can.add(f)); for (const s of d.signals) signalLines.push({ claim: c.id, target: t, ...s }); }
41
+ cover = can.size ? `${importing.size} import · ${mocking.size} mock · ${can.size} can detect` : importing.size ? `NO DEFENDER (${importing.size} import, all mock the target)` : 'NO DEFENDER';
42
+ }
23
43
  io.out(`${annotated.has(c.id) ? '@ ' : ' '}${c.id.padEnd(14)} ${c.severity.padEnd(8)} ${c.source.kind.padEnd(10)} ${String(c.faults.length).padStart(2)} fault${c.faults.length === 1 ? ' ' : 's'} ${cover.padEnd(12)} ${c.statement}`);
24
44
  }
45
+ if (signalLines.length) io.out('');
46
+ for (const s of signalLines) {
47
+ if (s.signal === 'mocked-never-asserted') io.out(`MOCKED-NEVER-ASSERTED ${s.file} mocks ${s.target} and never asserts on it (${s.claim}) — assert on the mocked call, drive the real module, or annotate the mock \`// unasserted: <why>\``);
48
+ else io.out(`unasserted (annotated) ${s.file} mocks ${s.target}: ${s.reason} (${s.claim})`);
49
+ }
25
50
  if (drift.undeclared.length || drift.stale.length) io.out('');
26
51
  for (const a of drift.undeclared) io.out(`UNDECLARED @claim ${a.id} at ${a.file}:${a.line} has no entry in the claims file — a claim with no fault model`);
27
52
  for (const c of drift.stale) io.out(`STALE ${c.id} is annotation-sourced but no source file carries @claim ${c.id}`);
53
+ if (removed) {
54
+ io.out('');
55
+ io.out(renderRemoved(removed));
56
+ }
28
57
  }
29
- return drift.undeclared.length || drift.stale.length ? 1 : 0;
58
+ return drift.undeclared.length || drift.stale.length || (removed?.removed.length ?? 0) > 0 ? 1 : 0;
30
59
  }
@@ -1,15 +1,28 @@
1
1
  import { initProject } from '../init/init.mjs';
2
2
 
3
3
  export async function initCommand({ projectDir, values }, io) {
4
- const { done, skipped } = initProject({ projectDir, force: values.force });
4
+ const r = initProject({ projectDir, force: values.force, here: values.here, ciEvidence: values['ci-evidence'], mcp: values.mcp });
5
+ const { done, skipped, warnings, agentRoot, dir, mcpConfig } = r;
5
6
  if (values.json) {
6
- io.out(JSON.stringify({ done, skipped }, null, 2));
7
- return 0;
7
+ io.out(JSON.stringify({ done, skipped, warnings, agentRoot, dir, ...(mcpConfig ? { mcpConfig } : {}) }, null, 2));
8
+ return warnings.length ? 1 : 0;
8
9
  }
9
10
  for (const d of done) io.out(`+ ${d}`);
10
11
  for (const s of skipped) io.out(`= ${s}`);
12
+ for (const w of warnings) io.out(`! ${w}`);
11
13
  io.out('');
12
- io.out('Agents in this project now start with the blind-spot brief and can run `testguard status --json` to learn what to do next.');
13
- io.out(done.length ? 'Commit these files.' : 'Nothing to change.');
14
- return 0;
14
+ if (dir !== '.') io.out(`Agent layer (skill, session-start hook, AGENTS.md section) installed at the git root ${agentRoot}, where agent sessions run; the project layer stays in ${dir}/. Use --here to keep everything in ${dir}/.`);
15
+ io.out(`Agents now start with the blind-spot brief and can run \`testguard status --json${dir === '.' ? '' : ` ${dir}`}\` to learn what to do next. The hook prefers a local install and never fetches from the network.`);
16
+ io.out(warnings.length ? 'Some written files are ignored by git — fix that before committing.' : done.length ? 'Commit these files.' : 'Nothing to change.');
17
+ if (mcpConfig) {
18
+ io.out('');
19
+ io.out('MCP — five READ-ONLY tools (status, brief, claims, evidence, next_command), so the loop survives a change of harness. Nothing there runs a probe. Add ONE of these:');
20
+ for (const [where, snippet] of Object.entries(mcpConfig)) {
21
+ io.out('');
22
+ io.out(` ${where}`);
23
+ const text = typeof snippet === 'string' ? snippet : JSON.stringify(snippet, null, 2);
24
+ for (const line of text.split('\n')) io.out(` ${line}`);
25
+ }
26
+ }
27
+ return warnings.length ? 1 : 0;
15
28
  }
@@ -0,0 +1,12 @@
1
+ import { serve } from '../mcp/server.mjs';
2
+
3
+ export async function mcpCommand({ values, version }, io) {
4
+ if (values.json) {
5
+ // Not a JSON command: it is a long-lived server on stdio, and printing
6
+ // anything to stdout would corrupt the protocol stream.
7
+ io.err('mcp speaks JSON-RPC on stdin/stdout; there is no --json mode');
8
+ return 3;
9
+ }
10
+ await serve({ version });
11
+ return 0;
12
+ }
@@ -30,6 +30,7 @@ export async function probeCommand({ projectDir, values, version }, io) {
30
30
  // Provisional and partial runs never overwrite the canonical evidence: only confirmed, complete runs may feed a baseline.
31
31
  const outPath = values.out ? resolve(values.out) : only ? join(projectDir, '.testguard', 'evidence-partial.json') : provisional ? provisionalEvidencePath(projectDir) : evidencePath(projectDir);
32
32
  if (provisional && !values.quiet) io.err(PROVISIONAL_WARNING(confirmRuns));
33
+ const discoveryNoted = new Set();
33
34
  const previous = !values['no-reuse'] && existsSync(outPath) ? readSpecDoc('evidence', outPath) : undefined;
34
35
  const basePath = values.baseline ? resolve(values.baseline) : baselinePath(projectDir);
35
36
  const baseline = existsSync(basePath) ? readSpecDoc('baseline', basePath) : undefined;
@@ -41,7 +42,11 @@ export async function probeCommand({ projectDir, values, version }, io) {
41
42
  confirmRuns,
42
43
  budgetMs,
43
44
  mode: values['in-place'] ? 'in-place' : 'worktree',
44
- ref: values.ref,
45
+ ref: values.ref ?? 'HEAD',
46
+ refExplicit: values.ref !== undefined,
47
+ ignoreDirty: values['ignore-dirty'],
48
+ serial: values.serial,
49
+ onWarn: (m) => io.err(`warning: ${m}`),
45
50
  runnerCommand: values['runner-cmd'],
46
51
  runnerName: values.runner,
47
52
  nodeModules: values['node-modules'] ? resolve(values['node-modules']) : process.env.TESTGUARD_NODE_MODULES,
@@ -52,14 +57,17 @@ export async function probeCommand({ projectDir, values, version }, io) {
52
57
  onStage: !values.quiet && !values.json && process.stderr.isTTY ? ({ claimId, faultId, stage, i, n }) => process.stderr.write(`\r\x1b[K … ${claimId}/${faultId} ${stage} ${i}/${n}`) : undefined,
53
58
  onProgress: values.quiet || values.json ? undefined : (r) => {
54
59
  if (process.stderr.isTTY) process.stderr.write('\r\x1b[K');
55
- if (values.verbose || r.verdict !== 'killed') io.out(renderRecord(r, { provisional }) + (r.reusedFrom ? ' (reused)' : ''));
60
+ // The discovery note is a property of the claim, not of each fault: say it once.
61
+ const firstOfClaim = !discoveryNoted.has(r.claim.id);
62
+ if (r.defenders.discovered) discoveryNoted.add(r.claim.id);
63
+ if (values.verbose || r.verdict !== 'killed') io.out(renderRecord(r, { provisional, showDiscovered: firstOfClaim }) + (r.reusedFrom ? ' (reused)' : ''));
56
64
  },
57
65
  });
58
66
  writeSpecDoc('evidence', outPath, evidence);
59
67
 
60
68
  const g = gate(evidence.records, baseline, { severityFloor: values.severity });
61
69
  if (values.json) {
62
- const status = withChangedRef(resolveChangedRef({ explicit: values.changed }), (changedRef) => computeStatus({ projectDir, toolVersion: version, changedRef, includeDirty: values['include-dirty'] }), io.err);
70
+ const status = withChangedRef(resolveChangedRef({ explicit: values.changed }), (changedRef) => computeStatus({ projectDir, toolVersion: version, changedRef, includeDirty: values['include-dirty'], evidence: outPath }), io.err);
63
71
  io.out(JSON.stringify({ ...status, run: { id: evidence.run.id, evidence: outPath, provisional, records: evidence.records.length, newSinceBaseline: g.new.length, exitCode: g.new.length > 0 ? 1 : 0 } }, null, 2));
64
72
  return g.new.length > 0 ? 1 : 0;
65
73
  }
@@ -0,0 +1,63 @@
1
+ import { dirname, join, resolve } from 'node:path';
2
+ import { replay, calibrationFrom, renderReplay } from '../replay/replay.mjs';
3
+ import { writeSpecDoc } from '../evidence/writer.mjs';
4
+
5
+ export const replayPath = (projectDir) => join(projectDir, '.testguard', 'replay.json');
6
+ export const calibrationPath = (projectDir) => join(projectDir, '.testguard', 'calibration.json');
7
+
8
+ export async function replayCommand({ projectDir, values, version }, io) {
9
+ const range = values.since ?? values.changed;
10
+ if (!range) {
11
+ io.err('usage: testguard replay [dir] --since <range> (e.g. --since HEAD~50..HEAD, or a tag)');
12
+ return 3;
13
+ }
14
+ const confirmRuns = Number(values.confirm);
15
+ const budgetMs = Number(values.budget);
16
+ const limit = values.max ? Number(values.max) : undefined;
17
+ if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
18
+ io.err('--confirm must be a positive integer and --budget at least 1000');
19
+ return 3;
20
+ }
21
+
22
+ const doc = await replay({
23
+ projectDir,
24
+ range,
25
+ confirmRuns,
26
+ budgetMs,
27
+ runnerCommand: values['runner-cmd'],
28
+ runnerName: values.runner,
29
+ nodeModules: values['node-modules'] ? resolve(values['node-modules']) : process.env.TESTGUARD_NODE_MODULES,
30
+ limit,
31
+ toolVersion: version,
32
+ onStage: !values.quiet && !values.json && process.stderr.isTTY ? ({ commit, i, n, stage }) => process.stderr.write(`\r\x1b[K … ${commit.slice(0, 9)} (${i}/${n}) ${stage}`) : undefined,
33
+ onProgress: values.quiet || values.json ? undefined : () => {
34
+ if (process.stderr.isTTY) process.stderr.write('\r\x1b[K');
35
+ },
36
+ });
37
+
38
+ const outPath = values.out ? resolve(values.out) : replayPath(projectDir);
39
+ writeSpecDoc('replay', outPath, doc);
40
+ const calibration = calibrationFrom(doc, { toolVersion: version });
41
+ // Beside the replay document, whatever --out says: the two are one result,
42
+ // and splitting them across directories loses the pairing — and leaves a
43
+ // file behind in a repository the run is only meant to read.
44
+ const calPath = values.baseline ? resolve(values.baseline) : values.out ? join(dirname(outPath), 'calibration.json') : calibrationPath(projectDir);
45
+ writeSpecDoc('calibration', calPath, calibration);
46
+
47
+ if (values.json) {
48
+ io.out(JSON.stringify({ replay: doc, calibration, paths: { replay: outPath, calibration: calPath } }, null, 2));
49
+ } else {
50
+ io.out(renderReplay(doc));
51
+ io.out('');
52
+ for (const [cls, b] of Object.entries(calibration.buckets).sort((a, b2) => b2[1].n - a[1].n)) {
53
+ io.out(` ${cls.padEnd(22)} blind ${String(b.positives).padStart(3)}/${String(b.n).padEnd(3)} p=${b.p.toFixed(2)} ci [${b.ci[0].toFixed(2)}, ${b.ci[1].toFixed(2)}]`);
54
+ }
55
+ if (Object.keys(calibration.buckets).length === 0) io.out(' no measurable bug in this range: nothing to calibrate from yet.');
56
+ io.out('');
57
+ io.out(`replay: ${outPath}`);
58
+ io.out(`calibration: ${calPath}`);
59
+ }
60
+ // Replay reports; it never gates. A bug that escaped is history, not a
61
+ // regression in this change, and a gate on history is a gate nobody can pass.
62
+ return 0;
63
+ }
@@ -1,9 +1,11 @@
1
+ import { resolve } from 'node:path';
1
2
  import { computeStatus, renderStatus } from '../status/status.mjs';
2
3
  import { validate } from '../../spec/lib/validate.mjs';
3
4
  import { resolveChangedRef, withChangedRef } from '../gate/changed.mjs';
4
5
 
5
6
  export async function statusCommand({ projectDir, values, version }, io) {
6
- const doc = withChangedRef(resolveChangedRef({ explicit: values.changed }), (changedRef) => computeStatus({ projectDir, toolVersion: version, changedRef, includeDirty: values['include-dirty'] }), io.err);
7
+ const evidence = values.evidence ? resolve(values.evidence) : undefined;
8
+ const doc = withChangedRef(resolveChangedRef({ explicit: values.changed }), (changedRef) => computeStatus({ projectDir, toolVersion: version, changedRef, includeDirty: values['include-dirty'], evidence }), io.err);
7
9
  const result = validate('status', doc);
8
10
  if (!result.ok) throw new Error(`status document does not conform: ${result.errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`);
9
11
  io.out(values.json ? JSON.stringify(doc, null, 2) : renderStatus(doc));
package/src/git.mjs CHANGED
@@ -23,6 +23,11 @@ export function headSha(dir, ref = 'HEAD') {
23
23
  }
24
24
  }
25
25
 
26
+ /** Is `ancestor` reachable from `ref`? False when either does not resolve. */
27
+ export function isAncestor(dir, ancestor, ref = 'HEAD') {
28
+ return spawnSync('git', ['merge-base', '--is-ancestor', ancestor, ref], { cwd: dir }).status === 0;
29
+ }
30
+
26
31
  /** True when any of `paths` (repo-relative; empty = whole tree) has uncommitted changes. */
27
32
  export const isDirty = (dir, paths = []) => git(['status', '--porcelain', '--', ...paths], dir).length > 0;
28
33
 
package/src/init/init.mjs CHANGED
@@ -1,68 +1,198 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { join, dirname } from 'node:path';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, realpathSync } from 'node:fs';
2
+ import { join, dirname, relative, resolve, sep } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { spawnSync } from 'node:child_process';
4
5
 
5
6
  const TEMPLATES = join(dirname(fileURLToPath(import.meta.url)), 'templates');
6
- const HOOK_CMD = 'npx -y testguard-cli brief --text';
7
7
  const AGENTS_BEGIN = '<!-- testguard:begin -->';
8
8
  const AGENTS_END = '<!-- testguard:end -->';
9
- const AGENTS_BLOCK = `${AGENTS_BEGIN}
9
+ const GITIGNORE_LINES = ['.testguard/evidence.json', '.testguard/evidence-provisional.json', '.testguard/evidence-partial.json', '.testguard/brief.json', '.testguard/gate.json', '.testguard/scaffold-*.json'];
10
+
11
+ /**
12
+ * The session-start hook command. It runs from the git root (where the agent
13
+ * session lives) and must never fetch from the network: a local install of
14
+ * the project (or of the root) is preferred, then a `testguard` already on
15
+ * PATH, then nothing — the hook exits 0 with no output rather than break a
16
+ * session.
17
+ *
18
+ * No form of `npx` appears here, and `--no-install` is not an exception.
19
+ * Measured: in a project with nothing installed,
20
+ * `npx --no-install --loglevel=http testguard-cli --version` logs
21
+ * `npm http fetch GET 200 https://registry.npmjs.org/testguard-cli`, and
22
+ * against an unreachable registry it exits non-zero. npm resolves the
23
+ * packument before it decides not to install, so `--no-install` is quiet, not
24
+ * offline. A `command -v` lookup covers the global-install case with no
25
+ * network at all, which is the whole point in a repository that pins and
26
+ * audits its dependencies.
27
+ */
28
+ export function hookCommand(dir) {
29
+ const arg = dir === '.' ? '' : ` ${dir}`;
30
+ const candidates = dir === '.' ? ['node_modules/.bin/testguard'] : [`${dir}/node_modules/.bin/testguard`, 'node_modules/.bin/testguard'];
31
+ // The PATH branch is braced: `a || b && c` binds as `(a || b) && c` in sh,
32
+ // so an unbraced `&&` would run the brief a second time whenever the local
33
+ // binary succeeded.
34
+ const onPath = `{ command -v testguard >/dev/null 2>&1 && testguard brief --text${arg} 2>/dev/null; }`;
35
+ return [...candidates.map((c) => `${c} brief --text${arg} 2>/dev/null`), onPath, 'true'].join(' || ');
36
+ }
37
+
38
+ function agentsBlock(entries) {
39
+ const lines = entries.map((dir) => (dir === '.'
40
+ ? '- This project: claims in `testguard.claims.json`; run `testguard status --json` and follow `next`.'
41
+ : `- \`${dir}/\`: claims in \`${dir}/testguard.claims.json\`; run \`testguard status --json ${dir}\` and follow \`next\`.`));
42
+ return `${AGENTS_BEGIN}
10
43
  ## TestGuard
11
44
 
12
- This project's tests are verified by [TestGuard](https://github.com/raccioly/testguard).
13
- Before writing or changing tests, run \`testguard status --json\` and follow \`next\`.
45
+ This repository's tests are verified by [TestGuard](https://github.com/raccioly/testguard).
46
+ Before writing or changing tests, run \`testguard status --json [dir]\` and follow \`next\`.
14
47
  The full operating loop and the verdict table are in \`.claude/skills/testguard/SKILL.md\`.
15
48
  Never make a fault die by editing \`testguard.claims.json\`; write the test. Claim edits are recorded in the evidence.
49
+
50
+ ${lines.join('\n')}
16
51
  ${AGENTS_END}`;
17
- const GITIGNORE_LINES = ['.testguard/evidence.json', '.testguard/evidence-provisional.json', '.testguard/evidence-partial.json', '.testguard/brief.json', '.testguard/gate.json', '.testguard/scaffold-*.json'];
52
+ }
53
+
54
+ /** The git root of `dir`, or null outside a repository. */
55
+ export function gitRoot(dir) {
56
+ const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: dir, encoding: 'utf8' });
57
+ return r.status === 0 ? r.stdout.trim() : null;
58
+ }
59
+
60
+ /** Is `file` (absolute) ignored by git in `root`? False outside a repository. */
61
+ export function isGitIgnored(root, file) {
62
+ if (!root) return false;
63
+ return spawnSync('git', ['check-ignore', '-q', '--', file], { cwd: root }).status === 0;
64
+ }
18
65
 
19
66
  /**
20
- * Install the agent operating layer into a consumer project: the skill, the
21
- * session-start hook, an AGENTS.md section and the .gitignore lines.
22
- * Idempotent: re-running changes nothing unless --force replaces the skill.
67
+ * Install the operating layer.
68
+ *
69
+ * Two layers, two places. The AGENT layer — the skill, the session-start
70
+ * hook, the AGENTS.md section — goes to the git root, because that is where
71
+ * an agent session runs; installed in a subdirectory it never fires and is
72
+ * never read (a field report installed it in a backend/ folder and got
73
+ * nothing). The PROJECT layer — the `.gitignore` lines beside the claims
74
+ * file — stays in the project directory. `here: true` keeps everything in
75
+ * the project directory for a subdirectory that is its own agent root.
76
+ *
77
+ * Idempotent per project: a second project in the same repository adds a
78
+ * hook line and an AGENTS.md bullet, never duplicates. Every written file is
79
+ * checked against .gitignore; an ignored file is reported as ignored, never
80
+ * as something to commit.
23
81
  */
24
- export function initProject({ projectDir, force = false }) {
82
+ const CI_EVIDENCE_HELPERS = {
83
+ github: `#!/bin/sh
84
+ # Fetch the evidence CI wrote on a branch, then brief from it. Run it when you
85
+ # want it: the session-start hook never touches the network, and this script
86
+ # exits 0 with a message whenever anything is missing.
87
+ BRANCH="\${1:-main}"
88
+ OUT=.testguard/ci
89
+ command -v gh >/dev/null 2>&1 || { echo "gh is not installed; see README > Read CI's evidence locally" >&2; exit 0; }
90
+ 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; }
91
+ mkdir -p "$OUT" || exit 0
92
+ gh run download --name "testguard-evidence-$BRANCH" --dir "$OUT" >/dev/null 2>&1 || { echo "no testguard-evidence-$BRANCH artifact to download" >&2; exit 0; }
93
+ exec testguard brief . --text --evidence "$OUT/ci-self-evidence.json"
94
+ `,
95
+ gitlab: `#!/bin/sh
96
+ # Fetch the evidence CI wrote on a branch, then brief from it. Run it when you
97
+ # want it: the session-start hook never touches the network, and this script
98
+ # exits 0 with a message whenever anything is missing.
99
+ BRANCH="\${1:-main}"
100
+ OUT=.testguard/ci
101
+ command -v glab >/dev/null 2>&1 || { echo "glab is not installed; see README > Read CI's evidence locally" >&2; exit 0; }
102
+ 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; }
103
+ mkdir -p "$OUT" || exit 0
104
+ glab ci artifact "$BRANCH" testguard:probe --path "$OUT/" >/dev/null 2>&1 || { echo "no testguard:probe artifact to download for $BRANCH" >&2; exit 0; }
105
+ exec testguard brief . --text --evidence "$OUT/.testguard/evidence.json"
106
+ `,
107
+ };
108
+
109
+ export function initProject({ projectDir, force = false, here = false, ciEvidence, mcp = false }) {
110
+ projectDir = realpathSync(resolve(projectDir));
111
+ const root = here ? null : gitRoot(projectDir);
112
+ const agentRoot = root ?? projectDir;
113
+ const ignoreRoot = root ?? gitRoot(projectDir);
114
+ const dir = (relative(agentRoot, projectDir).split(sep).join('/')) || '.';
25
115
  const done = [];
116
+ const notes = [];
117
+ let mcpConfig;
26
118
  const skipped = [];
119
+ const warnings = [];
120
+ const rel = (p) => relative(agentRoot, p).split(sep).join('/');
121
+ 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`); };
27
122
 
28
- const skillDir = join(projectDir, '.claude', 'skills', 'testguard');
123
+ const skillDir = join(agentRoot, '.claude', 'skills', 'testguard');
29
124
  const skillPath = join(skillDir, 'SKILL.md');
30
125
  if (!existsSync(skillPath) || force) {
126
+ const replaced = force && existsSync(skillPath);
31
127
  mkdirSync(skillDir, { recursive: true });
32
128
  writeFileSync(skillPath, readFileSync(join(TEMPLATES, 'SKILL.md'), 'utf8'));
33
- done.push(`.claude/skills/testguard/SKILL.md${force && existsSync(skillPath) ? ' (replaced)' : ''}`);
129
+ done.push(`${rel(skillPath)}${replaced ? ' (replaced)' : ''}`);
130
+ note(skillPath);
34
131
  } else {
35
- skipped.push('.claude/skills/testguard/SKILL.md exists (use --force to replace)');
132
+ skipped.push(`${rel(skillPath)} exists (use --force to replace)`);
36
133
  }
37
134
 
38
- const settingsPath = join(projectDir, '.claude', 'settings.json');
135
+ const settingsPath = join(agentRoot, '.claude', 'settings.json');
39
136
  let settings = {};
40
137
  if (existsSync(settingsPath)) {
41
138
  try {
42
139
  settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
43
140
  } catch (e) {
44
- throw new Error(`.claude/settings.json is not valid JSON (${e.message}); fix it before init can add the hook`);
141
+ throw new Error(`${rel(settingsPath)} is not valid JSON (${e.message}); fix it before init can add the hook`);
45
142
  }
46
143
  }
47
144
  settings.hooks ??= {};
48
145
  settings.hooks.SessionStart ??= [];
49
- const already = JSON.stringify(settings.hooks.SessionStart).includes(HOOK_CMD);
50
- if (already) {
51
- skipped.push('SessionStart hook already present in .claude/settings.json');
146
+ const cmd = hookCommand(dir);
147
+ const hasCmd = settings.hooks.SessionStart.some((e) => JSON.stringify(e).includes(cmd));
148
+ // Any earlier hook that reached for npx — `-y` fetched outright, and
149
+ // `--no-install` still resolves the packument from the registry — is replaced.
150
+ // Which project a hook is for is the token right after `brief --text`, if
151
+ // that token is an argument rather than a redirect or a shell operator.
152
+ // Testing for any non-space there mistook `brief --text 2>/dev/null` for a
153
+ // hook belonging to a directory, so a legacy root hook was never replaced.
154
+ const hookDir = (text) => {
155
+ const m = /brief --text(?:\s+([^\s"|;&]+))?/.exec(text);
156
+ const token = m?.[1];
157
+ return !token || token.startsWith('2>') || token.startsWith('>') ? '.' : token;
158
+ };
159
+ const legacy = settings.hooks.SessionStart.findIndex((e) => {
160
+ const text = JSON.stringify(e);
161
+ return /npx\s+(-y|--no-install)[^"]*brief --text/.test(text) && hookDir(text) === dir;
162
+ });
163
+ if (hasCmd) {
164
+ skipped.push(`SessionStart hook for ${dir === '.' ? 'this project' : dir} already present in ${rel(settingsPath)}`);
52
165
  } else {
53
- settings.hooks.SessionStart.push({ hooks: [{ type: 'command', command: HOOK_CMD }] });
166
+ if (legacy !== -1) {
167
+ settings.hooks.SessionStart.splice(legacy, 1);
168
+ done.push(`${rel(settingsPath)}: replaced the network-reaching npx hook with a local-then-PATH one`);
169
+ }
170
+ settings.hooks.SessionStart.push({ hooks: [{ type: 'command', command: cmd }] });
54
171
  mkdirSync(dirname(settingsPath), { recursive: true });
55
172
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
56
- done.push('.claude/settings.json: SessionStart hook → brief --text');
173
+ done.push(`${rel(settingsPath)}: SessionStart hook → brief --text${dir === '.' ? '' : ` ${dir}`} (local install first, npx --no-install fallback, never a fetch)`);
174
+ note(settingsPath);
57
175
  }
58
176
 
59
- const agentsPath = join(projectDir, 'AGENTS.md');
177
+ const agentsPath = join(agentRoot, 'AGENTS.md');
60
178
  const agents = existsSync(agentsPath) ? readFileSync(agentsPath, 'utf8') : '';
61
- if (agents.includes(AGENTS_BEGIN)) {
62
- skipped.push('AGENTS.md already has the TestGuard section');
179
+ const begin = agents.indexOf(AGENTS_BEGIN);
180
+ const end = agents.indexOf(AGENTS_END);
181
+ if (begin !== -1 && end !== -1) {
182
+ const block = agents.slice(begin, end + AGENTS_END.length);
183
+ const entries = [...block.matchAll(/^- (?:This project|`([^`]+)\/`)/gm)].map((m) => m[1] ?? '.');
184
+ const legacyBlock = entries.length === 0; // a pre-0.6 section with no project list
185
+ if (entries.includes(dir)) {
186
+ skipped.push(`AGENTS.md already has the TestGuard section for ${dir === '.' ? 'this project' : dir}`);
187
+ } else {
188
+ writeFileSync(agentsPath, agents.slice(0, begin) + agentsBlock([...entries, dir]) + agents.slice(end + AGENTS_END.length));
189
+ done.push(legacyBlock ? `AGENTS.md: TestGuard section rewritten to list ${dir}` : `AGENTS.md: TestGuard section now lists ${dir}`);
190
+ note(agentsPath);
191
+ }
63
192
  } else {
64
- writeFileSync(agentsPath, (agents ? agents.replace(/\s*$/, '\n\n') : '# Agent instructions\n\n') + AGENTS_BLOCK + '\n');
193
+ writeFileSync(agentsPath, (agents ? agents.replace(/\s*$/, '\n\n') : '# Agent instructions\n\n') + agentsBlock([dir]) + '\n');
65
194
  done.push(agents ? 'AGENTS.md: TestGuard section appended' : 'AGENTS.md created with the TestGuard section');
195
+ note(agentsPath);
66
196
  }
67
197
 
68
198
  const giPath = join(projectDir, '.gitignore');
@@ -71,12 +201,35 @@ export function initProject({ projectDir, force = false }) {
71
201
  const dirIgnored = entries.some((x) => /^\/?\.testguard\/?$/.test(x));
72
202
  const missing = dirIgnored ? [] : GITIGNORE_LINES.filter((l) => !entries.includes(l));
73
203
  if (dirIgnored) {
74
- skipped.push('.gitignore ignores .testguard/ entirely — note that baseline.json should be committed; ignore only the regenerated files if you adopt a baseline');
204
+ skipped.push(`${rel(giPath)} ignores .testguard/ entirely — note that baseline.json should be committed; ignore only the regenerated files if you adopt a baseline`);
75
205
  } else if (missing.length) {
76
206
  writeFileSync(giPath, (gi ? gi.replace(/\s*$/, '\n') : '') + '# TestGuard: regenerated per run (baseline.json IS committed)\n' + missing.join('\n') + '\n');
77
- done.push(`.gitignore: ${missing.length} line${missing.length === 1 ? '' : 's'} added`);
207
+ done.push(`${rel(giPath)}: ${missing.length} line${missing.length === 1 ? '' : 's'} added`);
78
208
  } else {
79
- skipped.push('.gitignore already ignores the regenerated files');
209
+ skipped.push(`${rel(giPath)} already ignores the regenerated files`);
210
+ }
211
+ if (ciEvidence) {
212
+ const helperPath = join(projectDir, '.testguard', 'fetch-ci-evidence.sh');
213
+ const body = CI_EVIDENCE_HELPERS[ciEvidence];
214
+ if (!body) throw new Error(`--ci-evidence must be github or gitlab, not ${ciEvidence}`);
215
+ if (!existsSync(helperPath) || force) {
216
+ mkdirSync(dirname(helperPath), { recursive: true });
217
+ writeFileSync(helperPath, body, { mode: 0o755 });
218
+ done.push(`.testguard/fetch-ci-evidence.sh (${ciEvidence}) — run it on demand; the session-start hook stays offline`);
219
+ } else {
220
+ skipped.push('.testguard/fetch-ci-evidence.sh exists (use --force to replace)');
221
+ }
222
+ }
223
+ if (mcp) {
224
+ // Printed, never written: a harness's own config is the person's file
225
+ // (and often global), so init shows the snippet and lets them place it.
226
+ const cmd = { command: 'npx', args: ['-y', 'testguard-cli', 'mcp'] };
227
+ mcpConfig = {
228
+ 'Claude Code — .mcp.json in the project root, or `claude mcp add`': { mcpServers: { testguard: cmd } },
229
+ 'Cursor — .cursor/mcp.json': { mcpServers: { testguard: cmd } },
230
+ 'Codex CLI — ~/.codex/config.toml': '[mcp_servers.testguard]\ncommand = "npx"\nargs = ["-y", "testguard-cli", "mcp"]',
231
+ };
232
+ notes.push('MCP: five read-only tools (status, brief, claims, evidence, next_command). Nothing there runs a probe.');
80
233
  }
81
- return { done, skipped };
234
+ return { done, skipped, warnings, agentRoot, projectDir, dir, ...(mcpConfig ? { mcpConfig } : {}) };
82
235
  }
@@ -45,7 +45,7 @@ code by construction. So, for every source file you create or change:
45
45
  thin wrapper whose logic is claimed elsewhere): a `path` entry in
46
46
  `testguard.ignore.json` with a reason and, where possible, an `expires`.
47
47
  The gate prints every entry it relied on; a reviewer reads them.
48
- 4. Then `testguard probe --claim <ID> --include-dirty` for the new claim.
48
+ 4. Then `testguard admit <test-file> --claim <ID>` for the new claim's test (or `testguard probe --claim <ID> --include-dirty` for the full report).
49
49
 
50
50
  ## Verdict → action
51
51
 
@@ -63,8 +63,8 @@ code by construction. So, for every source file you create or change:
63
63
 
64
64
  1. `testguard status --json` → take `next.target`.
65
65
  2. Write the test. It must **fail when the fault is applied and pass on HEAD**. To check the first half by hand: apply `find` → `replace` in the target file, run the defender, restore.
66
- 3. `testguard probe --claim <ID> --include-dirty` — probes your uncommitted test without touching the tree. `--confirm 1` gives a fast **provisional** signal; a `?` on a verdict means unconfirmed.
67
- 4. When it is `killed` at `--confirm 3`, commit the test.
66
+ 3. `testguard admit <test-file> --claim <ID>` — the two gates as one verb, on your uncommitted test, without touching the tree: `ADMITTED` (exit 0) means the test is green on HEAD and fails on every fault of the claim, 3/3; `NOT ADMITTED` (exit 1) names the first blocking fault and what to do. `--fault <FID>` judges one fault; `--confirm 1` gives a fast **provisional** `ADMITTED?` that must be confirmed at 3 before you commit. (`testguard probe --claim <ID> --include-dirty` is the same run with the full report.)
67
+ 4. When it is `ADMITTED` at `--confirm 3`, commit the test.
68
68
  5. When everything is killed or baselined, `testguard baseline` if `next` says so, and commit `.testguard/baseline.json`.
69
69
 
70
70
  ## Rules that are not yours to bend