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.
Files changed (64) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +196 -17
  3. package/package.json +1 -1
  4. package/spec/GATE-SEMANTICS.md +316 -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 +129 -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 +8 -8
  13. package/spec/schemas/common.schema.json +73 -0
  14. package/spec/schemas/evidence.schema.json +41 -8
  15. package/spec/schemas/status.schema.json +31 -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 +21 -3
  21. package/src/commands/annotations.mjs +67 -0
  22. package/src/commands/claims.mjs +46 -6
  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/gate/changed.mjs +5 -1
  29. package/src/git.mjs +4 -4
  30. package/src/init/init.mjs +6 -0
  31. package/src/init/templates/SKILL.md +78 -0
  32. package/src/probe/attribution.mjs +19 -0
  33. package/src/probe/contention.mjs +20 -2
  34. package/src/probe/cost.mjs +22 -9
  35. package/src/probe/inject.mjs +2 -6
  36. package/src/probe/origin-policy.mjs +69 -0
  37. package/src/probe/policy-freshness.mjs +74 -0
  38. package/src/probe/probe.mjs +74 -45
  39. package/src/probe/runners/discovery.mjs +2 -0
  40. package/src/probe/runners/jest.mjs +2 -2
  41. package/src/probe/runners/lifecycle.mjs +58 -0
  42. package/src/probe/runners/playwright.mjs +6 -2
  43. package/src/probe/runners/python.mjs +10 -4
  44. package/src/probe/runners/shared.mjs +12 -6
  45. package/src/probe/runners/vitest.mjs +3 -3
  46. package/src/probe/universe.mjs +47 -0
  47. package/src/probe/worktree.mjs +3 -1
  48. package/src/render.mjs +17 -1
  49. package/src/replay/replay.mjs +8 -4
  50. package/src/scaffold/admission.mjs +117 -0
  51. package/src/scaffold/append.mjs +41 -0
  52. package/src/scaffold/apply-append.mjs +71 -0
  53. package/src/scaffold/fix-input.mjs +119 -0
  54. package/src/scaffold/fix-paths.mjs +90 -0
  55. package/src/scaffold/intent.mjs +2 -0
  56. package/src/scaffold/open-append.mjs +44 -0
  57. package/src/scaffold/plan-append.mjs +37 -0
  58. package/src/scaffold/plan-intent-input.mjs +38 -0
  59. package/src/scaffold/prepare-append.mjs +46 -0
  60. package/src/scaffold/request.mjs +30 -0
  61. package/src/scaffold/scaffold.mjs +23 -10
  62. package/src/status/status.mjs +42 -10
  63. package/src/sweep/pool.mjs +33 -0
  64. package/src/sweep/sweep.mjs +9 -4
@@ -1,3 +1,4 @@
1
+ import { resetInterpreterCache } from '../probe/runners/python.mjs';
1
2
  import { existsSync, rmSync, realpathSync } from 'node:fs';
2
3
  import { basename, dirname, join, relative, resolve } from 'node:path';
3
4
  import { spawnSync } from 'node:child_process';
@@ -84,6 +85,8 @@ export async function replay({
84
85
  confirmRuns = 3,
85
86
  budgetMs = 120_000,
86
87
  commandBudget,
88
+ workers = 1,
89
+ serial = workers === 1,
87
90
  runnerCommand,
88
91
  runnerName = 'auto',
89
92
  nodeModules,
@@ -93,6 +96,7 @@ export async function replay({
93
96
  onProgress = () => {},
94
97
  onStage = () => {},
95
98
  }) {
99
+ resetInterpreterCache();
96
100
  commandBudget?.assertOpen();
97
101
  projectDir = realpathSync(resolve(projectDir));
98
102
  const root = repoRoot(projectDir);
@@ -142,7 +146,7 @@ export async function replay({
142
146
  }
143
147
  universe = buildReplayRunnerUniverse(runnerUsed, manifests);
144
148
  }
145
- const record = await replayOne({ fix, iso, root, projectDir, confirmRuns, budgetMs, commandBudget, commandTemplate, runner: runnerUsed, universe, onStage, index: i + 1, total: selected.length });
149
+ const record = await replayOne({ fix, iso, root, projectDir, confirmRuns, budgetMs, commandBudget, workers, serial, commandTemplate, runner: runnerUsed, universe, onStage, index: i + 1, total: selected.length });
146
150
  records.push(record);
147
151
  onProgress(record);
148
152
  } finally {
@@ -209,7 +213,7 @@ export function partitionReplayDefenders(files, primary, universe) {
209
213
  return groups;
210
214
  }
211
215
 
212
- async function replayOne({ fix, iso, root, projectDir, confirmRuns, budgetMs, commandBudget, commandTemplate, runner, universe, onStage, index, total }) {
216
+ async function replayOne({ fix, iso, root, projectDir, confirmRuns, budgetMs, commandBudget, workers, serial, commandTemplate, runner, universe, onStage, index, total }) {
213
217
  // git reports paths from the REPOSITORY root; the runner and every file
214
218
  // operation here work inside the scratch worktree, relative to the PROJECT
215
219
  // directory. Map once, and refuse anything that would land outside the
@@ -325,12 +329,12 @@ async function replayOne({ fix, iso, root, projectDir, confirmRuns, budgetMs, co
325
329
  // A custom command is one explicit execution boundary. Preserve that
326
330
  // contract exactly; TestGuard cannot infer several native engines from
327
331
  // an opaque user command.
328
- res = await runner.run({ projectDir: iso.projectDir, sourceDir: projectDir, files: related, budgetMs: commandBudget?.runBudget(budgetMs) ?? budgetMs, commandTemplate });
332
+ res = await runner.run({ projectDir: iso.projectDir, sourceDir: projectDir, files: related, budgetMs: commandBudget?.runBudget(budgetMs) ?? budgetMs, commandTemplate, workers, serial });
329
333
  } else {
330
334
  const parts = [];
331
335
  for (const [configuredRunner, files] of partitionReplayDefenders(related, runner, universe)) {
332
336
  commandBudget?.assertOpen();
333
- parts.push(await configuredRunner.run({ projectDir: iso.projectDir, sourceDir: projectDir, files, budgetMs: commandBudget?.runBudget(budgetMs) ?? budgetMs }));
337
+ parts.push(await configuredRunner.run({ projectDir: iso.projectDir, sourceDir: projectDir, files, budgetMs: commandBudget?.runBudget(budgetMs) ?? budgetMs, workers, serial }));
334
338
  commandBudget?.assertOpen();
335
339
  }
336
340
  res = mergeRuns(parts);
@@ -0,0 +1,117 @@
1
+ import { constants, closeSync, fstatSync, lstatSync, openSync, readSync, realpathSync } from 'node:fs';
2
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
+ import { readSpecDoc } from '../evidence/writer.mjs';
4
+ import { sha256 } from '../util/hash.mjs';
5
+
6
+ const MAX_FILE_BYTES = 2 * 1024 * 1024;
7
+ const MAX_SOURCE_BYTES = 16 * 1024 * 1024;
8
+ const MAX_FILES = 32;
9
+ const SUPPORTED = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx', '.py']);
10
+ const PROTECTED = new Set(['evidence.json', 'evidence-partial.json', 'evidence-provisional.json', 'baseline.json', 'sweep-evidence.json']);
11
+
12
+ function refuse(reason) { throw new Error(`draft append admission refused: ${reason}`); }
13
+ function identity(stat) { return { dev: stat.dev, ino: stat.ino, size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, mode: stat.mode }; }
14
+ function same(stat, expected) { return Object.entries(expected).every(([key, value]) => stat[key] === value); }
15
+
16
+ function selectedPath(requestedRoot, file) {
17
+ if (typeof file !== 'string' || !file || file.includes('\0') || file.includes('\\') || file.split('/').some((part) => part === '.' || part === '..')) refuse('unsafe-path');
18
+ const rel = relative(requestedRoot, resolve(requestedRoot, file));
19
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) refuse('outside-project');
20
+ return rel.split(sep).join('/');
21
+ }
22
+
23
+ /** Repeat the complete in-checkout path walk; never authorize by existence. */
24
+ function inspect(root, file) {
25
+ const base = lstatSync(root);
26
+ if (base.isSymbolicLink() || !base.isDirectory()) refuse('unsafe-root');
27
+ const parts = file.split('/');
28
+ let path = root;
29
+ for (let i = 0; i < parts.length; i++) {
30
+ path = join(path, parts[i]);
31
+ const stat = lstatSync(path);
32
+ if (stat.isSymbolicLink()) refuse('symlink');
33
+ if (i < parts.length - 1) {
34
+ if (!stat.isDirectory()) refuse('not-directory');
35
+ try { lstatSync(join(path, 'testguard.claims.json')); refuse('nested-project'); }
36
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
37
+ } else {
38
+ if (!stat.isFile() || stat.nlink !== 1) refuse('not-single-regular-file');
39
+ if (stat.size > MAX_FILE_BYTES) refuse('file-byte-limit');
40
+ return { path, stat };
41
+ }
42
+ }
43
+ }
44
+
45
+ function readInput(root, file, admitted) {
46
+ if (constants.O_NOFOLLOW === undefined) refuse('nofollow-unavailable');
47
+ const fd = openSync(admitted.path, constants.O_RDONLY | constants.O_NOFOLLOW);
48
+ try {
49
+ const expected = identity(admitted.stat);
50
+ const before = fstatSync(fd);
51
+ if (!before.isFile() || before.nlink !== 1 || !same(before, expected)) refuse('input-changed');
52
+ const buffer = Buffer.alloc(MAX_FILE_BYTES + 1);
53
+ let length = 0;
54
+ while (length < buffer.length) {
55
+ const count = readSync(fd, buffer, length, buffer.length - length, length);
56
+ if (!count) break;
57
+ length += count;
58
+ }
59
+ if (length > MAX_FILE_BYTES) refuse('file-byte-limit');
60
+ if (length !== expected.size || !same(fstatSync(fd), expected)) refuse('input-changed');
61
+ const current = inspect(root, file);
62
+ if (!same(current.stat, expected)) refuse('input-changed');
63
+ const bytes = buffer.subarray(0, length);
64
+ let source;
65
+ try { source = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); }
66
+ catch { refuse('invalid-utf8'); }
67
+ if (source.includes('\0')) refuse('invalid-encoding');
68
+ return { file, path: admitted.path, source, hash: sha256(bytes), identity: expected };
69
+ } finally { closeSync(fd); }
70
+ }
71
+
72
+ /** Explicit text input only: no statements, claims, provenance promotion or writes. */
73
+ export function admitIntentDocument({ projectDir, file }) {
74
+ const requestedRoot = resolve(projectDir);
75
+ if (lstatSync(requestedRoot).isSymbolicLink()) refuse('unsafe-root');
76
+ const root = realpathSync(requestedRoot);
77
+ const name = selectedPath(requestedRoot, file);
78
+ if (name.split('/').some(part => ['.local', '.wolf', '.git', '.testguard', 'graphify-out'].includes(part))) refuse('excluded-document');
79
+ if (!['.md', '.txt', '.rst', '.adoc'].includes(extname(name))) refuse('unsupported-document');
80
+ const input = readInput(root, name, inspect(root, name));
81
+ Object.freeze(input.identity);
82
+ return Object.freeze(input);
83
+ }
84
+
85
+ /** Selected conforming marker only; neither claim contents nor scope authority are returned. */
86
+ export function admitIntentProject({ projectDir }) {
87
+ const requestedRoot = resolve(projectDir);
88
+ if (lstatSync(requestedRoot).isSymbolicLink()) refuse('unsafe-root');
89
+ const root = realpathSync(requestedRoot);
90
+ if (root.split(sep).some(part => ['.local', '.wolf', '.git', '.testguard', 'graphify-out'].includes(part))) refuse('excluded-project');
91
+ const file = 'testguard.claims.json';
92
+ const input = readInput(root, file, inspect(root, file));
93
+ readSpecDoc('claims', input.path, { source: input.source });
94
+ const marker = Object.freeze({ file, hash: input.hash, identity: Object.freeze(input.identity) });
95
+ return Object.freeze({ projectDir: root, marker });
96
+ }
97
+
98
+ /** Internal read-only input snapshot. This is not publication authority. */
99
+ export function admitDraftAppend({ projectDir, draftPath, files, claimId }) {
100
+ if (!Array.isArray(files) || !files.length || files.length > MAX_FILES) refuse('source-count-limit');
101
+ if (typeof claimId !== 'string' || !claimId.trim() || claimId !== claimId.trim() || claimId.includes(',')) refuse('claim-selection');
102
+ const requestedRoot = resolve(projectDir);
103
+ if (lstatSync(requestedRoot).isSymbolicLink()) refuse('unsafe-root');
104
+ const root = realpathSync(requestedRoot);
105
+ const draftFile = selectedPath(requestedRoot, draftPath);
106
+ if (basename(draftFile) === 'testguard.claims.json' || (draftFile.startsWith('.testguard/') && PROTECTED.has(basename(draftFile)))) refuse('protected-destination');
107
+ const names = files.map((file) => selectedPath(requestedRoot, file));
108
+ if (new Set(names).size !== names.length || names.includes(draftFile)) refuse('input-alias');
109
+ if (names.some((file) => !SUPPORTED.has(extname(file)))) refuse('unsupported-source');
110
+ const draftInput = readInput(root, draftFile, inspect(root, draftFile));
111
+ const doc = readSpecDoc('claims', draftInput.path, { source: draftInput.source });
112
+ if (doc.claims.filter((claim) => claim.id === claimId).length !== 1) refuse('unknown-claim');
113
+ const admitted = names.sort().map((file) => ({ file, ...inspect(root, file) }));
114
+ if (admitted.reduce((total, input) => total + input.stat.size, 0) > MAX_SOURCE_BYTES) refuse('source-byte-limit');
115
+ const sources = admitted.map((input) => readInput(root, input.file, input));
116
+ return { projectDir: root, claimId, draft: { ...draftInput, doc }, sources };
117
+ }
@@ -0,0 +1,41 @@
1
+ import { validate } from '../../spec/lib/validate.mjs';
2
+
3
+ const injection = (fault) => (fault.method ?? 'fault-injection') === 'fault-injection';
4
+ // A different explicit defender selection is a different measurement, even
5
+ // when the injected bytes match. Absence and explicit [] must stay distinct.
6
+ const anchorKey = (fault) => JSON.stringify([
7
+ fault.file, fault.faultClass, fault.find, fault.replace,
8
+ fault.expectHits ?? 1, fault.occurrence ?? 1,
9
+ Object.hasOwn(fault, 'defendedBy') ? [...fault.defendedBy].sort() : null,
10
+ ]);
11
+
12
+ function conforming(label, doc) {
13
+ const result = validate('claims', doc);
14
+ if (!result.ok) throw new Error(`non-conforming ${label}: ${result.errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`);
15
+ }
16
+
17
+ /** Pure draft merge only. The future CLI owns all file admission/publication. */
18
+ export function appendDraft({ draft, proposals, claimId }) {
19
+ conforming('draft', draft);
20
+ conforming('proposals', proposals);
21
+ if (typeof claimId !== 'string' || !claimId) throw new Error('append requires one existing claim ID');
22
+ const doc = structuredClone(draft);
23
+ const target = doc.claims.find((claim) => claim.id === claimId);
24
+ if (!target) throw new Error('append selection does not name an existing draft claim');
25
+ const candidates = proposals.claims.flatMap((claim) => claim.faults);
26
+ if (candidates.some((fault) => !injection(fault))) throw new Error('append proposals require fault-injection methods');
27
+ const seen = new Set(target.faults.filter(injection).map(anchorKey));
28
+ const used = new Set(target.faults.map((fault) => fault.id));
29
+ let next = 1;
30
+ for (const candidate of candidates) {
31
+ const key = anchorKey(candidate);
32
+ if (seen.has(key)) continue;
33
+ while (used.has(`S${next}`)) next++;
34
+ const id = `S${next++}`;
35
+ used.add(id);
36
+ target.faults.push({ ...structuredClone(candidate), id });
37
+ seen.add(key);
38
+ }
39
+ conforming('appended draft', doc);
40
+ return doc;
41
+ }
@@ -0,0 +1,71 @@
1
+ import { fstatSync, fsyncSync, ftruncateSync, readSync, writeSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { isDeepStrictEqual } from 'node:util';
4
+ import { persistRecovery } from '../claims/annotation-placement.mjs';
5
+ import { readSpecDoc, writeSpecDoc } from '../evidence/writer.mjs';
6
+ import { admitDraftAppend } from './admission.mjs';
7
+ import { openDraftAppend } from './open-append.mjs';
8
+
9
+ /** Explicit internal draft publication. Partial writes never auto-rollback. */
10
+ export function applyDraftAppend({ request, plan }) {
11
+ let handle, stage = 'preparation';
12
+ const report = { ok: false, state: 'refused', writeAttempted: false, bytesWritten: 0, failed: [] };
13
+ const fail = (error) => {
14
+ report.ok = false; report.state = report.writeAttempted ? 'write-unconfirmed' : 'refused';
15
+ report.failed.push({ stage, reason: error.message });
16
+ };
17
+ try {
18
+ handle = openDraftAppend({ request, plan }); report.recoveryDir = handle.recoveryDir;
19
+ if (handle.plan.changed) {
20
+ stage = 'spec-publication';
21
+ writeSpecDoc('claims', handle.plan.draft.path, handle.plan.doc, { publish: (output) => {
22
+ if (output !== handle.plan.output) throw new Error('draft append serialized output changed');
23
+ stage = 'started-journal';
24
+ persistRecovery(join(handle.recoveryDir, 'started.json'), Buffer.from(JSON.stringify({ draft: handle.plan.draft.file, state: 'started' })));
25
+ stage = 'write-preflight'; handle.assertCurrent();
26
+ const bytes = Buffer.from(output, 'utf8');
27
+ stage = 'draft-write'; report.writeAttempted = true;
28
+ while (report.bytesWritten < bytes.length) {
29
+ const offset = report.bytesWritten;
30
+ const count = writeSync(handle.fd, bytes, offset, bytes.length - offset, offset);
31
+ if (!Number.isInteger(count) || count <= 0 || count > bytes.length - offset) throw new Error('draft write made no valid progress');
32
+ report.bytesWritten += count;
33
+ }
34
+ stage = 'truncate'; ftruncateSync(handle.fd, bytes.length);
35
+ stage = 'draft-fsync'; fsyncSync(handle.fd);
36
+ stage = 'verification'; handle.assertOwned();
37
+ const stat = fstatSync(handle.fd), identity = handle.plan.draft.identity;
38
+ if (!stat.isFile() || stat.nlink !== 1 || stat.dev !== identity.dev || stat.ino !== identity.ino || stat.mode !== identity.mode || stat.size !== bytes.length) throw new Error('draft descriptor verification failed');
39
+ const buffer = Buffer.alloc(bytes.length + 1); let length = 0;
40
+ while (length < buffer.length) {
41
+ const count = readSync(handle.fd, buffer, length, buffer.length - length, length);
42
+ if (!count) break;
43
+ length += count;
44
+ }
45
+ if (length !== bytes.length || !buffer.subarray(0, length).equals(bytes)) throw new Error('draft byte verification failed');
46
+ readSpecDoc('claims', handle.plan.draft.path, { source: buffer.subarray(0, length).toString('utf8') });
47
+ const current = admitDraftAppend(request);
48
+ if (current.projectDir !== handle.plan.projectDir || current.draft.source !== output || current.draft.identity.dev !== identity.dev || current.draft.identity.ino !== identity.ino || current.draft.identity.mode !== identity.mode || !isDeepStrictEqual(current.sources, handle.plan.sources)) throw new Error('draft or sources changed after write');
49
+ handle.assertOwned();
50
+ } });
51
+ report.state = 'updated';
52
+ } else { handle.assertCurrent(); report.state = 'unchanged'; }
53
+ report.ok = true;
54
+ } catch (error) {
55
+ if (error.recoveryDir) report.recoveryDir = error.recoveryDir;
56
+ if (error.lockRelease) report.release = error.lockRelease;
57
+ fail(error);
58
+ } finally {
59
+ if (handle) {
60
+ report.release = handle.release();
61
+ if (!report.release.released) { stage = 'release'; fail(new Error(report.release.reason || 'resource release failed')); }
62
+ }
63
+ }
64
+ if (report.recoveryDir) {
65
+ stage = 'result-journal';
66
+ try { persistRecovery(join(report.recoveryDir, 'result.json'), Buffer.from(JSON.stringify(report))); }
67
+ catch (error) { fail(error); }
68
+ }
69
+ report.failed.forEach(Object.freeze); Object.freeze(report.failed);
70
+ return Object.freeze(report);
71
+ }
@@ -0,0 +1,119 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { lstatSync, realpathSync } from 'node:fs';
3
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
4
+ import { devNull } from 'node:os';
5
+ import { createHash } from 'node:crypto';
6
+ import { performance } from 'node:perf_hooks';
7
+ import { parseFixChangedPaths, parseFixProjectMarkers, validateFixProjectPrefix } from './fix-paths.mjs';
8
+ import { admitIntentProject } from './admission.mjs';
9
+
10
+ const OBJECT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
11
+ const MAX_BYTES = 128 * 1024;
12
+ function refuse(reason) { throw new Error(`fix input refused: ${reason}`); }
13
+
14
+ function localFixContext({ projectDir, commit, budgetMs = 5000 }) {
15
+ if (typeof commit !== 'string' || !OBJECT_ID.test(commit)) refuse('full-object-id-required');
16
+ if (!Number.isSafeInteger(budgetMs) || budgetMs < 1 || budgetMs > 5000) refuse('invalid-budget');
17
+ const started = performance.now();
18
+ const requestedRoot = resolve(projectDir);
19
+ if (lstatSync(requestedRoot).isSymbolicLink()) refuse('unsafe-root');
20
+ const root = realpathSync(requestedRoot);
21
+ const identity = lstatSync(root);
22
+ if (!identity.isDirectory()) refuse('unsafe-root');
23
+ const checkRoot = () => {
24
+ const current = lstatSync(requestedRoot);
25
+ if (current.isSymbolicLink() || !current.isDirectory() || current.dev !== identity.dev || current.ino !== identity.ino || realpathSync(requestedRoot) !== root) refuse('root-changed');
26
+ };
27
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith('GIT_')));
28
+ Object.assign(env, { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: devNull, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', GIT_PAGER: 'cat', LC_ALL: 'C' });
29
+ let captured = 0;
30
+ const read = args => {
31
+ checkRoot();
32
+ const remaining = Math.floor(budgetMs - (performance.now() - started));
33
+ if (remaining < 1) refuse('deadline');
34
+ const run = spawnSync('git', ['--no-lazy-fetch', '--no-replace-objects', '-c', 'core.fsmonitor=false', '-c', `core.hooksPath=${devNull}`, ...args], {
35
+ cwd: root, env, shell: false, timeout: remaining, maxBuffer: MAX_BYTES,
36
+ });
37
+ if (run.error || run.signal || run.status !== 0 || performance.now() - started >= budgetMs) refuse('object-read-unavailable');
38
+ if (!Buffer.isBuffer(run.stdout) || run.stdout.length > MAX_BYTES) refuse('object-byte-limit');
39
+ captured += run.stdout.length;
40
+ if (captured > 512 * 1024) refuse('aggregate-byte-limit');
41
+ checkRoot();
42
+ return run.stdout;
43
+ };
44
+ const finish = () => {
45
+ checkRoot();
46
+ if (budgetMs <= performance.now() - started) refuse('deadline');
47
+ };
48
+ return { root, read, finish };
49
+ }
50
+
51
+ function commitMetadata(context, commit) {
52
+ const bytes = context.read(['cat-file', 'commit', commit]);
53
+ const hash = createHash(commit.length === 40 ? 'sha1' : 'sha256').update(`commit ${bytes.length}\0`).update(bytes).digest('hex');
54
+ if (hash !== commit) refuse('object-identity-mismatch');
55
+ let text;
56
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
57
+ catch { refuse('invalid-utf8'); }
58
+ if (text.includes('\0')) refuse('invalid-encoding');
59
+ const separator = text.indexOf('\n\n');
60
+ if (separator < 0) refuse('malformed-commit');
61
+ const parents = text.slice(0, separator).split('\n').filter(line => line.startsWith('parent ')).map(line => line.slice(7));
62
+ if (parents.length !== 1 || !OBJECT_ID.test(parents[0]) || parents[0].length !== commit.length) refuse('single-parent-required');
63
+ const subject = text.slice(separator + 2).split('\n', 1)[0];
64
+ if (Buffer.byteLength(subject) > 1024 || /[\x00-\x1f\x7f-\x9f]/.test(subject)) refuse('unsafe-subject');
65
+ return Object.freeze({ commit, parent: parents[0], subject });
66
+ }
67
+
68
+ /** Local immutable metadata, not a bug claim or an authenticated intent source. */
69
+ export function readFixCommitMetadata(options) {
70
+ const context = localFixContext(options);
71
+ const metadata = commitMetadata(context, options.commit);
72
+ context.finish();
73
+ return metadata;
74
+ }
75
+
76
+ /** Complete bounded historical diff for an exact worktree root, not current source authority. */
77
+ export function readFixCommitInventory(options) { return fixInventory(options, false); }
78
+
79
+ /** Explicit selected nested project; historical inventory is not current source authority. */
80
+ export function readNestedFixCommitInventory(options) { return fixInventory(options, true); }
81
+
82
+ /** Observe root/nested selection once, under one unchanged offline Git context. */
83
+ export function readScopedFixCommitInventory(options) { return fixInventory(options, 'selected'); }
84
+
85
+ function fixInventory(options, mode) {
86
+ const context = localFixContext(options);
87
+ let toplevel;
88
+ try { toplevel = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }).decode(context.read(['rev-parse', '--show-toplevel'])); }
89
+ catch (error) { if (error instanceof TypeError) refuse('invalid-root-encoding'); throw error; }
90
+ const scoped = mode === 'selected';
91
+ const nested = scoped ? toplevel.endsWith('\n') && toplevel.slice(0, -1) !== context.root : mode;
92
+ let projectPrefix = '', selected;
93
+ if (!nested) {
94
+ if (!toplevel.endsWith('\n') || toplevel.slice(0, -1) !== context.root) refuse('project-root-required');
95
+ } else {
96
+ if (!toplevel.endsWith('\n')) refuse('project-root-required');
97
+ const repositoryRoot = realpathSync(toplevel.slice(0, -1));
98
+ const prefix = relative(repositoryRoot, context.root);
99
+ if (!prefix) refuse('nested-project-required');
100
+ if (isAbsolute(prefix) || prefix === '..' || prefix.startsWith(`..${sep}`)) refuse('outside-project');
101
+ projectPrefix = validateFixProjectPrefix(prefix.split(sep).join('/'));
102
+ selected = admitIntentProject({ projectDir: context.root });
103
+ }
104
+ const metadata = commitMetadata(context, options.commit);
105
+ const tree = id => context.read(['ls-tree', '-r', '--name-only', '-z', '--full-tree', id]);
106
+ const projects = parseFixProjectMarkers(tree(metadata.parent), tree(metadata.commit), { projectPrefix });
107
+ const args = ['diff-tree', '--raw', '--no-abbrev', '-z', '--no-renames', '--no-ext-diff', '--no-textconv', '-r', '--no-commit-id', metadata.parent, metadata.commit, '--'];
108
+ if (projectPrefix) args.push(`:(top,literal)${projectPrefix}/`);
109
+ const raw = context.read(args);
110
+ const inventory = parseFixChangedPaths(raw, { nestedProjects: projects, objectIdWidth: metadata.commit.length, projectPrefix });
111
+ if (selected) {
112
+ const current = admitIntentProject({ projectDir: context.root });
113
+ if (current.projectDir !== selected.projectDir || current.marker.hash !== selected.marker.hash ||
114
+ Object.entries(selected.marker.identity).some(([key, value]) => current.marker.identity[key] !== value)) refuse('project-marker-changed');
115
+ }
116
+ context.finish();
117
+ if (scoped) return Object.freeze({ scope: projectPrefix ? 'selected-nested-project' : 'project-root', ...metadata, ...inventory });
118
+ return Object.freeze({ ...metadata, ...inventory });
119
+ }
@@ -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
+ }