gsd-pi 2.7.1 → 2.8.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.
Files changed (53) hide show
  1. package/README.md +12 -5
  2. package/dist/loader.js +0 -0
  3. package/dist/modes/interactive/theme/dark.json +85 -0
  4. package/dist/modes/interactive/theme/light.json +84 -0
  5. package/dist/modes/interactive/theme/theme-schema.json +335 -0
  6. package/dist/modes/interactive/theme/theme.d.ts +78 -0
  7. package/dist/modes/interactive/theme/theme.d.ts.map +1 -0
  8. package/dist/modes/interactive/theme/theme.js +949 -0
  9. package/dist/modes/interactive/theme/theme.js.map +1 -0
  10. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  11. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  12. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  13. package/node_modules/@gsd/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  14. package/node_modules/cliui/CHANGELOG.md +121 -0
  15. package/node_modules/color-convert/CHANGELOG.md +54 -0
  16. package/node_modules/esprima/ChangeLog +235 -0
  17. package/node_modules/mz/HISTORY.md +66 -0
  18. package/node_modules/proper-lockfile/CHANGELOG.md +108 -0
  19. package/node_modules/source-map/CHANGELOG.md +301 -0
  20. package/node_modules/thenify/History.md +11 -0
  21. package/node_modules/thenify-all/History.md +11 -0
  22. package/node_modules/y18n/CHANGELOG.md +100 -0
  23. package/node_modules/yargs/CHANGELOG.md +88 -0
  24. package/node_modules/yargs-parser/CHANGELOG.md +263 -0
  25. package/package.json +5 -2
  26. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  27. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  28. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  29. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  30. package/src/resources/extensions/browser-tools/capture.ts +165 -0
  31. package/src/resources/extensions/browser-tools/evaluate-helpers.ts +184 -0
  32. package/src/resources/extensions/browser-tools/index.ts +47 -4985
  33. package/src/resources/extensions/browser-tools/lifecycle.ts +265 -0
  34. package/src/resources/extensions/browser-tools/package.json +5 -1
  35. package/src/resources/extensions/browser-tools/refs.ts +264 -0
  36. package/src/resources/extensions/browser-tools/settle.ts +197 -0
  37. package/src/resources/extensions/browser-tools/state.ts +408 -0
  38. package/src/resources/extensions/browser-tools/tests/browser-tools-integration.test.mjs +652 -0
  39. package/src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +614 -0
  40. package/src/resources/extensions/browser-tools/tools/assertions.ts +342 -0
  41. package/src/resources/extensions/browser-tools/tools/forms.ts +801 -0
  42. package/src/resources/extensions/browser-tools/tools/inspection.ts +492 -0
  43. package/src/resources/extensions/browser-tools/tools/intent.ts +614 -0
  44. package/src/resources/extensions/browser-tools/tools/interaction.ts +865 -0
  45. package/src/resources/extensions/browser-tools/tools/navigation.ts +232 -0
  46. package/src/resources/extensions/browser-tools/tools/pages.ts +303 -0
  47. package/src/resources/extensions/browser-tools/tools/refs.ts +541 -0
  48. package/src/resources/extensions/browser-tools/tools/screenshot.ts +83 -0
  49. package/src/resources/extensions/browser-tools/tools/session.ts +400 -0
  50. package/src/resources/extensions/browser-tools/tools/wait.ts +247 -0
  51. package/src/resources/extensions/browser-tools/utils.ts +660 -0
  52. package/src/resources/extensions/gsd/git-service.ts +3 -0
  53. package/src/resources/extensions/shared/interview-ui.ts +1 -1
@@ -0,0 +1,265 @@
1
+ /**
2
+ * browser-tools — browser lifecycle management
3
+ *
4
+ * Manages the shared Browser + BrowserContext + Page singleton.
5
+ * Injects EVALUATE_HELPERS_SOURCE via context.addInitScript() so that
6
+ * page.evaluate() callbacks can reference window.__pi.* utilities.
7
+ */
8
+
9
+ import type { Browser, BrowserContext, Frame, Page } from "playwright";
10
+ import path from "node:path";
11
+ import {
12
+ registryAddPage,
13
+ registryGetActive,
14
+ registryRemovePage,
15
+ registrySetActive,
16
+ } from "./core.js";
17
+ import {
18
+ getBrowser,
19
+ setBrowser,
20
+ getContext,
21
+ setContext,
22
+ pageRegistry,
23
+ getActiveFrame,
24
+ setActiveFrame,
25
+ logPusher,
26
+ getConsoleLogs,
27
+ getNetworkLogs,
28
+ getDialogLogs,
29
+ getPendingCriticalRequestsByPage,
30
+ setHarState,
31
+ resetAllState,
32
+ HAR_FILENAME,
33
+ type ConsoleEntry,
34
+ type NetworkEntry,
35
+ } from "./state.js";
36
+ import {
37
+ isCriticalResourceType,
38
+ updatePendingCriticalRequests,
39
+ ensureSessionStartedAt,
40
+ ensureSessionArtifactDir,
41
+ } from "./utils.js";
42
+ import { EVALUATE_HELPERS_SOURCE } from "./evaluate-helpers.js";
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Page event wiring
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /** Attach all event listeners to a page. Called on initial page and new tabs. */
49
+ export function attachPageListeners(p: Page, pageId: number): void {
50
+ const pendingMap = getPendingCriticalRequestsByPage();
51
+ pendingMap.set(p, 0);
52
+
53
+ const consoleLogs = getConsoleLogs();
54
+ const networkLogs = getNetworkLogs();
55
+ const dialogLogs = getDialogLogs();
56
+
57
+ // Console messages
58
+ p.on("console", (msg) => {
59
+ logPusher(consoleLogs, {
60
+ type: msg.type(),
61
+ text: msg.text(),
62
+ timestamp: Date.now(),
63
+ url: p.url(),
64
+ pageId,
65
+ });
66
+ });
67
+
68
+ // Uncaught JS errors
69
+ p.on("pageerror", (err) => {
70
+ logPusher(consoleLogs, {
71
+ type: "pageerror",
72
+ text: err.message,
73
+ timestamp: Date.now(),
74
+ url: p.url(),
75
+ pageId,
76
+ });
77
+ });
78
+
79
+ // Network requests — start/completed/failed
80
+ p.on("request", (request) => {
81
+ if (isCriticalResourceType(request.resourceType())) {
82
+ updatePendingCriticalRequests(p, 1);
83
+ }
84
+ });
85
+
86
+ p.on("requestfinished", async (request) => {
87
+ if (isCriticalResourceType(request.resourceType())) {
88
+ updatePendingCriticalRequests(p, -1);
89
+ }
90
+ try {
91
+ const response = await request.response();
92
+ const status = response?.status() ?? null;
93
+ const entry: NetworkEntry = {
94
+ method: request.method(),
95
+ url: request.url(),
96
+ status,
97
+ resourceType: request.resourceType(),
98
+ timestamp: Date.now(),
99
+ failed: false,
100
+ pageId,
101
+ };
102
+ if (response && status !== null && status >= 400) {
103
+ try {
104
+ const body = await response.text();
105
+ entry.responseBody = body.slice(0, 2000);
106
+ } catch {}
107
+ }
108
+ logPusher(networkLogs, entry);
109
+ } catch {}
110
+ });
111
+
112
+ p.on("requestfailed", (request) => {
113
+ if (isCriticalResourceType(request.resourceType())) {
114
+ updatePendingCriticalRequests(p, -1);
115
+ }
116
+ logPusher(networkLogs, {
117
+ method: request.method(),
118
+ url: request.url(),
119
+ status: null,
120
+ resourceType: request.resourceType(),
121
+ timestamp: Date.now(),
122
+ failed: true,
123
+ failureText: request.failure()?.errorText ?? "Unknown failure",
124
+ pageId,
125
+ });
126
+ });
127
+
128
+ // Auto-handle JS dialogs (alert, confirm, prompt, beforeunload)
129
+ p.on("dialog", async (dialog) => {
130
+ logPusher(dialogLogs, {
131
+ type: dialog.type(),
132
+ message: dialog.message(),
133
+ timestamp: Date.now(),
134
+ url: p.url(),
135
+ defaultValue: dialog.defaultValue() || undefined,
136
+ accepted: true,
137
+ pageId,
138
+ });
139
+ // Auto-accept all dialogs to prevent page freezes
140
+ await dialog.accept().catch(() => {});
141
+ });
142
+
143
+ // Frame detach handler — clears activeFrame if the selected frame detaches
144
+ p.on("framedetached", (frame) => {
145
+ if (getActiveFrame() === frame) setActiveFrame(null);
146
+ });
147
+
148
+ // Page close handler — removes page from registry and handles active fallback
149
+ p.on("close", () => {
150
+ try {
151
+ registryRemovePage(pageRegistry, pageId);
152
+ } catch {
153
+ // Page already removed (e.g. during closeBrowser)
154
+ }
155
+ });
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Browser lifecycle
160
+ // ---------------------------------------------------------------------------
161
+
162
+ export async function ensureBrowser(): Promise<{ browser: Browser; context: BrowserContext; page: Page }> {
163
+ const existingBrowser = getBrowser();
164
+ const existingContext = getContext();
165
+ if (existingBrowser && existingContext) {
166
+ return { browser: existingBrowser, context: existingContext, page: getActivePage() };
167
+ }
168
+
169
+ const startedAt = ensureSessionStartedAt();
170
+ const artifactDir = await ensureSessionArtifactDir();
171
+ const sessionHarPath = path.join(artifactDir, HAR_FILENAME);
172
+ setHarState({
173
+ enabled: true,
174
+ configuredAtContextCreation: true,
175
+ path: sessionHarPath,
176
+ exportCount: 0,
177
+ lastExportedPath: null,
178
+ lastExportedAt: null,
179
+ });
180
+
181
+ // Lazy import so playwright is only loaded when actually needed
182
+ const { chromium } = await import("playwright");
183
+
184
+ const launchOptions: Record<string, unknown> = { headless: false };
185
+ const customPath = process.env.BROWSER_PATH;
186
+ if (customPath) launchOptions.executablePath = customPath;
187
+ const browser = await chromium.launch(launchOptions);
188
+ const context = await browser.newContext({
189
+ deviceScaleFactor: 2,
190
+ viewport: { width: 1280, height: 800 },
191
+ recordHar: {
192
+ path: sessionHarPath,
193
+ mode: "minimal",
194
+ content: "omit",
195
+ },
196
+ });
197
+
198
+ // Inject shared browser-side utilities into every new page/frame
199
+ await context.addInitScript(EVALUATE_HELPERS_SOURCE);
200
+
201
+ setBrowser(browser);
202
+ setContext(context);
203
+
204
+ const initialPage = await context.newPage();
205
+ const pageEntry = registryAddPage(pageRegistry, {
206
+ page: initialPage,
207
+ title: await initialPage.title().catch(() => ""),
208
+ url: initialPage.url(),
209
+ opener: null,
210
+ });
211
+ registrySetActive(pageRegistry, pageEntry.id);
212
+ attachPageListeners(initialPage, pageEntry.id);
213
+
214
+ // Register new pages (popups, target="_blank", window.open) but do NOT auto-switch
215
+ context.on("page", (newPage) => {
216
+ // Determine opener page ID — find which registry page opened this one
217
+ const openerPage = newPage.opener();
218
+ let openerId: number | null = null;
219
+ if (openerPage) {
220
+ const openerEntry = pageRegistry.pages.find((e: any) => e.page === openerPage);
221
+ if (openerEntry) openerId = openerEntry.id;
222
+ }
223
+ const entry = registryAddPage(pageRegistry, {
224
+ page: newPage,
225
+ title: "",
226
+ url: newPage.url(),
227
+ opener: openerId,
228
+ });
229
+ attachPageListeners(newPage, entry.id);
230
+ // Update title once loaded
231
+ newPage.waitForLoadState("domcontentloaded", { timeout: 5000 })
232
+ .then(() => newPage.title())
233
+ .then((title) => { entry.title = title; })
234
+ .catch(() => {});
235
+ });
236
+
237
+ return { browser, context, page: getActivePage() };
238
+ }
239
+
240
+ /** Get the currently active page from the registry. */
241
+ export function getActivePage(): Page {
242
+ return registryGetActive(pageRegistry).page;
243
+ }
244
+
245
+ /** Get the active target — returns the selected frame if one is active, otherwise the active page. */
246
+ export function getActiveTarget(): Page | Frame {
247
+ return getActiveFrame() ?? getActivePage();
248
+ }
249
+
250
+ /** Safe accessor for error handling — returns the active page or null if unavailable. */
251
+ export function getActivePageOrNull(): Page | null {
252
+ try {
253
+ return getActivePage();
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+
259
+ export async function closeBrowser(): Promise<void> {
260
+ const browser = getBrowser();
261
+ if (browser) {
262
+ await browser.close().catch(() => {});
263
+ }
264
+ resetAllState();
265
+ }
@@ -10,11 +10,15 @@
10
10
  "extensions": ["./index.ts"]
11
11
  },
12
12
  "peerDependencies": {
13
- "playwright": ">=1.40.0"
13
+ "playwright": ">=1.40.0",
14
+ "sharp": ">=0.33.0"
14
15
  },
15
16
  "peerDependenciesMeta": {
16
17
  "playwright": {
17
18
  "optional": true
19
+ },
20
+ "sharp": {
21
+ "optional": true
18
22
  }
19
23
  }
20
24
  }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * browser-tools — ref snapshot and resolution
3
+ *
4
+ * Builds deterministic element snapshots and resolves ref targets.
5
+ * Uses window.__pi.* utilities injected via addInitScript (from
6
+ * evaluate-helpers.ts) instead of redeclaring functions inline.
7
+ *
8
+ * Functions kept inline (not shared/duplicated):
9
+ * - matchesMode, computeNearestHeading, computeFormOwnership
10
+ */
11
+
12
+ import type { Frame, Page } from "playwright";
13
+ import type { RefNode } from "./state.js";
14
+ import { getSnapshotModeConfig } from "./core.js";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // buildRefSnapshot
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export async function buildRefSnapshot(
21
+ target: Page | Frame,
22
+ options: { selector?: string; interactiveOnly: boolean; limit: number; mode?: string },
23
+ ): Promise<Array<Omit<RefNode, "ref">>> {
24
+ // Resolve mode config in Node context and serialize it as plain data for the evaluate callback
25
+ const modeConfig = options.mode ? getSnapshotModeConfig(options.mode) : null;
26
+ return await target.evaluate(({ selector, interactiveOnly, limit, modeConfig: mc }) => {
27
+ const root = selector ? document.querySelector(selector) : document.body;
28
+ if (!root) {
29
+ throw new Error(`Selector scope not found: ${selector}`);
30
+ }
31
+
32
+ // Use injected window.__pi utilities
33
+ const pi = (window as any).__pi;
34
+ const simpleHash = pi.simpleHash;
35
+ const isVisible = pi.isVisible;
36
+ const isEnabled = pi.isEnabled;
37
+ const inferRole = pi.inferRole;
38
+ const accessibleName = pi.accessibleName;
39
+ const isInteractiveEl = pi.isInteractiveEl;
40
+ const cssPath = pi.cssPath;
41
+ const domPath = pi.domPath;
42
+ const selectorHints = pi.selectorHints;
43
+
44
+ // Mode-based element matching — used when a snapshot mode config is provided
45
+ const matchesMode = (el: Element, cfg: { tags: string[]; roles: string[]; selectors: string[]; ariaAttributes: string[] }): boolean => {
46
+ const tag = el.tagName.toLowerCase();
47
+ if (cfg.tags.length > 0 && cfg.tags.includes(tag)) return true;
48
+ const role = inferRole(el);
49
+ if (cfg.roles.length > 0 && cfg.roles.includes(role)) return true;
50
+ for (const sel of cfg.selectors) {
51
+ try { if (el.matches(sel)) return true; } catch { /* invalid selector, skip */ }
52
+ }
53
+ for (const attr of cfg.ariaAttributes) {
54
+ if (el.hasAttribute(attr)) return true;
55
+ }
56
+ return false;
57
+ };
58
+
59
+ let elements = Array.from(root.querySelectorAll("*"));
60
+
61
+ if (mc) {
62
+ // Mode takes precedence over interactiveOnly
63
+ if (mc.visibleOnly) {
64
+ // visible_only mode: include all elements that are visible
65
+ elements = elements.filter((el) => isVisible(el));
66
+ } else if (mc.useInteractiveFilter) {
67
+ // interactive mode: reuse existing isInteractiveEl
68
+ elements = elements.filter((el) => isInteractiveEl(el));
69
+ } else if (mc.containerExpand) {
70
+ // Container-expanding modes (dialog, errors): match containers, then include
71
+ // all interactive children of those containers, plus the containers themselves
72
+ const containers: Element[] = [];
73
+ const directMatches: Element[] = [];
74
+ for (const el of elements) {
75
+ if (matchesMode(el, mc)) {
76
+ // Check if this is a container element (has children)
77
+ const childEls = el.querySelectorAll("*");
78
+ if (childEls.length > 0) {
79
+ containers.push(el);
80
+ } else {
81
+ directMatches.push(el);
82
+ }
83
+ }
84
+ }
85
+ // Collect container elements + all interactive children inside containers
86
+ const result = new Set<Element>(directMatches);
87
+ for (const container of containers) {
88
+ result.add(container);
89
+ const children = Array.from(container.querySelectorAll("*"));
90
+ for (const child of children) {
91
+ if (isInteractiveEl(child)) result.add(child);
92
+ }
93
+ }
94
+ elements = Array.from(result);
95
+ } else {
96
+ // Standard mode filtering by tag/role/selector/ariaAttribute
97
+ elements = elements.filter((el) => matchesMode(el, mc));
98
+ }
99
+ } else if (!interactiveOnly) {
100
+ if (root instanceof Element) elements.unshift(root);
101
+ } else {
102
+ elements = elements.filter((el) => isInteractiveEl(el));
103
+ }
104
+
105
+ const seen = new Set<Element>();
106
+ const unique = elements.filter((el) => {
107
+ if (seen.has(el)) return false;
108
+ seen.add(el);
109
+ return true;
110
+ });
111
+
112
+ // Fingerprint helpers — computed for each element in the snapshot
113
+ const computeNearestHeading = (el: Element): string => {
114
+ const headingTags = new Set(["H1", "H2", "H3", "H4", "H5", "H6"]);
115
+ // Walk up ancestors looking for heading or preceding-sibling heading
116
+ let current: Element | null = el;
117
+ while (current && current !== document.body) {
118
+ // Check preceding siblings of current
119
+ let sib: Element | null = current.previousElementSibling;
120
+ while (sib) {
121
+ if (headingTags.has(sib.tagName) || sib.getAttribute("role") === "heading") {
122
+ return (sib.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80);
123
+ }
124
+ sib = sib.previousElementSibling;
125
+ }
126
+ // Check if the parent itself is a heading (unlikely but possible)
127
+ const parent = current.parentElement;
128
+ if (parent && (headingTags.has(parent.tagName) || parent.getAttribute("role") === "heading")) {
129
+ return (parent.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80);
130
+ }
131
+ current = parent;
132
+ }
133
+ return "";
134
+ };
135
+
136
+ const computeFormOwnership = (el: Element): string => {
137
+ // Check form attribute (explicit form association)
138
+ const formAttr = el.getAttribute("form");
139
+ if (formAttr) return formAttr;
140
+ // Walk up ancestors looking for <form>
141
+ let current: Element | null = el.parentElement;
142
+ while (current && current !== document.body) {
143
+ if (current.tagName === "FORM") {
144
+ return (current as HTMLFormElement).id || (current as HTMLFormElement).name || "form";
145
+ }
146
+ current = current.parentElement;
147
+ }
148
+ return "";
149
+ };
150
+
151
+ return unique.slice(0, limit).map((el) => {
152
+ const tag = el.tagName.toLowerCase();
153
+ const role = inferRole(el);
154
+ const textContent = (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 200);
155
+ const childTags = Array.from(el.children).map((c) => c.tagName.toLowerCase());
156
+
157
+ return {
158
+ tag,
159
+ role,
160
+ name: accessibleName(el),
161
+ selectorHints: selectorHints(el),
162
+ isVisible: isVisible(el),
163
+ isEnabled: isEnabled(el),
164
+ xpathOrPath: cssPath(el),
165
+ href: el.getAttribute("href") || undefined,
166
+ type: el.getAttribute("type") || undefined,
167
+ path: domPath(el),
168
+ contentHash: simpleHash(textContent),
169
+ structuralSignature: simpleHash(`${tag}|${role}|${childTags.join(",")}`),
170
+ nearestHeading: computeNearestHeading(el),
171
+ formOwnership: computeFormOwnership(el),
172
+ };
173
+ });
174
+ }, { ...options, modeConfig });
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // resolveRefTarget
179
+ // ---------------------------------------------------------------------------
180
+
181
+ export async function resolveRefTarget(
182
+ target: Page | Frame,
183
+ node: RefNode,
184
+ ): Promise<{ ok: true; selector: string } | { ok: false; reason: string }> {
185
+ return await target.evaluate((refNode) => {
186
+ // Use injected window.__pi utilities
187
+ const pi = (window as any).__pi;
188
+ const cssPath = pi.cssPath;
189
+ const simpleHash = pi.simpleHash;
190
+
191
+ const byPath = (): Element | null => {
192
+ let current: Element | null = document.documentElement;
193
+ for (const idx of refNode.path || []) {
194
+ if (!current || idx < 0 || idx >= current.children.length) return null;
195
+ current = current.children[idx] as Element;
196
+ }
197
+ return current;
198
+ };
199
+
200
+ const nodeName = (el: Element): string => {
201
+ return (
202
+ el.getAttribute("aria-label")?.trim() ||
203
+ (el as HTMLInputElement).value?.trim() ||
204
+ el.getAttribute("placeholder")?.trim() ||
205
+ (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80)
206
+ );
207
+ };
208
+
209
+ // Tier 1: path-based resolution
210
+ const pathEl = byPath();
211
+ if (pathEl && pathEl.tagName.toLowerCase() === refNode.tag) {
212
+ return { ok: true as const, selector: cssPath(pathEl) };
213
+ }
214
+
215
+ // Tier 2: selector hints
216
+ for (const hint of refNode.selectorHints || []) {
217
+ try {
218
+ const el = document.querySelector(hint);
219
+ if (!el) continue;
220
+ if (el.tagName.toLowerCase() !== refNode.tag) continue;
221
+ return { ok: true as const, selector: cssPath(el) };
222
+ } catch {
223
+ // ignore malformed selector hint
224
+ }
225
+ }
226
+
227
+ // Tier 3: role + name match
228
+ const candidates = Array.from(document.querySelectorAll(refNode.tag));
229
+ const matchTarget = candidates.find((el) => {
230
+ const role = el.getAttribute("role") || "";
231
+ const name = nodeName(el);
232
+ const roleMatch = !refNode.role || role === refNode.role;
233
+ const nameMatch = !!refNode.name && name.toLowerCase() === refNode.name.toLowerCase();
234
+ return roleMatch && nameMatch;
235
+ });
236
+ if (matchTarget) {
237
+ return { ok: true as const, selector: cssPath(matchTarget) };
238
+ }
239
+
240
+ // Tier 4: structural signature + content hash fingerprint matching
241
+ if (refNode.contentHash && refNode.structuralSignature) {
242
+ const fpMatches: Element[] = [];
243
+ for (const candidate of candidates) {
244
+ const tag = candidate.tagName.toLowerCase();
245
+ const role = candidate.getAttribute("role") || "";
246
+ const textContent = (candidate.textContent || "").trim().replace(/\s+/g, " ").slice(0, 200);
247
+ const childTags = Array.from(candidate.children).map((c) => c.tagName.toLowerCase());
248
+ const candidateContentHash = simpleHash(textContent);
249
+ const candidateStructSig = simpleHash(`${tag}|${role}|${childTags.join(",")}`);
250
+ if (candidateContentHash === refNode.contentHash && candidateStructSig === refNode.structuralSignature) {
251
+ fpMatches.push(candidate);
252
+ }
253
+ }
254
+ if (fpMatches.length === 1) {
255
+ return { ok: true as const, selector: cssPath(fpMatches[0]) };
256
+ }
257
+ if (fpMatches.length > 1) {
258
+ return { ok: false as const, reason: "multiple fingerprint matches — ambiguous" };
259
+ }
260
+ }
261
+
262
+ return { ok: false as const, reason: "element not found in current DOM" };
263
+ }, node);
264
+ }