@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/README.md +14 -2
- package/package.json +52 -3
- package/src/artifact-backfill.ts +253 -0
- package/src/artifact-uploads.ts +370 -0
- package/src/auth/index.ts +294 -0
- package/src/browser.ts +440 -0
- package/src/build-marker.ts +197 -0
- package/src/capture-session.ts +873 -0
- package/src/capture.ts +348 -0
- package/src/critic-dom.ts +951 -0
- package/src/critic-vision.ts +205 -0
- package/src/critic.ts +73 -0
- package/src/daemon-routes.ts +333 -0
- package/src/durability.ts +18 -0
- package/src/ensure-chromium.ts +118 -0
- package/src/export.ts +256 -0
- package/src/fill.ts +149 -0
- package/src/flow-store.ts +207 -0
- package/src/flow.ts +2869 -0
- package/src/frames.ts +195 -0
- package/src/gate.ts +123 -0
- package/src/http.ts +175 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +741 -0
- package/src/mcp.ts +562 -0
- package/src/motion-probe.ts +653 -0
- package/src/password-in-url.ts +106 -0
- package/src/pdf-text.ts +514 -0
- package/src/pixelmatch.d.ts +21 -0
- package/src/preflight.ts +76 -0
- package/src/readiness.ts +282 -0
- package/src/record-run.ts +427 -0
- package/src/record.ts +387 -0
- package/src/sandbox.ts +22 -0
- package/src/schema.ts +300 -0
- package/src/scrape.ts +116 -0
- package/src/seed-storage.ts +149 -0
- package/src/service.ts +1483 -0
- package/src/storage.ts +159 -0
- package/src/store.ts +267 -0
- package/src/swallowed-errors.ts +252 -0
- package/src/validate-vision.ts +97 -0
- package/src/verify.ts +411 -0
- package/src/video.ts +211 -0
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
|
+
}
|