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
package/src/cli.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { gateCommand } from './commands/gate.mjs';
|
|
|
18
18
|
import { replayCommand } from './commands/replay.mjs';
|
|
19
19
|
import { mcpCommand } from './commands/mcp.mjs';
|
|
20
20
|
import { admitCommand } from './commands/admit.mjs';
|
|
21
|
+
import { sweepCommand } from './commands/sweep.mjs';
|
|
21
22
|
|
|
22
23
|
const VERSION = JSON.parse(readFileSync(join(fileURLToPath(import.meta.url), '..', '..', 'package.json'), 'utf8')).version;
|
|
23
24
|
const ISSUES = 'https://github.com/raccioly/testguard/issues';
|
|
@@ -32,6 +33,7 @@ export const USAGE = `testguard ${VERSION} — proves a test suite defends the c
|
|
|
32
33
|
testguard brief [dir] emit the blind-spot block for an agent's session-start context
|
|
33
34
|
testguard gate [dir] fail when a changed source file carries no claim and no excusing ignore entry
|
|
34
35
|
testguard scaffold <file> propose faults mechanically for one source file, as a draft claims document
|
|
36
|
+
testguard sweep [dir] propose faults for the changed source files that carry no claim, probe a bounded selection, and report what nothing noticed
|
|
35
37
|
testguard mcp serve the read-only status/brief/claims/evidence tools over MCP on stdio, so the loop works in any agent harness
|
|
36
38
|
testguard replay [dir] would this suite have caught the bugs that already escaped? Replays real fix commits and calibrates fault classes against them
|
|
37
39
|
testguard admit <test> --claim <ID> the two-gate rule as one verb: is this test green on HEAD and does it fail on every fault of the claim?
|
|
@@ -74,6 +76,13 @@ probe
|
|
|
74
76
|
--json the status document plus this run's result (records, newSinceBaseline, exitCode)
|
|
75
77
|
|
|
76
78
|
scaffold --claim <ID> (put every proposal under this claim; copies it if it exists) --out <path> --json
|
|
79
|
+
sweep --changed <ref> (required unless --save-paths; CI bases and a safe local default are detected) --cap <n>
|
|
80
|
+
--save-paths sweep every file that WRITES TO STORAGE instead of the diff, and report the surface as the
|
|
81
|
+
denominator: "120 writes across 38 files, swept 12". A clean sweep over an unstated
|
|
82
|
+
denominator is a sample of unknown size, not a guarantee that saving works
|
|
83
|
+
--include-dirty --exclude <glob> --confirm <n> --budget <ms> --max <n> --out <path> --json
|
|
84
|
+
the cold start: no claim and no concern needed. Everything it proposes is a DRAFT — it never writes testguard.claims.json,
|
|
85
|
+
and its evidence never replaces .testguard/evidence.json. exit 1 on a fault that survived, or a file no test imports
|
|
77
86
|
shapes: if-guard → if (false) · single-line guard/mutation removed · return <check> → return true
|
|
78
87
|
· security flag/window/cost literal weakened · verify/validate/check call removed
|
|
79
88
|
· field dropped from a payload/allow-list/schema/merge · parameter-derived argument swapped for undefined/{}
|
|
@@ -117,7 +126,7 @@ brief --evidence <path> --baseline <path> --max <n> --text (print only;
|
|
|
117
126
|
exit codes: 0 nothing new to prove · 1 unproven claims (or claim drift, or unclaimed changes) · 2 precondition failed · 3 usage
|
|
118
127
|
`;
|
|
119
128
|
|
|
120
|
-
const COMMANDS = { mcp: mcpCommand, replay: replayCommand, probe: probeCommand, claims: claimsCommand, baseline: baselineCommand, brief: briefCommand, scaffold: scaffoldCommand, status: statusCommand, init: initCommand, gate: gateCommand, admit: admitCommand };
|
|
129
|
+
const COMMANDS = { mcp: mcpCommand, replay: replayCommand, probe: probeCommand, claims: claimsCommand, baseline: baselineCommand, brief: briefCommand, scaffold: scaffoldCommand, status: statusCommand, init: initCommand, gate: gateCommand, admit: admitCommand, sweep: sweepCommand };
|
|
121
130
|
|
|
122
131
|
export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n'), err: (s) => process.stderr.write(s + '\n') }) {
|
|
123
132
|
let parsed;
|
|
@@ -143,6 +152,8 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
|
|
|
143
152
|
changed: { type: 'string' },
|
|
144
153
|
since: { type: 'string' },
|
|
145
154
|
exclude: { type: 'string', multiple: true },
|
|
155
|
+
cap: { type: 'string' },
|
|
156
|
+
'save-paths': { type: 'boolean', default: false },
|
|
146
157
|
strict: { type: 'boolean', default: false },
|
|
147
158
|
explain: { type: 'boolean', default: false },
|
|
148
159
|
ignore: { type: 'string' },
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { join, resolve } from 'node:path';
|
|
2
|
+
import { sweep, renderSweep } from '../sweep/sweep.mjs';
|
|
3
|
+
import { resolveChangedRef } from '../gate/changed.mjs';
|
|
4
|
+
import { writeSpecDoc } from '../evidence/writer.mjs';
|
|
5
|
+
|
|
6
|
+
export const sweepPath = (projectDir) => join(projectDir, '.testguard', 'sweep.json');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `testguard sweep [dir] --changed <ref>`: propose faults for the changed
|
|
10
|
+
* source files that carry no claim, probe a bounded selection, and report what
|
|
11
|
+
* a green suite did not notice.
|
|
12
|
+
*
|
|
13
|
+
* Exit 0 nothing survived · 1 a survivor or an untested file · 2 cannot
|
|
14
|
+
* evaluate · 3 no reference.
|
|
15
|
+
*
|
|
16
|
+
* The sweep document goes to `.testguard/sweep.json` and NEVER to
|
|
17
|
+
* `.testguard/evidence.json`. The canonical evidence is the record of what the
|
|
18
|
+
* project claims; a sweep probes faults nobody stated, under TODO statements,
|
|
19
|
+
* and folding one into the other would corrupt what `status` and `baseline`
|
|
20
|
+
* read.
|
|
21
|
+
*/
|
|
22
|
+
export async function sweepCommand({ projectDir, values, version }, io) {
|
|
23
|
+
const mode = values['save-paths'] ? 'save-paths' : 'changed';
|
|
24
|
+
// `--save-paths` scans the whole write surface, so it needs no diff. The gate
|
|
25
|
+
// is still computed underneath (the document reports `changed`), and HEAD is
|
|
26
|
+
// a reference every repository has.
|
|
27
|
+
const resolved = resolveChangedRef({ explicit: values.changed, projectDir })
|
|
28
|
+
?? (mode === 'save-paths' ? { ref: 'HEAD', from: 'save-paths', required: false } : null);
|
|
29
|
+
if (!resolved) {
|
|
30
|
+
io.err('sweep needs a reference to measure the change against: --changed <ref> (e.g. origin/main), or set TESTGUARD_CHANGED_REF. CI bases and a safe local remote default or differently named upstream are detected automatically. Or sweep the write surface instead with --save-paths.');
|
|
31
|
+
return 3;
|
|
32
|
+
}
|
|
33
|
+
const confirmRuns = Number(values.confirm);
|
|
34
|
+
const budgetMs = Number(values.budget);
|
|
35
|
+
if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
|
|
36
|
+
io.err('--confirm must be a positive integer and --budget at least 1000');
|
|
37
|
+
return 3;
|
|
38
|
+
}
|
|
39
|
+
let cap;
|
|
40
|
+
if (values.cap !== undefined) {
|
|
41
|
+
cap = Number(values.cap);
|
|
42
|
+
if (!Number.isInteger(cap) || cap < 1) {
|
|
43
|
+
io.err('--cap must be a positive integer');
|
|
44
|
+
return 3;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (mode === 'changed' && !resolved.required && !values.json && !values.quiet) io.err(`sweep: comparing against ${resolved.ref} (${resolved.from})`);
|
|
48
|
+
|
|
49
|
+
const doc = await sweep({
|
|
50
|
+
projectDir,
|
|
51
|
+
ref: resolved.ref,
|
|
52
|
+
mode,
|
|
53
|
+
includeDirty: values['include-dirty'],
|
|
54
|
+
exclude: values.exclude ?? [],
|
|
55
|
+
cap,
|
|
56
|
+
confirmRuns,
|
|
57
|
+
budgetMs,
|
|
58
|
+
runnerCommand: values['runner-cmd'],
|
|
59
|
+
runnerName: values.runner,
|
|
60
|
+
nodeModules: values['node-modules'] ? resolve(values['node-modules']) : process.env.TESTGUARD_NODE_MODULES,
|
|
61
|
+
toolVersion: version,
|
|
62
|
+
claimsPath: values.claims ? resolve(values.claims) : undefined,
|
|
63
|
+
ignorePath: values.ignore ? resolve(values.ignore) : undefined,
|
|
64
|
+
onStage: !values.quiet && !values.json && process.stderr.isTTY
|
|
65
|
+
? ({ claimId, faultId, stage, i, n }) => process.stderr.write(`\r\x1b[K … ${claimId}/${faultId} ${stage} ${i}/${n}`)
|
|
66
|
+
: undefined,
|
|
67
|
+
onWarn: (m) => { if (!values.quiet) io.err(`warning: ${m}`); },
|
|
68
|
+
});
|
|
69
|
+
if (process.stderr.isTTY && !values.quiet && !values.json) process.stderr.write('\r\x1b[K');
|
|
70
|
+
|
|
71
|
+
const outPath = values.out ? resolve(values.out) : sweepPath(projectDir);
|
|
72
|
+
writeSpecDoc('sweep', outPath, doc);
|
|
73
|
+
if (values.json) {
|
|
74
|
+
io.out(JSON.stringify(doc, null, 2));
|
|
75
|
+
} else {
|
|
76
|
+
io.out(renderSweep(doc, { limit: Number(values.max) || 20 }));
|
|
77
|
+
if (!values.quiet) io.out(`\nsweep: ${outPath}`);
|
|
78
|
+
}
|
|
79
|
+
return doc.exitCode;
|
|
80
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The save-button signal: why a fault on the persistence path survived.
|
|
3
|
+
*
|
|
4
|
+
* `mocks.mjs` answers "does this test replace the SUBJECT?", and a file that
|
|
5
|
+
* does cannot detect any fault in it. This is the other half, and it is the
|
|
6
|
+
* one that explains a green suite over a save that does not save. Such a test
|
|
7
|
+
* does NOT mock the subject — it mocks the persistence layer underneath it —
|
|
8
|
+
* so it is a legitimate defender and stays one. It can catch a deleted write.
|
|
9
|
+
* What it cannot catch is a wrong PAYLOAD, and only because of how it asserts.
|
|
10
|
+
*
|
|
11
|
+
* Measured on a real AI-authored Next.js application (2026-09-21): 10 of 10
|
|
12
|
+
* server-action tests mocked `@/lib/prisma`, and 12 of 12 surviving faults on
|
|
13
|
+
* the persistence path were a field dropped from a `data` / `where` object.
|
|
14
|
+
* The assertion that should have caught them was `toHaveBeenCalledWith(
|
|
15
|
+
* expect.objectContaining({...}))` naming every field except the one carrying
|
|
16
|
+
* the data — the pathology this project's README reports from the field.
|
|
17
|
+
*
|
|
18
|
+
* DESCRIPTIVE, NOT PREDICTIVE — and that is a correction the measurement
|
|
19
|
+
* forced. The first version of this file silenced itself when a test contained
|
|
20
|
+
* any exact payload assertion. On the same 26 probed faults that rule fired on
|
|
21
|
+
* 1 of 10 files while 4 had survivors, because a file asserts many calls and
|
|
22
|
+
* the one exact assertion is usually about something else:
|
|
23
|
+
* `identity-password-recovery.test.ts` has one exact assertion among forty and
|
|
24
|
+
* every one of its payload faults survived, while `contact.test.ts` has the
|
|
25
|
+
* same one exact assertion and none did. No file-level threshold separates
|
|
26
|
+
* those two, so this reports the SHAPE and refuses to score it.
|
|
27
|
+
*
|
|
28
|
+
* It is therefore attached to a verdict the probe already reached, never used
|
|
29
|
+
* to predict one or to select what gets probed. The probe says a fault
|
|
30
|
+
* survived; this says the structural reason and what to write instead.
|
|
31
|
+
*
|
|
32
|
+
* Line- and regex-oriented, like the scaffold producers: no AST, no type
|
|
33
|
+
* information, and an honest miss rather than a confident wrong claim. In
|
|
34
|
+
* particular there is no read-back detection here. A regex for "the test asked
|
|
35
|
+
* for the row again" matched every file that mocks `findUnique` and asserts
|
|
36
|
+
* anything, including files whose payload faults all survived, so it measured
|
|
37
|
+
* nothing and was removed rather than shipped as a silencer.
|
|
38
|
+
*/
|
|
39
|
+
import { readFileSync } from 'node:fs';
|
|
40
|
+
import { join } from 'node:path';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Module specifiers that mean "the database". A project-local path counts when
|
|
44
|
+
* it names one of these — `@/lib/prisma`, `../db`, `~/server/drizzle` — which
|
|
45
|
+
* is how these layers are named in practice.
|
|
46
|
+
*/
|
|
47
|
+
const PERSISTENCE_SPECIFIER = /(?:^|[/@~.])(?:prisma|db|database|knex|sequelize|mongoose|typeorm|drizzle|supabase|pg|mysql2?|sqlite3?|dynamodb|mongodb)(?:$|[/.])/i;
|
|
48
|
+
|
|
49
|
+
/** `vi.mock('x')` / `jest.mock('x')` — the same call shapes mocks.mjs reads. */
|
|
50
|
+
const MOCK_CALL = /\b(?:vi|jest)\.(?:mock|doMock|unstable_mockModule)\(\s*(['"])([^'"]+)\1/g;
|
|
51
|
+
|
|
52
|
+
/** Partial matchers: true of a subset, so a dropped field outside the subset passes. */
|
|
53
|
+
const PARTIAL_MATCHER = /expect\.(?:objectContaining|arrayContaining|any|anything|stringContaining|stringMatching)\s*\(/;
|
|
54
|
+
|
|
55
|
+
/** The persistence specifiers this source mocks, with line numbers. */
|
|
56
|
+
export function persistenceMocks(source) {
|
|
57
|
+
const out = [];
|
|
58
|
+
source.split('\n').forEach((text, i) => {
|
|
59
|
+
for (const m of text.matchAll(MOCK_CALL)) {
|
|
60
|
+
if (PERSISTENCE_SPECIFIER.test(m[2])) out.push({ specifier: m[2], line: i + 1 });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The text between an open parenthesis at `open` and its match, so one
|
|
68
|
+
* assertion's arguments are read without reading the next one's.
|
|
69
|
+
*
|
|
70
|
+
* A fixed-width window cannot do this: two assertions on adjacent lines put the
|
|
71
|
+
* second one's `expect.objectContaining` inside the first one's window, and
|
|
72
|
+
* every exact assertion in a dense test file is reported as partial. Measured —
|
|
73
|
+
* the first version of this scored a file with one exact and one partial
|
|
74
|
+
* assertion as two partials.
|
|
75
|
+
*
|
|
76
|
+
* Quotes are tracked because a parenthesis inside a string is not a
|
|
77
|
+
* parenthesis; anything unbalanced returns what it read, which errs toward
|
|
78
|
+
* reporting a partial matcher that is really there.
|
|
79
|
+
*/
|
|
80
|
+
function balancedArgs(source, open) {
|
|
81
|
+
let depth = 0;
|
|
82
|
+
let quote = null;
|
|
83
|
+
for (let i = open; i < source.length; i++) {
|
|
84
|
+
const c = source[i];
|
|
85
|
+
if (quote) {
|
|
86
|
+
if (c === '\\') i += 1;
|
|
87
|
+
else if (c === quote) quote = null;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (c === '"' || c === "'" || c === '`') quote = c;
|
|
91
|
+
else if (c === '(') depth += 1;
|
|
92
|
+
else if (c === ')') {
|
|
93
|
+
depth -= 1;
|
|
94
|
+
if (depth === 0) return source.slice(open + 1, i);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return source.slice(open + 1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* How this source asserts on its mocks: how many assertions name the exact
|
|
102
|
+
* arguments, how many name them only partially, and how many assert that a
|
|
103
|
+
* call happened without saying with what.
|
|
104
|
+
*
|
|
105
|
+
* Counting rather than deciding. The counts go into the reason string so a
|
|
106
|
+
* reader can disagree with the inference instead of only with a verdict.
|
|
107
|
+
*/
|
|
108
|
+
export function payloadAssertions(source) {
|
|
109
|
+
let exact = 0;
|
|
110
|
+
let partial = 0;
|
|
111
|
+
let argless = 0;
|
|
112
|
+
for (const m of source.matchAll(/\.toHaveBeenCalledWith\s*\(/g)) {
|
|
113
|
+
const open = m.index + m[0].length - 1;
|
|
114
|
+
if (PARTIAL_MATCHER.test(balancedArgs(source, open))) partial += 1;
|
|
115
|
+
else exact += 1;
|
|
116
|
+
}
|
|
117
|
+
for (const _ of source.matchAll(/\.(?:toHaveBeenCalled|toHaveBeenCalledTimes|toHaveBeenCalledOnce)\s*\(/g)) argless += 1;
|
|
118
|
+
return { exact, partial, argless };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The shape of one defender's relationship to the persistence layer, or null
|
|
123
|
+
* when it does not mock one. Shaped like the records `mocks.mjs` produces so a
|
|
124
|
+
* caller can merge the two lists without reshaping either.
|
|
125
|
+
*/
|
|
126
|
+
export function analyzePersistence(projectDir, testRel) {
|
|
127
|
+
let source;
|
|
128
|
+
try {
|
|
129
|
+
source = readFileSync(join(projectDir, testRel), 'utf8');
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const mocks = persistenceMocks(source);
|
|
134
|
+
if (mocks.length === 0) return null;
|
|
135
|
+
const counts = payloadAssertions(source);
|
|
136
|
+
const specifiers = [...new Set(mocks.map((m) => m.specifier))].sort();
|
|
137
|
+
const shape = [
|
|
138
|
+
counts.exact ? `${counts.exact} exact` : '',
|
|
139
|
+
counts.partial ? `${counts.partial} partial` : '',
|
|
140
|
+
counts.argless ? `${counts.argless} argument-free` : '',
|
|
141
|
+
].filter(Boolean).join(', ') || 'no';
|
|
142
|
+
return {
|
|
143
|
+
file: testRel,
|
|
144
|
+
signal: 'persistence-payload-unasserted',
|
|
145
|
+
reason: `mocks ${specifiers.join(', ')}; ${shape} call assertion${counts.exact + counts.partial + counts.argless === 1 ? '' : 's'} in the file. A field dropped from the write payload fails only against an assertion that names that field exactly.`,
|
|
146
|
+
counts,
|
|
147
|
+
specifiers,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The signal for a claim's defenders. Only ever consulted for a SURVIVOR whose
|
|
153
|
+
* fault is on the persistence path: on a kill it explains nothing, and on any
|
|
154
|
+
* other line a loose mock assertion is not evidence of anything. Reporting it
|
|
155
|
+
* everywhere would be the non-actionable noise that gets a check switched off.
|
|
156
|
+
*/
|
|
157
|
+
export function persistenceSignals(projectDir, defenders) {
|
|
158
|
+
return defenders.map((d) => analyzePersistence(projectDir, d)).filter(Boolean);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* One line an agent can act on, in the register `hintFor()` uses.
|
|
163
|
+
*
|
|
164
|
+
* The field name is read from either property form. Shorthand is not an edge
|
|
165
|
+
* case here: every surviving payload fault measured on the register action was
|
|
166
|
+
* shorthand — `email,` `username,` `password_hash,` — so a pattern that only
|
|
167
|
+
* matched `name: value` would fail to name the field in exactly the cases that
|
|
168
|
+
* prompted this signal.
|
|
169
|
+
*/
|
|
170
|
+
export function persistenceHint(signal, faultLine) {
|
|
171
|
+
const field = /^\s*([A-Za-z_$][\w$]*)\s*(?::|,\s*$)/.exec(faultLine ?? '')?.[1];
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|