testguard-cli 0.15.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/README.md +196 -17
- package/package.json +1 -1
- package/spec/GATE-SEMANTICS.md +316 -0
- package/spec/README.md +27 -2
- package/spec/lib/authoring-input.mjs +40 -0
- package/spec/lib/origins.mjs +56 -0
- package/spec/lib/validate.mjs +129 -1
- package/spec/schemas/annotations.schema.json +65 -0
- package/spec/schemas/authoring-input.schema.json +67 -0
- package/spec/schemas/brief.schema.json +2 -0
- package/spec/schemas/claims.schema.json +8 -8
- package/spec/schemas/common.schema.json +73 -0
- package/spec/schemas/evidence.schema.json +41 -8
- package/spec/schemas/status.schema.json +31 -2
- package/src/brief/brief.mjs +11 -4
- package/src/claims/annotation-placement.mjs +357 -0
- package/src/claims/annotations.mjs +61 -2
- package/src/cli.mjs +40 -5
- package/src/commands/admit.mjs +21 -3
- package/src/commands/annotations.mjs +67 -0
- package/src/commands/claims.mjs +46 -6
- package/src/commands/probe.mjs +50 -7
- package/src/commands/replay.mjs +7 -0
- package/src/commands/scaffold.mjs +49 -2
- package/src/commands/sweep.mjs +7 -0
- package/src/evidence/writer.mjs +12 -4
- package/src/gate/changed.mjs +5 -1
- package/src/git.mjs +4 -4
- package/src/init/init.mjs +6 -0
- package/src/init/templates/SKILL.md +78 -0
- package/src/probe/attribution.mjs +19 -0
- package/src/probe/contention.mjs +20 -2
- package/src/probe/cost.mjs +22 -9
- package/src/probe/inject.mjs +2 -6
- package/src/probe/origin-policy.mjs +69 -0
- package/src/probe/policy-freshness.mjs +74 -0
- package/src/probe/probe.mjs +74 -45
- package/src/probe/runners/discovery.mjs +2 -0
- package/src/probe/runners/jest.mjs +2 -2
- package/src/probe/runners/lifecycle.mjs +58 -0
- package/src/probe/runners/playwright.mjs +6 -2
- package/src/probe/runners/python.mjs +10 -4
- package/src/probe/runners/shared.mjs +12 -6
- package/src/probe/runners/vitest.mjs +3 -3
- package/src/probe/universe.mjs +47 -0
- package/src/probe/worktree.mjs +3 -1
- package/src/render.mjs +17 -1
- package/src/replay/replay.mjs +8 -4
- package/src/scaffold/admission.mjs +117 -0
- package/src/scaffold/append.mjs +41 -0
- package/src/scaffold/apply-append.mjs +71 -0
- package/src/scaffold/fix-input.mjs +119 -0
- package/src/scaffold/fix-paths.mjs +90 -0
- package/src/scaffold/intent.mjs +2 -0
- package/src/scaffold/open-append.mjs +44 -0
- package/src/scaffold/plan-append.mjs +37 -0
- package/src/scaffold/plan-intent-input.mjs +38 -0
- package/src/scaffold/prepare-append.mjs +46 -0
- package/src/scaffold/request.mjs +30 -0
- package/src/scaffold/scaffold.mjs +23 -10
- package/src/status/status.mjs +42 -10
- package/src/sweep/pool.mjs +33 -0
- package/src/sweep/sweep.mjs +9 -4
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { chmodSync, mkdtempSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
|
+
import { authoringLock, persistRecovery, recoveryBase } from '../claims/annotation-placement.mjs';
|
|
5
|
+
import { sha256 } from '../util/hash.mjs';
|
|
6
|
+
import { planDraftAppend } from './plan-append.mjs';
|
|
7
|
+
|
|
8
|
+
function freezeOwned(value) {
|
|
9
|
+
if (value && typeof value === 'object') {
|
|
10
|
+
for (const child of Object.values(value)) freezeOwned(child);
|
|
11
|
+
Object.freeze(value);
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Preparation only: current-plan replay and recovery, never draft writes. */
|
|
17
|
+
export function prepareDraftAppend({ request, plan }) {
|
|
18
|
+
const initial = planDraftAppend(request);
|
|
19
|
+
if (!isDeepStrictEqual(initial, plan)) throw new Error('draft append plan changed');
|
|
20
|
+
if (!initial.changed) return freezeOwned({ ready: true, plan: initial, release: () => ({ released: true }), assertOwned: () => {} });
|
|
21
|
+
// All cooperative source/draft authoring in the checkout shares this lock.
|
|
22
|
+
const lock = authoringLock(initial.projectDir);
|
|
23
|
+
let recoveryDir;
|
|
24
|
+
try {
|
|
25
|
+
const fresh = planDraftAppend(request);
|
|
26
|
+
if (!isDeepStrictEqual(fresh, initial)) throw new Error('draft append plan changed under lock');
|
|
27
|
+
lock.assertOwned();
|
|
28
|
+
recoveryDir = mkdtempSync(join(recoveryBase(fresh.projectDir), 'testguard-draft-recovery-'));
|
|
29
|
+
chmodSync(recoveryDir, 0o700);
|
|
30
|
+
persistRecovery(join(recoveryDir, 'draft.original'), Buffer.from(fresh.draft.source, 'utf8'));
|
|
31
|
+
const manifest = {
|
|
32
|
+
state: 'prepared', projectDir: fresh.projectDir, draftPath: fresh.draft.path,
|
|
33
|
+
claimId: fresh.claimId, sourceHash: fresh.draft.hash,
|
|
34
|
+
proposedHash: sha256(Buffer.from(fresh.output, 'utf8')), identity: fresh.draft.identity,
|
|
35
|
+
sources: fresh.sources.map(({ file, hash, identity }) => ({ file, hash, identity })),
|
|
36
|
+
};
|
|
37
|
+
persistRecovery(join(recoveryDir, 'manifest.json'), Buffer.from(JSON.stringify(manifest), 'utf8'));
|
|
38
|
+
lock.assertOwned();
|
|
39
|
+
return freezeOwned({ ready: true, plan: fresh, recoveryDir, release: lock.release, assertOwned: lock.assertOwned });
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (recoveryDir) error.recoveryDir = recoveryDir;
|
|
42
|
+
try { error.lockRelease = lock.release(); }
|
|
43
|
+
catch (releaseError) { error.lockRelease = { released: false, reason: 'lock-release-failed', error: releaseError.message }; }
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Token-aware CLI admission; defaults are not explicit user options. */
|
|
2
|
+
export function draftAppendRequest({ command, values, suppliedOptions = [], files = [] }) {
|
|
3
|
+
if (values.into === undefined) return null;
|
|
4
|
+
if (command !== 'scaffold') throw new Error('--into is only valid on scaffold');
|
|
5
|
+
const allowed = new Set(['into', 'claim', 'json', 'help', 'version']);
|
|
6
|
+
const conflict = suppliedOptions.find(name => !allowed.has(name));
|
|
7
|
+
if (conflict || values.out !== undefined || values.claims !== undefined) throw new Error(`--into cannot use --${conflict || (values.out !== undefined ? 'out' : 'claims')}`);
|
|
8
|
+
if (!Array.isArray(values.into) || values.into.length !== 1 || !values.into[0]?.trim() || values.into[0] !== values.into[0].trim()) throw new Error('--into requires exactly one existing draft path');
|
|
9
|
+
if (!Array.isArray(values.claim) || values.claim.length !== 1 || !values.claim[0]?.trim() || values.claim[0].includes(',') || values.claim[0] !== values.claim[0].trim()) throw new Error('--into requires exactly one --claim <existing-ID>');
|
|
10
|
+
if (!files.length) throw new Error('--into requires at least one source file');
|
|
11
|
+
return { draftPath: values.into[0], claimId: values.claim[0], files };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Internal read-only input selection; public CLI exposure requires its report contract. */
|
|
15
|
+
export function intentInputRequest({ command, values, suppliedOptions = [], files = [] }) {
|
|
16
|
+
const modes = ['from-document', 'from-fix'].filter(name => values[name] !== undefined);
|
|
17
|
+
if (!modes.length) return null;
|
|
18
|
+
if ('scaffold' !== command) throw new Error('intent input is only valid on scaffold');
|
|
19
|
+
if (modes.length !== 1) throw new Error('select exactly one input mode');
|
|
20
|
+
const allowed = new Set(['from-document', 'from-fix', 'json', 'help', 'version']);
|
|
21
|
+
const conflict = suppliedOptions.find(name => !allowed.has(name)) ?? ['into', 'claim', 'claims', 'out'].find(name => values[name] !== undefined);
|
|
22
|
+
if (conflict) throw new Error(`intent input cannot use --${conflict}`);
|
|
23
|
+
if (files.length) throw new Error('intent input cannot use positional source files');
|
|
24
|
+
const mode = modes[0], selectors = values[mode];
|
|
25
|
+
if (!Array.isArray(selectors) || selectors.length !== 1 || typeof selectors[0] !== 'string' || !selectors[0] || selectors[0] !== selectors[0].trim() || selectors[0].includes('\0')) throw new Error('intent input requires exactly one nonempty trimmed selector');
|
|
26
|
+
const selector = selectors[0];
|
|
27
|
+
if (Buffer.byteLength(selector) > 4096) throw new Error('intent input selector-byte-limit');
|
|
28
|
+
if (mode === 'from-fix' && !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(selector)) throw new Error('intent input requires full-object-id');
|
|
29
|
+
return Object.freeze(mode === 'from-document' ? { kind: 'document', file: selector } : { kind: 'fix', commit: selector });
|
|
30
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { join } from 'node:path';
|
|
3
3
|
import { proposalsForLine, functionHead, functionParams } from './producers.mjs';
|
|
4
4
|
import * as py from './producers.python.mjs';
|
|
5
5
|
import { locate } from '../probe/inject.mjs';
|
|
@@ -15,8 +15,6 @@ const NO_FIELD_DROPS = /\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)(__tests__|__fixtures
|
|
|
15
15
|
// change which proposals a JavaScript file gets.
|
|
16
16
|
const NO_FIELD_DROPS_PY = /(^|\/)(test[^/]*\.py|[^/]*_test\.py|conftest\.py)$|(^|\/)(tests?|__fixtures__|fixtures|migrations)\//;
|
|
17
17
|
|
|
18
|
-
const idPart = (s) => s.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').toUpperCase();
|
|
19
|
-
|
|
20
18
|
const isPython = (file) => file.endsWith('.py');
|
|
21
19
|
|
|
22
20
|
/**
|
|
@@ -136,10 +134,13 @@ function anchorFor(source, find, offset) {
|
|
|
136
134
|
* with `producedBy: { producer: "derived" }` and TODO statements a human must
|
|
137
135
|
* replace. Never touches the real claims file.
|
|
138
136
|
*/
|
|
139
|
-
export function scaffoldFile({ projectDir, file, claimId, existingClaims, toolVersion = '0.0.0' }) {
|
|
140
|
-
|
|
137
|
+
export function scaffoldFile({ projectDir, file, claimId, existingClaims, toolVersion = '0.0.0', source: admittedSource, maxProposals }) {
|
|
138
|
+
if (maxProposals !== undefined && (!Number.isSafeInteger(maxProposals) || maxProposals < 0)) throw new Error('maxProposals must be a non-negative safe integer');
|
|
139
|
+
if (admittedSource !== undefined && typeof admittedSource !== 'string') throw new Error('source must be a string');
|
|
140
|
+
// Internal authoring can scan bytes already admitted through a descriptor.
|
|
141
|
+
// Legacy callers still read the file; supplied empty text never falls back.
|
|
142
|
+
const source = admittedSource === undefined ? readFileSync(join(projectDir, file), 'utf8') : admittedSource;
|
|
141
143
|
const lines = source.split('\n');
|
|
142
|
-
const stem = idPart(basename(file, extname(file)));
|
|
143
144
|
const producedBy = { producer: 'derived', by: `testguard scaffold ${toolVersion}` };
|
|
144
145
|
|
|
145
146
|
const groups = new Map(); // key → { id, proposals[] , annotated }
|
|
@@ -150,28 +151,40 @@ export function scaffoldFile({ projectDir, file, claimId, existingClaims, toolVe
|
|
|
150
151
|
|
|
151
152
|
const fieldDrops = !(isPython(file) ? NO_FIELD_DROPS_PY : NO_FIELD_DROPS).test(file);
|
|
152
153
|
const scan = isPython(file) ? scanPython : scanJs;
|
|
153
|
-
|
|
154
|
+
let accepted = 0;
|
|
155
|
+
const keep = (fault) => {
|
|
156
|
+
if (!usableProposal(source, fault)) return false;
|
|
157
|
+
if (maxProposals !== undefined && accepted >= maxProposals) throw new Error('scaffold proposal limit exceeded');
|
|
158
|
+
accepted++;
|
|
159
|
+
return true;
|
|
160
|
+
};
|
|
161
|
+
const proposals = scan({ source, lines, fieldDrops, keep, anchor: (find, offset) => anchorFor(source, find, offset) });
|
|
154
162
|
|
|
155
163
|
const existing = new Map((existingClaims?.claims ?? []).map((c) => [c.id, c]));
|
|
156
164
|
const usedIds = new Set();
|
|
157
165
|
for (const p of proposals) {
|
|
158
166
|
const key = claimId ? '__all__' : p.annotation ?? p.fn ?? '__file__';
|
|
159
|
-
let id = claimId ?? p.annotation ??
|
|
167
|
+
let id = claimId ?? p.annotation ?? null;
|
|
160
168
|
groupFor(key, id).proposals.push(p);
|
|
161
169
|
}
|
|
162
170
|
|
|
163
171
|
const defendedBy = discoverDefenders(projectDir, file);
|
|
164
172
|
const claims = [];
|
|
173
|
+
const reservedIds = new Set([...existing.keys(), ...[...groups.values()].map(g => g.id).filter(id => id !== null)]);
|
|
174
|
+
let placeholder = 1;
|
|
165
175
|
for (const g of groups.values()) {
|
|
166
176
|
let id = g.id;
|
|
177
|
+
if (id === null) {
|
|
178
|
+
do { id = `TODO-CLAIM-${placeholder++}`; } while (reservedIds.has(id) || usedIds.has(id));
|
|
179
|
+
}
|
|
167
180
|
while (usedIds.has(id)) id += '-2';
|
|
168
181
|
usedIds.add(id);
|
|
169
182
|
const base = existing.get(id);
|
|
170
183
|
const fnLabel = g.proposals[0].fn ? `\`${g.proposals[0].fn}\`` : 'this file';
|
|
171
184
|
const claim = {
|
|
172
185
|
id,
|
|
173
|
-
statement: base?.statement ?? `TODO:
|
|
174
|
-
source: base?.source ?? { kind: '
|
|
186
|
+
statement: base?.statement ?? `TODO: supply intended observable behavior for ${fnLabel} in ${file} from a requirement, ADR, bug or incident: inputs, expected outcome and forbidden outcome (${g.proposals.length} proposed fault${g.proposals.length === 1 ? '' : 's'}; keep or drop each against that intent)`,
|
|
187
|
+
source: base?.source ?? { kind: 'inferred', ref: `testguard scaffold ${file}` },
|
|
175
188
|
severity: base?.severity ?? 'medium',
|
|
176
189
|
producedBy: base?.producedBy ?? producedBy,
|
|
177
190
|
...(base?.defendedBy?.length ? { defendedBy: base.defendedBy } : defendedBy.length ? { defendedBy } : {}),
|
package/src/status/status.mjs
CHANGED
|
@@ -1,16 +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 {
|
|
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';
|
|
10
14
|
import { computeChangedGate, defaultIgnorePath } from '../gate/changed.mjs';
|
|
11
15
|
import { headSha, isAncestor } from '../git.mjs';
|
|
12
16
|
import { checkAnchorLocations } from '../claims/anchors.mjs';
|
|
13
17
|
import { computeClaimedSurface, needsClaimExpansion } from './surface.mjs';
|
|
18
|
+
import { projectAnnotationAdvisory } from '../claims/annotations.mjs';
|
|
14
19
|
|
|
15
20
|
export const faultContentHash = (fault) => sha256(`${fault.find}\n${fault.replace}`);
|
|
16
21
|
|
|
@@ -43,7 +48,7 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
|
|
|
43
48
|
tool: { name: 'testguard', version: toolVersion },
|
|
44
49
|
generatedAt,
|
|
45
50
|
state: 'no-claims',
|
|
46
|
-
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}` },
|
|
47
52
|
provisional: false,
|
|
48
53
|
counts: { claims: 0, faults: 0 },
|
|
49
54
|
stale: [],
|
|
@@ -70,14 +75,14 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
|
|
|
70
75
|
}
|
|
71
76
|
const unclaimedWhy = () => {
|
|
72
77
|
const files = changes.uncovered.map((u) => u.file);
|
|
73
|
-
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}`;
|
|
74
79
|
};
|
|
75
80
|
|
|
76
81
|
if (!existsSync(paths.claims)) {
|
|
77
82
|
doc.surface = computeClaimedSurface({ projectDir });
|
|
78
83
|
const candidate = doc.surface.rankedUnclaimed[0];
|
|
79
84
|
if (candidate && !changes?.uncovered.length) {
|
|
80
|
-
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 };
|
|
81
86
|
}
|
|
82
87
|
if (changes?.uncovered.length) {
|
|
83
88
|
const u = changes.uncovered[0];
|
|
@@ -88,11 +93,13 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
|
|
|
88
93
|
doc.paths.claims = rel(paths.claims);
|
|
89
94
|
|
|
90
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);
|
|
91
98
|
doc.counts.claims = claims.claims.length;
|
|
92
99
|
doc.counts.faults = claims.claims.reduce((n, c) => n + c.faults.length, 0);
|
|
93
100
|
doc.surface = computeClaimedSurface({ projectDir, claims });
|
|
94
101
|
if (claims.claims.length === 0 && !changes?.uncovered.length) {
|
|
95
|
-
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}` };
|
|
96
103
|
return doc;
|
|
97
104
|
}
|
|
98
105
|
const faultIndex = new Map();
|
|
@@ -139,6 +146,9 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
|
|
|
139
146
|
doc.paths.evidence = rel(evidenceFile);
|
|
140
147
|
doc.evidenceSource = evidenceOverride ? 'provided' : 'local';
|
|
141
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 });
|
|
142
152
|
// A verdict is about a commit. When the evidence describes a different one,
|
|
143
153
|
// say both: "looks right and is not" is the failure mode this prevents.
|
|
144
154
|
doc.evidenceHead = evidence.run.repo.snapshot ?? evidence.run.repo.head;
|
|
@@ -161,17 +171,36 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
|
|
|
161
171
|
|
|
162
172
|
// ── Staleness: does the evidence still describe this claims file and this tree? ──
|
|
163
173
|
const probed = new Map(evidence.records.map((r) => [`${r.claim.id}/${r.subject.id}`, r]));
|
|
174
|
+
const discoveryHashCache = new Map();
|
|
164
175
|
for (const key of faultIndex.keys()) if (!probed.has(key)) doc.stale.push(`fault ${key} has never been probed`);
|
|
165
176
|
for (const [key, r] of probed) {
|
|
166
177
|
const cur = faultIndex.get(key);
|
|
167
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
|
+
}
|
|
168
192
|
if (r.subject.contentHash && r.subject.contentHash !== faultContentHash(cur.fault)) {
|
|
169
193
|
doc.changedFaults.push({ claimId: cur.claim.id, subjectId: cur.fault.id, previousVerdict: r.verdict, file: cur.fault.file });
|
|
170
194
|
doc.stale.push(`fault ${key} changed since it was probed (was ${r.verdict})`);
|
|
171
195
|
}
|
|
172
196
|
const target = join(projectDir, cur.fault.file);
|
|
173
197
|
if (existsSync(target) && hashFile(target) !== r.inputs.targetHash) doc.stale.push(`${cur.fault.file} changed since ${key} was probed`);
|
|
174
|
-
const
|
|
198
|
+
const selection = defenderSelection(cur.claim, cur.fault);
|
|
199
|
+
const defenders = selection.requested.length ? resolveDefenders(projectDir, selection.requested) : discoverDefenders(projectDir, cur.fault.file);
|
|
200
|
+
const priorOrigin = r.defenders.selectionSource ?? (r.defenders.requested.length ? 'claim' : 'discovery');
|
|
201
|
+
if (priorOrigin !== selection.selectionSource || JSON.stringify(r.defenders.requested) !== JSON.stringify(selection.requested) || JSON.stringify([...r.defenders.resolved].sort()) !== JSON.stringify([...defenders].sort())) {
|
|
202
|
+
doc.stale.push(`defender selection for ${key} changed since it was probed`);
|
|
203
|
+
}
|
|
175
204
|
for (const d of defenders) {
|
|
176
205
|
const h = r.inputs.defenderHashes[d];
|
|
177
206
|
if (!h) doc.stale.push(`${d} now defends ${key} but was not probed`);
|
|
@@ -224,7 +253,8 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
|
|
|
224
253
|
if (top) {
|
|
225
254
|
doc.state = 'unproven';
|
|
226
255
|
const cur = faultIndex.get(`${top.claimId}/${top.subjectId}`);
|
|
227
|
-
const
|
|
256
|
+
const { requested } = defenderSelection(cur.claim, cur.fault);
|
|
257
|
+
const defenders = requested.length ? resolveDefenders(projectDir, requested) : discoverDefenders(projectDir, cur.fault.file);
|
|
228
258
|
const where = defenders[0] ?? `a new test file that imports ${cur.fault.file}`;
|
|
229
259
|
const whyByVerdict = {
|
|
230
260
|
survived: `${top.claimId}/${top.subjectId} (${top.severity}) survived: "${cur.claim.statement}" can be false with the suite green.`,
|
|
@@ -257,7 +287,7 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
|
|
|
257
287
|
doc.next = {
|
|
258
288
|
action: 'claim',
|
|
259
289
|
command: `testguard scaffold ${candidate.file}`,
|
|
260
|
-
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}`,
|
|
261
291
|
file: candidate.file,
|
|
262
292
|
};
|
|
263
293
|
}
|
|
@@ -271,6 +301,8 @@ export function renderStatus(doc) {
|
|
|
271
301
|
}
|
|
272
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` : '')
|
|
273
303
|
);
|
|
304
|
+
if (doc.origins) lines.push(renderOrigins(doc.origins));
|
|
305
|
+
if (doc.originPolicy) lines.push(renderOriginPolicy(doc.originPolicy, { basis: 'offline' }));
|
|
274
306
|
if (doc.surface) {
|
|
275
307
|
const s = doc.surface;
|
|
276
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,
|