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
package/src/status/status.mjs
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { join, relative } from 'node:path';
|
|
1
|
+
import { existsSync, readFileSync, lstatSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { join, relative, isAbsolute, sep } from 'node:path';
|
|
3
3
|
import { readSpecDoc } from '../evidence/writer.mjs';
|
|
4
4
|
import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
|
|
5
5
|
import { gate } from '../baseline/baseline.mjs';
|
|
6
6
|
import { hashFile, sha256 } from '../util/hash.mjs';
|
|
7
7
|
import { resolveDefenders } from '../probe/runners/shared.mjs';
|
|
8
8
|
import { discoverDefenders } from '../probe/discover.mjs';
|
|
9
|
-
import { defenderSelection } from '../probe/attribution.mjs';
|
|
10
|
-
import { coAuthorshipWarning, sortForReport } from '../render.mjs';
|
|
9
|
+
import { defenderSelection, sameClaimMetadata } from '../probe/attribution.mjs';
|
|
10
|
+
import { coAuthorshipWarning, sortForReport, renderOrigins, renderOriginPolicy } from '../render.mjs';
|
|
11
|
+
import { evaluateOriginPolicy } from '../probe/origin-policy.mjs';
|
|
12
|
+
import { declaredOriginSummary } from '../../spec/lib/origins.mjs';
|
|
13
|
+
import { intentHandoff } from '../scaffold/intent.mjs';
|
|
11
14
|
import { computeChangedGate, defaultIgnorePath } from '../gate/changed.mjs';
|
|
12
15
|
import { headSha, isAncestor } from '../git.mjs';
|
|
13
16
|
import { checkAnchorLocations } from '../claims/anchors.mjs';
|
|
14
17
|
import { computeClaimedSurface, needsClaimExpansion } from './surface.mjs';
|
|
18
|
+
import { projectAnnotationAdvisory } from '../claims/annotations.mjs';
|
|
15
19
|
|
|
16
20
|
export const faultContentHash = (fault) => sha256(`${fault.find}\n${fault.replace}`);
|
|
17
21
|
|
|
@@ -44,7 +48,7 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
|
|
|
44
48
|
tool: { name: 'testguard', version: toolVersion },
|
|
45
49
|
generatedAt,
|
|
46
50
|
state: 'no-claims',
|
|
47
|
-
next: { action: 'scaffold', command: 'testguard scaffold <source-file>', why:
|
|
51
|
+
next: { action: 'scaffold', command: 'testguard scaffold <source-file>', why: `No testguard.claims.json in this project. Scaffold proposes mechanical faults, not intent. ${intentHandoff}` },
|
|
48
52
|
provisional: false,
|
|
49
53
|
counts: { claims: 0, faults: 0 },
|
|
50
54
|
stale: [],
|
|
@@ -71,14 +75,14 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
|
|
|
71
75
|
}
|
|
72
76
|
const unclaimedWhy = () => {
|
|
73
77
|
const files = changes.uncovered.map((u) => u.file);
|
|
74
|
-
return `${files.length} changed file${files.length === 1 ? '' : 's'} since ${changes.ref} carr${files.length === 1 ? 'ies' : 'y'} no claim and no excusing ignore entry: ${files.slice(0, 3).join(', ')}${files.length > 3 ? ', …' : ''}. State the claim before writing more code — TestGuard is silent about unclaimed code by construction
|
|
78
|
+
return `${files.length} changed file${files.length === 1 ? '' : 's'} since ${changes.ref} carr${files.length === 1 ? 'ies' : 'y'} no claim and no excusing ignore entry: ${files.slice(0, 3).join(', ')}${files.length > 3 ? ', …' : ''}. State the claim before writing more code — TestGuard is silent about unclaimed code by construction. ${intentHandoff}`;
|
|
75
79
|
};
|
|
76
80
|
|
|
77
81
|
if (!existsSync(paths.claims)) {
|
|
78
82
|
doc.surface = computeClaimedSurface({ projectDir });
|
|
79
83
|
const candidate = doc.surface.rankedUnclaimed[0];
|
|
80
84
|
if (candidate && !changes?.uncovered.length) {
|
|
81
|
-
doc.next = { action: 'scaffold', command: `testguard scaffold ${candidate.file}`, why: `${doc.surface.sourceModules} source module${doc.surface.sourceModules === 1 ? '' : 's'} exist and none carries a claim. Start with ${candidateReason(doc.surface, candidate)}
|
|
85
|
+
doc.next = { action: 'scaffold', command: `testguard scaffold ${candidate.file}`, why: `${doc.surface.sourceModules} source module${doc.surface.sourceModules === 1 ? '' : 's'} exist and none carries a claim. Start with ${candidateReason(doc.surface, candidate)}. ${intentHandoff}`, file: candidate.file };
|
|
82
86
|
}
|
|
83
87
|
if (changes?.uncovered.length) {
|
|
84
88
|
const u = changes.uncovered[0];
|
|
@@ -89,11 +93,13 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
|
|
|
89
93
|
doc.paths.claims = rel(paths.claims);
|
|
90
94
|
|
|
91
95
|
const claims = loadClaims(paths.claims);
|
|
96
|
+
doc.origins = declaredOriginSummary(claims.claims);
|
|
97
|
+
if (claims.claims.length) doc.notes.push(...projectAnnotationAdvisory(projectDir, claims, { customClaims: paths.claims !== defaultClaimsPath(projectDir) }).notes);
|
|
92
98
|
doc.counts.claims = claims.claims.length;
|
|
93
99
|
doc.counts.faults = claims.claims.reduce((n, c) => n + c.faults.length, 0);
|
|
94
100
|
doc.surface = computeClaimedSurface({ projectDir, claims });
|
|
95
101
|
if (claims.claims.length === 0 && !changes?.uncovered.length) {
|
|
96
|
-
doc.next = { action: 'scaffold', command: 'testguard scaffold <source-file>', why:
|
|
102
|
+
doc.next = { action: 'scaffold', command: 'testguard scaffold <source-file>', why: `No claims have been declared; no verification was performed. ${intentHandoff}` };
|
|
97
103
|
return doc;
|
|
98
104
|
}
|
|
99
105
|
const faultIndex = new Map();
|
|
@@ -140,6 +146,9 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
|
|
|
140
146
|
doc.paths.evidence = rel(evidenceFile);
|
|
141
147
|
doc.evidenceSource = evidenceOverride ? 'provided' : 'local';
|
|
142
148
|
const evidence = readSpecDoc('evidence', evidenceFile);
|
|
149
|
+
// Offline status cannot collect native runner/configuration membership.
|
|
150
|
+
// Only reuse the selection; a saved pass is never current freshness proof.
|
|
151
|
+
if (evidence.originPolicy) doc.originPolicy = evaluateOriginPolicy({ claims, evidence, eligibleKinds: evidence.originPolicy.eligibleKinds, fresh: false });
|
|
143
152
|
// A verdict is about a commit. When the evidence describes a different one,
|
|
144
153
|
// say both: "looks right and is not" is the failure mode this prevents.
|
|
145
154
|
doc.evidenceHead = evidence.run.repo.snapshot ?? evidence.run.repo.head;
|
|
@@ -162,10 +171,24 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
|
|
|
162
171
|
|
|
163
172
|
// ── Staleness: does the evidence still describe this claims file and this tree? ──
|
|
164
173
|
const probed = new Map(evidence.records.map((r) => [`${r.claim.id}/${r.subject.id}`, r]));
|
|
174
|
+
const discoveryHashCache = new Map();
|
|
165
175
|
for (const key of faultIndex.keys()) if (!probed.has(key)) doc.stale.push(`fault ${key} has never been probed`);
|
|
166
176
|
for (const [key, r] of probed) {
|
|
167
177
|
const cur = faultIndex.get(key);
|
|
168
178
|
if (!cur) { doc.stale.push(`fault ${key} was probed but is no longer in the claims file`); continue; }
|
|
179
|
+
if (!sameClaimMetadata(r.claim, cur.claim)) doc.stale.push(`claim metadata for ${key} changed since it was probed`);
|
|
180
|
+
for (const [file, recordedHash] of Object.entries(r.inputs.discoveryHashes ?? {})) {
|
|
181
|
+
if (!discoveryHashCache.has(file)) {
|
|
182
|
+
let currentHash = null;
|
|
183
|
+
try {
|
|
184
|
+
const path = join(projectDir, file);
|
|
185
|
+
const inside = relative(realpathSync(projectDir), realpathSync(path));
|
|
186
|
+
if (!isAbsolute(inside) && inside !== '..' && !inside.startsWith(`..${sep}`) && lstatSync(path).isFile()) currentHash = hashFile(path);
|
|
187
|
+
} catch { /* Missing/unreadable discovery inputs are stale, not fresh. */ }
|
|
188
|
+
discoveryHashCache.set(file, currentHash);
|
|
189
|
+
}
|
|
190
|
+
if (discoveryHashCache.get(file) !== recordedHash) doc.stale.push(`${file} discovery input changed or became unavailable since ${key} was probed`);
|
|
191
|
+
}
|
|
169
192
|
if (r.subject.contentHash && r.subject.contentHash !== faultContentHash(cur.fault)) {
|
|
170
193
|
doc.changedFaults.push({ claimId: cur.claim.id, subjectId: cur.fault.id, previousVerdict: r.verdict, file: cur.fault.file });
|
|
171
194
|
doc.stale.push(`fault ${key} changed since it was probed (was ${r.verdict})`);
|
|
@@ -264,7 +287,7 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
|
|
|
264
287
|
doc.next = {
|
|
265
288
|
action: 'claim',
|
|
266
289
|
command: `testguard scaffold ${candidate.file}`,
|
|
267
|
-
why: `The existing claims are defended, but only ${doc.surface.claimedModules} of ${doc.surface.sourceModules} source modules carry a claim. Expand the denominator at ${candidateReason(doc.surface, candidate)}
|
|
290
|
+
why: `The existing claims are defended, but only ${doc.surface.claimedModules} of ${doc.surface.sourceModules} source modules carry a claim. Expand the denominator at ${candidateReason(doc.surface, candidate)}. ${intentHandoff}`,
|
|
268
291
|
file: candidate.file,
|
|
269
292
|
};
|
|
270
293
|
}
|
|
@@ -278,6 +301,8 @@ export function renderStatus(doc) {
|
|
|
278
301
|
}
|
|
279
302
|
lines.push(`state: ${doc.state}${doc.provisional ? ' (provisional)' : ''} — ${doc.counts.claims} claims / ${doc.counts.faults} faults` + (doc.counts.byVerdict ? `; ${Object.entries(doc.counts.byVerdict).map(([k, v]) => `${v} ${k}`).join(', ')}; ${doc.counts.new ?? 0} new, ${doc.counts.baselined ?? 0} baselined` : '')
|
|
280
303
|
);
|
|
304
|
+
if (doc.origins) lines.push(renderOrigins(doc.origins));
|
|
305
|
+
if (doc.originPolicy) lines.push(renderOriginPolicy(doc.originPolicy, { basis: 'offline' }));
|
|
281
306
|
if (doc.surface) {
|
|
282
307
|
const s = doc.surface;
|
|
283
308
|
const history = s.history.available ? `last ${s.history.commitsRead} commit${s.history.commitsRead === 1 ? '' : 's'}` : 'git history unavailable';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { validate } from '../../spec/lib/validate.mjs';
|
|
2
|
+
|
|
3
|
+
const check = (doc) => {
|
|
4
|
+
const result = validate('claims', doc);
|
|
5
|
+
if (!result.ok) throw new Error(`non-conforming sweep draft: ${result.errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`);
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** Keep per-file drafts distinct before selection, without changing callers. */
|
|
9
|
+
export function poolDrafts(drafts) {
|
|
10
|
+
for (const draft of drafts) check(draft);
|
|
11
|
+
const pooled = structuredClone(drafts);
|
|
12
|
+
const entries = pooled.flatMap((draft) => draft.claims.map((claim) => ({ claim, originalId: claim.id, file: claim.faults[0].file ?? '' })));
|
|
13
|
+
entries.sort((a, b) => a.file.localeCompare(b.file) || a.originalId.localeCompare(b.originalId));
|
|
14
|
+
const reserved = new Set(entries.map((entry) => entry.originalId));
|
|
15
|
+
const used = new Set();
|
|
16
|
+
const nextFor = new Map();
|
|
17
|
+
for (const { claim, originalId } of entries) {
|
|
18
|
+
let id = originalId;
|
|
19
|
+
if (used.has(id)) {
|
|
20
|
+
let n = nextFor.get(originalId) ?? 2;
|
|
21
|
+
do {
|
|
22
|
+
const suffix = `-${n++}`;
|
|
23
|
+
id = originalId.slice(0, 128 - suffix.length) + suffix;
|
|
24
|
+
} while (reserved.has(id) || used.has(id));
|
|
25
|
+
nextFor.set(originalId, n);
|
|
26
|
+
}
|
|
27
|
+
claim.id = id;
|
|
28
|
+
used.add(id);
|
|
29
|
+
}
|
|
30
|
+
for (const draft of pooled) check(draft);
|
|
31
|
+
check({ schemaVersion: 1, claims: pooled.flatMap((draft) => draft.claims) });
|
|
32
|
+
return pooled;
|
|
33
|
+
}
|
package/src/sweep/sweep.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import { existsSync } from 'node:fs';
|
|
|
34
34
|
import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
|
|
35
35
|
import { computeChangedGate } from '../gate/changed.mjs';
|
|
36
36
|
import { scaffoldFile } from '../scaffold/scaffold.mjs';
|
|
37
|
+
import { poolDrafts } from './pool.mjs';
|
|
37
38
|
import { selectFaults, onWritePath, capFor } from '../supply/select.mjs';
|
|
38
39
|
import { learnedProductivity } from '../supply/feedback.mjs';
|
|
39
40
|
import { loadConcerns, concernById, targetsFor, filterByConcern } from '../supply/concerns.mjs';
|
|
@@ -163,6 +164,8 @@ export async function sweep({
|
|
|
163
164
|
confirmRuns = 3,
|
|
164
165
|
budgetMs = 120_000,
|
|
165
166
|
commandBudget,
|
|
167
|
+
workers = 1,
|
|
168
|
+
serial = workers === 1,
|
|
166
169
|
runnerCommand,
|
|
167
170
|
runnerName,
|
|
168
171
|
nodeModules,
|
|
@@ -206,9 +209,8 @@ export async function sweep({
|
|
|
206
209
|
const cPath = claimsPath ?? defaultClaimsPath(projectDir);
|
|
207
210
|
const existingClaims = existsSync(cPath) ? loadClaims(cPath) : { claims: [] };
|
|
208
211
|
|
|
209
|
-
const candidates = [];
|
|
210
212
|
const skipped = [];
|
|
211
|
-
const
|
|
213
|
+
const rawDrafts = [];
|
|
212
214
|
for (const file of targets) {
|
|
213
215
|
commandBudget?.assertOpen();
|
|
214
216
|
let result;
|
|
@@ -225,9 +227,10 @@ export async function sweep({
|
|
|
225
227
|
skipped.push({ file, reason: 'no line in this file matches a fault producer' });
|
|
226
228
|
continue;
|
|
227
229
|
}
|
|
228
|
-
|
|
229
|
-
for (const claim of result.doc.claims) for (const fault of claim.faults) candidates.push({ claim, fault });
|
|
230
|
+
rawDrafts.push(result.doc);
|
|
230
231
|
}
|
|
232
|
+
const drafts = poolDrafts(rawDrafts);
|
|
233
|
+
const candidates = drafts.flatMap((draft) => draft.claims.flatMap((claim) => claim.faults.map((fault) => ({ claim, fault }))));
|
|
231
234
|
|
|
232
235
|
// What the defenders of this surface are CAPABLE of proving, before anything
|
|
233
236
|
// is probed. A test that replaced the database can prove the call shape and
|
|
@@ -259,6 +262,8 @@ export async function sweep({
|
|
|
259
262
|
claims: { schemaVersion: 1, claims },
|
|
260
263
|
confirmRuns,
|
|
261
264
|
budgetMs,
|
|
265
|
+
workers,
|
|
266
|
+
serial,
|
|
262
267
|
commandBudget,
|
|
263
268
|
mode: 'worktree',
|
|
264
269
|
includeDirty,
|