testguard-cli 0.12.0 → 0.14.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.
@@ -38,6 +38,7 @@
38
38
  */
39
39
  import { readFileSync } from 'node:fs';
40
40
  import { join } from 'node:path';
41
+ import { onWritePath } from './select.mjs';
41
42
 
42
43
  /**
43
44
  * Module specifiers that mean "the database". A project-local path counts when
@@ -158,6 +159,25 @@ export function persistenceSignals(projectDir, defenders) {
158
159
  return defenders.map((d) => analyzePersistence(projectDir, d)).filter(Boolean);
159
160
  }
160
161
 
162
+ /**
163
+ * The persistence signals ONE EVIDENCE RECORD may carry, or none.
164
+ *
165
+ * The rule the sweep document already enforces, now applied where the record
166
+ * is written: the signal explains a SURVIVOR whose fault sits on the write
167
+ * path, and nothing else. On a kill it explains nothing; off the write path a
168
+ * loose mock assertion is not evidence of anything; on `nocover` or
169
+ * `unverifiable` the question was never asked. Reporting it anywhere else is
170
+ * the non-actionable noise that gets a check switched off.
171
+ *
172
+ * `analyze` is injected so the rule is testable without a filesystem; the
173
+ * probe passes the scratch tree, where the defenders are the project's own.
174
+ */
175
+ export function persistenceSignalsFor({ verdict, fault, defenders, projectDir }, analyze = persistenceSignals) {
176
+ if (verdict !== 'survived') return [];
177
+ if (!onWritePath(fault)) return [];
178
+ return analyze(projectDir, defenders);
179
+ }
180
+
161
181
  /**
162
182
  * One line an agent can act on, in the register `hintFor()` uses.
163
183
  *
@@ -171,3 +191,56 @@ export function persistenceHint(signal, faultLine) {
171
191
  const field = /^\s*([A-Za-z_$][\w$]*)\s*(?::|,\s*$)/.exec(faultLine ?? '')?.[1];
172
192
  return `${signal.file} ${signal.reason} Assert the whole object written by the call this fault changes${field ? `, including \`${field}\`` : ''} — or read the record back and assert on what was stored.`;
173
193
  }
194
+
195
+ /**
196
+ * What this claim's defenders are CAPABLE of proving about a write.
197
+ *
198
+ * The read-back question — "did the row actually land?" — cannot be answered by
199
+ * a test that replaced the database with a mock. Such a test can prove the call
200
+ * shape and nothing beyond it: the `where` may match no rows, the transaction
201
+ * may roll back, a constraint may reject the write, and every one of those
202
+ * passes against a mock that recorded the arguments and returned a plausible
203
+ * object.
204
+ *
205
+ * So rather than pretend to measure persistence, this states the LIMIT. It is
206
+ * the same discipline as `nocover` — which says "no test imports this" instead
207
+ * of guessing — one level finer: these tests exist, they run, and there is a
208
+ * class of failure they structurally cannot see.
209
+ *
210
+ * `mocked` every defender replaces the persistence layer. Call shape only.
211
+ * `unmocked` at least one does not, so a real write is at least possible.
212
+ * NOT a promise that one happens: a test may simply never reach
213
+ * the database. Possible is the honest word.
214
+ * `none` no defender at all; `nocover` already covers this.
215
+ *
216
+ * Measured on a real AI-authored application: of 37 files that write to
217
+ * storage, 32 were defended only by mocking tests, 4 had no defender, and 1
218
+ * had an unmocked defender — a mailer, not a database write. Nothing in that
219
+ * suite could prove any save persisted.
220
+ */
221
+ export function persistenceProvability(projectDir, defenders) {
222
+ if (!defenders || defenders.length === 0) return { class: 'none', mocking: [], unmocked: [] };
223
+ const mocking = [];
224
+ const unmocked = [];
225
+ for (const d of defenders) (analyzePersistence(projectDir, d) ? mocking : unmocked).push(d);
226
+ return { class: unmocked.length ? 'unmocked' : 'mocked', mocking, unmocked };
227
+ }
228
+
229
+ /**
230
+ * The provability of a whole surface, as counts. The headline a save-path
231
+ * report opens with, because "how many of these could be proven at all" is a
232
+ * different question from "how many were probed" and a far more alarming one.
233
+ */
234
+ export function provabilitySummary(projectDir, targets, defendersOf) {
235
+ const counts = { mocked: 0, unmocked: 0, none: 0 };
236
+ for (const file of targets) {
237
+ let defenders = [];
238
+ try {
239
+ defenders = defendersOf(file);
240
+ } catch {
241
+ defenders = [];
242
+ }
243
+ counts[persistenceProvability(projectDir, defenders).class] += 1;
244
+ }
245
+ return counts;
246
+ }
@@ -89,6 +89,35 @@ export function onWritePath(fault) {
89
89
  return PERSISTENCE_CALL.test(line) || PAYLOAD_KEY.test(line) || PERSISTENCE_CALL.test(fault.description ?? '');
90
90
  }
91
91
 
92
+ /**
93
+ * Is this proposal a purely presentational JSX element — the arid class this
94
+ * tool actually has?
95
+ *
96
+ * Google suppresses "arid" nodes nobody would write a test for (logging,
97
+ * timeouts, flags). Measured here on 2026-09-22, those account for under 1% of
98
+ * proposals: the producers are shape-targeted and rarely land on a log line.
99
+ * What IS material is this: on a 14-file UI diff, 150 of 510 proposals were an
100
+ * `element-removed` on an icon (`<X />`) or a static wrapper (`<div>`, `<p>`
101
+ * with no expression and no handler). The ranker spent 29 of 30 slots on that
102
+ * class, 7 of the 10 survivors were of this shape, and because the ordering
103
+ * learns from survival those survivors then teach it to rank the class higher.
104
+ *
105
+ * Line-oriented, like every producer. An icon is a self-closing PascalCase
106
+ * element whose props are only sizing and styling; a static wrapper is a
107
+ * layout or text tag whose own line carries no `{expression}` and no
108
+ * interactive or test-facing attribute. Anything else — a component, an
109
+ * `<img>`, a `<label>`, an element with a handler or dynamic children — is a
110
+ * real proposal and stays.
111
+ */
112
+ const ICON_ELEMENT = /^\s*<[A-Z]\w*(?:\s+(?:size|className|strokeWidth|aria-hidden|color|width|height|fill)(?:=(?:"[^"]*"|\{[^}]*\}))?)*\s*\/>\s*$/;
113
+ const WRAPPER_ELEMENT = /^\s*<(?:div|span|p|h[1-6]|section|header|footer|main|nav|ul|ol|li|small|strong|em|b|i|hr|br)\b/;
114
+ const INTERACTIVE_ATTR = /\b(?:on[A-Z]\w*=|href=|role=|aria-(?!hidden)\w+=|data-testid=|htmlFor=|tabIndex=)/;
115
+ export function presentational(fault) {
116
+ if (fault.faultClass !== 'element-removed') return false;
117
+ const line = fault.find ?? '';
118
+ return ICON_ELEMENT.test(line) || (WRAPPER_ELEMENT.test(line) && !line.includes('{') && !INTERACTIVE_ATTR.test(line));
119
+ }
120
+
92
121
  /** The boost a write-path fault gets. Additive, not multiplicative: it must not be able to overtake a measured 1.0. */
93
122
  const WRITE_PATH_BONUS = 0.25;
94
123
 
@@ -109,13 +138,17 @@ export const capFor = (fileCount, perFile = 7) => Math.max(perFile, fileCount *
109
138
  *
110
139
  * `candidates` are `{ claim, fault }` pairs — the claim is carried so the
111
140
  * caller can rebuild a claims document from the selection without a second
112
- * lookup. Returns the selection, everything deferred, and the per-class tally
113
- * a report needs to explain itself.
141
+ * lookup. Returns the selection, everything deferred, everything set aside as
142
+ * presentational, and the per-class tally a report needs to explain itself.
143
+ * The three lists partition the candidates: nothing is dropped without a name.
114
144
  */
115
145
  export function selectFaults(candidates, { cap, table } = {}) {
116
146
  const limit = Number.isInteger(cap) && cap > 0 ? cap : capFor(new Set(candidates.map((c) => c.fault.file)).size);
117
- const ranked = candidates
118
- .map((c) => ({ ...c, score: scoreOf(c.fault, { table }), writePath: onWritePath(c.fault) }))
147
+ const all = candidates.map((c) => ({ ...c, score: scoreOf(c.fault, { table }), writePath: onWritePath(c.fault) }));
148
+ // Set aside, never silently: a presentational element is not worth a probe
149
+ // run, and a survivor on one would teach the ordering the wrong lesson.
150
+ const presentationalList = all.filter((c) => presentational(c.fault));
151
+ const ranked = all.filter((c) => !presentational(c.fault))
119
152
  // Deterministic to the last tie: score, then file, then line, then id.
120
153
  // Two runs of the same sweep on the same tree are the same sweep.
121
154
  .sort((a, b) =>
@@ -158,11 +191,11 @@ export function selectFaults(candidates, { cap, table } = {}) {
158
191
  const taken = new Set(selected);
159
192
  const deferred = ranked.filter((r) => !taken.has(r));
160
193
  const byClass = {};
161
- for (const r of ranked) {
194
+ for (const r of all) {
162
195
  const e = (byClass[r.fault.faultClass] ??= { proposed: 0, selected: 0, writePath: 0 });
163
196
  e.proposed += 1;
164
197
  if (r.writePath) e.writePath += 1;
165
198
  }
166
199
  for (const r of selected) byClass[r.fault.faultClass].selected += 1;
167
- return { selected, deferred, cap: limit, byClass };
200
+ return { selected, deferred, presentational: presentationalList, cap: limit, byClass };
168
201
  }
@@ -35,9 +35,12 @@ import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
35
35
  import { computeChangedGate } from '../gate/changed.mjs';
36
36
  import { scaffoldFile } from '../scaffold/scaffold.mjs';
37
37
  import { selectFaults, onWritePath, capFor } from '../supply/select.mjs';
38
+ import { learnedProductivity } from '../supply/feedback.mjs';
39
+ import { loadConcerns, concernById, targetsFor, filterByConcern } from '../supply/concerns.mjs';
38
40
  import { saveSurface } from '../supply/savepath.mjs';
39
- import { persistenceSignals, persistenceHint } from '../supply/persistence.mjs';
41
+ import { persistenceHint, provabilitySummary } from '../supply/persistence.mjs';
40
42
  import { probe } from '../probe/probe.mjs';
43
+ import { discoverDefenders } from '../probe/discover.mjs';
41
44
  import { hintFor } from '../brief/brief.mjs';
42
45
 
43
46
  /**
@@ -106,6 +109,30 @@ export function sortFindings(findings) {
106
109
  /** How many findings a sweep is willing to fail on. */
107
110
  export const gatingCount = (findings) => findings.filter((f) => GATING.has(f.verdict)).length;
108
111
 
112
+ /**
113
+ * The `selection` block of the document: every proposal accounted for.
114
+ *
115
+ * Pure and exported for the same reason as `findingFrom`: the arithmetic that
116
+ * proves nothing was hidden — proposed = probed + deferred + set aside — is a
117
+ * rule you can state in one line, and the validator that checks it is not the
118
+ * engine that has to satisfy it.
119
+ */
120
+ export function summarizeSelection(selection, { proposed, outOfScope = 0 }) {
121
+ const presentationalCount = selection.presentational?.length ?? 0;
122
+ return {
123
+ proposed,
124
+ ...(outOfScope ? { outOfScope } : {}),
125
+ selected: selection.selected.length,
126
+ deferred: selection.deferred.length,
127
+ ...(presentationalCount ? { presentational: presentationalCount } : {}),
128
+ cap: selection.cap,
129
+ byClass: selection.byClass,
130
+ };
131
+ }
132
+
133
+ /** A concern named on the command line that this project does not declare. */
134
+ export class ConcernError extends Error {}
135
+
109
136
  /** Rebuild claim documents from a flat selection, keeping each claim's own faults together. */
110
137
  function claimsFromSelection(selected) {
111
138
  const byClaim = new Map();
@@ -128,6 +155,8 @@ export async function sweep({
128
155
  projectDir,
129
156
  ref,
130
157
  mode = 'changed',
158
+ concern: concernId,
159
+ concernsPath,
131
160
  includeDirty = false,
132
161
  exclude = [],
133
162
  cap,
@@ -154,10 +183,23 @@ export async function sweep({
154
183
  //
155
184
  // A test file with no claim is a different problem — it has nothing to
156
185
  // falsify — and the gate already names it. Sweep proposes against source.
157
- const surface = mode === 'save-paths' ? saveSurface(projectDir) : null;
158
- const targets = surface
159
- ? surface.files.map((f) => f.file)
160
- : gate.uncovered.filter((u) => u.kind === 'source').map((u) => u.file);
186
+ // `--save-paths` is sugar for the built-in SAVE-PERSISTS concern: the same
187
+ // targets and the same producer selection, named so a project can retune it
188
+ // for an ORM this tool does not recognise without forking anything.
189
+ const loaded = loadConcerns(projectDir, { path: concernsPath });
190
+ const concern = concernId
191
+ ? concernById(loaded.concerns, concernId)
192
+ : mode === 'save-paths' ? concernById(loaded.concerns, 'SAVE-PERSISTS') : null;
193
+ if (concernId && !concern) {
194
+ throw new ConcernError(`unknown concern ${concernId}. Declared: ${loaded.concerns.map((c) => c.id).join(', ')}`);
195
+ }
196
+ const changedTargets = gate.uncovered.filter((u) => u.kind === 'source').map((u) => u.file);
197
+ // A concern whose targets are the diff defers to the gate rather than
198
+ // re-deriving what it already computed.
199
+ const concernTargets = concern ? targetsFor(projectDir, concern, { changedFiles: changedTargets }) : null;
200
+ const usesSurface = concern?.targets?.kind === 'write-sites' || mode === 'save-paths';
201
+ const surface = usesSurface ? saveSurface(projectDir) : null;
202
+ const targets = concernTargets ?? changedTargets;
161
203
 
162
204
  const cPath = claimsPath ?? defaultClaimsPath(projectDir);
163
205
  const existingClaims = existsSync(cPath) ? loadClaims(cPath) : { claims: [] };
@@ -184,8 +226,27 @@ export async function sweep({
184
226
  for (const claim of result.doc.claims) for (const fault of claim.faults) candidates.push({ claim, fault });
185
227
  }
186
228
 
229
+ // What the defenders of this surface are CAPABLE of proving, before anything
230
+ // is probed. A test that replaced the database can prove the call shape and
231
+ // never that the row landed, and that limit is a property of the suite rather
232
+ // than of any verdict — so it is measured once, over the whole surface, and
233
+ // reported whether or not a single fault survives.
234
+ const provability = surface
235
+ ? provabilitySummary(projectDir, targets, (f) => discoverDefenders(projectDir, f))
236
+ : null;
237
+
238
+ // Order by what THIS project's own runs have shown, not only by what was
239
+ // measured elsewhere. A project that has probed nothing falls back to the
240
+ // shipped prior; one with its own records overrides it in proportion to how
241
+ // many it has. This is the feedback half of Google's 15%-to-89%, using data
242
+ // the project already produced rather than a new thing to collect.
243
+ const learned = learnedProductivity(projectDir);
244
+ // Narrowing is what makes one sentence useful across two hundred files: a
245
+ // persistence concern that also reported every altered return value would
246
+ // bury the payload findings it exists to surface.
247
+ const relevant = filterByConcern(candidates, concern);
187
248
  const limit = cap ?? capFor(targets.length);
188
- const selection = selectFaults(candidates, { cap: limit });
249
+ const selection = selectFaults(relevant, { cap: limit, table: learned.table });
189
250
  const claims = claimsFromSelection(selection.selected);
190
251
 
191
252
  let evidence = { records: [] };
@@ -214,7 +275,10 @@ export async function sweep({
214
275
  // second scan; `line` and `find` live on the draft, not on the evidence.
215
276
  const draftOf = new Map(selection.selected.map(({ claim, fault }) => [`${claim.id}/${fault.id}`, fault]));
216
277
 
217
- const signalsFor = (r) => persistenceSignals(projectDir, r.defenders?.resolved ?? []);
278
+ // The probe attaches the persistence signal to the record itself now
279
+ // (claimspec v1 evidence, `defenders.signals`); the finding repeats what the
280
+ // record says rather than re-deriving it, so the two can never disagree.
281
+ const signalsFor = (r) => (r.defenders?.signals ?? []).filter((s) => s.signal === 'persistence-payload-unasserted');
218
282
  const counts = {};
219
283
  const findings = [];
220
284
  for (const r of evidence.records) {
@@ -234,20 +298,21 @@ export async function sweep({
234
298
  includeDirty,
235
299
  scope: {
236
300
  mode,
301
+ ...(concern ? { concern: concern.id } : {}),
237
302
  changed: gate.changed,
238
- ...(surface ? { writeSites: surface.siteCount, payloadFields: surface.keyCount } : {}),
303
+ ...(surface ? { writeSites: surface.siteCount, payloadFields: surface.keyCount, provability } : {}),
239
304
  targets: targets.length,
240
305
  swept: drafts.length,
241
306
  ...(skipped.length ? { skipped } : {}),
242
307
  },
243
- selection: {
244
- proposed: candidates.length,
245
- selected: selection.selected.length,
246
- deferred: selection.deferred.length,
247
- cap: selection.cap,
248
- byClass: selection.byClass,
249
- },
308
+ selection: summarizeSelection(selection, { proposed: relevant.length, outOfScope: candidates.length - relevant.length }),
250
309
  counts,
310
+ ordering: { observed: learned.observed, sources: learned.sources },
311
+ // Carried, not written here: the caller owns I/O. Persisting it is what
312
+ // closes the feedback loop — the next sweep learns from these verdicts,
313
+ // which are MACHINE-proposed faults and so the closest observation of the
314
+ // distribution the ranker orders.
315
+ evidence,
251
316
  findings: ordered,
252
317
  exitCode: gating > 0 ? 1 : 0,
253
318
  drafts,
@@ -262,16 +327,47 @@ export function renderSweep(doc, { limit = 20 } = {}) {
262
327
  if (scope.targets === 0) {
263
328
  out.push(saves
264
329
  ? 'sweep: no write to storage found in this project. Nothing to propose.'
265
- : `sweep: no changed source file is without a claim against ${doc.ref}. Nothing to propose.`);
330
+ : scope.concern
331
+ ? `sweep: concern ${scope.concern} matches no file in this project. Nothing to propose.`
332
+ : `sweep: no changed source file is without a claim against ${doc.ref}. Nothing to propose.`);
266
333
  return out.join('\n');
267
334
  }
268
335
  // The denominator first. A report that lists findings without saying how much
269
336
  // was looked at invites the reader to assume the rest is fine.
337
+ // Say how the sweep was aimed. A glob concern that reports "unclaimed changed
338
+ // files" is describing a diff it never looked at.
270
339
  out.push(saves
271
340
  ? `${scope.writeSites} write${scope.writeSites === 1 ? '' : 's'} to storage across ${scope.targets} file${scope.targets === 1 ? '' : 's'}, carrying ${scope.payloadFields} payload field${scope.payloadFields === 1 ? '' : 's'}. Swept ${scope.swept}.`
272
- : `swept ${scope.swept} of ${scope.targets} unclaimed changed file${scope.targets === 1 ? '' : 's'} against ${doc.ref}.`);
341
+ : scope.concern
342
+ ? `concern ${scope.concern} matches ${scope.targets} file${scope.targets === 1 ? '' : 's'}. Swept ${scope.swept}.`
343
+ : `swept ${scope.swept} of ${scope.targets} unclaimed changed file${scope.targets === 1 ? '' : 's'} against ${doc.ref}.`);
344
+ if (saves && scope.provability) {
345
+ // The limit, before any verdict. A test that replaced the database can
346
+ // prove the call shape and never that the row landed, so on a surface
347
+ // defended entirely by such tests a clean probe is not evidence of
348
+ // persistence — it is evidence that nothing could have measured it.
349
+ const { mocked, unmocked, none } = scope.provability;
350
+ out.push(` of those ${scope.targets} file${scope.targets === 1 ? '' : 's'}: ${mocked} defended only by tests that mock the persistence layer, ${unmocked} with an unmocked defender, ${none} with no defender at all.`);
351
+ if (unmocked === 0 && mocked > 0) {
352
+ out.push(' Nothing in this suite can prove a write reached storage. A mocked test proves the');
353
+ out.push(' call shape; a where-clause that matches nothing, a rolled-back transaction and a');
354
+ out.push(' rejected constraint all pass against it.');
355
+ }
356
+ }
273
357
  out.push(`proposed ${selection.proposed} fault${selection.proposed === 1 ? '' : 's'}, probed ${selection.selected} (cap ${selection.cap})${selection.deferred ? `, deferred ${selection.deferred}` : ''}.`);
274
- if (selection.deferred) out.push(` The ${selection.deferred} deferred are not a verdict: raise --cap${saves ? '.' : ', or sweep a smaller change.'}`);
358
+ if (selection.deferred) out.push(` The ${selection.deferred} deferred are not a verdict: raise --cap${saves || scope.concern ? '.' : ', or sweep a smaller change.'}`);
359
+ // A narrow concern must not look like a quiet one: the producers found these,
360
+ // and this concern is not about them.
361
+ if (selection.outOfScope) out.push(` ${selection.outOfScope} further proposal${selection.outOfScope === 1 ? ' was' : 's were'} outside this concern's fault classes.`);
362
+ // The arid class this tool has. Set aside and said so: a count nobody prints
363
+ // is a proposal nobody made.
364
+ if (selection.presentational) out.push(` ${selection.presentational} presentational element${selection.presentational === 1 ? '' : 's'} (icons, static wrappers) set aside: nobody writes a test for those, and a survivor there would teach the ordering the wrong lesson.`);
365
+ // An ordering nobody can trace is a number nobody should trust.
366
+ if (doc.ordering) {
367
+ out.push(doc.ordering.observed === 0
368
+ ? ' ordering: the shipped productivity prior — this project has no probed evidence yet.'
369
+ : ` ordering: ${doc.ordering.observed} probed fault${doc.ordering.observed === 1 ? '' : 's'} of this project's own (${doc.ordering.sources.join(', ')}), shrunk toward the shipped prior.`);
370
+ }
275
371
  for (const s of scope.skipped ?? []) out.push(` skipped ${s.file}: ${s.reason}`);
276
372
  out.push('');
277
373
 
package/src/util/glob.mjs CHANGED
@@ -11,7 +11,11 @@ import { join } from 'node:path';
11
11
  // the application — including its writes and its tests — so a surface scan of a
12
12
  // real app counted the same code twice and a defender glob could match a
13
13
  // compiled chunk. The promise was right and the list was short.
14
- const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', '.testguard',
14
+ // `.claude` holds agent tooling and, under `.claude/worktrees/`, whole nested
15
+ // checkouts of OTHER branches. Measured: discovery on a real repository returned
16
+ // another branch's test file as a defender — a file the scratch tree cannot
17
+ // even contain, because the directory is gitignored.
18
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', '.testguard', '.claude',
15
19
  '.next', '.nuxt', '.svelte-kit', '.output', '.turbo', '.parcel-cache', '.angular', '.astro',
16
20
  'build', 'out', '.cache', '.vercel', '.netlify',
17
21
  '__pycache__', '.venv', 'venv', 'site-packages', '.tox', '.nox',