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.
- package/CHANGELOG.md +108 -0
- package/README.md +141 -4
- package/package.json +1 -1
- package/spec/lib/validate.mjs +49 -1
- package/spec/schemas/concerns.schema.json +41 -0
- package/spec/schemas/sweep.schema.json +288 -49
- package/src/cli.mjs +12 -2
- package/src/commands/concerns.mjs +32 -0
- package/src/commands/sweep.mjs +59 -24
- package/src/supply/concerns.mjs +162 -0
- package/src/supply/feedback.mjs +146 -0
- package/src/supply/persistence.mjs +53 -0
- package/src/supply/savepath.mjs +216 -0
- package/src/sweep/sweep.mjs +111 -7
- package/src/util/glob.mjs +8 -0
package/src/sweep/sweep.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
|