testguard-cli 0.18.2 → 0.19.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.
@@ -21,7 +21,7 @@ import { hashFile, sha256 } from '../util/hash.mjs';
21
21
  import { fingerprint } from '../../spec/lib/fingerprint.mjs';
22
22
  import { recordedOriginSummary } from '../../spec/lib/origins.mjs';
23
23
  import { persistenceSignalsFor } from '../supply/persistence.mjs';
24
- import { collectOwnedManifests, hashNativeTestUniverse } from './universe.mjs';
24
+ import { collectOwnedManifests, hashNativeTestUniverse, hashCustomCommandUniverse } from './universe.mjs';
25
25
 
26
26
 
27
27
  /** Is `child` the same file as `root`, or under it? Both must already be real paths. */
@@ -62,6 +62,27 @@ export function sameWorkerPolicy(previousRun, { workers = 1, serial = workers ==
62
62
  return previousRun?.workers === (serial ? 1 : workers) && Boolean(previousRun?.serial) === serial;
63
63
  }
64
64
 
65
+ /**
66
+ * What the evidence says ran: the engine's name, the version it reported, and
67
+ * where it was resolved from (`project`, `path`, `builtin`). A custom command
68
+ * resolves nothing, so it claims no source.
69
+ */
70
+ export function runnerRecord(name, version, source) {
71
+ return { name, ...(version ? { version } : {}), ...(source ? { source } : {}) };
72
+ }
73
+
74
+ /**
75
+ * Reuse cannot stand in for a measurement by a different runner. pytest and
76
+ * unittest are one adapter and two engines; a global vitest of the same
77
+ * version is a different binary from the project's. Evidence that recorded no
78
+ * runner cannot vouch for this one.
79
+ */
80
+ export function sameRunner(previousRun, current) {
81
+ const recorded = previousRun?.runner;
82
+ if (!recorded) return false;
83
+ return recorded.name === current.name && recorded.version === current.version && recorded.source === current.source;
84
+ }
85
+
65
86
  /**
66
87
  * Probe every fault of every claim. Returns a spec-conformant evidence
67
88
  * document; writing it is the caller's job.
@@ -205,13 +226,14 @@ export async function probe({
205
226
  // Contention: name it before the first run, and record it, so a later
206
227
  // reader of a TIMEOUT or FLAKY-DEFENDER knows the machine was loaded.
207
228
  const contention = detectContention();
208
- if (contention.detected) onWarn(contentionWarning(contention));
229
+ if (contention.detected) onWarn(contentionWarning(contention, { serial, workers, custom: Boolean(runnerCommand) }));
209
230
 
210
231
  const iso = mode === 'worktree' ? createScratch({ repoRoot: root, projectDir, ref: snapshot ?? ref, scratchBase, nodeModules }) : inPlace({ repoRoot: root, projectDir });
211
232
  const isoReal = realpathSync(iso.projectDir);
212
233
  const records = [];
213
234
  let runnerVersion;
214
235
  let runnerSource;
236
+ let recordedRunner;
215
237
  let primaryManifest;
216
238
  let runner = RUNNERS[runnerName === 'auto' ? 'vitest' : runnerName];
217
239
  // A runner with more than one engine (Python: pytest or unittest) is recorded
@@ -236,7 +258,8 @@ export async function probe({
236
258
  primaryManifest = sel.manifest;
237
259
  if (sel.engine) engines.set(sel.runner, sel.engine);
238
260
  }
239
- runnersUsed.set(labelOf(runner), runnerVersion ?? readRunnerVersion(projectDir, runner.name));
261
+ recordedRunner = runnerRecord(labelOf(runner), runnerVersion ?? readRunnerVersion(projectDir, runner.name), runnerSource);
262
+ runnersUsed.set(recordedRunner.name, recordedRunner.version);
240
263
  // Files an owning runner (Playwright) claims run under it, whatever the
241
264
  // project runner is; it must resolve before the first such defender runs.
242
265
  const owned = OWNED_RUNNERS.filter((r) => r !== runner
@@ -283,7 +306,7 @@ export async function probe({
283
306
  ? [...new Set([...runner.tests(iso.projectDir).filter((t) => !owned.some((r) => r.owns(iso.projectDir, t))), ...owned.flatMap((r) => r.tests(iso.projectDir))])]
284
307
  : [...ownerByFile.keys()]).sort());
285
308
  const testUniverseHash = commandTemplate
286
- ? sha256(JSON.stringify({ schemaVersion: 1, source: 'custom-command-static', files: allTests }))
309
+ ? hashCustomCommandUniverse(allTests, commandTemplate)
287
310
  : hashNativeTestUniverse(manifests);
288
311
  const baselineCache = new Map();
289
312
  const discoveryHashCache = new Map();
@@ -295,6 +318,7 @@ export async function probe({
295
318
  const fatalEditFor = (file) => runnerFor(iso.projectDir, file, runner).fatalEdit?.();
296
319
  const prior = previous && previous.run.confirmRuns === confirmRuns
297
320
  && sameWorkerPolicy(previous.run, { workers, serial, runnerCommand })
321
+ && sameRunner(previous.run, recordedRunner)
298
322
  ? new Map(previous.records.map((r) => [`${r.claim.id}/${r.subject.id}`, r]))
299
323
  : new Map();
300
324
  const partitionConfigured = (files) => {
@@ -403,7 +427,9 @@ export async function probe({
403
427
  startedAt,
404
428
  finishedAt: new Date().toISOString(),
405
429
  repo: { head, dirty, ...(snapshot ? { snapshot } : {}), ...(ignoredDirty.length ? { ignoredDirty } : {}) },
406
- runner: { name: labelOf(runner), ...((runnerVersion ?? readRunnerVersion(projectDir, runner.name)) ? { version: runnerVersion ?? readRunnerVersion(projectDir, runner.name) } : {}), ...(runnerSource === 'builtin' ? { source: runnerSource } : {}) },
430
+ // Where the runner came from is part of what was measured: recorded for
431
+ // every resolved runner, as the schema documents, not only the builtin.
432
+ runner: recordedRunner,
407
433
  ...(runnersUsed.size > 1 ? { runners: [...runnersUsed].map(([n, v]) => ({ name: n, ...(v ? { version: v } : {}) })) } : {}),
408
434
  confirmRuns,
409
435
  measurements,
@@ -2,7 +2,7 @@ import { performance } from 'node:perf_hooks';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { registerChild } from './lifecycle.mjs';
4
4
  import { spawn } from 'node:child_process';
5
- import { dirname, join } from 'node:path';
5
+ import { dirname, join, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { runProcess, listTestFiles, firstInformativeLine, terminateProcessTree } from './shared.mjs';
8
8
 
@@ -69,6 +69,20 @@ export const testGlobs = ['**/test_*.py', '**/test*.py', '**/*_test.py'];
69
69
  const PY_FILE = /\.py$/;
70
70
  const TIMEOUT = /\bTimeout >|\btimed out\b|\bTimeoutError\b/i;
71
71
 
72
+ /**
73
+ * The interpreter the operator named, with `--python` or `TESTGUARD_PYTHON` —
74
+ * both the same way, as a shell would. A bare command name (`python3`, no path
75
+ * separator) is looked up on PATH: resolving it against the working directory
76
+ * produced `<cwd>/python3`, which is never an interpreter. Anything with a
77
+ * separator is a path, resolved once against the working directory. An empty
78
+ * value names nothing.
79
+ */
80
+ export function explicitInterpreter(value) {
81
+ if (typeof value !== 'string' || value === '') return undefined;
82
+ const bare = !value.includes('/') && !(process.platform === 'win32' && value.includes('\\'));
83
+ return bare ? { path: value, source: 'path' } : { path: resolve(value), source: 'project' };
84
+ }
85
+
72
86
  /**
73
87
  * Interpreters to try, best first: the active virtualenv, the project's own,
74
88
  * then PATH.
@@ -82,8 +96,8 @@ function candidates(projectDir, python) {
82
96
  const out = [];
83
97
  const win = process.platform === 'win32';
84
98
  const binOf = (root) => (win ? join(root, 'Scripts', 'python.exe') : join(root, 'bin', 'python'));
85
- const explicit = python ?? process.env.TESTGUARD_PYTHON;
86
- if (explicit) return [{ path: explicit, source: 'project', explicit: true }];
99
+ const explicit = explicitInterpreter(python ?? process.env.TESTGUARD_PYTHON);
100
+ if (explicit) return [{ ...explicit, explicit: true }];
87
101
  if (process.env.VIRTUAL_ENV) out.push({ path: binOf(process.env.VIRTUAL_ENV), source: 'project' });
88
102
  for (const dir of ['.venv', 'venv', '.env']) out.push({ path: binOf(join(projectDir, dir)), source: 'project' });
89
103
  out.push({ path: win ? 'python.exe' : 'python3', source: 'path' });
@@ -159,9 +173,9 @@ export function resolveInterpreter({ projectDir, python, budgetMs = 30_000, budg
159
173
  if (answer) return { ...candidate, ...answer };
160
174
  tried.push(candidate.path);
161
175
  }
162
- const explicit = python ?? process.env.TESTGUARD_PYTHON;
176
+ const explicit = explicitInterpreter(python ?? process.env.TESTGUARD_PYTHON);
163
177
  return { error: explicit
164
- ? `${explicit} is not a usable Python interpreter`
178
+ ? `${explicit.path} is not a usable Python interpreter${explicit.source === 'path' ? ' (looked up on PATH)' : ''}`
165
179
  : `no usable Python interpreter (tried ${tried.join(', ') || 'nothing'}); install Python 3.8+, or point --python / TESTGUARD_PYTHON at one` };
166
180
  })();
167
181
  interpreters.set(key, task);
@@ -18,6 +18,17 @@ export function hashNativeTestUniverse(manifests) {
18
18
  return sha256(JSON.stringify(bound));
19
19
  }
20
20
 
21
+ /**
22
+ * A custom `--runner-cmd` has no native listing, so its universe is the static
23
+ * file set — and the command itself. The files alone cannot tell a broken
24
+ * command from the fixed one, and a verdict measured by one is not an answer
25
+ * from the other. The parsed argv is hashed, so reformatting whitespace is not
26
+ * a change and the command text never appears in the evidence.
27
+ */
28
+ export function hashCustomCommandUniverse(files, commandTemplate) {
29
+ return sha256(JSON.stringify({ schemaVersion: 2, source: 'custom-command-static', command: commandTemplate, files }));
30
+ }
31
+
21
32
  /** Shared owning-adapter collection; check failures retain existing probe semantics. */
22
33
  export async function collectOwnedManifests(runner, primaryManifest, { projectDir, sourceDir, python, budgetMs, budgetFor, assertOpen = () => {} }) {
23
34
  const owned = OWNED_RUNNERS.filter((r) => r !== runner
@@ -41,7 +52,8 @@ export async function readNativeTestUniverse(options) {
41
52
  options.assertOpen?.();
42
53
  if (selected.error) throw new DiscoveryError(selected.error);
43
54
  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 }))];
55
+ const identity = (name, version, source) => ({ name, version, ...(source ? { source } : {}) });
56
+ const primaryRunner = identity(selected.engine ?? selected.runner.name, selected.version, selected.source);
57
+ const runners = [primaryRunner, ...[...collection.ownedChecked].filter(([, check]) => check.ok).map(([runner, check]) => identity(check.engine ?? runner.name, check.version, check.source))];
46
58
  return { ...collection, primary: selected.runner, primaryRunner, runners, testUniverseHash: hashNativeTestUniverse(collection.manifests) };
47
59
  }
@@ -2,7 +2,7 @@ import { resetInterpreterCache } from '../probe/runners/python.mjs';
2
2
  import { existsSync, rmSync, realpathSync } from 'node:fs';
3
3
  import { basename, dirname, join, relative, resolve } from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
- import { git, GitError, repoRoot, headSha } from '../git.mjs';
5
+ import { git, GitError, NO_HOOKS, repoRoot, headSha } from '../git.mjs';
6
6
  import { createScratch, PreconditionError } from '../probe/worktree.mjs';
7
7
  import { selectRunner, discoverRunnerManifest, RUNNERS, OWNED_RUNNERS, mergeRuns } from '../probe/runners/index.mjs';
8
8
  import { parseCommandTemplate } from '../probe/runners/shared.mjs';
@@ -90,6 +90,7 @@ export async function replay({
90
90
  runnerCommand,
91
91
  runnerName = 'auto',
92
92
  nodeModules,
93
+ python,
93
94
  scratchBase,
94
95
  limit,
95
96
  toolVersion = '0.0.0',
@@ -123,7 +124,7 @@ export async function replay({
123
124
  let universe;
124
125
  if (!commandTemplate) {
125
126
  const budgetFor = () => commandBudget?.runBudget(budgetMs) ?? budgetMs;
126
- const sel = await selectRunner({ projectDir: iso.projectDir, sourceDir: projectDir, name: runnerName, budgetMs: budgetFor(), budgetFor });
127
+ const sel = await selectRunner({ projectDir: iso.projectDir, sourceDir: projectDir, python, name: runnerName, budgetMs: budgetFor(), budgetFor });
127
128
  commandBudget?.assertOpen();
128
129
  if (sel.error) throw new PreconditionError(`test runner is not resolvable in the scratch worktree (${sel.error}). Pass --node-modules <path>.`);
129
130
  runnerUsed = sel.runner;
@@ -135,7 +136,7 @@ export async function replay({
135
136
  const primaryIsPython = ['python', 'pytest', 'unittest'].includes(runnerUsed.name);
136
137
  const owned = OWNED_RUNNERS.filter((candidate) => candidate !== runnerUsed && !(candidate.name === 'python' && primaryIsPython));
137
138
  for (const candidate of owned) {
138
- const check = await candidate.check({ projectDir: iso.projectDir, sourceDir: projectDir, budgetMs: budgetFor(), budgetFor });
139
+ const check = await candidate.check({ projectDir: iso.projectDir, sourceDir: projectDir, python, budgetMs: budgetFor(), budgetFor });
139
140
  commandBudget?.assertOpen();
140
141
  if (!check.ok) continue;
141
142
  try {
@@ -146,7 +147,7 @@ export async function replay({
146
147
  }
147
148
  universe = buildReplayRunnerUniverse(runnerUsed, manifests);
148
149
  }
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 });
150
+ const record = await replayOne({ fix, iso, root, projectDir, python, confirmRuns, budgetMs, commandBudget, workers, serial, commandTemplate, runner: runnerUsed, universe, onStage, index: i + 1, total: selected.length });
150
151
  records.push(record);
151
152
  onProgress(record);
152
153
  } finally {
@@ -213,7 +214,7 @@ export function partitionReplayDefenders(files, primary, universe) {
213
214
  return groups;
214
215
  }
215
216
 
216
- async function replayOne({ fix, iso, root, projectDir, confirmRuns, budgetMs, commandBudget, workers, serial, commandTemplate, runner, universe, onStage, index, total }) {
217
+ async function replayOne({ fix, iso, root, projectDir, python, confirmRuns, budgetMs, commandBudget, workers, serial, commandTemplate, runner, universe, onStage, index, total }) {
217
218
  // git reports paths from the REPOSITORY root; the runner and every file
218
219
  // operation here work inside the scratch worktree, relative to the PROJECT
219
220
  // directory. Map once, and refuse anything that would land outside the
@@ -262,7 +263,7 @@ async function replayOne({ fix, iso, root, projectDir, confirmRuns, budgetMs, co
262
263
  return { ...base, faultClass, verdict: 'unverifiable', reason: 'no-prior-version', runs: [{ outcome: 'error', durationMs: 0 }] };
263
264
  }
264
265
  try {
265
- if (existedBefore.length) git(['checkout', `${fix.commit}^`, '--', ...existedBefore.map(rel)], iso.projectDir);
266
+ if (existedBefore.length) git([...NO_HOOKS, 'checkout', `${fix.commit}^`, '--', ...existedBefore.map(rel)], iso.projectDir);
266
267
  for (const f of addedByTheFix) {
267
268
  const abs = join(iso.projectDir, rel(f));
268
269
  if (existsSync(abs)) rmSync(inside(abs), { force: true });
@@ -329,12 +330,12 @@ async function replayOne({ fix, iso, root, projectDir, confirmRuns, budgetMs, co
329
330
  // A custom command is one explicit execution boundary. Preserve that
330
331
  // contract exactly; TestGuard cannot infer several native engines from
331
332
  // an opaque user command.
332
- res = await runner.run({ projectDir: iso.projectDir, sourceDir: projectDir, files: related, budgetMs: commandBudget?.runBudget(budgetMs) ?? budgetMs, commandTemplate, workers, serial });
333
+ res = await runner.run({ projectDir: iso.projectDir, sourceDir: projectDir, python, files: related, budgetMs: commandBudget?.runBudget(budgetMs) ?? budgetMs, commandTemplate, workers, serial });
333
334
  } else {
334
335
  const parts = [];
335
336
  for (const [configuredRunner, files] of partitionReplayDefenders(related, runner, universe)) {
336
337
  commandBudget?.assertOpen();
337
- parts.push(await configuredRunner.run({ projectDir: iso.projectDir, sourceDir: projectDir, files, budgetMs: commandBudget?.runBudget(budgetMs) ?? budgetMs, workers, serial }));
338
+ parts.push(await configuredRunner.run({ projectDir: iso.projectDir, sourceDir: projectDir, python, files, budgetMs: commandBudget?.runBudget(budgetMs) ?? budgetMs, workers, serial }));
338
339
  commandBudget?.assertOpen();
339
340
  }
340
341
  res = mergeRuns(parts);
@@ -27,9 +27,10 @@
27
27
  * look; the probe says what it found; a human states the sentence worth
28
28
  * defending. Nothing here is written to `testguard.claims.json`.
29
29
  */
30
- import { existsSync, readFileSync } from 'node:fs';
30
+ import { existsSync } from 'node:fs';
31
31
  import { join } from 'node:path';
32
32
  import { saveSurface } from './savepath.mjs';
33
+ import { readSpecDoc } from '../evidence/writer.mjs';
33
34
  import { globToRegExp, walk } from '../util/glob.mjs';
34
35
 
35
36
  export const defaultConcernsPath = (projectDir) => join(projectDir, 'testguard.concerns.json');
@@ -80,8 +81,13 @@ export function loadConcerns(projectDir, { path } = {}) {
80
81
  let project = [];
81
82
  let source = null;
82
83
  if (existsSync(p)) {
83
- const doc = JSON.parse(readFileSync(p, 'utf8'));
84
- project = doc.concerns ?? [];
84
+ // Through the spec reader, for every caller: a file that does not parse or
85
+ // does not conform is a SpecDocError naming the file (exit 2), never a
86
+ // crash reported as a bug in this tool — and never a concern sweep quietly
87
+ // aims at nothing. A glob concern with no globs matches no file, and a
88
+ // sweep over no file reports clean.
89
+ const doc = readSpecDoc('concerns', p);
90
+ project = doc.concerns;
85
91
  source = p;
86
92
  }
87
93
  const overridden = new Set(project.map((c) => c.id));
@@ -91,11 +97,11 @@ export function loadConcerns(projectDir, { path } = {}) {
91
97
  ];
92
98
  return {
93
99
  concerns,
94
- // The project's own entries, exactly as written. Validation runs against
95
- // these: the enriched objects above carry a `builtin` marker this tool
96
- // added, and a schema that refuses unknown properties is right to reject
97
- // them. Validating what we constructed instead of what the user wrote
98
- // would report OUR bug as THEIR malformed file.
100
+ // The project's own entries, exactly as written — and as validated above,
101
+ // before enrichment: the objects in `concerns` carry a `builtin` marker
102
+ // this tool added, and a schema that refuses unknown properties is right to
103
+ // reject them. Validating what we constructed instead of what the user
104
+ // wrote would report OUR bug as THEIR malformed file.
99
105
  raw: project.map(({ builtin, ...c }) => c),
100
106
  source,
101
107
  shadowed: BUILTIN_CONCERNS.filter((c) => overridden.has(c.id)).map((c) => c.id),
@@ -169,6 +169,7 @@ export async function sweep({
169
169
  runnerCommand,
170
170
  runnerName,
171
171
  nodeModules,
172
+ python,
172
173
  toolVersion = '0.0.0',
173
174
  generatedAt = new Date().toISOString(),
174
175
  claimsPath,
@@ -274,6 +275,7 @@ export async function sweep({
274
275
  runnerCommand,
275
276
  runnerName,
276
277
  nodeModules,
278
+ python,
277
279
  toolVersion,
278
280
  onStage,
279
281
  onWarn,