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.
- package/CHANGELOG.md +82 -0
- package/README.md +196 -17
- package/package.json +1 -1
- package/spec/GATE-SEMANTICS.md +316 -0
- package/spec/README.md +27 -2
- package/spec/lib/authoring-input.mjs +40 -0
- package/spec/lib/origins.mjs +56 -0
- package/spec/lib/validate.mjs +129 -1
- package/spec/schemas/annotations.schema.json +65 -0
- package/spec/schemas/authoring-input.schema.json +67 -0
- package/spec/schemas/brief.schema.json +2 -0
- package/spec/schemas/claims.schema.json +8 -8
- package/spec/schemas/common.schema.json +73 -0
- package/spec/schemas/evidence.schema.json +41 -8
- package/spec/schemas/status.schema.json +31 -2
- package/src/brief/brief.mjs +11 -4
- package/src/claims/annotation-placement.mjs +357 -0
- package/src/claims/annotations.mjs +61 -2
- package/src/cli.mjs +40 -5
- package/src/commands/admit.mjs +21 -3
- package/src/commands/annotations.mjs +67 -0
- package/src/commands/claims.mjs +46 -6
- package/src/commands/probe.mjs +50 -7
- package/src/commands/replay.mjs +7 -0
- package/src/commands/scaffold.mjs +49 -2
- package/src/commands/sweep.mjs +7 -0
- package/src/evidence/writer.mjs +12 -4
- package/src/gate/changed.mjs +5 -1
- package/src/git.mjs +4 -4
- package/src/init/init.mjs +6 -0
- package/src/init/templates/SKILL.md +78 -0
- package/src/probe/attribution.mjs +19 -0
- package/src/probe/contention.mjs +20 -2
- package/src/probe/cost.mjs +22 -9
- package/src/probe/inject.mjs +2 -6
- package/src/probe/origin-policy.mjs +69 -0
- package/src/probe/policy-freshness.mjs +74 -0
- package/src/probe/probe.mjs +74 -45
- package/src/probe/runners/discovery.mjs +2 -0
- package/src/probe/runners/jest.mjs +2 -2
- package/src/probe/runners/lifecycle.mjs +58 -0
- package/src/probe/runners/playwright.mjs +6 -2
- package/src/probe/runners/python.mjs +10 -4
- package/src/probe/runners/shared.mjs +12 -6
- package/src/probe/runners/vitest.mjs +3 -3
- package/src/probe/universe.mjs +47 -0
- package/src/probe/worktree.mjs +3 -1
- package/src/render.mjs +17 -1
- package/src/replay/replay.mjs +8 -4
- package/src/scaffold/admission.mjs +117 -0
- package/src/scaffold/append.mjs +41 -0
- package/src/scaffold/apply-append.mjs +71 -0
- package/src/scaffold/fix-input.mjs +119 -0
- package/src/scaffold/fix-paths.mjs +90 -0
- package/src/scaffold/intent.mjs +2 -0
- package/src/scaffold/open-append.mjs +44 -0
- package/src/scaffold/plan-append.mjs +37 -0
- package/src/scaffold/plan-intent-input.mjs +38 -0
- package/src/scaffold/prepare-append.mjs +46 -0
- package/src/scaffold/request.mjs +30 -0
- package/src/scaffold/scaffold.mjs +23 -10
- package/src/status/status.mjs +42 -10
- package/src/sweep/pool.mjs +33 -0
- package/src/sweep/sweep.mjs +9 -4
package/src/brief/brief.mjs
CHANGED
|
@@ -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]+(?:[._]
|
|
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
|
|
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 --
|
|
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 --
|
|
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}`);
|