sela-core 0.1.0-alpha.39 → 0.1.0-alpha.41

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 (97) hide show
  1. package/dist/boot/bootImpl.d.ts +14 -0
  2. package/dist/boot/bootImpl.d.ts.map +1 -0
  3. package/dist/boot/bootImpl.js +68 -0
  4. package/dist/boot/register.cjs +45 -0
  5. package/dist/boot/register.d.cts +2 -0
  6. package/dist/boot/register.d.cts.map +1 -0
  7. package/dist/boot/register.d.mts +2 -0
  8. package/dist/boot/register.d.mts.map +1 -0
  9. package/dist/boot/register.mjs +10 -0
  10. package/dist/cli/commands/init.js +48 -16
  11. package/dist/cli/commands/merge.d.ts.map +1 -1
  12. package/dist/cli/commands/merge.js +5 -0
  13. package/dist/cli/commands/shadowRegression.js +1 -1
  14. package/dist/config/ConfigLoader.d.ts.map +1 -1
  15. package/dist/config/ConfigLoader.js +33 -12
  16. package/dist/config/SelaConfig.d.ts +59 -0
  17. package/dist/config/SelaConfig.d.ts.map +1 -1
  18. package/dist/config/SelaConfig.js +36 -0
  19. package/dist/engine/SelaEngine.d.ts +3 -1
  20. package/dist/engine/SelaEngine.d.ts.map +1 -1
  21. package/dist/engine/SelaEngine.js +193 -3
  22. package/dist/errors/SelaError.d.ts +7 -0
  23. package/dist/errors/SelaError.d.ts.map +1 -1
  24. package/dist/errors/SelaError.js +7 -1
  25. package/dist/fixtures/index.d.ts.map +1 -1
  26. package/dist/fixtures/index.js +5 -4
  27. package/dist/providers/AnthropicProvider.d.ts +9 -0
  28. package/dist/providers/AnthropicProvider.d.ts.map +1 -0
  29. package/dist/providers/AnthropicProvider.js +33 -0
  30. package/dist/providers/LLMProvider.d.ts +13 -0
  31. package/dist/providers/LLMProvider.d.ts.map +1 -0
  32. package/dist/providers/LLMProvider.js +7 -0
  33. package/dist/providers/OpenAICompatibleProvider.d.ts +8 -0
  34. package/dist/providers/OpenAICompatibleProvider.d.ts.map +1 -0
  35. package/dist/providers/OpenAICompatibleProvider.js +48 -0
  36. package/dist/providers/ProviderFactory.d.ts +4 -0
  37. package/dist/providers/ProviderFactory.d.ts.map +1 -0
  38. package/dist/providers/ProviderFactory.js +50 -0
  39. package/dist/providers/SanitizingProvider.d.ts +8 -0
  40. package/dist/providers/SanitizingProvider.d.ts.map +1 -0
  41. package/dist/providers/SanitizingProvider.js +27 -0
  42. package/dist/services/ASTSourceUpdater.d.ts.map +1 -1
  43. package/dist/services/ASTSourceUpdater.js +39 -5
  44. package/dist/services/CrossFileHealer.d.ts +7 -1
  45. package/dist/services/CrossFileHealer.d.ts.map +1 -1
  46. package/dist/services/CrossFileHealer.js +243 -9
  47. package/dist/services/DefinitionTracer.d.ts +1 -0
  48. package/dist/services/DefinitionTracer.d.ts.map +1 -1
  49. package/dist/services/DefinitionTracer.js +59 -36
  50. package/dist/services/HealReportService.d.ts +32 -0
  51. package/dist/services/HealReportService.d.ts.map +1 -1
  52. package/dist/services/HealReportService.js +111 -18
  53. package/dist/services/IntentAuditor.d.ts +9 -7
  54. package/dist/services/IntentAuditor.d.ts.map +1 -1
  55. package/dist/services/IntentAuditor.js +21 -16
  56. package/dist/services/LLMService.d.ts +10 -7
  57. package/dist/services/LLMService.d.ts.map +1 -1
  58. package/dist/services/LLMService.js +76 -10
  59. package/dist/services/PRAutomationService.d.ts +7 -2
  60. package/dist/services/PRAutomationService.d.ts.map +1 -1
  61. package/dist/services/PRAutomationService.js +144 -133
  62. package/dist/services/RegressionManifestService.d.ts +8 -0
  63. package/dist/services/RegressionManifestService.d.ts.map +1 -1
  64. package/dist/services/RegressionManifestService.js +8 -0
  65. package/dist/services/RegressionReclassifier.d.ts +25 -0
  66. package/dist/services/RegressionReclassifier.d.ts.map +1 -0
  67. package/dist/services/RegressionReclassifier.js +195 -0
  68. package/dist/services/ReportMergeService.d.ts +9 -0
  69. package/dist/services/ReportMergeService.d.ts.map +1 -1
  70. package/dist/services/ReportMergeService.js +28 -0
  71. package/dist/services/SmartDOMPruner.d.ts +154 -0
  72. package/dist/services/SmartDOMPruner.d.ts.map +1 -0
  73. package/dist/services/SmartDOMPruner.js +678 -0
  74. package/dist/services/TraceBackEngine.d.ts +23 -6
  75. package/dist/services/TraceBackEngine.d.ts.map +1 -1
  76. package/dist/services/TraceBackEngine.js +51 -13
  77. package/dist/services/VisualSnapshotService.d.ts +54 -0
  78. package/dist/services/VisualSnapshotService.d.ts.map +1 -0
  79. package/dist/services/VisualSnapshotService.js +279 -0
  80. package/dist/services/vcs/GitHubProvider.d.ts +17 -0
  81. package/dist/services/vcs/GitHubProvider.d.ts.map +1 -0
  82. package/dist/services/vcs/GitHubProvider.js +162 -0
  83. package/dist/services/vcs/GitLabProvider.d.ts +17 -0
  84. package/dist/services/vcs/GitLabProvider.d.ts.map +1 -0
  85. package/dist/services/vcs/GitLabProvider.js +175 -0
  86. package/dist/services/vcs/VCSProvider.d.ts +100 -0
  87. package/dist/services/vcs/VCSProvider.d.ts.map +1 -0
  88. package/dist/services/vcs/VCSProvider.js +7 -0
  89. package/dist/services/vcs/VCSProviderFactory.d.ts +7 -0
  90. package/dist/services/vcs/VCSProviderFactory.d.ts.map +1 -0
  91. package/dist/services/vcs/VCSProviderFactory.js +42 -0
  92. package/dist/types/index.d.ts +7 -0
  93. package/dist/types/index.d.ts.map +1 -1
  94. package/dist/utils/shell.d.ts +7 -0
  95. package/dist/utils/shell.d.ts.map +1 -0
  96. package/dist/utils/shell.js +32 -0
  97. package/package.json +7 -1
@@ -0,0 +1,678 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SmartDOMPruner = exports.DEFAULT_PRUNER_OPTIONS = void 0;
4
+ exports.DEFAULT_PRUNER_OPTIONS = {
5
+ candidateCount: 5,
6
+ baselineK: 30,
7
+ scoreFloor: 0.45,
8
+ repeatCollapseThreshold: 8,
9
+ exemplarsPerRun: 2,
10
+ tokenBudget: 6000,
11
+ // variant prefixes (hover:, md:), numeric scales (px-4, w-1/2, z-50),
12
+ // single-purpose atoms (flex, grid, block), color scales (text-red-500).
13
+ utilityClassDenyRegex: /^(?:[a-z-]+:)|^-?[a-z]+-\d/i,
14
+ exposeCandidatesToPrompt: true,
15
+ };
16
+ // ---------------------------------------------------------------------------
17
+ class SmartDOMPruner {
18
+ /**
19
+ * Build a candidate-focused, repeat-collapsed snapshot for a FAILED selector.
20
+ * Runs the whole in-browser pipeline (Stages 1–6) in one evaluate pass and
21
+ * assembles the manifest (Stage 7) on the Node side.
22
+ */
23
+ static async prune(page, target, opts = {}) {
24
+ const resolved = { ...exports.DEFAULT_PRUNER_OPTIONS, ...opts };
25
+ // --- Stage 0: decomposition / normalization (Node-side) ----------------
26
+ const normalized = SmartDOMPruner.normalizeTarget(target);
27
+ const browserOpts = {
28
+ candidateCount: resolved.candidateCount,
29
+ baselineK: resolved.baselineK,
30
+ scoreFloor: resolved.scoreFloor,
31
+ repeatCollapseThreshold: resolved.repeatCollapseThreshold,
32
+ exemplarsPerRun: resolved.exemplarsPerRun,
33
+ tokenBudget: resolved.tokenBudget,
34
+ utilityClassDenySource: resolved.utilityClassDenyRegex.source,
35
+ utilityClassDenyFlags: resolved.utilityClassDenyRegex.flags,
36
+ };
37
+ // --- Stages 1–6 run in the browser; result is fully serializable -------
38
+ const browserResult = await page.evaluate(SmartDOMPruner.browserPipeline, { target: normalized, opts: browserOpts });
39
+ // --- Stage 7: manifest assembly (Node-side) — STUB ---------------------
40
+ return browserResult;
41
+ }
42
+ /**
43
+ * Stage 0 — fold the failed selector + DNA into a clean feature vector.
44
+ * Derives the leaf class when absent and trims/normalizes text fields.
45
+ */
46
+ static normalizeTarget(target) {
47
+ const classes = (target.classes ?? []).filter(Boolean);
48
+ const leafClass = target.leafClass ?? (classes.length ? classes[classes.length - 1] : undefined);
49
+ const norm = (s) => (s == null ? s : s.replace(/\s+/g, " ").trim());
50
+ return {
51
+ ...target,
52
+ classes,
53
+ leafClass,
54
+ text: norm(target.text),
55
+ closestLabel: norm(target.closestLabel),
56
+ rowAnchor: norm(target.rowAnchor),
57
+ };
58
+ }
59
+ /**
60
+ * The in-browser pipeline. MUST be self-contained — under real Playwright it
61
+ * is serialized and executed in the page, so it may not close over Node-side
62
+ * scope. Stage 1 is implemented; Stages 2–6 are stubs.
63
+ */
64
+ static browserPipeline(arg) {
65
+ const { opts } = arg;
66
+ // ---- Stage 1: lightweight, layout-free deep traversal + flat index ----
67
+ // HARD RULE: no getComputedStyle / getBoundingClientRect / rect reads in
68
+ // this pass — on a 50k-node page that thrashes layout and freezes the
69
+ // browser thread. Only string-cheap attribute/property reads here.
70
+ const SKIP = new Set([
71
+ "script", "style", "svg", "path", "g", "defs", "template",
72
+ "link", "meta", "noscript", "head", "base", "title",
73
+ ]);
74
+ const cheapHidden = (el) => {
75
+ if (el.getAttribute("aria-hidden") === "true")
76
+ return true;
77
+ if (el.hasAttribute("hidden"))
78
+ return true;
79
+ if (el.tagName === "INPUT" && el.getAttribute("type") === "hidden")
80
+ return true;
81
+ const st = el.getAttribute("style");
82
+ if (st && /(?:^|;)\s*display\s*:\s*none/i.test(st))
83
+ return true;
84
+ if (st && /(?:^|;)\s*visibility\s*:\s*hidden/i.test(st))
85
+ return true;
86
+ return false;
87
+ };
88
+ // Light DOM + OPEN shadow root + same-origin iframe body. Closed shadow
89
+ // roots return null (inaccessible) — Stage 5 marks them as blind spots.
90
+ const childrenOf = (node) => {
91
+ const out = [];
92
+ const el = node;
93
+ if (el.shadowRoot)
94
+ out.push(...Array.from(el.shadowRoot.childNodes));
95
+ out.push(...Array.from(node.childNodes));
96
+ if (node.tagName === "IFRAME") {
97
+ try {
98
+ const d = node.contentDocument;
99
+ if (d && d.body)
100
+ out.push(...Array.from(d.body.childNodes));
101
+ }
102
+ catch {
103
+ /* cross-origin frame — handled by the >> drill instead */
104
+ }
105
+ }
106
+ return out;
107
+ };
108
+ const featuresOf = (el, docOrder) => ({
109
+ el,
110
+ docOrder,
111
+ tag: el.tagName.toLowerCase(),
112
+ id: el.getAttribute("id"),
113
+ testid: el.getAttribute("data-testid"),
114
+ name: el.getAttribute("name"),
115
+ role: el.getAttribute("role"),
116
+ type: el.getAttribute("type"),
117
+ placeholder: el.getAttribute("placeholder"),
118
+ classes: Array.from(el.classList || []),
119
+ ariaLabel: el.getAttribute("aria-label"),
120
+ // Own text only (direct text-node children) — cheap, no layout.
121
+ text: Array.from(el.childNodes)
122
+ .filter((n) => n.nodeType === 3 /* TEXT_NODE */)
123
+ .map((n) => (n.textContent || "").trim())
124
+ .filter(Boolean)
125
+ .join(" "),
126
+ });
127
+ const index = [];
128
+ let rawChars = 0;
129
+ const ELEMENT_NODE = 1;
130
+ const walk = (node) => {
131
+ if (node.nodeType !== ELEMENT_NODE)
132
+ return;
133
+ const el = node;
134
+ const tag = el.tagName.toLowerCase();
135
+ if (SKIP.has(tag))
136
+ return;
137
+ if (cheapHidden(el))
138
+ return;
139
+ const entry = featuresOf(el, index.length);
140
+ index.push(entry);
141
+ // Shadow-inclusive raw-size estimate. innerHTML excludes shadow content,
142
+ // which would skew reduction% for shadow-hosted UIs (card grids etc.).
143
+ rawChars += tag.length * 2 + 5 + entry.text.length;
144
+ for (const child of childrenOf(el))
145
+ walk(child);
146
+ };
147
+ const root = document.body || document.documentElement;
148
+ if (root)
149
+ walk(root);
150
+ const originalChars = rawChars;
151
+ // =====================================================================
152
+ // Stage 2 — Candidate scoring (2a cheap baseline → 2b heavy refine)
153
+ // =====================================================================
154
+ const tt = arg.target;
155
+ const norm = (s) => (s || "").toLowerCase().replace(/\s+/g, " ").trim();
156
+ const targetName = norm(tt.text || tt.closestLabel);
157
+ const targetClasses = new Set((tt.classes || []).filter(Boolean));
158
+ const targetAncestry = new Set((tt.ancestryFingerprints || []).filter(Boolean));
159
+ // Minimal implicit-ARIA-role map (layout-free; no accessibility tree).
160
+ const IMPLICIT_ROLE = {
161
+ button: "button", a: "link", input: "textbox", textarea: "textbox",
162
+ select: "combobox", nav: "navigation", main: "main",
163
+ h1: "heading", h2: "heading", h3: "heading",
164
+ h4: "heading", h5: "heading", h6: "heading",
165
+ };
166
+ const roleOf = (e) => e.role || IMPLICIT_ROLE[e.tag] || "";
167
+ const fingerprintOf = (el) => {
168
+ const tag = el.tagName.toLowerCase();
169
+ const id = el.getAttribute("id");
170
+ const cls = Array.from(el.classList || []).sort();
171
+ return (tag + (id ? `#${id}` : "") + (cls.length ? `.${cls.join(".")}` : ""));
172
+ };
173
+ // Climb one logical parent, crossing shadow boundaries (host hop).
174
+ const parentOf = (node) => {
175
+ const p = node.parentNode;
176
+ if (!p)
177
+ return null;
178
+ return p.nodeType === 11
179
+ ? p.host
180
+ : p;
181
+ };
182
+ const ancestorFingerprints = (el, max = 15) => {
183
+ const out = new Set();
184
+ let cur = parentOf(el);
185
+ let depth = 0;
186
+ while (cur && depth < max) {
187
+ if (cur.tagName)
188
+ out.add(fingerprintOf(cur));
189
+ cur = parentOf(cur);
190
+ depth++;
191
+ }
192
+ return out;
193
+ };
194
+ // Repeater containers: any element with ≥ threshold element children.
195
+ // children.length is O(1), so precomputing the set once keeps membership
196
+ // checks below from re-introducing the O(nodes × siblings) blow-up.
197
+ const repeaterContainers = new Set();
198
+ for (const e of index) {
199
+ if (e.el.children && e.el.children.length >= opts.repeatCollapseThreshold)
200
+ repeaterContainers.add(e.el);
201
+ }
202
+ // Nearest ancestor that is a direct child of a repeater container — the
203
+ // generalized "row"/member. Works for <tr>, <li>, card grids, virtual rows.
204
+ const repeaterMemberOf = (el) => {
205
+ let cur = el;
206
+ let depth = 0;
207
+ while (cur && depth < 25) {
208
+ const p = parentOf(cur);
209
+ if (p && p.tagName && repeaterContainers.has(p))
210
+ return cur;
211
+ cur = p;
212
+ depth++;
213
+ }
214
+ return null;
215
+ };
216
+ // Shadow-piercing text of a member subtree (cached, bounded) — element
217
+ // .textContent does NOT cross shadow boundaries, so row identity for a
218
+ // shadow-hosted card needs an explicit deep walk.
219
+ const deepTextCache = new Map();
220
+ const deepText = (el) => {
221
+ const cached = deepTextCache.get(el);
222
+ if (cached !== undefined)
223
+ return cached;
224
+ let out = "";
225
+ const stack = [el];
226
+ let n = 0;
227
+ while (stack.length && out.length < 500 && n < 2000) {
228
+ const node = stack.pop();
229
+ n++;
230
+ if (node.nodeType === 3)
231
+ out += " " + (node.textContent || "");
232
+ else if (node.nodeType === 1)
233
+ for (const k of childrenOf(node))
234
+ stack.push(k);
235
+ }
236
+ out = out.replace(/\s+/g, " ").trim();
237
+ deepTextCache.set(el, out);
238
+ return out;
239
+ };
240
+ const scoreEntry = (e) => {
241
+ let score = 0;
242
+ const signals = [];
243
+ // Identity (dominant).
244
+ if (tt.testid && e.testid && e.testid === tt.testid) {
245
+ score += 0.45;
246
+ signals.push("testid");
247
+ }
248
+ if (tt.id && e.id && e.id === tt.id) {
249
+ score += 0.45;
250
+ signals.push("id");
251
+ }
252
+ if (tt.name && e.name && e.name === tt.name) {
253
+ score += 0.15;
254
+ signals.push("name");
255
+ }
256
+ // Role + accessible name (semantics over geometry).
257
+ const roleMatch = !!tt.role && roleOf(e) === tt.role;
258
+ const nameText = norm(e.text || e.ariaLabel);
259
+ const nameMatch = !!targetName && !!nameText &&
260
+ (nameText === targetName ||
261
+ nameText.includes(targetName) ||
262
+ targetName.includes(nameText));
263
+ if (roleMatch && nameMatch) {
264
+ score += 0.3;
265
+ signals.push("role+name");
266
+ }
267
+ else if (roleMatch) {
268
+ score += 0.08;
269
+ signals.push("role");
270
+ }
271
+ else if (nameMatch) {
272
+ score += 0.22;
273
+ signals.push("text");
274
+ }
275
+ // Structural continuity.
276
+ if (targetAncestry.size) {
277
+ const anc = ancestorFingerprints(e.el);
278
+ let hit = 0;
279
+ targetAncestry.forEach((f) => { if (anc.has(f))
280
+ hit++; });
281
+ if (hit) {
282
+ score += (hit / targetAncestry.size) * 0.15;
283
+ signals.push("ancestry");
284
+ }
285
+ }
286
+ // Row identity (the 50-buttons / virtual-scroll lever) — generalized to
287
+ // any repeater member, with shadow-piercing text.
288
+ const rowEl = repeaterMemberOf(e.el);
289
+ const rowMatchesIdentity = !!tt.rowAnchor && !!rowEl && deepText(rowEl).includes(tt.rowAnchor);
290
+ if (rowMatchesIdentity) {
291
+ score += 0.2;
292
+ signals.push("rowAnchor");
293
+ }
294
+ // Class token overlap (exact Jaccard; fuzzy typo handling lands in Stage 5).
295
+ if (targetClasses.size && e.classes.length) {
296
+ const ecls = new Set(e.classes);
297
+ let inter = 0;
298
+ targetClasses.forEach((c) => { if (ecls.has(c))
299
+ inter++; });
300
+ const union = new Set([...targetClasses, ...e.classes]).size;
301
+ const j = union ? inter / union : 0;
302
+ if (inter) {
303
+ score += j * 0.1;
304
+ signals.push("class");
305
+ }
306
+ }
307
+ if (tt.tag && e.tag === tt.tag) {
308
+ score += 0.05;
309
+ signals.push("tag");
310
+ }
311
+ if (tt.placeholder && e.placeholder && e.placeholder === tt.placeholder) {
312
+ score += 0.1;
313
+ signals.push("placeholder");
314
+ }
315
+ if (score > 1)
316
+ score = 1;
317
+ return {
318
+ entry: e, score, signals, rowEl,
319
+ inRepeater: !!rowEl, rowMatchesIdentity,
320
+ };
321
+ };
322
+ // 2a — cheap baseline over the whole index, filtered by floor.
323
+ const baseline = index
324
+ .map(scoreEntry)
325
+ .filter((s) => s.score >= opts.scoreFloor)
326
+ .sort((a, b) => b.score - a.score);
327
+ // 2b — heavy refine STRICTLY gated to the top-K (perf invariant). Layout
328
+ // reads (getBoundingClientRect) happen here and ONLY here, ≤ baselineK.
329
+ let heavyChecks = 0;
330
+ const topK = baseline.slice(0, opts.baselineK);
331
+ const rectDeltaOf = new Map();
332
+ if (tt.rect) {
333
+ const rect = tt.rect;
334
+ for (const s of topK) {
335
+ const r = s.entry.el.getBoundingClientRect();
336
+ const dx = r.x - rect.x;
337
+ const dy = r.y - rect.y;
338
+ rectDeltaOf.set(s.entry, Math.round(Math.hypot(dx, dy)));
339
+ heavyChecks++;
340
+ }
341
+ }
342
+ // =====================================================================
343
+ // Stage 3 — Selection / multi-anchor pivot set / Trust-Engine back-off
344
+ // =====================================================================
345
+ const virtualization = (() => {
346
+ const grids = Array.from(document.querySelectorAll("[aria-rowcount],[aria-setsize]"));
347
+ for (const g of grids) {
348
+ const declared = Math.max(parseInt(g.getAttribute("aria-rowcount") || "", 10) || 0, parseInt(g.getAttribute("aria-setsize") || "", 10) || 0);
349
+ if (!declared)
350
+ continue;
351
+ const rendered = g.querySelectorAll('[role="row"], tr, li').length;
352
+ if (declared - rendered >= opts.repeatCollapseThreshold)
353
+ return true;
354
+ }
355
+ return false;
356
+ })();
357
+ // Identity gate: a candidate INSIDE a repeater must prove it is the
358
+ // target's row (rowAnchor match). Non-repeater candidates are not gated.
359
+ const gated = topK.filter((s) => !tt.rowAnchor || !s.inRepeater || s.rowMatchesIdentity);
360
+ const backoff = (reason) => ({
361
+ dom: "",
362
+ candidates: [],
363
+ reason,
364
+ stats: {
365
+ originalChars,
366
+ prunedChars: 0,
367
+ reductionPct: 0,
368
+ nodesIndexed: index.length,
369
+ heavyChecks,
370
+ estTokens: 0,
371
+ },
372
+ });
373
+ if (gated.length === 0) {
374
+ // Windowed grid + a repeater target whose row never mounted → the
375
+ // visible rows are NOT the target. Refuse rather than hallucinate one.
376
+ if (virtualization && tt.rowAnchor && topK.some((s) => s.inRepeater)) {
377
+ return backoff("virtualized:target-out-of-viewport");
378
+ }
379
+ // Nothing cleared the floor → element genuinely gone (real app bug).
380
+ return backoff("not-found");
381
+ }
382
+ // ---- Stage 7 (partial): manifest assembly ----------------------------
383
+ const LANDMARKS = new Set([
384
+ "dialog", "nav", "main", "section", "aside", "header", "footer", "form",
385
+ ]);
386
+ const landmarkPathOf = (el) => {
387
+ let cur = parentOf(el);
388
+ let depth = 0;
389
+ while (cur && depth < 20) {
390
+ const c = cur;
391
+ if (c.tagName) {
392
+ const tag = c.tagName.toLowerCase();
393
+ const role = c.getAttribute("role");
394
+ if (tag === "dialog" || role === "dialog" || LANDMARKS.has(tag) || role) {
395
+ const label = c.getAttribute("aria-label") || c.getAttribute("aria-labelledby");
396
+ const kind = tag === "dialog" || role === "dialog" ? "dialog" : role || tag;
397
+ return `${kind}${label ? `:"${label}"` : ""}`;
398
+ }
399
+ }
400
+ cur = parentOf(cur);
401
+ depth++;
402
+ }
403
+ return null;
404
+ };
405
+ // Count preceding same-tag siblings via O(1)-step pointers. NEVER iterate
406
+ // `parent.children` (a jsdom HTMLCollection is O(n)-indexed → O(n²)).
407
+ const nthInRepeaterOf = (rowEl) => {
408
+ if (!rowEl)
409
+ return null;
410
+ const tag = rowEl.tagName;
411
+ let i = 0;
412
+ let c = rowEl.previousElementSibling;
413
+ while (c) {
414
+ if (c.tagName === tag)
415
+ i++;
416
+ c = c.previousElementSibling;
417
+ }
418
+ return i;
419
+ };
420
+ const chosen = gated.slice(0, opts.candidateCount);
421
+ const candidates = chosen.map((s, i) => ({
422
+ selaCand: i,
423
+ rank: i + 1,
424
+ score: Math.round(s.score * 100) / 100,
425
+ matchedSignals: s.signals,
426
+ disambiguators: {
427
+ rowAnchor: s.rowMatchesIdentity ? tt.rowAnchor ?? null : null,
428
+ closestLabel: tt.closestLabel ?? null,
429
+ landmarkPath: landmarkPathOf(s.entry.el),
430
+ nthInRepeater: nthInRepeaterOf(s.rowEl),
431
+ rectDelta: rectDeltaOf.has(s.entry)
432
+ ? rectDeltaOf.get(s.entry)
433
+ : null,
434
+ },
435
+ semanticFlip: false,
436
+ }));
437
+ // =====================================================================
438
+ // Stage 4 + 5 — Repeat-collapse + serialization + class classifier
439
+ // =====================================================================
440
+ const utilityRe = new RegExp(opts.utilityClassDenySource, opts.utilityClassDenyFlags);
441
+ const PRESERVED = [
442
+ "id", "data-testid", "role", "name", "type", "placeholder",
443
+ "href", "alt", "title", "for", "value",
444
+ "aria-label", "aria-labelledby", "aria-describedby",
445
+ ];
446
+ const MAXTEXT = 200;
447
+ const truncate = (s) => s.length > MAXTEXT ? s.slice(0, MAXTEXT) + "…" : s;
448
+ // Candidate markers + survival spine (each candidate + all its ancestors).
449
+ const candIdOf = new Map();
450
+ chosen.forEach((s, i) => candIdOf.set(s.entry.el, i));
451
+ const spine = new Set();
452
+ for (const s of chosen) {
453
+ let cur = s.entry.el;
454
+ while (cur) {
455
+ if (cur.tagName)
456
+ spine.add(cur);
457
+ cur = parentOf(cur);
458
+ }
459
+ }
460
+ // Stage 6 state: elements whose subtree the budget drops, with their token
461
+ // cost. Populated AFTER the first full render, then re-rendered.
462
+ const budgetDrop = new Set();
463
+ const branchTokens = new Map();
464
+ // Merge consecutive per-node elision markers into one (keeps output tidy
465
+ // and the marker overhead bounded).
466
+ const coalesceElided = (s) => s.replace(/(?:<!-- sela:budget-elided \d+ tokens -->)+/g, (m) => {
467
+ const parts = m.match(/budget-elided (\d+) tokens/g) || [];
468
+ let sum = 0;
469
+ for (const p of parts)
470
+ sum += parseInt(p.replace(/\D+/g, ""), 10) || 0;
471
+ return `<!-- sela:budget-elided ${parts.length} nodes, ~${sum} tokens -->`;
472
+ });
473
+ const semanticClasses = (el) => Array.from(el.classList || []).filter((c) => !utilityRe.test(c));
474
+ // Volatile-data-blind structural signature: tag + semantic classes +
475
+ // shallow child-tag shape (incl. open shadow). Ignores text/ids/indices so
476
+ // 500 data rows hash identically and collapse into one run.
477
+ const signatureOf = (el) => {
478
+ const tag = el.tagName.toLowerCase();
479
+ const cls = semanticClasses(el).sort().join(".");
480
+ const shape = childrenOf(el)
481
+ .filter((n) => n.nodeType === 1)
482
+ .map((n) => n.tagName.toLowerCase())
483
+ .join(",");
484
+ return `${tag}.${cls}|${shape}`;
485
+ };
486
+ const containsHeader = (el) => el.tagName === "TH" || !!el.querySelector?.("th");
487
+ // Closed shadow roots are invisible to page script. Probe via attachShadow:
488
+ // it THROWS when a (closed) shadow already exists → blind-spot marker. Only
489
+ // probed for light-empty RENDERED nodes (post-collapse), so it can never
490
+ // mass-mutate the page.
491
+ const hasClosedShadow = (el) => {
492
+ if (el.shadowRoot || el.childNodes.length > 0)
493
+ return false;
494
+ try {
495
+ el.attachShadow({ mode: "open" });
496
+ return false;
497
+ }
498
+ catch {
499
+ return true;
500
+ }
501
+ };
502
+ const renderAttrs = (el) => {
503
+ let a = "";
504
+ const cid = candIdOf.get(el);
505
+ if (cid !== undefined)
506
+ a += ` sela-cand="${cid}"`;
507
+ for (const name of PRESERVED) {
508
+ const v = el.getAttribute(name);
509
+ if (v)
510
+ a += ` ${name}="${v}"`;
511
+ }
512
+ const cls = semanticClasses(el);
513
+ if (cls.length)
514
+ a += ` class="${cls.join(" ")}"`;
515
+ return a;
516
+ };
517
+ // Mutually recursive — function declarations so order/TDZ is a non-issue.
518
+ function renderNode(node) {
519
+ if (node.nodeType === 3) {
520
+ const t = (node.textContent || "").trim();
521
+ return t ? truncate(t) : "";
522
+ }
523
+ if (node.nodeType !== 1)
524
+ return "";
525
+ const el = node;
526
+ const tag = el.tagName.toLowerCase();
527
+ if (SKIP.has(tag) || cheapHidden(el))
528
+ return "";
529
+ if (budgetDrop.has(el)) {
530
+ const t = branchTokens.get(el) ?? 0;
531
+ return `<!-- sela:budget-elided ${t} tokens -->`;
532
+ }
533
+ const closed = hasClosedShadow(el) ? "<!-- shadow:closed -->" : "";
534
+ return `<${tag}${renderAttrs(el)}>${closed}${renderChildren(el)}</${tag}>`;
535
+ }
536
+ function renderChildren(el) {
537
+ const kids = childrenOf(el);
538
+ if (!repeaterContainers.has(el))
539
+ return kids.map(renderNode).join("");
540
+ // Collapse consecutive same-signature runs of element children.
541
+ const elemKids = kids.filter((n) => n.nodeType === 1 &&
542
+ !SKIP.has(n.tagName.toLowerCase()) &&
543
+ !cheapHidden(n));
544
+ const sigs = elemKids.map(signatureOf);
545
+ let out = "";
546
+ let i = 0;
547
+ while (i < elemKids.length) {
548
+ let j = i;
549
+ while (j < elemKids.length && sigs[j] === sigs[i])
550
+ j++;
551
+ const run = elemKids.slice(i, j);
552
+ if (run.length >= opts.repeatCollapseThreshold) {
553
+ // Keep: spine members (target/candidates) + header rows + a couple of
554
+ // exemplars. The target row is pinned BEFORE collapse can see it, so
555
+ // it is structurally impossible to drop.
556
+ let shown = 0;
557
+ let collapsed = 0;
558
+ let exemplars = 0;
559
+ for (const m of run) {
560
+ if (spine.has(m) || containsHeader(m)) {
561
+ out += renderNode(m);
562
+ shown++;
563
+ }
564
+ else if (exemplars < opts.exemplarsPerRun) {
565
+ out += renderNode(m);
566
+ shown++;
567
+ exemplars++;
568
+ }
569
+ else
570
+ collapsed++;
571
+ }
572
+ if (collapsed > 0) {
573
+ const sig = sigs[i].split("|")[0];
574
+ out += `<!-- sela:collapsed ${collapsed} of ${run.length} «${sig}» (${shown} shown) -->`;
575
+ }
576
+ }
577
+ else {
578
+ for (const m of run)
579
+ out += renderNode(m);
580
+ }
581
+ i = j;
582
+ }
583
+ return out;
584
+ }
585
+ let dom = root ? renderNode(root) : "";
586
+ // =====================================================================
587
+ // Stage 6 — Candidate-aware budgeting (priority elision)
588
+ // =====================================================================
589
+ if (root && Math.ceil(dom.length / 4) > opts.tokenBudget) {
590
+ const orderOf = new Map();
591
+ for (const e of index)
592
+ orderOf.set(e.el, e.docOrder);
593
+ const candOrders = chosen
594
+ .map((s) => orderOf.get(s.entry.el))
595
+ .filter((n) => n !== undefined);
596
+ const spineDepthOf = (el) => {
597
+ let d = 0;
598
+ let cur = el;
599
+ while (cur) {
600
+ d++;
601
+ cur = parentOf(cur);
602
+ }
603
+ return d;
604
+ };
605
+ // Elidable branches: element subtrees hanging off the spine that are
606
+ // neither on the spine nor inside a candidate (candidate neighborhoods
607
+ // are reserved). Priority = closeness to a candidate: deeper spine
608
+ // attachment first, then nearer in document order.
609
+ const branches = [];
610
+ for (const p of spine) {
611
+ if (candIdOf.has(p))
612
+ continue; // never elide under a candidate
613
+ for (const c of childrenOf(p)) {
614
+ if (c.nodeType !== 1)
615
+ continue;
616
+ const ce = c;
617
+ if (spine.has(ce) || candIdOf.has(ce))
618
+ continue;
619
+ if (SKIP.has(ce.tagName.toLowerCase()) || cheapHidden(ce))
620
+ continue;
621
+ const ord = orderOf.get(ce) ?? 0;
622
+ const dist = candOrders.length
623
+ ? Math.min(...candOrders.map((o) => Math.abs(o - ord)))
624
+ : 0;
625
+ branches.push({
626
+ el: ce,
627
+ tokens: Math.ceil(renderNode(ce).length / 4),
628
+ depth: spineDepthOf(p),
629
+ dist,
630
+ });
631
+ }
632
+ }
633
+ branches.sort((a, b) => b.depth - a.depth || a.dist - b.dist);
634
+ const branchTotal = branches.reduce((s, b) => s + b.tokens, 0);
635
+ const RESERVE = 40; // headroom for the coalesced elision markers
636
+ let used = Math.ceil(dom.length / 4) - branchTotal; // trunk cost
637
+ let dropping = false;
638
+ for (const b of branches) {
639
+ if (!dropping && used + b.tokens <= opts.tokenBudget - RESERVE) {
640
+ used += b.tokens; // keep — closest first
641
+ }
642
+ else {
643
+ dropping = true; // once over, drop this and every farther branch
644
+ budgetDrop.add(b.el);
645
+ branchTokens.set(b.el, b.tokens);
646
+ }
647
+ }
648
+ dom = coalesceElided(renderNode(root));
649
+ // Safety net: if marker overhead still tipped us over, drop the current
650
+ // farthest-kept branch and re-render, until we fit (bounded).
651
+ let guard = branches.length;
652
+ while (Math.ceil(dom.length / 4) > opts.tokenBudget && guard-- > 0) {
653
+ const victim = branches.filter((b) => !budgetDrop.has(b.el)).pop();
654
+ if (!victim)
655
+ break;
656
+ budgetDrop.add(victim.el);
657
+ branchTokens.set(victim.el, victim.tokens);
658
+ dom = coalesceElided(renderNode(root));
659
+ }
660
+ }
661
+ const reductionPct = originalChars > 0
662
+ ? Math.round((1 - dom.length / originalChars) * 1000) / 10
663
+ : 0;
664
+ return {
665
+ dom,
666
+ candidates,
667
+ stats: {
668
+ originalChars,
669
+ prunedChars: dom.length,
670
+ reductionPct,
671
+ nodesIndexed: index.length,
672
+ heavyChecks,
673
+ estTokens: Math.ceil(dom.length / 4),
674
+ },
675
+ };
676
+ }
677
+ }
678
+ exports.SmartDOMPruner = SmartDOMPruner;