testguard-cli 0.14.2 → 0.15.1

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.
@@ -10,6 +10,7 @@ import { resolveChangedRef, withChangedRef } from '../gate/changed.mjs';
10
10
  import { costReport, renderCost } from '../probe/cost.mjs';
11
11
  import { progressMode, stageReporter, clearStageLine, recordEvent, isProgressMode, progressStream } from '../probe/progress.mjs';
12
12
  import { validate } from '../../spec/lib/validate.mjs';
13
+ import { createCommandBudget, parseCommandBudget } from '../command-budget.mjs';
13
14
  export const provisionalEvidencePath = (projectDir) => join(projectDir, '.testguard', 'evidence-provisional.json');
14
15
 
15
16
  export const evidencePath = (projectDir) => join(projectDir, '.testguard', 'evidence.json');
@@ -36,10 +37,16 @@ export function partialScope(only, records) {
36
37
  export async function probeCommand({ projectDir, values, version }, io) {
37
38
  const confirmRuns = Number(values.confirm);
38
39
  const budgetMs = Number(values.budget);
40
+ const commandBudgetMs = parseCommandBudget(values['command-budget']);
39
41
  if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
40
42
  io.err('--confirm must be a positive integer and --budget at least 1000');
41
43
  return 3;
42
44
  }
45
+ if (commandBudgetMs === null) {
46
+ io.err('--command-budget must be at least 1000 milliseconds');
47
+ return 3;
48
+ }
49
+ const commandBudget = createCommandBudget(commandBudgetMs);
43
50
  if (values.progress && !isProgressMode(values.progress)) {
44
51
  io.err(`--progress must be one of auto, tty, plain, ndjson, none (got ${values.progress})`);
45
52
  return 3;
@@ -50,6 +57,19 @@ export async function probeCommand({ projectDir, values, version }, io) {
50
57
  const stageOut = stageReporter(progress, (s) => progressTo.write(s));
51
58
  const claims = loadClaims(values.claims ? resolve(values.claims) : defaultClaimsPath(projectDir));
52
59
  if (claims.claims.length === 0) {
60
+ if (values['allow-empty'] && values.claim === undefined) {
61
+ io.err('claims file declares no claims; verification skipped (--allow-empty); run gate to check changed-file coverage');
62
+ if (values.json) {
63
+ const doc = computeStatus({ projectDir, toolVersion: version, paths: {
64
+ claims: values.claims ? resolve(values.claims) : defaultClaimsPath(projectDir),
65
+ evidence: evidencePath(projectDir), provisional: provisionalEvidencePath(projectDir), baseline: baselinePath(projectDir),
66
+ } });
67
+ const result = validate('status', doc);
68
+ if (!result.ok) throw new Error(`empty probe JSON document does not conform: ${result.errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`);
69
+ io.out(JSON.stringify(doc, null, 2));
70
+ }
71
+ return 0;
72
+ }
53
73
  io.err('claims file declares no claims; nothing to verify');
54
74
  return 2;
55
75
  }
@@ -74,6 +94,7 @@ export async function probeCommand({ projectDir, values, version }, io) {
74
94
  previous,
75
95
  confirmRuns,
76
96
  budgetMs,
97
+ commandBudget,
77
98
  mode: values['in-place'] ? 'in-place' : 'worktree',
78
99
  ref: values.ref ?? 'HEAD',
79
100
  refExplicit: values.ref !== undefined,
@@ -101,6 +122,7 @@ export async function probeCommand({ projectDir, values, version }, io) {
101
122
  if (values.verbose || r.verdict !== 'killed') io.out(renderRecord(r, { provisional, showDiscovered: firstOfClaim }) + (r.reusedFrom ? ' (reused)' : ''));
102
123
  },
103
124
  });
125
+ commandBudget?.assertOpen();
104
126
  writeSpecDoc('evidence', outPath, evidence);
105
127
 
106
128
  const g = gate(evidence.records, baseline, { severityFloor: values.severity });
@@ -1,6 +1,7 @@
1
1
  import { dirname, join, resolve } from 'node:path';
2
2
  import { replay, calibrationFrom, renderReplay } from '../replay/replay.mjs';
3
3
  import { writeSpecDoc } from '../evidence/writer.mjs';
4
+ import { createCommandBudget, parseCommandBudget } from '../command-budget.mjs';
4
5
 
5
6
  export const replayPath = (projectDir) => join(projectDir, '.testguard', 'replay.json');
6
7
  export const calibrationPath = (projectDir) => join(projectDir, '.testguard', 'calibration.json');
@@ -13,17 +14,24 @@ export async function replayCommand({ projectDir, values, version }, io) {
13
14
  }
14
15
  const confirmRuns = Number(values.confirm);
15
16
  const budgetMs = Number(values.budget);
17
+ const commandBudgetMs = parseCommandBudget(values['command-budget']);
16
18
  const limit = values.max ? Number(values.max) : undefined;
17
19
  if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
18
20
  io.err('--confirm must be a positive integer and --budget at least 1000');
19
21
  return 3;
20
22
  }
23
+ if (commandBudgetMs === null) {
24
+ io.err('--command-budget must be at least 1000 milliseconds');
25
+ return 3;
26
+ }
27
+ const commandBudget = createCommandBudget(commandBudgetMs);
21
28
 
22
29
  const doc = await replay({
23
30
  projectDir,
24
31
  range,
25
32
  confirmRuns,
26
33
  budgetMs,
34
+ commandBudget,
27
35
  runnerCommand: values['runner-cmd'],
28
36
  runnerName: values.runner,
29
37
  nodeModules: values['node-modules'] ? resolve(values['node-modules']) : process.env.TESTGUARD_NODE_MODULES,
@@ -36,12 +44,17 @@ export async function replayCommand({ projectDir, values, version }, io) {
36
44
  });
37
45
 
38
46
  const outPath = values.out ? resolve(values.out) : replayPath(projectDir);
39
- writeSpecDoc('replay', outPath, doc);
40
47
  const calibration = calibrationFrom(doc, { toolVersion: version });
41
48
  // Beside the replay document, whatever --out says: the two are one result,
42
49
  // and splitting them across directories loses the pairing — and leaves a
43
50
  // file behind in a repository the run is only meant to read.
44
51
  const calPath = values.baseline ? resolve(values.baseline) : values.out ? join(dirname(outPath), 'calibration.json') : calibrationPath(projectDir);
52
+ // Replay and calibration are one result. Cross the deadline boundary only
53
+ // after both complete documents exist in memory; serialization is then the
54
+ // documented non-interruptible final step, so expiry can never leave half
55
+ // of the pair as though it were complete.
56
+ commandBudget?.assertOpen();
57
+ writeSpecDoc('replay', outPath, doc);
45
58
  writeSpecDoc('calibration', calPath, calibration);
46
59
 
47
60
  if (values.json) {
@@ -2,6 +2,7 @@ import { join, resolve } from 'node:path';
2
2
  import { sweep, renderSweep, ConcernError } from '../sweep/sweep.mjs';
3
3
  import { resolveChangedRef } from '../gate/changed.mjs';
4
4
  import { writeSpecDoc } from '../evidence/writer.mjs';
5
+ import { createCommandBudget, parseCommandBudget } from '../command-budget.mjs';
5
6
 
6
7
  export const sweepPath = (projectDir) => join(projectDir, '.testguard', 'sweep.json');
7
8
  /**
@@ -29,6 +30,18 @@ export const sweepEvidencePath = (projectDir) => join(projectDir, '.testguard',
29
30
  */
30
31
  export async function sweepCommand({ projectDir, values, version }, io) {
31
32
  const mode = values['save-paths'] ? 'save-paths' : 'changed';
33
+ const confirmRuns = Number(values.confirm);
34
+ const budgetMs = Number(values.budget);
35
+ const commandBudgetMs = parseCommandBudget(values['command-budget']);
36
+ if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
37
+ io.err('--confirm must be a positive integer and --budget at least 1000');
38
+ return 3;
39
+ }
40
+ if (commandBudgetMs === null) {
41
+ io.err('--command-budget must be at least 1000 milliseconds');
42
+ return 3;
43
+ }
44
+ const commandBudget = createCommandBudget(commandBudgetMs);
32
45
  // A concern that aims at the write surface or at a glob needs no diff, the
33
46
  // same as --save-paths. Only a concern that defers to the gate does.
34
47
  const aimed = Boolean(values.concern) || mode === 'save-paths';
@@ -41,12 +54,6 @@ export async function sweepCommand({ projectDir, values, version }, io) {
41
54
  io.err('sweep needs a reference to measure the change against: --changed <ref> (e.g. origin/main), or set TESTGUARD_CHANGED_REF. CI bases and a safe local remote default or differently named upstream are detected automatically. Or sweep the write surface instead with --save-paths.');
42
55
  return 3;
43
56
  }
44
- const confirmRuns = Number(values.confirm);
45
- const budgetMs = Number(values.budget);
46
- if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
47
- io.err('--confirm must be a positive integer and --budget at least 1000');
48
- return 3;
49
- }
50
57
  let cap;
51
58
  if (values.cap !== undefined) {
52
59
  cap = Number(values.cap);
@@ -70,6 +77,7 @@ export async function sweepCommand({ projectDir, values, version }, io) {
70
77
  cap,
71
78
  confirmRuns,
72
79
  budgetMs,
80
+ commandBudget,
73
81
  runnerCommand: values['runner-cmd'],
74
82
  runnerName: values.runner,
75
83
  nodeModules: values['node-modules'] ? resolve(values['node-modules']) : process.env.TESTGUARD_NODE_MODULES,
@@ -95,6 +103,7 @@ export async function sweepCommand({ projectDir, values, version }, io) {
95
103
 
96
104
  const { evidence, ...document } = doc;
97
105
  const outPath = values.out ? resolve(values.out) : sweepPath(projectDir);
106
+ commandBudget?.assertOpen();
98
107
  writeSpecDoc('sweep', outPath, document);
99
108
  // Written only when something was actually probed: an empty document would
100
109
  // teach the next run that every class is unproductive.
@@ -1,7 +1,7 @@
1
- import { existsSync, realpathSync } from 'node:fs';
1
+ import { existsSync, realpathSync, lstatSync } from 'node:fs';
2
2
  import { extname, join, relative, resolve, dirname } from 'node:path';
3
3
  import { git, repoRoot, headSha, GitError } from '../git.mjs';
4
- import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
4
+ import { loadClaims, defaultClaimsPath, ClaimsError } from '../claims/load.mjs';
5
5
  import { readSpecDoc } from '../evidence/writer.mjs';
6
6
  import { resolveDefenders } from '../probe/runners/shared.mjs';
7
7
  import { discoverDefenders } from '../probe/discover.mjs';
@@ -49,6 +49,33 @@ export const isDefaultExcluded = (file) => DEFAULT_EXCLUDE_RES.some((re) => re.t
49
49
 
50
50
  export const defaultIgnorePath = (projectDir) => join(projectDir, 'testguard.ignore.json');
51
51
 
52
+ /** Cache validated descendant boundaries for one measurement, never across runs. */
53
+ export function nestedProjectResolver(projectDir) {
54
+ const root = realpathSync(resolve(projectDir));
55
+ const markers = new Map();
56
+ return (file) => {
57
+ const parts = file.split('/').slice(0, -1);
58
+ for (let i = 1; i <= parts.length; i++) {
59
+ const project = parts.slice(0, i).join('/');
60
+ if (!markers.has(project)) {
61
+ const marker = join(root, project, 'testguard.claims.json');
62
+ let stat;
63
+ try { stat = lstatSync(marker); }
64
+ catch (error) {
65
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw new ClaimsError(`cannot inspect nested claims file ${marker}: ${error.message}`);
66
+ }
67
+ if (stat) {
68
+ if (!stat.isFile() || stat.isSymbolicLink() || realpathSync(marker) !== marker) throw new ClaimsError(`nested claims file must be a regular file without symlink traversal: ${marker}`);
69
+ loadClaims(marker);
70
+ }
71
+ markers.set(project, Boolean(stat));
72
+ }
73
+ if (markers.get(project)) return project;
74
+ }
75
+ return null;
76
+ };
77
+ }
78
+
52
79
  /**
53
80
  * The reference the change is measured against. Environment overrides and CI
54
81
  * bases are handled here; local Git discovery is separate because it needs a
@@ -212,7 +239,15 @@ export function computeChangedGate({ projectDir, ref, includeDirty = false, excl
212
239
  const excluded = [];
213
240
  const covered = [];
214
241
  const uncovered = [];
242
+ const nested = new Map();
243
+ const ownerOf = nestedProjectResolver(projectDir);
215
244
  for (const file of changed) {
245
+ const owner = ownerOf(file);
246
+ if (owner) {
247
+ if (!nested.has(owner)) nested.set(owner, []);
248
+ nested.get(owner).push(file);
249
+ continue;
250
+ }
216
251
  if (!SOURCE_EXT.has(extname(file))) { excluded.push({ file, by: 'non-source' }); continue; }
217
252
  const ux = userExcludes.find((x) => x.re.test(file));
218
253
  if (ux) { excluded.push({ file, by: `exclude:${ux.glob}` }); continue; }
@@ -256,6 +291,7 @@ export function computeChangedGate({ projectDir, ref, includeDirty = false, excl
256
291
  changed: changed.length,
257
292
  evaluated,
258
293
  excluded,
294
+ ...(nested.size ? { nested: [...nested].map(([project, files]) => ({ project, files })) } : {}),
259
295
  covered,
260
296
  uncovered,
261
297
  reliedOn,
@@ -271,7 +307,8 @@ export function renderGate(doc) {
271
307
  for (const u of doc.uncovered) lines.push(`UNCLAIMED ${u.file} (${u.kind}${u.nearestClaimId ? `, nearest claim ${u.nearestClaimId}` : ''})\n → ${u.suggestion}`);
272
308
  for (const r of doc.reliedOn) lines.push(`excused ${r.files.join(', ')} by ignore "${r.pattern}": ${r.reason}${r.expires ? ` (expires ${r.expires})` : ''}`);
273
309
  for (const e of doc.expired) lines.push(`EXPIRED ignore "${e.pattern}" (expired ${e.expires}) no longer excuses ${e.files.join(', ')}`);
274
- if (doc.changed > 0 && doc.evaluated === 0) lines.push(`note: every changed file was excluded (${[...new Set(doc.excluded.map((x) => x.by))].join(', ')}); 0 evaluated${doc.strict ? ' — --strict makes this a failure' : ''}`);
310
+ for (const n of doc.nested ?? []) lines.push(`delegated ${n.files.length} file(s) belong to the TestGuard project at ${n.project}/; run testguard gate ${n.project} --changed ${doc.ref}${doc.includeDirty ? ' --include-dirty' : ''}; child coverage has not been evaluated here`);
311
+ if (doc.changed > 0 && doc.evaluated === 0) lines.push(`note: every changed file was excluded or delegated (${[...new Set(doc.excluded.map((x) => x.by))].join(', ')}); 0 evaluated${doc.strict ? ' — --strict makes this a failure' : ''}`);
275
312
  if (doc.changed === 0) lines.push(`no changes since ${doc.ref}; nothing to claim`);
276
313
  if (doc.uncovered.length === 0 && doc.exitCode === 0 && doc.evaluated > 0) lines.push('every changed source file carries a claim or an excusing ignore entry');
277
314
  return lines.join('\n');
package/src/git.mjs CHANGED
@@ -6,12 +6,14 @@ import { randomBytes } from 'node:crypto';
6
6
 
7
7
  export class GitError extends Error {}
8
8
 
9
- export function git(args, cwd, env) {
9
+ export function gitRaw(args, cwd, env) {
10
10
  const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: env ? { ...process.env, ...env } : process.env });
11
11
  if (r.status !== 0) throw new GitError(`git ${args.join(' ')}: ${(r.stderr || r.stdout).trim()}`);
12
- return r.stdout.trim();
12
+ return r.stdout;
13
13
  }
14
14
 
15
+ export const git = (args, cwd, env) => gitRaw(args, cwd, env).trim();
16
+
15
17
  export const repoRoot = (dir) => git(['rev-parse', '--show-toplevel'], dir);
16
18
 
17
19
  /** Full sha of `ref` (default HEAD), or null when it does not resolve. */
@@ -20,7 +20,7 @@ which files exist. `state` is one of:
20
20
 
21
21
  | state | meaning | you do |
22
22
  |---|---|---|
23
- | `no-claims` | no `testguard.claims.json` | `testguard scaffold <file>` for a file with guards; replace every `TODO:` statement with what the code guarantees; keep or drop each proposal; move the claims into `testguard.claims.json` |
23
+ | `no-claims` | missing or empty `testguard.claims.json` | `testguard scaffold <file>` for a file with guards; replace every `TODO:` statement with the intended guarantee from a spec, ticket, or past bug; keep or drop each proposal; move the claims into `testguard.claims.json` |
24
24
  | `unclaimed-changes` | files you changed carry no claim (when a reference is explicit, supplied by CI, or safely inferred from local Git's remote default/upstream metadata) | `next.file` names the first; `testguard scaffold <file>` and state the claim, or add a `testguard.ignore.json` path entry with a reason a reviewer will accept. **Before** writing more code. |
25
25
  | `invalid-anchors` | an exact fault anchor moved or became ambiguous | run `testguard claims --check-anchors`; repair `next.target` in the claims file without changing what the fault means, then re-probe. Never guess or auto-fix an anchor. |
26
26
  | `unprobed` | claims never probed | `testguard probe` |
@@ -117,6 +117,8 @@ export function isReusable(prior, current) {
117
117
  const same = (x, y) => JSON.stringify(x) === JSON.stringify(y);
118
118
  return prior.inputs.targetHash === current.inputs.targetHash
119
119
  && same(prior.inputs.defenderHashes, current.inputs.defenderHashes)
120
+ && same(prior.inputs.testUniverseHash, current.inputs.testUniverseHash)
121
+ && same(prior.inputs.discoveryHashes, current.inputs.discoveryHashes)
120
122
  && same(prior.defenders.requested, current.requested)
121
123
  && same(prior.defenders.resolved, current.resolved)
122
124
  && prior.subject.contentHash === current.contentHash;
@@ -1,9 +1,10 @@
1
1
  import { join } from 'node:path';
2
2
  import { listTestFiles } from './runners/shared.mjs';
3
3
  import { testGlobs as PY_TEST_GLOBS } from './runners/python.mjs';
4
- import { fileImports } from './rank.mjs';
4
+ import { fileImportRelation } from './rank.mjs';
5
5
  import { pyFileImports } from './pyimports.mjs';
6
6
  import { classifyDefenders } from './mocks.mjs';
7
+ import { normalizeDiscoveredFiles } from './runners/discovery.mjs';
7
8
 
8
9
  /** A target's language decides how its importers are found. Nothing else about it matters here. */
9
10
  export const isPython = (rel) => rel.endsWith('.py');
@@ -17,13 +18,48 @@ export const isPython = (rel) => rel.endsWith('.py');
17
18
  * `nocover` therefore means exactly "no test file imports this source without
18
19
  * mocking it".
19
20
  */
20
- export function discoverDefendersDetailed(projectDir, targetRel) {
21
- const importing = isPython(targetRel)
22
- ? listTestFiles(projectDir, PY_TEST_GLOBS).filter((t) => pyFileImports(projectDir, join(projectDir, t), targetRel, t))
23
- : listTestFiles(projectDir).filter((t) => fileImports(projectDir, join(projectDir, t), targetRel));
24
- return { importing, ...classifyDefenders(projectDir, targetRel, importing) };
21
+ const explicitFiles = (projectDir, testUniverse) => {
22
+ if (testUniverse === undefined) return null;
23
+ if (!Array.isArray(testUniverse) && !Object.isFrozen(testUniverse)) throw new TypeError('explicit test universe manifest must be immutable');
24
+ const files = Array.isArray(testUniverse) ? testUniverse : testUniverse?.files;
25
+ if (!Array.isArray(files)) throw new TypeError('explicit test universe must be an array or a manifest with files');
26
+ if (!Object.isFrozen(files)) throw new TypeError('explicit test universe must be immutable');
27
+ return normalizeDiscoveredFiles(projectDir, files);
28
+ };
29
+
30
+ export function discoverDefendersDetailed(projectDir, targetRel, testUniverse) {
31
+ const explicit = explicitFiles(projectDir, testUniverse);
32
+ const candidates = explicit ?? (isPython(targetRel) ? listTestFiles(projectDir, PY_TEST_GLOBS) : listTestFiles(projectDir));
33
+ if (isPython(targetRel)) {
34
+ const pythonCandidates = candidates.filter((t) => t.endsWith('.py'));
35
+ const importing = pythonCandidates.filter((t) => pyFileImports(projectDir, join(projectDir, t), targetRel, t));
36
+ return { importing, ...classifyDefenders(projectDir, targetRel, importing), indeterminate: [], dependencies: pythonCandidates, relations: new Map() };
37
+ }
38
+
39
+ const relations = new Map();
40
+ const importing = [];
41
+ const indeterminate = [];
42
+ const jsCandidates = candidates.filter((candidate) => !candidate.endsWith('.py'));
43
+ // Every candidate's source participates in the negative discovery result:
44
+ // editing an unrelated test so it starts importing the target must invalidate
45
+ // a prior `nocover`, not silently reuse it.
46
+ const dependencies = new Set(jsCandidates);
47
+ for (const file of jsCandidates) {
48
+ const relation = fileImportRelation(projectDir, join(projectDir, file), targetRel);
49
+ relations.set(file, relation);
50
+ for (const dependency of relation.dependencies) dependencies.add(dependency);
51
+ if (relation.status === 'matched') importing.push(file);
52
+ else if (relation.status === 'indeterminate') indeterminate.push({ file, reason: relation.reason, issues: relation.issues ?? [] });
53
+ }
54
+ return {
55
+ importing,
56
+ ...classifyDefenders(projectDir, targetRel, importing, relations),
57
+ indeterminate,
58
+ dependencies: [...dependencies].sort(),
59
+ relations,
60
+ };
25
61
  }
26
62
 
27
- export function discoverDefenders(projectDir, targetRel) {
28
- return discoverDefendersDetailed(projectDir, targetRel).canDetect;
63
+ export function discoverDefenders(projectDir, targetRel, testUniverse) {
64
+ return discoverDefendersDetailed(projectDir, targetRel, testUniverse).canDetect;
29
65
  }