testguard-cli 0.16.0 → 0.17.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 (62) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +152 -13
  3. package/package.json +1 -1
  4. package/spec/GATE-SEMANTICS.md +295 -0
  5. package/spec/README.md +27 -2
  6. package/spec/lib/authoring-input.mjs +40 -0
  7. package/spec/lib/origins.mjs +56 -0
  8. package/spec/lib/validate.mjs +120 -1
  9. package/spec/schemas/annotations.schema.json +65 -0
  10. package/spec/schemas/authoring-input.schema.json +67 -0
  11. package/spec/schemas/brief.schema.json +2 -0
  12. package/spec/schemas/claims.schema.json +2 -8
  13. package/spec/schemas/common.schema.json +73 -0
  14. package/spec/schemas/evidence.schema.json +37 -8
  15. package/spec/schemas/status.schema.json +6 -2
  16. package/src/brief/brief.mjs +11 -4
  17. package/src/claims/annotation-placement.mjs +357 -0
  18. package/src/claims/annotations.mjs +61 -2
  19. package/src/cli.mjs +40 -5
  20. package/src/commands/admit.mjs +13 -0
  21. package/src/commands/annotations.mjs +67 -0
  22. package/src/commands/claims.mjs +8 -4
  23. package/src/commands/probe.mjs +50 -7
  24. package/src/commands/replay.mjs +7 -0
  25. package/src/commands/scaffold.mjs +49 -2
  26. package/src/commands/sweep.mjs +7 -0
  27. package/src/evidence/writer.mjs +12 -4
  28. package/src/git.mjs +4 -4
  29. package/src/init/templates/SKILL.md +35 -0
  30. package/src/probe/attribution.mjs +10 -0
  31. package/src/probe/contention.mjs +20 -2
  32. package/src/probe/cost.mjs +6 -1
  33. package/src/probe/inject.mjs +2 -6
  34. package/src/probe/origin-policy.mjs +69 -0
  35. package/src/probe/policy-freshness.mjs +74 -0
  36. package/src/probe/probe.mjs +48 -23
  37. package/src/probe/runners/discovery.mjs +2 -0
  38. package/src/probe/runners/jest.mjs +2 -2
  39. package/src/probe/runners/lifecycle.mjs +58 -0
  40. package/src/probe/runners/playwright.mjs +6 -2
  41. package/src/probe/runners/python.mjs +10 -4
  42. package/src/probe/runners/shared.mjs +12 -6
  43. package/src/probe/runners/vitest.mjs +3 -3
  44. package/src/probe/universe.mjs +47 -0
  45. package/src/probe/worktree.mjs +3 -1
  46. package/src/render.mjs +17 -1
  47. package/src/replay/replay.mjs +8 -4
  48. package/src/scaffold/admission.mjs +117 -0
  49. package/src/scaffold/append.mjs +41 -0
  50. package/src/scaffold/apply-append.mjs +71 -0
  51. package/src/scaffold/fix-input.mjs +119 -0
  52. package/src/scaffold/fix-paths.mjs +90 -0
  53. package/src/scaffold/intent.mjs +2 -0
  54. package/src/scaffold/open-append.mjs +44 -0
  55. package/src/scaffold/plan-append.mjs +37 -0
  56. package/src/scaffold/plan-intent-input.mjs +38 -0
  57. package/src/scaffold/prepare-append.mjs +46 -0
  58. package/src/scaffold/request.mjs +30 -0
  59. package/src/scaffold/scaffold.mjs +23 -10
  60. package/src/status/status.mjs +34 -9
  61. package/src/sweep/pool.mjs +33 -0
  62. package/src/sweep/sweep.mjs +9 -4
package/src/cli.mjs CHANGED
@@ -20,6 +20,8 @@ import { mcpCommand } from './commands/mcp.mjs';
20
20
  import { admitCommand } from './commands/admit.mjs';
21
21
  import { sweepCommand } from './commands/sweep.mjs';
22
22
  import { concernsCommand } from './commands/concerns.mjs';
23
+ import { originPolicyRequest } from './probe/origin-policy.mjs';
24
+ import { draftAppendRequest, intentInputRequest } from './scaffold/request.mjs';
23
25
 
24
26
  const VERSION = JSON.parse(readFileSync(join(fileURLToPath(import.meta.url), '..', '..', 'package.json'), 'utf8')).version;
25
27
  const ISSUES = 'https://github.com/raccioly/testguard/issues';
@@ -44,10 +46,12 @@ probe
44
46
  --claims <path> claims file (default: <dir>/testguard.claims.json)
45
47
  --confirm <n> runs per verdict (default: 3)
46
48
  --budget <ms> wall clock per run (default: 120000)
47
- --command-budget <ms> cooperative measurement deadline for probe, sweep, or replay. Caps async children; expiry exits 2 and writes no partial result
49
+ --command-budget <ms> cooperative measurement deadline for probe, sweep, replay, or admit. Caps async children; expiry exits 2 and writes no partial result
48
50
  --out <path> evidence file (default: <dir>/.testguard/evidence.json)
49
51
  --baseline <path> baseline to gate against (default: <dir>/.testguard/baseline.json if present)
50
52
  --severity <level> gate only at or above (default: low)
53
+ --require-origin <kinds> complete confirmed probe only; repeatable comma-separated declared source kinds.
54
+ No kind authenticates independence. Policy failures ignore baseline debt and severity floors.
51
55
  --ref <commit> probe this commit in the scratch worktree (default: HEAD). An explicit --ref is honoured even when
52
56
  defender/target files are dirty: a warning names them, the evidence records them (repo.ignoredDirty)
53
57
  --ignore-dirty probe HEAD as committed although defender/target files are dirty (same warning and record)
@@ -69,6 +73,7 @@ probe
69
73
  --node-modules <dir> node_modules to link into the scratch worktree (or TESTGUARD_NODE_MODULES)
70
74
  --python <path> Python interpreter for .py defenders (or TESTGUARD_PYTHON; default: $VIRTUAL_ENV, then the project's
71
75
  .venv/venv, then python3 on PATH). Resolved against your working tree, never the scratch worktree.
76
+ --workers <n> maximum built-in runner workers (default: 1); Python remains serial
72
77
  --serial run one test file at a time (vitest --no-file-parallelism, jest --runInBand, playwright --workers=1,
73
78
  pytest -p no:xdist);
74
79
  use it when another test runner is already running — probe warns and records the contention either way
@@ -80,13 +85,19 @@ probe
80
85
  --json the status document plus this run's result (records, newSinceBaseline, exitCode)
81
86
 
82
87
  scaffold --claim <ID> (put every proposal under this claim; copies it if it exists) --out <path> --json
88
+ --from-document <local-text-path> OR --from-fix <full-commit-ID> inspects read-only authoring inputs;
89
+ no positional sources or other options except --json/help/version. Stdout only; verification not performed.
90
+ Document text is not emitted; fix counts retain exclusions. Next: supply independent intent, then review/probe.
91
+ <source...> --into <existing-draft.json> --claim <ID> appends faults to supplied intent;
92
+ --json previews without writes. Without --json, updates only that draft with private recovery.
93
+ No --out/--claims or unrelated options; never canonical claims/evidence/baseline. Unproven until reviewed/probed.
83
94
  sweep --changed <ref> (required unless --save-paths; CI bases and a safe local default are detected) --cap <n>
84
95
  --concern <ID> aim the sweep by one of this project's concerns: its targets and its fault classes.
85
96
  testguard concerns lists them. --save-paths is sugar for the built-in SAVE-PERSISTS
86
97
  --save-paths sweep every file that WRITES TO STORAGE instead of the diff, and report the surface as the
87
98
  denominator: "120 writes across 38 files, swept 12". A clean sweep over an unstated
88
99
  denominator is a sample of unknown size, not a guarantee that saving works
89
- --include-dirty --exclude <glob> --confirm <n> --budget <ms> --command-budget <ms> --max <n> --out <path> --json
100
+ --include-dirty --exclude <glob> --confirm <n> --budget <ms> --command-budget <ms> --workers <n> --serial --max <n> --out <path> --json
90
101
  the cold start: no claim and no concern needed. Everything it proposes is a DRAFT — it never writes testguard.claims.json,
91
102
  and its evidence never replaces .testguard/evidence.json. exit 1 on a fault that survived, or a file no test imports
92
103
  shapes: if-guard → if (false) · single-line guard/mutation removed · return <check> → return true
@@ -95,14 +106,16 @@ sweep --changed <ref> (required unless --save-paths; CI bases and a safe lo
95
106
  mcp no options. JSON-RPC 2.0 over stdio; five READ-ONLY tools (status, brief, claims, evidence, next_command).
96
107
  Nothing here runs a probe: next_command hands back the shell line for you to run where the person can see it.
97
108
  Register it with your harness — testguard init --mcp prints the config for Claude Code, Cursor and Codex.
98
- replay --since <range> commit range to search for fix commits (HEAD~50..HEAD, a tag, origin/main..HEAD)
109
+ replay --workers <n> (default 1) --serial --budget <ms> (per run)
110
+ --since <range> commit range to search for fix commits (HEAD~50..HEAD, a tag, origin/main..HEAD)
99
111
  --max <n> replay at most n fixes (they are slow: one worktree and N runs each)
100
112
  --confirm <n> runs per verdict (default 3); a flaky failure reads as "the suite caught it", so mixed runs are never caught
101
113
  --command-budget <ms> cooperative measurement deadline; expiry writes neither replay nor calibration, so an unmeasured suffix cannot look clean
102
114
  --out <path> replay document (default: <dir>/.testguard/replay.json); the calibration goes beside it
103
115
  reports, never gates: a bug that escaped is history, not a regression in this change
104
116
  · one-line JSX element removed · on<Event> handler prop dropped
105
- admit --claim <ID> (required) --fault <FID> (one fault only) --confirm <n> --json
117
+ admit --workers <n> (default 1) --serial --budget <ms> --command-budget <ms>
118
+ --claim <ID> (required) --fault <FID> (one fault only) --confirm <n> --json
106
119
  ADMITTED (exit 0) only when every fault of the claim is killed N/N by defenders that are green N/N; anything else is NOT ADMITTED (exit 1) and names the first blocking fault
107
120
  the test must be a declared or discovered defender of the claim (exit 3 otherwise); evidence goes to .testguard/evidence-partial.json
108
121
  gate --changed <ref> measure the change since merge-base(ref, HEAD); auto-detected in GitHub Actions / GitLab CI
@@ -112,6 +125,9 @@ gate --changed <ref> measure the change since merge-base(ref, HEAD); aut
112
125
  --ignore <path> ignore file (default: <dir>/testguard.ignore.json; kind=path entries excuse files, with a reason)
113
126
  exit 0 every changed source file is claimed or excused · 1 unclaimed file · 2 cannot evaluate · 3 no reference
114
127
  claims --check-anchors locate every fault without running tests; JS/MJS and Python replacements are syntax-checked in memory
128
+ --annotate read-only file-header preview (JS/TS/Python); --claim <ID,ID> selects claims
129
+ --apply explicit annotation writes, only with --annotate; retains private recovery on partial failure
130
+ authoring is not verification; each invocation recomputes its plan. Results are stdout-only, never --out
115
131
  --json includes anchorChecks; exit 1 when an anchor is missing/ambiguous or a supported replacement does not compile
116
132
  status --json (exit 0 clean · 1 unproven/stale/unclaimed/invalid anchors · 2 nothing to probe yet)
117
133
  --evidence <path> read this evidence instead of .testguard/evidence.json (e.g. CI's, fetched as an artifact); staleness is still computed from the recorded input hashes, and both commits are named
@@ -162,8 +178,12 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
162
178
  parsed = parseArgs({
163
179
  args: argv,
164
180
  allowPositionals: true,
181
+ tokens: true,
165
182
  options: {
166
183
  claims: { type: 'string' },
184
+ into: { type: 'string', multiple: true },
185
+ 'from-document': { type: 'string', multiple: true },
186
+ 'from-fix': { type: 'string', multiple: true },
167
187
  confirm: { type: 'string', default: '3' },
168
188
  budget: { type: 'string', default: '120000' },
169
189
  'command-budget': { type: 'string' },
@@ -174,8 +194,10 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
174
194
  max: { type: 'string', default: '20' },
175
195
  ref: { type: 'string' },
176
196
  'ignore-dirty': { type: 'boolean', default: false },
197
+ workers: { type: 'string', default: '1' },
177
198
  serial: { type: 'boolean', default: false },
178
199
  claim: { type: 'string', multiple: true },
200
+ 'require-origin': { type: 'string', multiple: true },
179
201
  fault: { type: 'string' },
180
202
  'include-dirty': { type: 'boolean', default: false },
181
203
  changed: { type: 'string' },
@@ -209,6 +231,8 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
209
231
  markdown: { type: 'boolean', default: false },
210
232
  cost: { type: 'boolean', default: false },
211
233
  'check-anchors': { type: 'boolean', default: false },
234
+ annotate: { type: 'boolean', default: false },
235
+ apply: { type: 'boolean', default: false },
212
236
  progress: { type: 'string' },
213
237
  help: { type: 'boolean', short: 'h', default: false },
214
238
  version: { type: 'boolean', short: 'v', default: false },
@@ -239,6 +263,17 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
239
263
  io.out(commandUsage(command));
240
264
  return 0;
241
265
  }
266
+ if ((values.annotate || values.apply) && command !== 'claims') {
267
+ io.err('--annotate and --apply are only valid on claims');
268
+ return 3;
269
+ }
270
+ try { originPolicyRequest(values, command); }
271
+ catch (e) { io.err(e.message); return 3; }
272
+ const suppliedOptions = parsed.tokens.filter((token) => token.kind === 'option').map((token) => token.name);
273
+ try { intentInputRequest({ command, values, suppliedOptions, files: positionals.slice(1) }); }
274
+ catch (e) { io.err(e.message); return 3; }
275
+ try { draftAppendRequest({ command, values, suppliedOptions, files: positionals.slice(1) }); }
276
+ catch (e) { io.err(e.message); return 3; }
242
277
  if (![...RUNNER_NAMES, 'auto'].includes(values.runner)) {
243
278
  io.err(`--runner must be one of ${RUNNER_NAMES.join(', ')} or auto`);
244
279
  return 3;
@@ -249,7 +284,7 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
249
284
  }
250
285
  try {
251
286
  const projectDir = command === 'scaffold' || command === 'admit' ? resolve('.') : resolve(dirArg ?? '.');
252
- return await handler({ projectDir, file: dirArg, values, version: VERSION }, io);
287
+ return await handler({ projectDir, file: dirArg, files: positionals.slice(1), values, suppliedOptions, version: VERSION }, io);
253
288
  } catch (e) {
254
289
  if (e instanceof ClaimsError || e instanceof PreconditionError || e instanceof GitError || e instanceof SpecDocError) {
255
290
  io.err(`error: ${e.message}`);
@@ -1,3 +1,4 @@
1
+ import { createCommandBudget, parseCommandBudget } from '../command-budget.mjs';
1
2
  import { existsSync } from 'node:fs';
2
3
  import { dirname, join, relative, resolve } from 'node:path';
3
4
  import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
@@ -45,6 +46,14 @@ export async function admitCommand({ file, values, version }, io) {
45
46
  }
46
47
  const confirmRuns = Number(values.confirm);
47
48
  const budgetMs = Number(values.budget);
49
+ const commandBudgetMs = parseCommandBudget(values['command-budget']);
50
+ if (commandBudgetMs === null) { io.err('--command-budget must be at least 1000 milliseconds'); return 3; }
51
+ const commandBudget = createCommandBudget(commandBudgetMs);
52
+ const workers = Number(values.workers ?? 1);
53
+ if (!Number.isSafeInteger(workers) || workers < 1) {
54
+ io.err('--workers must be a positive safe integer');
55
+ return 3;
56
+ }
48
57
  if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
49
58
  io.err('--confirm must be a positive integer and --budget at least 1000');
50
59
  return 3;
@@ -84,6 +93,9 @@ export async function admitCommand({ file, values, version }, io) {
84
93
  claims: { ...all, claims: [{ ...claim, faults }] },
85
94
  confirmRuns,
86
95
  budgetMs,
96
+ commandBudget,
97
+ workers,
98
+ serial: values.serial || workers === 1,
87
99
  mode: 'worktree',
88
100
  includeDirty: true,
89
101
  only: [claimId],
@@ -96,6 +108,7 @@ export async function admitCommand({ file, values, version }, io) {
96
108
  });
97
109
  if (process.stderr.isTTY && !values.quiet && !values.json) process.stderr.write('\r\x1b[K');
98
110
  const outPath = values.out ? resolve(values.out) : join(projectDir, '.testguard', 'evidence-partial.json');
111
+ commandBudget?.assertOpen();
99
112
  writeSpecDoc('evidence', outPath, evidence);
100
113
 
101
114
  const decision = decideAdmission(evidence.records);
@@ -0,0 +1,67 @@
1
+ import { planAnnotationPlacement, applyAnnotationPlacement, loadAnnotationClaims } from '../claims/annotation-placement.mjs';
2
+ import { validate } from '../../spec/lib/validate.mjs';
3
+ import { SpecDocError } from '../evidence/writer.mjs';
4
+
5
+ /** Authoring is explicitly selected, never an inspection side effect. */
6
+ export async function annotationsCommand({ projectDir, path, values, suppliedOptions = Object.keys(values), version }, io) {
7
+ const allowed = new Set(['claims', 'claim', 'annotate', 'apply', 'json']);
8
+ if (!values.annotate || suppliedOptions.some((key) => !allowed.has(key))) {
9
+ io.err('--apply requires --annotate; annotation authoring cannot combine inspection/output switches');
10
+ return 3;
11
+ }
12
+ const claimIds = (values.claim ?? []).flatMap((value) => value.split(','));
13
+ let claims;
14
+ try {
15
+ claims = loadAnnotationClaims(path);
16
+ if (!validate('claims', claims).ok) throw new Error('claims must conform before annotation placement');
17
+ }
18
+ catch { io.err('annotation input refused: claims could not be safely loaded or validated'); return 2; }
19
+ if (claimIds.some((id) => !id || !claims.claims.some((claim) => claim.id === id))) {
20
+ io.err('--claim must name existing non-empty claim IDs');
21
+ return 3;
22
+ }
23
+ const plan = planAnnotationPlacement({ projectDir, claims, claimIds });
24
+ const doc = {
25
+ schemaVersion: 1, tool: { name: 'testguard', version }, mode: values.apply ? 'apply' : 'preview',
26
+ state: plan.applicable ? 'preview' : 'refused', selected: plan.selectedClaimIds,
27
+ targets: plan.files.map(({ file, claimIds, changed }) => ({ file, claimIds, action: changed ? 'add' : 'unchanged' })),
28
+ refused: plan.refused,
29
+ };
30
+ if (values.apply) {
31
+ doc.state = 'refused';
32
+ doc.outcome = { changed: [], touched: [], unchanged: plan.files.filter((f) => !f.changed).map((f) => f.file), failed: [] };
33
+ if (plan.applicable) {
34
+ try {
35
+ const report = applyAnnotationPlacement({ projectDir, claimsPath: path, plan });
36
+ doc.state = report.state;
37
+ doc.outcome = {
38
+ changed: report.changed, touched: report.touched, unchanged: report.unchanged,
39
+ failed: report.failed.map(publicFailure), recoveryDir: report.recoveryDir,
40
+ lockRelease: { released: report.lockRelease.released, ...(report.lockRelease.reason ? { reason: 'owned lock release could not be verified' } : {}) },
41
+ };
42
+ } catch (error) {
43
+ doc.outcome.failed.push(publicFailure({ file: null, stage: 'preparation' }));
44
+ if (error.recoveryDir) doc.outcome.recoveryDir = error.recoveryDir;
45
+ if (error.lockRelease) doc.outcome.lockRelease = { released: error.lockRelease.released, ...(error.lockRelease.reason ? { reason: 'owned lock release could not be verified' } : {}) };
46
+ }
47
+ }
48
+ }
49
+ const checked = validate('annotations', doc);
50
+ if (!checked.ok) throw new SpecDocError(`annotation result does not conform: ${checked.errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`);
51
+ if (values.json) io.out(JSON.stringify(doc, null, 2));
52
+ else {
53
+ io.out(`Annotation ${doc.mode}: ${doc.state} — file-level authoring, not verification or intent authentication.`);
54
+ for (const t of doc.targets) io.out(` ${t.action} ${t.file}: ${t.claimIds.join(', ')}`);
55
+ for (const r of doc.refused) io.out(` REFUSED ${r.file}: ${r.reason}`);
56
+ for (const f of doc.outcome?.failed ?? []) io.out(` FAILED ${f.file ?? '(operation)'} [${f.stage}]: ${f.reason}`);
57
+ if (doc.outcome?.recoveryDir) io.out(`Recovery originals retained: ${doc.outcome.recoveryDir}`);
58
+ if (doc.mode === 'preview' && doc.state === 'preview') io.out('No files changed. Review this selection, then rerun with --apply; the plan is recomputed against current files.');
59
+ }
60
+ return doc.state === 'preview' || doc.state === 'applied' ? 0 : 2;
61
+ }
62
+
63
+ // Raw syscall messages may contain absolute source paths. Keep them in the
64
+ // private recovery journal, never the public authoring document.
65
+ function publicFailure({ file, stage }) {
66
+ return { file, stage, reason: `Annotation ${stage} failed; inspect private recovery details when available.` };
67
+ }
@@ -1,6 +1,6 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
3
- import { scanAnnotations, reconcile } from '../claims/annotations.mjs';
3
+ import { scanAnnotations, reconcile, annotationAdvisory } from '../claims/annotations.mjs';
4
4
  import { resolveDefenders } from '../probe/runners/shared.mjs';
5
5
  import { discoverDefendersDetailed } from '../probe/discover.mjs';
6
6
  import { classifyDefenders } from '../probe/mocks.mjs';
@@ -11,6 +11,7 @@ import { readSpecDoc } from '../evidence/writer.mjs';
11
11
  import { existsSync } from 'node:fs';
12
12
  import { checkAnchors, renderAnchorChecks } from '../claims/anchors.mjs';
13
13
  import { defenderSelection } from '../probe/attribution.mjs';
14
+ import { annotationsCommand } from './annotations.mjs';
14
15
 
15
16
  /** A selection warning is not a verdict: only a fresh probe can defend it. */
16
17
  export function defenderNarrowing(projectDir, claims, records) {
@@ -32,11 +33,13 @@ export function defenderNarrowing(projectDir, claims, records) {
32
33
  return warnings;
33
34
  }
34
35
 
35
- export async function claimsCommand({ projectDir, values, version }, io) {
36
+ export async function claimsCommand({ projectDir, values, suppliedOptions, version }, io) {
36
37
  const path = values.claims ? resolve(values.claims) : defaultClaimsPath(projectDir);
38
+ if (values.annotate || values.apply) return annotationsCommand({ projectDir, path, values, suppliedOptions, version }, io);
37
39
  const claims = loadClaims(path);
38
40
  const annotations = scanAnnotations(projectDir);
39
41
  const drift = reconcile(claims, annotations);
42
+ const advisory = annotationAdvisory(claims, annotations, { customClaims: Boolean(values.claims) });
40
43
  const anchorChecks = values['check-anchors']
41
44
  ? await checkAnchors(projectDir, claims, { python: values.python ? resolve(values.python) : undefined })
42
45
  : undefined;
@@ -54,16 +57,17 @@ export async function claimsCommand({ projectDir, values, version }, io) {
54
57
  const prior = (values.cost || hasOverrides) && existsSync(ev) ? readSpecDoc('evidence', ev) : undefined;
55
58
  const narrowedDefenders = hasOverrides && prior ? defenderNarrowing(projectDir, claims, prior.records) : [];
56
59
  if (values.cost) {
57
- cost = prior ? costReport(prior.records) : undefined;
60
+ cost = prior ? costReport(prior.records, { run: prior.run }) : undefined;
58
61
  if (!cost && !values.json) io.err(`no evidence at ${ev} — run \`testguard probe\` first; cost is derived from the run durations it records`);
59
62
  }
60
63
 
61
64
  if (values.json) {
62
- io.out(JSON.stringify({ path, claims, annotations, drift, ...(hasOverrides ? { narrowedDefenders } : {}), ...(anchorChecks ? { anchorChecks } : {}), ...(removed ? { removed } : {}), ...(cost ? { cost } : {}) }, null, 2));
65
+ io.out(JSON.stringify({ path, claims, annotations, drift, annotationAdvisory: advisory, ...(hasOverrides ? { narrowedDefenders } : {}), ...(anchorChecks ? { anchorChecks } : {}), ...(removed ? { removed } : {}), ...(cost ? { cost } : {}) }, null, 2));
63
66
  } else {
64
67
  const annotated = new Set(drift.annotated);
65
68
  io.out(`${claims.claims.length} claims in ${path} — ${annotated.size} carry a @claim annotation in source (test files are not scanned)`);
66
69
  io.out('');
70
+ for (const note of advisory.notes) io.out(note);
67
71
  const signalLines = [];
68
72
  for (const c of claims.claims) {
69
73
  const declared = c.defendedBy?.length > 0;
@@ -4,13 +4,16 @@ import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
4
4
  import { probe } from '../probe/probe.mjs';
5
5
  import { writeSpecDoc, readSpecDoc } from '../evidence/writer.mjs';
6
6
  import { gate } from '../baseline/baseline.mjs';
7
- import { renderRecord, renderSummary, sortForReport, PROVISIONAL_WARNING } from '../render.mjs';
7
+ import { renderRecord, renderSummary, sortForReport, PROVISIONAL_WARNING, renderOriginPolicy } from '../render.mjs';
8
8
  import { computeStatus } from '../status/status.mjs';
9
9
  import { resolveChangedRef, withChangedRef } from '../gate/changed.mjs';
10
10
  import { costReport, renderCost } from '../probe/cost.mjs';
11
11
  import { progressMode, stageReporter, clearStageLine, recordEvent, isProgressMode, progressStream } from '../probe/progress.mjs';
12
12
  import { validate } from '../../spec/lib/validate.mjs';
13
13
  import { createCommandBudget, parseCommandBudget } from '../command-budget.mjs';
14
+ import { originPolicyRequest, evaluateOriginPolicy } from '../probe/origin-policy.mjs';
15
+ import { readNativeTestUniverse } from '../probe/universe.mjs';
16
+ import { originPolicyInputsFresh } from '../probe/policy-freshness.mjs';
14
17
  export const provisionalEvidencePath = (projectDir) => join(projectDir, '.testguard', 'evidence-provisional.json');
15
18
 
16
19
  export const evidencePath = (projectDir) => join(projectDir, '.testguard', 'evidence.json');
@@ -35,8 +38,16 @@ export function partialScope(only, records) {
35
38
  }
36
39
 
37
40
  export async function probeCommand({ projectDir, values, version }, io) {
41
+ let eligibleKinds;
42
+ try { eligibleKinds = originPolicyRequest(values); }
43
+ catch (e) { io.err(e.message); return 3; }
38
44
  const confirmRuns = Number(values.confirm);
39
45
  const budgetMs = Number(values.budget);
46
+ const workers = Number(values.workers ?? 1);
47
+ if (!Number.isSafeInteger(workers) || workers < 1) {
48
+ io.err('--workers must be a positive safe integer');
49
+ return 3;
50
+ }
40
51
  const commandBudgetMs = parseCommandBudget(values['command-budget']);
41
52
  if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
42
53
  io.err('--confirm must be a positive integer and --budget at least 1000');
@@ -94,12 +105,13 @@ export async function probeCommand({ projectDir, values, version }, io) {
94
105
  previous,
95
106
  confirmRuns,
96
107
  budgetMs,
108
+ workers,
109
+ serial: values.serial || workers === 1,
97
110
  commandBudget,
98
111
  mode: values['in-place'] ? 'in-place' : 'worktree',
99
112
  ref: values.ref ?? 'HEAD',
100
113
  refExplicit: values.ref !== undefined,
101
114
  ignoreDirty: values['ignore-dirty'],
102
- serial: values.serial,
103
115
  onWarn: (m) => io.err(`warning: ${m}`),
104
116
  runnerCommand: values['runner-cmd'],
105
117
  runnerName: values.runner,
@@ -123,17 +135,47 @@ export async function probeCommand({ projectDir, values, version }, io) {
123
135
  },
124
136
  });
125
137
  commandBudget?.assertOpen();
138
+ if (eligibleKinds) {
139
+ let universe;
140
+ // Custom commands carry a static universe, which cannot establish this
141
+ // native policy boundary. Discovery failures are unavailability, not kills.
142
+ if (!values['runner-cmd']) {
143
+ try {
144
+ universe = await readNativeTestUniverse({
145
+ projectDir, sourceDir: projectDir, runnerName: values.runner,
146
+ python: values.python ? resolve(values.python) : undefined,
147
+ budgetMs: commandBudget?.runBudget(budgetMs) ?? budgetMs,
148
+ budgetFor: () => commandBudget?.runBudget(budgetMs) ?? budgetMs,
149
+ assertOpen: () => commandBudget?.assertOpen(),
150
+ });
151
+ } catch { /* An absent binding can never pass policy. */ }
152
+ }
153
+ commandBudget?.assertOpen();
154
+ const currentClaims = loadClaims(values.claims ? resolve(values.claims) : defaultClaimsPath(projectDir));
155
+ const fresh = originPolicyInputsFresh({ projectDir, claims: currentClaims, evidence, universe });
156
+ evidence.originPolicy = evaluateOriginPolicy({ claims: currentClaims, evidence, eligibleKinds, fresh });
157
+ }
158
+ commandBudget?.assertOpen();
126
159
  writeSpecDoc('evidence', outPath, evidence);
127
160
 
128
161
  const g = gate(evidence.records, baseline, { severityFloor: values.severity });
162
+ const policy = evidence.originPolicy;
163
+ const exitCode = policy?.state === 'unavailable' ? 2 : policy?.state === 'failed' || g.new.length > 0 ? 1 : 0;
129
164
  const scope = partialScope(only, evidence.records);
130
165
  if (values.json) {
131
- const status = withChangedRef(resolveChangedRef({ explicit: values.changed, projectDir }), (changedRef) => computeStatus({ projectDir, toolVersion: version, changedRef, includeDirty: values['include-dirty'], evidence: outPath }), io.err);
132
- const doc = { ...status, run: { id: evidence.run.id, evidence: outPath, provisional, records: evidence.records.length, newSinceBaseline: g.new.length, exitCode: g.new.length > 0 ? 1 : 0, ...(scope ? { scope } : {}) }, ...(values.cost ? { cost: costReport(evidence.records) } : {}) };
166
+ const status = withChangedRef(resolveChangedRef({ explicit: values.changed, projectDir }), (changedRef) => computeStatus({
167
+ projectDir, toolVersion: version, changedRef, includeDirty: values['include-dirty'], evidence: outPath,
168
+ paths: {
169
+ claims: values.claims ? resolve(values.claims) : defaultClaimsPath(projectDir),
170
+ evidence: outPath, provisional: provisionalEvidencePath(projectDir),
171
+ baseline: values.baseline ? resolve(values.baseline) : baselinePath(projectDir),
172
+ },
173
+ }), io.err);
174
+ const doc = { ...status, ...(policy ? { originPolicy: policy } : {}), run: { id: evidence.run.id, evidence: outPath, provisional, records: evidence.records.length, newSinceBaseline: g.new.length, exitCode, ...(policy ? { originPolicy: policy } : {}), ...(scope ? { scope } : {}) }, ...(values.cost ? { cost: costReport(evidence.records, { run: evidence.run }) } : {}) };
133
175
  const result = validate('status', doc);
134
176
  if (!result.ok) throw new Error(`probe JSON document does not conform: ${result.errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`);
135
177
  io.out(JSON.stringify(doc, null, 2));
136
- return g.new.length > 0 ? 1 : 0;
178
+ return exitCode;
137
179
  }
138
180
  if (!values.quiet && baseline) {
139
181
  io.out('');
@@ -146,12 +188,13 @@ export async function probeCommand({ projectDir, values, version }, io) {
146
188
  if (killed) io.out(` ${killed} killed (not listed; --verbose to see them)`);
147
189
  }
148
190
  io.out(renderSummary(evidence.records, evidence.run) + (baseline ? ` ${g.new.length} new since baseline, ${g.baselined.length} baselined.` : ' No baseline.'));
191
+ if (policy) io.out(renderOriginPolicy(policy, { basis: 'current' }));
149
192
  if (values.cost) {
150
193
  io.out('');
151
- io.out(renderCost(costReport(evidence.records)));
194
+ io.out(renderCost(costReport(evidence.records, { run: evidence.run })));
152
195
  io.out('');
153
196
  }
154
197
  io.out(`evidence: ${outPath}${scope ? ` (partial: ${scope.requestedCount} claim${scope.requestedCount === 1 ? '' : 's'} requested, ${scope.probedCount} probed: --claim ${scope.requestedClaims.join(',')}; not the canonical evidence file)` : provisional ? ' (provisional; not the canonical evidence file)' : ''}`);
155
198
  if (provisional && !values.quiet) io.err(PROVISIONAL_WARNING(confirmRuns));
156
- return g.new.length > 0 ? 1 : 0;
199
+ return exitCode;
157
200
  }
@@ -14,6 +14,11 @@ export async function replayCommand({ projectDir, values, version }, io) {
14
14
  }
15
15
  const confirmRuns = Number(values.confirm);
16
16
  const budgetMs = Number(values.budget);
17
+ const workers = Number(values.workers ?? 1);
18
+ if (!Number.isSafeInteger(workers) || workers < 1) {
19
+ io.err('--workers must be a positive safe integer');
20
+ return 3;
21
+ }
17
22
  const commandBudgetMs = parseCommandBudget(values['command-budget']);
18
23
  const limit = values.max ? Number(values.max) : undefined;
19
24
  if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
@@ -31,6 +36,8 @@ export async function replayCommand({ projectDir, values, version }, io) {
31
36
  range,
32
37
  confirmRuns,
33
38
  budgetMs,
39
+ workers,
40
+ serial: values.serial || workers === 1,
34
41
  commandBudget,
35
42
  runnerCommand: values['runner-cmd'],
36
43
  runnerName: values.runner,
@@ -1,11 +1,58 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join, resolve, relative, basename, extname } from 'node:path';
3
3
  import { scaffoldFile } from '../scaffold/scaffold.mjs';
4
+ import { intentHandoff } from '../scaffold/intent.mjs';
4
5
  import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
5
6
  import { writeSpecDoc } from '../evidence/writer.mjs';
6
7
  import { PreconditionError } from '../probe/worktree.mjs';
8
+ import { draftAppendRequest, intentInputRequest } from '../scaffold/request.mjs';
9
+ import { planIntentInput } from '../scaffold/plan-intent-input.mjs';
10
+ import { planDraftAppend } from '../scaffold/plan-append.mjs';
11
+ import { applyDraftAppend } from '../scaffold/apply-append.mjs';
7
12
 
8
- export async function scaffoldCommand({ projectDir, file, values, version }, io) {
13
+ export async function scaffoldCommand({ projectDir, file, files = file ? [file] : [], values, suppliedOptions = [], version }, io) {
14
+ let input;
15
+ try { input = intentInputRequest({ command: 'scaffold', values, suppliedOptions, files }); }
16
+ catch (error) { io.err(error.message); return 3; }
17
+ if (input) {
18
+ try {
19
+ const plan = planIntentInput({ projectDir, command: 'scaffold', values, suppliedOptions, files, toolVersion: version });
20
+ if (values.json) io.out(plan.output.trimEnd());
21
+ else {
22
+ io.out(`authoring input inspection (${input.kind}); verification not performed`);
23
+ if (input.kind === 'fix') {
24
+ const counts = plan.doc.input.counts;
25
+ io.out(`${counts.total} changed paths: ${counts.supported} supported, ${counts.deleted} deleted, ${counts.unsupported} unsupported, ${counts.excluded} excluded`);
26
+ }
27
+ io.out('Next: supply independent intent, review proposed faults, then probe. Input inspection is not verification.');
28
+ }
29
+ return 0;
30
+ } catch (error) { io.err(`intent input refused: ${error.message}`); return 2; }
31
+ }
32
+ let append;
33
+ try { append = draftAppendRequest({ command: 'scaffold', values, suppliedOptions, files }); }
34
+ catch (error) { io.err(error.message); return 3; }
35
+ if (append) {
36
+ try {
37
+ const request = { ...append, projectDir, toolVersion: version };
38
+ const plan = planDraftAppend(request);
39
+ if (values.json) {
40
+ writeSpecDoc('claims', plan.draft.path, plan.doc, { publish: output => io.out(output.trimEnd()) });
41
+ return 0;
42
+ }
43
+ const report = applyDraftAppend({ request, plan });
44
+ if (!report.ok) {
45
+ io.err(`draft append ${report.state}: ${report.failed.map(f => `${f.stage}: ${f.reason}`).join('; ')}; write attempted: ${report.writeAttempted}; bytes written: ${report.bytesWritten}`);
46
+ if (report.recoveryDir) io.err(`private recovery: ${report.recoveryDir}`);
47
+ return 2;
48
+ }
49
+ io.out(`unproven draft ${report.state}: ${plan.draft.path} (${plan.stats.appended} appended faults)`);
50
+ if (report.recoveryDir) io.out(`private recovery: ${report.recoveryDir}`);
51
+ io.out('Next: review the proposed faults against supplied intent, then probe the draft. Authoring is not verification.');
52
+ return 0;
53
+ } catch (error) { io.err(`draft append refused: ${error.message}`); return 2; }
54
+ }
55
+ if (files.length > 1) { io.err('ordinary scaffold accepts one source file; use --into for multiple sources'); return 3; }
9
56
  if (!file) {
10
57
  io.err('usage: testguard scaffold <source-file> [--claim <ID>] [--out <path>] [--json]');
11
58
  return 3;
@@ -38,6 +85,6 @@ export async function scaffoldCommand({ projectDir, file, values, version }, io)
38
85
  io.out(`${stats.proposals} proposed fault${stats.proposals === 1 ? '' : 's'} in ${stats.claims} draft claim${stats.claims === 1 ? '' : 's'} for ${rel}${shapes ? ` — ${shapes}` : ''}`);
39
86
  io.out(stats.defendedBy.length ? `defendedBy prefilled from imports: ${stats.defendedBy.join(', ')}` : 'no test file imports this module; probing the draft as-is will report NOCOVER');
40
87
  io.out(`draft: ${outPath}`);
41
- io.out('Next: replace each TODO statement, drop proposals that are not claims, then move the claims into testguard.claims.json.');
88
+ io.out(`Next: ${intentHandoff} Replace each TODO statement, drop proposals that do not express the intended claim, then review before moving the claims into testguard.claims.json.`);
42
89
  return 0;
43
90
  }
@@ -32,6 +32,11 @@ export async function sweepCommand({ projectDir, values, version }, io) {
32
32
  const mode = values['save-paths'] ? 'save-paths' : 'changed';
33
33
  const confirmRuns = Number(values.confirm);
34
34
  const budgetMs = Number(values.budget);
35
+ const workers = Number(values.workers ?? 1);
36
+ if (!Number.isSafeInteger(workers) || workers < 1) {
37
+ io.err('--workers must be a positive safe integer');
38
+ return 3;
39
+ }
35
40
  const commandBudgetMs = parseCommandBudget(values['command-budget']);
36
41
  if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
37
42
  io.err('--confirm must be a positive integer and --budget at least 1000');
@@ -77,6 +82,8 @@ export async function sweepCommand({ projectDir, values, version }, io) {
77
82
  cap,
78
83
  confirmRuns,
79
84
  budgetMs,
85
+ workers,
86
+ serial: values.serial || workers === 1,
80
87
  commandBudget,
81
88
  runnerCommand: values['runner-cmd'],
82
89
  runnerName: values.runner,
@@ -1,3 +1,4 @@
1
+ import { assertNotCancelled } from '../probe/runners/lifecycle.mjs';
1
2
  import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
2
3
  import { dirname } from 'node:path';
3
4
  import { validate } from '../../spec/lib/validate.mjs';
@@ -13,18 +14,25 @@ function describe(kind, path, result) {
13
14
  * Write a spec document — but only if it conforms. The tool is held to its
14
15
  * own spec at the moment of output, not by a separate test someone might skip.
15
16
  */
16
- export function writeSpecDoc(kind, path, doc) {
17
+ export function writeSpecDoc(kind, path, doc, { publish } = {}) {
18
+ assertNotCancelled();
17
19
  const result = validate(kind, doc);
18
20
  if (!result.ok) throw new SpecDocError('refusing to write: ' + describe(kind, path, result));
21
+ const output = JSON.stringify(doc, null, 2) + '\n';
22
+ if (publish !== undefined) {
23
+ if (typeof publish !== 'function') throw new TypeError('publish must be a function');
24
+ return publish(output);
25
+ }
19
26
  mkdirSync(dirname(path), { recursive: true });
20
- writeFileSync(path, JSON.stringify(doc, null, 2) + '\n');
27
+ writeFileSync(path, output);
21
28
  }
22
29
 
23
30
  /** Read a spec document, refusing one that does not conform. */
24
- export function readSpecDoc(kind, path) {
31
+ export function readSpecDoc(kind, path, { source } = {}) {
25
32
  let doc;
26
33
  try {
27
- doc = JSON.parse(readFileSync(path, 'utf8'));
34
+ if (source !== undefined && typeof source !== 'string') throw new Error('source must be a string');
35
+ doc = JSON.parse(source === undefined ? readFileSync(path, 'utf8') : source);
28
36
  } catch (e) {
29
37
  throw new SpecDocError(`cannot read ${kind} document ${path}: ${e.message}`);
30
38
  }
package/src/git.mjs CHANGED
@@ -7,7 +7,7 @@ import { randomBytes } from 'node:crypto';
7
7
  export class GitError extends Error {}
8
8
 
9
9
  export function gitRaw(args, cwd, env) {
10
- const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: env ? { ...process.env, ...env } : process.env });
10
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024, env: env ? { ...process.env, ...env } : process.env });
11
11
  if (r.status !== 0) throw new GitError(`git ${args.join(' ')}: ${(r.stderr || r.stdout).trim()}`);
12
12
  return r.stdout;
13
13
  }
@@ -27,7 +27,7 @@ export function headSha(dir, ref = 'HEAD') {
27
27
 
28
28
  /** Is `ancestor` reachable from `ref`? False when either does not resolve. */
29
29
  export function isAncestor(dir, ancestor, ref = 'HEAD') {
30
- return spawnSync('git', ['merge-base', '--is-ancestor', ancestor, ref], { cwd: dir }).status === 0;
30
+ return spawnSync('git', ['merge-base', '--is-ancestor', ancestor, ref], { cwd: dir, timeout: 30_000 }).status === 0;
31
31
  }
32
32
 
33
33
  /** True when any of `paths` (repo-relative; empty = whole tree) has uncommitted changes. */
@@ -59,7 +59,7 @@ export function snapshotWorkingTree(repo) {
59
59
  }
60
60
 
61
61
  export function removeWorktree(repo, dest) {
62
- spawnSync('git', ['worktree', 'remove', '--force', dest], { cwd: repo });
62
+ spawnSync('git', ['worktree', 'remove', '--force', dest], { cwd: repo, timeout: 30_000 });
63
63
  rmSync(dest, { recursive: true, force: true });
64
- spawnSync('git', ['worktree', 'prune'], { cwd: repo });
64
+ spawnSync('git', ['worktree', 'prune'], { cwd: repo, timeout: 30_000 });
65
65
  }