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
@@ -1,4 +1,6 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
+ import { registerChild, assertNotCancelled } from './lifecycle.mjs';
3
+ import { readBoundedJsonFile } from './discovery.mjs';
2
4
  import { existsSync, readFileSync, rmSync } from 'node:fs';
3
5
  import { createRequire } from 'node:module';
4
6
  import { dirname } from 'node:path';
@@ -16,7 +18,7 @@ export const NODE_NOISE = /MODULE_TYPELESS_PACKAGE_JSON|ExperimentalWarning|--tr
16
18
 
17
19
  function descendantPids(rootPid) {
18
20
  if (process.platform === 'win32') return [];
19
- const result = spawnSync('ps', ['-axo', 'pid=,ppid='], { encoding: 'utf8' });
21
+ const result = spawnSync('ps', ['-axo', 'pid=,ppid='], { encoding: 'utf8', timeout: 1000, maxBuffer: 4 * 1024 * 1024 });
20
22
  if (result.status !== 0) return [];
21
23
  const children = new Map();
22
24
  for (const line of result.stdout.split('\n')) {
@@ -43,7 +45,7 @@ export const TIMEOUT_CLEANUP_WARNING = 'cleanup-unverified: TestGuard hard-kille
43
45
  export function terminateProcessTree(child) {
44
46
  if (!child.pid) return;
45
47
  if (process.platform === 'win32') {
46
- spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
48
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', timeout: 1000 });
47
49
  try { child.kill('SIGKILL'); } catch {}
48
50
  return;
49
51
  }
@@ -147,11 +149,12 @@ export function checkRunner({ projectDir, pkg, bin, budgetMs = 60_000, env = pro
147
149
  } catch (e) {
148
150
  return resolve({ ok: false, message: e.message });
149
151
  }
152
+ registerChild(child, () => terminateProcessTree(child));
150
153
  let out = '';
151
154
  let err = '';
152
155
  child.on('error', () => resolve({ ok: false, message: `${pkg} is not installed in the project and \`${bin}\` is not on PATH (npm i -D ${pkg})` }));
153
- child.stdout.on('data', (d) => (out += d));
154
- child.stderr.on('data', (d) => (err += d));
156
+ child.stdout.on('data', (d) => (out = (out + d).slice(-65536)));
157
+ child.stderr.on('data', (d) => (err = (err + d).slice(-65536)));
155
158
  const timer = setTimeout(() => terminateProcessTree(child), budgetMs);
156
159
  child.on('close', (code) => {
157
160
  clearTimeout(timer);
@@ -239,6 +242,7 @@ export function parseReport(report, durationMs) {
239
242
  * override it.
240
243
  */
241
244
  export function runProcess({ projectDir, files, budgetMs = 120_000, command, commandTemplate, argv, env = {}, parse = parseReport }) {
245
+ assertNotCancelled();
242
246
  const outFile = join(tmpdir(), `testguard-run-${randomBytes(6).toString('hex')}.json`);
243
247
  const [cmd, ...args] = command
244
248
  ?? (commandTemplate ? expandCommand(commandTemplate, files, outFile) : argv(files, outFile));
@@ -248,11 +252,13 @@ export function runProcess({ projectDir, files, budgetMs = 120_000, command, com
248
252
 
249
253
  return new Promise((resolve) => {
250
254
  const child = spawn(cmd, args, { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', env: { ...process.env, CI: '1', FORCE_COLOR: '0', ...extraEnv } });
255
+ registerChild(child, () => terminateProcessTree(child));
256
+ child.stdout.resume(); // Reports are read from the file; drain logs to prevent pipe backpressure.
251
257
  let stderr = '';
252
258
  // Node prints MODULE_TYPELESS_PACKAGE_JSON (and friends) for the PROBED
253
259
  // project's config, not for anything the fault did. Keep it out of the
254
260
  // stream and out of `loadMessage`, which names the cause of a load error.
255
- child.stderr.on('data', (d) => (stderr += String(d).split('\n').filter((l) => !NODE_NOISE.test(l)).join('\n')));
261
+ child.stderr.on('data', (d) => (stderr = (stderr + String(d).split('\n').filter((l) => !NODE_NOISE.test(l)).join('\n')).slice(-65536)));
256
262
  child.on('error', (e) => (stderr += e.message));
257
263
  let killed = false;
258
264
  const timer = setTimeout(() => {
@@ -270,7 +276,7 @@ export function runProcess({ projectDir, files, budgetMs = 120_000, command, com
270
276
  result = { run: { outcome: 'error', tests: { total: 0, passed: 0, failed: 0 }, assertionFailures: 0, durationMs }, timeouts: 0, loadMessage: stderr.trim().split('\n').filter(Boolean).slice(-1)[0] ?? 'runner produced no report', failedTests: [] };
271
277
  } else {
272
278
  try {
273
- result = parse(JSON.parse(readFileSync(outFile, 'utf8')), durationMs);
279
+ result = parse(readBoundedJsonFile(outFile), durationMs);
274
280
  } catch (e) {
275
281
  result = { run: { outcome: 'error', tests: { total: 0, passed: 0, failed: 0 }, assertionFailures: 0, durationMs }, timeouts: 0, loadMessage: `unreadable report: ${e.message}`, failedTests: [] };
276
282
  }
@@ -10,7 +10,7 @@ const DISCOVERY_CONFIG_FILES = [
10
10
  'vitest.workspace.json',
11
11
  ];
12
12
  /** Ask Vitest itself to load config and enumerate the files it would collect. */
13
- export const discoveryArgvFor = (projectDir) => [...runnerArgv(projectDir, 'vitest', 'vitest'), 'list', '--filesOnly', '--passWithNoTests'];
13
+ export const discoveryArgvFor = (projectDir) => [...runnerArgv(projectDir, 'vitest', 'vitest'), 'list', '--filesOnly', '--passWithNoTests', '--maxWorkers=1'];
14
14
 
15
15
  export function parseDiscoveryOutput(stdout) {
16
16
  if (typeof stdout !== 'string') throw new TypeError('Vitest discovery output must be text');
@@ -27,8 +27,8 @@ export async function discoverTests({ projectDir, version, timeoutMs, maxOutputB
27
27
  }
28
28
  // --serial: one file at a time in one process, so a contended machine cannot turn a slow suite into a TIMEOUT verdict.
29
29
  /** The command line, as data. Exported so it is falsifiable without spawning anything. */
30
- export const argvFor = (projectDir, files, outFile, { serial = false } = {}) =>
31
- [...runnerArgv(projectDir, 'vitest', 'vitest'), 'run', ...files, '--reporter=json', `--outputFile=${outFile}`, ...(serial ? ['--no-file-parallelism'] : [])];
30
+ export const argvFor = (projectDir, files, outFile, { serial = false, workers = 1 } = {}) =>
31
+ [...runnerArgv(projectDir, 'vitest', 'vitest'), 'run', ...files, '--reporter=json', `--outputFile=${outFile}`, `--maxWorkers=${serial ? 1 : workers}`, ...(serial || workers === 1 ? ['--no-file-parallelism'] : [])];
32
32
  export const run = (opts) => runProcess({ ...opts, argv: (files, outFile) => argvFor(opts.projectDir, files, outFile, opts) });
33
33
 
34
34
  /**
@@ -0,0 +1,47 @@
1
+ import { sha256 } from '../util/hash.mjs';
2
+ import { selectRunner, discoverRunnerManifest, OWNED_RUNNERS } from './runners/index.mjs';
3
+ import { createDiscoveryManifest, DiscoveryError } from './runners/discovery.mjs';
4
+
5
+ /** The same adapter/configuration universe used by probing, never a file-glob guess. */
6
+ export function hashNativeTestUniverse(manifests) {
7
+ const entries = [...manifests];
8
+ if (!entries.length) throw new DiscoveryError('a native test universe requires runner manifests');
9
+ if (!entries.some(([, manifest]) => manifest?.files?.length)) throw new DiscoveryError('a native test universe must contain test files');
10
+ const names = new Set();
11
+ const bound = entries.map(([runner, manifest]) => {
12
+ if (!runner?.name || names.has(runner.name) || !manifest?.runner) throw new DiscoveryError('native universe runner identities must be unique');
13
+ names.add(runner.name);
14
+ const canonical = createDiscoveryManifest({ runner: manifest.runner.name, version: manifest.runner.version, files: manifest.files, configFiles: manifest.configFiles, source: manifest.source });
15
+ if (canonical.testUniverseHash !== manifest.testUniverseHash || runner.name !== manifest.runner.name) throw new DiscoveryError('native universe manifest binding is inconsistent');
16
+ return { runner: runner.name, hash: manifest.testUniverseHash };
17
+ }).sort((a, b) => a.runner.localeCompare(b.runner));
18
+ return sha256(JSON.stringify(bound));
19
+ }
20
+
21
+ /** Shared owning-adapter collection; check failures retain existing probe semantics. */
22
+ export async function collectOwnedManifests(runner, primaryManifest, { projectDir, sourceDir, python, budgetMs, budgetFor, assertOpen = () => {} }) {
23
+ const owned = OWNED_RUNNERS.filter((r) => r !== runner
24
+ && !(r.name === 'python' && ['python', 'pytest', 'unittest'].includes(runner.name)));
25
+ const ownedChecked = new Map();
26
+ const manifests = new Map(primaryManifest ? [[runner, primaryManifest]] : []);
27
+ for (const r of owned) {
28
+ const check = await r.check({ projectDir, sourceDir, python, budgetMs: budgetFor ? budgetFor() : budgetMs, budgetFor });
29
+ assertOpen();
30
+ ownedChecked.set(r, check);
31
+ if (!check.ok) continue;
32
+ manifests.set(r, await discoverRunnerManifest(r, check, projectDir, budgetFor ? budgetFor() : budgetMs));
33
+ assertOpen();
34
+ }
35
+ return { owned, ownedChecked, manifests };
36
+ }
37
+
38
+ /** Fresh current-project collection for an explicitly requested policy boundary. */
39
+ export async function readNativeTestUniverse(options) {
40
+ const selected = await selectRunner({ ...options, name: options.runnerName ?? 'auto' });
41
+ options.assertOpen?.();
42
+ if (selected.error) throw new DiscoveryError(selected.error);
43
+ const collection = await collectOwnedManifests(selected.runner, selected.manifest, options);
44
+ const primaryRunner = { name: selected.engine ?? selected.runner.name, version: selected.version };
45
+ const runners = [primaryRunner, ...[...collection.ownedChecked].filter(([, check]) => check.ok).map(([runner, check]) => ({ name: check.engine ?? runner.name, version: check.version }))];
46
+ return { ...collection, primary: selected.runner, primaryRunner, runners, testUniverseHash: hashNativeTestUniverse(collection.manifests) };
47
+ }
@@ -1,3 +1,4 @@
1
+ import { registerCleanup } from './runners/lifecycle.mjs';
1
2
  import { existsSync, mkdtempSync, readdirSync, symlinkSync, mkdirSync, statSync, realpathSync } from 'node:fs';
2
3
  import { tmpdir } from 'node:os';
3
4
  import { join, relative, dirname } from 'node:path';
@@ -53,6 +54,7 @@ export function createScratch({ repoRoot, projectDir, ref = 'HEAD', scratchBase
53
54
  throw new PreconditionError(ref === 'HEAD' ? 'repository has no commits; commit first, or run with --in-place' : `ref ${ref} does not resolve to a commit`);
54
55
  }
55
56
  const dest = mkdtempSync(join(scratchBase, 'testguard-'));
57
+ const unregister = registerCleanup(() => removeWorktree(repoRoot, dest));
56
58
  addWorktree(repoRoot, dest, sha);
57
59
  const links = findNodeModules(repoRoot);
58
60
  if (nodeModules) {
@@ -71,7 +73,7 @@ export function createScratch({ repoRoot, projectDir, ref = 'HEAD', scratchBase
71
73
  sha,
72
74
  root: dest,
73
75
  projectDir: join(dest, relative(repoRoot, projectDir)),
74
- cleanup: () => removeWorktree(repoRoot, dest),
76
+ cleanup: () => { removeWorktree(repoRoot, dest); unregister(); },
75
77
  };
76
78
  }
77
79
 
package/src/render.mjs CHANGED
@@ -1,5 +1,15 @@
1
+ import { recordedOriginSummary } from '../spec/lib/origins.mjs';
2
+
1
3
  const ORDER = ['survived', 'nocover', 'unverifiable', 'fault-invalid', 'timeout', 'flaky-defender', 'killed'];
2
4
 
5
+ /** Informational declarations only; never a verdict or an eligibility gate. */
6
+ export function renderOrigins(origins) {
7
+ const split = (counts) => Object.entries(counts).filter(([, n]) => n > 0).map(([kind, n]) => `${kind}: ${n}`).join(', ') || 'none';
8
+ return `origins (${origins.basis}): ${origins.claims.total} distinct claims (${split(origins.claims.byKind)}; mixed: ${origins.claims.mixed})` +
9
+ (origins.records ? `; ${origins.records.total} records (${split(origins.records.byKind)})` : '') +
10
+ ' — declared origins, not authenticated independence';
11
+ }
12
+
3
13
  /** Non-passing verdicts shout; the one pass does not. */
4
14
  export const formatVerdict = (v, provisional = false) => (v === 'killed' ? 'killed' : v.toUpperCase()) + (provisional ? '?' : '');
5
15
 
@@ -37,7 +47,13 @@ export function renderSummary(records, run) {
37
47
  const kills = records.filter((r) => r.verdict === 'killed');
38
48
  const coAuthored = kills.filter((r) => r.detail.independence?.class === 'co-authored').length;
39
49
  const quality = coAuthored ? `\n${coAuthorshipWarning(coAuthored, kills.length)}` : '';
40
- return `${run?.provisional ? 'PROVISIONAL: ' : ''}${records.length} faults probed: ${parts.join(', ')}. ${unproven.length} unproven fault${unproven.length === 1 ? '' : 's'} across ${claims} claim${claims === 1 ? '' : 's'}.${where}${quality}`;
50
+ return `${run?.provisional ? 'PROVISIONAL: ' : ''}${records.length} faults probed: ${parts.join(', ')}. ${unproven.length} unproven fault${unproven.length === 1 ? '' : 's'} across ${claims} claim${claims === 1 ? '' : 's'}.${where}${quality}\n${renderOrigins(recordedOriginSummary(records))}`;
51
+ }
52
+
53
+ /** Distinguish current admission, offline audit and recorded-run policy. */
54
+ export function renderOriginPolicy(policy, { basis = 'recorded' } = {}) {
55
+ const label = basis === 'offline' ? 'current declarations; offline freshness unavailable' : basis === 'current' ? 'current inputs' : 'recorded run';
56
+ return `origin policy (${label}): ${policy.state}; eligible declarations: ${policy.eligibleKinds.join(', ')}; ${policy.claims} claims / ${policy.faults} faults; ${policy.ineligibleClaims.length} ineligible claims; ${policy.nonKilledFaults.length} non-killed faults${policy.unavailableReasons.length ? `; unavailable: ${policy.unavailableReasons.join(', ')}` : ''} — declared origins, not authenticated independence${basis === 'recorded' ? '; not current freshness' : ''}`;
41
57
  }
42
58
 
43
59
  /** Survivors first, then by rank score; killed last. */
@@ -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
+ }