@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.
@@ -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
+ }