testguard-cli 0.16.0 → 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.
Files changed (62) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +152 -13
  3. package/package.json +1 -1
  4. package/spec/GATE-SEMANTICS.md +295 -0
  5. package/spec/README.md +27 -2
  6. package/spec/lib/authoring-input.mjs +40 -0
  7. package/spec/lib/origins.mjs +56 -0
  8. package/spec/lib/validate.mjs +120 -1
  9. package/spec/schemas/annotations.schema.json +65 -0
  10. package/spec/schemas/authoring-input.schema.json +67 -0
  11. package/spec/schemas/brief.schema.json +2 -0
  12. package/spec/schemas/claims.schema.json +2 -8
  13. package/spec/schemas/common.schema.json +73 -0
  14. package/spec/schemas/evidence.schema.json +37 -8
  15. package/spec/schemas/status.schema.json +6 -2
  16. package/src/brief/brief.mjs +11 -4
  17. package/src/claims/annotation-placement.mjs +357 -0
  18. package/src/claims/annotations.mjs +61 -2
  19. package/src/cli.mjs +40 -5
  20. package/src/commands/admit.mjs +13 -0
  21. package/src/commands/annotations.mjs +67 -0
  22. package/src/commands/claims.mjs +8 -4
  23. package/src/commands/probe.mjs +50 -7
  24. package/src/commands/replay.mjs +7 -0
  25. package/src/commands/scaffold.mjs +49 -2
  26. package/src/commands/sweep.mjs +7 -0
  27. package/src/evidence/writer.mjs +12 -4
  28. package/src/git.mjs +4 -4
  29. package/src/init/templates/SKILL.md +35 -0
  30. package/src/probe/attribution.mjs +10 -0
  31. package/src/probe/contention.mjs +20 -2
  32. package/src/probe/cost.mjs +6 -1
  33. package/src/probe/inject.mjs +2 -6
  34. package/src/probe/origin-policy.mjs +69 -0
  35. package/src/probe/policy-freshness.mjs +74 -0
  36. package/src/probe/probe.mjs +48 -23
  37. package/src/probe/runners/discovery.mjs +2 -0
  38. package/src/probe/runners/jest.mjs +2 -2
  39. package/src/probe/runners/lifecycle.mjs +58 -0
  40. package/src/probe/runners/playwright.mjs +6 -2
  41. package/src/probe/runners/python.mjs +10 -4
  42. package/src/probe/runners/shared.mjs +12 -6
  43. package/src/probe/runners/vitest.mjs +3 -3
  44. package/src/probe/universe.mjs +47 -0
  45. package/src/probe/worktree.mjs +3 -1
  46. package/src/render.mjs +17 -1
  47. package/src/replay/replay.mjs +8 -4
  48. package/src/scaffold/admission.mjs +117 -0
  49. package/src/scaffold/append.mjs +41 -0
  50. package/src/scaffold/apply-append.mjs +71 -0
  51. package/src/scaffold/fix-input.mjs +119 -0
  52. package/src/scaffold/fix-paths.mjs +90 -0
  53. package/src/scaffold/intent.mjs +2 -0
  54. package/src/scaffold/open-append.mjs +44 -0
  55. package/src/scaffold/plan-append.mjs +37 -0
  56. package/src/scaffold/plan-intent-input.mjs +38 -0
  57. package/src/scaffold/prepare-append.mjs +46 -0
  58. package/src/scaffold/request.mjs +30 -0
  59. package/src/scaffold/scaffold.mjs +23 -10
  60. package/src/status/status.mjs +34 -9
  61. package/src/sweep/pool.mjs +33 -0
  62. package/src/sweep/sweep.mjs +9 -4
@@ -0,0 +1,90 @@
1
+ import { extname } from 'node:path';
2
+
3
+ const PRIVATE = new Set(['.local', '.wolf', '.git', '.testguard', 'graphify-out']);
4
+ const SUPPORTED = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx', '.py']);
5
+ function refuse(reason) { throw new Error(`fix inventory refused: ${reason}`); }
6
+ function safePath(file) {
7
+ if (!file || file.includes('\\') || /[\x00-\x1f\x7f-\x9f]/.test(file) || file.split('/').some(part => !part || part === '.' || part === '..') || /^[A-Za-z]:/.test(file)) refuse('unsafe-path');
8
+ if (Buffer.byteLength(file) > 4096) refuse('path-byte-limit');
9
+ return file;
10
+ }
11
+
12
+ export function validateFixProjectPrefix(prefix) {
13
+ if (typeof prefix !== 'string') refuse('unsafe-project-prefix');
14
+ if (!prefix) return prefix;
15
+ safePath(prefix);
16
+ if (prefix.split('/').some(part => PRIVATE.has(part))) refuse('excluded-project');
17
+ return prefix;
18
+ }
19
+
20
+ /** Complete historical marker union only; current filesystem admission remains separate. */
21
+ export function parseFixProjectMarkers(parentRaw, commitRaw, { projectPrefix = '' } = {}) {
22
+ validateFixProjectPrefix(projectPrefix);
23
+ const projects = new Set();
24
+ for (const raw of [parentRaw, commitRaw]) {
25
+ if (!Buffer.isBuffer(raw)) refuse('buffer-required');
26
+ if (raw.length > 128 * 1024) refuse('output-byte-limit');
27
+ let listing;
28
+ try { listing = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }).decode(raw); }
29
+ catch { refuse('invalid-utf8'); }
30
+ if (listing && !listing.endsWith('\0')) refuse('truncated-output');
31
+ const files = listing ? listing.slice(0, -1).split('\0') : [];
32
+ if (files.length > 4096) refuse('tree-name-count-limit');
33
+ const namesSeen = new Set();
34
+ for (const name of files) {
35
+ const file = safePath(name);
36
+ if (namesSeen.has(file)) refuse('duplicate-path');
37
+ namesSeen.add(file);
38
+ if (!file.endsWith('/testguard.claims.json')) continue;
39
+ const project = file.slice(0, -'/testguard.claims.json'.length);
40
+ projects.add(project);
41
+ if (projects.size > 256) refuse('project-count-limit');
42
+ }
43
+ }
44
+ if (projectPrefix) {
45
+ const descendants = [...projects].filter(project => project.startsWith(`${projectPrefix}/`));
46
+ projects.clear();
47
+ for (const project of descendants) projects.add(project.slice(projectPrefix.length + 1));
48
+ }
49
+ return Object.freeze([...projects].filter(project => !project.split('/').some(part => PRIVATE.has(part))).sort());
50
+ }
51
+
52
+ /** Decode complete raw Git data only; supplied exclusions are not discovery authority. */
53
+ export function parseFixChangedPaths(raw, { nestedProjects = [], objectIdWidth, projectPrefix = '' } = {}) {
54
+ validateFixProjectPrefix(projectPrefix);
55
+ if (!Buffer.isBuffer(raw)) refuse('buffer-required');
56
+ if (objectIdWidth !== undefined && objectIdWidth !== 40 && objectIdWidth !== 64) refuse('invalid-object-width');
57
+ if (raw.length > 128 * 1024) refuse('output-byte-limit');
58
+ if (!Array.isArray(nestedProjects) || nestedProjects.length > 256) refuse('project-count-limit');
59
+ const projects = nestedProjects.map(safePath);
60
+ let text;
61
+ try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(raw); }
62
+ catch { refuse('invalid-utf8'); }
63
+ if (text && !text.endsWith('\0')) refuse('truncated-output');
64
+ const parts = text ? text.slice(0, -1).split('\0') : [];
65
+ if (parts.length % 2) refuse('malformed-record');
66
+ if (parts.length / 2 > 256) refuse('path-count-limit');
67
+ const paths = [], seen = new Set();
68
+ const counts = { total: 0, supported: 0, deleted: 0, unsupported: 0, excluded: 0 };
69
+ for (let i = 0; i < parts.length; i += 2) {
70
+ const match = /^:([0-7]{6}) ([0-7]{6}) ([a-f0-9]{40}|[a-f0-9]{64}) ([a-f0-9]{40}|[a-f0-9]{64}) ([AMDT])$/.exec(parts[i]);
71
+ if (!match || match[3].length !== match[4].length) refuse('malformed-record');
72
+ const [, oldMode, newMode, oldId, newId, status] = match;
73
+ if (objectIdWidth !== undefined && oldId.length !== objectIdWidth) refuse('object-width-mismatch');
74
+ const oldZero = /^0+$/.test(oldId), newZero = /^0+$/.test(newId);
75
+ if ((oldMode === '000000') !== oldZero || (newMode === '000000') !== newZero ||
76
+ (status === 'A' ? !oldZero || newZero : status === 'D' ? oldZero || !newZero : oldZero || newZero)) refuse('inconsistent-identity');
77
+ const repositoryFile = safePath(parts[i + 1]);
78
+ if (projectPrefix && !repositoryFile.startsWith(`${projectPrefix}/`)) refuse('out-of-scope-path');
79
+ const file = projectPrefix ? repositoryFile.slice(projectPrefix.length + 1) : repositoryFile;
80
+ if (seen.has(file)) refuse('duplicate-path');
81
+ seen.add(file); counts.total++;
82
+ if (file.split('/').some(part => PRIVATE.has(part)) || projects.some(project => file === project || file.startsWith(`${project}/`))) {
83
+ counts.excluded++; continue;
84
+ }
85
+ const disposition = status === 'D' ? 'deleted' : !['100644', '100755'].includes(newMode) || !SUPPORTED.has(extname(file)) ? 'unsupported' : 'supported';
86
+ counts[disposition]++;
87
+ paths.push(Object.freeze({ file, status, oldMode, newMode, oldId, newId, disposition }));
88
+ }
89
+ return Object.freeze({ paths: Object.freeze(paths), counts: Object.freeze(counts) });
90
+ }
@@ -0,0 +1,2 @@
1
+ // Authoring advice, not an authenticated origin or a verification result.
2
+ export const intentHandoff = 'Supply intended observable behavior from a requirement, ADR, bug or incident independently of the implementation: name inputs, expected outcome and forbidden outcome. Record the supplied source kind/ref; when unavailable, keep inferred. Source references and annotations are not authenticated intent. Review each proposal against that intent before probing.';
@@ -0,0 +1,44 @@
1
+ import { constants, closeSync, fstatSync, openSync } from 'node:fs';
2
+ import { isDeepStrictEqual } from 'node:util';
3
+ import { planDraftAppend } from './plan-append.mjs';
4
+ import { prepareDraftAppend } from './prepare-append.mjs';
5
+
6
+ /** Acquire a checked descriptor; no draft writes and no implicit truncation. */
7
+ export function openDraftAppend({ request, plan }) {
8
+ const prepared = prepareDraftAppend({ request, plan });
9
+ let fd, closed = false, releaseResult;
10
+ function release() {
11
+ if (closed) return releaseResult;
12
+ closed = true;
13
+ let closeError;
14
+ try { if (fd !== undefined) closeSync(fd); } catch (error) { closeError = error; }
15
+ let lockRelease;
16
+ try { lockRelease = prepared.release(); }
17
+ catch (error) { lockRelease = { released: false, reason: 'lock-release-failed', error: error.message }; }
18
+ releaseResult = closeError ? { released: false, reason: 'descriptor-close-failed', error: closeError.message, lockRelease } : lockRelease;
19
+ return releaseResult;
20
+ }
21
+ function assertCurrent() {
22
+ if (closed) throw new Error('draft append descriptor closed');
23
+ prepared.assertOwned();
24
+ if (!isDeepStrictEqual(planDraftAppend(request), prepared.plan)) throw new Error('draft append inputs changed before write');
25
+ if (fd !== undefined) {
26
+ const stat = fstatSync(fd);
27
+ if (!stat.isFile() || stat.nlink !== 1 || Object.entries(prepared.plan.draft.identity).some(([key, value]) => stat[key] !== value)) throw new Error('draft append descriptor changed');
28
+ }
29
+ prepared.assertOwned();
30
+ }
31
+ try {
32
+ assertCurrent();
33
+ if (prepared.plan.changed) {
34
+ if (constants.O_NOFOLLOW === undefined) throw new Error('draft append nofollow unavailable');
35
+ fd = openSync(prepared.plan.draft.path, constants.O_RDWR | constants.O_NOFOLLOW);
36
+ assertCurrent();
37
+ }
38
+ return Object.freeze({ ...prepared, fd, assertCurrent, release });
39
+ } catch (error) {
40
+ if (prepared.recoveryDir) error.recoveryDir = prepared.recoveryDir;
41
+ error.lockRelease = release();
42
+ throw error;
43
+ }
44
+ }
@@ -0,0 +1,37 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { admitDraftAppend } from './admission.mjs';
3
+ import { appendDraft } from './append.mjs';
4
+ import { scaffoldFile } from './scaffold.mjs';
5
+
6
+ const MAX_PROPOSALS = 4096;
7
+ const MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
8
+
9
+ function boundedLimit(value, ceiling, name) {
10
+ if (!Number.isSafeInteger(value) || value < 0 || value > ceiling) throw new Error(`${name} must be a non-negative safe integer no greater than ${ceiling}`);
11
+ return value;
12
+ }
13
+
14
+ function outputOf(doc, maxOutputBytes) {
15
+ const output = JSON.stringify(doc, null, 2) + '\n';
16
+ if (Buffer.byteLength(output, 'utf8') > maxOutputBytes) throw new Error('draft append output byte limit exceeded');
17
+ return output;
18
+ }
19
+
20
+ /** Read-only generation. Lower internal limits may tighten, never raise caps. */
21
+ export function planDraftAppend({ maxProposals = MAX_PROPOSALS, maxOutputBytes = MAX_OUTPUT_BYTES, toolVersion = '0.0.0', ...request }) {
22
+ boundedLimit(maxProposals, MAX_PROPOSALS, 'maxProposals');
23
+ boundedLimit(maxOutputBytes, MAX_OUTPUT_BYTES, 'maxOutputBytes');
24
+ const inputs = admitDraftAppend(request);
25
+ let doc = structuredClone(inputs.draft.doc);
26
+ let output = outputOf(doc, maxOutputBytes);
27
+ let generated = 0;
28
+ const initialFaults = doc.claims.find((claim) => claim.id === inputs.claimId).faults.length;
29
+ for (const input of inputs.sources) {
30
+ const proposed = scaffoldFile({ projectDir: inputs.projectDir, file: input.file, source: input.source, claimId: inputs.claimId, toolVersion, maxProposals: maxProposals - generated });
31
+ generated += proposed.stats.proposals;
32
+ doc = appendDraft({ draft: doc, proposals: proposed.doc, claimId: inputs.claimId });
33
+ output = outputOf(doc, maxOutputBytes);
34
+ }
35
+ const appended = doc.claims.find((claim) => claim.id === inputs.claimId).faults.length - initialFaults;
36
+ return { ...inputs, doc, output, changed: !isDeepStrictEqual(doc, inputs.draft.doc), stats: { targets: inputs.sources.length, generated, appended } };
37
+ }
@@ -0,0 +1,38 @@
1
+ import { performance } from 'node:perf_hooks';
2
+ import { intentInputRequest } from './request.mjs';
3
+ import { admitIntentDocument } from './admission.mjs';
4
+ import { readScopedFixCommitInventory } from './fix-input.mjs';
5
+ import { writeSpecDoc } from '../evidence/writer.mjs';
6
+
7
+ function freezeOwned(value) {
8
+ if (value && typeof value === 'object') {
9
+ for (const child of Object.values(value)) freezeOwned(child);
10
+ Object.freeze(value);
11
+ }
12
+ return value;
13
+ }
14
+
15
+ /** Validated read-only metadata, not a claim, a measurement or write authorization. */
16
+ export function planIntentInput({ projectDir, command = 'scaffold', values, suppliedOptions = [], files = [], toolVersion = '0.0.0', budgetMs = 5000 }) {
17
+ if (!Number.isSafeInteger(budgetMs) || budgetMs < 1 || budgetMs > 5000) throw new Error('intent input invalid-budget');
18
+ const started = performance.now();
19
+ const request = intentInputRequest({ command, values, suppliedOptions, files });
20
+ if (!request) throw new Error('intent input requires an explicit mode');
21
+ const remaining = Math.floor(budgetMs - (performance.now() - started));
22
+ if (remaining < 1) throw new Error('intent input deadline');
23
+ let input;
24
+ if (request.kind === 'document') {
25
+ const admitted = admitIntentDocument({ projectDir, file: request.file });
26
+ input = { kind: 'document', file: admitted.file, hash: admitted.hash, bytes: admitted.identity.size };
27
+ } else {
28
+ input = { kind: 'fix', ...readScopedFixCommitInventory({ projectDir, commit: request.commit, budgetMs: remaining }) };
29
+ }
30
+ const doc = { schemaVersion: 1, tool: { name: 'testguard', version: toolVersion }, purpose: 'authoring-input', verification: 'not-performed', next: 'supply-independent-intent', input };
31
+ let output;
32
+ writeSpecDoc('authoring-input', 'read-only input preview', doc, { publish: bytes => {
33
+ if (Buffer.byteLength(bytes) > 1024 * 1024) throw new Error('intent input output byte limit exceeded');
34
+ output = bytes;
35
+ } });
36
+ if (performance.now() - started >= budgetMs) throw new Error('intent input deadline');
37
+ return freezeOwned({ doc, output });
38
+ }
@@ -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 { basename, extname, join } from 'node:path';
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
- const source = readFileSync(join(projectDir, file), 'utf8');
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
- const proposals = scan({ source, lines, fieldDrops, keep: (fault) => usableProposal(source, fault), anchor: (find, offset) => anchorFor(source, find, offset) });
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 ?? `${stem}-${p.fn ? idPart(p.fn) : 'FILE'}`;
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: state what ${fnLabel} in ${file} guarantees (${g.proposals.length} proposed fault${g.proposals.length === 1 ? '' : 's'}; keep or drop each)`,
174
- source: base?.source ?? { kind: 'manual', ref: `testguard scaffold ${file}` },
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 } : {}),
@@ -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: 'No testguard.claims.json in this project. Scaffold proposes faults for a file; state what each guarantees, then probe.' },
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)}.`, file: candidate.file };
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: 'No claims have been declared; no verification was performed. State a claim before probing.' };
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
+ }
@@ -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 drafts = [];
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
- drafts.push(result.doc);
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,