@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/manifest.ts
ADDED
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
// F074.6 — lens.manifest.json loader + capture-catalogue + data-testid inventory.
|
|
2
|
+
//
|
|
3
|
+
// A project commits a `lens.manifest.json` at its root describing the surfaces
|
|
4
|
+
// Lens should photograph + verify: name, path, capture mode, the [data-testid]
|
|
5
|
+
// selector, an optional assert, and a baseline_key. `lens_capture_catalogue`
|
|
6
|
+
// (F074.5) runs the whole catalogue in one call — "photograph + verify the
|
|
7
|
+
// whole surface" as a one-liner.
|
|
8
|
+
//
|
|
9
|
+
// inventoryTestIds() is the auto-discovery half: scan a repo's source for
|
|
10
|
+
// data-testid attributes so a project can see what anchors exist (and the
|
|
11
|
+
// cardmem audit / F073 gate can flag interactive elements that lack one).
|
|
12
|
+
|
|
13
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
+
import { join, extname, isAbsolute } from 'node:path';
|
|
15
|
+
import { resolveSandboxedPath } from './sandbox.ts';
|
|
16
|
+
import type { CaptureMode } from './capture.ts';
|
|
17
|
+
import type { AuthConfig } from './auth/index.ts';
|
|
18
|
+
import type { CatalogueInput, CatalogueSurface } from './service.ts';
|
|
19
|
+
import { parseFlow, type Flow } from './flow.ts';
|
|
20
|
+
|
|
21
|
+
export const MANIFEST_FILENAME = 'lens.manifest.json';
|
|
22
|
+
|
|
23
|
+
export interface LensManifestSurface {
|
|
24
|
+
name: string;
|
|
25
|
+
path: string;
|
|
26
|
+
mode?: CaptureMode;
|
|
27
|
+
/** Preferred: a [data-testid="…"] selector for element mode. */
|
|
28
|
+
selector?: string;
|
|
29
|
+
/** F098.6 — navigation settle strategy. Default 'domcontentloaded' (won't hang
|
|
30
|
+
* on live-data/streaming/canvas SPAs where 'load' never fires). Override with
|
|
31
|
+
* 'load' for a static page that genuinely needs full sub-resource load. */
|
|
32
|
+
waitUntil?: 'load' | 'domcontentloaded' | 'commit';
|
|
33
|
+
waitFor?: string | number;
|
|
34
|
+
assert?: string;
|
|
35
|
+
baseline_key?: string;
|
|
36
|
+
/** F089.2 — smoke-only: verify on render + anchor + assert, NEVER pixel-diff
|
|
37
|
+
* (even if a baseline exists). The right mode for live-data dashboards whose
|
|
38
|
+
* changing counts/timestamps make a pixel baseline drift to a false red. */
|
|
39
|
+
no_diff?: boolean;
|
|
40
|
+
/** F092.9 — capture this surface at a non-default viewport (e.g. 390x844 for
|
|
41
|
+
* a mobile baseline). Omit for the 1280x800 desktop default. */
|
|
42
|
+
viewport?: { width: number; height: number };
|
|
43
|
+
/** Per-surface auth override: omit to inherit the manifest-level auth,
|
|
44
|
+
* `null` to force NO auth (public pages that redirect logged-in visitors). */
|
|
45
|
+
auth?: AuthConfig | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface LensManifest {
|
|
49
|
+
/** Project slug — scopes runs/baselines. */
|
|
50
|
+
project: string;
|
|
51
|
+
/** Base URL for relative surface paths, e.g. "http://localhost:3019".
|
|
52
|
+
* Overridable at call time (a deployed preview URL). */
|
|
53
|
+
base_url: string;
|
|
54
|
+
/** Optional auth pre-step applied to every surface (secret from daemon env). */
|
|
55
|
+
auth?: AuthConfig;
|
|
56
|
+
diff_threshold?: number;
|
|
57
|
+
/** F074.57 — BCP-47 locale for every capture/flow in this project, e.g.
|
|
58
|
+
* "da-DK". A call-time value wins. Absent means absent: nothing is passed to
|
|
59
|
+
* Playwright, so every existing baseline in the fleet stays valid. */
|
|
60
|
+
locale?: string;
|
|
61
|
+
/** F074.57 — IANA timezone, e.g. "Europe/Copenhagen". Absent means the context
|
|
62
|
+
* inherits the HOST machine's zone, which is why the same page renders
|
|
63
|
+
* different dates on the Mac and on Cloud Lens. Declare it to make a project's
|
|
64
|
+
* captures machine-independent. */
|
|
65
|
+
timezone?: string;
|
|
66
|
+
surfaces: LensManifestSurface[];
|
|
67
|
+
/** F082 — E2E manuscript flows. Optional; the flow-gate runs each one and
|
|
68
|
+
* anchor-checks every [data-testid] the steps reference against the repo. */
|
|
69
|
+
flows?: Flow[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validate(m: unknown): LensManifest {
|
|
73
|
+
if (!m || typeof m !== 'object') throw new Error('manifest is not an object');
|
|
74
|
+
const obj = m as Record<string, unknown>;
|
|
75
|
+
if (typeof obj.project !== 'string' || !obj.project.trim()) {
|
|
76
|
+
throw new Error('manifest.project (slug) is required');
|
|
77
|
+
}
|
|
78
|
+
if (typeof obj.base_url !== 'string' || !obj.base_url.trim()) {
|
|
79
|
+
throw new Error('manifest.base_url is required');
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(obj.surfaces) || obj.surfaces.length === 0) {
|
|
82
|
+
throw new Error('manifest.surfaces[] is required and non-empty');
|
|
83
|
+
}
|
|
84
|
+
for (const [i, s] of (obj.surfaces as unknown[]).entries()) {
|
|
85
|
+
const sf = s as Record<string, unknown>;
|
|
86
|
+
if (typeof sf.name !== 'string' || typeof sf.path !== 'string') {
|
|
87
|
+
throw new Error(`manifest.surfaces[${i}] needs string name + path`);
|
|
88
|
+
}
|
|
89
|
+
if (sf.mode === 'element' && typeof sf.selector !== 'string') {
|
|
90
|
+
throw new Error(`manifest.surfaces[${i}] (${String(sf.name)}) mode=element requires a selector`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (obj.flows !== undefined) {
|
|
94
|
+
if (!Array.isArray(obj.flows)) throw new Error('manifest.flows must be an array');
|
|
95
|
+
// parseFlow throws on the first structural problem in any flow.
|
|
96
|
+
(obj.flows as unknown[]).forEach((f, i) => {
|
|
97
|
+
try {
|
|
98
|
+
parseFlow(f);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
throw new Error(`manifest.flows[${i}]: ${err instanceof Error ? err.message : err}`);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return m as LensManifest;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Read + validate a project's lens.manifest.json. */
|
|
108
|
+
export function loadManifest(localPathInput: string, sandboxRoots: string[]): LensManifest {
|
|
109
|
+
const root = resolveSandboxedPath(localPathInput, sandboxRoots);
|
|
110
|
+
const file = join(root, MANIFEST_FILENAME);
|
|
111
|
+
let raw: string;
|
|
112
|
+
try {
|
|
113
|
+
raw = readFileSync(file, 'utf-8');
|
|
114
|
+
} catch {
|
|
115
|
+
throw new Error(`no ${MANIFEST_FILENAME} at ${root}`);
|
|
116
|
+
}
|
|
117
|
+
let parsed: unknown;
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(raw);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
throw new Error(`${MANIFEST_FILENAME} is not valid JSON: ${err instanceof Error ? err.message : err}`);
|
|
122
|
+
}
|
|
123
|
+
const manifest = validate(parsed);
|
|
124
|
+
const fixAuth = (a: AuthConfig | null | undefined) => resolveAuthPaths(a, root);
|
|
125
|
+
if (manifest.auth) manifest.auth = fixAuth(manifest.auth) as AuthConfig | undefined;
|
|
126
|
+
for (const s of manifest.surfaces) {
|
|
127
|
+
if (s.auth !== undefined) s.auth = fixAuth(s.auth);
|
|
128
|
+
}
|
|
129
|
+
// F082 — resolve the same portable-relative auth path on each flow.
|
|
130
|
+
if (manifest.flows) {
|
|
131
|
+
for (const f of manifest.flows) {
|
|
132
|
+
if (f.auth !== undefined) f.auth = fixAuth(f.auth) as AuthConfig | undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return manifest;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// F074.12/.13 + F082.6 — resolve a relative storageState `statePath` (or
|
|
139
|
+
// mintEndpoint `secretPath`) against the repo root so a COMMITTED manifest /
|
|
140
|
+
// flow can point portably at a gitignored credential file (".lens/state.json")
|
|
141
|
+
// without an absolute machine path or an env var. Shared by the manifest loader
|
|
142
|
+
// AND standalone flow/manuscript runs (so auth reaches those paths too).
|
|
143
|
+
export function resolveAuthPaths(
|
|
144
|
+
a: AuthConfig | null | undefined,
|
|
145
|
+
root: string,
|
|
146
|
+
): AuthConfig | null | undefined {
|
|
147
|
+
if (a && a.adapter === 'storageState' && a.statePath && !isAbsolute(a.statePath)) {
|
|
148
|
+
return { ...a, statePath: join(root, a.statePath) };
|
|
149
|
+
}
|
|
150
|
+
if (a && a.adapter === 'mintEndpoint' && a.secretPath && !isAbsolute(a.secretPath)) {
|
|
151
|
+
return { ...a, secretPath: join(root, a.secretPath) };
|
|
152
|
+
}
|
|
153
|
+
return a;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Where a run's credential came from. `none` ALWAYS carries a note saying why. */
|
|
157
|
+
export type AuthSource = 'explicit' | 'manifest' | 'none';
|
|
158
|
+
|
|
159
|
+
export interface AuthProvenance {
|
|
160
|
+
auth: AuthConfig | undefined;
|
|
161
|
+
source: AuthSource;
|
|
162
|
+
/** The adapter that will be applied, for the report. Null when source='none'. */
|
|
163
|
+
adapter: string | null;
|
|
164
|
+
/** Why there is no credential. Always present when source==='none'. */
|
|
165
|
+
note?: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* F074.36 — the effective auth for a standalone flow/manuscript run, AND the
|
|
170
|
+
* reason when there is none.
|
|
171
|
+
*
|
|
172
|
+
* The old version returned `undefined` for five different situations — no
|
|
173
|
+
* local_path, an unsandboxed path, no manifest, an invalid manifest, a manifest
|
|
174
|
+
* with no `auth` — and its comment called that "best-effort". It is not
|
|
175
|
+
* best-effort, it is silent: the run then drives an unauthenticated browser,
|
|
176
|
+
* lands on the login wall, and reports a confident verdict about a page nobody
|
|
177
|
+
* asked for (reproduced on cardmem prod, flow run 0938570e — `goto /board`
|
|
178
|
+
* returned PASS on the sign-in screen). A comment promising the failure is
|
|
179
|
+
* benign is worse than no comment; it manufactures confidence in the next
|
|
180
|
+
* reader. Same defect the F074.32 scan exists to find: take the data, drop the
|
|
181
|
+
* error.
|
|
182
|
+
*
|
|
183
|
+
* So every branch now names itself. The caller decides what to do with a `none`;
|
|
184
|
+
* what it can no longer do is not know.
|
|
185
|
+
*/
|
|
186
|
+
export function authProvenance(
|
|
187
|
+
flowAuth: AuthConfig | undefined,
|
|
188
|
+
localPath: string | undefined,
|
|
189
|
+
sandboxRoots: string[],
|
|
190
|
+
): AuthProvenance {
|
|
191
|
+
const ok = (auth: AuthConfig, source: AuthSource): AuthProvenance => ({
|
|
192
|
+
auth,
|
|
193
|
+
source,
|
|
194
|
+
adapter: auth.adapter ?? null,
|
|
195
|
+
});
|
|
196
|
+
if (!localPath) {
|
|
197
|
+
return flowAuth
|
|
198
|
+
? ok(flowAuth, 'explicit')
|
|
199
|
+
: { auth: undefined, source: 'none', adapter: null, note: 'no auth given and no local_path to inherit a manifest from' };
|
|
200
|
+
}
|
|
201
|
+
let root: string;
|
|
202
|
+
try {
|
|
203
|
+
root = resolveSandboxedPath(localPath, sandboxRoots);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
// An unsandboxed local_path used to silently disable auth. It is a caller
|
|
206
|
+
// error and must read as one.
|
|
207
|
+
return flowAuth
|
|
208
|
+
? ok(flowAuth, 'explicit')
|
|
209
|
+
: {
|
|
210
|
+
auth: undefined,
|
|
211
|
+
source: 'none',
|
|
212
|
+
adapter: null,
|
|
213
|
+
note: `local_path is outside the daemon sandbox: ${err instanceof Error ? err.message : String(err)}`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
// An explicit auth wins and never consults the manifest — the caller said
|
|
217
|
+
// exactly what to use.
|
|
218
|
+
if (flowAuth) return ok(resolveAuthPaths(flowAuth, root) as AuthConfig, 'explicit');
|
|
219
|
+
try {
|
|
220
|
+
const inherited = loadManifest(localPath, sandboxRoots).auth;
|
|
221
|
+
return inherited
|
|
222
|
+
? ok(inherited, 'manifest')
|
|
223
|
+
: {
|
|
224
|
+
auth: undefined,
|
|
225
|
+
source: 'none',
|
|
226
|
+
adapter: null,
|
|
227
|
+
note: `lens.manifest.json at ${root} has no global "auth" — an authed route will land on the login page`,
|
|
228
|
+
};
|
|
229
|
+
} catch (err) {
|
|
230
|
+
return {
|
|
231
|
+
auth: undefined,
|
|
232
|
+
source: 'none',
|
|
233
|
+
adapter: null,
|
|
234
|
+
note: err instanceof Error ? err.message : String(err),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** F074.57 — where the locale/timezone for a run came from. Reported, not
|
|
240
|
+
* inferred: same reasoning as auth's provenance — a caller that gets English
|
|
241
|
+
* when it declared Danish must be able to see WHICH layer answered. */
|
|
242
|
+
export interface LocaleResolution {
|
|
243
|
+
locale?: string;
|
|
244
|
+
timezone?: string;
|
|
245
|
+
/** Per field, because the two resolve independently: a project may declare a
|
|
246
|
+
* timezone in the manifest and override only the locale at call time. */
|
|
247
|
+
localeSource: 'call' | 'manifest' | 'none';
|
|
248
|
+
timezoneSource: 'call' | 'manifest' | 'none';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Precedence: call time > manifest > unset. Resolved PER FIELD.
|
|
253
|
+
*
|
|
254
|
+
* The unset case is the one with teeth. It must yield `undefined`, never a
|
|
255
|
+
* default — `locale: undefined` is still a key on an object literal, and the
|
|
256
|
+
* whole promise of this feature to everyone who did not ask for it is that
|
|
257
|
+
* Playwright receives nothing and their baselines cannot move.
|
|
258
|
+
*/
|
|
259
|
+
export function resolveLocale(
|
|
260
|
+
call: { locale?: string; timezone?: string } | undefined,
|
|
261
|
+
manifest: { locale?: string; timezone?: string } | null | undefined,
|
|
262
|
+
): LocaleResolution {
|
|
263
|
+
const pick = (c: string | undefined, m: string | undefined) =>
|
|
264
|
+
c ? ({ value: c, source: 'call' as const })
|
|
265
|
+
: m ? ({ value: m, source: 'manifest' as const })
|
|
266
|
+
: ({ value: undefined, source: 'none' as const });
|
|
267
|
+
const l = pick(call?.locale, manifest?.locale);
|
|
268
|
+
const t = pick(call?.timezone, manifest?.timezone);
|
|
269
|
+
return {
|
|
270
|
+
...(l.value ? { locale: l.value } : {}),
|
|
271
|
+
...(t.value ? { timezone: t.value } : {}),
|
|
272
|
+
localeSource: l.source,
|
|
273
|
+
timezoneSource: t.source,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** F074.57 — the manifest half of {@link resolveLocale}, read the same way auth
|
|
278
|
+
* is: best-effort, and a missing/unreadable manifest is simply "no project
|
|
279
|
+
* value" rather than an error. A locale is a preference, not a credential. */
|
|
280
|
+
export function manifestLocale(
|
|
281
|
+
localPath: string | undefined,
|
|
282
|
+
sandboxRoots: string[],
|
|
283
|
+
): { locale?: string; timezone?: string } | null {
|
|
284
|
+
if (!localPath) return null;
|
|
285
|
+
try {
|
|
286
|
+
const m = loadManifest(localPath, sandboxRoots);
|
|
287
|
+
return { ...(m.locale ? { locale: m.locale } : {}), ...(m.timezone ? { timezone: m.timezone } : {}) };
|
|
288
|
+
} catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** F082.6 — the credential only. Kept for callers that don't report provenance
|
|
294
|
+
* (the /lens/gate path); everything user-facing should use authProvenance. */
|
|
295
|
+
export function effectiveFlowAuth(
|
|
296
|
+
flowAuth: AuthConfig | undefined,
|
|
297
|
+
localPath: string | undefined,
|
|
298
|
+
sandboxRoots: string[],
|
|
299
|
+
): AuthConfig | undefined {
|
|
300
|
+
return authProvenance(flowAuth, localPath, sandboxRoots).auth;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** F082 — flows from a manifest, with base_url overridable (deployed preview
|
|
304
|
+
* instead of the manifest's localhost). */
|
|
305
|
+
export function manifestFlows(manifest: LensManifest, baseUrlOverride?: string): Flow[] {
|
|
306
|
+
return (manifest.flows ?? []).map((f) => ({
|
|
307
|
+
...f,
|
|
308
|
+
base_url: baseUrlOverride ?? f.base_url ?? manifest.base_url,
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Map a manifest into a CatalogueInput for lensCaptureCatalogue. base_url can
|
|
313
|
+
* be overridden (e.g. a deployed preview instead of localhost). */
|
|
314
|
+
export function manifestToCatalogue(manifest: LensManifest, baseUrlOverride?: string): CatalogueInput {
|
|
315
|
+
const surfaces: CatalogueSurface[] = manifest.surfaces.map((s) => ({
|
|
316
|
+
name: s.name,
|
|
317
|
+
path: s.path,
|
|
318
|
+
mode: s.mode,
|
|
319
|
+
selector: s.selector,
|
|
320
|
+
waitUntil: s.waitUntil,
|
|
321
|
+
waitFor: s.waitFor,
|
|
322
|
+
assert: s.assert,
|
|
323
|
+
baseline_key: s.baseline_key,
|
|
324
|
+
no_diff: s.no_diff,
|
|
325
|
+
viewport: s.viewport,
|
|
326
|
+
...(s.auth !== undefined ? { auth: s.auth } : {}),
|
|
327
|
+
}));
|
|
328
|
+
return {
|
|
329
|
+
project: manifest.project,
|
|
330
|
+
base_url: baseUrlOverride ?? manifest.base_url,
|
|
331
|
+
auth: manifest.auth,
|
|
332
|
+
diff_threshold: manifest.diff_threshold,
|
|
333
|
+
surfaces,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export interface TestIdHit {
|
|
338
|
+
testid: string;
|
|
339
|
+
count: number;
|
|
340
|
+
files: string[];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const SOURCE_EXTS = new Set(['.tsx', '.jsx', '.ts', '.js', '.vue', '.svelte', '.html', '.astro']);
|
|
344
|
+
// `worktrees` holds throwaway copies of the WHOLE repo (the Agent tool's
|
|
345
|
+
// isolation:"worktree" mode leaves them under .claude/worktrees). They are real
|
|
346
|
+
// source files, so the walk happily read them — and 63% of the gaps this gate
|
|
347
|
+
// reported for cardmem came from two abandoned ones, at whatever commit they
|
|
348
|
+
// were cut from. Measured 2026-08-06: 548 reported, 346 from worktrees, 202
|
|
349
|
+
// real. A gate whose number is mostly noise teaches people to ignore it, and it
|
|
350
|
+
// nearly sent me to add testids to code that ships nowhere.
|
|
351
|
+
//
|
|
352
|
+
// Same family as the other false-greens today, inverted: a guard reading a real
|
|
353
|
+
// source that is not the one the system runs. There it hid a failure; here it
|
|
354
|
+
// manufactures phantom ones. Both make the number untrustworthy.
|
|
355
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.turbo', 'coverage', '.cache', 'worktrees']);
|
|
356
|
+
const TESTID_RE = /data-testid\s*=\s*["'`]([^"'`]+)["'`]/g;
|
|
357
|
+
|
|
358
|
+
function walkSource(dir: string, hits: Map<string, TestIdHit>, rootLen: number, budget: { left: number }): void {
|
|
359
|
+
if (budget.left <= 0) return;
|
|
360
|
+
let entries: string[];
|
|
361
|
+
try {
|
|
362
|
+
entries = readdirSync(dir);
|
|
363
|
+
} catch {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
for (const name of entries) {
|
|
367
|
+
if (budget.left <= 0) return;
|
|
368
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
369
|
+
const full = join(dir, name);
|
|
370
|
+
let st;
|
|
371
|
+
try {
|
|
372
|
+
st = statSync(full);
|
|
373
|
+
} catch {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (st.isDirectory()) {
|
|
377
|
+
walkSource(full, hits, rootLen, budget);
|
|
378
|
+
} else if (SOURCE_EXTS.has(extname(name))) {
|
|
379
|
+
budget.left -= 1;
|
|
380
|
+
let content: string;
|
|
381
|
+
try {
|
|
382
|
+
content = readFileSync(full, 'utf-8');
|
|
383
|
+
} catch {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const rel = full.slice(rootLen + 1);
|
|
387
|
+
for (const m of content.matchAll(TESTID_RE)) {
|
|
388
|
+
const id = m[1]!;
|
|
389
|
+
const hit = hits.get(id) ?? { testid: id, count: 0, files: [] };
|
|
390
|
+
hit.count += 1;
|
|
391
|
+
if (!hit.files.includes(rel)) hit.files.push(rel);
|
|
392
|
+
hits.set(id, hit);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Scan a repo's source for data-testid attributes. Auto-discovery for the
|
|
399
|
+
* manifest + the strict-gate audit. Bounded file budget so a huge monorepo
|
|
400
|
+
* scan can't run away. */
|
|
401
|
+
export function inventoryTestIds(
|
|
402
|
+
localPathInput: string,
|
|
403
|
+
sandboxRoots: string[],
|
|
404
|
+
maxFiles = 5000,
|
|
405
|
+
): { total: number; unique: number; testids: TestIdHit[] } {
|
|
406
|
+
const root = resolveSandboxedPath(localPathInput, sandboxRoots);
|
|
407
|
+
const hits = new Map<string, TestIdHit>();
|
|
408
|
+
walkSource(root, hits, root.length, { left: maxFiles });
|
|
409
|
+
const testids = [...hits.values()].sort((a, b) => a.testid.localeCompare(b.testid));
|
|
410
|
+
return {
|
|
411
|
+
total: testids.reduce((n, h) => n + h.count, 0),
|
|
412
|
+
unique: testids.length,
|
|
413
|
+
testids,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// F082.6 / upmetrics #4 — find INTERACTIVE elements that LACK a data-testid, so
|
|
418
|
+
// step-2 instrumentation ("add anchors before writing a flow") is actionable.
|
|
419
|
+
// Heuristic, like inventoryTestIds: an opening tag for a native control
|
|
420
|
+
// (button/input/select/textarea) or any tag carrying an onClick is a gap if the
|
|
421
|
+
// tag's own attributes don't include data-testid. Per-file, per-line.
|
|
422
|
+
export interface TestIdGap {
|
|
423
|
+
file: string;
|
|
424
|
+
line: number;
|
|
425
|
+
element: string;
|
|
426
|
+
snippet: string;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const TAG_START_RE = /<([A-Za-z][\w.]*)\b/g;
|
|
430
|
+
const NATIVE_INTERACTIVE = new Set(['button', 'input', 'select', 'textarea', 'a']);
|
|
431
|
+
// F074.58 — ONE definition of "an interactive handler", used by BOTH the gap
|
|
432
|
+
// matcher and the blind-spot probe below, so the probe that reports what we could
|
|
433
|
+
// not see can never fall behind the matcher it reports for. The `[=:]` form is
|
|
434
|
+
// deliberate: `onClick={fn}` is JSX and `onClick: fn` is the SAME handler in
|
|
435
|
+
// hyperscript (`h('button', { onClick: fn })`), createElement, or a Vue props
|
|
436
|
+
// object — the syntaxes this probe exists to notice.
|
|
437
|
+
const HANDLER_NAMES = 'Click|Change|Input|Submit';
|
|
438
|
+
const HANDLER_ATTR_RE = new RegExp(`\\bon(?:${HANDLER_NAMES})\\s*=`);
|
|
439
|
+
const HANDLER_ANY_RE = new RegExp(`\\bon(?:${HANDLER_NAMES})\\s*[=:]`, 'g');
|
|
440
|
+
|
|
441
|
+
// F074.22b — the real end of a JSX opening tag is the first '>' that is NOT
|
|
442
|
+
// inside a {…} expression or a string/template literal. The old /<tag[^>]*>/
|
|
443
|
+
// stopped at the '>' in an arrow handler (`onChange={e => …}`), truncating the
|
|
444
|
+
// attribute scan BEFORE a data-testid that followed the handler — so every
|
|
445
|
+
// interactive element with an arrow handler before its testid was a
|
|
446
|
+
// false-positive gap (fleet-wide; it broke the F086 self-check). Brace + quote
|
|
447
|
+
// aware finds the true tag end. Returns the index of the closing '>' or -1.
|
|
448
|
+
function openingTagEnd(content: string, from: number): number {
|
|
449
|
+
let depth = 0;
|
|
450
|
+
for (let i = from; i < content.length; i++) {
|
|
451
|
+
const ch = content[i];
|
|
452
|
+
if (ch === '{') depth++;
|
|
453
|
+
else if (ch === '}') {
|
|
454
|
+
if (depth > 0) depth--;
|
|
455
|
+
} else if (ch === '"' || ch === "'" || ch === '`') {
|
|
456
|
+
const quote = ch;
|
|
457
|
+
i++;
|
|
458
|
+
while (i < content.length && content[i] !== quote) {
|
|
459
|
+
if (content[i] === '\\') i++;
|
|
460
|
+
i++;
|
|
461
|
+
}
|
|
462
|
+
} else if (ch === '>' && depth === 0) {
|
|
463
|
+
return i;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return -1;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// upmetrics (F074.11 closeout) — a comment line that MENTIONS a native control
|
|
470
|
+
// ("// Custom date picker — never a native <input type='date'>") must not count
|
|
471
|
+
// as a gap. Blank /* */ blocks + start-of-line // comments to spaces (keeping
|
|
472
|
+
// newlines, so reported line numbers stay correct) before matching.
|
|
473
|
+
function stripComments(src: string): string {
|
|
474
|
+
return src
|
|
475
|
+
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
|
|
476
|
+
.replace(/^[ \t]*\/\/.*$/gm, (m) => m.replace(/[^\n]/g, ' '));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// F074.58 (second cut) — "does this file contain markup?" cannot be answered by
|
|
480
|
+
// "does it contain a '<' followed by a letter". trail measured the escape on their
|
|
481
|
+
// REAL popup (#21962) after the first cut shipped:
|
|
482
|
+
//
|
|
483
|
+
// line 3: // F086 + house rule: no native <select>. ← a COMMENT
|
|
484
|
+
// line 161: useState<Config | null>(null) ← TS GENERICS
|
|
485
|
+
//
|
|
486
|
+
// Either one alone was enough to call the file examined, so its nine hyperscript
|
|
487
|
+
// handlers stayed invisible. Generics appear in nearly every TypeScript file, which
|
|
488
|
+
// makes the first probe close to a no-op: measured on cardmem, 310 files it called
|
|
489
|
+
// examined contain no markup at all. The old probe answered "this file contains a
|
|
490
|
+
// pair of angle brackets", not "this file contains markup".
|
|
491
|
+
//
|
|
492
|
+
// A generic's '<' always follows an identifier, ')' or ']' (`useState<`, `Array<`,
|
|
493
|
+
// `foo()<`). A JSX element's '<' never does — it follows whitespace, '(', '{', ','
|
|
494
|
+
// or '>'. That one character is the whole discriminator, and it is syntax-agnostic:
|
|
495
|
+
// hyperscript, createElement and Vue render functions all score 0 without needing a
|
|
496
|
+
// parser for any of them. Measured on the real file: 7 generic-shaped, 0 element-shaped.
|
|
497
|
+
export function elementLikeCount(rawContent: string): number {
|
|
498
|
+
const content = stripComments(rawContent);
|
|
499
|
+
let n = 0;
|
|
500
|
+
for (const m of content.matchAll(TAG_START_RE)) {
|
|
501
|
+
const i = m.index ?? 0;
|
|
502
|
+
if (i > 0 && /[A-Za-z0-9_$)\]]/.test(content[i - 1]!)) continue;
|
|
503
|
+
n++;
|
|
504
|
+
}
|
|
505
|
+
return n;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// F074.58 — the evidence that separates "element-less and inert" (cardmem's
|
|
509
|
+
// idle-cue-watcher, a genuinely markup-free effect component) from "interactive and
|
|
510
|
+
// UNSEEN" (trail's popup: nine handlers the matcher never met). Without it the third
|
|
511
|
+
// state is unreadable: on cardmem it names 491 files, nearly all of them .ts modules
|
|
512
|
+
// that were never expected to hold markup.
|
|
513
|
+
//
|
|
514
|
+
// The handler names are the matcher's OWN — deliberately narrow, and measured rather
|
|
515
|
+
// than chosen: a wider `on[A-Z]\w+` scored 140 "signals" on packages/db/src/schema.ts,
|
|
516
|
+
// which are Drizzle's onDelete/onUpdate, and `data-testid` as evidence added nine Lens
|
|
517
|
+
// tooling files that merely mention the string inside their own regexes. Narrow scores
|
|
518
|
+
// 0 on all of cardmem and exactly 1 on trail's web-clipper — the reported file.
|
|
519
|
+
export function interactiveHandlerCount(rawContent: string): number {
|
|
520
|
+
return stripComments(rawContent).match(HANDLER_ANY_RE)?.length ?? 0;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function gapsInFile(rawContent: string, rel: string, out: TestIdGap[]): void {
|
|
524
|
+
const content = stripComments(rawContent);
|
|
525
|
+
for (const m of content.matchAll(TAG_START_RE)) {
|
|
526
|
+
const tag = m[1]!;
|
|
527
|
+
const start = m.index ?? 0;
|
|
528
|
+
// Attributes span from just after the tag name to the tag's TRUE '>' (skipping
|
|
529
|
+
// '>' inside {…}/strings — an arrow handler's '>' must not truncate this).
|
|
530
|
+
const end = openingTagEnd(content, start + m[0].length);
|
|
531
|
+
if (end < 0) continue;
|
|
532
|
+
const attrs = content.slice(start + m[0].length, end);
|
|
533
|
+
const isNative = NATIVE_INTERACTIVE.has(tag.toLowerCase());
|
|
534
|
+
const hasOnClick = HANDLER_ATTR_RE.test(attrs);
|
|
535
|
+
// PascalCase / dotted tags (`<Select>`, `<FieldEditor>`, `<SelectPrimitive.Root>`)
|
|
536
|
+
// are React COMPONENTS, not DOM elements. Their on*-handlers are PROPS passed
|
|
537
|
+
// down — the real Lens anchor is the DOM the component renders (e.g. Select →
|
|
538
|
+
// SelectTrigger), which is tagged in that component's own file. Radix
|
|
539
|
+
// SelectPrimitive.Root renders no DOM and rejects data-testid, so flagging the
|
|
540
|
+
// component itself produced an UNCLOSEABLE gap (no file with a <Select> could
|
|
541
|
+
// ever reach 0). Only native DOM controls + lowercase tags with a handler count.
|
|
542
|
+
// (Surfaced independently by the sa + fysiodk Lens sweeps, 2026-06-02.)
|
|
543
|
+
const isComponent = /^[A-Z]/.test(tag) || tag.includes('.');
|
|
544
|
+
if (isComponent) continue;
|
|
545
|
+
// `<a>` only counts when it's clickable (href or onClick).
|
|
546
|
+
if (tag.toLowerCase() === 'a' && !hasOnClick && !/\bhref\s*=/.test(attrs)) continue;
|
|
547
|
+
if (!isNative && !hasOnClick) continue;
|
|
548
|
+
if (/\bdata-testid\s*=/.test(attrs)) continue;
|
|
549
|
+
const line = content.slice(0, start).split('\n').length;
|
|
550
|
+
out.push({
|
|
551
|
+
file: rel,
|
|
552
|
+
line,
|
|
553
|
+
element: tag,
|
|
554
|
+
snippet: content.slice(start, end + 1).replace(/\s+/g, ' ').slice(0, 100),
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export function inventoryInteractiveGaps(
|
|
560
|
+
localPathInput: string,
|
|
561
|
+
sandboxRoots: string[],
|
|
562
|
+
maxFiles = 5000,
|
|
563
|
+
): {
|
|
564
|
+
total_gaps: number;
|
|
565
|
+
files_with_gaps: number;
|
|
566
|
+
gaps: TestIdGap[];
|
|
567
|
+
not_examined: string[];
|
|
568
|
+
unseen_interactive: string[];
|
|
569
|
+
} {
|
|
570
|
+
const root = resolveSandboxedPath(localPathInput, sandboxRoots);
|
|
571
|
+
const gaps: TestIdGap[] = [];
|
|
572
|
+
// F074.58 — a file we could not READ and a file we found no elements in are
|
|
573
|
+
// both reported here, and neither is a gap. Reported by trail (#21959) with
|
|
574
|
+
// the mutation that proves it: their hyperscript popup (`h('button', …)`,
|
|
575
|
+
// Preact without JSX) answered 0 gaps, and still answered 0 with a
|
|
576
|
+
// data-testid REMOVED. TAG_START_RE matches JSX tag-starts, so the file was
|
|
577
|
+
// never examined — and "never examined" was indistinguishable from "clean".
|
|
578
|
+
//
|
|
579
|
+
// This repo already wrote the rule during the NUL-byte sweep: scan everything,
|
|
580
|
+
// classify afterwards, and let the check prove its own coverage. A structured
|
|
581
|
+
// "no problems" about a file nobody could read is the false green this closes.
|
|
582
|
+
const notExamined: string[] = [];
|
|
583
|
+
// The ACTIONABLE subset of not_examined: no elements visible, yet the file carries
|
|
584
|
+
// handlers. "I could not read this, and there is something here to read."
|
|
585
|
+
const unseenInteractive: string[] = [];
|
|
586
|
+
const budget = { left: maxFiles };
|
|
587
|
+
const walk = (dir: string): void => {
|
|
588
|
+
if (budget.left <= 0) return;
|
|
589
|
+
let entries: string[];
|
|
590
|
+
try {
|
|
591
|
+
entries = readdirSync(dir);
|
|
592
|
+
} catch {
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
for (const name of entries) {
|
|
596
|
+
if (budget.left <= 0) return;
|
|
597
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
598
|
+
const full = join(dir, name);
|
|
599
|
+
let st;
|
|
600
|
+
try {
|
|
601
|
+
st = statSync(full);
|
|
602
|
+
} catch {
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
if (st.isDirectory()) {
|
|
606
|
+
walk(full);
|
|
607
|
+
} else if (SOURCE_EXTS.has(extname(name))) {
|
|
608
|
+
budget.left -= 1;
|
|
609
|
+
const rel = full.slice(root.length + 1);
|
|
610
|
+
try {
|
|
611
|
+
const text = readFileSync(full, 'utf-8');
|
|
612
|
+
// Count elements BEFORE judging gaps: zero elements in a source file is
|
|
613
|
+
// not a clean file, it is a file this matcher cannot see. It is NOT a
|
|
614
|
+
// gap either — cardmem's own idle-cue-watcher.tsx is genuinely
|
|
615
|
+
// element-less — so it is reported separately and a reader decides.
|
|
616
|
+
// elementLikeCount is its own pass with its own non-global matching, so
|
|
617
|
+
// the shared global TAG_START_RE's lastIndex is never advanced by the
|
|
618
|
+
// coverage probe. The first cut called .test() on the global one, and
|
|
619
|
+
// matchAll() inherits that index — gapsInFile then started mid-file and
|
|
620
|
+
// missed the first element. My own negative control caught it: a file
|
|
621
|
+
// with a real gap reported 0. The same class this card is about,
|
|
622
|
+
// introduced by the fix for it.
|
|
623
|
+
if (elementLikeCount(text) === 0) {
|
|
624
|
+
notExamined.push(rel);
|
|
625
|
+
if (interactiveHandlerCount(text) > 0) unseenInteractive.push(rel);
|
|
626
|
+
}
|
|
627
|
+
gapsInFile(text, rel, gaps);
|
|
628
|
+
} catch {
|
|
629
|
+
// Unreadable is the same class: say so instead of skipping silently.
|
|
630
|
+
notExamined.push(rel);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
walk(root);
|
|
636
|
+
const files = new Set(gaps.map((g) => g.file));
|
|
637
|
+
return {
|
|
638
|
+
total_gaps: gaps.length,
|
|
639
|
+
files_with_gaps: files.size,
|
|
640
|
+
gaps,
|
|
641
|
+
not_examined: notExamined,
|
|
642
|
+
unseen_interactive: unseenInteractive,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// F123.4 — design-consistency structural check (the cheap, deterministic 80%
|
|
647
|
+
// catcher; the F086-twin for layout). A "route surface" hand-rolls its own
|
|
648
|
+
// header/frame chrome instead of rendering the project's shared layout
|
|
649
|
+
// component — exactly the drift Christian has seen 300+ times. No browser: pure
|
|
650
|
+
// static analysis. The Lens shell-diff (F123.5) catches what static can't.
|
|
651
|
+
//
|
|
652
|
+
// Heuristic (tuned to keep false-positives near zero): a file is a ROUTE
|
|
653
|
+
// SURFACE if it renders the shared page shell (a `page-shell` class) AND carries
|
|
654
|
+
// a `<x>-root` data-testid (the route-root convention). It DRIFTS if it does not
|
|
655
|
+
// render the shared layout component (default `<PageHeader`). Drift is suppressed
|
|
656
|
+
// by a per-file opt-out marker so a deliberate brand-variation page is declared
|
|
657
|
+
// once and the check (and any auto-remediation loop) leaves it alone.
|
|
658
|
+
//
|
|
659
|
+
// Opt-out marker (anywhere in the file): design: intentional-divergence
|
|
660
|
+
export interface ChromeDriftHit {
|
|
661
|
+
file: string;
|
|
662
|
+
root_testid: string;
|
|
663
|
+
reason: string;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// The route-surface markers + the opt-out are configurable so other repos can
|
|
667
|
+
// point at their own shell class / layout component / convention.
|
|
668
|
+
export interface ChromeDriftOpts {
|
|
669
|
+
/** Shared layout/header component every route must render. Default 'PageHeader'. */
|
|
670
|
+
layoutComponent?: string;
|
|
671
|
+
/** Class marking the shared page frame. Default 'page-shell'. */
|
|
672
|
+
shellMarker?: string;
|
|
673
|
+
}
|
|
674
|
+
const DIVERGENCE_RE = /design:\s*intentional-divergence/;
|
|
675
|
+
const ROOT_TESTID_RE = /data-testid\s*=\s*["'`]([A-Za-z0-9_-]+-root)["'`]/;
|
|
676
|
+
|
|
677
|
+
export function inventoryChromeDrift(
|
|
678
|
+
localPathInput: string,
|
|
679
|
+
sandboxRoots: string[],
|
|
680
|
+
opts: ChromeDriftOpts = {},
|
|
681
|
+
maxFiles = 5000,
|
|
682
|
+
): {
|
|
683
|
+
layout_component: string;
|
|
684
|
+
surfaces: number;
|
|
685
|
+
drift_count: number;
|
|
686
|
+
drift: ChromeDriftHit[];
|
|
687
|
+
} {
|
|
688
|
+
const layout = opts.layoutComponent ?? 'PageHeader';
|
|
689
|
+
const shellMarker = opts.shellMarker ?? 'page-shell';
|
|
690
|
+
const layoutRe = new RegExp(`<${layout}\\b|\\b${layout}\\s*(?:,|\\}|from)`); // JSX use OR import
|
|
691
|
+
const root = resolveSandboxedPath(localPathInput, sandboxRoots);
|
|
692
|
+
const drift: ChromeDriftHit[] = [];
|
|
693
|
+
let surfaces = 0;
|
|
694
|
+
const budget = { left: maxFiles };
|
|
695
|
+
const walk = (dir: string): void => {
|
|
696
|
+
if (budget.left <= 0) return;
|
|
697
|
+
let entries: string[];
|
|
698
|
+
try {
|
|
699
|
+
entries = readdirSync(dir);
|
|
700
|
+
} catch {
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
for (const name of entries) {
|
|
704
|
+
if (budget.left <= 0) return;
|
|
705
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
706
|
+
const full = join(dir, name);
|
|
707
|
+
let st;
|
|
708
|
+
try {
|
|
709
|
+
st = statSync(full);
|
|
710
|
+
} catch {
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
if (st.isDirectory()) {
|
|
714
|
+
walk(full);
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
if (extname(name) !== '.tsx' && extname(name) !== '.jsx') continue;
|
|
718
|
+
budget.left -= 1;
|
|
719
|
+
let content: string;
|
|
720
|
+
try {
|
|
721
|
+
content = readFileSync(full, 'utf-8');
|
|
722
|
+
} catch {
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
// Is this a top-level route surface? (renders the shared shell + a *-root)
|
|
726
|
+
if (!content.includes(shellMarker)) continue;
|
|
727
|
+
const m = content.match(ROOT_TESTID_RE);
|
|
728
|
+
if (!m) continue;
|
|
729
|
+
surfaces += 1;
|
|
730
|
+
if (DIVERGENCE_RE.test(content)) continue; // deliberate divergence — leave it alone
|
|
731
|
+
if (layoutRe.test(content)) continue; // renders the shared layout — OK
|
|
732
|
+
drift.push({
|
|
733
|
+
file: full.slice(root.length + 1),
|
|
734
|
+
root_testid: m[1]!,
|
|
735
|
+
reason: `route surface renders the shared shell ("${shellMarker}") but hand-rolls its header — it does not use <${layout}>. Mirror a sibling route's <${layout}> (or declare "design: intentional-divergence" if this page is deliberately different).`,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
walk(root);
|
|
740
|
+
return { layout_component: layout, surfaces, drift_count: drift.length, drift };
|
|
741
|
+
}
|