testguard-cli 0.15.1 → 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 (64) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +196 -17
  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 +129 -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 +8 -8
  13. package/spec/schemas/common.schema.json +73 -0
  14. package/spec/schemas/evidence.schema.json +41 -8
  15. package/spec/schemas/status.schema.json +31 -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 +21 -3
  21. package/src/commands/annotations.mjs +67 -0
  22. package/src/commands/claims.mjs +46 -6
  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/gate/changed.mjs +5 -1
  29. package/src/git.mjs +4 -4
  30. package/src/init/init.mjs +6 -0
  31. package/src/init/templates/SKILL.md +78 -0
  32. package/src/probe/attribution.mjs +19 -0
  33. package/src/probe/contention.mjs +20 -2
  34. package/src/probe/cost.mjs +22 -9
  35. package/src/probe/inject.mjs +2 -6
  36. package/src/probe/origin-policy.mjs +69 -0
  37. package/src/probe/policy-freshness.mjs +74 -0
  38. package/src/probe/probe.mjs +74 -45
  39. package/src/probe/runners/discovery.mjs +2 -0
  40. package/src/probe/runners/jest.mjs +2 -2
  41. package/src/probe/runners/lifecycle.mjs +58 -0
  42. package/src/probe/runners/playwright.mjs +6 -2
  43. package/src/probe/runners/python.mjs +10 -4
  44. package/src/probe/runners/shared.mjs +12 -6
  45. package/src/probe/runners/vitest.mjs +3 -3
  46. package/src/probe/universe.mjs +47 -0
  47. package/src/probe/worktree.mjs +3 -1
  48. package/src/render.mjs +17 -1
  49. package/src/replay/replay.mjs +8 -4
  50. package/src/scaffold/admission.mjs +117 -0
  51. package/src/scaffold/append.mjs +41 -0
  52. package/src/scaffold/apply-append.mjs +71 -0
  53. package/src/scaffold/fix-input.mjs +119 -0
  54. package/src/scaffold/fix-paths.mjs +90 -0
  55. package/src/scaffold/intent.mjs +2 -0
  56. package/src/scaffold/open-append.mjs +44 -0
  57. package/src/scaffold/plan-append.mjs +37 -0
  58. package/src/scaffold/plan-intent-input.mjs +38 -0
  59. package/src/scaffold/prepare-append.mjs +46 -0
  60. package/src/scaffold/request.mjs +30 -0
  61. package/src/scaffold/scaffold.mjs +23 -10
  62. package/src/status/status.mjs +42 -10
  63. package/src/sweep/pool.mjs +33 -0
  64. package/src/sweep/sweep.mjs +9 -4
@@ -1,5 +1,6 @@
1
1
  import { gate } from '../baseline/baseline.mjs';
2
- import { coAuthorshipWarning, summarize, formatVerdict } from '../render.mjs';
2
+ import { coAuthorshipWarning, summarize, formatVerdict, renderOrigins, renderOriginPolicy } from '../render.mjs';
3
+ import { recordedOriginSummary } from '../../spec/lib/origins.mjs';
3
4
 
4
5
  export const HEADING = '## TEST BLINDSPOT CONTEXT';
5
6
  /** First line of every markdown brief, so a poster can find and update its own note instead of adding another. */
@@ -72,10 +73,12 @@ export function buildBrief(evidence, baseline, { max = 20, generatedAt = new Dat
72
73
  new: g.new.length + g.belowFloor.length,
73
74
  baselined: g.baselined.length,
74
75
  };
76
+ const origins = recordedOriginSummary(evidence.records);
75
77
  const kills = evidence.records.filter((r) => r.verdict === 'killed');
76
78
  const coAuthored = kills.filter((r) => r.detail.independence?.class === 'co-authored').length;
77
79
  const unclaimed = changes?.uncovered?.length ? { ref: changes.ref, files: changes.uncovered } : undefined;
78
- const doc = { schemaVersion: 1, tool: evidence.tool, generatedAt, head: evidence.run.repo.head, heading: HEADING, summary, ...(next ? { next: { action: next.action, command: next.command, why: next.why } } : {}), ...(unclaimed ? { unclaimed } : {}), items, text: '' };
80
+ const doc = { schemaVersion: 1, tool: evidence.tool, generatedAt, head: evidence.run.repo.head, heading: HEADING, summary, origins, ...(next ? { next: { action: next.action, command: next.command, why: next.why } } : {}), ...(unclaimed ? { unclaimed } : {}), items, text: '' };
81
+ if (evidence.originPolicy) doc.originPolicy = structuredClone(evidence.originPolicy);
79
82
  doc.text = renderBriefText({ ...doc, provisional: Boolean(evidence.run.provisional), install }, { hasBaseline: Boolean(baseline), total: evidence.records.length, independence: kills.length ? { coAuthored, kills: kills.length } : undefined });
80
83
  return doc;
81
84
  }
@@ -89,6 +92,8 @@ export function renderBriefText(brief, { hasBaseline, total, independence }) {
89
92
  `testguard ${brief.tool.version}${brief.install ? ` (${brief.install})` : ''}${brief.head ? ` @ ${brief.head.slice(0, 12)}` : ''} — ${brief.summary.claims} claims, ${total} faults probed, ${unproven} unproven` +
90
93
  (unproven === 0 ? '.' : hasBaseline ? ` (${brief.summary.new} new since baseline).` : ' (no baseline; everything is new).'),
91
94
  ];
95
+ if (brief.origins) lines.push(renderOrigins(brief.origins));
96
+ if (brief.originPolicy) lines.push(renderOriginPolicy(brief.originPolicy));
92
97
  // Unclaimed changes come before everything else: the claim is written
93
98
  // before more code, and TestGuard is silent about unclaimed code otherwise.
94
99
  if (brief.unclaimed) {
@@ -101,7 +106,7 @@ export function renderBriefText(brief, { hasBaseline, total, independence }) {
101
106
  if (independence?.coAuthored) lines.push('', coAuthorshipWarning(independence.coAuthored, independence.kills));
102
107
  if (brief.next) lines.push('', `NEXT [${brief.next.action}]: ${brief.next.command}`, ` why: ${brief.next.why}`);
103
108
  if (brief.items.length === 0) {
104
- lines.push('', 'Every probed claim is defended. Keep it that way: new claims need a fault and a test that fails on it.');
109
+ lines.push('', unproven === 0 ? 'Every probed claim is defended. Keep it that way: new claims need a fault and a test that fails on it.' : `${unproven} unproven faults remain; no items shown at the current cap. See the full evidence file.`);
105
110
  return lines.join('\n') + '\n';
106
111
  }
107
112
  lines.push(
@@ -149,6 +154,8 @@ export function renderBriefMarkdown(brief, { hasBaseline, total, install } = {})
149
154
  if (brief.provisional) lines.push('**PROVISIONAL** — fewer than three confirmation runs; nothing below is confirmed. Re-probe with `--confirm 3`.', '');
150
155
  lines.push(`\`testguard ${brief.tool.version}${install ? ` (${install})` : ''}\`${brief.head ? ` @ \`${brief.head.slice(0, 12)}\`` : ''} — **${brief.summary.claims}** claims, **${total}** faults probed, **${unproven}** unproven` +
151
156
  (unproven === 0 ? '.' : hasBaseline ? ` (**${brief.summary.new}** new since baseline).` : ' (no baseline; everything is new).'));
157
+ if (brief.origins) lines.push('', renderOrigins(brief.origins));
158
+ if (brief.originPolicy) lines.push('', renderOriginPolicy(brief.originPolicy));
152
159
  if (brief.unclaimed) {
153
160
  const n = brief.unclaimed.files.length;
154
161
  lines.push('', `### Unclaimed changes since \`${brief.unclaimed.ref}\``, '', `${n} changed file${n === 1 ? '' : 's'} carr${n === 1 ? 'ies' : 'y'} no claim. State the claim first; nothing below can see this code.`, '');
@@ -156,7 +163,7 @@ export function renderBriefMarkdown(brief, { hasBaseline, total, install } = {})
156
163
  }
157
164
  if (brief.next) lines.push('', `**Next** \`[${brief.next.action}]\`: \`${brief.next.command}\``, '', `> ${brief.next.why}`);
158
165
  if (brief.items.length === 0) {
159
- lines.push('', 'Every probed claim is defended. Keep it that way: new claims need a fault and a test that fails on it.');
166
+ lines.push('', unproven === 0 ? 'Every probed claim is defended. Keep it that way: new claims need a fault and a test that fails on it.' : `${unproven} unproven faults remain; no items shown at the current cap. See the full evidence file.`);
160
167
  return lines.join('\n') + '\n';
161
168
  }
162
169
  lines.push('', 'Where the test suite is blind, ranked. A **SURVIVED** fault means its defenders stayed green while the claim was false. Do not close these by asserting current behaviour; write a test that fails on the described fault and passes on HEAD.', '');
@@ -0,0 +1,357 @@
1
+ import { constants, chmodSync, closeSync, fsyncSync, fstatSync, ftruncateSync, lstatSync, mkdtempSync, openSync, readSync, realpathSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
2
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
+ import { isDeepStrictEqual } from 'node:util';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { tmpdir } from 'node:os';
6
+ import { validate } from '../../spec/lib/validate.mjs';
7
+ import { locate } from '../probe/inject.mjs';
8
+ import { sha256 } from '../util/hash.mjs';
9
+
10
+ const SUPPORTED = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx', '.py']);
11
+ const SKIP = new Set(['node_modules', 'dist', 'coverage', 'test', 'tests', '__tests__']);
12
+ const CLAIM_ID = /^[A-Za-z0-9]+(?:[._][A-Za-z0-9]+)*-[A-Za-z0-9._-]*[A-Za-z0-9]$/;
13
+ const MAX_BYTES = 2 * 1024 * 1024;
14
+ const MAX_PLAN_BYTES = 16 * 1024 * 1024;
15
+
16
+ function canonicalRoot(projectDir) {
17
+ const requested = resolve(projectDir);
18
+ try {
19
+ // Keep a directly symlinked root for admission to refuse; parent aliases
20
+ // are resolved once so plans bind the actual explicitly selected root.
21
+ if (lstatSync(requested).isSymbolicLink()) return requested;
22
+ return realpathSync(requested);
23
+ } catch { return requested; }
24
+ }
25
+
26
+ function containedRelative(file) {
27
+ return file !== '..' && !file.startsWith(`..${sep}`) && !isAbsolute(file);
28
+ }
29
+
30
+ function selectedClaimsPath(root, projectDir, claimsPath) {
31
+ const fromSelected = relative(resolve(projectDir), resolve(claimsPath));
32
+ return containedRelative(fromSelected) ? resolve(root, fromSelected) : resolve(claimsPath);
33
+ }
34
+
35
+ export function recoveryBase(root) {
36
+ const base = realpathSync(tmpdir());
37
+ if (containedRelative(relative(root, base))) throw new Error('recovery directory must be outside the project');
38
+ return base;
39
+ }
40
+
41
+ /** Admission precedes reads: do not follow even an ancestor symlink. */
42
+ function admittedPath(root, file, { source = true } = {}) {
43
+ const parts = file.split('/');
44
+ if (file.includes('\\') || parts.some((p) => !p || p === '.' || p === '..') || resolve(root, file) !== join(root, ...parts)) return { reason: 'unsafe-path' };
45
+ if (source && (parts.some((p) => p.startsWith('.') || SKIP.has(p)) || /\.(test|spec)\.[cm]?[jt]sx?$/.test(file))) return { reason: 'excluded-target' };
46
+ if (source && !SUPPORTED.has(extname(file))) return { reason: 'unsupported-source' };
47
+ let path = root;
48
+ try {
49
+ const base = lstatSync(root);
50
+ if (base.isSymbolicLink() || !base.isDirectory()) return { reason: 'symlink-target' };
51
+ for (let i = 0; i < parts.length; i++) {
52
+ path = join(path, parts[i]);
53
+ const stat = lstatSync(path);
54
+ if (stat.isSymbolicLink()) return { reason: 'symlink-target' };
55
+ if (i < parts.length - 1) {
56
+ if (!stat.isDirectory()) return { reason: 'not-regular-file' };
57
+ try { lstatSync(join(path, 'testguard.claims.json')); return { reason: 'nested-project' }; }
58
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
59
+ } else {
60
+ if (!stat.isFile() || stat.nlink !== 1) return { reason: 'not-regular-file' };
61
+ if (stat.size > MAX_BYTES) return { reason: 'source-too-large' };
62
+ return { path, stat };
63
+ }
64
+ }
65
+ } catch (error) {
66
+ if (error.code === 'ENOENT') return { reason: 'target-missing' };
67
+ return { reason: 'target-unreadable' };
68
+ }
69
+ }
70
+
71
+ function readSource(path, expected) {
72
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
73
+ try { return readDescriptor(fd, expected); }
74
+ finally { closeSync(fd); }
75
+ }
76
+
77
+ function readDescriptor(fd, expected) {
78
+ const stat = fstatSync(fd);
79
+ if (!stat.isFile() || stat.nlink !== 1 || stat.dev !== expected.dev || stat.ino !== expected.ino || stat.size !== expected.size || stat.mtimeMs !== expected.mtimeMs) return { reason: 'target-changed' };
80
+ const buffer = Buffer.alloc(MAX_BYTES + 1);
81
+ let length = 0;
82
+ while (length < buffer.length) {
83
+ const count = readSync(fd, buffer, length, buffer.length - length, length);
84
+ if (!count) break;
85
+ length += count;
86
+ }
87
+ const bytes = buffer.subarray(0, length);
88
+ const after = fstatSync(fd);
89
+ if (bytes.length > MAX_BYTES || bytes.length !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) return { reason: 'target-changed' };
90
+ let source;
91
+ try { source = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); }
92
+ catch { return { reason: 'invalid-encoding' }; }
93
+ if (source.includes('\0')) return { reason: 'invalid-encoding' };
94
+ const bare = source.replaceAll('\r\n', '');
95
+ if (bare.includes('\r') || (source.includes('\r\n') && bare.includes('\n'))) return { reason: 'mixed-newlines' };
96
+ return { source, sourceHash: sha256(bytes), newline: source.includes('\r\n') ? '\r\n' : '\n', identity: { dev: stat.dev, ino: stat.ino, mode: stat.mode } };
97
+ }
98
+
99
+ /** Only leading line-comment headers are witnesses, never quoted code text. */
100
+ function hasAnnotation(source, id, python) {
101
+ for (const line of source.split(/\r?\n/)) {
102
+ const text = line.replace(/^\uFEFF/, '').trimStart();
103
+ if (!text || text.startsWith('#!')) continue;
104
+ if (!text.startsWith(python ? '#' : '//')) break;
105
+ if ([...text.matchAll(/@claim\s+([^\s]+)/g)].some((match) => match[1] === id)) return true;
106
+ }
107
+ return false;
108
+ }
109
+
110
+ function header(source, ids, python, newline) {
111
+ let offset = source.startsWith('\uFEFF') ? 1 : 0;
112
+ const lines = source.slice(offset).split(newline);
113
+ let protectedLines = lines[0].startsWith('#!') ? 1 : 0;
114
+ if (python) {
115
+ for (let i = 0; i < Math.min(2, lines.length); i++) {
116
+ if (/^\s*#.*coding\s*[:=]\s*[-\w.]+/.test(lines[i])) protectedLines = i + 1;
117
+ }
118
+ }
119
+ for (let i = 0; i < protectedLines; i++) offset += lines[i].length + (i < lines.length - 1 ? newline.length : 0);
120
+ const prefix = source.slice(0, offset);
121
+ const separator = protectedLines && !prefix.endsWith(newline) ? newline : '';
122
+ return prefix + separator + ids.map((id) => `${python ? '#' : '//'} @claim ${id}${newline}`).join('') + source.slice(offset);
123
+ }
124
+
125
+ /** Internal read-only plan. Not an evidence document or a successful apply. */
126
+ export function planAnnotationPlacement({ projectDir, claims, claimIds = [] }) {
127
+ if (!validate('claims', claims).ok) throw new Error('claims must conform before annotation placement');
128
+ if (!Array.isArray(claimIds) || claimIds.some((id) => typeof id !== 'string')) throw new Error('claim selection must be an array of IDs');
129
+ const selected = new Set(claimIds);
130
+ for (const id of selected) if (!claims.claims.some((c) => c.id === id)) throw new Error(`unknown claim ${id}`);
131
+ const root = canonicalRoot(projectDir);
132
+ const targets = new Map();
133
+ const selectedClaims = claims.claims.filter((c) => !selected.size || selected.has(c.id));
134
+ for (const claim of selectedClaims) {
135
+ for (const fault of claim.faults) {
136
+ const file = fault.file ?? `(claim ${claim.id} without a target)`;
137
+ if (!targets.has(file)) targets.set(file, []);
138
+ targets.get(file).push({ claim, fault });
139
+ }
140
+ }
141
+ const files = [], refused = [];
142
+ let admittedBytes = 0;
143
+ for (const [file, entries] of [...targets].sort(([a], [b]) => a.localeCompare(b))) {
144
+ const ids = [...new Set(entries.map(({ claim }) => claim.id))].sort();
145
+ const refuse = (reason) => refused.push({ file, claimIds: ids, reason });
146
+ if (ids.some((id) => !CLAIM_ID.test(id))) { refuse('unsupported-claim-id'); continue; }
147
+ if (entries.some(({ fault }) => (fault.method ?? 'fault-injection') !== 'fault-injection')) { refuse('unsupported-method'); continue; }
148
+ const target = admittedPath(root, file);
149
+ if (target.reason) { refuse(target.reason); continue; }
150
+ if (admittedBytes + target.stat.size > MAX_PLAN_BYTES) { refuse('plan-source-budget'); continue; }
151
+ admittedBytes += target.stat.size;
152
+ let input;
153
+ try { input = readSource(target.path, target.stat); }
154
+ catch { refuse('target-unreadable'); continue; }
155
+ if (input.reason) { refuse(input.reason); continue; }
156
+ const invalid = entries.map(({ fault }) => locate(input.source, fault)).find((anchor) => anchor.status !== 'ok');
157
+ if (invalid) { refuse(invalid.status); continue; }
158
+ const python = extname(file) === '.py';
159
+ const missing = ids.filter((id) => !hasAnnotation(input.source, id, python));
160
+ const proposed = missing.length ? header(input.source, missing, python, input.newline) : input.source;
161
+ const proposedBytes = Buffer.byteLength(proposed, 'utf8');
162
+ if (proposedBytes > MAX_BYTES) { refuse('proposed-source-too-large'); continue; }
163
+ const growth = Math.max(0, proposedBytes - target.stat.size);
164
+ if (admittedBytes + growth > MAX_PLAN_BYTES) { refuse('plan-source-budget'); continue; }
165
+ admittedBytes += growth;
166
+ files.push({ file, claimIds: ids, sourceHash: input.sourceHash, identity: input.identity, source: input.source, proposed, changed: proposed !== input.source });
167
+ }
168
+ return { projectDir: root, selectedClaimIds: selectedClaims.map((c) => c.id).sort(), claimsHash: sha256(JSON.stringify(claims)), files, refused, applicable: refused.length === 0 };
169
+ }
170
+
171
+ /** Recompute before authoring. Never trust a saved proposal or hash alone. */
172
+ export function revalidateAnnotationPlan({ projectDir, claims, plan }) {
173
+ if (!plan || plan.projectDir !== canonicalRoot(projectDir) || !Array.isArray(plan.selectedClaimIds)) return { current: false, reason: 'plan-invalid' };
174
+ if (!validate('claims', claims).ok || plan.claimsHash !== sha256(JSON.stringify(claims))) return { current: false, reason: 'claims-changed' };
175
+ let fresh;
176
+ try { fresh = planAnnotationPlacement({ projectDir, claims, claimIds: plan.selectedClaimIds }); }
177
+ catch { return { current: false, reason: 'plan-invalid' }; }
178
+ if (!fresh.applicable) return { current: false, reason: 'plan-refused' };
179
+ if (!isDeepStrictEqual(plan, fresh)) return { current: false, reason: 'plan-changed' };
180
+ return { current: true, plan: fresh };
181
+ }
182
+
183
+ /** Bounded preview input; an external claims file never authorizes an apply. */
184
+ export function loadAnnotationClaims(claimsPath) {
185
+ const selectedRoot = dirname(resolve(claimsPath));
186
+ const root = canonicalRoot(selectedRoot);
187
+ return currentClaims(root, selectedClaimsPath(root, selectedRoot, claimsPath));
188
+ }
189
+
190
+ function currentClaims(root, claimsPath) {
191
+ const file = relative(root, resolve(claimsPath)).split(sep).join('/');
192
+ const target = admittedPath(root, file, { source: false });
193
+ if (target.reason) throw new Error(`claims path refused: ${target.reason}`);
194
+ const input = readSource(target.path, target.stat);
195
+ if (input.reason) throw new Error(`claims read refused: ${input.reason}`);
196
+ try { return JSON.parse(input.source); }
197
+ catch { throw new Error('claims file contains invalid JSON'); }
198
+ }
199
+
200
+ /** Exclusive creation and byte ownership: never reclaim a possibly live lock. */
201
+ export function authoringLock(root) {
202
+ const path = join(root, '.testguard-annotations.lock');
203
+ let fd;
204
+ try { fd = openSync(path, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); }
205
+ catch (error) { if (error.code === 'EEXIST') throw new Error('annotation authoring lock already exists'); throw error; }
206
+ const identity = fstatSync(fd);
207
+ const content = Buffer.from(JSON.stringify({ pid: process.pid, token: randomUUID() }));
208
+ let result;
209
+ const assertOwned = () => {
210
+ if (result) throw new Error('annotation authoring lock released');
211
+ const current = lstatSync(path);
212
+ const bytes = Buffer.alloc(content.length + 1);
213
+ const count = readSync(fd, bytes, 0, bytes.length, 0);
214
+ if (current.isSymbolicLink() || current.nlink !== 1 || current.dev !== identity.dev || current.ino !== identity.ino || count !== content.length || !bytes.subarray(0, count).equals(content)) throw new Error('annotation authoring lock changed');
215
+ };
216
+ const release = () => {
217
+ if (result) return result;
218
+ try {
219
+ const current = lstatSync(path);
220
+ const bytes = Buffer.alloc(content.length + 1);
221
+ const count = readSync(fd, bytes, 0, bytes.length, 0);
222
+ if (current.isSymbolicLink() || current.nlink !== 1 || current.dev !== identity.dev || current.ino !== identity.ino || count !== content.length || !bytes.subarray(0, count).equals(content)) {
223
+ result = { released: false, reason: 'lock-changed' };
224
+ } else { unlinkSync(path); result = { released: true }; }
225
+ } catch (error) { result = { released: false, reason: error.code === 'ENOENT' ? 'lock-changed' : 'lock-release-failed' }; }
226
+ finally { closeSync(fd); }
227
+ return result;
228
+ };
229
+ try { writeFileSync(fd, content); }
230
+ catch (error) {
231
+ // No other actor may use this exclusively-created file yet. Still verify
232
+ // inode ownership rather than unlinking an arbitrary replacement.
233
+ try {
234
+ const current = lstatSync(path);
235
+ if (!current.isSymbolicLink() && current.dev === identity.dev && current.ino === identity.ino) unlinkSync(path);
236
+ } finally { closeSync(fd); }
237
+ throw error;
238
+ }
239
+ return { release, assertOwned };
240
+ }
241
+
242
+ export function persistRecovery(path, bytes) {
243
+ const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
244
+ try { writeFileSync(fd, bytes); fsyncSync(fd); }
245
+ finally { closeSync(fd); }
246
+ }
247
+
248
+ /** Only fresh internal plan containers enter this boundary, never caller objects. */
249
+ function freezePrepared(value) {
250
+ if (value && typeof value === 'object') {
251
+ for (const child of Object.values(value)) freezePrepared(child);
252
+ Object.freeze(value);
253
+ }
254
+ return value;
255
+ }
256
+
257
+ /** Prepare recoverable authoring. This function never rewrites source files. */
258
+ export function prepareAnnotationApply({ projectDir, claimsPath, plan }) {
259
+ const root = canonicalRoot(projectDir);
260
+ const claimsFile = selectedClaimsPath(root, projectDir, claimsPath);
261
+ // Establish safe claims/root admission before creating even a lock.
262
+ currentClaims(root, claimsFile);
263
+ const lock = authoringLock(root);
264
+ let recoveryDir;
265
+ try {
266
+ const claims = currentClaims(root, claimsFile);
267
+ const checked = revalidateAnnotationPlan({ projectDir: root, claims, plan });
268
+ if (!checked.current) throw new Error(`annotation preview refused: ${checked.reason}`);
269
+ if (checked.plan.files.some((file) => resolve(root, file.file) === claimsFile)) throw new Error('claims file cannot be an annotation target');
270
+ recoveryDir = mkdtempSync(join(recoveryBase(root), 'testguard-annotation-recovery-'));
271
+ chmodSync(recoveryDir, 0o700);
272
+ const files = checked.plan.files.filter((file) => file.changed).map((file, index) => {
273
+ const original = `${index}.original`;
274
+ persistRecovery(join(recoveryDir, original), Buffer.from(file.source, 'utf8'));
275
+ return { file: file.file, original, sourceHash: file.sourceHash, proposedHash: sha256(Buffer.from(file.proposed, 'utf8')), identity: file.identity };
276
+ });
277
+ const manifest = { state: 'prepared', projectDir: root, claimsPath: claimsFile, claimsHash: checked.plan.claimsHash, files };
278
+ persistRecovery(join(recoveryDir, 'manifest.json'), Buffer.from(JSON.stringify(manifest)));
279
+ return freezePrepared({ ready: true, plan: checked.plan, claimsPath: claimsFile, recoveryDir, release: lock.release, assertOwned: lock.assertOwned });
280
+ } catch (error) {
281
+ if (recoveryDir) error.recoveryDir = recoveryDir;
282
+ error.lockRelease = lock.release();
283
+ throw error;
284
+ }
285
+ }
286
+
287
+ /** Explicit internal authoring: recoverable descriptor writes, never automatic rollback. */
288
+ export function applyAnnotationPlacement({ projectDir, claimsPath, plan }) {
289
+ const handle = prepareAnnotationApply({ projectDir, claimsPath, plan });
290
+ const root = handle.plan.projectDir;
291
+ const files = handle.plan.files.filter((file) => file.changed);
292
+ const report = { state: 'applied', changed: [], touched: [], unchanged: handle.plan.files.filter((file) => !file.changed).map((file) => file.file), failed: [], recoveryDir: handle.recoveryDir };
293
+ let activeFile = null, stage = 'all-target-preflight';
294
+ const checkClaims = () => {
295
+ handle.assertOwned();
296
+ const claims = currentClaims(root, handle.claimsPath);
297
+ if (!validate('claims', claims).ok || sha256(JSON.stringify(claims)) !== handle.plan.claimsHash) throw new Error('claims changed before source write');
298
+ return claims;
299
+ };
300
+ try {
301
+ const checked = revalidateAnnotationPlan({ projectDir: root, claims: checkClaims(), plan: handle.plan });
302
+ if (!checked.current) throw new Error(`annotation write preflight refused: ${checked.reason}`);
303
+ if (files.length) {
304
+ stage = 'started-journal';
305
+ persistRecovery(join(handle.recoveryDir, 'started.json'), Buffer.from(JSON.stringify({ files: files.map((file) => file.file) })));
306
+ }
307
+ for (const [index, file] of files.entries()) {
308
+ activeFile = file.file; stage = 'target-preflight';
309
+ checkClaims();
310
+ const target = admittedPath(root, file.file);
311
+ if (target.reason) throw new Error(`target refused: ${target.reason}`);
312
+ const fd = openSync(target.path, constants.O_RDWR | (constants.O_NOFOLLOW ?? 0));
313
+ try {
314
+ const input = readDescriptor(fd, target.stat);
315
+ if (input.reason || input.sourceHash !== file.sourceHash || !isDeepStrictEqual(input.identity, file.identity)) throw new Error('target changed before source write');
316
+ const current = admittedPath(root, file.file);
317
+ if (current.reason || current.stat.dev !== file.identity.dev || current.stat.ino !== file.identity.ino || current.stat.mode !== file.identity.mode) throw new Error('target path changed before source write');
318
+ handle.assertOwned();
319
+ const bytes = Buffer.from(file.proposed, 'utf8');
320
+ stage = 'source-write'; report.touched.push(file.file);
321
+ let offset = 0;
322
+ while (offset < bytes.length) {
323
+ const count = writeSync(fd, bytes, offset, bytes.length - offset, offset);
324
+ if (!Number.isInteger(count) || count <= 0 || count > bytes.length - offset) throw new Error('source write made no valid progress');
325
+ offset += count;
326
+ }
327
+ ftruncateSync(fd, bytes.length);
328
+ fsyncSync(fd);
329
+ stage = 'write-verification';
330
+ const output = readDescriptor(fd, fstatSync(fd));
331
+ const after = admittedPath(root, file.file);
332
+ if (output.reason || output.sourceHash !== sha256(bytes) || !isDeepStrictEqual(output.identity, file.identity) || after.reason || after.stat.dev !== file.identity.dev || after.stat.ino !== file.identity.ino) throw new Error('source write verification failed');
333
+ report.changed.push(file.file);
334
+ } finally { closeSync(fd); }
335
+ stage = 'completion-journal';
336
+ persistRecovery(join(handle.recoveryDir, `${index}.applied.json`), Buffer.from(JSON.stringify({ file: file.file, proposedHash: sha256(Buffer.from(file.proposed, 'utf8')) })));
337
+ }
338
+ } catch (error) {
339
+ report.state = report.touched.length ? 'partial' : 'refused';
340
+ report.failed.push({ file: activeFile, stage, reason: error.message });
341
+ } finally {
342
+ report.lockRelease = handle.release();
343
+ if (!report.lockRelease.released && !report.failed.length) {
344
+ report.state = report.touched.length ? 'partial' : 'refused';
345
+ report.failed.push({ file: null, stage: 'lock-release', reason: report.lockRelease.reason });
346
+ }
347
+ }
348
+ try {
349
+ persistRecovery(join(handle.recoveryDir, 'result.pending.json'), Buffer.from(JSON.stringify(report)));
350
+ renameSync(join(handle.recoveryDir, 'result.pending.json'), join(handle.recoveryDir, 'result.json'));
351
+ }
352
+ catch (error) {
353
+ report.state = report.touched.length ? 'partial' : 'refused';
354
+ report.failed.push({ file: null, stage: 'result-journal', reason: error.message });
355
+ }
356
+ return freezePrepared(report);
357
+ }
@@ -1,4 +1,4 @@
1
- import { readFileSync, readdirSync, existsSync } from 'node:fs';
1
+ import { readFileSync, readdirSync, existsSync, opendirSync, openSync, fstatSync, readSync, closeSync, constants } from 'node:fs';
2
2
  import { join, extname } from 'node:path';
3
3
 
4
4
  const SCAN_EXT = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx', '.py', '.go', '.rs', '.java', '.kt', '.md']);
@@ -6,7 +6,7 @@ const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage']);
6
6
  const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)(tests?|__tests__)\//;
7
7
  // An annotation id must contain a hyphen (REDACT-001, TG-KILL-NEEDS-N). Prose
8
8
  // such as "@claim annotations" is not an annotation.
9
- const RE = /@claim\s+([A-Za-z0-9]+(?:[._]?[A-Za-z0-9]+)*-[A-Za-z0-9._-]*[A-Za-z0-9])\b/g;
9
+ const RE = /@claim\s+([A-Za-z0-9]+(?:[._][A-Za-z0-9]+)*-[A-Za-z0-9._-]*[A-Za-z0-9])\b/g;
10
10
 
11
11
  /**
12
12
  * Source files worth scanning: skips hidden directories (tooling), dependency
@@ -57,3 +57,62 @@ export function reconcile(claims, annotations) {
57
57
  annotated: [...declared.keys()].filter((id) => annotated.has(id)),
58
58
  };
59
59
  }
60
+
61
+ /** Lexical links aid discovery, never verification or provenance authentication. */
62
+ export function annotationAdvisory(claims, annotations, { customClaims = false } = {}) {
63
+ const linked = new Set(annotations.map((a) => a.id));
64
+ const missingIds = [...new Set(claims.claims.map((c) => c.id))].filter((id) => !linked.has(id)).sort();
65
+ return {
66
+ missingIds,
67
+ notes: missingIds.length ? [
68
+ `ANNOTATION ADVISORY: ${missingIds.length} declared claim ID${missingIds.length === 1 ? '' : 's'} lack a scanned source link. Discoverability and annotation reconciliation are reduced; exact fault anchors remain checked.`,
69
+ ...missingIds.map((id) => `Missing source annotation: ${id}`),
70
+ 'Optional read-only preview in the selected project: testguard claims --annotate. Placement is not verification or authenticated intent; review before explicitly applying.',
71
+ ...(customClaims ? ['Preserve the original --claims argument when running that preview; the default claims file is not this selection.'] : []),
72
+ ] : [],
73
+ };
74
+ }
75
+
76
+ /** Advice cannot turn unrelated file failures or excessive reads into status failure. */
77
+ export function projectAnnotationAdvisory(projectDir, claims, options) {
78
+ const annotations = [];
79
+ let entries = 0; let files = 0; let bytes = 0;
80
+ const visit = (dir, rel, depth) => {
81
+ if (depth > 64) throw new Error('scan incomplete');
82
+ const directory = opendirSync(dir);
83
+ try {
84
+ let e;
85
+ while ((e = directory.readSync())) {
86
+ if (++entries > 10000) throw new Error('scan incomplete');
87
+ const file = rel ? `${rel}/${e.name}` : e.name;
88
+ if (e.isDirectory()) {
89
+ if (e.name.startsWith('.') || SKIP_DIRS.has(e.name) || existsSync(join(dir, e.name, 'testguard.claims.json'))) continue;
90
+ visit(join(dir, e.name), file, depth + 1);
91
+ } else if (e.isFile() && SCAN_EXT.has(extname(e.name)) && !TEST_FILE.test(file)) {
92
+ if (++files > 1000) throw new Error('scan incomplete');
93
+ const fd = openSync(join(dir, e.name), constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
94
+ try {
95
+ const stat = fstatSync(fd);
96
+ const limit = Math.min(256 * 1024, 2 * 1024 * 1024 - bytes);
97
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > limit) throw new Error('scan incomplete');
98
+ const buffer = Buffer.alloc(limit + 1);
99
+ let read = 0; let n;
100
+ while ((n = readSync(fd, buffer, read, buffer.length - read, null))) {
101
+ read += n;
102
+ if (read > limit) throw new Error('scan incomplete');
103
+ }
104
+ bytes += read;
105
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, read));
106
+ text.split('\n').forEach((line, i) => {
107
+ for (const m of line.matchAll(RE)) annotations.push({ id: m[1], file, line: i + 1 });
108
+ });
109
+ } finally { closeSync(fd); }
110
+ }
111
+ }
112
+ } finally { directory.closeSync(); }
113
+ };
114
+ try { visit(projectDir, '', 0); return annotationAdvisory(claims, annotations, options); }
115
+ catch {
116
+ return { missingIds: [], notes: ['ANNOTATION ADVISORY unavailable: source scan was incomplete or exceeded its resource limits. No missing-ID conclusion was made; verification state and next action are unchanged.'] };
117
+ }
118
+ }
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}`);