testguard-cli 0.2.0 → 0.2.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-09-17
11
+
12
+ ### Added
13
+
14
+ - **Provisional verdicts** (#5). A run with `--confirm` below 3 declares
15
+ `run.provisional: true` — a spec field whose equivalence with
16
+ `confirmRuns < 3` the validator enforces both ways. Provisional verdicts
17
+ print with a `?`, the summary is prefixed `PROVISIONAL`, a warning is
18
+ printed on stderr, evidence is written to `.testguard/evidence-provisional.json`
19
+ so the canonical file only ever holds confirmed runs, `brief` warns at the
20
+ top, and `baseline` refuses provisional evidence unless
21
+ `--allow-provisional`. A confirmed prior verdict is never reused by a
22
+ provisional run, nor the reverse.
23
+
10
24
  ## [0.2.0] - 2026-09-17
11
25
 
12
26
  ### Added
package/README.md CHANGED
@@ -52,7 +52,7 @@ tests were written against the survivors, 39/39 were killed.
52
52
  | npm | `npm i -D testguard-cli` then `npx testguard probe` |
53
53
  | pip | `pip install testguard-cli` then `testguard probe` (needs Node ≥ 20) |
54
54
  | Homebrew | `brew tap raccioly/tap && brew install testguard` |
55
- | GitHub Action | `uses: raccioly/testguard@v0.2.0` — see [`action.yml`](./action.yml) |
55
+ | GitHub Action | `uses: raccioly/testguard@v0.2.1` — see [`action.yml`](./action.yml) |
56
56
  | pre-commit | `repo: https://github.com/raccioly/testguard`, hooks `testguard-claims`, `testguard-probe` |
57
57
 
58
58
  Projects that set `min-release-age` in `.npmrc` cannot see a version published
@@ -112,7 +112,10 @@ npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to
112
112
  Practical loop: first pass `--no-escalate` (escalation re-runs the whole
113
113
  suite N times per survivor); iterate on one claim with `--claim <ID>` and
114
114
  either `--include-dirty` or `--in-place` (only fault target files must be
115
- clean there; test files may be dirty); final pass with defaults. By default
115
+ clean there; test files may be dirty), optionally `--confirm 1` for a fast
116
+ **provisional** signal — verdicts print with a `?`, evidence goes to
117
+ `evidence-provisional.json`, and `baseline` refuses it; final pass with
118
+ defaults. By default
116
119
  the stream shows only unproven faults plus a killed count — `--verbose`
117
120
  shows every fault. A custom
118
121
  runner (`pnpm --filter`, a specific config) goes in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testguard-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Proves a test suite defends the claims a project makes: injects the faults those claims forbid and reports every one the tests fail to detect.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,7 +22,11 @@ Rules that follow from the table:
22
22
  1. **Green baseline first.** Defenders run N times unmodified before any fault
23
23
  is applied. Anything short of N/N pass is `flaky-defender` and stops there.
24
24
  2. **Confirm over N runs.** `killed` and `survived` both require exactly N
25
- probe runs, all agreeing. Default N is 3.
25
+ probe runs, all agreeing. Default N is 3. **Fewer than three runs is
26
+ provisional**: the evidence declares `run.provisional: true`, every
27
+ rendering marks the verdicts as unconfirmed, and a provisional run is
28
+ never frozen into a baseline. Provisional runs exist for the fix loop —
29
+ a fast signal while writing a test — not for a gate.
26
30
  3. **Only a test body rejecting the behaviour kills.** A test that fails by
27
31
  assertion — or by an exception the fault provoked inside it — counts. A
28
32
  timeout does not, and a suite that fails to load does not: neither is
@@ -45,6 +45,8 @@ const semantic = {
45
45
  evidence(doc) {
46
46
  const errors = [];
47
47
  const n = doc.run.confirmRuns;
48
+ if (n < 3 && doc.run.provisional !== true) errors.push({ path: '/run/provisional', message: `confirmRuns ${n} is below 3; the run must declare provisional: true` });
49
+ if (n >= 3 && doc.run.provisional === true) errors.push({ path: '/run/provisional', message: `confirmRuns ${n} is confirmed; provisional must be absent or false` });
48
50
  doc.records.forEach((r, i) => {
49
51
  const p = `/records/${i}`;
50
52
  const expected = fingerprint({ claimId: r.claim.id, subjectId: r.subject.id, file: r.subject.file ?? '', verdict: r.verdict });
@@ -43,6 +43,10 @@
43
43
  "type": "integer",
44
44
  "minimum": 1
45
45
  },
46
+ "provisional": {
47
+ "description": "True iff confirmRuns < 3. A provisional run's verdicts are not confirmed: they must not be frozen into a baseline and must be rendered as provisional wherever they appear. Validators enforce the equivalence in both directions.",
48
+ "type": "boolean"
49
+ },
46
50
  "mode": {
47
51
  "description": "`worktree` = faults were applied in a scratch git worktree (default). `in-place` = the user's working tree was mutated and restored.",
48
52
  "enum": ["worktree", "in-place"]
@@ -55,7 +55,7 @@ export function buildBrief(evidence, baseline, { max = 20, generatedAt = new Dat
55
55
  baselined: g.baselined.length,
56
56
  };
57
57
  const doc = { schemaVersion: 1, tool: evidence.tool, generatedAt, head: evidence.run.repo.head, heading: HEADING, summary, items, text: '' };
58
- doc.text = renderBriefText(doc, { hasBaseline: Boolean(baseline), total: evidence.records.length });
58
+ doc.text = renderBriefText({ ...doc, provisional: Boolean(evidence.run.provisional) }, { hasBaseline: Boolean(baseline), total: evidence.records.length });
59
59
  return doc;
60
60
  }
61
61
 
@@ -64,6 +64,7 @@ export function renderBriefText(brief, { hasBaseline, total }) {
64
64
  const lines = [
65
65
  brief.heading,
66
66
  '',
67
+ ...(brief.provisional ? ['**PROVISIONAL** — this evidence came from fewer than three confirmation runs; treat every verdict below as unconfirmed and re-probe with --confirm 3 before acting on it.', ''] : []),
67
68
  `testguard ${brief.tool.version}${brief.head ? ` @ ${brief.head.slice(0, 12)}` : ''} — ${brief.summary.claims} claims, ${total} faults probed, ${unproven} unproven` +
68
69
  (hasBaseline ? ` (${brief.summary.new} new since baseline).` : ' (no baseline; everything is new).'),
69
70
  ];
package/src/cli.mjs CHANGED
@@ -33,6 +33,7 @@ probe
33
33
  --claim <ID,ID> probe only these claims; writes .testguard/evidence-partial.json
34
34
  --include-dirty probe the working tree (a snapshot commit) instead of HEAD; uncommitted tests count
35
35
  --verbose also print each killed fault (default: only unproven ones, plus a count)
36
+ --confirm below 3 is PROVISIONAL: verdicts print with "?", evidence goes to .testguard/evidence-provisional.json
36
37
  --runner-cmd "<cmd>" custom runner; must contain {files} and {out}, e.g. "pnpm vitest run {files} --reporter=json --outputFile={out}"
37
38
  --node-modules <dir> node_modules to link into the scratch worktree (or TESTGUARD_NODE_MODULES)
38
39
  --in-place mutate the working tree instead of a scratch worktree
@@ -44,7 +45,7 @@ scaffold --claim <ID> (put every proposal under this claim; copies it if it ex
44
45
  shapes: if-guard → if (false) · single-line guard/mutation removed · return <check> → return true
45
46
  · security flag/window/cost literal weakened · verify/validate/check call removed
46
47
  claims --json
47
- baseline --evidence <path> --out <path>
48
+ baseline --evidence <path> --out <path> --allow-provisional (freeze unconfirmed evidence; normally refused)
48
49
  brief --evidence <path> --baseline <path> --max <n> --text (print only; safe for hooks)
49
50
 
50
51
  exit codes: 0 nothing new to prove · 1 unproven claims (or claim drift) · 2 precondition failed · 3 usage
@@ -70,6 +71,7 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
70
71
  ref: { type: 'string', default: 'HEAD' },
71
72
  claim: { type: 'string' },
72
73
  'include-dirty': { type: 'boolean', default: false },
74
+ 'allow-provisional': { type: 'boolean', default: false },
73
75
  verbose: { type: 'boolean', default: false },
74
76
  'runner-cmd': { type: 'string' },
75
77
  'node-modules': { type: 'string' },
@@ -11,11 +11,15 @@ export async function baselineCommand({ projectDir, values }, io) {
11
11
  return 2;
12
12
  }
13
13
  const evidence = readSpecDoc('evidence', evPath);
14
+ if (evidence.run.provisional && !values['allow-provisional']) {
15
+ io.err(`error: ${evPath} is provisional (confirmRuns ${evidence.run.confirmRuns} < 3); a baseline must be frozen from confirmed evidence. Re-run probe with --confirm 3, or pass --allow-provisional if you accept unconfirmed findings as the frozen contract.`);
16
+ return 2;
17
+ }
14
18
  const baseline = buildBaseline(evidence);
15
19
  const outPath = values.out ? resolve(values.out) : baselinePath(projectDir);
16
20
  writeSpecDoc('baseline', outPath, baseline);
17
21
  const n = Object.values(baseline.fingerprints).reduce((a, b) => a + b, 0);
18
- io.out(`baseline: ${n} unproven finding${n === 1 ? '' : 's'} frozen at ${baseline.head.slice(0, 12)}${baseline.dirty ? ' (working tree was dirty)' : ''} → ${outPath}`);
22
+ io.out(`baseline: ${n} unproven finding${n === 1 ? '' : 's'} frozen at ${baseline.head.slice(0, 12)}${baseline.dirty ? ' (working tree was dirty)' : ''}${evidence.run.provisional ? ' — FROM PROVISIONAL EVIDENCE (--allow-provisional)' : ''} → ${outPath}`);
19
23
  io.out('Commit this file; from now on only new findings gate. Ignore the regenerated ones — add to .gitignore:');
20
24
  io.out(' .testguard/evidence.json');
21
25
  io.out(' .testguard/brief.json');
@@ -4,7 +4,8 @@ import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
4
4
  import { probe } from '../probe/probe.mjs';
5
5
  import { writeSpecDoc, readSpecDoc } from '../evidence/writer.mjs';
6
6
  import { gate } from '../baseline/baseline.mjs';
7
- import { renderRecord, renderSummary, sortForReport } from '../render.mjs';
7
+ import { renderRecord, renderSummary, sortForReport, PROVISIONAL_WARNING } from '../render.mjs';
8
+ export const provisionalEvidencePath = (projectDir) => join(projectDir, '.testguard', 'evidence-provisional.json');
8
9
 
9
10
  export const evidencePath = (projectDir) => join(projectDir, '.testguard', 'evidence.json');
10
11
  export const baselinePath = (projectDir) => join(projectDir, '.testguard', 'baseline.json');
@@ -23,7 +24,10 @@ export async function probeCommand({ projectDir, values, version }, io) {
23
24
  }
24
25
  const only = values.claim ? values.claim.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
25
26
  // A --claim run is partial evidence; keep it away from the canonical file unless --out says otherwise.
26
- const outPath = values.out ? resolve(values.out) : only ? join(projectDir, '.testguard', 'evidence-partial.json') : evidencePath(projectDir);
27
+ const provisional = confirmRuns < 3;
28
+ // Provisional and partial runs never overwrite the canonical evidence: only confirmed, complete runs may feed a baseline.
29
+ const outPath = values.out ? resolve(values.out) : only ? join(projectDir, '.testguard', 'evidence-partial.json') : provisional ? provisionalEvidencePath(projectDir) : evidencePath(projectDir);
30
+ if (provisional && !values.quiet) io.err(PROVISIONAL_WARNING(confirmRuns));
27
31
  const previous = !values['no-reuse'] && existsSync(outPath) ? readSpecDoc('evidence', outPath) : undefined;
28
32
  const basePath = values.baseline ? resolve(values.baseline) : baselinePath(projectDir);
29
33
  const baseline = existsSync(basePath) ? readSpecDoc('baseline', basePath) : undefined;
@@ -45,7 +49,7 @@ export async function probeCommand({ projectDir, values, version }, io) {
45
49
  onStage: !values.quiet && process.stderr.isTTY ? ({ claimId, faultId, stage, i, n }) => process.stderr.write(`\r\x1b[K … ${claimId}/${faultId} ${stage} ${i}/${n}`) : undefined,
46
50
  onProgress: values.quiet ? undefined : (r) => {
47
51
  if (process.stderr.isTTY) process.stderr.write('\r\x1b[K');
48
- if (values.verbose || r.verdict !== 'killed') io.out(renderRecord(r) + (r.reusedFrom ? ' (reused)' : ''));
52
+ if (values.verbose || r.verdict !== 'killed') io.out(renderRecord(r, { provisional }) + (r.reusedFrom ? ' (reused)' : ''));
49
53
  },
50
54
  });
51
55
  writeSpecDoc('evidence', outPath, evidence);
@@ -54,7 +58,7 @@ export async function probeCommand({ projectDir, values, version }, io) {
54
58
  if (!values.quiet && baseline) {
55
59
  io.out('');
56
60
  const tag = (r) => (g.new.includes(r) ? '[NEW] ' : g.baselined.includes(r) ? '[baseline] ' : '[below floor] ');
57
- for (const r of sortForReport(evidence.records).filter((x) => x.verdict !== 'killed')) io.out(' ' + tag(r) + renderRecord(r));
61
+ for (const r of sortForReport(evidence.records).filter((x) => x.verdict !== 'killed')) io.out(' ' + tag(r) + renderRecord(r, { provisional }));
58
62
  }
59
63
  io.out('');
60
64
  if (!values.quiet && !values.verbose) {
@@ -62,6 +66,7 @@ export async function probeCommand({ projectDir, values, version }, io) {
62
66
  if (killed) io.out(` ${killed} killed (not listed; --verbose to see them)`);
63
67
  }
64
68
  io.out(renderSummary(evidence.records, evidence.run) + (baseline ? ` ${g.new.length} new since baseline, ${g.baselined.length} baselined.` : ' No baseline.'));
65
- io.out(`evidence: ${outPath}${only ? ` (partial: --claim ${only.join(',')}; not the canonical evidence file)` : ''}`);
69
+ io.out(`evidence: ${outPath}${only ? ` (partial: --claim ${only.join(',')}; not the canonical evidence file)` : provisional ? ' (provisional; not the canonical evidence file)' : ''}`);
70
+ if (provisional && !values.quiet) io.err(PROVISIONAL_WARNING(confirmRuns));
66
71
  return g.new.length > 0 ? 1 : 0;
67
72
  }
@@ -132,6 +132,7 @@ export async function probe({
132
132
  repo: { head, dirty: isDirty(root), ...(snapshot ? { snapshot } : {}) },
133
133
  runner: { name: vitest.name, ...((runnerVersion ?? readRunnerVersion(projectDir)) ? { version: runnerVersion ?? readRunnerVersion(projectDir) } : {}) },
134
134
  confirmRuns,
135
+ ...(confirmRuns < 3 ? { provisional: true } : {}),
135
136
  mode,
136
137
  },
137
138
  records,
package/src/render.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  const ORDER = ['survived', 'nocover', 'unverifiable', 'fault-invalid', 'timeout', 'flaky-defender', 'killed'];
2
2
 
3
3
  /** Non-passing verdicts shout; the one pass does not. */
4
- export const formatVerdict = (v) => (v === 'killed' ? 'killed' : v.toUpperCase());
4
+ export const formatVerdict = (v, provisional = false) => (v === 'killed' ? 'killed' : v.toUpperCase()) + (provisional ? '?' : '');
5
5
 
6
- export function renderRecord(r) {
7
- const head = `${formatVerdict(r.verdict).padEnd(15)} ${r.claim.id}/${r.subject.id}`.padEnd(38);
6
+ export const PROVISIONAL_WARNING = (n) => `PROVISIONAL — confirmRuns ${n} (< 3): nothing below is confirmed. Verdicts carry a "?"; this evidence cannot be frozen into a baseline. Re-run with --confirm 3 before trusting it.`;
7
+
8
+ export function renderRecord(r, { provisional = false } = {}) {
9
+ const head = `${formatVerdict(r.verdict, provisional).padEnd(15)} ${r.claim.id}/${r.subject.id}`.padEnd(38);
8
10
  let why = '';
9
11
  if (r.detail.reason === 'killed-by-undeclared-tests' && r.detail.undeclaredKillers?.length) {
10
12
  const files = [...new Set(r.detail.undeclaredKillers.map((k) => k.split('::')[0]))];
@@ -26,11 +28,11 @@ export function summarize(records) {
26
28
 
27
29
  export function renderSummary(records, run) {
28
30
  const byVerdict = summarize(records);
29
- const parts = ORDER.filter((v) => byVerdict[v]).map((v) => `${byVerdict[v]} ${formatVerdict(v)}`);
31
+ const parts = ORDER.filter((v) => byVerdict[v]).map((v) => `${byVerdict[v]} ${formatVerdict(v, run?.provisional)}`);
30
32
  const unproven = records.filter((r) => r.verdict !== 'killed');
31
33
  const claims = new Set(unproven.map((r) => r.claim.id)).size;
32
34
  const where = run ? ` Probed ${run.repo.snapshot ? `working tree (snapshot ${run.repo.snapshot.slice(0, 7)} of ${run.repo.head.slice(0, 7)})` : run.mode === 'in-place' ? `in place at ${run.repo.head.slice(0, 7)}${run.repo.dirty ? ' (dirty)' : ''}` : run.repo.head.slice(0, 7)}.` : '';
33
- return `${records.length} faults probed: ${parts.join(', ')}. ${unproven.length} unproven fault${unproven.length === 1 ? '' : 's'} across ${claims} claim${claims === 1 ? '' : 's'}.${where}`;
35
+ 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}`;
34
36
  }
35
37
 
36
38
  /** Survivors first, then by rank score; killed last. */