testguard-cli 0.16.0 → 0.18.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 (67) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +169 -14
  3. package/package.json +1 -1
  4. package/spec/GATE-SEMANTICS.md +316 -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 +123 -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 +40 -10
  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 +41 -6
  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 +49 -24
  37. package/src/probe/runners/discovery.mjs +4 -0
  38. package/src/probe/runners/index.mjs +3 -1
  39. package/src/probe/runners/jest.mjs +2 -2
  40. package/src/probe/runners/lifecycle.mjs +58 -0
  41. package/src/probe/runners/node/entry.mjs +11 -0
  42. package/src/probe/runners/node/report.mjs +66 -0
  43. package/src/probe/runners/node/runner.mjs +42 -0
  44. package/src/probe/runners/node-test.mjs +130 -0
  45. package/src/probe/runners/playwright.mjs +6 -2
  46. package/src/probe/runners/python.mjs +10 -4
  47. package/src/probe/runners/shared.mjs +14 -7
  48. package/src/probe/runners/vitest.mjs +3 -3
  49. package/src/probe/universe.mjs +47 -0
  50. package/src/probe/worktree.mjs +3 -1
  51. package/src/render.mjs +17 -1
  52. package/src/replay/replay.mjs +8 -4
  53. package/src/scaffold/admission.mjs +117 -0
  54. package/src/scaffold/append.mjs +41 -0
  55. package/src/scaffold/apply-append.mjs +71 -0
  56. package/src/scaffold/fix-input.mjs +119 -0
  57. package/src/scaffold/fix-paths.mjs +90 -0
  58. package/src/scaffold/intent.mjs +2 -0
  59. package/src/scaffold/open-append.mjs +44 -0
  60. package/src/scaffold/plan-append.mjs +37 -0
  61. package/src/scaffold/plan-intent-input.mjs +38 -0
  62. package/src/scaffold/prepare-append.mjs +46 -0
  63. package/src/scaffold/request.mjs +30 -0
  64. package/src/scaffold/scaffold.mjs +23 -10
  65. package/src/status/status.mjs +34 -9
  66. package/src/sweep/pool.mjs +33 -0
  67. package/src/sweep/sweep.mjs +9 -4
@@ -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
  }
@@ -54,6 +54,21 @@ sections already listing this project.
54
54
 
55
55
  ## The one source of truth
56
56
 
57
+ Missing source-link advisories in claims/status never replace `next`, change
58
+ verdicts or prove intent. Exact anchors remain checked without annotations.
59
+ Origin summaries are likewise informational: status counts current declared
60
+ claims; evidence/briefs count recorded declarations and records, including
61
+ mixed sources. Source kinds are not authenticated independence. Never use an
62
+ origin label to waive a survivor.
63
+ Optional source links: `testguard claims --annotate --claim <ID>` is a read-only
64
+ file-header preview. Only with explicit permission to edit source, rerun with
65
+ `--apply`; each invocation recomputes its plan. Review refusals and partial
66
+ failures, and retain the reported private recovery originals. Never automatically
67
+ remove an existing lock or roll back over user edits. Placement is file-level,
68
+ not symbol ownership, verified behavior or authenticated intent. Missing links
69
+ do not disable exact fault-anchor checks. Authoring never replaces the status
70
+ operating loop or writes canonical claims/evidence/baselines.
71
+
57
72
  ```bash
58
73
  testguard status --json
59
74
  ```
@@ -76,6 +91,18 @@ Exit codes: `0` clean · `1` unproven claims (or drift, or unclaimed changes) ·
76
91
 
77
92
  ## Every change needs a claim
78
93
 
94
+ Scaffold proposes mechanical faults, not intended behavior. New drafts declare
95
+ `source.kind: inferred` even when grouped by an annotation. Before adopting a
96
+ draft, ask for the intended observable behavior from a requirement, ADR, bug or
97
+ incident independently of the implementation: inputs, expected outcome and
98
+ forbidden outcome. Record the supplied source kind/ref; when unavailable, keep
99
+ inferred and say the intent remains unestablished. Do not promote origin because
100
+ an annotation exists or tests pass. References are opaque, not authenticated;
101
+ verification commands never fetch them. Preserve existing supplied metadata.
102
+ Review each proposal against that intent, then probe. Do not paraphrase the code
103
+ and call that independent intent, or automatically move drafts into canonical
104
+ claims.
105
+
79
106
  `probe` verifies only the claims that exist; it is silent about unclaimed
80
107
  code by construction. So, for every source file you create or change:
81
108
 
@@ -108,6 +135,14 @@ code by construction. So, for every source file you create or change:
108
135
 
109
136
  ## The fix loop
110
137
 
138
+ For independently supplied intent in an existing disposable draft, use
139
+ `testguard scaffold <source...> --into <draft.json> --claim <existing-ID> --json`
140
+ to preview appended faults without writes. Omit `--json` only when explicitly
141
+ authorized to update that draft; private recovery is retained. Never use a
142
+ canonical claims/evidence/baseline path. Preserve intended behavior and declared
143
+ origin; generated faults are not independent intent or verification. Review the
144
+ faults and probe the selected draft before promoting it to canonical claims.
145
+
111
146
  1. `testguard status --json` → take `next.target`.
112
147
  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.
113
148
  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.)
@@ -11,8 +11,17 @@
11
11
  * worktree is. Everything it needs is passed in.
12
12
  */
13
13
 
14
+ import { isDeepStrictEqual } from 'node:util';
15
+
14
16
  const isKill = (r) => r.outcome === 'fail' && (r.assertionFailures ?? 0) > 0;
15
17
 
18
+ /** Compare only the claim metadata actually recorded in evidence. */
19
+ export function sameClaimMetadata(recorded, current) {
20
+ if (!recorded || !current) return false;
21
+ const metadata = ({ id, statement, severity, source, producedBy }) => ({ id, statement, severity, source, producedBy });
22
+ return isDeepStrictEqual(metadata(recorded), metadata(current));
23
+ }
24
+
16
25
  /**
17
26
  * Fold one escalation run into the running attribution state.
18
27
  *
@@ -118,6 +127,7 @@ export function defenderSelection(claim, fault) {
118
127
 
119
128
  export function isReusable(prior, current) {
120
129
  if (!prior || !prior.subject?.contentHash) return false;
130
+ if (current.claim && !sameClaimMetadata(prior.claim, current.claim)) return false;
121
131
  // A record that exists because the probe threw is a statement about the run,
122
132
  // not about the code. Reusing it would make one disturbed run permanent.
123
133
  if (prior.detail?.reason === 'probe-error') return false;
@@ -37,9 +37,13 @@ export function processList({ platform = process.platform, run = spawnSync } = {
37
37
  try {
38
38
  const r = platform === 'win32'
39
39
  ? run('wmic', ['process', 'get', 'ProcessId,CommandLine', '/format:csv'], { encoding: 'utf8', timeout: 5000 })
40
- : run('ps', ['-Ao', 'pid=,args='], { encoding: 'utf8', timeout: 5000 });
40
+ : run('ps', ['-Ao', 'pid=,ppid=,stat=,args='], { encoding: 'utf8', timeout: 5000 });
41
41
  if (r.status !== 0 || !r.stdout) return [];
42
42
  return r.stdout.split('\n').map((line) => {
43
+ if (platform !== 'win32') {
44
+ const row = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
45
+ if (row) return { pid: Number(row[1]), ppid: Number(row[2]), stat: row[3], command: row[4].trim() };
46
+ }
43
47
  const m = platform === 'win32' ? /^[^,]*,(.*),(\d+)\s*$/.exec(line) : /^\s*(\d+)\s+(.*)$/.exec(line);
44
48
  if (!m) return null;
45
49
  const [pid, command] = platform === 'win32' ? [m[2], m[1]] : [m[1], m[2]];
@@ -57,8 +61,22 @@ export function processList({ platform = process.platform, run = spawnSync } = {
57
61
  */
58
62
  export function detectContention({ self = process.pid, alive = defaultAlive, ...opts } = {}) {
59
63
  const rows = processList(opts);
64
+ const owned = new Set([self]);
65
+ let changed = true;
66
+ while (changed) {
67
+ changed = false;
68
+ for (const row of rows) {
69
+ if (owned.has(row.ppid) && !owned.has(row.pid)) { owned.add(row.pid); changed = true; }
70
+ }
71
+ }
72
+ const ancestors = new Set();
73
+ let parent = rows.find((row) => row.pid === self)?.ppid;
74
+ while (parent && !ancestors.has(parent)) {
75
+ ancestors.add(parent);
76
+ parent = rows.find((row) => row.pid === parent)?.ppid;
77
+ }
60
78
  const runners = rows
61
- .filter((p) => p.pid !== self && RUNNER_RE.test(p.command) && !SELF_RE.test(p.command))
79
+ .filter((p) => !owned.has(p.pid) && !ancestors.has(p.pid) && !p.stat?.startsWith('Z') && RUNNER_RE.test(p.command) && (p.ppid !== undefined || !SELF_RE.test(p.command)))
62
80
  // `ps` lists the grep/ps itself and any shell wrapper; a runner's command
63
81
  // line always names its own binary, so require it to look like an exec.
64
82
  .filter((p) => !/^\s*(ps|grep|wmic)\b/.test(p.command))
@@ -92,10 +92,11 @@ export function defenderCosts(records) {
92
92
  * turns a twenty-four-minute gate into a one-minute one, and until a report
93
93
  * names the file nobody knows which test to split.
94
94
  */
95
- export function costReport(records) {
95
+ export function costReport(records, { run } = {}) {
96
96
  const claims = claimCosts(records);
97
97
  const defenders = defenderCosts(records);
98
98
  return {
99
+ ...(run?.measurements ? { measurements: { ...run.measurements } } : {}),
99
100
  totalMs: claims.reduce((a, c) => a + c.ms, 0),
100
101
  totalRuns: claims.reduce((a, c) => a + c.runs, 0),
101
102
  reusedRecords: records.filter((r) => r.reusedFrom).length,
@@ -118,6 +119,10 @@ export const formatMs = secs;
118
119
  */
119
120
  export function renderCost(report, { limit = 10 } = {}) {
120
121
  const out = [];
122
+ if (report.measurements) {
123
+ const m = report.measurements;
124
+ out.push(`actual elapsed ${secs(m.elapsedMs)}; fresh runners ${secs(m.runnerMs)} across ${m.runnerInvocations} invocations; setup/discovery/other overhead ${secs(m.overheadMs)}`);
125
+ }
121
126
  if (!report.records) return 'no evidence records — run `testguard probe` first; cost is read back from the runs it records.';
122
127
  out.push(`${report.records} fault record${report.records === 1 ? '' : 's'} cost ${secs(report.totalMs)} across ${report.totalRuns} defender runs${report.reusedRecords ? ` (${report.reusedRecords} reused; their runs are what they cost when reuse does not apply)` : ''}.`);
123
128
  out.push('');
@@ -1,3 +1,4 @@
1
+ import { registerCleanup } from './runners/lifecycle.mjs';
1
2
  import { readFileSync, writeFileSync } from 'node:fs';
2
3
  import { join } from 'node:path';
3
4
 
@@ -19,12 +20,7 @@ function restoreAll() {
19
20
  function installHandlers() {
20
21
  if (handlersInstalled) return;
21
22
  handlersInstalled = true;
22
- for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
23
- process.on(sig, () => {
24
- restoreAll();
25
- process.exit(130);
26
- });
27
- }
23
+ registerCleanup(restoreAll, { priority: 1 });
28
24
  process.on('uncaughtException', (err) => {
29
25
  restoreAll();
30
26
  throw err;