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/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
+ }