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.
- package/dist/boot/bootImpl.d.ts +14 -0
- package/dist/boot/bootImpl.d.ts.map +1 -0
- package/dist/boot/bootImpl.js +68 -0
- package/dist/boot/register.cjs +45 -0
- package/dist/boot/register.d.cts +2 -0
- package/dist/boot/register.d.cts.map +1 -0
- package/dist/boot/register.d.mts +2 -0
- package/dist/boot/register.d.mts.map +1 -0
- package/dist/boot/register.mjs +10 -0
- package/dist/cli/commands/init.js +48 -16
- package/dist/cli/commands/merge.d.ts.map +1 -1
- package/dist/cli/commands/merge.js +5 -0
- package/dist/cli/commands/shadowRegression.js +1 -1
- package/dist/config/ConfigLoader.d.ts.map +1 -1
- package/dist/config/ConfigLoader.js +33 -12
- package/dist/config/SelaConfig.d.ts +59 -0
- package/dist/config/SelaConfig.d.ts.map +1 -1
- package/dist/config/SelaConfig.js +36 -0
- package/dist/engine/SelaEngine.d.ts +3 -1
- package/dist/engine/SelaEngine.d.ts.map +1 -1
- package/dist/engine/SelaEngine.js +193 -3
- package/dist/errors/SelaError.d.ts +7 -0
- package/dist/errors/SelaError.d.ts.map +1 -1
- package/dist/errors/SelaError.js +7 -1
- package/dist/fixtures/index.d.ts.map +1 -1
- package/dist/fixtures/index.js +5 -4
- package/dist/providers/AnthropicProvider.d.ts +9 -0
- package/dist/providers/AnthropicProvider.d.ts.map +1 -0
- package/dist/providers/AnthropicProvider.js +33 -0
- package/dist/providers/LLMProvider.d.ts +13 -0
- package/dist/providers/LLMProvider.d.ts.map +1 -0
- package/dist/providers/LLMProvider.js +7 -0
- package/dist/providers/OpenAICompatibleProvider.d.ts +8 -0
- package/dist/providers/OpenAICompatibleProvider.d.ts.map +1 -0
- package/dist/providers/OpenAICompatibleProvider.js +48 -0
- package/dist/providers/ProviderFactory.d.ts +4 -0
- package/dist/providers/ProviderFactory.d.ts.map +1 -0
- package/dist/providers/ProviderFactory.js +50 -0
- package/dist/providers/SanitizingProvider.d.ts +8 -0
- package/dist/providers/SanitizingProvider.d.ts.map +1 -0
- package/dist/providers/SanitizingProvider.js +27 -0
- package/dist/services/ASTSourceUpdater.d.ts.map +1 -1
- package/dist/services/ASTSourceUpdater.js +39 -5
- package/dist/services/CrossFileHealer.d.ts +7 -1
- package/dist/services/CrossFileHealer.d.ts.map +1 -1
- package/dist/services/CrossFileHealer.js +243 -9
- package/dist/services/DefinitionTracer.d.ts +1 -0
- package/dist/services/DefinitionTracer.d.ts.map +1 -1
- package/dist/services/DefinitionTracer.js +59 -36
- package/dist/services/HealReportService.d.ts +32 -0
- package/dist/services/HealReportService.d.ts.map +1 -1
- package/dist/services/HealReportService.js +111 -18
- package/dist/services/IntentAuditor.d.ts +9 -7
- package/dist/services/IntentAuditor.d.ts.map +1 -1
- package/dist/services/IntentAuditor.js +21 -16
- package/dist/services/LLMService.d.ts +10 -7
- package/dist/services/LLMService.d.ts.map +1 -1
- package/dist/services/LLMService.js +76 -10
- package/dist/services/PRAutomationService.d.ts +7 -2
- package/dist/services/PRAutomationService.d.ts.map +1 -1
- package/dist/services/PRAutomationService.js +144 -133
- package/dist/services/RegressionManifestService.d.ts +8 -0
- package/dist/services/RegressionManifestService.d.ts.map +1 -1
- package/dist/services/RegressionManifestService.js +8 -0
- package/dist/services/RegressionReclassifier.d.ts +25 -0
- package/dist/services/RegressionReclassifier.d.ts.map +1 -0
- package/dist/services/RegressionReclassifier.js +195 -0
- package/dist/services/ReportMergeService.d.ts +9 -0
- package/dist/services/ReportMergeService.d.ts.map +1 -1
- package/dist/services/ReportMergeService.js +28 -0
- package/dist/services/SmartDOMPruner.d.ts +154 -0
- package/dist/services/SmartDOMPruner.d.ts.map +1 -0
- package/dist/services/SmartDOMPruner.js +678 -0
- package/dist/services/TraceBackEngine.d.ts +23 -6
- package/dist/services/TraceBackEngine.d.ts.map +1 -1
- package/dist/services/TraceBackEngine.js +51 -13
- package/dist/services/VisualSnapshotService.d.ts +54 -0
- package/dist/services/VisualSnapshotService.d.ts.map +1 -0
- package/dist/services/VisualSnapshotService.js +279 -0
- package/dist/services/vcs/GitHubProvider.d.ts +17 -0
- package/dist/services/vcs/GitHubProvider.d.ts.map +1 -0
- package/dist/services/vcs/GitHubProvider.js +162 -0
- package/dist/services/vcs/GitLabProvider.d.ts +17 -0
- package/dist/services/vcs/GitLabProvider.d.ts.map +1 -0
- package/dist/services/vcs/GitLabProvider.js +175 -0
- package/dist/services/vcs/VCSProvider.d.ts +100 -0
- package/dist/services/vcs/VCSProvider.d.ts.map +1 -0
- package/dist/services/vcs/VCSProvider.js +7 -0
- package/dist/services/vcs/VCSProviderFactory.d.ts +7 -0
- package/dist/services/vcs/VCSProviderFactory.d.ts.map +1 -0
- package/dist/services/vcs/VCSProviderFactory.js +42 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/shell.d.ts +7 -0
- package/dist/utils/shell.d.ts.map +1 -0
- package/dist/utils/shell.js +32 -0
- 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;
|