testguard-cli 0.3.1 → 0.5.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.
@@ -3,12 +3,11 @@ import { existsSync, readFileSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { randomBytes } from 'node:crypto';
6
- import { matchGlobs } from '../util/glob.mjs';
6
+ import { matchGlobs } from '../../util/glob.mjs';
7
7
 
8
- const TEST_GLOBS = ['**/*.test.js', '**/*.test.mjs', '**/*.test.cjs', '**/*.test.ts', '**/*.test.mts', '**/*.test.tsx', '**/*.test.jsx',
8
+ export const TEST_GLOBS = ['**/*.test.js', '**/*.test.mjs', '**/*.test.cjs', '**/*.test.ts', '**/*.test.mts', '**/*.test.tsx', '**/*.test.jsx',
9
9
  '**/*.spec.js', '**/*.spec.mjs', '**/*.spec.cjs', '**/*.spec.ts', '**/*.spec.mts', '**/*.spec.tsx', '**/*.spec.jsx'];
10
-
11
- export const name = 'vitest';
10
+ export const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
12
11
 
13
12
  /**
14
13
  * Split a runner command template into argv. Supports double and single
@@ -45,14 +44,13 @@ export function parseCommandTemplate(template) {
45
44
  export const expandCommand = (words, files, outFile) => words.flatMap((w) => (w === '{files}' ? files : [w.replaceAll('{out}', outFile)]));
46
45
 
47
46
  /**
48
- * Can the runner be resolved at all from this directory? Run before any
49
- * verdict is attempted: an unresolvable runner is a precondition failure,
50
- * never a statement about the tests.
47
+ * Can `bin` be resolved at all from this directory? Run before any verdict
48
+ * is attempted: an unresolvable runner is a precondition failure, never a
49
+ * statement about the tests.
51
50
  */
52
- export function checkRunner({ projectDir, budgetMs = 60_000, env = process.env }) {
53
- const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
51
+ export function checkBinary({ projectDir, bin, budgetMs = 60_000, env = process.env }) {
54
52
  return new Promise((resolve) => {
55
- const child = spawn(npx, ['--no-install', 'vitest', '--version'], { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...env, CI: '1' } });
53
+ const child = spawn(npx, ['--no-install', bin, '--version'], { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...env, CI: '1' } });
56
54
  let out = '';
57
55
  let err = '';
58
56
  child.on('error', (e) => resolve({ ok: false, message: e.message }));
@@ -61,7 +59,7 @@ export function checkRunner({ projectDir, budgetMs = 60_000, env = process.env }
61
59
  const timer = setTimeout(() => child.kill('SIGKILL'), budgetMs);
62
60
  child.on('close', (code) => {
63
61
  clearTimeout(timer);
64
- const version = out.trim().replace(/^vitest\//, '');
62
+ const version = out.trim().replace(/^vitest\//, '').split('\n').pop();
65
63
  resolve(code === 0 && version ? { ok: true, version } : { ok: false, message: (err || out).trim().split('\n').filter(Boolean).slice(-1)[0] ?? `exit ${code}` });
66
64
  });
67
65
  });
@@ -70,7 +68,7 @@ export function checkRunner({ projectDir, budgetMs = 60_000, env = process.env }
70
68
  /** Defender globs → existing files. Empty result is the `nocover` signal. */
71
69
  export const resolveDefenders = (projectDir, globs) => matchGlobs(projectDir, globs ?? []);
72
70
 
73
- export const listTestFiles = (projectDir) => matchGlobs(projectDir, TEST_GLOBS);
71
+ export const listTestFiles = (projectDir, globs = TEST_GLOBS) => matchGlobs(projectDir, globs);
74
72
 
75
73
  /**
76
74
  * Turn vitest's JSON report into a spec `testRun`.
@@ -80,11 +78,16 @@ export const listTestFiles = (projectDir) => matchGlobs(projectDir, TEST_GLOBS);
80
78
  * can kill a fault. A suite that fails to load, or a test that times out, is
81
79
  * not evidence that the suite defends the claim.
82
80
  */
81
+ function firstInformativeLine(message) {
82
+ const lines = message.split('\n').map((l) => l.replace(/\x1b\[[0-9;]*m/g, '').trim()).filter(Boolean);
83
+ return lines.find((l) => /error|syntax|unexpected|expected .+ but found|cannot find|failed to parse|transform failed/i.test(l) && !/^●/.test(l)) ?? lines[0] ?? '';
84
+ }
85
+
83
86
  export function parseReport(report, durationMs) {
84
87
  const files = report.testResults ?? [];
85
88
  const loadFailed = files.some((f) => f.status === 'failed' && (f.assertionResults?.length ?? 0) === 0);
86
89
  const results = files.flatMap((f) => (f.assertionResults ?? []).map((t) => ({ ...t, id: `${f.name}::${t.fullName ?? t.title ?? ''}` }))).filter((t) => t.status === 'failed');
87
- const timeouts = results.filter((t) => (t.failureMessages ?? []).some((m) => /timed out/i.test(m))).length;
90
+ const timeouts = results.filter((t) => (t.failureMessages ?? []).some((m) => /timed out|exceeded timeout/i.test(m))).length;
88
91
  const failedTests = results.map((t) => t.id);
89
92
  const assertionFailures = results.length - timeouts;
90
93
  const tests = { total: report.numTotalTests ?? 0, passed: report.numPassedTests ?? 0, failed: report.numFailedTests ?? 0 };
@@ -95,20 +98,25 @@ export function parseReport(report, durationMs) {
95
98
  else outcome = 'fail';
96
99
 
97
100
  const run = { outcome, tests, assertionFailures, durationMs };
98
- const loadMessage = loadFailed ? (files.find((f) => f.message)?.message ?? '').split('\n')[0] : undefined;
101
+ // jest opens a load failure with "● Test suite failed to run" and puts the
102
+ // cause lines below; vitest puts it on the first line. Take the first line
103
+ // that names an error, else the first non-empty one.
104
+ const loadMessage = loadFailed ? firstInformativeLine(files.find((f) => f.message)?.message ?? '') : undefined;
99
105
  return { run, timeouts, loadMessage, failedTests };
100
106
  }
101
107
 
102
108
  /**
103
- * Run vitest on `files` inside `projectDir` with a hard wall-clock budget.
104
- * The budget matters because a synchronous infinite loop is immune to
105
- * vitest's own test timeout; only killing the process ends it.
109
+ * Run a test process on `files` inside `projectDir` with a hard wall-clock
110
+ * budget and read back its jest-compatible JSON report. The budget matters
111
+ * because a synchronous infinite loop is immune to any runner's own test
112
+ * timeout; only killing the process ends it. `argv(files, outFile)` is the
113
+ * runner's command line; `command` (tests) or `commandTemplate` (--runner-cmd)
114
+ * override it.
106
115
  */
107
- export function runVitest({ projectDir, files, budgetMs = 120_000, command, commandTemplate }) {
108
- const outFile = join(tmpdir(), `testguard-vitest-${randomBytes(6).toString('hex')}.json`);
109
- const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
116
+ export function runProcess({ projectDir, files, budgetMs = 120_000, command, commandTemplate, argv }) {
117
+ const outFile = join(tmpdir(), `testguard-run-${randomBytes(6).toString('hex')}.json`);
110
118
  const [cmd, ...args] = command
111
- ?? (commandTemplate ? expandCommand(commandTemplate, files, outFile) : [npx, 'vitest', 'run', ...files, '--reporter=json', `--outputFile=${outFile}`]);
119
+ ?? (commandTemplate ? expandCommand(commandTemplate, files, outFile) : argv(files, outFile));
112
120
  const started = Date.now();
113
121
 
114
122
  return new Promise((resolve) => {
@@ -0,0 +1,7 @@
1
+ import { TEST_GLOBS, npx, runProcess, checkBinary, listTestFiles } from './shared.mjs';
2
+
3
+ export const name = 'vitest';
4
+ export const testGlobs = TEST_GLOBS;
5
+ export const check = (opts) => checkBinary({ ...opts, bin: 'vitest' });
6
+ export const tests = (projectDir) => listTestFiles(projectDir, testGlobs);
7
+ export const run = (opts) => runProcess({ ...opts, argv: (files, outFile) => [npx, 'vitest', 'run', ...files, '--reporter=json', `--outputFile=${outFile}`] });
@@ -4,9 +4,10 @@ import { readSpecDoc } from '../evidence/writer.mjs';
4
4
  import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
5
5
  import { gate } from '../baseline/baseline.mjs';
6
6
  import { hashFile, sha256 } from '../util/hash.mjs';
7
- import { resolveDefenders } from '../probe/runner-vitest.mjs';
7
+ import { resolveDefenders } from '../probe/runners/shared.mjs';
8
8
  import { discoverDefenders } from '../probe/discover.mjs';
9
9
  import { sortForReport } from '../render.mjs';
10
+ import { computeChangedGate, defaultIgnorePath } from '../gate/changed.mjs';
10
11
 
11
12
  export const faultContentHash = (fault) => sha256(`${fault.find}\n${fault.replace}`);
12
13
 
@@ -22,7 +23,7 @@ const P = (projectDir) => ({
22
23
  * Reads the claims file, evidence, baseline and the working tree; never
23
24
  * trusts a cached verdict whose inputs have changed.
24
25
  */
25
- export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt = new Date().toISOString(), paths = P(projectDir), max = 20 }) {
26
+ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt = new Date().toISOString(), paths = P(projectDir), max = 20, changedRef, includeDirty = false }) {
26
27
  const rel = (p) => relative(projectDir, p) || '.';
27
28
  const doc = {
28
29
  schemaVersion: 1,
@@ -37,7 +38,32 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
37
38
  findings: [],
38
39
  paths: {},
39
40
  };
40
- if (!existsSync(paths.claims)) return doc;
41
+ // ── Claim coverage of the change, when a reference is known ──
42
+ // Computed before anything about evidence: a change that touches unclaimed
43
+ // code is the finding every field report shared, and TestGuard is silent
44
+ // about unclaimed code by construction. The claim is written first.
45
+ // The reference is the caller's: this function never reads the environment,
46
+ // so a library caller or a test in a temp directory is never surprised by CI.
47
+ const ref = changedRef;
48
+ let changes;
49
+ if (ref) {
50
+ const g = computeChangedGate({ projectDir, ref, includeDirty, toolVersion });
51
+ changes = { ref: g.ref, base: g.base, includeDirty: g.includeDirty, changed: g.changed, evaluated: g.evaluated, excluded: g.excluded.length, uncovered: g.uncovered, reliedOn: g.reliedOn, expired: g.expired };
52
+ doc.changes = changes;
53
+ if (existsSync(defaultIgnorePath(projectDir))) doc.paths.ignore = rel(defaultIgnorePath(projectDir));
54
+ }
55
+ const unclaimedWhy = () => {
56
+ const files = changes.uncovered.map((u) => u.file);
57
+ return `${files.length} changed file${files.length === 1 ? '' : 's'} since ${changes.ref} carr${files.length === 1 ? 'ies' : 'y'} no claim and no excusing ignore entry: ${files.slice(0, 3).join(', ')}${files.length > 3 ? ', …' : ''}. State the claim before writing more code — TestGuard is silent about unclaimed code by construction.`;
58
+ };
59
+
60
+ if (!existsSync(paths.claims)) {
61
+ if (changes?.uncovered.length) {
62
+ const u = changes.uncovered[0];
63
+ doc.next = { action: 'scaffold', command: `testguard scaffold ${u.file}`, why: `No testguard.claims.json in this project, and ${unclaimedWhy()}`, file: u.file };
64
+ }
65
+ return doc;
66
+ }
41
67
  doc.paths.claims = rel(paths.claims);
42
68
 
43
69
  const claims = loadClaims(paths.claims);
@@ -46,6 +72,13 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
46
72
  const faultIndex = new Map();
47
73
  for (const c of claims.claims) for (const f of c.faults) faultIndex.set(`${c.id}/${f.id}`, { claim: c, fault: f });
48
74
 
75
+ if (changes && changes.uncovered.length > 0) {
76
+ const u = changes.uncovered[0];
77
+ doc.state = 'unclaimed-changes';
78
+ doc.next = { action: 'claim', command: u.suggestion, why: unclaimedWhy(), file: u.file };
79
+ return doc;
80
+ }
81
+
49
82
  const hasEvidence = existsSync(paths.evidence);
50
83
  const hasProvisional = existsSync(paths.provisional);
51
84
  if (hasProvisional) doc.paths.provisionalEvidence = rel(paths.provisional);
@@ -155,6 +188,13 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
155
188
 
156
189
  export function renderStatus(doc) {
157
190
  const lines = [`state: ${doc.state}${doc.provisional ? ' (provisional)' : ''} — ${doc.counts.claims} claims / ${doc.counts.faults} faults` + (doc.counts.byVerdict ? `; ${Object.entries(doc.counts.byVerdict).map(([k, v]) => `${v} ${k}`).join(', ')}; ${doc.counts.new ?? 0} new, ${doc.counts.baselined ?? 0} baselined` : '')];
191
+ if (doc.changes) {
192
+ const c = doc.changes;
193
+ lines.push(`changes: ${c.changed} file${c.changed === 1 ? '' : 's'} since ${c.ref}; ${c.evaluated} evaluated, ${c.excluded} excluded, ${c.uncovered.length} unclaimed`);
194
+ for (const u of c.uncovered) lines.push(`UNCLAIMED ${u.file} (${u.kind}) → ${u.suggestion}`);
195
+ for (const r of c.reliedOn) lines.push(`excused ${r.files.join(', ')} by ignore "${r.pattern}": ${r.reason}`);
196
+ for (const e of c.expired) lines.push(`EXPIRED ignore "${e.pattern}" no longer excuses ${e.files.join(', ')}`);
197
+ }
158
198
  for (const c of doc.changedFaults) lines.push(`CHANGED ${c.claimId}/${c.subjectId} edited since it ${c.previousVerdict} (${c.file})`);
159
199
  for (const s of doc.stale.slice(0, 8)) lines.push(`stale ${s}`);
160
200
  lines.push(`next: [${doc.next.action}] ${doc.next.command}`);