testguard-cli 0.11.0 → 0.13.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.
@@ -35,8 +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 { persistenceSignals, persistenceHint } from '../supply/persistence.mjs';
38
+ import { learnedProductivity } from '../supply/feedback.mjs';
39
+ import { loadConcerns, concernById, targetsFor, filterByConcern } from '../supply/concerns.mjs';
40
+ import { saveSurface } from '../supply/savepath.mjs';
41
+ import { persistenceSignals, persistenceHint, provabilitySummary } from '../supply/persistence.mjs';
39
42
  import { probe } from '../probe/probe.mjs';
43
+ import { discoverDefenders } from '../probe/discover.mjs';
40
44
  import { hintFor } from '../brief/brief.mjs';
41
45
 
42
46
  /**
@@ -105,6 +109,9 @@ export function sortFindings(findings) {
105
109
  /** How many findings a sweep is willing to fail on. */
106
110
  export const gatingCount = (findings) => findings.filter((f) => GATING.has(f.verdict)).length;
107
111
 
112
+ /** A concern named on the command line that this project does not declare. */
113
+ export class ConcernError extends Error {}
114
+
108
115
  /** Rebuild claim documents from a flat selection, keeping each claim's own faults together. */
109
116
  function claimsFromSelection(selected) {
110
117
  const byClaim = new Map();
@@ -126,6 +133,9 @@ function claimsFromSelection(selected) {
126
133
  export async function sweep({
127
134
  projectDir,
128
135
  ref,
136
+ mode = 'changed',
137
+ concern: concernId,
138
+ concernsPath,
129
139
  includeDirty = false,
130
140
  exclude = [],
131
141
  cap,
@@ -142,9 +152,33 @@ export async function sweep({
142
152
  onWarn = () => {},
143
153
  }) {
144
154
  const gate = computeChangedGate({ projectDir, ref, includeDirty, exclude, toolVersion, claimsPath, ignorePath });
155
+
156
+ // ── What the sweep is pointed at. ──
157
+ // `changed` asks "what did this change leave unclaimed". `save-paths` asks a
158
+ // question the diff cannot: "of every place this project writes to storage,
159
+ // how many would notice if the write stopped carrying a field". The second
160
+ // needs the whole surface, because a clean report over an unstated
161
+ // denominator is a sample of unknown size, not a guarantee.
162
+ //
145
163
  // A test file with no claim is a different problem — it has nothing to
146
164
  // falsify — and the gate already names it. Sweep proposes against source.
147
- const targets = gate.uncovered.filter((u) => u.kind === 'source').map((u) => u.file);
165
+ // `--save-paths` is sugar for the built-in SAVE-PERSISTS concern: the same
166
+ // targets and the same producer selection, named so a project can retune it
167
+ // for an ORM this tool does not recognise without forking anything.
168
+ const loaded = loadConcerns(projectDir, { path: concernsPath });
169
+ const concern = concernId
170
+ ? concernById(loaded.concerns, concernId)
171
+ : mode === 'save-paths' ? concernById(loaded.concerns, 'SAVE-PERSISTS') : null;
172
+ if (concernId && !concern) {
173
+ throw new ConcernError(`unknown concern ${concernId}. Declared: ${loaded.concerns.map((c) => c.id).join(', ')}`);
174
+ }
175
+ const changedTargets = gate.uncovered.filter((u) => u.kind === 'source').map((u) => u.file);
176
+ // A concern whose targets are the diff defers to the gate rather than
177
+ // re-deriving what it already computed.
178
+ const concernTargets = concern ? targetsFor(projectDir, concern, { changedFiles: changedTargets }) : null;
179
+ const usesSurface = concern?.targets?.kind === 'write-sites' || mode === 'save-paths';
180
+ const surface = usesSurface ? saveSurface(projectDir) : null;
181
+ const targets = concernTargets ?? changedTargets;
148
182
 
149
183
  const cPath = claimsPath ?? defaultClaimsPath(projectDir);
150
184
  const existingClaims = existsSync(cPath) ? loadClaims(cPath) : { claims: [] };
@@ -171,8 +205,27 @@ export async function sweep({
171
205
  for (const claim of result.doc.claims) for (const fault of claim.faults) candidates.push({ claim, fault });
172
206
  }
173
207
 
208
+ // What the defenders of this surface are CAPABLE of proving, before anything
209
+ // is probed. A test that replaced the database can prove the call shape and
210
+ // never that the row landed, and that limit is a property of the suite rather
211
+ // than of any verdict — so it is measured once, over the whole surface, and
212
+ // reported whether or not a single fault survives.
213
+ const provability = surface
214
+ ? provabilitySummary(projectDir, targets, (f) => discoverDefenders(projectDir, f))
215
+ : null;
216
+
217
+ // Order by what THIS project's own runs have shown, not only by what was
218
+ // measured elsewhere. A project that has probed nothing falls back to the
219
+ // shipped prior; one with its own records overrides it in proportion to how
220
+ // many it has. This is the feedback half of Google's 15%-to-89%, using data
221
+ // the project already produced rather than a new thing to collect.
222
+ const learned = learnedProductivity(projectDir);
223
+ // Narrowing is what makes one sentence useful across two hundred files: a
224
+ // persistence concern that also reported every altered return value would
225
+ // bury the payload findings it exists to surface.
226
+ const relevant = filterByConcern(candidates, concern);
174
227
  const limit = cap ?? capFor(targets.length);
175
- const selection = selectFaults(candidates, { cap: limit });
228
+ const selection = selectFaults(relevant, { cap: limit, table: learned.table });
176
229
  const claims = claimsFromSelection(selection.selected);
177
230
 
178
231
  let evidence = { records: [] };
@@ -220,19 +273,29 @@ export async function sweep({
220
273
  head: gate.head,
221
274
  includeDirty,
222
275
  scope: {
276
+ mode,
277
+ ...(concern ? { concern: concern.id } : {}),
223
278
  changed: gate.changed,
279
+ ...(surface ? { writeSites: surface.siteCount, payloadFields: surface.keyCount, provability } : {}),
224
280
  targets: targets.length,
225
281
  swept: drafts.length,
226
282
  ...(skipped.length ? { skipped } : {}),
227
283
  },
228
284
  selection: {
229
- proposed: candidates.length,
285
+ proposed: relevant.length,
286
+ ...(candidates.length !== relevant.length ? { outOfScope: candidates.length - relevant.length } : {}),
230
287
  selected: selection.selected.length,
231
288
  deferred: selection.deferred.length,
232
289
  cap: selection.cap,
233
290
  byClass: selection.byClass,
234
291
  },
235
292
  counts,
293
+ ordering: { observed: learned.observed, sources: learned.sources },
294
+ // Carried, not written here: the caller owns I/O. Persisting it is what
295
+ // closes the feedback loop — the next sweep learns from these verdicts,
296
+ // which are MACHINE-proposed faults and so the closest observation of the
297
+ // distribution the ranker orders.
298
+ evidence,
236
299
  findings: ordered,
237
300
  exitCode: gating > 0 ? 1 : 0,
238
301
  drafts,
@@ -243,13 +306,48 @@ export async function sweep({
243
306
  export function renderSweep(doc, { limit = 20 } = {}) {
244
307
  const out = [];
245
308
  const { scope, selection } = doc;
309
+ const saves = scope.mode === 'save-paths';
246
310
  if (scope.targets === 0) {
247
- out.push(`sweep: no changed source file is without a claim against ${doc.ref}. Nothing to propose.`);
311
+ out.push(saves
312
+ ? 'sweep: no write to storage found in this project. Nothing to propose.'
313
+ : scope.concern
314
+ ? `sweep: concern ${scope.concern} matches no file in this project. Nothing to propose.`
315
+ : `sweep: no changed source file is without a claim against ${doc.ref}. Nothing to propose.`);
248
316
  return out.join('\n');
249
317
  }
250
- out.push(`swept ${scope.swept} of ${scope.targets} unclaimed changed file${scope.targets === 1 ? '' : 's'} against ${doc.ref}.`);
318
+ // The denominator first. A report that lists findings without saying how much
319
+ // was looked at invites the reader to assume the rest is fine.
320
+ // Say how the sweep was aimed. A glob concern that reports "unclaimed changed
321
+ // files" is describing a diff it never looked at.
322
+ out.push(saves
323
+ ? `${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}.`
324
+ : scope.concern
325
+ ? `concern ${scope.concern} matches ${scope.targets} file${scope.targets === 1 ? '' : 's'}. Swept ${scope.swept}.`
326
+ : `swept ${scope.swept} of ${scope.targets} unclaimed changed file${scope.targets === 1 ? '' : 's'} against ${doc.ref}.`);
327
+ if (saves && scope.provability) {
328
+ // The limit, before any verdict. A test that replaced the database can
329
+ // prove the call shape and never that the row landed, so on a surface
330
+ // defended entirely by such tests a clean probe is not evidence of
331
+ // persistence — it is evidence that nothing could have measured it.
332
+ const { mocked, unmocked, none } = scope.provability;
333
+ 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.`);
334
+ if (unmocked === 0 && mocked > 0) {
335
+ out.push(' Nothing in this suite can prove a write reached storage. A mocked test proves the');
336
+ out.push(' call shape; a where-clause that matches nothing, a rolled-back transaction and a');
337
+ out.push(' rejected constraint all pass against it.');
338
+ }
339
+ }
251
340
  out.push(`proposed ${selection.proposed} fault${selection.proposed === 1 ? '' : 's'}, probed ${selection.selected} (cap ${selection.cap})${selection.deferred ? `, deferred ${selection.deferred}` : ''}.`);
252
- if (selection.deferred) out.push(` The ${selection.deferred} deferred are not a verdict: raise --cap, or sweep a smaller change.`);
341
+ if (selection.deferred) out.push(` The ${selection.deferred} deferred are not a verdict: raise --cap${saves || scope.concern ? '.' : ', or sweep a smaller change.'}`);
342
+ // A narrow concern must not look like a quiet one: the producers found these,
343
+ // and this concern is not about them.
344
+ if (selection.outOfScope) out.push(` ${selection.outOfScope} further proposal${selection.outOfScope === 1 ? ' was' : 's were'} outside this concern's fault classes.`);
345
+ // An ordering nobody can trace is a number nobody should trust.
346
+ if (doc.ordering) {
347
+ out.push(doc.ordering.observed === 0
348
+ ? ' ordering: the shipped productivity prior — this project has no probed evidence yet.'
349
+ : ` 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.`);
350
+ }
253
351
  for (const s of scope.skipped ?? []) out.push(` skipped ${s.file}: ${s.reason}`);
254
352
  out.push('');
255
353
 
@@ -268,6 +366,12 @@ export function renderSweep(doc, { limit = 20 } = {}) {
268
366
  }
269
367
  if (doc.findings.length > limit) out.push(` … ${doc.findings.length - limit} more in the sweep document`);
270
368
  }
369
+ if (saves) {
370
+ out.push('');
371
+ out.push(`This is a sample of the write surface, not a verdict over it: ${selection.selected} of`);
372
+ out.push(`${selection.proposed} proposed faults were probed. The ${scope.writeSites} writes above are the`);
373
+ out.push('denominator — a clean sweep says nothing about the ones nobody pointed a fault at.');
374
+ }
271
375
  out.push('These are PROPOSALS, not claims. Keep the ones worth defending: state the');
272
376
  out.push('claim, copy its fault into testguard.claims.json, and probe it from then on.');
273
377
  return out.join('\n');
package/src/util/glob.mjs CHANGED
@@ -5,7 +5,15 @@ import { join } from 'node:path';
5
5
  // virtualenv inside the project holds thousands of `test_*.py` files belonging
6
6
  // to installed packages, and without skipping it every one of them would be
7
7
  // collected as a test file of the project under probe.
8
+ // `dist` and `coverage` were here from the start; the framework build outputs
9
+ // were not, and the claim above them already promised that "a build's test
10
+ // files" are not this project's. A Next.js `.next/` carries compiled copies of
11
+ // the application — including its writes and its tests — so a surface scan of a
12
+ // real app counted the same code twice and a defender glob could match a
13
+ // compiled chunk. The promise was right and the list was short.
8
14
  const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', '.testguard',
15
+ '.next', '.nuxt', '.svelte-kit', '.output', '.turbo', '.parcel-cache', '.angular', '.astro',
16
+ 'build', 'out', '.cache', '.vercel', '.netlify',
9
17
  '__pycache__', '.venv', 'venv', 'site-packages', '.tox', '.nox',
10
18
  '.pytest_cache', '.mypy_cache', '.ruff_cache', '.eggs']);
11
19