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.
- package/CHANGELOG.md +144 -0
- package/README.md +95 -11
- package/package.json +1 -1
- package/spec/GATE-SEMANTICS.md +8 -0
- package/spec/lib/validate.mjs +50 -3
- package/spec/schemas/concerns.schema.json +41 -0
- package/spec/schemas/evidence.schema.json +35 -2
- package/spec/schemas/sweep.schema.json +61 -0
- package/src/cli.mjs +7 -1
- package/src/commands/concerns.mjs +32 -0
- package/src/commands/sweep.mjs +52 -23
- package/src/probe/probe.mjs +8 -1
- package/src/probe/pyimports.mjs +9 -2
- package/src/scaffold/producers.python.mjs +54 -2
- package/src/supply/concerns.mjs +162 -0
- package/src/supply/feedback.mjs +146 -0
- package/src/supply/persistence.mjs +73 -0
- package/src/supply/select.mjs +39 -6
- package/src/sweep/sweep.mjs +114 -18
- package/src/util/glob.mjs +5 -1
|
@@ -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
|
+
}
|
package/src/supply/select.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
118
|
-
|
|
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
|
|
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
|
}
|
package/src/sweep/sweep.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
-
|
|
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',
|