@broberg/lens-daemon 0.0.0-stage → 0.1.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/storage.ts ADDED
@@ -0,0 +1,159 @@
1
+ // F074.3 — Lens artifact storage adapter.
2
+ //
3
+ // Screenshots + diff PNGs are bytes; they don't belong in SQLite. This
4
+ // adapter abstracts "put / get / delete bytes by key" behind two backends:
5
+ //
6
+ // - local (default) : a directory on the daemon machine. Zero config —
7
+ // the MVP + dev reality (the daemon is on Christian's
8
+ // Mac; the SPA fetches artifact bytes from the daemon's
9
+ // own HTTP, the same way FleetTemplatesSection already
10
+ // talks to 127.0.0.1:7475).
11
+ // - tigris (opt-in) : S3-compatible durable storage via Bun's native
12
+ // S3Client. Enabled when LENS_TIGRIS_BUCKET + creds are
13
+ // in the daemon env. Region is 'auto' (Tigris is global;
14
+ // the bucket itself is created in arn per project policy).
15
+ //
16
+ // Keys are POSIX-style paths, e.g. "<project>/runs/<id>.png". The backend is
17
+ // chosen once at construction from env; callers never branch on it.
18
+
19
+ import { S3Client } from 'bun';
20
+ import { mkdir, readFile, writeFile, rm } from 'node:fs/promises';
21
+ import { dirname, join } from 'node:path';
22
+
23
+ export interface LensStorage {
24
+ readonly backend: 'local' | 'tigris';
25
+ put(key: string, bytes: Buffer, contentType?: string): Promise<void>;
26
+ get(key: string): Promise<Buffer | null>;
27
+ delete(key: string): Promise<void>;
28
+ }
29
+
30
+ class LocalDirStorage implements LensStorage {
31
+ readonly backend = 'local' as const;
32
+ constructor(private readonly root: string) {}
33
+
34
+ private path(key: string): string {
35
+ return join(this.root, key);
36
+ }
37
+
38
+ async put(key: string, bytes: Buffer): Promise<void> {
39
+ const p = this.path(key);
40
+ await mkdir(dirname(p), { recursive: true });
41
+ await writeFile(p, bytes);
42
+ }
43
+
44
+ async get(key: string): Promise<Buffer | null> {
45
+ try {
46
+ return await readFile(this.path(key));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ async delete(key: string): Promise<void> {
53
+ await rm(this.path(key), { force: true });
54
+ }
55
+ }
56
+
57
+ class TigrisStorage implements LensStorage {
58
+ readonly backend = 'tigris' as const;
59
+ constructor(private readonly client: S3Client) {}
60
+
61
+ async put(key: string, bytes: Buffer, contentType = 'image/png'): Promise<void> {
62
+ await this.client.write(key, bytes, { type: contentType });
63
+ }
64
+
65
+ async get(key: string): Promise<Buffer | null> {
66
+ try {
67
+ const buf = await this.client.file(key).arrayBuffer();
68
+ return Buffer.from(buf);
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ async delete(key: string): Promise<void> {
75
+ await this.client.delete(key);
76
+ }
77
+ }
78
+
79
+ export interface StorageEnv {
80
+ LENS_TIGRIS_BUCKET?: string;
81
+ LENS_TIGRIS_ENDPOINT?: string;
82
+ AWS_ACCESS_KEY_ID?: string;
83
+ AWS_SECRET_ACCESS_KEY?: string;
84
+ }
85
+
86
+ /** Build the storage backend. Tigris when a bucket + creds are present in
87
+ * env; otherwise a local directory under the daemon's app-support dir. */
88
+ export function createStorage(localRoot: string, env: StorageEnv = process.env as unknown as StorageEnv): LensStorage {
89
+ const bucket = env.LENS_TIGRIS_BUCKET;
90
+ const accessKeyId = env.AWS_ACCESS_KEY_ID;
91
+ const secretAccessKey = env.AWS_SECRET_ACCESS_KEY;
92
+ if (bucket && accessKeyId && secretAccessKey) {
93
+ const client = new S3Client({
94
+ bucket,
95
+ accessKeyId,
96
+ secretAccessKey,
97
+ // Fly Tigris default endpoint; overridable for other S3-compatible stores.
98
+ endpoint: env.LENS_TIGRIS_ENDPOINT ?? 'https://fly.storage.tigris.dev',
99
+ region: 'auto',
100
+ });
101
+ return new TigrisStorage(client);
102
+ }
103
+ return new LocalDirStorage(localRoot);
104
+ }
105
+
106
+ /** Conventional artifact keys — one place so runs + baselines + the HTTP
107
+ * surface never disagree on layout. */
108
+ export const artifactKeys = {
109
+ run: (project: string, runId: string) => `${project}/runs/${runId}.png`,
110
+ diff: (project: string, runId: string) => `${project}/runs/${runId}.diff.png`,
111
+ baseline: (project: string, baselineKey: string) =>
112
+ `${project}/baselines/${encodeURIComponent(baselineKey)}.png`,
113
+ // F094.3 — content-addressed per-version baseline artifact (never overwritten).
114
+ baselineVersion: (project: string, baselineKey: string, versionNo: number) =>
115
+ `${project}/baselines/${encodeURIComponent(baselineKey)}/v${versionNo}.png`,
116
+ // F082 — per-step screenshot of a flow run.
117
+ flowStep: (project: string, flowRunId: string, stepIndex: number) =>
118
+ `${project}/flows/${flowRunId}/step-${stepIndex}.png`,
119
+ // F255.5 — the whole driven run as a video, alongside its step stills.
120
+ flowVideo: (project: string, flowRunId: string) => `${project}/flows/${flowRunId}/run.webm`,
121
+ // F074.63 — a vector export (PDF). Its own prefix, not runs/: an export is not
122
+ // a capture, carries no baseline and must never be diffed against one.
123
+ exportDoc: (project: string, exportId: string, ext: string) =>
124
+ `${project}/exports/${exportId}.${ext}`,
125
+ };
126
+
127
+ /** F074.63 — what to serve a stored artifact as.
128
+ *
129
+ * This map replaces a two-branch guess that defaulted everything-not-.webm to
130
+ * image/png. That default was already wrong once (F255.5, video) and would be
131
+ * wrong again for every format added after — and the failure is silent: the
132
+ * browser gets a PDF labelled as a PNG and simply cannot open it. For an export
133
+ * whose entire purpose is to be handed to a customer's graphic designer, an
134
+ * un-openable file is indistinguishable from no feature at all. */
135
+ export function artifactContentType(key: string): string {
136
+ const ext = key.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? '';
137
+ switch (ext) {
138
+ case 'pdf':
139
+ return 'application/pdf';
140
+ case 'webm':
141
+ return 'video/webm';
142
+ // NO 'svg' BRANCH, and its absence is the point. /lens/artifact is
143
+ // un-authenticated on loopback, so a file served as image/svg+xml executes
144
+ // its own script in the daemon's origin the moment anyone opens it directly
145
+ // — and that origin can drive the browser, read the project store and reach
146
+ // every other /lens route. Nothing in Lens writes an .svg today, so the
147
+ // branch bought nothing and widened that. An .svg falls through to
148
+ // octet-stream below: it downloads intact and runs nothing.
149
+ case 'jpg':
150
+ case 'jpeg':
151
+ return 'image/jpeg';
152
+ case 'png':
153
+ return 'image/png';
154
+ default:
155
+ // Not a guess dressed as a fact: an unknown type is served as opaque bytes
156
+ // so a browser downloads it intact rather than mis-rendering it as an image.
157
+ return 'application/octet-stream';
158
+ }
159
+ }
package/src/store.ts ADDED
@@ -0,0 +1,267 @@
1
+ // F074.3 — Lens runs + baselines data layer.
2
+ //
3
+ // Ties a verify result to persistence: screenshot/diff BYTES go to the
4
+ // storage adapter; the run METADATA goes to the daemon's SQLite. Baseline
5
+ // approval promotes a stored run screenshot into the baseline slot for its
6
+ // surface key (one baseline per project_slug + baseline_key).
7
+ //
8
+ // Everything is hard-scoped to project_slug — a project's runs/baselines are
9
+ // never visible to another project.
10
+
11
+ import type { Database } from 'bun:sqlite';
12
+ import { randomUUID } from 'node:crypto';
13
+ import type { LensRunRow, LensBaselineRow, LensBaselineVersionRow } from './schema.ts';
14
+ import { artifactKeys, type LensStorage } from './storage.ts';
15
+ import { enqueueArtifacts } from './artifact-uploads.ts';
16
+ import type { VerifyResult } from './verify.ts';
17
+
18
+ export interface RecordRunInput {
19
+ project_slug: string;
20
+ surface_key?: string | null;
21
+ }
22
+
23
+ /** Persist a verify run: store screenshot (+ diff PNG) bytes, insert the
24
+ * metadata row. Returns the new run id. */
25
+ export async function recordRun(
26
+ db: Database,
27
+ storage: LensStorage,
28
+ result: VerifyResult,
29
+ input: RecordRunInput,
30
+ ): Promise<LensRunRow> {
31
+ const id = randomUUID();
32
+ const now = Date.now();
33
+ const project = input.project_slug;
34
+
35
+ let artifactKey: string | null = null;
36
+ let diffArtifactKey: string | null = null;
37
+ if (result.screenshot) {
38
+ artifactKey = artifactKeys.run(project, id);
39
+ await storage.put(artifactKey, result.screenshot);
40
+ }
41
+ if (result.diff?.diffPng) {
42
+ diffArtifactKey = artifactKeys.diff(project, id);
43
+ await storage.put(diffArtifactKey, result.diff.diffPng);
44
+ }
45
+ // F276.1 — the capture path needs this as much as the flow path. Christian's
46
+ // complaint was about Lens images generally, and 2410 of the 6588 files on disk
47
+ // are capture runs; hooking only flows would have fixed the half I happened to
48
+ // be working on and left the other half invisible on his phone.
49
+ enqueueArtifacts(db, project, [artifactKey, diffArtifactKey]);
50
+
51
+ const row: LensRunRow = {
52
+ id,
53
+ project_slug: project,
54
+ surface_key: input.surface_key ?? result.selector ?? null,
55
+ url: result.url,
56
+ mode: result.mode,
57
+ selector: result.selector,
58
+ status: result.status,
59
+ reason: result.reason,
60
+ diff_ratio: result.diff?.diffRatio ?? null,
61
+ artifact_key: artifactKey,
62
+ diff_artifact_key: diffArtifactKey,
63
+ dom_hash: result.domHash,
64
+ assert_pass: result.assertResult ? (result.assertResult.pass ? 1 : 0) : null,
65
+ assert_detail_json: result.assertResult ? JSON.stringify(result.assertResult.detail ?? null) : null,
66
+ console_json: JSON.stringify(result.console),
67
+ network_json: JSON.stringify(result.network),
68
+ page_errors_json: JSON.stringify(result.pageErrors),
69
+ width: result.width,
70
+ height: result.height,
71
+ duration_ms: result.durationMs,
72
+ critic_json: result.critic ? JSON.stringify(result.critic) : null,
73
+ created_at: now,
74
+ build: result.build ?? null,
75
+ };
76
+
77
+ db.prepare(
78
+ `INSERT INTO lens_runs (
79
+ id, project_slug, surface_key, url, mode, selector, status, reason,
80
+ diff_ratio, artifact_key, diff_artifact_key, dom_hash, assert_pass,
81
+ assert_detail_json, console_json, network_json, page_errors_json,
82
+ width, height, duration_ms, critic_json, created_at, build
83
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
84
+ ).run(
85
+ row.id, row.project_slug, row.surface_key, row.url, row.mode, row.selector,
86
+ row.status, row.reason, row.diff_ratio, row.artifact_key, row.diff_artifact_key,
87
+ row.dom_hash, row.assert_pass, row.assert_detail_json, row.console_json,
88
+ row.network_json, row.page_errors_json, row.width, row.height, row.duration_ms,
89
+ row.critic_json, row.created_at, row.build ?? null,
90
+ );
91
+
92
+ return row;
93
+ }
94
+
95
+ export interface ListRunsFilter {
96
+ status?: string;
97
+ surface_key?: string;
98
+ limit?: number;
99
+ }
100
+
101
+ export function listRuns(db: Database, projectSlug: string, filter: ListRunsFilter = {}): LensRunRow[] {
102
+ const clauses = ['project_slug = ?'];
103
+ const params: (string | number)[] = [projectSlug];
104
+ if (filter.status) {
105
+ clauses.push('status = ?');
106
+ params.push(filter.status);
107
+ }
108
+ if (filter.surface_key) {
109
+ clauses.push('surface_key = ?');
110
+ params.push(filter.surface_key);
111
+ }
112
+ const limit = Math.min(Math.max(filter.limit ?? 50, 1), 500);
113
+ params.push(limit);
114
+ return db
115
+ .query(`SELECT * FROM lens_runs WHERE ${clauses.join(' AND ')} ORDER BY created_at DESC LIMIT ?`)
116
+ .all(...params) as LensRunRow[];
117
+ }
118
+
119
+ export function getRun(db: Database, id: string): LensRunRow | null {
120
+ return (db.query('SELECT * FROM lens_runs WHERE id = ?').get(id) as LensRunRow | null) ?? null;
121
+ }
122
+
123
+ export function getBaseline(db: Database, projectSlug: string, baselineKey: string): LensBaselineRow | null {
124
+ return (
125
+ (db
126
+ .query('SELECT * FROM lens_baselines WHERE project_slug = ? AND baseline_key = ?')
127
+ .get(projectSlug, baselineKey) as LensBaselineRow | null) ?? null
128
+ );
129
+ }
130
+
131
+ export function listBaselines(db: Database, projectSlug: string): LensBaselineRow[] {
132
+ return db
133
+ .query('SELECT * FROM lens_baselines WHERE project_slug = ? ORDER BY baseline_key')
134
+ .all(projectSlug) as LensBaselineRow[];
135
+ }
136
+
137
+ /** F089 — unapprove: drop a baseline (row + artifact bytes) so its surface goes
138
+ * back to no-baseline (smoke). Lets a data-drift surface that was wrongly
139
+ * approved stop diffing — approve was previously one-way. Idempotent. */
140
+ export async function deleteBaseline(
141
+ db: Database,
142
+ storage: LensStorage,
143
+ projectSlug: string,
144
+ baselineKey: string,
145
+ ): Promise<boolean> {
146
+ const row = getBaseline(db, projectSlug, baselineKey);
147
+ if (!row) return false;
148
+ await storage.delete(row.artifact_key).catch(() => {});
149
+ db.prepare('DELETE FROM lens_baselines WHERE id = ?').run(row.id);
150
+ return true;
151
+ }
152
+
153
+ /** Load the baseline PNG bytes for a surface, if a baseline is approved.
154
+ * verify() calls this to auto-supply `baseline` when only a key is given. */
155
+ export async function getBaselineBytes(
156
+ db: Database,
157
+ storage: LensStorage,
158
+ projectSlug: string,
159
+ baselineKey: string,
160
+ ): Promise<Buffer | null> {
161
+ const row = getBaseline(db, projectSlug, baselineKey);
162
+ if (!row) return null;
163
+ return storage.get(row.artifact_key);
164
+ }
165
+
166
+ export async function getArtifact(storage: LensStorage, key: string): Promise<Buffer | null> {
167
+ return storage.get(key);
168
+ }
169
+
170
+ /** F094.3 — list a surface's approved baseline versions, newest first. */
171
+ export function listBaselineVersions(
172
+ db: Database,
173
+ projectSlug: string,
174
+ baselineKey: string,
175
+ ): LensBaselineVersionRow[] {
176
+ return db
177
+ .query(
178
+ 'SELECT * FROM lens_baseline_versions WHERE project_slug = ? AND baseline_key = ? ORDER BY version_no DESC',
179
+ )
180
+ .all(projectSlug, baselineKey) as LensBaselineVersionRow[];
181
+ }
182
+
183
+ /** Promote a run's screenshot to the baseline for its surface. F094.3: this is
184
+ * now APPEND-ONLY — each approve writes a NEW content-addressed version
185
+ * (baselines/<key>/v<n>.png) and advances lens_baselines.current_version_id; the
186
+ * previous version's bytes are never overwritten, so the visual history (+ revert)
187
+ * survives. lens_baselines.artifact_key always mirrors the current version, so
188
+ * verify()/getBaselineBytes() are unchanged. One baseline row per (project, key). */
189
+ export async function promoteBaseline(
190
+ db: Database,
191
+ storage: LensStorage,
192
+ runId: string,
193
+ baselineKeyOverride?: string,
194
+ ): Promise<LensBaselineRow> {
195
+ const run = getRun(db, runId);
196
+ if (!run) throw new Error(`run not found: ${runId}`);
197
+ if (!run.artifact_key) throw new Error(`run ${runId} has no screenshot to promote`);
198
+ const baselineKey = baselineKeyOverride ?? run.surface_key;
199
+ if (!baselineKey) {
200
+ throw new Error(`run ${runId} has no surface_key — pass an explicit baseline key`);
201
+ }
202
+
203
+ const bytes = await storage.get(run.artifact_key);
204
+ if (!bytes) throw new Error(`run ${runId} artifact missing in storage: ${run.artifact_key}`);
205
+
206
+ const now = Date.now();
207
+ // Next version number for this surface (1 for a brand-new baseline).
208
+ const maxRow = db
209
+ .query(
210
+ 'SELECT MAX(version_no) AS n FROM lens_baseline_versions WHERE project_slug = ? AND baseline_key = ?',
211
+ )
212
+ .get(run.project_slug, baselineKey) as { n: number | null };
213
+ const versionNo = (maxRow?.n ?? 0) + 1;
214
+
215
+ // Content-addressed per-version artifact — never overwrites a prior version.
216
+ const versionKey = artifactKeys.baselineVersion(run.project_slug, baselineKey, versionNo);
217
+ await storage.put(versionKey, bytes);
218
+
219
+ const versionId = randomUUID();
220
+ db.prepare(
221
+ `INSERT INTO lens_baseline_versions
222
+ (id, project_slug, baseline_key, version_no, artifact_key, width, height,
223
+ approved_from_run_id, commit_sha, note, approved_at)
224
+ VALUES (?,?,?,?,?,?,?,?,NULL,NULL,?)`,
225
+ ).run(versionId, run.project_slug, baselineKey, versionNo, versionKey, run.width, run.height, run.id, now);
226
+
227
+ // Point the baseline at the new version (current artifact = this version).
228
+ const existing = getBaseline(db, run.project_slug, baselineKey);
229
+ if (existing) {
230
+ db.prepare(
231
+ `UPDATE lens_baselines SET artifact_key = ?, width = ?, height = ?,
232
+ approved_from_run_id = ?, current_version_id = ?, updated_at = ? WHERE id = ?`,
233
+ ).run(versionKey, run.width, run.height, run.id, versionId, now, existing.id);
234
+ } else {
235
+ db.prepare(
236
+ `INSERT INTO lens_baselines (
237
+ id, project_slug, baseline_key, artifact_key, width, height,
238
+ approved_from_run_id, current_version_id, created_at, updated_at
239
+ ) VALUES (?,?,?,?,?,?,?,?,?,?)`,
240
+ ).run(randomUUID(), run.project_slug, baselineKey, versionKey, run.width, run.height, run.id, versionId, now, now);
241
+ }
242
+ return getBaseline(db, run.project_slug, baselineKey)!;
243
+ }
244
+
245
+ /** F094.3 — revert a surface's active baseline to a prior version. Re-points
246
+ * lens_baselines at that version's artifact WITHOUT creating a new version or
247
+ * deleting any history. Throws if the version doesn't exist. */
248
+ export function revertBaseline(
249
+ db: Database,
250
+ projectSlug: string,
251
+ baselineKey: string,
252
+ versionNo: number,
253
+ ): LensBaselineRow {
254
+ const version = db
255
+ .query(
256
+ 'SELECT * FROM lens_baseline_versions WHERE project_slug = ? AND baseline_key = ? AND version_no = ?',
257
+ )
258
+ .get(projectSlug, baselineKey, versionNo) as LensBaselineVersionRow | null;
259
+ if (!version) throw new Error(`no version ${versionNo} for baseline "${baselineKey}"`);
260
+ const baseline = getBaseline(db, projectSlug, baselineKey);
261
+ if (!baseline) throw new Error(`no baseline "${baselineKey}" to revert`);
262
+ db.prepare(
263
+ `UPDATE lens_baselines SET artifact_key = ?, width = ?, height = ?,
264
+ approved_from_run_id = ?, current_version_id = ?, updated_at = ? WHERE id = ?`,
265
+ ).run(version.artifact_key, version.width, version.height, version.approved_from_run_id, version.id, Date.now(), baseline.id);
266
+ return getBaseline(db, projectSlug, baselineKey)!;
267
+ }
@@ -0,0 +1,252 @@
1
+ // F074.32 — find code that takes the data and throws the error away.
2
+ //
3
+ // The bug it exists for (fd-sundhed, 2026-08-04, production): a query asked for
4
+ // three columns that did not exist in prod, the database returned an error, the
5
+ // code destructured only `{ data }` and dropped `error`, `data` came back null,
6
+ // and the page answered notFound(). A TECHNICAL FAILURE DISGUISED ITSELF AS AN
7
+ // ACCESS DENIAL — and the one user who should have had access got a 404.
8
+ // Typecheck could not see it: the generated types file claimed the columns
9
+ // existed. A generated types file is not truth about production.
10
+ //
11
+ // WHY IT CALIBRATES INSTEAD OF MATCHING A LIST. The naive rule — flag every
12
+ // `const { data } = await …` with no `error` — is unusable: axios and friends
13
+ // legitimately return `{ data }` and nothing else, so it would flag most of a
14
+ // codebase. The tempting fix is a hardcoded list of clients that DO return
15
+ // `{ data, error }`. That is the exact anti-pattern fd-sundhed named the same
16
+ // day, after their own guard missed 6 of 10 mail paths: "a list of the field
17
+ // names you thought of is a list of what you already knew."
18
+ //
19
+ // So the repo's OWN dominant usage is the specification. Group call sites by the
20
+ // callee they await; a site that drops `error` is only a finding when other
21
+ // sites awaiting THE SAME callee do read it. That is evidence from the codebase
22
+ // in front of us, not a guess about the world — it needs no maintenance, and it
23
+ // works on a repo we have never seen.
24
+ //
25
+ // Deliberately NOT required: that the same function then returns null/404. The
26
+ // null branch and the notFound() lived in DIFFERENT functions in the real bug,
27
+ // so a rule spanning both finds nothing. Taking the data and discarding the
28
+ // error is the defect on its own, whatever the caller does with it.
29
+
30
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
31
+ import { join, extname } from 'node:path';
32
+ import { resolveSandboxedPath } from './sandbox.ts';
33
+
34
+ const SOURCE_EXTS = new Set(['.tsx', '.jsx', '.ts', '.js', '.vue', '.svelte', '.astro']);
35
+ const SKIP_DIRS = new Set([
36
+ 'node_modules', '.git', 'dist', 'build', '.next', '.turbo', 'coverage', '.cache',
37
+ // `.claude/worktrees` holds full COPIES of the repo, one per agent worktree.
38
+ // Scanning them counted the same file up to a dozen times and turned a real
39
+ // answer into an unusable number (measured on fd-sundhed: 468 findings, most
40
+ // of them the same lines over and over).
41
+ '.claude',
42
+ ]);
43
+
44
+ /** `const { … } = await <expr>` — the destructuring and the awaited expression.
45
+ * `let`/`var` count too; a bare `({ data } = await …)` reassignment is rare
46
+ * enough to skip rather than complicate the pattern.
47
+ *
48
+ * The expression window crosses NEWLINES on purpose. A fluent client is almost
49
+ * always wrapped:
50
+ *
51
+ * const { data: profile } = await supabase
52
+ * .from('profiles')
53
+ * .select('*')
54
+ *
55
+ * Stopping at the newline made every one of those group under the bare
56
+ * identifier `supabase`, which collapsed 98 unrelated call sites into one
57
+ * bucket and made the calibration meaningless (measured on fd-sundhed). Only
58
+ * the HEAD of the window is ever read, so the generous bound is harmless. */
59
+ const DESTRUCTURE_AWAIT_RE = /\b(?:const|let|var)\s*\{([^}]*)\}\s*=\s*await\s+/g;
60
+
61
+ /** How far past `await` we look for the callee. Read from the source AFTER the
62
+ * match rather than captured by it — a capture that wide consumes the next
63
+ * statements, so consecutive call sites vanish (they did, in the first cut). */
64
+ const EXPR_WINDOW = 200;
65
+
66
+ export interface SwallowedErrorSite {
67
+ file: string;
68
+ line: number;
69
+ /** Normalised head of the awaited expression, e.g. `supabase.from`. */
70
+ callee: string;
71
+ binds_error: boolean;
72
+ snippet: string;
73
+ }
74
+
75
+ export interface SwallowedErrorFinding extends SwallowedErrorSite {
76
+ /** How many sites awaiting the SAME callee DO read the error — the evidence
77
+ * that the field exists and that this site is the odd one out. */
78
+ peers_reading_error: number;
79
+ }
80
+
81
+ /**
82
+ * Property names bound by a destructuring pattern, by PROPERTY not by local name.
83
+ *
84
+ * `{ data: roster, error }` → ['data', 'error']. Matching the bound name instead
85
+ * would miss the real bug, which was written `const { data: roster } = await …`
86
+ * (fd-sundhed's first sharpening). Nested/rest patterns are ignored; this only
87
+ * needs the top level.
88
+ */
89
+ export function boundProps(inner: string): string[] {
90
+ const props: string[] = [];
91
+ // Split on commas that are not inside nested braces/brackets/parens.
92
+ let depth = 0;
93
+ let cur = '';
94
+ for (const ch of inner) {
95
+ if (ch === '{' || ch === '[' || ch === '(') depth++;
96
+ else if (ch === '}' || ch === ']' || ch === ')') depth--;
97
+ if (ch === ',' && depth === 0) {
98
+ props.push(cur);
99
+ cur = '';
100
+ continue;
101
+ }
102
+ cur += ch;
103
+ }
104
+ props.push(cur);
105
+ return props
106
+ .map((p) => p.trim())
107
+ .filter(Boolean)
108
+ .map((p) => {
109
+ if (p.startsWith('...')) return '';
110
+ // `data: roster` / `data : roster` → data ; `data = fallback` → data
111
+ const name = p.split(':')[0]!.split('=')[0]!.trim();
112
+ return /^[A-Za-z_$][\w$]*$/.test(name) ? name : '';
113
+ })
114
+ .filter(Boolean);
115
+ }
116
+
117
+ /**
118
+ * The awaited expression's identity, so sites can be grouped.
119
+ *
120
+ * `supabase.from('x').select('y').single()` → `supabase.from`
121
+ * `db.query(...)` → `db.query`
122
+ * `fetchRoster(id)` → `fetchRoster`
123
+ *
124
+ * Two segments is the sweet spot: one segment lumps every method on a client
125
+ * together, three splits `.select()` from `.single()` and destroys the grouping
126
+ * that makes the calibration work.
127
+ */
128
+ export function calleeKey(expr: string): string {
129
+ // Whitespace-tolerant: the dot is routinely on the next line.
130
+ const m = expr
131
+ .trim()
132
+ .match(/^([A-Za-z_$][\w$]*)\s*(?:(\??\.)\s*([A-Za-z_$][\w$]*))?/);
133
+ if (!m) return '';
134
+ return m[3] ? `${m[1]}.${m[3]}` : m[1]!;
135
+ }
136
+
137
+ /** Every `const {…} = await …` site in one file. */
138
+ export function sitesInFile(content: string, rel: string): SwallowedErrorSite[] {
139
+ const out: SwallowedErrorSite[] = [];
140
+ for (const m of content.matchAll(DESTRUCTURE_AWAIT_RE)) {
141
+ const props = boundProps(m[1] ?? '');
142
+ if (!props.includes('data')) continue;
143
+ const start = m.index ?? 0;
144
+ const expr = content.slice(start + m[0].length, start + m[0].length + EXPR_WINDOW);
145
+ const callee = calleeKey(expr);
146
+ if (!callee) continue;
147
+ out.push({
148
+ file: rel,
149
+ line: content.slice(0, start).split('\n').length,
150
+ callee,
151
+ binds_error: props.includes('error'),
152
+ snippet: `${m[0]}${expr.slice(0, 60)}`.replace(/\s+/g, ' ').trim().slice(0, 120),
153
+ });
154
+ }
155
+ return out;
156
+ }
157
+
158
+ /**
159
+ * Decide which sites are findings, given every site in the repo.
160
+ *
161
+ * A site is flagged only when another site awaiting the same callee reads the
162
+ * error — the codebase itself is the evidence that the field exists. A callee
163
+ * nobody ever reads `error` from produces nothing: there is no reason to believe
164
+ * it has one, and inventing that belief is how a scanner becomes noise.
165
+ */
166
+ export function findingsFrom(sites: SwallowedErrorSite[]): SwallowedErrorFinding[] {
167
+ const readsErrorByCallee = new Map<string, number>();
168
+ for (const s of sites) {
169
+ if (s.binds_error) readsErrorByCallee.set(s.callee, (readsErrorByCallee.get(s.callee) ?? 0) + 1);
170
+ }
171
+ return sites
172
+ .filter((s) => !s.binds_error && (readsErrorByCallee.get(s.callee) ?? 0) > 0)
173
+ .map((s) => ({ ...s, peers_reading_error: readsErrorByCallee.get(s.callee)! }));
174
+ }
175
+
176
+ export function inventorySwallowedErrors(
177
+ localPathInput: string,
178
+ sandboxRoots: string[],
179
+ maxFiles = 5000,
180
+ ): {
181
+ total_findings: number;
182
+ files_scanned: number;
183
+ sites_seen: number;
184
+ /** Per-callee shape: "87 of 111 supabase.from sites drop the error". A bare
185
+ * total is not actionable on a codebase where the pattern is widespread —
186
+ * the RATIO is what tells you whether you are looking at a few slips or a
187
+ * house style, and those are different conversations. */
188
+ by_callee: Array<{ callee: string; dropping: number; reading: number }>;
189
+ findings: SwallowedErrorFinding[];
190
+ } {
191
+ const root = resolveSandboxedPath(localPathInput, sandboxRoots);
192
+ const sites: SwallowedErrorSite[] = [];
193
+ const budget = { left: maxFiles };
194
+ let scanned = 0;
195
+
196
+ const walk = (dir: string): void => {
197
+ if (budget.left <= 0) return;
198
+ let entries: string[];
199
+ try {
200
+ entries = readdirSync(dir);
201
+ } catch {
202
+ return;
203
+ }
204
+ for (const name of entries) {
205
+ if (budget.left <= 0) return;
206
+ if (SKIP_DIRS.has(name)) continue;
207
+ const full = join(dir, name);
208
+ let st;
209
+ try {
210
+ st = statSync(full);
211
+ } catch {
212
+ continue;
213
+ }
214
+ if (st.isDirectory()) {
215
+ walk(full);
216
+ } else if (SOURCE_EXTS.has(extname(name))) {
217
+ budget.left -= 1;
218
+ scanned += 1;
219
+ try {
220
+ sites.push(...sitesInFile(readFileSync(full, 'utf-8'), full.slice(root.length + 1)));
221
+ } catch {
222
+ // unreadable — skip
223
+ }
224
+ }
225
+ }
226
+ };
227
+ walk(root);
228
+
229
+ const findings = findingsFrom(sites).sort(
230
+ (a, b) => a.file.localeCompare(b.file) || a.line - b.line,
231
+ );
232
+
233
+ const tally = new Map<string, { dropping: number; reading: number }>();
234
+ for (const s of sites) {
235
+ const e = tally.get(s.callee) ?? { dropping: 0, reading: 0 };
236
+ if (s.binds_error) e.reading += 1;
237
+ else e.dropping += 1;
238
+ tally.set(s.callee, e);
239
+ }
240
+ const by_callee = [...tally.entries()]
241
+ .filter(([, v]) => v.reading > 0 && v.dropping > 0)
242
+ .map(([callee, v]) => ({ callee, ...v }))
243
+ .sort((a, b) => b.dropping - a.dropping);
244
+
245
+ return {
246
+ total_findings: findings.length,
247
+ files_scanned: scanned,
248
+ sites_seen: sites.length,
249
+ by_callee,
250
+ findings,
251
+ };
252
+ }