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.
- package/CHANGELOG.md +56 -0
- package/README.md +62 -7
- package/package.json +2 -1
- package/spec/GATE-SEMANTICS.md +51 -0
- package/spec/README.md +3 -2
- package/spec/lib/validate.mjs +19 -1
- package/spec/schemas/brief.schema.json +11 -1
- package/spec/schemas/common.schema.json +27 -0
- package/spec/schemas/gate.schema.json +52 -0
- package/spec/schemas/status.schema.json +26 -4
- package/src/brief/brief.mjs +26 -2
- package/src/cli.mjs +23 -3
- package/src/commands/brief.mjs +18 -10
- package/src/commands/claims.mjs +1 -1
- package/src/commands/gate.mjs +47 -0
- package/src/commands/probe.mjs +3 -1
- package/src/commands/status.mjs +2 -1
- package/src/gate/changed.mjs +222 -0
- package/src/init/init.mjs +1 -1
- package/src/init/templates/SKILL.md +19 -1
- package/src/probe/discover.mjs +1 -1
- package/src/probe/probe.mjs +17 -13
- package/src/probe/runners/index.mjs +20 -0
- package/src/probe/runners/jest.mjs +9 -0
- package/src/probe/{runner-vitest.mjs → runners/shared.mjs} +29 -21
- package/src/probe/runners/vitest.mjs +7 -0
- package/src/status/status.mjs +43 -3
|
@@ -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 '
|
|
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
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
|
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',
|
|
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,
|
|
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
|
-
|
|
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
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
|
108
|
-
const outFile = join(tmpdir(), `testguard-
|
|
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) :
|
|
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}`] });
|
package/src/status/status.mjs
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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}`);
|