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.
@@ -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