testguard-cli 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +169 -14
  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 +123 -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 +40 -10
  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 +41 -6
  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 +49 -24
  37. package/src/probe/runners/discovery.mjs +4 -0
  38. package/src/probe/runners/index.mjs +3 -1
  39. package/src/probe/runners/jest.mjs +2 -2
  40. package/src/probe/runners/lifecycle.mjs +58 -0
  41. package/src/probe/runners/node/entry.mjs +11 -0
  42. package/src/probe/runners/node/report.mjs +66 -0
  43. package/src/probe/runners/node/runner.mjs +42 -0
  44. package/src/probe/runners/node-test.mjs +130 -0
  45. package/src/probe/runners/playwright.mjs +6 -2
  46. package/src/probe/runners/python.mjs +10 -4
  47. package/src/probe/runners/shared.mjs +14 -7
  48. package/src/probe/runners/vitest.mjs +3 -3
  49. package/src/probe/universe.mjs +47 -0
  50. package/src/probe/worktree.mjs +3 -1
  51. package/src/render.mjs +17 -1
  52. package/src/replay/replay.mjs +8 -4
  53. package/src/scaffold/admission.mjs +117 -0
  54. package/src/scaffold/append.mjs +41 -0
  55. package/src/scaffold/apply-append.mjs +71 -0
  56. package/src/scaffold/fix-input.mjs +119 -0
  57. package/src/scaffold/fix-paths.mjs +90 -0
  58. package/src/scaffold/intent.mjs +2 -0
  59. package/src/scaffold/open-append.mjs +44 -0
  60. package/src/scaffold/plan-append.mjs +37 -0
  61. package/src/scaffold/plan-intent-input.mjs +38 -0
  62. package/src/scaffold/prepare-append.mjs +46 -0
  63. package/src/scaffold/request.mjs +30 -0
  64. package/src/scaffold/scaffold.mjs +23 -10
  65. package/src/status/status.mjs +34 -9
  66. package/src/sweep/pool.mjs +33 -0
  67. package/src/sweep/sweep.mjs +9 -4
@@ -0,0 +1,130 @@
1
+ import { closeSync, mkdtempSync, openSync, readFileSync, readSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { runProcess } from './shared.mjs';
7
+ import { createDiscoveryManifest, DiscoveryError, hashDiscoveryConfigs, normalizeDiscoveredFiles, runDiscoveryProcess } from './discovery.mjs';
8
+
9
+ export const name = 'node-test';
10
+ export const testGlobs = []; // Native collection is authoritative; never guess from globs.
11
+ const HERE = join(dirname(fileURLToPath(import.meta.url)), 'node');
12
+ const RUNNER = join(HERE, 'runner.mjs');
13
+ const ENTRY = join(HERE, 'entry.mjs');
14
+ const VERSION = process.versions.node;
15
+ const strategyHash = () => {
16
+ const digest = createHash('sha256');
17
+ const descriptor = openSync(process.execPath, 'r');
18
+ try {
19
+ const bytes = Buffer.alloc(256 * 1024);
20
+ let length;
21
+ while ((length = readSync(descriptor, bytes, 0, bytes.length, null)) > 0) digest.update(bytes.subarray(0, length));
22
+ } finally { closeSync(descriptor); }
23
+ for (const file of [fileURLToPath(import.meta.url), ...['runner.mjs', 'entry.mjs', 'report.mjs'].map((file) => join(HERE, file)), ...['shared.mjs', 'discovery.mjs', 'lifecycle.mjs'].map((file) => join(HERE, '..', file))]) digest.update(readFileSync(file));
24
+ return digest.digest('hex');
25
+ };
26
+ const admissionError = () => Number(VERSION.split('.')[0]) < 20 ? 'node-test requires Node20 or newer'
27
+ : process.env.NODE_OPTIONS || process.env.NODE_PATH ? 'node-test supports default JavaScript loading; NODE_OPTIONS and NODE_PATH must be unset'
28
+ : undefined;
29
+ export const check = async () => admissionError()
30
+ ? { ok: false, message: admissionError() }
31
+ : { ok: true, version: VERSION, source: 'builtin' };
32
+ export const fatalEdit = () => '/* testguard negative control: this file must not parse */\n(\n';
33
+
34
+ const receipts = () => {
35
+ const dir = mkdtempSync(join(tmpdir(), 'testguard-node-'));
36
+ const file = join(dir, 'entries.json');
37
+ writeFileSync(file, '{"entries":[', { mode: 0o600 });
38
+ return { dir, file };
39
+ };
40
+ const environment = (file, collect, workers) => ({
41
+ TESTGUARD_NODE_ENTRIES: file,
42
+ TESTGUARD_NODE_COLLECT: collect ? '1' : '0',
43
+ TESTGUARD_NODE_WORKERS: String(workers),
44
+ TESTGUARD_NODE_REPORT: '{out}',
45
+ NODE_TEST_CONTEXT: '',
46
+ NODE_DISABLE_COMPILE_CACHE: '1',
47
+ });
48
+ export const argvFor = (projectDir, files) => [process.execPath, `--import=${ENTRY}`, RUNNER, ...files.map((file) => resolve(realpathSync(projectDir), file))];
49
+
50
+ /** Reject unsupported/incomplete envelopes before deriving any verdict inputs. */
51
+ export function validateReport(report) {
52
+ if (report?.schemaVersion !== 1 || report.engine !== name || report.complete !== true
53
+ || report.version !== VERSION || typeof report.collect !== 'boolean' || typeof report.failed !== 'boolean'
54
+ || !Array.isArray(report.entries) || report.entries.length > 100_000
55
+ || !Array.isArray(report.cases) || report.cases.length > 100_000
56
+ || report.diagnostic !== undefined && (typeof report.diagnostic !== 'string' || report.diagnostic.length > 65536)) throw new DiscoveryError('malformed or incomplete node-test report');
57
+ const pids = new Set();
58
+ for (const entry of report.entries) {
59
+ if (!entry || typeof entry.file !== 'string' || !entry.file || entry.file.includes('\0') || !Number.isSafeInteger(entry.pid) || entry.pid <= 0 || pids.has(entry.pid) || entry.version !== report.version) throw new DiscoveryError('malformed node-test worker receipt');
60
+ pids.add(entry.pid);
61
+ }
62
+ for (const t of report.cases) {
63
+ if (!t || typeof t.file !== 'string' || typeof t.name !== 'string'
64
+ || ['container', 'suite', 'skipped', 'passed'].some((k) => typeof t[k] !== 'boolean')
65
+ || t.error !== undefined && (typeof t.error !== 'object' || t.error === null || typeof t.error.failureType !== 'string' || typeof t.error.code !== 'string' || typeof t.error.message !== 'string' || typeof t.error.fileExit !== 'boolean')) throw new DiscoveryError('malformed node-test result');
66
+ if (t.passed && t.error || !t.container && (!t.file || t.file.split('/').some((part) => part === '..' || part === '') || t.file.includes('\\') || t.file.startsWith('/') || !t.name)) throw new DiscoveryError('inconsistent node-test result identity');
67
+ }
68
+ if (report.failed !== report.cases.some((t) => !t.passed && !t.skipped)) throw new DiscoveryError('inconsistent node-test failure summary');
69
+ return report;
70
+ }
71
+
72
+ export function parseReport(report, durationMs) {
73
+ validateReport(report);
74
+ if (report.collect) throw new DiscoveryError('collection is not a test run');
75
+ const tests = report.cases.filter((t) => !t.container && !t.suite && !t.skipped);
76
+ const failed = tests.filter((t) => !t.passed);
77
+ const timeout = report.cases.filter((t) => !t.skipped && t.error?.failureType === 'testTimeoutFailure');
78
+ const loadErrors = report.cases.filter((t) => !t.skipped && !t.passed
79
+ && (t.container || t.suite ? t.error?.failureType !== 'subtestsFailed' : !['testCodeFailure', 'testTimeoutFailure'].includes(t.error?.failureType) || t.error?.fileExit));
80
+ if (!tests.length || !report.entries.length) loadErrors.push({ error: { message: 'node-test executed no test bodies' } });
81
+ const assertionFailures = failed.filter((t) => t.error?.failureType === 'testCodeFailure' && !t.error.fileExit).length;
82
+ const names = new Map();
83
+ for (const t of tests) { const key = `${t.file}::${t.name}`; names.set(key, (names.get(key) ?? 0) + 1); }
84
+ const seen = new Map();
85
+ const failedTests = [];
86
+ for (const t of tests) {
87
+ const key = `${t.file}::${t.name}`;
88
+ const ordinal = (seen.get(key) ?? 0) + 1; seen.set(key, ordinal);
89
+ if (!t.passed) failedTests.push(names.get(key) > 1 ? `${key} [case ${ordinal}]` : key);
90
+ }
91
+ return {
92
+ run: { outcome: loadErrors.length ? 'error' : report.failed || failed.length ? 'fail' : 'pass', tests: { total: tests.length, passed: tests.length - failed.length, failed: failed.length }, assertionFailures: loadErrors.length ? 0 : assertionFailures, durationMs },
93
+ timeouts: timeout.length,
94
+ loadMessage: loadErrors.length ? report.diagnostic?.split(/\r?\n/).find((line) => /(?:\bError:|SyntaxError:|cannot find|unexpected token)/i.test(line))?.trim().slice(0, 1000) ?? loadErrors[0]?.error?.message : undefined,
95
+ failedTests,
96
+ };
97
+ }
98
+
99
+ export async function discoverTests({ projectDir, version, timeoutMs, maxOutputBytes } = {}) {
100
+ if (admissionError()) throw new DiscoveryError(admissionError());
101
+ const before = hashDiscoveryConfigs(projectDir);
102
+ const spool = receipts();
103
+ try {
104
+ const result = await runDiscoveryProcess({ projectDir, argv: argvFor(projectDir, []), timeoutMs, maxOutputBytes, cleanupOnClose: true, env: environment(spool.file, true, 1) });
105
+ let report;
106
+ try { report = validateReport(JSON.parse(result.stdout)); } catch (error) { throw new DiscoveryError(`node-test collection report: ${error.message}`); }
107
+ if (!report.collect || report.failed) throw new DiscoveryError('node-test collection did not complete successfully');
108
+ const files = normalizeDiscoveredFiles(projectDir, report.entries.map((entry) => entry.file));
109
+ if (files.length !== report.entries.length) throw new DiscoveryError('node-test collection executed a duplicate entry');
110
+ const configFiles = hashDiscoveryConfigs(projectDir);
111
+ if (JSON.stringify(configFiles) !== JSON.stringify(before)) throw new DiscoveryError('node-test discovery config changed while modules were being collected');
112
+ const strategy = strategyHash();
113
+ return createDiscoveryManifest({ runner: name, version, files, configFiles, source: `native-node-entry-v1:${strategy}` });
114
+ } finally { rmSync(spool.dir, { recursive: true, force: true }); }
115
+ }
116
+
117
+ export async function run(opts) {
118
+ if (admissionError()) throw new DiscoveryError(admissionError());
119
+ if (!opts.files?.length) throw new DiscoveryError('node-test requires explicit selected test files');
120
+ const files = normalizeDiscoveredFiles(opts.projectDir, opts.files);
121
+ const spool = receipts();
122
+ try {
123
+ return await runProcess({ ...opts, files, cleanupOnClose: true, env: environment(spool.file, false, opts.serial ? 1 : opts.workers ?? 1), argv: (selected) => argvFor(opts.projectDir, selected), parse: (report, durationMs) => {
124
+ validateReport(report);
125
+ const entries = normalizeDiscoveredFiles(opts.projectDir, report.entries.map((entry) => entry.file));
126
+ if (entries.length !== report.entries.length || JSON.stringify(entries) !== JSON.stringify(files)) throw new DiscoveryError('node-test did not execute every selected entry exactly');
127
+ return parseReport(report, durationMs);
128
+ } });
129
+ } finally { rmSync(spool.dir, { recursive: true, force: true }); }
130
+ }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import { isAbsolute, join, relative, resolve, sep } from 'node:path';
4
- import { npx, runProcess, listTestFiles, firstInformativeLine, runnerArgv } from './shared.mjs';
4
+ import { runProcess, listTestFiles, firstInformativeLine, runnerArgv } from './shared.mjs';
5
5
  import { createDiscoveryManifest, DiscoveryError, hashDiscoveryConfigs, normalizeDiscoveredFiles, runDiscoveryReportProcess } from './discovery.mjs';
6
6
  import { globToRegExp } from '../../util/glob.mjs';
7
7
 
@@ -185,7 +185,7 @@ export function parseReport(report, durationMs) {
185
185
 
186
186
  export const run = (opts) => {
187
187
  const env = { PLAYWRIGHT_JSON_OUTPUT_FILE: '{out}', PLAYWRIGHT_JSON_OUTPUT_NAME: '{out}' };
188
- return runProcess({ ...opts, env, parse: parseReport, argv: (files) => [npx, 'playwright', 'test', '--reporter=json', ...(opts.serial ? ['--workers=1'] : []), ...files] });
188
+ return runProcess({ ...opts, env, parse: parseReport, argv: (files) => argvFor(opts.projectDir, files, opts) });
189
189
  };
190
190
 
191
191
  /**
@@ -196,3 +196,7 @@ export const run = (opts) => {
196
196
  * contained before.
197
197
  */
198
198
  export const fatalEdit = () => '/* testguard negative control: this file must not parse */\n(\n';
199
+
200
+ /** Run the project-pinned CLI directly, avoiding an npm subprocess per confirmation. */
201
+ export const argvFor = (projectDir, files, { serial = false, workers = 1 } = {}) =>
202
+ [...runnerArgv(projectDir, '@playwright/test', 'playwright'), 'test', '--reporter=json', `--workers=${serial ? 1 : workers}`, ...files];
@@ -1,4 +1,6 @@
1
+ import { performance } from 'node:perf_hooks';
1
2
  import { existsSync, readFileSync } from 'node:fs';
3
+ import { registerChild } from './lifecycle.mjs';
2
4
  import { spawn } from 'node:child_process';
3
5
  import { dirname, join } from 'node:path';
4
6
  import { fileURLToPath } from 'node:url';
@@ -120,9 +122,10 @@ function ask(path, budgetMs) {
120
122
  } catch {
121
123
  return resolve(null);
122
124
  }
125
+ registerChild(child, () => terminateProcessTree(child));
123
126
  let out = '';
124
127
  child.on('error', () => resolve(null));
125
- child.stdout.on('data', (d) => (out += d));
128
+ child.stdout.on('data', (d) => (out = (out + d).slice(-65536)));
126
129
  child.stderr.on('data', () => {});
127
130
  const timer = setTimeout(() => terminateProcessTree(child), budgetMs);
128
131
  child.on('close', (code) => {
@@ -276,10 +279,10 @@ export function environment(targets) {
276
279
  * the configured value; the rest of the project's `addopts` is left alone,
277
280
  * because TestGuard runs a project's tests the way the project runs them.
278
281
  */
279
- export function argvFor({ engine, interpreter, files, serial }) {
282
+ export function argvFor({ engine, interpreter, files, serial = true }) {
280
283
  if (engine === 'pytest') {
281
284
  return [interpreter, '-m', 'pytest', '-p', '_testguard_pytest_plugin', '-p', 'no:cacheprovider',
282
- '--maxfail=0', '-q', ...(serial ? ['-p', 'no:xdist'] : []), ...files];
285
+ '--maxfail=0', '-q', '-p', 'no:xdist', ...files];
283
286
  }
284
287
  return [interpreter, '-m', '_testguard_unittest_main', ...files];
285
288
  }
@@ -287,12 +290,15 @@ export function argvFor({ engine, interpreter, files, serial }) {
287
290
  export function makeRun(pinned) {
288
291
  const check = makeCheck(pinned);
289
292
  return async (opts) => {
290
- const resolved = await check({ projectDir: opts.sourceDir ?? opts.projectDir, python: opts.python });
293
+ const deadline = performance.now() + (opts.budgetMs ?? 120_000);
294
+ const remaining = () => Math.max(1, Math.floor(deadline - performance.now()));
295
+ const resolved = await check({ projectDir: opts.sourceDir ?? opts.projectDir, python: opts.python, budgetMs: remaining(), budgetFor: remaining });
291
296
  if (!resolved.ok) {
292
297
  return { run: { outcome: 'error', tests: { total: 0, passed: 0, failed: 0 }, assertionFailures: 0, durationMs: 0 }, timeouts: 0, loadMessage: resolved.message, failedTests: [], provenance: {} };
293
298
  }
294
299
  return runProcess({
295
300
  ...opts,
301
+ budgetMs: remaining(),
296
302
  env: environment(opts.targets),
297
303
  parse: parseReport,
298
304
  argv: (files) => argvFor({ engine: resolved.engine, interpreter: resolved.interpreter, files, serial: opts.serial }),
@@ -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);
@@ -238,7 +241,8 @@ export function parseReport(report, durationMs) {
238
241
  * runner's command line; `command` (tests) or `commandTemplate` (--runner-cmd)
239
242
  * override it.
240
243
  */
241
- export function runProcess({ projectDir, files, budgetMs = 120_000, command, commandTemplate, argv, env = {}, parse = parseReport }) {
244
+ export function runProcess({ projectDir, files, budgetMs = 120_000, command, commandTemplate, argv, env = {}, parse = parseReport, cleanupOnClose = false }) {
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(() => {
@@ -262,6 +268,7 @@ export function runProcess({ projectDir, files, budgetMs = 120_000, command, com
262
268
 
263
269
  child.on('close', () => {
264
270
  clearTimeout(timer);
271
+ if (cleanupOnClose) terminateProcessTree(child);
265
272
  const durationMs = Date.now() - started;
266
273
  let result;
267
274
  if (killed) {
@@ -270,7 +277,7 @@ export function runProcess({ projectDir, files, budgetMs = 120_000, command, com
270
277
  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
278
  } else {
272
279
  try {
273
- result = parse(JSON.parse(readFileSync(outFile, 'utf8')), durationMs);
280
+ result = parse(readBoundedJsonFile(outFile), durationMs);
274
281
  } catch (e) {
275
282
  result = { run: { outcome: 'error', tests: { total: 0, passed: 0, failed: 0 }, assertionFailures: 0, durationMs }, timeouts: 0, loadMessage: `unreadable report: ${e.message}`, failedTests: [] };
276
283
  }
@@ -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
+ }