testguard-cli 0.10.4 → 0.12.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 +85 -0
- package/README.md +130 -5
- package/package.json +1 -1
- package/spec/lib/validate.mjs +55 -1
- package/spec/schemas/sweep.schema.json +310 -0
- package/src/cli.mjs +12 -1
- package/src/commands/sweep.mjs +80 -0
- package/src/supply/persistence.mjs +173 -0
- package/src/supply/savepath.mjs +216 -0
- package/src/supply/select.mjs +168 -0
- package/src/sweep/sweep.mjs +302 -0
- package/src/util/glob.mjs +8 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which drafted faults are worth probing, and in what order.
|
|
3
|
+
*
|
|
4
|
+
* `scaffold` proposes mechanically and proposes a lot: eight source files of a
|
|
5
|
+
* real application yielded 238 proposals. Probing all of them is the cost model
|
|
6
|
+
* the tool already refuses — `(baseline + faults x N) x defender-set cost`,
|
|
7
|
+
* per claim — so a sweep that probes everything is a sweep nobody runs twice.
|
|
8
|
+
*
|
|
9
|
+
* Google's mutation service solved the same problem by never surfacing more
|
|
10
|
+
* than 7 x |files| mutants per change and by ordering candidates on the
|
|
11
|
+
* measured productivity of their operator in similar context, which took their
|
|
12
|
+
* productive rate from 15% to 89% ("Practical Mutation Testing at Scale",
|
|
13
|
+
* Petrovic et al., 2021). This module is that idea at this tool's scale.
|
|
14
|
+
*
|
|
15
|
+
* TWO THINGS IT MUST NOT DO. It must not drop a candidate silently — a cap
|
|
16
|
+
* that hides work is a coverage claim nobody made, so everything not selected
|
|
17
|
+
* is returned as `deferred` and the caller is expected to say so. And it must
|
|
18
|
+
* not be nondeterministic: the same drafts in the same repository must select
|
|
19
|
+
* the same faults in the same order, or a sweep's output stops being evidence.
|
|
20
|
+
* Every tie therefore breaks on file, then line, then id.
|
|
21
|
+
*
|
|
22
|
+
* Pure: drafts in, a selection out. Nothing here reads a file or runs anything.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* P(a fault of this class survives a green suite), measured — not guessed.
|
|
27
|
+
*
|
|
28
|
+
* From 56 probed faults across 12 modules of a real AI-authored Next.js
|
|
29
|
+
* application (2026-09-21). Survival is the right prior for ordering because a
|
|
30
|
+
* fault that dies everywhere teaches nobody anything: the whole output of a
|
|
31
|
+
* sweep is the faults that lived.
|
|
32
|
+
*
|
|
33
|
+
* `n` is carried so a reader can see which of these is one observation and
|
|
34
|
+
* which is twelve, and so a later feedback pass can weight them honestly
|
|
35
|
+
* instead of treating 100%-of-one as certainty.
|
|
36
|
+
*/
|
|
37
|
+
export const MEASURED_PRODUCTIVITY = Object.freeze({
|
|
38
|
+
'element-removed': { p: 1.0, n: 1 },
|
|
39
|
+
'call-removed': { p: 0.67, n: 3 },
|
|
40
|
+
'field-dropped': { p: 0.5, n: 12 },
|
|
41
|
+
'statement-deleted': { p: 0.33, n: 12 },
|
|
42
|
+
'literal-changed': { p: 0.33, n: 3 },
|
|
43
|
+
'condition-forced': { p: 0.1, n: 10 },
|
|
44
|
+
'argument-swapped': { p: 0.09, n: 11 },
|
|
45
|
+
'return-altered': { p: 0.0, n: 4 },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A class nobody measured is worth more attention than the worst measured one
|
|
50
|
+
* and less than the best. Ranking it last would make every new producer
|
|
51
|
+
* invisible until someone hand-edited this table; ranking it first would let a
|
|
52
|
+
* new producer crowd out everything known to be productive.
|
|
53
|
+
*/
|
|
54
|
+
const UNMEASURED = 0.4;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Shrink a small sample toward the unmeasured prior. One observation of
|
|
58
|
+
* `element-removed` is not evidence that it always survives, and ordering the
|
|
59
|
+
* whole sweep on it would be exactly the overconfidence this tool exists to
|
|
60
|
+
* refuse. m is the pseudo-count: at n = m the measurement and the prior weigh
|
|
61
|
+
* the same.
|
|
62
|
+
*/
|
|
63
|
+
const M = 4;
|
|
64
|
+
export function priorFor(faultClass, table = MEASURED_PRODUCTIVITY) {
|
|
65
|
+
const hit = table[faultClass];
|
|
66
|
+
if (!hit) return UNMEASURED;
|
|
67
|
+
return (hit.p * hit.n + UNMEASURED * M) / (hit.n + M);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The write payload of a persisted record is where this project's own
|
|
72
|
+
* measurement found every survivor: 12 of 12 were a field dropped from a
|
|
73
|
+
* `data` / `where` / `update` object reaching a database call. A fault on such
|
|
74
|
+
* a line is worth more than its class alone says, because the assertion that
|
|
75
|
+
* would have to catch it is the one tests most often write loosely
|
|
76
|
+
* (`expect.objectContaining({...})` naming everything except the field that
|
|
77
|
+
* carried the data).
|
|
78
|
+
*
|
|
79
|
+
* Deliberately a line-shape test, not a type-aware one: the scaffold producers
|
|
80
|
+
* are line-oriented and this must not become the one part of the pipeline that
|
|
81
|
+
* needs an AST.
|
|
82
|
+
*/
|
|
83
|
+
const PERSISTENCE_CALL = /\b(?:prisma|db|tx|trx|knex|sequelize|mongoose|supabase|drizzle)\b\s*[.[]|\.(?:create|createMany|update|updateMany|upsert|insert|save|delete|deleteMany|destroy)\s*\(/;
|
|
84
|
+
const PAYLOAD_KEY = /^\s*(?:data|where|update|create|select|set|values)\s*:/;
|
|
85
|
+
|
|
86
|
+
/** Is this fault on the persistence path — a write call, or a field of its payload? */
|
|
87
|
+
export function onWritePath(fault) {
|
|
88
|
+
const line = fault.find ?? '';
|
|
89
|
+
return PERSISTENCE_CALL.test(line) || PAYLOAD_KEY.test(line) || PERSISTENCE_CALL.test(fault.description ?? '');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The boost a write-path fault gets. Additive, not multiplicative: it must not be able to overtake a measured 1.0. */
|
|
93
|
+
const WRITE_PATH_BONUS = 0.25;
|
|
94
|
+
|
|
95
|
+
/** What one draft is worth probing, in [0, 1.25]. Exported so a test can pin the ordering rule rather than the order. */
|
|
96
|
+
export function scoreOf(fault, { table } = {}) {
|
|
97
|
+
return priorFor(fault.faultClass, table) + (onWritePath(fault) ? WRITE_PATH_BONUS : 0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Google surfaces at most 7 x |files| mutants per change. The number is theirs
|
|
102
|
+
* and the reason is general: past it the reader stops reading, and a finding
|
|
103
|
+
* nobody reads is worse than no finding, because it also spends the runner.
|
|
104
|
+
*/
|
|
105
|
+
export const capFor = (fileCount, perFile = 7) => Math.max(perFile, fileCount * perFile);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Order every candidate and take the cap.
|
|
109
|
+
*
|
|
110
|
+
* `candidates` are `{ claim, fault }` pairs — the claim is carried so the
|
|
111
|
+
* 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.
|
|
114
|
+
*/
|
|
115
|
+
export function selectFaults(candidates, { cap, table } = {}) {
|
|
116
|
+
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) }))
|
|
119
|
+
// Deterministic to the last tie: score, then file, then line, then id.
|
|
120
|
+
// Two runs of the same sweep on the same tree are the same sweep.
|
|
121
|
+
.sort((a, b) =>
|
|
122
|
+
b.score - a.score
|
|
123
|
+
|| (a.fault.file ?? '').localeCompare(b.fault.file ?? '')
|
|
124
|
+
|| (a.fault.line ?? 0) - (b.fault.line ?? 0)
|
|
125
|
+
|| `${a.claim.id}/${a.fault.id}`.localeCompare(`${b.claim.id}/${b.fault.id}`));
|
|
126
|
+
|
|
127
|
+
// ── Spread the cap across FILES, best-first within each. ──
|
|
128
|
+
// Taking the global top N looks right and is not: a file with many
|
|
129
|
+
// high-scoring lines takes the whole budget and every other changed file is
|
|
130
|
+
// reported as having no findings, which is indistinguishable from having
|
|
131
|
+
// none. Measured — the first sweep of a 14-file change spent all six slots
|
|
132
|
+
// on one page component and said nothing about the other twelve.
|
|
133
|
+
// The cap is already expressed per file (7 x |files|), so this is what that
|
|
134
|
+
// number was always supposed to mean.
|
|
135
|
+
const byFile = new Map();
|
|
136
|
+
for (const r of ranked) {
|
|
137
|
+
const key = r.fault.file ?? '';
|
|
138
|
+
if (!byFile.has(key)) byFile.set(key, []);
|
|
139
|
+
byFile.get(key).push(r);
|
|
140
|
+
}
|
|
141
|
+
// Files in the order of their best candidate, so a round that cannot serve
|
|
142
|
+
// everyone still serves the most promising files first. Ties on file name.
|
|
143
|
+
const queues = [...byFile.entries()]
|
|
144
|
+
.sort((a, b) => b[1][0].score - a[1][0].score || a[0].localeCompare(b[0]))
|
|
145
|
+
.map(([, q]) => q);
|
|
146
|
+
|
|
147
|
+
const selected = [];
|
|
148
|
+
for (let round = 0; selected.length < limit; round++) {
|
|
149
|
+
let served = false;
|
|
150
|
+
for (const q of queues) {
|
|
151
|
+
if (q.length <= round) continue;
|
|
152
|
+
selected.push(q[round]);
|
|
153
|
+
served = true;
|
|
154
|
+
if (selected.length >= limit) break;
|
|
155
|
+
}
|
|
156
|
+
if (!served) break;
|
|
157
|
+
}
|
|
158
|
+
const taken = new Set(selected);
|
|
159
|
+
const deferred = ranked.filter((r) => !taken.has(r));
|
|
160
|
+
const byClass = {};
|
|
161
|
+
for (const r of ranked) {
|
|
162
|
+
const e = (byClass[r.fault.faultClass] ??= { proposed: 0, selected: 0, writePath: 0 });
|
|
163
|
+
e.proposed += 1;
|
|
164
|
+
if (r.writePath) e.writePath += 1;
|
|
165
|
+
}
|
|
166
|
+
for (const r of selected) byClass[r.fault.faultClass].selected += 1;
|
|
167
|
+
return { selected, deferred, cap: limit, byClass };
|
|
168
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cold start: findings on a repository that has no claims yet.
|
|
3
|
+
*
|
|
4
|
+
* `gate --changed` already says which changed source files carry no claim, and
|
|
5
|
+
* stops there — correctly, because stating a claim is a human act. But a
|
|
6
|
+
* project adopting this tool reads that list, has nothing to compare it
|
|
7
|
+
* against, and closes the tab. Every escaped defect reported from the field so
|
|
8
|
+
* far was a claim gap, not a probe miss, so the bottleneck was never
|
|
9
|
+
* verification. It is oracle SUPPLY.
|
|
10
|
+
*
|
|
11
|
+
* Sweep is the lowest rung of that supply: no concern, no statement, nothing
|
|
12
|
+
* written by anybody. It takes the files the gate just called uncovered,
|
|
13
|
+
* proposes faults mechanically with the existing scaffold producers, probes a
|
|
14
|
+
* bounded selection of them, and reports what survived. A fault that survives
|
|
15
|
+
* needs no claim to be alarming: the code changed, something was deliberately
|
|
16
|
+
* broken, and not one test noticed.
|
|
17
|
+
*
|
|
18
|
+
* WHAT IT IS NOT. It does not write `testguard.claims.json`, and it never
|
|
19
|
+
* will — a claim is a sentence a human is willing to stand behind, and a
|
|
20
|
+
* sentence nobody wrote is not one. Its drafts land beside the evidence for a
|
|
21
|
+
* human to keep or drop, exactly as `scaffold` already works. It does not
|
|
22
|
+
* overwrite the canonical `.testguard/evidence.json` either: a sweep probes
|
|
23
|
+
* machine-proposed faults under TODO statements, and folding that into the
|
|
24
|
+
* document `status` and `baseline` read from would corrupt the record of what
|
|
25
|
+
* the project actually claims.
|
|
26
|
+
*
|
|
27
|
+
* The cap and the ordering come from Google's mutation service, which surfaces
|
|
28
|
+
* at most 7 x |files| mutants per change and orders candidates on the measured
|
|
29
|
+
* productivity of their operator, taking their productive rate from 15% to 89%
|
|
30
|
+
* ("Practical Mutation Testing at Scale", Petrovic et al., 2021). See
|
|
31
|
+
* `src/supply/select.mjs`.
|
|
32
|
+
*/
|
|
33
|
+
import { existsSync } from 'node:fs';
|
|
34
|
+
import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
|
|
35
|
+
import { computeChangedGate } from '../gate/changed.mjs';
|
|
36
|
+
import { scaffoldFile } from '../scaffold/scaffold.mjs';
|
|
37
|
+
import { selectFaults, onWritePath, capFor } from '../supply/select.mjs';
|
|
38
|
+
import { saveSurface } from '../supply/savepath.mjs';
|
|
39
|
+
import { persistenceSignals, persistenceHint } from '../supply/persistence.mjs';
|
|
40
|
+
import { probe } from '../probe/probe.mjs';
|
|
41
|
+
import { hintFor } from '../brief/brief.mjs';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Findings first, in the order a reader should act on them. The same order
|
|
45
|
+
* `brief` uses, minus the verdicts a sweep does not gate on.
|
|
46
|
+
*/
|
|
47
|
+
const FINDING_ORDER = ['survived', 'nocover', 'timeout', 'flaky-defender', 'unverifiable', 'fault-invalid'];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Which verdicts are a finding ABOUT THE PROJECT, and so decide the exit code.
|
|
51
|
+
*
|
|
52
|
+
* `survived` and `nocover` are statements about the test suite: something was
|
|
53
|
+
* broken and nothing failed, or nothing imports the file at all. The rest are
|
|
54
|
+
* statements about the PROPOSAL — an anchor that did not locate, a replacement
|
|
55
|
+
* that does not compile — and a sweep's proposals are machine-made and thrown
|
|
56
|
+
* away. Gating on them would make the tool fail because its own guess was bad,
|
|
57
|
+
* which is the fastest way to get a check switched off. They are still
|
|
58
|
+
* reported; they just do not gate.
|
|
59
|
+
*/
|
|
60
|
+
export const GATING = new Set(['survived', 'nocover']);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Turn one evidence record into a finding, or null when it is not one.
|
|
64
|
+
*
|
|
65
|
+
* Pure, and exported, for the reason `classify.mjs` gives about itself: the
|
|
66
|
+
* only thing that could otherwise falsify these rules is a full fixture sweep,
|
|
67
|
+
* which is a minutes-long acceptance test standing in for a decision you can
|
|
68
|
+
* state in four lines. Self-probing this file found exactly that gap — the
|
|
69
|
+
* schema was guarded and the engine that fills it was not.
|
|
70
|
+
*
|
|
71
|
+
* `signalsFor` is injected so the rule about WHEN a signal applies can be
|
|
72
|
+
* tested without a filesystem; the default in `sweep()` reads the defenders.
|
|
73
|
+
*/
|
|
74
|
+
export function findingFrom(record, fault, signalsFor = () => []) {
|
|
75
|
+
if (record.verdict === 'killed') return null;
|
|
76
|
+
const writePath = fault ? onWritePath(fault) : false;
|
|
77
|
+
// The persistence signal explains a survivor on the write path and nothing
|
|
78
|
+
// else. On a kill it explains nothing, and on any other line a loose mock
|
|
79
|
+
// assertion is not evidence — reporting it everywhere is the
|
|
80
|
+
// non-actionable noise that gets a check switched off.
|
|
81
|
+
const signals = record.verdict === 'survived' && writePath ? signalsFor(record) : [];
|
|
82
|
+
return {
|
|
83
|
+
file: record.subject.file,
|
|
84
|
+
...(fault?.line ? { line: fault.line } : {}),
|
|
85
|
+
claimId: record.claim.id,
|
|
86
|
+
faultId: record.subject.id,
|
|
87
|
+
faultClass: record.subject.faultClass,
|
|
88
|
+
verdict: record.verdict,
|
|
89
|
+
...(record.detail?.reason ? { reason: record.detail.reason } : {}),
|
|
90
|
+
description: record.subject.description,
|
|
91
|
+
writePath,
|
|
92
|
+
hint: signals.length ? persistenceHint(signals[0], fault?.find) : hintFor(record),
|
|
93
|
+
...(signals.length ? { signals } : {}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Findings in the order a reader should act on them. Pure; deterministic to the last tie. */
|
|
98
|
+
export function sortFindings(findings) {
|
|
99
|
+
return [...findings].sort((a, b) =>
|
|
100
|
+
FINDING_ORDER.indexOf(a.verdict) - FINDING_ORDER.indexOf(b.verdict)
|
|
101
|
+
|| Number(b.writePath) - Number(a.writePath)
|
|
102
|
+
|| a.file.localeCompare(b.file)
|
|
103
|
+
|| (a.line ?? 0) - (b.line ?? 0));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** How many findings a sweep is willing to fail on. */
|
|
107
|
+
export const gatingCount = (findings) => findings.filter((f) => GATING.has(f.verdict)).length;
|
|
108
|
+
|
|
109
|
+
/** Rebuild claim documents from a flat selection, keeping each claim's own faults together. */
|
|
110
|
+
function claimsFromSelection(selected) {
|
|
111
|
+
const byClaim = new Map();
|
|
112
|
+
for (const { claim, fault } of selected) {
|
|
113
|
+
if (!byClaim.has(claim.id)) byClaim.set(claim.id, { ...claim, faults: [] });
|
|
114
|
+
byClaim.get(claim.id).faults.push(fault);
|
|
115
|
+
}
|
|
116
|
+
// Deterministic: the probe's own ordering, and the evidence it writes, must
|
|
117
|
+
// not depend on the order a Map happened to be filled in.
|
|
118
|
+
return [...byClaim.values()]
|
|
119
|
+
.map((c) => ({ ...c, faults: [...c.faults].sort((a, b) => a.id.localeCompare(b.id)) }))
|
|
120
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Run a sweep. Returns the sweep document; the caller owns writing and exit
|
|
125
|
+
* codes, as every other engine module in this tool does.
|
|
126
|
+
*/
|
|
127
|
+
export async function sweep({
|
|
128
|
+
projectDir,
|
|
129
|
+
ref,
|
|
130
|
+
mode = 'changed',
|
|
131
|
+
includeDirty = false,
|
|
132
|
+
exclude = [],
|
|
133
|
+
cap,
|
|
134
|
+
confirmRuns = 3,
|
|
135
|
+
budgetMs = 120_000,
|
|
136
|
+
runnerCommand,
|
|
137
|
+
runnerName,
|
|
138
|
+
nodeModules,
|
|
139
|
+
toolVersion = '0.0.0',
|
|
140
|
+
generatedAt = new Date().toISOString(),
|
|
141
|
+
claimsPath,
|
|
142
|
+
ignorePath,
|
|
143
|
+
onStage,
|
|
144
|
+
onWarn = () => {},
|
|
145
|
+
}) {
|
|
146
|
+
const gate = computeChangedGate({ projectDir, ref, includeDirty, exclude, toolVersion, claimsPath, ignorePath });
|
|
147
|
+
|
|
148
|
+
// ── What the sweep is pointed at. ──
|
|
149
|
+
// `changed` asks "what did this change leave unclaimed". `save-paths` asks a
|
|
150
|
+
// question the diff cannot: "of every place this project writes to storage,
|
|
151
|
+
// how many would notice if the write stopped carrying a field". The second
|
|
152
|
+
// needs the whole surface, because a clean report over an unstated
|
|
153
|
+
// denominator is a sample of unknown size, not a guarantee.
|
|
154
|
+
//
|
|
155
|
+
// A test file with no claim is a different problem — it has nothing to
|
|
156
|
+
// 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);
|
|
161
|
+
|
|
162
|
+
const cPath = claimsPath ?? defaultClaimsPath(projectDir);
|
|
163
|
+
const existingClaims = existsSync(cPath) ? loadClaims(cPath) : { claims: [] };
|
|
164
|
+
|
|
165
|
+
const candidates = [];
|
|
166
|
+
const skipped = [];
|
|
167
|
+
const drafts = [];
|
|
168
|
+
for (const file of targets) {
|
|
169
|
+
let result;
|
|
170
|
+
try {
|
|
171
|
+
result = scaffoldFile({ projectDir, file, existingClaims, toolVersion });
|
|
172
|
+
} catch (e) {
|
|
173
|
+
// One unscaffoldable file must not take the sweep with it: the other
|
|
174
|
+
// files' findings are still true, and a reader needs to know which file
|
|
175
|
+
// produced nothing and why.
|
|
176
|
+
skipped.push({ file, reason: e.message.split('\n')[0].slice(0, 200) });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (result.stats.proposals === 0) {
|
|
180
|
+
skipped.push({ file, reason: 'no line in this file matches a fault producer' });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
drafts.push(result.doc);
|
|
184
|
+
for (const claim of result.doc.claims) for (const fault of claim.faults) candidates.push({ claim, fault });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const limit = cap ?? capFor(targets.length);
|
|
188
|
+
const selection = selectFaults(candidates, { cap: limit });
|
|
189
|
+
const claims = claimsFromSelection(selection.selected);
|
|
190
|
+
|
|
191
|
+
let evidence = { records: [] };
|
|
192
|
+
if (claims.length > 0) {
|
|
193
|
+
evidence = await probe({
|
|
194
|
+
projectDir,
|
|
195
|
+
claims: { schemaVersion: 1, claims },
|
|
196
|
+
confirmRuns,
|
|
197
|
+
budgetMs,
|
|
198
|
+
mode: 'worktree',
|
|
199
|
+
includeDirty,
|
|
200
|
+
// A proposed fault has no declared defenders to be missing from, so the
|
|
201
|
+
// question escalation answers — "did something UNDECLARED catch it?" —
|
|
202
|
+
// has no meaning here, and it is the most expensive thing the probe does.
|
|
203
|
+
escalate: false,
|
|
204
|
+
runnerCommand,
|
|
205
|
+
runnerName,
|
|
206
|
+
nodeModules,
|
|
207
|
+
toolVersion,
|
|
208
|
+
onStage,
|
|
209
|
+
onWarn,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Index the selection so a record can find the fault it came from without a
|
|
214
|
+
// second scan; `line` and `find` live on the draft, not on the evidence.
|
|
215
|
+
const draftOf = new Map(selection.selected.map(({ claim, fault }) => [`${claim.id}/${fault.id}`, fault]));
|
|
216
|
+
|
|
217
|
+
const signalsFor = (r) => persistenceSignals(projectDir, r.defenders?.resolved ?? []);
|
|
218
|
+
const counts = {};
|
|
219
|
+
const findings = [];
|
|
220
|
+
for (const r of evidence.records) {
|
|
221
|
+
counts[r.verdict] = (counts[r.verdict] ?? 0) + 1;
|
|
222
|
+
const f = findingFrom(r, draftOf.get(`${r.claim.id}/${r.subject.id}`), signalsFor);
|
|
223
|
+
if (f) findings.push(f);
|
|
224
|
+
}
|
|
225
|
+
const ordered = sortFindings(findings);
|
|
226
|
+
const gating = gatingCount(ordered);
|
|
227
|
+
return {
|
|
228
|
+
schemaVersion: 1,
|
|
229
|
+
tool: { name: 'testguard', version: toolVersion },
|
|
230
|
+
generatedAt,
|
|
231
|
+
ref,
|
|
232
|
+
base: gate.base,
|
|
233
|
+
head: gate.head,
|
|
234
|
+
includeDirty,
|
|
235
|
+
scope: {
|
|
236
|
+
mode,
|
|
237
|
+
changed: gate.changed,
|
|
238
|
+
...(surface ? { writeSites: surface.siteCount, payloadFields: surface.keyCount } : {}),
|
|
239
|
+
targets: targets.length,
|
|
240
|
+
swept: drafts.length,
|
|
241
|
+
...(skipped.length ? { skipped } : {}),
|
|
242
|
+
},
|
|
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
|
+
},
|
|
250
|
+
counts,
|
|
251
|
+
findings: ordered,
|
|
252
|
+
exitCode: gating > 0 ? 1 : 0,
|
|
253
|
+
drafts,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The sweep as text, for someone who ran it in a terminal or reads it in a CI log. */
|
|
258
|
+
export function renderSweep(doc, { limit = 20 } = {}) {
|
|
259
|
+
const out = [];
|
|
260
|
+
const { scope, selection } = doc;
|
|
261
|
+
const saves = scope.mode === 'save-paths';
|
|
262
|
+
if (scope.targets === 0) {
|
|
263
|
+
out.push(saves
|
|
264
|
+
? '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.`);
|
|
266
|
+
return out.join('\n');
|
|
267
|
+
}
|
|
268
|
+
// The denominator first. A report that lists findings without saying how much
|
|
269
|
+
// was looked at invites the reader to assume the rest is fine.
|
|
270
|
+
out.push(saves
|
|
271
|
+
? `${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}.`);
|
|
273
|
+
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.'}`);
|
|
275
|
+
for (const s of scope.skipped ?? []) out.push(` skipped ${s.file}: ${s.reason}`);
|
|
276
|
+
out.push('');
|
|
277
|
+
|
|
278
|
+
const gating = doc.findings.filter((f) => GATING.has(f.verdict));
|
|
279
|
+
if (gating.length === 0) {
|
|
280
|
+
out.push(`No fault survived. ${doc.counts.killed ?? 0} of ${selection.selected} probed faults were caught.`);
|
|
281
|
+
} else {
|
|
282
|
+
out.push(`${gating.length} finding${gating.length === 1 ? '' : 's'} — a deliberate break that no test noticed:`);
|
|
283
|
+
out.push('');
|
|
284
|
+
for (const f of doc.findings.slice(0, limit)) {
|
|
285
|
+
const where = f.line ? `${f.file}:${f.line}` : f.file;
|
|
286
|
+
out.push(` ${f.verdict.toUpperCase().padEnd(13)} ${where}${f.writePath ? ' [write path]' : ''}`);
|
|
287
|
+
out.push(` ${f.description}`);
|
|
288
|
+
out.push(` ${f.hint}`);
|
|
289
|
+
out.push('');
|
|
290
|
+
}
|
|
291
|
+
if (doc.findings.length > limit) out.push(` … ${doc.findings.length - limit} more in the sweep document`);
|
|
292
|
+
}
|
|
293
|
+
if (saves) {
|
|
294
|
+
out.push('');
|
|
295
|
+
out.push(`This is a sample of the write surface, not a verdict over it: ${selection.selected} of`);
|
|
296
|
+
out.push(`${selection.proposed} proposed faults were probed. The ${scope.writeSites} writes above are the`);
|
|
297
|
+
out.push('denominator — a clean sweep says nothing about the ones nobody pointed a fault at.');
|
|
298
|
+
}
|
|
299
|
+
out.push('These are PROPOSALS, not claims. Keep the ones worth defending: state the');
|
|
300
|
+
out.push('claim, copy its fault into testguard.claims.json, and probe it from then on.');
|
|
301
|
+
return out.join('\n');
|
|
302
|
+
}
|
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
|
|