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,67 @@
|
|
|
1
|
+
import { planAnnotationPlacement, applyAnnotationPlacement, loadAnnotationClaims } from '../claims/annotation-placement.mjs';
|
|
2
|
+
import { validate } from '../../spec/lib/validate.mjs';
|
|
3
|
+
import { SpecDocError } from '../evidence/writer.mjs';
|
|
4
|
+
|
|
5
|
+
/** Authoring is explicitly selected, never an inspection side effect. */
|
|
6
|
+
export async function annotationsCommand({ projectDir, path, values, suppliedOptions = Object.keys(values), version }, io) {
|
|
7
|
+
const allowed = new Set(['claims', 'claim', 'annotate', 'apply', 'json']);
|
|
8
|
+
if (!values.annotate || suppliedOptions.some((key) => !allowed.has(key))) {
|
|
9
|
+
io.err('--apply requires --annotate; annotation authoring cannot combine inspection/output switches');
|
|
10
|
+
return 3;
|
|
11
|
+
}
|
|
12
|
+
const claimIds = (values.claim ?? []).flatMap((value) => value.split(','));
|
|
13
|
+
let claims;
|
|
14
|
+
try {
|
|
15
|
+
claims = loadAnnotationClaims(path);
|
|
16
|
+
if (!validate('claims', claims).ok) throw new Error('claims must conform before annotation placement');
|
|
17
|
+
}
|
|
18
|
+
catch { io.err('annotation input refused: claims could not be safely loaded or validated'); return 2; }
|
|
19
|
+
if (claimIds.some((id) => !id || !claims.claims.some((claim) => claim.id === id))) {
|
|
20
|
+
io.err('--claim must name existing non-empty claim IDs');
|
|
21
|
+
return 3;
|
|
22
|
+
}
|
|
23
|
+
const plan = planAnnotationPlacement({ projectDir, claims, claimIds });
|
|
24
|
+
const doc = {
|
|
25
|
+
schemaVersion: 1, tool: { name: 'testguard', version }, mode: values.apply ? 'apply' : 'preview',
|
|
26
|
+
state: plan.applicable ? 'preview' : 'refused', selected: plan.selectedClaimIds,
|
|
27
|
+
targets: plan.files.map(({ file, claimIds, changed }) => ({ file, claimIds, action: changed ? 'add' : 'unchanged' })),
|
|
28
|
+
refused: plan.refused,
|
|
29
|
+
};
|
|
30
|
+
if (values.apply) {
|
|
31
|
+
doc.state = 'refused';
|
|
32
|
+
doc.outcome = { changed: [], touched: [], unchanged: plan.files.filter((f) => !f.changed).map((f) => f.file), failed: [] };
|
|
33
|
+
if (plan.applicable) {
|
|
34
|
+
try {
|
|
35
|
+
const report = applyAnnotationPlacement({ projectDir, claimsPath: path, plan });
|
|
36
|
+
doc.state = report.state;
|
|
37
|
+
doc.outcome = {
|
|
38
|
+
changed: report.changed, touched: report.touched, unchanged: report.unchanged,
|
|
39
|
+
failed: report.failed.map(publicFailure), recoveryDir: report.recoveryDir,
|
|
40
|
+
lockRelease: { released: report.lockRelease.released, ...(report.lockRelease.reason ? { reason: 'owned lock release could not be verified' } : {}) },
|
|
41
|
+
};
|
|
42
|
+
} catch (error) {
|
|
43
|
+
doc.outcome.failed.push(publicFailure({ file: null, stage: 'preparation' }));
|
|
44
|
+
if (error.recoveryDir) doc.outcome.recoveryDir = error.recoveryDir;
|
|
45
|
+
if (error.lockRelease) doc.outcome.lockRelease = { released: error.lockRelease.released, ...(error.lockRelease.reason ? { reason: 'owned lock release could not be verified' } : {}) };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const checked = validate('annotations', doc);
|
|
50
|
+
if (!checked.ok) throw new SpecDocError(`annotation result does not conform: ${checked.errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`);
|
|
51
|
+
if (values.json) io.out(JSON.stringify(doc, null, 2));
|
|
52
|
+
else {
|
|
53
|
+
io.out(`Annotation ${doc.mode}: ${doc.state} — file-level authoring, not verification or intent authentication.`);
|
|
54
|
+
for (const t of doc.targets) io.out(` ${t.action} ${t.file}: ${t.claimIds.join(', ')}`);
|
|
55
|
+
for (const r of doc.refused) io.out(` REFUSED ${r.file}: ${r.reason}`);
|
|
56
|
+
for (const f of doc.outcome?.failed ?? []) io.out(` FAILED ${f.file ?? '(operation)'} [${f.stage}]: ${f.reason}`);
|
|
57
|
+
if (doc.outcome?.recoveryDir) io.out(`Recovery originals retained: ${doc.outcome.recoveryDir}`);
|
|
58
|
+
if (doc.mode === 'preview' && doc.state === 'preview') io.out('No files changed. Review this selection, then rerun with --apply; the plan is recomputed against current files.');
|
|
59
|
+
}
|
|
60
|
+
return doc.state === 'preview' || doc.state === 'applied' ? 0 : 2;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Raw syscall messages may contain absolute source paths. Keep them in the
|
|
64
|
+
// private recovery journal, never the public authoring document.
|
|
65
|
+
function publicFailure({ file, stage }) {
|
|
66
|
+
return { file, stage, reason: `Annotation ${stage} failed; inspect private recovery details when available.` };
|
|
67
|
+
}
|
package/src/commands/claims.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
|
|
3
|
-
import { scanAnnotations, reconcile } from '../claims/annotations.mjs';
|
|
3
|
+
import { scanAnnotations, reconcile, annotationAdvisory } from '../claims/annotations.mjs';
|
|
4
4
|
import { resolveDefenders } from '../probe/runners/shared.mjs';
|
|
5
5
|
import { discoverDefendersDetailed } from '../probe/discover.mjs';
|
|
6
6
|
import { classifyDefenders } from '../probe/mocks.mjs';
|
|
@@ -11,6 +11,7 @@ import { readSpecDoc } from '../evidence/writer.mjs';
|
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
12
|
import { checkAnchors, renderAnchorChecks } from '../claims/anchors.mjs';
|
|
13
13
|
import { defenderSelection } from '../probe/attribution.mjs';
|
|
14
|
+
import { annotationsCommand } from './annotations.mjs';
|
|
14
15
|
|
|
15
16
|
/** A selection warning is not a verdict: only a fresh probe can defend it. */
|
|
16
17
|
export function defenderNarrowing(projectDir, claims, records) {
|
|
@@ -32,11 +33,13 @@ export function defenderNarrowing(projectDir, claims, records) {
|
|
|
32
33
|
return warnings;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
export async function claimsCommand({ projectDir, values, version }, io) {
|
|
36
|
+
export async function claimsCommand({ projectDir, values, suppliedOptions, version }, io) {
|
|
36
37
|
const path = values.claims ? resolve(values.claims) : defaultClaimsPath(projectDir);
|
|
38
|
+
if (values.annotate || values.apply) return annotationsCommand({ projectDir, path, values, suppliedOptions, version }, io);
|
|
37
39
|
const claims = loadClaims(path);
|
|
38
40
|
const annotations = scanAnnotations(projectDir);
|
|
39
41
|
const drift = reconcile(claims, annotations);
|
|
42
|
+
const advisory = annotationAdvisory(claims, annotations, { customClaims: Boolean(values.claims) });
|
|
40
43
|
const anchorChecks = values['check-anchors']
|
|
41
44
|
? await checkAnchors(projectDir, claims, { python: values.python ? resolve(values.python) : undefined })
|
|
42
45
|
: undefined;
|
|
@@ -54,16 +57,17 @@ export async function claimsCommand({ projectDir, values, version }, io) {
|
|
|
54
57
|
const prior = (values.cost || hasOverrides) && existsSync(ev) ? readSpecDoc('evidence', ev) : undefined;
|
|
55
58
|
const narrowedDefenders = hasOverrides && prior ? defenderNarrowing(projectDir, claims, prior.records) : [];
|
|
56
59
|
if (values.cost) {
|
|
57
|
-
cost = prior ? costReport(prior.records) : undefined;
|
|
60
|
+
cost = prior ? costReport(prior.records, { run: prior.run }) : undefined;
|
|
58
61
|
if (!cost && !values.json) io.err(`no evidence at ${ev} — run \`testguard probe\` first; cost is derived from the run durations it records`);
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
if (values.json) {
|
|
62
|
-
io.out(JSON.stringify({ path, claims, annotations, drift, ...(hasOverrides ? { narrowedDefenders } : {}), ...(anchorChecks ? { anchorChecks } : {}), ...(removed ? { removed } : {}), ...(cost ? { cost } : {}) }, null, 2));
|
|
65
|
+
io.out(JSON.stringify({ path, claims, annotations, drift, annotationAdvisory: advisory, ...(hasOverrides ? { narrowedDefenders } : {}), ...(anchorChecks ? { anchorChecks } : {}), ...(removed ? { removed } : {}), ...(cost ? { cost } : {}) }, null, 2));
|
|
63
66
|
} else {
|
|
64
67
|
const annotated = new Set(drift.annotated);
|
|
65
68
|
io.out(`${claims.claims.length} claims in ${path} — ${annotated.size} carry a @claim annotation in source (test files are not scanned)`);
|
|
66
69
|
io.out('');
|
|
70
|
+
for (const note of advisory.notes) io.out(note);
|
|
67
71
|
const signalLines = [];
|
|
68
72
|
for (const c of claims.claims) {
|
|
69
73
|
const declared = c.defendedBy?.length > 0;
|
package/src/commands/probe.mjs
CHANGED
|
@@ -4,13 +4,16 @@ import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
|
|
|
4
4
|
import { probe } from '../probe/probe.mjs';
|
|
5
5
|
import { writeSpecDoc, readSpecDoc } from '../evidence/writer.mjs';
|
|
6
6
|
import { gate } from '../baseline/baseline.mjs';
|
|
7
|
-
import { renderRecord, renderSummary, sortForReport, PROVISIONAL_WARNING } from '../render.mjs';
|
|
7
|
+
import { renderRecord, renderSummary, sortForReport, PROVISIONAL_WARNING, renderOriginPolicy } from '../render.mjs';
|
|
8
8
|
import { computeStatus } from '../status/status.mjs';
|
|
9
9
|
import { resolveChangedRef, withChangedRef } from '../gate/changed.mjs';
|
|
10
10
|
import { costReport, renderCost } from '../probe/cost.mjs';
|
|
11
11
|
import { progressMode, stageReporter, clearStageLine, recordEvent, isProgressMode, progressStream } from '../probe/progress.mjs';
|
|
12
12
|
import { validate } from '../../spec/lib/validate.mjs';
|
|
13
13
|
import { createCommandBudget, parseCommandBudget } from '../command-budget.mjs';
|
|
14
|
+
import { originPolicyRequest, evaluateOriginPolicy } from '../probe/origin-policy.mjs';
|
|
15
|
+
import { readNativeTestUniverse } from '../probe/universe.mjs';
|
|
16
|
+
import { originPolicyInputsFresh } from '../probe/policy-freshness.mjs';
|
|
14
17
|
export const provisionalEvidencePath = (projectDir) => join(projectDir, '.testguard', 'evidence-provisional.json');
|
|
15
18
|
|
|
16
19
|
export const evidencePath = (projectDir) => join(projectDir, '.testguard', 'evidence.json');
|
|
@@ -35,8 +38,16 @@ export function partialScope(only, records) {
|
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export async function probeCommand({ projectDir, values, version }, io) {
|
|
41
|
+
let eligibleKinds;
|
|
42
|
+
try { eligibleKinds = originPolicyRequest(values); }
|
|
43
|
+
catch (e) { io.err(e.message); return 3; }
|
|
38
44
|
const confirmRuns = Number(values.confirm);
|
|
39
45
|
const budgetMs = Number(values.budget);
|
|
46
|
+
const workers = Number(values.workers ?? 1);
|
|
47
|
+
if (!Number.isSafeInteger(workers) || workers < 1) {
|
|
48
|
+
io.err('--workers must be a positive safe integer');
|
|
49
|
+
return 3;
|
|
50
|
+
}
|
|
40
51
|
const commandBudgetMs = parseCommandBudget(values['command-budget']);
|
|
41
52
|
if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
|
|
42
53
|
io.err('--confirm must be a positive integer and --budget at least 1000');
|
|
@@ -94,12 +105,13 @@ export async function probeCommand({ projectDir, values, version }, io) {
|
|
|
94
105
|
previous,
|
|
95
106
|
confirmRuns,
|
|
96
107
|
budgetMs,
|
|
108
|
+
workers,
|
|
109
|
+
serial: values.serial || workers === 1,
|
|
97
110
|
commandBudget,
|
|
98
111
|
mode: values['in-place'] ? 'in-place' : 'worktree',
|
|
99
112
|
ref: values.ref ?? 'HEAD',
|
|
100
113
|
refExplicit: values.ref !== undefined,
|
|
101
114
|
ignoreDirty: values['ignore-dirty'],
|
|
102
|
-
serial: values.serial,
|
|
103
115
|
onWarn: (m) => io.err(`warning: ${m}`),
|
|
104
116
|
runnerCommand: values['runner-cmd'],
|
|
105
117
|
runnerName: values.runner,
|
|
@@ -123,17 +135,47 @@ export async function probeCommand({ projectDir, values, version }, io) {
|
|
|
123
135
|
},
|
|
124
136
|
});
|
|
125
137
|
commandBudget?.assertOpen();
|
|
138
|
+
if (eligibleKinds) {
|
|
139
|
+
let universe;
|
|
140
|
+
// Custom commands carry a static universe, which cannot establish this
|
|
141
|
+
// native policy boundary. Discovery failures are unavailability, not kills.
|
|
142
|
+
if (!values['runner-cmd']) {
|
|
143
|
+
try {
|
|
144
|
+
universe = await readNativeTestUniverse({
|
|
145
|
+
projectDir, sourceDir: projectDir, runnerName: values.runner,
|
|
146
|
+
python: values.python ? resolve(values.python) : undefined,
|
|
147
|
+
budgetMs: commandBudget?.runBudget(budgetMs) ?? budgetMs,
|
|
148
|
+
budgetFor: () => commandBudget?.runBudget(budgetMs) ?? budgetMs,
|
|
149
|
+
assertOpen: () => commandBudget?.assertOpen(),
|
|
150
|
+
});
|
|
151
|
+
} catch { /* An absent binding can never pass policy. */ }
|
|
152
|
+
}
|
|
153
|
+
commandBudget?.assertOpen();
|
|
154
|
+
const currentClaims = loadClaims(values.claims ? resolve(values.claims) : defaultClaimsPath(projectDir));
|
|
155
|
+
const fresh = originPolicyInputsFresh({ projectDir, claims: currentClaims, evidence, universe });
|
|
156
|
+
evidence.originPolicy = evaluateOriginPolicy({ claims: currentClaims, evidence, eligibleKinds, fresh });
|
|
157
|
+
}
|
|
158
|
+
commandBudget?.assertOpen();
|
|
126
159
|
writeSpecDoc('evidence', outPath, evidence);
|
|
127
160
|
|
|
128
161
|
const g = gate(evidence.records, baseline, { severityFloor: values.severity });
|
|
162
|
+
const policy = evidence.originPolicy;
|
|
163
|
+
const exitCode = policy?.state === 'unavailable' ? 2 : policy?.state === 'failed' || g.new.length > 0 ? 1 : 0;
|
|
129
164
|
const scope = partialScope(only, evidence.records);
|
|
130
165
|
if (values.json) {
|
|
131
|
-
const status = withChangedRef(resolveChangedRef({ explicit: values.changed, projectDir }), (changedRef) => computeStatus({
|
|
132
|
-
|
|
166
|
+
const status = withChangedRef(resolveChangedRef({ explicit: values.changed, projectDir }), (changedRef) => computeStatus({
|
|
167
|
+
projectDir, toolVersion: version, changedRef, includeDirty: values['include-dirty'], evidence: outPath,
|
|
168
|
+
paths: {
|
|
169
|
+
claims: values.claims ? resolve(values.claims) : defaultClaimsPath(projectDir),
|
|
170
|
+
evidence: outPath, provisional: provisionalEvidencePath(projectDir),
|
|
171
|
+
baseline: values.baseline ? resolve(values.baseline) : baselinePath(projectDir),
|
|
172
|
+
},
|
|
173
|
+
}), io.err);
|
|
174
|
+
const doc = { ...status, ...(policy ? { originPolicy: policy } : {}), run: { id: evidence.run.id, evidence: outPath, provisional, records: evidence.records.length, newSinceBaseline: g.new.length, exitCode, ...(policy ? { originPolicy: policy } : {}), ...(scope ? { scope } : {}) }, ...(values.cost ? { cost: costReport(evidence.records, { run: evidence.run }) } : {}) };
|
|
133
175
|
const result = validate('status', doc);
|
|
134
176
|
if (!result.ok) throw new Error(`probe JSON document does not conform: ${result.errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`);
|
|
135
177
|
io.out(JSON.stringify(doc, null, 2));
|
|
136
|
-
return
|
|
178
|
+
return exitCode;
|
|
137
179
|
}
|
|
138
180
|
if (!values.quiet && baseline) {
|
|
139
181
|
io.out('');
|
|
@@ -146,12 +188,13 @@ export async function probeCommand({ projectDir, values, version }, io) {
|
|
|
146
188
|
if (killed) io.out(` ${killed} killed (not listed; --verbose to see them)`);
|
|
147
189
|
}
|
|
148
190
|
io.out(renderSummary(evidence.records, evidence.run) + (baseline ? ` ${g.new.length} new since baseline, ${g.baselined.length} baselined.` : ' No baseline.'));
|
|
191
|
+
if (policy) io.out(renderOriginPolicy(policy, { basis: 'current' }));
|
|
149
192
|
if (values.cost) {
|
|
150
193
|
io.out('');
|
|
151
|
-
io.out(renderCost(costReport(evidence.records)));
|
|
194
|
+
io.out(renderCost(costReport(evidence.records, { run: evidence.run })));
|
|
152
195
|
io.out('');
|
|
153
196
|
}
|
|
154
197
|
io.out(`evidence: ${outPath}${scope ? ` (partial: ${scope.requestedCount} claim${scope.requestedCount === 1 ? '' : 's'} requested, ${scope.probedCount} probed: --claim ${scope.requestedClaims.join(',')}; not the canonical evidence file)` : provisional ? ' (provisional; not the canonical evidence file)' : ''}`);
|
|
155
198
|
if (provisional && !values.quiet) io.err(PROVISIONAL_WARNING(confirmRuns));
|
|
156
|
-
return
|
|
199
|
+
return exitCode;
|
|
157
200
|
}
|
package/src/commands/replay.mjs
CHANGED
|
@@ -14,6 +14,11 @@ export async function replayCommand({ projectDir, values, version }, io) {
|
|
|
14
14
|
}
|
|
15
15
|
const confirmRuns = Number(values.confirm);
|
|
16
16
|
const budgetMs = Number(values.budget);
|
|
17
|
+
const workers = Number(values.workers ?? 1);
|
|
18
|
+
if (!Number.isSafeInteger(workers) || workers < 1) {
|
|
19
|
+
io.err('--workers must be a positive safe integer');
|
|
20
|
+
return 3;
|
|
21
|
+
}
|
|
17
22
|
const commandBudgetMs = parseCommandBudget(values['command-budget']);
|
|
18
23
|
const limit = values.max ? Number(values.max) : undefined;
|
|
19
24
|
if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
|
|
@@ -31,6 +36,8 @@ export async function replayCommand({ projectDir, values, version }, io) {
|
|
|
31
36
|
range,
|
|
32
37
|
confirmRuns,
|
|
33
38
|
budgetMs,
|
|
39
|
+
workers,
|
|
40
|
+
serial: values.serial || workers === 1,
|
|
34
41
|
commandBudget,
|
|
35
42
|
runnerCommand: values['runner-cmd'],
|
|
36
43
|
runnerName: values.runner,
|
|
@@ -1,11 +1,58 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join, resolve, relative, basename, extname } from 'node:path';
|
|
3
3
|
import { scaffoldFile } from '../scaffold/scaffold.mjs';
|
|
4
|
+
import { intentHandoff } from '../scaffold/intent.mjs';
|
|
4
5
|
import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
|
|
5
6
|
import { writeSpecDoc } from '../evidence/writer.mjs';
|
|
6
7
|
import { PreconditionError } from '../probe/worktree.mjs';
|
|
8
|
+
import { draftAppendRequest, intentInputRequest } from '../scaffold/request.mjs';
|
|
9
|
+
import { planIntentInput } from '../scaffold/plan-intent-input.mjs';
|
|
10
|
+
import { planDraftAppend } from '../scaffold/plan-append.mjs';
|
|
11
|
+
import { applyDraftAppend } from '../scaffold/apply-append.mjs';
|
|
7
12
|
|
|
8
|
-
export async function scaffoldCommand({ projectDir, file, values, version }, io) {
|
|
13
|
+
export async function scaffoldCommand({ projectDir, file, files = file ? [file] : [], values, suppliedOptions = [], version }, io) {
|
|
14
|
+
let input;
|
|
15
|
+
try { input = intentInputRequest({ command: 'scaffold', values, suppliedOptions, files }); }
|
|
16
|
+
catch (error) { io.err(error.message); return 3; }
|
|
17
|
+
if (input) {
|
|
18
|
+
try {
|
|
19
|
+
const plan = planIntentInput({ projectDir, command: 'scaffold', values, suppliedOptions, files, toolVersion: version });
|
|
20
|
+
if (values.json) io.out(plan.output.trimEnd());
|
|
21
|
+
else {
|
|
22
|
+
io.out(`authoring input inspection (${input.kind}); verification not performed`);
|
|
23
|
+
if (input.kind === 'fix') {
|
|
24
|
+
const counts = plan.doc.input.counts;
|
|
25
|
+
io.out(`${counts.total} changed paths: ${counts.supported} supported, ${counts.deleted} deleted, ${counts.unsupported} unsupported, ${counts.excluded} excluded`);
|
|
26
|
+
}
|
|
27
|
+
io.out('Next: supply independent intent, review proposed faults, then probe. Input inspection is not verification.');
|
|
28
|
+
}
|
|
29
|
+
return 0;
|
|
30
|
+
} catch (error) { io.err(`intent input refused: ${error.message}`); return 2; }
|
|
31
|
+
}
|
|
32
|
+
let append;
|
|
33
|
+
try { append = draftAppendRequest({ command: 'scaffold', values, suppliedOptions, files }); }
|
|
34
|
+
catch (error) { io.err(error.message); return 3; }
|
|
35
|
+
if (append) {
|
|
36
|
+
try {
|
|
37
|
+
const request = { ...append, projectDir, toolVersion: version };
|
|
38
|
+
const plan = planDraftAppend(request);
|
|
39
|
+
if (values.json) {
|
|
40
|
+
writeSpecDoc('claims', plan.draft.path, plan.doc, { publish: output => io.out(output.trimEnd()) });
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
const report = applyDraftAppend({ request, plan });
|
|
44
|
+
if (!report.ok) {
|
|
45
|
+
io.err(`draft append ${report.state}: ${report.failed.map(f => `${f.stage}: ${f.reason}`).join('; ')}; write attempted: ${report.writeAttempted}; bytes written: ${report.bytesWritten}`);
|
|
46
|
+
if (report.recoveryDir) io.err(`private recovery: ${report.recoveryDir}`);
|
|
47
|
+
return 2;
|
|
48
|
+
}
|
|
49
|
+
io.out(`unproven draft ${report.state}: ${plan.draft.path} (${plan.stats.appended} appended faults)`);
|
|
50
|
+
if (report.recoveryDir) io.out(`private recovery: ${report.recoveryDir}`);
|
|
51
|
+
io.out('Next: review the proposed faults against supplied intent, then probe the draft. Authoring is not verification.');
|
|
52
|
+
return 0;
|
|
53
|
+
} catch (error) { io.err(`draft append refused: ${error.message}`); return 2; }
|
|
54
|
+
}
|
|
55
|
+
if (files.length > 1) { io.err('ordinary scaffold accepts one source file; use --into for multiple sources'); return 3; }
|
|
9
56
|
if (!file) {
|
|
10
57
|
io.err('usage: testguard scaffold <source-file> [--claim <ID>] [--out <path>] [--json]');
|
|
11
58
|
return 3;
|
|
@@ -38,6 +85,6 @@ export async function scaffoldCommand({ projectDir, file, values, version }, io)
|
|
|
38
85
|
io.out(`${stats.proposals} proposed fault${stats.proposals === 1 ? '' : 's'} in ${stats.claims} draft claim${stats.claims === 1 ? '' : 's'} for ${rel}${shapes ? ` — ${shapes}` : ''}`);
|
|
39
86
|
io.out(stats.defendedBy.length ? `defendedBy prefilled from imports: ${stats.defendedBy.join(', ')}` : 'no test file imports this module; probing the draft as-is will report NOCOVER');
|
|
40
87
|
io.out(`draft: ${outPath}`);
|
|
41
|
-
io.out(
|
|
88
|
+
io.out(`Next: ${intentHandoff} Replace each TODO statement, drop proposals that do not express the intended claim, then review before moving the claims into testguard.claims.json.`);
|
|
42
89
|
return 0;
|
|
43
90
|
}
|
package/src/commands/sweep.mjs
CHANGED
|
@@ -32,6 +32,11 @@ export async function sweepCommand({ projectDir, values, version }, io) {
|
|
|
32
32
|
const mode = values['save-paths'] ? 'save-paths' : 'changed';
|
|
33
33
|
const confirmRuns = Number(values.confirm);
|
|
34
34
|
const budgetMs = Number(values.budget);
|
|
35
|
+
const workers = Number(values.workers ?? 1);
|
|
36
|
+
if (!Number.isSafeInteger(workers) || workers < 1) {
|
|
37
|
+
io.err('--workers must be a positive safe integer');
|
|
38
|
+
return 3;
|
|
39
|
+
}
|
|
35
40
|
const commandBudgetMs = parseCommandBudget(values['command-budget']);
|
|
36
41
|
if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
|
|
37
42
|
io.err('--confirm must be a positive integer and --budget at least 1000');
|
|
@@ -77,6 +82,8 @@ export async function sweepCommand({ projectDir, values, version }, io) {
|
|
|
77
82
|
cap,
|
|
78
83
|
confirmRuns,
|
|
79
84
|
budgetMs,
|
|
85
|
+
workers,
|
|
86
|
+
serial: values.serial || workers === 1,
|
|
80
87
|
commandBudget,
|
|
81
88
|
runnerCommand: values['runner-cmd'],
|
|
82
89
|
runnerName: values.runner,
|
package/src/evidence/writer.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertNotCancelled } from '../probe/runners/lifecycle.mjs';
|
|
1
2
|
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
2
3
|
import { dirname } from 'node:path';
|
|
3
4
|
import { validate } from '../../spec/lib/validate.mjs';
|
|
@@ -13,18 +14,25 @@ function describe(kind, path, result) {
|
|
|
13
14
|
* Write a spec document — but only if it conforms. The tool is held to its
|
|
14
15
|
* own spec at the moment of output, not by a separate test someone might skip.
|
|
15
16
|
*/
|
|
16
|
-
export function writeSpecDoc(kind, path, doc) {
|
|
17
|
+
export function writeSpecDoc(kind, path, doc, { publish } = {}) {
|
|
18
|
+
assertNotCancelled();
|
|
17
19
|
const result = validate(kind, doc);
|
|
18
20
|
if (!result.ok) throw new SpecDocError('refusing to write: ' + describe(kind, path, result));
|
|
21
|
+
const output = JSON.stringify(doc, null, 2) + '\n';
|
|
22
|
+
if (publish !== undefined) {
|
|
23
|
+
if (typeof publish !== 'function') throw new TypeError('publish must be a function');
|
|
24
|
+
return publish(output);
|
|
25
|
+
}
|
|
19
26
|
mkdirSync(dirname(path), { recursive: true });
|
|
20
|
-
writeFileSync(path,
|
|
27
|
+
writeFileSync(path, output);
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
/** Read a spec document, refusing one that does not conform. */
|
|
24
|
-
export function readSpecDoc(kind, path) {
|
|
31
|
+
export function readSpecDoc(kind, path, { source } = {}) {
|
|
25
32
|
let doc;
|
|
26
33
|
try {
|
|
27
|
-
|
|
34
|
+
if (source !== undefined && typeof source !== 'string') throw new Error('source must be a string');
|
|
35
|
+
doc = JSON.parse(source === undefined ? readFileSync(path, 'utf8') : source);
|
|
28
36
|
} catch (e) {
|
|
29
37
|
throw new SpecDocError(`cannot read ${kind} document ${path}: ${e.message}`);
|
|
30
38
|
}
|
package/src/git.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
7
7
|
export class GitError extends Error {}
|
|
8
8
|
|
|
9
9
|
export function gitRaw(args, cwd, env) {
|
|
10
|
-
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: env ? { ...process.env, ...env } : process.env });
|
|
10
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024, env: env ? { ...process.env, ...env } : process.env });
|
|
11
11
|
if (r.status !== 0) throw new GitError(`git ${args.join(' ')}: ${(r.stderr || r.stdout).trim()}`);
|
|
12
12
|
return r.stdout;
|
|
13
13
|
}
|
|
@@ -27,7 +27,7 @@ export function headSha(dir, ref = 'HEAD') {
|
|
|
27
27
|
|
|
28
28
|
/** Is `ancestor` reachable from `ref`? False when either does not resolve. */
|
|
29
29
|
export function isAncestor(dir, ancestor, ref = 'HEAD') {
|
|
30
|
-
return spawnSync('git', ['merge-base', '--is-ancestor', ancestor, ref], { cwd: dir }).status === 0;
|
|
30
|
+
return spawnSync('git', ['merge-base', '--is-ancestor', ancestor, ref], { cwd: dir, timeout: 30_000 }).status === 0;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/** True when any of `paths` (repo-relative; empty = whole tree) has uncommitted changes. */
|
|
@@ -59,7 +59,7 @@ export function snapshotWorkingTree(repo) {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
export function removeWorktree(repo, dest) {
|
|
62
|
-
spawnSync('git', ['worktree', 'remove', '--force', dest], { cwd: repo });
|
|
62
|
+
spawnSync('git', ['worktree', 'remove', '--force', dest], { cwd: repo, timeout: 30_000 });
|
|
63
63
|
rmSync(dest, { recursive: true, force: true });
|
|
64
|
-
spawnSync('git', ['worktree', 'prune'], { cwd: repo });
|
|
64
|
+
spawnSync('git', ['worktree', 'prune'], { cwd: repo, timeout: 30_000 });
|
|
65
65
|
}
|
|
@@ -54,6 +54,21 @@ sections already listing this project.
|
|
|
54
54
|
|
|
55
55
|
## The one source of truth
|
|
56
56
|
|
|
57
|
+
Missing source-link advisories in claims/status never replace `next`, change
|
|
58
|
+
verdicts or prove intent. Exact anchors remain checked without annotations.
|
|
59
|
+
Origin summaries are likewise informational: status counts current declared
|
|
60
|
+
claims; evidence/briefs count recorded declarations and records, including
|
|
61
|
+
mixed sources. Source kinds are not authenticated independence. Never use an
|
|
62
|
+
origin label to waive a survivor.
|
|
63
|
+
Optional source links: `testguard claims --annotate --claim <ID>` is a read-only
|
|
64
|
+
file-header preview. Only with explicit permission to edit source, rerun with
|
|
65
|
+
`--apply`; each invocation recomputes its plan. Review refusals and partial
|
|
66
|
+
failures, and retain the reported private recovery originals. Never automatically
|
|
67
|
+
remove an existing lock or roll back over user edits. Placement is file-level,
|
|
68
|
+
not symbol ownership, verified behavior or authenticated intent. Missing links
|
|
69
|
+
do not disable exact fault-anchor checks. Authoring never replaces the status
|
|
70
|
+
operating loop or writes canonical claims/evidence/baselines.
|
|
71
|
+
|
|
57
72
|
```bash
|
|
58
73
|
testguard status --json
|
|
59
74
|
```
|
|
@@ -76,6 +91,18 @@ Exit codes: `0` clean · `1` unproven claims (or drift, or unclaimed changes) ·
|
|
|
76
91
|
|
|
77
92
|
## Every change needs a claim
|
|
78
93
|
|
|
94
|
+
Scaffold proposes mechanical faults, not intended behavior. New drafts declare
|
|
95
|
+
`source.kind: inferred` even when grouped by an annotation. Before adopting a
|
|
96
|
+
draft, ask for the intended observable behavior from a requirement, ADR, bug or
|
|
97
|
+
incident independently of the implementation: inputs, expected outcome and
|
|
98
|
+
forbidden outcome. Record the supplied source kind/ref; when unavailable, keep
|
|
99
|
+
inferred and say the intent remains unestablished. Do not promote origin because
|
|
100
|
+
an annotation exists or tests pass. References are opaque, not authenticated;
|
|
101
|
+
verification commands never fetch them. Preserve existing supplied metadata.
|
|
102
|
+
Review each proposal against that intent, then probe. Do not paraphrase the code
|
|
103
|
+
and call that independent intent, or automatically move drafts into canonical
|
|
104
|
+
claims.
|
|
105
|
+
|
|
79
106
|
`probe` verifies only the claims that exist; it is silent about unclaimed
|
|
80
107
|
code by construction. So, for every source file you create or change:
|
|
81
108
|
|
|
@@ -108,6 +135,14 @@ code by construction. So, for every source file you create or change:
|
|
|
108
135
|
|
|
109
136
|
## The fix loop
|
|
110
137
|
|
|
138
|
+
For independently supplied intent in an existing disposable draft, use
|
|
139
|
+
`testguard scaffold <source...> --into <draft.json> --claim <existing-ID> --json`
|
|
140
|
+
to preview appended faults without writes. Omit `--json` only when explicitly
|
|
141
|
+
authorized to update that draft; private recovery is retained. Never use a
|
|
142
|
+
canonical claims/evidence/baseline path. Preserve intended behavior and declared
|
|
143
|
+
origin; generated faults are not independent intent or verification. Review the
|
|
144
|
+
faults and probe the selected draft before promoting it to canonical claims.
|
|
145
|
+
|
|
111
146
|
1. `testguard status --json` → take `next.target`.
|
|
112
147
|
2. Write the test. It must **fail when the fault is applied and pass on HEAD**. To check the first half by hand: apply `find` → `replace` in the target file, run the defender, restore.
|
|
113
148
|
3. `testguard admit <test-file> --claim <ID>` — the two gates as one verb, on your uncommitted test, without touching the tree: `ADMITTED` (exit 0) means the test is green on HEAD and fails on every fault of the claim, 3/3; `NOT ADMITTED` (exit 1) names the first blocking fault and what to do. `--fault <FID>` judges one fault; `--confirm 1` gives a fast **provisional** `ADMITTED?` that must be confirmed at 3 before you commit. (`testguard probe --claim <ID> --include-dirty` is the same run with the full report.)
|
|
@@ -11,8 +11,17 @@
|
|
|
11
11
|
* worktree is. Everything it needs is passed in.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
15
|
+
|
|
14
16
|
const isKill = (r) => r.outcome === 'fail' && (r.assertionFailures ?? 0) > 0;
|
|
15
17
|
|
|
18
|
+
/** Compare only the claim metadata actually recorded in evidence. */
|
|
19
|
+
export function sameClaimMetadata(recorded, current) {
|
|
20
|
+
if (!recorded || !current) return false;
|
|
21
|
+
const metadata = ({ id, statement, severity, source, producedBy }) => ({ id, statement, severity, source, producedBy });
|
|
22
|
+
return isDeepStrictEqual(metadata(recorded), metadata(current));
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
/**
|
|
17
26
|
* Fold one escalation run into the running attribution state.
|
|
18
27
|
*
|
|
@@ -118,6 +127,7 @@ export function defenderSelection(claim, fault) {
|
|
|
118
127
|
|
|
119
128
|
export function isReusable(prior, current) {
|
|
120
129
|
if (!prior || !prior.subject?.contentHash) return false;
|
|
130
|
+
if (current.claim && !sameClaimMetadata(prior.claim, current.claim)) return false;
|
|
121
131
|
// A record that exists because the probe threw is a statement about the run,
|
|
122
132
|
// not about the code. Reusing it would make one disturbed run permanent.
|
|
123
133
|
if (prior.detail?.reason === 'probe-error') return false;
|
package/src/probe/contention.mjs
CHANGED
|
@@ -37,9 +37,13 @@ export function processList({ platform = process.platform, run = spawnSync } = {
|
|
|
37
37
|
try {
|
|
38
38
|
const r = platform === 'win32'
|
|
39
39
|
? run('wmic', ['process', 'get', 'ProcessId,CommandLine', '/format:csv'], { encoding: 'utf8', timeout: 5000 })
|
|
40
|
-
: run('ps', ['-Ao', 'pid=,args='], { encoding: 'utf8', timeout: 5000 });
|
|
40
|
+
: run('ps', ['-Ao', 'pid=,ppid=,stat=,args='], { encoding: 'utf8', timeout: 5000 });
|
|
41
41
|
if (r.status !== 0 || !r.stdout) return [];
|
|
42
42
|
return r.stdout.split('\n').map((line) => {
|
|
43
|
+
if (platform !== 'win32') {
|
|
44
|
+
const row = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
|
|
45
|
+
if (row) return { pid: Number(row[1]), ppid: Number(row[2]), stat: row[3], command: row[4].trim() };
|
|
46
|
+
}
|
|
43
47
|
const m = platform === 'win32' ? /^[^,]*,(.*),(\d+)\s*$/.exec(line) : /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
44
48
|
if (!m) return null;
|
|
45
49
|
const [pid, command] = platform === 'win32' ? [m[2], m[1]] : [m[1], m[2]];
|
|
@@ -57,8 +61,22 @@ export function processList({ platform = process.platform, run = spawnSync } = {
|
|
|
57
61
|
*/
|
|
58
62
|
export function detectContention({ self = process.pid, alive = defaultAlive, ...opts } = {}) {
|
|
59
63
|
const rows = processList(opts);
|
|
64
|
+
const owned = new Set([self]);
|
|
65
|
+
let changed = true;
|
|
66
|
+
while (changed) {
|
|
67
|
+
changed = false;
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
if (owned.has(row.ppid) && !owned.has(row.pid)) { owned.add(row.pid); changed = true; }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const ancestors = new Set();
|
|
73
|
+
let parent = rows.find((row) => row.pid === self)?.ppid;
|
|
74
|
+
while (parent && !ancestors.has(parent)) {
|
|
75
|
+
ancestors.add(parent);
|
|
76
|
+
parent = rows.find((row) => row.pid === parent)?.ppid;
|
|
77
|
+
}
|
|
60
78
|
const runners = rows
|
|
61
|
-
.filter((p) => p.pid
|
|
79
|
+
.filter((p) => !owned.has(p.pid) && !ancestors.has(p.pid) && !p.stat?.startsWith('Z') && RUNNER_RE.test(p.command) && (p.ppid !== undefined || !SELF_RE.test(p.command)))
|
|
62
80
|
// `ps` lists the grep/ps itself and any shell wrapper; a runner's command
|
|
63
81
|
// line always names its own binary, so require it to look like an exec.
|
|
64
82
|
.filter((p) => !/^\s*(ps|grep|wmic)\b/.test(p.command))
|
package/src/probe/cost.mjs
CHANGED
|
@@ -92,10 +92,11 @@ export function defenderCosts(records) {
|
|
|
92
92
|
* turns a twenty-four-minute gate into a one-minute one, and until a report
|
|
93
93
|
* names the file nobody knows which test to split.
|
|
94
94
|
*/
|
|
95
|
-
export function costReport(records) {
|
|
95
|
+
export function costReport(records, { run } = {}) {
|
|
96
96
|
const claims = claimCosts(records);
|
|
97
97
|
const defenders = defenderCosts(records);
|
|
98
98
|
return {
|
|
99
|
+
...(run?.measurements ? { measurements: { ...run.measurements } } : {}),
|
|
99
100
|
totalMs: claims.reduce((a, c) => a + c.ms, 0),
|
|
100
101
|
totalRuns: claims.reduce((a, c) => a + c.runs, 0),
|
|
101
102
|
reusedRecords: records.filter((r) => r.reusedFrom).length,
|
|
@@ -118,6 +119,10 @@ export const formatMs = secs;
|
|
|
118
119
|
*/
|
|
119
120
|
export function renderCost(report, { limit = 10 } = {}) {
|
|
120
121
|
const out = [];
|
|
122
|
+
if (report.measurements) {
|
|
123
|
+
const m = report.measurements;
|
|
124
|
+
out.push(`actual elapsed ${secs(m.elapsedMs)}; fresh runners ${secs(m.runnerMs)} across ${m.runnerInvocations} invocations; setup/discovery/other overhead ${secs(m.overheadMs)}`);
|
|
125
|
+
}
|
|
121
126
|
if (!report.records) return 'no evidence records — run `testguard probe` first; cost is read back from the runs it records.';
|
|
122
127
|
out.push(`${report.records} fault record${report.records === 1 ? '' : 's'} cost ${secs(report.totalMs)} across ${report.totalRuns} defender runs${report.reusedRecords ? ` (${report.reusedRecords} reused; their runs are what they cost when reuse does not apply)` : ''}.`);
|
|
123
128
|
out.push('');
|
package/src/probe/inject.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { registerCleanup } from './runners/lifecycle.mjs';
|
|
1
2
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
|
|
@@ -19,12 +20,7 @@ function restoreAll() {
|
|
|
19
20
|
function installHandlers() {
|
|
20
21
|
if (handlersInstalled) return;
|
|
21
22
|
handlersInstalled = true;
|
|
22
|
-
|
|
23
|
-
process.on(sig, () => {
|
|
24
|
-
restoreAll();
|
|
25
|
-
process.exit(130);
|
|
26
|
-
});
|
|
27
|
-
}
|
|
23
|
+
registerCleanup(restoreAll, { priority: 1 });
|
|
28
24
|
process.on('uncaughtException', (err) => {
|
|
29
25
|
restoreAll();
|
|
30
26
|
throw err;
|