testguard-cli 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -0
- package/README.md +169 -14
- 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 +123 -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 +2 -8
- package/spec/schemas/common.schema.json +73 -0
- package/spec/schemas/evidence.schema.json +40 -10
- package/spec/schemas/status.schema.json +6 -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 +41 -6
- package/src/commands/admit.mjs +13 -0
- package/src/commands/annotations.mjs +67 -0
- package/src/commands/claims.mjs +8 -4
- 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/git.mjs +4 -4
- package/src/init/templates/SKILL.md +35 -0
- package/src/probe/attribution.mjs +10 -0
- package/src/probe/contention.mjs +20 -2
- package/src/probe/cost.mjs +6 -1
- 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 +49 -24
- package/src/probe/runners/discovery.mjs +4 -0
- package/src/probe/runners/index.mjs +3 -1
- package/src/probe/runners/jest.mjs +2 -2
- package/src/probe/runners/lifecycle.mjs +58 -0
- package/src/probe/runners/node/entry.mjs +11 -0
- package/src/probe/runners/node/report.mjs +66 -0
- package/src/probe/runners/node/runner.mjs +42 -0
- package/src/probe/runners/node-test.mjs +130 -0
- package/src/probe/runners/playwright.mjs +6 -2
- package/src/probe/runners/python.mjs +10 -4
- package/src/probe/runners/shared.mjs +14 -7
- 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 +34 -9
- package/src/sweep/pool.mjs +33 -0
- package/src/sweep/sweep.mjs +9 -4
|
@@ -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)
|
|
@@ -60,7 +64,7 @@ probe
|
|
|
60
64
|
append-only lines everywhere else, so a redirected log and CI see progress
|
|
61
65
|
instead of twenty silent minutes. Always on stderr; ndjson also streams verdicts
|
|
62
66
|
--confirm below 3 is PROVISIONAL: verdicts print with "?", evidence goes to .testguard/evidence-provisional.json
|
|
63
|
-
--runner <name> vitest | jest | playwright | python | pytest | unittest | auto
|
|
67
|
+
--runner <name> vitest | jest | playwright | python | pytest | unittest | node-test | auto
|
|
64
68
|
(default: auto — first of vitest, jest, python that resolves. A defender under playwright's testDir
|
|
65
69
|
always runs under playwright, and a .py defender always under python, whatever the project runner.
|
|
66
70
|
python picks pytest when the interpreter can import it and stdlib unittest otherwise; pytest and
|
|
@@ -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}`);
|
package/src/commands/admit.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createCommandBudget, parseCommandBudget } from '../command-budget.mjs';
|
|
1
2
|
import { existsSync } from 'node:fs';
|
|
2
3
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
3
4
|
import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
|
|
@@ -45,6 +46,14 @@ export async function admitCommand({ file, values, version }, io) {
|
|
|
45
46
|
}
|
|
46
47
|
const confirmRuns = Number(values.confirm);
|
|
47
48
|
const budgetMs = Number(values.budget);
|
|
49
|
+
const commandBudgetMs = parseCommandBudget(values['command-budget']);
|
|
50
|
+
if (commandBudgetMs === null) { io.err('--command-budget must be at least 1000 milliseconds'); return 3; }
|
|
51
|
+
const commandBudget = createCommandBudget(commandBudgetMs);
|
|
52
|
+
const workers = Number(values.workers ?? 1);
|
|
53
|
+
if (!Number.isSafeInteger(workers) || workers < 1) {
|
|
54
|
+
io.err('--workers must be a positive safe integer');
|
|
55
|
+
return 3;
|
|
56
|
+
}
|
|
48
57
|
if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
|
|
49
58
|
io.err('--confirm must be a positive integer and --budget at least 1000');
|
|
50
59
|
return 3;
|
|
@@ -84,6 +93,9 @@ export async function admitCommand({ file, values, version }, io) {
|
|
|
84
93
|
claims: { ...all, claims: [{ ...claim, faults }] },
|
|
85
94
|
confirmRuns,
|
|
86
95
|
budgetMs,
|
|
96
|
+
commandBudget,
|
|
97
|
+
workers,
|
|
98
|
+
serial: values.serial || workers === 1,
|
|
87
99
|
mode: 'worktree',
|
|
88
100
|
includeDirty: true,
|
|
89
101
|
only: [claimId],
|
|
@@ -96,6 +108,7 @@ export async function admitCommand({ file, values, version }, io) {
|
|
|
96
108
|
});
|
|
97
109
|
if (process.stderr.isTTY && !values.quiet && !values.json) process.stderr.write('\r\x1b[K');
|
|
98
110
|
const outPath = values.out ? resolve(values.out) : join(projectDir, '.testguard', 'evidence-partial.json');
|
|
111
|
+
commandBudget?.assertOpen();
|
|
99
112
|
writeSpecDoc('evidence', outPath, evidence);
|
|
100
113
|
|
|
101
114
|
const decision = decideAdmission(evidence.records);
|