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
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A concern: one sentence a human writes, that generates faults across a whole
|
|
3
|
+
* surface.
|
|
4
|
+
*
|
|
5
|
+
* The rung between "nothing written" and "a claim per behaviour". A claim names
|
|
6
|
+
* one promise and the faults that would break it; that is precise and it is why
|
|
7
|
+
* a repository with two hundred screens never finishes writing them. A concern
|
|
8
|
+
* names a KIND of promise — "every write persists the data it was given" — and
|
|
9
|
+
* says where to look for it and which shapes of break are relevant. One
|
|
10
|
+
* sentence, two hundred screens.
|
|
11
|
+
*
|
|
12
|
+
* The idea is Meta's. ACH has an engineer describe an area of concern in plain
|
|
13
|
+
* text, generates faults specific to it, and gates every step by execution
|
|
14
|
+
* ("Mutation-Guided LLM-based Test Generation at Meta", Foster et al., FSE
|
|
15
|
+
* 2025). What is taken here is the UNIT — the concern as the thing a human
|
|
16
|
+
* writes — not their generator.
|
|
17
|
+
*
|
|
18
|
+
* NO MODEL IS REQUIRED, and that is deliberate. A concern's useful core is a
|
|
19
|
+
* named scope plus a producer selection, both of which the mechanical
|
|
20
|
+
* producers already satisfy. An LLM can later propose faults a line-oriented
|
|
21
|
+
* producer cannot see, as an ADDITIONAL source for a concern that already
|
|
22
|
+
* works — rather than as the thing the rung depends on. That keeps every
|
|
23
|
+
* verifying command offline, which is the property this tool is least willing
|
|
24
|
+
* to trade.
|
|
25
|
+
*
|
|
26
|
+
* A concern is NOT a claim and never becomes one by itself. It says where to
|
|
27
|
+
* look; the probe says what it found; a human states the sentence worth
|
|
28
|
+
* defending. Nothing here is written to `testguard.claims.json`.
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import { saveSurface } from './savepath.mjs';
|
|
33
|
+
import { globToRegExp, walk } from '../util/glob.mjs';
|
|
34
|
+
|
|
35
|
+
export const defaultConcernsPath = (projectDir) => join(projectDir, 'testguard.concerns.json');
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The concerns every project gets without writing anything.
|
|
39
|
+
*
|
|
40
|
+
* Two, because two are the ones whose targets can be enumerated honestly. An
|
|
41
|
+
* authorization concern would need a heuristic for "which files check
|
|
42
|
+
* permissions", and a guess there produces exactly the non-actionable noise
|
|
43
|
+
* that gets a check switched off. A project that knows its own auth layer can
|
|
44
|
+
* say so with a `glob` target in three lines; this file will not guess for it.
|
|
45
|
+
*/
|
|
46
|
+
export const BUILTIN_CONCERNS = Object.freeze([
|
|
47
|
+
Object.freeze({
|
|
48
|
+
id: 'SAVE-PERSISTS',
|
|
49
|
+
statement: 'Every write to storage persists the data it was given.',
|
|
50
|
+
severity: 'high',
|
|
51
|
+
targets: { kind: 'write-sites' },
|
|
52
|
+
// The shapes that break a persisted payload. Measured on a real
|
|
53
|
+
// application: 12 of 12 surviving faults on the persistence path were a
|
|
54
|
+
// field dropped from the payload, so the list leads with that.
|
|
55
|
+
faultClasses: ['field-dropped', 'call-removed', 'statement-deleted', 'literal-changed'],
|
|
56
|
+
builtin: true,
|
|
57
|
+
}),
|
|
58
|
+
Object.freeze({
|
|
59
|
+
id: 'CHANGED-CODE',
|
|
60
|
+
statement: 'Code changed in this branch is defended by something.',
|
|
61
|
+
severity: 'medium',
|
|
62
|
+
targets: { kind: 'changed' },
|
|
63
|
+
// Everything: a diff has no shape to narrow by.
|
|
64
|
+
faultClasses: null,
|
|
65
|
+
builtin: true,
|
|
66
|
+
}),
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Load the project's concerns, with the built-ins behind them.
|
|
71
|
+
*
|
|
72
|
+
* A project concern with the same id as a built-in REPLACES it, so a team can
|
|
73
|
+
* retune `SAVE-PERSISTS` for an ORM this tool does not recognise without
|
|
74
|
+
* forking anything. Shadowing is recorded rather than silent: a concern that
|
|
75
|
+
* quietly replaced a default would be the kind of surprise that costs an
|
|
76
|
+
* afternoon.
|
|
77
|
+
*/
|
|
78
|
+
export function loadConcerns(projectDir, { path } = {}) {
|
|
79
|
+
const p = path ?? defaultConcernsPath(projectDir);
|
|
80
|
+
let project = [];
|
|
81
|
+
let source = null;
|
|
82
|
+
if (existsSync(p)) {
|
|
83
|
+
const doc = JSON.parse(readFileSync(p, 'utf8'));
|
|
84
|
+
project = doc.concerns ?? [];
|
|
85
|
+
source = p;
|
|
86
|
+
}
|
|
87
|
+
const overridden = new Set(project.map((c) => c.id));
|
|
88
|
+
const concerns = [
|
|
89
|
+
...project.map((c) => ({ ...c, builtin: false })),
|
|
90
|
+
...BUILTIN_CONCERNS.filter((c) => !overridden.has(c.id)),
|
|
91
|
+
];
|
|
92
|
+
return {
|
|
93
|
+
concerns,
|
|
94
|
+
// The project's own entries, exactly as written. Validation runs against
|
|
95
|
+
// these: the enriched objects above carry a `builtin` marker this tool
|
|
96
|
+
// added, and a schema that refuses unknown properties is right to reject
|
|
97
|
+
// them. Validating what we constructed instead of what the user wrote
|
|
98
|
+
// would report OUR bug as THEIR malformed file.
|
|
99
|
+
raw: project.map(({ builtin, ...c }) => c),
|
|
100
|
+
source,
|
|
101
|
+
shadowed: BUILTIN_CONCERNS.filter((c) => overridden.has(c.id)).map((c) => c.id),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** One concern by id, or null. Ids are matched exactly: a near-miss is a typo, not an intention. */
|
|
106
|
+
export const concernById = (concerns, id) => concerns.find((c) => c.id === id) ?? null;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The files a concern applies to.
|
|
110
|
+
*
|
|
111
|
+
* `write-sites` asks the enumerator; `glob` matches the project's own paths;
|
|
112
|
+
* `changed` returns null, meaning "the caller's diff decides" — a concern does
|
|
113
|
+
* not get to re-derive what the gate already computed.
|
|
114
|
+
*/
|
|
115
|
+
export function targetsFor(projectDir, concern, { changedFiles } = {}) {
|
|
116
|
+
const t = concern.targets ?? { kind: 'changed' };
|
|
117
|
+
if (t.kind === 'write-sites') return saveSurface(projectDir).files.map((f) => f.file);
|
|
118
|
+
if (t.kind === 'glob') {
|
|
119
|
+
// Against the whole project, never the diff. A concern like "every admin
|
|
120
|
+
// route refuses an unauthorised caller" is about the admin routes, not
|
|
121
|
+
// about the ones that happened to change today — and pooling from the diff
|
|
122
|
+
// made such a concern report clean on a clean branch, which is the exact
|
|
123
|
+
// false reassurance a concern exists to prevent.
|
|
124
|
+
const res = (t.globs ?? []).map(globToRegExp);
|
|
125
|
+
return walk(projectDir).filter((f) => res.some((re) => re.test(f)));
|
|
126
|
+
}
|
|
127
|
+
return changedFiles ?? null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Keep only the faults whose class this concern cares about.
|
|
132
|
+
*
|
|
133
|
+
* `faultClasses: null` means every class — the honest default for a concern
|
|
134
|
+
* with no shape to narrow by. Narrowing is what makes one sentence useful
|
|
135
|
+
* across two hundred files: a persistence concern that also reported every
|
|
136
|
+
* altered return value would bury the payload findings it exists to surface.
|
|
137
|
+
*/
|
|
138
|
+
export function filterByConcern(candidates, concern) {
|
|
139
|
+
if (!concern?.faultClasses) return candidates;
|
|
140
|
+
const keep = new Set(concern.faultClasses);
|
|
141
|
+
return candidates.filter((c) => keep.has(c.fault.faultClass));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The concerns as text, for someone deciding which to sweep. */
|
|
145
|
+
export function renderConcerns({ concerns, source, shadowed }) {
|
|
146
|
+
const out = [];
|
|
147
|
+
out.push(source ? `${concerns.length} concern${concerns.length === 1 ? '' : 's'} (${source}, plus the built-ins)` : `${concerns.length} built-in concern${concerns.length === 1 ? '' : 's'} — none declared in this project.`);
|
|
148
|
+
out.push('');
|
|
149
|
+
for (const c of concerns) {
|
|
150
|
+
const shape = c.targets?.kind === 'glob' ? `glob ${(c.targets.globs ?? []).join(', ')}` : c.targets?.kind ?? 'changed';
|
|
151
|
+
out.push(` ${c.id}${c.builtin ? ' (built-in)' : ''}`);
|
|
152
|
+
out.push(` ${c.statement}`);
|
|
153
|
+
out.push(` targets: ${shape} · faults: ${c.faultClasses ? c.faultClasses.join(', ') : 'every class'}`);
|
|
154
|
+
out.push('');
|
|
155
|
+
}
|
|
156
|
+
if (shadowed.length) out.push(`This project replaces the built-in ${shadowed.join(', ')}.`);
|
|
157
|
+
if (!source) {
|
|
158
|
+
out.push('Declare your own in testguard.concerns.json. A concern is one sentence and a');
|
|
159
|
+
out.push('scope: it says where to look, never what is true. Only a claim does that.');
|
|
160
|
+
}
|
|
161
|
+
return out.join('\n');
|
|
162
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this project's own runs say about which faults are worth proposing.
|
|
3
|
+
*
|
|
4
|
+
* `select.mjs` ships a productivity prior measured on one real application.
|
|
5
|
+
* That is a reasonable starting point and a poor stopping point: the classes
|
|
6
|
+
* that survive a Next.js suite are not the classes that survive a Python one,
|
|
7
|
+
* and Google's mutation service got from a 15% productive rate to 89% on
|
|
8
|
+
* exactly this — feedback, accumulated over years, refining which candidates
|
|
9
|
+
* are worth a developer's attention ("Practical Mutation Testing at Scale",
|
|
10
|
+
* Petrović et al., 2021). The static table is the part of that idea this tool
|
|
11
|
+
* had not yet taken.
|
|
12
|
+
*
|
|
13
|
+
* THE FEEDBACK IS ALREADY ON DISK. Every evidence record carries a
|
|
14
|
+
* `faultClass` and a `verdict`, so "how often does a fault of this class
|
|
15
|
+
* survive here" is a tally, not a new thing to capture. There is no click to
|
|
16
|
+
* collect and no state to maintain: a project that has probed anything has
|
|
17
|
+
* already said what its own productive classes are.
|
|
18
|
+
*
|
|
19
|
+
* WHY THIS ESTIMATOR AND NOT A BETTER-SOUNDING ONE. The combination below is a
|
|
20
|
+
* Beta-Binomial posterior mean, and a posterior mean is what minimises Brier
|
|
21
|
+
* and log score in expectation — it is a STRICTLY PROPER estimate, which is
|
|
22
|
+
* the only kind that cannot be improved by reporting something other than an
|
|
23
|
+
* honest probability. Optimising the ordering for accuracy or F1 instead would
|
|
24
|
+
* maximise the number of survivors found while destroying the calibration of
|
|
25
|
+
* the number attached to each, which is the documented failure mode of naive
|
|
26
|
+
* optimisation and the reason Laya trains against proper scoring rules. If
|
|
27
|
+
* this file is ever changed to rank by anything but an honest probability,
|
|
28
|
+
* that is the invariant being broken.
|
|
29
|
+
*
|
|
30
|
+
* Pure apart from reading the documents it is given.
|
|
31
|
+
*/
|
|
32
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import { MEASURED_PRODUCTIVITY } from './select.mjs';
|
|
35
|
+
|
|
36
|
+
/** The evidence a project may have produced, in the order they are most relevant. */
|
|
37
|
+
export const EVIDENCE_PATHS = Object.freeze([
|
|
38
|
+
// A sweep probes MACHINE-proposed faults, the same distribution the ranker
|
|
39
|
+
// orders, so it is the closest observation available.
|
|
40
|
+
'.testguard/sweep-evidence.json',
|
|
41
|
+
// The canonical probe is human-authored faults: a real observation of the
|
|
42
|
+
// same classes, drawn slightly differently, and still worth counting.
|
|
43
|
+
'.testguard/evidence.json',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Survival counts per fault class from one evidence document.
|
|
48
|
+
*
|
|
49
|
+
* Only `killed` and `survived` are counted. `nocover`, `unverifiable`,
|
|
50
|
+
* `fault-invalid`, `timeout` and `flaky-defender` say nothing about whether a
|
|
51
|
+
* fault of that class would be NOTICED — they say the question could not be
|
|
52
|
+
* asked — and folding them in either direction would be inventing data.
|
|
53
|
+
*/
|
|
54
|
+
export function tallyEvidence(doc) {
|
|
55
|
+
const by = {};
|
|
56
|
+
for (const r of doc?.records ?? []) {
|
|
57
|
+
const cls = r.subject?.faultClass;
|
|
58
|
+
if (!cls) continue;
|
|
59
|
+
if (r.verdict !== 'killed' && r.verdict !== 'survived') continue;
|
|
60
|
+
const e = (by[cls] ??= { survived: 0, n: 0 });
|
|
61
|
+
e.n += 1;
|
|
62
|
+
if (r.verdict === 'survived') e.survived += 1;
|
|
63
|
+
}
|
|
64
|
+
return by;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Merge tallies from several documents. Counts add; nothing is averaged twice. */
|
|
68
|
+
export function mergeTallies(tallies) {
|
|
69
|
+
const by = {};
|
|
70
|
+
for (const t of tallies) {
|
|
71
|
+
for (const [cls, e] of Object.entries(t)) {
|
|
72
|
+
const m = (by[cls] ??= { survived: 0, n: 0 });
|
|
73
|
+
m.survived += e.survived;
|
|
74
|
+
m.n += e.n;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return by;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The productivity table to rank with: this project's own observations, with
|
|
82
|
+
* the shipped measurement as the prior they are shrunk toward.
|
|
83
|
+
*
|
|
84
|
+
* The shipped numbers become pseudo-counts rather than a competing estimate —
|
|
85
|
+
* `n` of them, so a class measured on twelve faults elsewhere carries the
|
|
86
|
+
* weight of twelve observations here and no more. A project with its own
|
|
87
|
+
* hundred records therefore overrides the shipping default; a project with
|
|
88
|
+
* three nudges it.
|
|
89
|
+
*
|
|
90
|
+
* `shipped` is injectable so the combination can be tested without depending
|
|
91
|
+
* on whatever the current measurement happens to be.
|
|
92
|
+
*/
|
|
93
|
+
export function combinedProductivity(local, shipped = MEASURED_PRODUCTIVITY) {
|
|
94
|
+
const out = {};
|
|
95
|
+
for (const cls of new Set([...Object.keys(shipped), ...Object.keys(local)])) {
|
|
96
|
+
const s = shipped[cls];
|
|
97
|
+
const l = local[cls];
|
|
98
|
+
const priorSurvived = s ? s.p * s.n : 0;
|
|
99
|
+
const priorN = s ? s.n : 0;
|
|
100
|
+
const n = priorN + (l?.n ?? 0);
|
|
101
|
+
if (n === 0) continue;
|
|
102
|
+
const survived = priorSurvived + (l?.survived ?? 0);
|
|
103
|
+
out[cls] = { p: survived / n, n, observed: l?.n ?? 0 };
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Read whichever evidence documents this project has. Missing or malformed ones are skipped, never guessed at. */
|
|
109
|
+
export function readEvidence(projectDir, paths = EVIDENCE_PATHS) {
|
|
110
|
+
const docs = [];
|
|
111
|
+
for (const rel of paths) {
|
|
112
|
+
const p = join(projectDir, rel);
|
|
113
|
+
if (!existsSync(p)) continue;
|
|
114
|
+
try {
|
|
115
|
+
docs.push({ path: rel, doc: JSON.parse(readFileSync(p, 'utf8')) });
|
|
116
|
+
} catch {
|
|
117
|
+
// A document this tool cannot parse is not evidence of anything. It is
|
|
118
|
+
// skipped rather than defaulted, because a ranking built on a guess
|
|
119
|
+
// about a corrupt file is worse than one built on the shipped prior.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return docs;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The learned table plus what it was learned from, so a report can say whether
|
|
127
|
+
* an ordering came from this project or from the shipping default. An ordering
|
|
128
|
+
* nobody can trace is a number nobody should trust.
|
|
129
|
+
*/
|
|
130
|
+
export function learnedProductivity(projectDir, { paths, shipped } = {}) {
|
|
131
|
+
const docs = readEvidence(projectDir, paths);
|
|
132
|
+
const local = mergeTallies(docs.map((d) => tallyEvidence(d.doc)));
|
|
133
|
+
const observed = Object.values(local).reduce((a, e) => a + e.n, 0);
|
|
134
|
+
return {
|
|
135
|
+
table: combinedProductivity(local, shipped),
|
|
136
|
+
sources: docs.map((d) => d.path),
|
|
137
|
+
observed,
|
|
138
|
+
classes: Object.keys(local).sort(),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One line for a report: where the ordering came from. */
|
|
143
|
+
export function renderLearned(learned) {
|
|
144
|
+
if (learned.observed === 0) return 'ordering: the shipped productivity prior — this project has no probed evidence yet.';
|
|
145
|
+
return `ordering: ${learned.observed} probed fault${learned.observed === 1 ? '' : 's'} of this project's own, across ${learned.classes.length} class${learned.classes.length === 1 ? '' : 'es'} (${learned.sources.join(', ')}), shrunk toward the shipped prior.`;
|
|
146
|
+
}
|
|
@@ -171,3 +171,56 @@ export function persistenceHint(signal, faultLine) {
|
|
|
171
171
|
const field = /^\s*([A-Za-z_$][\w$]*)\s*(?::|,\s*$)/.exec(faultLine ?? '')?.[1];
|
|
172
172
|
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
173
|
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* What this claim's defenders are CAPABLE of proving about a write.
|
|
177
|
+
*
|
|
178
|
+
* The read-back question — "did the row actually land?" — cannot be answered by
|
|
179
|
+
* a test that replaced the database with a mock. Such a test can prove the call
|
|
180
|
+
* shape and nothing beyond it: the `where` may match no rows, the transaction
|
|
181
|
+
* may roll back, a constraint may reject the write, and every one of those
|
|
182
|
+
* passes against a mock that recorded the arguments and returned a plausible
|
|
183
|
+
* object.
|
|
184
|
+
*
|
|
185
|
+
* So rather than pretend to measure persistence, this states the LIMIT. It is
|
|
186
|
+
* the same discipline as `nocover` — which says "no test imports this" instead
|
|
187
|
+
* of guessing — one level finer: these tests exist, they run, and there is a
|
|
188
|
+
* class of failure they structurally cannot see.
|
|
189
|
+
*
|
|
190
|
+
* `mocked` every defender replaces the persistence layer. Call shape only.
|
|
191
|
+
* `unmocked` at least one does not, so a real write is at least possible.
|
|
192
|
+
* NOT a promise that one happens: a test may simply never reach
|
|
193
|
+
* the database. Possible is the honest word.
|
|
194
|
+
* `none` no defender at all; `nocover` already covers this.
|
|
195
|
+
*
|
|
196
|
+
* Measured on a real AI-authored application: of 37 files that write to
|
|
197
|
+
* storage, 32 were defended only by mocking tests, 4 had no defender, and 1
|
|
198
|
+
* had an unmocked defender — a mailer, not a database write. Nothing in that
|
|
199
|
+
* suite could prove any save persisted.
|
|
200
|
+
*/
|
|
201
|
+
export function persistenceProvability(projectDir, defenders) {
|
|
202
|
+
if (!defenders || defenders.length === 0) return { class: 'none', mocking: [], unmocked: [] };
|
|
203
|
+
const mocking = [];
|
|
204
|
+
const unmocked = [];
|
|
205
|
+
for (const d of defenders) (analyzePersistence(projectDir, d) ? mocking : unmocked).push(d);
|
|
206
|
+
return { class: unmocked.length ? 'unmocked' : 'mocked', mocking, unmocked };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The provability of a whole surface, as counts. The headline a save-path
|
|
211
|
+
* report opens with, because "how many of these could be proven at all" is a
|
|
212
|
+
* different question from "how many were probed" and a far more alarming one.
|
|
213
|
+
*/
|
|
214
|
+
export function provabilitySummary(projectDir, targets, defendersOf) {
|
|
215
|
+
const counts = { mocked: 0, unmocked: 0, none: 0 };
|
|
216
|
+
for (const file of targets) {
|
|
217
|
+
let defenders = [];
|
|
218
|
+
try {
|
|
219
|
+
defenders = defendersOf(file);
|
|
220
|
+
} catch {
|
|
221
|
+
defenders = [];
|
|
222
|
+
}
|
|
223
|
+
counts[persistenceProvability(projectDir, defenders).class] += 1;
|
|
224
|
+
}
|
|
225
|
+
return counts;
|
|
226
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where this project writes to storage, and what it writes.
|
|
3
|
+
*
|
|
4
|
+
* A probe answers "would a test notice if this were wrong". It cannot answer
|
|
5
|
+
* "how many places write to the database, and how many of those has anyone
|
|
6
|
+
* looked at" — and on an application with two hundred screens that second
|
|
7
|
+
* question is the one that decides whether a clean report means anything. A
|
|
8
|
+
* verdict with no denominator is not a guarantee; it is a sample of unknown
|
|
9
|
+
* size.
|
|
10
|
+
*
|
|
11
|
+
* So this enumerates the surface. Measured on a real AI-authored Next.js
|
|
12
|
+
* application: 115 write sites across 34 source files, of which 46% of the
|
|
13
|
+
* probed persistence faults survived a fully green suite, and every survivor
|
|
14
|
+
* was a field dropped from the payload. The faults are already produced by
|
|
15
|
+
* `field-dropped` and already ranked up by `onWritePath`; what was missing was
|
|
16
|
+
* the list of places to point them at.
|
|
17
|
+
*
|
|
18
|
+
* DELIBERATELY SHALLOW. This finds call sites, not data flow. It does not know
|
|
19
|
+
* whether a write is reachable, whether two writes are the same transaction,
|
|
20
|
+
* or whether a helper wraps one. A site it cannot see is reported as nothing
|
|
21
|
+
* at all rather than as an absence of risk — the same rule the scaffold
|
|
22
|
+
* producers follow, and the reason `skipped` exists on a sweep. Its output is
|
|
23
|
+
* a denominator and a set of targets, never a verdict.
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { join, extname } from 'node:path';
|
|
27
|
+
import { walk } from '../util/glob.mjs';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A call that puts data into storage.
|
|
31
|
+
*
|
|
32
|
+
* The receiver is matched by NAME because that is how these layers are bound
|
|
33
|
+
* in practice — `prisma.user.create`, `tx.invitation.upsert`, `db.orders.insertOne`.
|
|
34
|
+
* Matching by import would be more correct and would miss the common case
|
|
35
|
+
* where the client is a module-scope singleton imported under a project alias.
|
|
36
|
+
*
|
|
37
|
+
* `delete` is included: a save path that stops deleting is as broken as one
|
|
38
|
+
* that stops writing, and account deletion is where that matters most.
|
|
39
|
+
*/
|
|
40
|
+
const WRITE_CALL = /\b(prisma|tx|trx|db|database|knex|sequelize|mongoose|supabase|drizzle|client|conn)\s*\.\s*(?:([A-Za-z_$][\w$]*)\s*\.\s*)?(create|createMany|createOne|update|updateMany|updateOne|upsert|insert|insertOne|insertMany|save|delete|deleteMany|deleteOne|destroy|remove|set|put)\s*\(/g;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Raw SQL that writes. A template literal or a string argument, either way.
|
|
44
|
+
*
|
|
45
|
+
* Two regexes for one pattern, deliberately. A `g`-flagged RegExp carries
|
|
46
|
+
* `lastIndex` across calls, so `WRITE_SQL.test(a)` followed by
|
|
47
|
+
* `WRITE_SQL.test(b)` answers the second question from where the first one
|
|
48
|
+
* stopped — which makes a scan's result depend on the order the files were
|
|
49
|
+
* read. `matchAll` needs the flag; the cheap reject must not have it.
|
|
50
|
+
*/
|
|
51
|
+
const WRITE_SQL = /\b(INSERT\s+INTO|UPDATE\s+[A-Za-z_"`[]|DELETE\s+FROM|UPSERT\s+INTO)\b/gi;
|
|
52
|
+
const HAS_SQL = new RegExp(WRITE_SQL.source, 'i');
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every operation name the call pattern accepts, as one alternation. Derived
|
|
56
|
+
* rather than written twice: the cheap reject below used a shorter list, and a
|
|
57
|
+
* `deleteMany(` call matched the full pattern while failing the reject, so
|
|
58
|
+
* every file whose only write was a `...Many` or `...One` call was dropped
|
|
59
|
+
* from the surface before it was ever scanned.
|
|
60
|
+
*/
|
|
61
|
+
const OPERATIONS = 'create|createMany|createOne|update|updateMany|updateOne|upsert|insert|insertOne|insertMany|save|delete|deleteMany|deleteOne|destroy|remove|set|put';
|
|
62
|
+
const HAS_WRITE_CALL = new RegExp(`\\b(?:${OPERATIONS})\\s*\\(`);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Files that contain writes but are never the product's own save path.
|
|
66
|
+
*
|
|
67
|
+
* Seed and migration scripts write, and a real run surfaced
|
|
68
|
+
* `prisma/seed-badges.ts` as NOCOVER — true, and not a finding: nobody tests a
|
|
69
|
+
* seed script, and reporting it teaches the reader to skim. The directory
|
|
70
|
+
* forms are matched as well as the file stems, because a seed lives in
|
|
71
|
+
* `prisma/seed.ts` as often as in `seeds/`.
|
|
72
|
+
*/
|
|
73
|
+
const NOT_SOURCE = /\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)(tests?|__tests__|__mocks__|__fixtures__|fixtures|migrations|seeds?|scripts?|prisma|drizzle)\/|(^|\/)seeds?[.-][^/]*$|(^|\/)seeds?\.[cm]?[jt]sx?$/;
|
|
74
|
+
|
|
75
|
+
const SOURCE_EXT = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx']);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The text of one call's arguments, from its open parenthesis to the matching
|
|
79
|
+
* close, with quotes tracked so a parenthesis inside a string is not counted.
|
|
80
|
+
*
|
|
81
|
+
* The same scan `payloadAssertions` uses, and for the same reason: a
|
|
82
|
+
* fixed-width window reads into the NEXT call, and on a file with several
|
|
83
|
+
* writes in a row that silently attributes one write's payload to another.
|
|
84
|
+
*/
|
|
85
|
+
export function balancedArgs(source, open) {
|
|
86
|
+
let depth = 0;
|
|
87
|
+
let quote = null;
|
|
88
|
+
for (let i = open; i < source.length; i++) {
|
|
89
|
+
const c = source[i];
|
|
90
|
+
if (quote) {
|
|
91
|
+
if (c === '\\') i += 1;
|
|
92
|
+
else if (c === quote) quote = null;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (c === '"' || c === "'" || c === '`') quote = c;
|
|
96
|
+
else if (c === '(') depth += 1;
|
|
97
|
+
else if (c === ')') {
|
|
98
|
+
depth -= 1;
|
|
99
|
+
if (depth === 0) return { start: open + 1, end: i, text: source.slice(open + 1, i) };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { start: open + 1, end: source.length, text: source.slice(open + 1) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The keys of a write's payload, each with the line it sits on.
|
|
107
|
+
*
|
|
108
|
+
* Both property forms, because shorthand is not an edge case here: every
|
|
109
|
+
* surviving payload fault measured on the register action was shorthand
|
|
110
|
+
* (`email,` `username,` `password_hash,`). A pattern requiring a colon would
|
|
111
|
+
* miss exactly the fields this exists to find.
|
|
112
|
+
*
|
|
113
|
+
* Keys are read only where they sit on their own line, which is what the
|
|
114
|
+
* `field-dropped` producer can anchor to. A single-line write like
|
|
115
|
+
* `update({ where, data })` therefore reports no keys — correctly, because
|
|
116
|
+
* there is no line to delete.
|
|
117
|
+
*/
|
|
118
|
+
export function payloadKeys(argsText, firstLine) {
|
|
119
|
+
const keys = [];
|
|
120
|
+
argsText.split('\n').forEach((line, i) => {
|
|
121
|
+
const m = /^\s*(?:['"`])?([A-Za-z_$][\w$]*)(?:['"`])?\s*(?::|,\s*$)/.exec(line);
|
|
122
|
+
if (m) keys.push({ key: m[1], line: firstLine + i });
|
|
123
|
+
});
|
|
124
|
+
return keys;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Line number (1-based) of an index into a source string. */
|
|
128
|
+
const lineAt = (source, index) => source.slice(0, index).split('\n').length;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Every write this file performs. Pure apart from the read; exported so the
|
|
132
|
+
* shape can be tested against a string without a project on disk.
|
|
133
|
+
*/
|
|
134
|
+
export function writeSitesIn(source, file) {
|
|
135
|
+
const sites = [];
|
|
136
|
+
for (const m of source.matchAll(WRITE_CALL)) {
|
|
137
|
+
const open = m.index + m[0].length - 1;
|
|
138
|
+
const args = balancedArgs(source, open);
|
|
139
|
+
const line = lineAt(source, m.index);
|
|
140
|
+
sites.push({
|
|
141
|
+
file,
|
|
142
|
+
line,
|
|
143
|
+
endLine: lineAt(source, args.end),
|
|
144
|
+
receiver: m[1],
|
|
145
|
+
model: m[2] ?? null,
|
|
146
|
+
operation: m[3],
|
|
147
|
+
keys: payloadKeys(args.text, lineAt(source, args.start)),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
for (const m of source.matchAll(WRITE_SQL)) {
|
|
151
|
+
sites.push({
|
|
152
|
+
file,
|
|
153
|
+
line: lineAt(source, m.index),
|
|
154
|
+
endLine: lineAt(source, m.index),
|
|
155
|
+
receiver: 'sql',
|
|
156
|
+
model: null,
|
|
157
|
+
operation: m[1].split(/\s+/)[0].toLowerCase(),
|
|
158
|
+
keys: [],
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
// Deterministic: a surface that reorders between runs is not a denominator.
|
|
162
|
+
return sites.sort((a, b) => a.line - b.line || a.operation.localeCompare(b.operation));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Is this path part of the product's own save surface? */
|
|
166
|
+
export const isSaveSource = (rel) => SOURCE_EXT.has(extname(rel)) && !NOT_SOURCE.test(rel);
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The project's whole write surface: every source file that writes, and every
|
|
170
|
+
* write in it.
|
|
171
|
+
*
|
|
172
|
+
* `files` may be given to scope the scan (a diff, a directory); the default is
|
|
173
|
+
* every source file the glob walker can see.
|
|
174
|
+
*/
|
|
175
|
+
export function saveSurface(projectDir, { files } = {}) {
|
|
176
|
+
const candidates = (files ?? walk(projectDir)).filter(isSaveSource);
|
|
177
|
+
const byFile = [];
|
|
178
|
+
let siteCount = 0;
|
|
179
|
+
let keyCount = 0;
|
|
180
|
+
for (const rel of candidates) {
|
|
181
|
+
let source;
|
|
182
|
+
try {
|
|
183
|
+
source = readFileSync(join(projectDir, rel), 'utf8');
|
|
184
|
+
} catch {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
// Cheap reject before the balanced scan: most files never write.
|
|
188
|
+
if (!HAS_WRITE_CALL.test(source) && !HAS_SQL.test(source)) continue;
|
|
189
|
+
const sites = writeSitesIn(source, rel);
|
|
190
|
+
if (!sites.length) continue;
|
|
191
|
+
byFile.push({ file: rel, sites });
|
|
192
|
+
siteCount += sites.length;
|
|
193
|
+
keyCount += sites.reduce((a, s) => a + s.keys.length, 0);
|
|
194
|
+
}
|
|
195
|
+
byFile.sort((a, b) => a.file.localeCompare(b.file));
|
|
196
|
+
return { files: byFile, fileCount: byFile.length, siteCount, keyCount };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The surface as text — the denominator, said out loud.
|
|
201
|
+
*
|
|
202
|
+
* A report that lists findings without saying how much was looked at invites
|
|
203
|
+
* the reader to assume the rest is fine, which is the assumption this whole
|
|
204
|
+
* tool exists to remove.
|
|
205
|
+
*/
|
|
206
|
+
export function renderSurface(surface, { limit = 10 } = {}) {
|
|
207
|
+
if (surface.fileCount === 0) return 'no write to storage found in this project.';
|
|
208
|
+
const out = [`${surface.siteCount} write${surface.siteCount === 1 ? '' : 's'} to storage across ${surface.fileCount} file${surface.fileCount === 1 ? '' : 's'}, carrying ${surface.keyCount} payload field${surface.keyCount === 1 ? '' : 's'} on their own lines.`];
|
|
209
|
+
const ranked = [...surface.files].sort((a, b) => b.sites.length - a.sites.length || a.file.localeCompare(b.file));
|
|
210
|
+
for (const f of ranked.slice(0, limit)) {
|
|
211
|
+
const ops = [...new Set(f.sites.map((s) => s.operation))].sort().join(', ');
|
|
212
|
+
out.push(` ${String(f.sites.length).padStart(3)} ${f.file} (${ops})`);
|
|
213
|
+
}
|
|
214
|
+
if (ranked.length > limit) out.push(` … ${ranked.length - limit} more files`);
|
|
215
|
+
return out.join('\n');
|
|
216
|
+
}
|