testguard-cli 0.1.3 → 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,38 @@ 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
+
24
+ ## [0.2.0] - 2026-09-17
25
+
26
+ ### Added
27
+
28
+ - **`testguard scaffold <file>`** — mechanical fault producer (#6). Proposes
29
+ the five shapes both field reports found behind ~80% of hand-written
30
+ faults: guard forced false, single-line guard or state change removed,
31
+ `return <check>` → `return true`, security literal weakened, check call
32
+ removed. Every proposal is an exact-line anchor with `expectHits` and
33
+ `occurrence` computed from the file (verifiable by construction; anything
34
+ `locate()` would reject is never emitted), `producedBy: derived`,
35
+ `defendedBy` prefilled from the tests that import the module, grouped
36
+ under a preceding `@claim` annotation or by enclosing function.
37
+ Statements are `TODO:` placeholders; the output is a draft under
38
+ `.testguard/`, never the claims file. `--claim <ID>` puts everything under
39
+ one claim and copies it if it exists; `--json` prints instead.
40
+ - Self-claim `TG-SCAFFOLD-ANCHORS-HIT`; install smoke exercises `scaffold`.
41
+
10
42
  ## [0.1.3] - 2026-09-17
11
43
 
12
44
  From a second field report on a real codebase (456 tests, 27 claims, 35
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.1.3` — 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
@@ -66,6 +66,7 @@ npx testguard-cli claims # what does this project claim, and is every claim
66
66
  npx testguard-cli probe # try to falsify each claim; report what the tests missed
67
67
  npx testguard-cli baseline # freeze today's unproven findings; from now on only new ones gate
68
68
  npx testguard-cli brief # tell the agent where the suite is blind, before it writes
69
+ npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to keep or drop
69
70
  ```
70
71
 
71
72
  1. **Claims** live in `testguard.claims.json` (editors validate it against
@@ -111,7 +112,10 @@ npx testguard-cli brief # tell the agent where the suite is blind, before
111
112
  Practical loop: first pass `--no-escalate` (escalation re-runs the whole
112
113
  suite N times per survivor); iterate on one claim with `--claim <ID>` and
113
114
  either `--include-dirty` or `--in-place` (only fault target files must be
114
- 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
115
119
  the stream shows only unproven faults plus a killed count — `--verbose`
116
120
  shows every fault. A custom
117
121
  runner (`pnpm --filter`, a specific config) goes in
@@ -135,6 +139,34 @@ npx testguard-cli brief # tell the agent where the suite is blind, before
135
139
  `--text` prints only, and exits 0 silently when there is no evidence yet,
136
140
  so the hook can never break a session.
137
141
 
142
+ ### Authoring faults mechanically
143
+
144
+ Writing faults by hand means reading the code to find exact anchors. Two
145
+ field reports found that ~80% of hand-written faults are one of five shapes,
146
+ so `scaffold` proposes them for you:
147
+
148
+ ```bash
149
+ npx testguard-cli scaffold src/auth.ts # → .testguard/scaffold-auth.json (a draft, never your claims file)
150
+ npx testguard-cli scaffold src/auth.ts --claim AUTH-ADMIN # every proposal under one claim; copies it if it exists
151
+ ```
152
+
153
+ | Shape | What it proposes |
154
+ |---|---|
155
+ | `condition-forced` | `if (<guard>) {` → `if (false) {` — a guard is a `!…` condition or one whose body returns, throws or 4xx-es |
156
+ | `statement-deleted` | a single-line guard (`if (…) return …;`) or a state change (`x = …;`) removed |
157
+ | `return-altered` | `return <check>;` (`===`, `.includes(`, `&&`, …) → `return true;` |
158
+ | `literal-changed` | `httpOnly`/`secure` flipped, `sameSite` → `none`, a cost/rounds → `1`, a ttl/tolerance/window/limit ×1000 |
159
+ | `call-removed` | a bare `verify…()` / `validate…()` / `check…()` / `authorize…()` call removed |
160
+
161
+ Every proposal's `find` is the exact line with `expectHits`/`occurrence`
162
+ computed from the file, so it is verifiable by construction; provenance is
163
+ `producer: derived`; `defendedBy` is prefilled from the tests that import
164
+ the module; proposals are grouped under a preceding `@claim <ID>` annotation
165
+ or by enclosing function. Statements are `TODO:` placeholders — a proposal
166
+ becomes a claim only when a human states what it defends. Deterministic
167
+ heuristics, no AST, no LLM; a proposal the tool cannot anchor is never
168
+ emitted.
169
+
138
170
  **Commit `.testguard/baseline.json`; ignore `evidence.json` and `brief.json`.**
139
171
  The baseline is the frozen contract; the other two are regenerated per run.
140
172
 
@@ -160,13 +192,14 @@ through every verdict.
160
192
 
161
193
  ## Status
162
194
 
163
- **v0.1.** Four commands, vitest runner, hand-authored faults. The contract
195
+ **v0.2.** Five commands, vitest runner, hand-authored faults plus a
196
+ mechanical scaffold for the five common shapes. The contract
164
197
  spine — six JSON Schemas shared with the other Guard tools — is under
165
198
  [`spec/`](spec/). One exact-pinned runtime dependency (`ajv`, for schema validation); Node ≥ 20.
166
199
 
167
200
  Not yet: test generation (the two-gate acceptance loop), other runners,
168
- mechanical fault producers, and calibration of fault classes against real
169
- escaped bugs. Each is designed for; none is claimed.
201
+ AST-aware producers, and calibration of fault classes against real escaped
202
+ bugs. Each is designed for; none is claimed.
170
203
 
171
204
  ## Licence
172
205
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testguard-cli",
3
- "version": "0.1.3",
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
@@ -10,6 +10,7 @@ import { probeCommand } from './commands/probe.mjs';
10
10
  import { claimsCommand } from './commands/claims.mjs';
11
11
  import { baselineCommand } from './commands/baseline.mjs';
12
12
  import { briefCommand } from './commands/brief.mjs';
13
+ import { scaffoldCommand } from './commands/scaffold.mjs';
13
14
 
14
15
  const VERSION = JSON.parse(readFileSync(join(fileURLToPath(import.meta.url), '..', '..', 'package.json'), 'utf8')).version;
15
16
 
@@ -19,6 +20,7 @@ const USAGE = `testguard ${VERSION} — proves a test suite defends the claims a
19
20
  testguard probe [dir] inject each claim's faults, run its defenders, report what survived
20
21
  testguard baseline [dir] freeze today's unproven findings so only new ones gate
21
22
  testguard brief [dir] emit the blind-spot block for an agent's session-start context
23
+ testguard scaffold <file> propose faults mechanically for one source file, as a draft claims document
22
24
 
23
25
  probe
24
26
  --claims <path> claims file (default: <dir>/testguard.claims.json)
@@ -31,6 +33,7 @@ probe
31
33
  --claim <ID,ID> probe only these claims; writes .testguard/evidence-partial.json
32
34
  --include-dirty probe the working tree (a snapshot commit) instead of HEAD; uncommitted tests count
33
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
34
37
  --runner-cmd "<cmd>" custom runner; must contain {files} and {out}, e.g. "pnpm vitest run {files} --reporter=json --outputFile={out}"
35
38
  --node-modules <dir> node_modules to link into the scratch worktree (or TESTGUARD_NODE_MODULES)
36
39
  --in-place mutate the working tree instead of a scratch worktree
@@ -38,14 +41,17 @@ probe
38
41
  --no-reuse re-probe claims whose inputs have not changed
39
42
  --quiet suppress the per-fault stream and ranked block; print only the summary and evidence path
40
43
 
44
+ scaffold --claim <ID> (put every proposal under this claim; copies it if it exists) --out <path> --json
45
+ shapes: if-guard → if (false) · single-line guard/mutation removed · return <check> → return true
46
+ · security flag/window/cost literal weakened · verify/validate/check call removed
41
47
  claims --json
42
- baseline --evidence <path> --out <path>
48
+ baseline --evidence <path> --out <path> --allow-provisional (freeze unconfirmed evidence; normally refused)
43
49
  brief --evidence <path> --baseline <path> --max <n> --text (print only; safe for hooks)
44
50
 
45
51
  exit codes: 0 nothing new to prove · 1 unproven claims (or claim drift) · 2 precondition failed · 3 usage
46
52
  `;
47
53
 
48
- const COMMANDS = { probe: probeCommand, claims: claimsCommand, baseline: baselineCommand, brief: briefCommand };
54
+ const COMMANDS = { probe: probeCommand, claims: claimsCommand, baseline: baselineCommand, brief: briefCommand, scaffold: scaffoldCommand };
49
55
 
50
56
  export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n'), err: (s) => process.stderr.write(s + '\n') }) {
51
57
  let parsed;
@@ -65,6 +71,7 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
65
71
  ref: { type: 'string', default: 'HEAD' },
66
72
  claim: { type: 'string' },
67
73
  'include-dirty': { type: 'boolean', default: false },
74
+ 'allow-provisional': { type: 'boolean', default: false },
68
75
  verbose: { type: 'boolean', default: false },
69
76
  'runner-cmd': { type: 'string' },
70
77
  'node-modules': { type: 'string' },
@@ -104,7 +111,8 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
104
111
  return 3;
105
112
  }
106
113
  try {
107
- return await handler({ projectDir: resolve(dirArg ?? '.'), values, version: VERSION }, io);
114
+ const projectDir = command === 'scaffold' ? resolve('.') : resolve(dirArg ?? '.');
115
+ return await handler({ projectDir, file: dirArg, values, version: VERSION }, io);
108
116
  } catch (e) {
109
117
  if (e instanceof ClaimsError || e instanceof PreconditionError || e instanceof GitError || e instanceof SpecDocError) {
110
118
  io.err(`error: ${e.message}`);
@@ -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
  }
@@ -0,0 +1,34 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join, resolve, relative, basename, extname } from 'node:path';
3
+ import { scaffoldFile } from '../scaffold/scaffold.mjs';
4
+ import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
5
+ import { writeSpecDoc } from '../evidence/writer.mjs';
6
+ import { PreconditionError } from '../probe/worktree.mjs';
7
+
8
+ export async function scaffoldCommand({ projectDir, file, values, version }, io) {
9
+ if (!file) {
10
+ io.err('usage: testguard scaffold <source-file> [--claim <ID>] [--out <path>] [--json]');
11
+ return 3;
12
+ }
13
+ const abs = resolve(file);
14
+ if (!existsSync(abs)) throw new PreconditionError(`no such file: ${file}`);
15
+ const rel = relative(projectDir, abs);
16
+ if (rel.startsWith('..')) throw new PreconditionError(`${file} is outside the project directory ${projectDir}`);
17
+
18
+ const claimsPath = values.claims ? resolve(values.claims) : defaultClaimsPath(projectDir);
19
+ const existingClaims = existsSync(claimsPath) ? loadClaims(claimsPath) : undefined;
20
+ const { doc, stats } = scaffoldFile({ projectDir, file: rel, claimId: values.claim, existingClaims, toolVersion: version });
21
+
22
+ if (values.json) {
23
+ io.out(JSON.stringify(doc, null, 2));
24
+ return 0;
25
+ }
26
+ const outPath = values.out ? resolve(values.out) : join(projectDir, '.testguard', `scaffold-${basename(rel, extname(rel))}.json`);
27
+ writeSpecDoc('claims', outPath, doc);
28
+ const shapes = Object.entries(stats.byClass).map(([k, v]) => `${v} ${k}`).join(', ');
29
+ io.out(`${stats.proposals} proposed fault${stats.proposals === 1 ? '' : 's'} in ${stats.claims} draft claim${stats.claims === 1 ? '' : 's'} for ${rel}${shapes ? ` — ${shapes}` : ''}`);
30
+ io.out(stats.defendedBy.length ? `defendedBy prefilled from imports: ${stats.defendedBy.join(', ')}` : 'no test file imports this module; probing the draft as-is will report NOCOVER');
31
+ io.out(`draft: ${outPath}`);
32
+ io.out('Next: replace each TODO statement, drop proposals that are not claims, then move the claims into testguard.claims.json.');
33
+ return 0;
34
+ }
@@ -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. */
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Mechanical fault producers. Deterministic, line-oriented, no AST, no LLM.
3
+ *
4
+ * Two independent field reports found that ~80% of hand-written faults are
5
+ * one of these shapes. Each producer looks at one line (plus a little
6
+ * context) and proposes a fault whose `find` is the exact line, so the anchor
7
+ * hits by construction. A human keeps or drops every proposal.
8
+ */
9
+
10
+ const COMMENT = /^\s*(\/\/|\*|\/\*)/;
11
+ const GUARD_BODY = /\b(return|throw)\b|\.status\(\s*4\d\d|\bredirect\(|\bfalse\b|\bnull\b/;
12
+ const IF_LINE = /^(\s*)(?:\}\s*)?(?:else\s+)?if\s*\((.+)\)\s*(\{\s*|(?:return|throw)\b.*;\s*)?$/;
13
+ const RETURN_CHECK = /^\s*return\s+(.+);\s*$/;
14
+ const CHECK_EXPR = /(===|!==|\.includes\(|\.has\(|\.some\(|\.every\(|\.test\(|\.startsWith\(|\.endsWith\(|\binstanceof\b|&&|\|\||^!)/;
15
+ const CHECK_CALL = /^\s*(?:await\s+)?(?:[\w$]+\.)*(verify|validate|assert|check|require|ensure|authoriz|authentic|rateLimit|throttle|enforce|guard)\w*\s*\(.*\)\s*;\s*$/i;
16
+ const MUTATION = /^\s*(?!(?:const|let|var|return|if|for|while|else|switch|case|import|export|throw)\b)[\w$]+(?:[.\[][\w$'"\]]+)*\s*(=|\+=|-=|\|\|=|&&=|\?\?=)\s*(?!=)[^;]*;\s*$/;
17
+ const FLAG_TRUE = /\b(httpOnly|secure|signed|requireTLS|rejectUnauthorized|strict)\s*:\s*true\b/;
18
+ const SAME_SITE = /\bsameSite\s*:\s*(['"])(strict|lax)\1/i;
19
+ const COST_NUM = /\b(\w*(?:cost|rounds|iterations)\w*)\s*([:=])\s*(\d+)\b/i;
20
+ const WINDOW_NUM = /\b(\w*(?:ttl|expir|tolerance|window|maxAge|max_age|timeout|limit|attempts|length|minLength|clockSkew|skew)\w*)\s*([:=])\s*(\d+)\b/i;
21
+ const FUNCTION_HEAD = [
22
+ /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([\w$]+)\s*\(/,
23
+ /^\s*(?:export\s+)?(?:const|let|var)\s+([\w$]+)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[\w$]+)\s*=>/,
24
+ /^\s*(?:export\s+)?(?:const|let|var)\s+([\w$]+)\s*=\s*(?:async\s+)?function\b/,
25
+ /^\s*(?:public\s+|private\s+|protected\s+)?(?:static\s+)?(?:async\s+)?([\w$]+)\s*\([^)]*\)\s*(?::\s*[\w<>\[\]| ]+)?\s*\{\s*$/,
26
+ ];
27
+ const NOT_A_FUNCTION = new Set(['if', 'for', 'while', 'switch', 'catch', 'else', 'return', 'function', 'constructor']);
28
+
29
+ /** Name of the function whose head is on this line, or null. */
30
+ export function functionHead(line) {
31
+ for (const re of FUNCTION_HEAD) {
32
+ const m = re.exec(line);
33
+ if (m && !NOT_A_FUNCTION.has(m[1])) return m[1];
34
+ }
35
+ return null;
36
+ }
37
+
38
+ function isGuard(cond, lines, i) {
39
+ if (/^\s*!/.test(cond)) return true;
40
+ const tail = lines.slice(i, i + 4).join('\n');
41
+ return GUARD_BODY.test(tail);
42
+ }
43
+
44
+ /** Proposals for one line. Each: { faultClass, description, replace } where find is the line itself. */
45
+ export function proposalsForLine(lines, i) {
46
+ const line = lines[i];
47
+ const out = [];
48
+ if (!line.trim() || COMMENT.test(line)) return out;
49
+
50
+ const ifm = IF_LINE.exec(line);
51
+ if (ifm && isGuard(ifm[2], lines, i)) {
52
+ const singleLine = ifm[3] && /^(return|throw)\b/.test(ifm[3].trim());
53
+ if (singleLine) {
54
+ out.push({ faultClass: 'statement-deleted', description: `Guard removed: \`${line.trim()}\` no longer runs.`, replace: '' });
55
+ } else {
56
+ out.push({ faultClass: 'condition-forced', description: `Guard never triggers: \`if (${ifm[2].trim()})\` becomes \`if (false)\`.`, replace: line.replace(`(${ifm[2]})`, '(false)') });
57
+ }
58
+ return out; // an `if` line is not also a mutation/return/call
59
+ }
60
+
61
+ const rm = RETURN_CHECK.exec(line);
62
+ if (rm && CHECK_EXPR.test(rm[1]) && !/^(new|await)\b/.test(rm[1].trim())) {
63
+ out.push({ faultClass: 'return-altered', description: `Check always passes: \`return ${rm[1].trim()}\` becomes \`return true\`.`, replace: line.replace(/return\s+.+;/, 'return true;') });
64
+ }
65
+
66
+ if (CHECK_CALL.test(line)) {
67
+ out.push({ faultClass: 'call-removed', description: `Check call removed: \`${line.trim()}\` no longer runs.`, replace: '' });
68
+ } else if (MUTATION.test(line)) {
69
+ out.push({ faultClass: 'statement-deleted', description: `State change removed: \`${line.trim()}\` no longer runs.`, replace: '' });
70
+ }
71
+
72
+ let m;
73
+ if ((m = FLAG_TRUE.exec(line))) {
74
+ out.push({ faultClass: 'literal-changed', description: `Security flag flipped: \`${m[1]}: true\` becomes \`${m[1]}: false\`.`, replace: line.replace(m[0], `${m[1]}: false`) });
75
+ }
76
+ if ((m = SAME_SITE.exec(line))) {
77
+ out.push({ faultClass: 'literal-changed', description: `sameSite weakened: \`${m[2]}\` becomes \`none\`.`, replace: line.replace(m[0], `sameSite: ${m[1]}none${m[1]}`) });
78
+ }
79
+ if ((m = COST_NUM.exec(line))) {
80
+ out.push({ faultClass: 'literal-changed', description: `Work factor collapsed: \`${m[1]}\` ${m[3]} becomes 1.`, replace: line.replace(m[0], `${m[1]}${m[2] === ':' ? ': ' : ' = '}1`) });
81
+ } else if ((m = WINDOW_NUM.exec(line))) {
82
+ out.push({ faultClass: 'literal-changed', description: `Window widened ×1000: \`${m[1]}\` ${m[3]} becomes ${Number(m[3]) * 1000}.`, replace: line.replace(m[0], `${m[1]}${m[2] === ':' ? ': ' : ' = '}${Number(m[3]) * 1000}`) });
83
+ }
84
+ return out;
85
+ }
@@ -0,0 +1,118 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { basename, extname, join } from 'node:path';
3
+ import { proposalsForLine, functionHead } from './producers.mjs';
4
+ import { locate } from '../probe/inject.mjs';
5
+ import { discoverDefenders } from '../probe/discover.mjs';
6
+ import { validate } from '../../spec/lib/validate.mjs';
7
+
8
+ const ANNOTATION = /@claim\s+([A-Za-z0-9]+(?:[._]?[A-Za-z0-9]+)*-[A-Za-z0-9._-]*[A-Za-z0-9])\b/;
9
+
10
+ const idPart = (s) => s.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').toUpperCase();
11
+
12
+ /** How many times `find` occurs, and which occurrence the line at `offset` is. */
13
+ function anchorFor(source, find, offset) {
14
+ let hits = 0;
15
+ let occurrence = 0;
16
+ let i = -1;
17
+ while ((i = source.indexOf(find, i + 1)) !== -1) {
18
+ hits++;
19
+ if (i === offset) occurrence = hits;
20
+ }
21
+ return { hits, occurrence };
22
+ }
23
+
24
+ /**
25
+ * Scan one source file and produce a DRAFT claims document: every proposal is
26
+ * an exact-line anchor that `locate()` accepts, grouped by enclosing function
27
+ * (or by a preceding `@claim <ID>` annotation, or entirely under `claimId`),
28
+ * with `producedBy: { producer: "derived" }` and TODO statements a human must
29
+ * replace. Never touches the real claims file.
30
+ */
31
+ export function scaffoldFile({ projectDir, file, claimId, existingClaims, toolVersion = '0.0.0' }) {
32
+ const source = readFileSync(join(projectDir, file), 'utf8');
33
+ const lines = source.split('\n');
34
+ const stem = idPart(basename(file, extname(file)));
35
+ const producedBy = { producer: 'derived', by: `testguard scaffold ${toolVersion}` };
36
+
37
+ const groups = new Map(); // key → { id, proposals[] , annotated }
38
+ const groupFor = (key, id) => {
39
+ if (!groups.has(key)) groups.set(key, { id, proposals: [] });
40
+ return groups.get(key);
41
+ };
42
+
43
+ let offset = 0;
44
+ let fn = null;
45
+ let fnDepth = 0;
46
+ let depth = 0;
47
+ let pendingAnnotation = null;
48
+ const proposals = [];
49
+ lines.forEach((line, i) => {
50
+ const ann = ANNOTATION.exec(line);
51
+ if (ann && /^\s*(\/\/|\*|\/\*)/.test(line)) pendingAnnotation = ann[1];
52
+
53
+ const head = functionHead(line);
54
+ if (head) {
55
+ fn = head;
56
+ fnDepth = depth;
57
+ }
58
+
59
+ for (const p of proposalsForLine(lines, i)) {
60
+ const find = line;
61
+ const { hits, occurrence } = anchorFor(source, find, offset);
62
+ const fault = { ...p, find, replace: p.replace, expectHits: hits, occurrence, line: i + 1, fn, annotation: pendingAnnotation };
63
+ if (locate(source, fault).status !== 'ok') continue; // never propose an anchor that would be unverifiable
64
+ proposals.push(fault);
65
+ }
66
+
67
+ depth += (line.match(/\{/g) ?? []).length - (line.match(/\}/g) ?? []).length;
68
+ if (fn && depth <= fnDepth && !head) fn = null;
69
+ if (pendingAnnotation && !ann && !head && line.trim() && !/^\s*(\/\/|\*|\/\*)/.test(line)) pendingAnnotation = null;
70
+ offset += line.length + 1;
71
+ });
72
+
73
+ const existing = new Map((existingClaims?.claims ?? []).map((c) => [c.id, c]));
74
+ const usedIds = new Set();
75
+ for (const p of proposals) {
76
+ const key = claimId ? '__all__' : p.annotation ?? p.fn ?? '__file__';
77
+ let id = claimId ?? p.annotation ?? `${stem}-${p.fn ? idPart(p.fn) : 'FILE'}`;
78
+ groupFor(key, id).proposals.push(p);
79
+ }
80
+
81
+ const defendedBy = discoverDefenders(projectDir, file);
82
+ const claims = [];
83
+ for (const g of groups.values()) {
84
+ let id = g.id;
85
+ while (usedIds.has(id)) id += '-2';
86
+ usedIds.add(id);
87
+ const base = existing.get(id);
88
+ const fnLabel = g.proposals[0].fn ? `\`${g.proposals[0].fn}\`` : 'this file';
89
+ const claim = {
90
+ id,
91
+ statement: base?.statement ?? `TODO: state what ${fnLabel} in ${file} guarantees (${g.proposals.length} proposed fault${g.proposals.length === 1 ? '' : 's'}; keep or drop each)`,
92
+ source: base?.source ?? { kind: 'manual', ref: `testguard scaffold ${file}` },
93
+ severity: base?.severity ?? 'medium',
94
+ producedBy: base?.producedBy ?? producedBy,
95
+ ...(base?.defendedBy?.length ? { defendedBy: base.defendedBy } : defendedBy.length ? { defendedBy } : {}),
96
+ faults: g.proposals.map((p, n) => ({
97
+ id: `S${n + 1}`,
98
+ description: `[line ${p.line}] ${p.description}`,
99
+ faultClass: p.faultClass,
100
+ file,
101
+ find: p.find,
102
+ replace: p.replace,
103
+ ...(p.expectHits > 1 ? { expectHits: p.expectHits, occurrence: p.occurrence } : {}),
104
+ producedBy,
105
+ })),
106
+ ...(base?.tags ? { tags: base.tags } : {}),
107
+ };
108
+ claims.push(claim);
109
+ }
110
+
111
+ const doc = { $schema: './node_modules/testguard-cli/spec/schemas/claims.schema.json', schemaVersion: 1, claims };
112
+ const result = validate('claims', doc);
113
+ if (!result.ok) throw new Error(`scaffold produced a non-conforming draft:\n${result.errors.map((e) => ` ${e.path}: ${e.message}`).join('\n')}`);
114
+
115
+ const byClass = {};
116
+ for (const p of proposals) byClass[p.faultClass] = (byClass[p.faultClass] ?? 0) + 1;
117
+ return { doc, stats: { proposals: proposals.length, claims: claims.length, byClass, defendedBy } };
118
+ }