explorbot 0.1.12 → 0.1.13

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 (59) hide show
  1. package/bin/explorbot-cli.ts +21 -21
  2. package/dist/bin/explorbot-cli.js +3 -3
  3. package/dist/package.json +3 -2
  4. package/dist/rules/researcher/container-rules.md +2 -0
  5. package/dist/src/action-result.js +2 -1
  6. package/dist/src/action.js +0 -6
  7. package/dist/src/ai/captain.js +0 -2
  8. package/dist/src/ai/driller.js +1108 -0
  9. package/dist/src/ai/pilot.js +31 -22
  10. package/dist/src/ai/rules.js +3 -5
  11. package/dist/src/ai/session-analyst.js +117 -0
  12. package/dist/src/ai/tester.js +13 -2
  13. package/dist/src/commands/base-command.js +6 -6
  14. package/dist/src/commands/drill-command.js +3 -2
  15. package/dist/src/commands/exit-command.js +1 -0
  16. package/dist/src/commands/explore-command.js +1 -0
  17. package/dist/src/components/AddRule.js +1 -1
  18. package/dist/src/explorbot.js +48 -8
  19. package/dist/src/explorer.js +9 -8
  20. package/dist/src/reporter.js +64 -3
  21. package/dist/src/state-manager.js +4 -3
  22. package/dist/src/stats.js +5 -0
  23. package/dist/src/utils/aria.js +354 -529
  24. package/dist/src/utils/hooks-runner.js +2 -8
  25. package/dist/src/utils/html.js +371 -0
  26. package/dist/src/utils/unique-names.js +12 -1
  27. package/dist/src/utils/url-matcher.js +6 -1
  28. package/dist/src/utils/web-element.js +27 -24
  29. package/dist/src/utils/xpath.js +1 -1
  30. package/package.json +3 -2
  31. package/rules/researcher/container-rules.md +2 -0
  32. package/src/action-result.ts +2 -1
  33. package/src/action.ts +0 -8
  34. package/src/ai/captain.ts +0 -2
  35. package/src/ai/driller.ts +1194 -0
  36. package/src/ai/pilot.ts +31 -21
  37. package/src/ai/rules.ts +3 -5
  38. package/src/ai/session-analyst.ts +133 -0
  39. package/src/ai/tester.ts +15 -2
  40. package/src/commands/base-command.ts +6 -6
  41. package/src/commands/drill-command.ts +3 -2
  42. package/src/commands/exit-command.ts +1 -0
  43. package/src/commands/explore-command.ts +1 -0
  44. package/src/components/AddRule.tsx +1 -1
  45. package/src/config.ts +4 -0
  46. package/src/explorbot.ts +55 -10
  47. package/src/explorer.ts +9 -8
  48. package/src/reporter.ts +64 -3
  49. package/src/state-manager.ts +4 -3
  50. package/src/stats.ts +7 -0
  51. package/src/utils/aria.ts +367 -537
  52. package/src/utils/hooks-runner.ts +2 -6
  53. package/src/utils/html.ts +381 -0
  54. package/src/utils/unique-names.ts +13 -0
  55. package/src/utils/url-matcher.ts +5 -1
  56. package/src/utils/web-element.ts +31 -28
  57. package/src/utils/xpath.ts +1 -1
  58. package/dist/src/ai/bosun.js +0 -456
  59. package/src/ai/bosun.ts +0 -571
package/src/utils/aria.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import { parse as parseYaml } from 'yaml';
2
+
3
+ // ─────────────────────────────────────────────────────────────────
4
+ // Roles
5
+ // ─────────────────────────────────────────────────────────────────
6
+
1
7
  const INTERACTIVE_ROLES = new Set(
2
8
  [
3
9
  'button',
@@ -37,75 +43,18 @@ const INTERACTIVE_ROLES = new Set(
37
43
  ].map((role) => role.toLowerCase())
38
44
  );
39
45
 
40
- const IGNORED_CONTAINER_ROLES = new Set(['navigation']);
46
+ const IGNORED_ROLES = new Set(['navigation']);
41
47
 
42
- type AriaNode = {
43
- role: string;
44
- name?: string;
45
- value?: string | boolean | null;
46
- attributes: Record<string, string | boolean | null>;
47
- children: AriaNode[];
48
- rawChildren?: AriaNode[];
49
- };
48
+ // ─────────────────────────────────────────────────────────────────
49
+ // Tunables (knobs that change pipeline behavior)
50
+ // ─────────────────────────────────────────────────────────────────
50
51
 
51
- const serializeChildContent = (node: AriaNode): string => {
52
- const children = node.rawChildren || node.children;
53
- const parts: string[] = [];
54
- for (const child of children) {
55
- let part = child.role;
56
- if (child.name) part += ` "${child.name}"`;
57
- const nested = serializeChildContent(child);
58
- if (nested) part += ` > ${nested}`;
59
- parts.push(part);
60
- }
61
- return parts.join(', ');
62
- };
52
+ const SIBLING_COLLAPSE_THRESHOLD = 50;
53
+ const SIBLING_COLLAPSE_KEEP_EACH_SIDE = 5;
63
54
 
64
- const buildInteractiveEntry = (node: AriaNode): Record<string, unknown> | null => {
65
- if (!INTERACTIVE_ROLES.has(node.role)) {
66
- return null;
67
- }
68
- const entry: Record<string, unknown> = { role: node.role };
69
- if (node.name && node.name.trim() !== '') {
70
- entry.name = node.name.trim();
71
- }
72
- if (node.value !== undefined && node.value !== null) {
73
- const valueText = `${node.value}`.trim();
74
- if (valueText !== '') {
75
- entry.value = node.value;
76
- }
77
- }
78
- for (const [key, value] of Object.entries(node.attributes)) {
79
- if (value === undefined || value === null) {
80
- continue;
81
- }
82
- if (value === '') {
83
- continue;
84
- }
85
- entry[key] = value;
86
- }
87
- const hasData = Object.keys(entry).some((key) => key !== 'role');
88
- const entryName = typeof entry.name === 'string' ? entry.name : '';
89
- const hasValue = Object.prototype.hasOwnProperty.call(entry, 'value');
90
- const isButtonOrLink = node.role === 'button' || node.role === 'link';
91
- let shouldInclude = hasData;
92
- if (!shouldInclude && hasValue) {
93
- shouldInclude = true;
94
- }
95
- if (isButtonOrLink && !entryName && !hasValue) {
96
- const resolved = resolveDisplayName(node);
97
- if (resolved) {
98
- entry.name = resolved;
99
- } else {
100
- entry.unnamed = true;
101
- }
102
- shouldInclude = true;
103
- }
104
- if (!shouldInclude) {
105
- return null;
106
- }
107
- return entry;
108
- };
55
+ // ─────────────────────────────────────────────────────────────────
56
+ // STEP 1 · Parse: YAML text → AriaNode[]
57
+ // ─────────────────────────────────────────────────────────────────
109
58
 
110
59
  const normalizeScalar = (input: string): string | boolean | null => {
111
60
  let value = input.trim();
@@ -113,242 +62,311 @@ const normalizeScalar = (input: string): string | boolean | null => {
113
62
  value = value.slice(1, -1);
114
63
  }
115
64
  const lower = value.toLowerCase();
116
- if (lower === 'true') {
117
- return true;
118
- }
119
- if (lower === 'false') {
120
- return false;
121
- }
122
- if (lower === 'null') {
123
- return null;
124
- }
65
+ if (lower === 'true') return true;
66
+ if (lower === 'false') return false;
67
+ if (lower === 'null') return null;
125
68
  return value;
126
69
  };
127
70
 
128
- const tokenizeAttributes = (input: string): string[] => {
129
- const tokens: string[] = [];
130
- let current = '';
131
- let inQuotes = false;
132
- let quoteChar = '';
133
- for (let i = 0; i < input.length; i += 1) {
134
- const char = input[i];
135
- if ((char === '"' || char === "'") && input[i - 1] !== '\\') {
136
- if (inQuotes && quoteChar === char) {
137
- inQuotes = false;
138
- quoteChar = '';
139
- } else if (!inQuotes) {
140
- inQuotes = true;
141
- quoteChar = char;
142
- }
143
- current += char;
144
- continue;
145
- }
146
- if (!inQuotes && (char === ' ' || char === ',')) {
147
- if (current.trim() !== '') {
148
- tokens.push(current.trim());
149
- }
150
- current = '';
151
- continue;
152
- }
153
- current += char;
154
- }
155
- if (current.trim() !== '') {
156
- tokens.push(current.trim());
71
+ // Parse one YAML node label like: `button "Save"`, `textbox "Email" [focused]`, `heading "Title" [level=2]`
72
+ const parseLabel = (label: string): { role: string; name?: string; attributes: Record<string, string | boolean | null> } | null => {
73
+ if (!label) return null;
74
+ const trimmed = label.trim();
75
+ const roleMatch = trimmed.match(/^(\w+)/);
76
+ if (!roleMatch) return null;
77
+ const role = roleMatch[1].toLowerCase();
78
+ let rest = trimmed.slice(roleMatch[0].length);
79
+
80
+ let name: string | undefined;
81
+ const nameMatch = rest.match(/^\s*"((?:[^"\\]|\\.)*)"/) || rest.match(/^\s*'((?:[^'\\]|\\.)*)'/);
82
+ if (nameMatch) {
83
+ name = nameMatch[1];
84
+ rest = rest.slice(nameMatch[0].length);
157
85
  }
158
- return tokens;
159
- };
160
86
 
161
- const parseAttributes = (input: string): Record<string, string | boolean | null> => {
162
87
  const attributes: Record<string, string | boolean | null> = {};
163
- const tokens = tokenizeAttributes(input);
164
- for (const token of tokens) {
165
- if (token === '') {
166
- continue;
167
- }
168
- const separatorIndex = token.indexOf('=');
169
- if (separatorIndex === -1) {
170
- attributes[token.toLowerCase()] = true;
171
- continue;
88
+ const attrMatch = rest.match(/\[([^\]]*)\]/);
89
+ if (attrMatch) {
90
+ for (const tok of attrMatch[1].split(/[\s,]+/).filter(Boolean)) {
91
+ const eq = tok.indexOf('=');
92
+ if (eq === -1) {
93
+ attributes[tok.toLowerCase()] = true;
94
+ continue;
95
+ }
96
+ attributes[tok.slice(0, eq).trim().toLowerCase()] = normalizeScalar(tok.slice(eq + 1));
172
97
  }
173
- const key = token.slice(0, separatorIndex).trim().toLowerCase();
174
- const valueRaw = token.slice(separatorIndex + 1).trim();
175
- attributes[key] = normalizeScalar(valueRaw);
176
98
  }
177
- return attributes;
99
+
100
+ return { role, name, attributes };
178
101
  };
179
102
 
180
- const parseHeader = (header: string): { role: string; name?: string; attributes: Record<string, string | boolean | null> } | null => {
181
- if (!header) {
182
- return null;
103
+ const yamlItemToNode = (item: unknown): AriaNode | null => {
104
+ if (typeof item === 'string') {
105
+ const label = parseLabel(item);
106
+ if (!label) return null;
107
+ const node: AriaNode = { role: label.role, attributes: label.attributes, children: [] };
108
+ if (label.name && label.name.trim() !== '') node.name = label.name.trim();
109
+ return node;
110
+ }
111
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
112
+
113
+ const entries = Object.entries(item as Record<string, unknown>);
114
+ if (entries.length === 0) return null;
115
+ const [key, value] = entries[0];
116
+ const label = parseLabel(key);
117
+ if (!label) return null;
118
+ const node: AriaNode = { role: label.role, attributes: label.attributes, children: [] };
119
+ if (label.name && label.name.trim() !== '') node.name = label.name.trim();
120
+
121
+ if (Array.isArray(value)) {
122
+ node.children = value.map(yamlItemToNode).filter((n): n is AriaNode => n !== null);
123
+ return node;
124
+ }
125
+ if (value === null || value === undefined) return node;
126
+ const normalized = normalizeScalar(String(value));
127
+ if (normalized !== '' && normalized !== undefined) node.value = normalized;
128
+ return node;
129
+ };
130
+
131
+ const parseSnapshot = (snapshot: string | null): AriaNode[] => {
132
+ if (!snapshot) return [];
133
+ let parsed: unknown;
134
+ try {
135
+ parsed = parseYaml(snapshot);
136
+ } catch {
137
+ return [];
183
138
  }
184
- let index = 0;
185
- const length = header.length;
186
- while (index < length && header[index] === ' ') {
187
- index += 1;
139
+ if (!Array.isArray(parsed)) return [];
140
+ return parsed.map(yamlItemToNode).filter((n): n is AriaNode => n !== null);
141
+ };
142
+
143
+ // ─────────────────────────────────────────────────────────────────
144
+ // STEP 2 · Transforms: AriaNode[] → AriaNode[]
145
+ // Each is a pure function. Compose by stacking calls in the public API.
146
+ // ─────────────────────────────────────────────────────────────────
147
+
148
+ // Dissolve <navigation> wrappers into their children.
149
+ const unwrapIgnored = (nodes: AriaNode[]): AriaNode[] =>
150
+ nodes.flatMap((node) => {
151
+ const children = unwrapIgnored(node.children);
152
+ if (IGNORED_ROLES.has(node.role)) return children;
153
+ return [{ ...node, children }];
154
+ });
155
+
156
+ // Walk children to produce a synthetic label for naming icon-only buttons.
157
+ const summarizeChildren = (children: AriaNode[]): string =>
158
+ children
159
+ .map((child) => {
160
+ let part = child.role;
161
+ if (child.name) part += ` "${child.name}"`;
162
+ const nested = summarizeChildren(child.children);
163
+ if (nested) part += ` > ${nested}`;
164
+ return part;
165
+ })
166
+ .join(', ');
167
+
168
+ // Set node.name = "{img "icon"}" for buttons/links that have no name but do have children.
169
+ // Recurses so nested buttons get named too. Uses ORIGINAL children for the summary, before pruning.
170
+ const nameIconButtons = (nodes: AriaNode[]): AriaNode[] =>
171
+ nodes.map((node) => {
172
+ const namedChildren = nameIconButtons(node.children);
173
+ if (node.name) return { ...node, children: namedChildren };
174
+ if (node.role !== 'button' && node.role !== 'link') return { ...node, children: namedChildren };
175
+ if (node.children.length === 0) return { ...node, children: namedChildren };
176
+ return { ...node, name: `{${summarizeChildren(node.children)}}`, children: namedChildren };
177
+ });
178
+
179
+ // Drop containers that contribute nothing.
180
+ // keepNamed=true → also keep named non-interactive nodes (e.g. headings, named text).
181
+ const dropEmpty = (nodes: AriaNode[], opts: { keepNamed?: boolean } = {}): AriaNode[] =>
182
+ nodes.flatMap((node) => {
183
+ const children = dropEmpty(node.children, opts);
184
+ if (INTERACTIVE_ROLES.has(node.role)) return [{ ...node, children }];
185
+ if (children.length > 0) return [{ ...node, children }];
186
+ if (opts.keepNamed && (node.name || node.value !== undefined)) return [{ ...node, children }];
187
+ return [];
188
+ });
189
+
190
+ // ─────────────────────────────────────────────────────────────────
191
+ // STEP 3 · Render: AriaNode[] → text or flat entries
192
+ // ─────────────────────────────────────────────────────────────────
193
+
194
+ // One-line representation of a node. Stable attr order so diff comparisons are deterministic.
195
+ const formatNode = (node: AriaNode): string => {
196
+ let line = node.role;
197
+ if (node.name?.trim()) line += ` "${node.name.trim()}"`;
198
+ const attrStr = Object.keys(node.attributes)
199
+ .sort()
200
+ .map((k) => {
201
+ const v = node.attributes[k];
202
+ if (v === undefined || v === null || v === '') return '';
203
+ if (v === true) return k;
204
+ return `${k}=${v}`;
205
+ })
206
+ .filter(Boolean)
207
+ .join(' ');
208
+ if (attrStr) line += ` [${attrStr}]`;
209
+ if (node.value !== undefined && node.value !== null) {
210
+ const text = String(node.value).trim();
211
+ if (text) line += `: ${text}`;
188
212
  }
189
- let roleEnd = index;
190
- while (roleEnd < length) {
191
- const char = header[roleEnd];
192
- if (char === ' ' || char === '[' || char === '"' || char === "'") {
193
- break;
194
- }
195
- roleEnd += 1;
213
+ return line;
214
+ };
215
+
216
+ // Group consecutive same-role siblings. [a,a,b,a,a,a] [[a,a],[b],[a,a,a]]
217
+ const groupByConsecutiveRole = (nodes: AriaNode[]): AriaNode[][] =>
218
+ nodes.reduce<AriaNode[][]>((groups, node) => {
219
+ const last = groups[groups.length - 1];
220
+ if (last && last[0].role === node.role) {
221
+ last.push(node);
222
+ return groups;
223
+ }
224
+ groups.push([node]);
225
+ return groups;
226
+ }, []);
227
+
228
+ // Large group of same-role siblings → first N + "...M omitted..." marker + last N.
229
+ const collapseGroup = (group: AriaNode[], depth: number): RenderEntry[] => {
230
+ if (group.length <= SIBLING_COLLAPSE_THRESHOLD) {
231
+ return group.map((node) => ({ node }));
232
+ }
233
+ const keep = SIBLING_COLLAPSE_KEEP_EACH_SIDE;
234
+ const omitted = group.length - keep * 2;
235
+ const indent = ' '.repeat(depth);
236
+ return [...group.slice(0, keep).map((node) => ({ node })), { placeholder: `${indent}- ...${omitted} similar "${group[0].role}" items omitted...` }, ...group.slice(-keep).map((node) => ({ node }))];
237
+ };
238
+
239
+ const collapseSiblingGroups = (nodes: AriaNode[], depth: number): RenderEntry[] => groupByConsecutiveRole(nodes).flatMap((group) => collapseGroup(group, depth));
240
+
241
+ // Tree → indented YAML text.
242
+ const renderTree = (nodes: AriaNode[], depth = 0): string =>
243
+ collapseSiblingGroups(nodes, depth)
244
+ .map((entry) => {
245
+ if ('placeholder' in entry) return entry.placeholder;
246
+ const { node } = entry;
247
+ const indent = ' '.repeat(depth);
248
+ const head = `${indent}- ${formatNode(node)}`;
249
+ if (node.children.length === 0) return head;
250
+ return `${head}:\n${renderTree(node.children, depth + 1)}`;
251
+ })
252
+ .join('\n');
253
+
254
+ // Build the structured "entry" object for an interactive node, or null if not worth keeping.
255
+ const nodeToEntry = (node: AriaNode): Record<string, unknown> | null => {
256
+ if (!INTERACTIVE_ROLES.has(node.role)) return null;
257
+ const entry: Record<string, unknown> = { role: node.role };
258
+ if (node.name?.trim()) entry.name = node.name.trim();
259
+ if (node.value !== undefined && node.value !== null) {
260
+ const text = String(node.value).trim();
261
+ if (text) entry.value = node.value;
196
262
  }
197
- const role = header.slice(index, roleEnd).trim().toLowerCase();
198
- if (!role) {
199
- return null;
263
+ for (const [key, value] of Object.entries(node.attributes)) {
264
+ if (value === undefined || value === null || value === '') continue;
265
+ entry[key] = value;
200
266
  }
201
- let name: string | undefined;
202
- const attributes: Record<string, string | boolean | null> = {};
203
- index = roleEnd;
204
- while (index < length) {
205
- const char = header[index];
206
- if (char === ' ') {
207
- index += 1;
208
- continue;
209
- }
210
- if (char === '"' || char === "'") {
211
- const quoteChar = char;
212
- index += 1;
213
- let value = '';
214
- while (index < length) {
215
- const currentChar = header[index];
216
- if (currentChar === quoteChar && header[index - 1] !== '\\') {
217
- index += 1;
218
- break;
219
- }
220
- value += currentChar;
221
- index += 1;
222
- }
223
- if (!name) {
224
- name = value;
225
- }
226
- continue;
227
- }
228
- if (char === '[') {
229
- const end = header.indexOf(']', index);
230
- const content = end === -1 ? header.slice(index + 1) : header.slice(index + 1, end);
231
- const parsed = parseAttributes(content);
232
- for (const [key, value] of Object.entries(parsed)) {
233
- attributes[key] = value;
234
- }
235
- index = end === -1 ? length : end + 1;
236
- continue;
237
- }
238
- break;
267
+ const isButtonOrLink = node.role === 'button' || node.role === 'link';
268
+ const hasContent = Object.keys(entry).length > 1;
269
+ if (isButtonOrLink && !hasContent) {
270
+ entry.unnamed = true;
271
+ return entry;
239
272
  }
240
- return { role, name, attributes };
273
+ if (!hasContent) return null;
274
+ return entry;
241
275
  };
242
276
 
243
- const splitHeaderValue = (content: string): { header: string; value: string | null } => {
244
- let activeQuote: string | null = null;
245
- let bracketDepth = 0;
246
- for (let i = 0; i < content.length; i += 1) {
247
- const char = content[i];
248
- if ((char === '"' || char === "'") && content[i - 1] !== '\\') {
249
- if (activeQuote === char) {
250
- activeQuote = null;
251
- } else if (!activeQuote) {
252
- activeQuote = char;
253
- }
254
- continue;
255
- }
256
- if (!activeQuote) {
257
- if (char === '[') {
258
- bracketDepth += 1;
259
- continue;
260
- }
261
- if (char === ']') {
262
- if (bracketDepth > 0) {
263
- bracketDepth -= 1;
264
- }
265
- continue;
266
- }
267
- if (char === ':') {
268
- if (bracketDepth === 0) {
269
- const header = content.slice(0, i).trimEnd();
270
- const value = content.slice(i + 1).trimStart();
271
- return { header, value: value === '' ? null : value };
272
- }
273
- }
274
- }
275
- }
276
- return { header: content.trim(), value: null };
277
+ // Walk tree, emit one FlatEntry per interactive node. Path is dotted index from root.
278
+ const flatten = (nodes: AriaNode[]): FlatEntry[] => {
279
+ const collect = (node: AriaNode, path: string): FlatEntry[] => {
280
+ const entry = nodeToEntry(node);
281
+ const here: FlatEntry[] = entry ? [{ path, summary: formatNode(node), entry }] : [];
282
+ const fromChildren = node.children.flatMap((child, i) => collect(child, `${path}.${i}`));
283
+ return [...here, ...fromChildren];
284
+ };
285
+ return nodes.flatMap((node, i) => collect(node, String(i)));
277
286
  };
278
287
 
279
- const pruneNodes = (nodes: AriaNode[], keepNamed = false): AriaNode[] => {
280
- const result: AriaNode[] = [];
281
- for (const node of nodes) {
282
- const children = pruneNodes(node.children, keepNamed);
283
- if (IGNORED_CONTAINER_ROLES.has(node.role)) {
284
- result.push(...children);
285
- continue;
286
- }
287
- const interactive = INTERACTIVE_ROLES.has(node.role);
288
- if (!interactive && children.length === 0) {
289
- if (!keepNamed || (!node.name && node.value === undefined)) {
290
- continue;
291
- }
292
- }
293
- result.push({ ...node, children, rawChildren: node.children });
288
+ // ─────────────────────────────────────────────────────────────────
289
+ // STEP 4 · Diff: FlatEntry[] × FlatEntry[] → text
290
+ // ─────────────────────────────────────────────────────────────────
291
+
292
+ const countBy = (items: string[]): Map<string, number> =>
293
+ items.reduce((map, item) => {
294
+ if (item === '') return map;
295
+ map.set(item, (map.get(item) ?? 0) + 1);
296
+ return map;
297
+ }, new Map<string, number>());
298
+
299
+ // Bag-style diff: any summary appearing more in one bag than the other becomes added/removed.
300
+ const diffByCount = (before: Map<string, number>, after: Map<string, number>): { added: string[]; removed: string[] } => {
301
+ const added: string[] = [];
302
+ const removed: string[] = [];
303
+ const all = new Set<string>([...before.keys(), ...after.keys()]);
304
+ for (const summary of all) {
305
+ const b = before.get(summary) ?? 0;
306
+ const a = after.get(summary) ?? 0;
307
+ for (let i = 0; i < a - b; i += 1) added.push(summary);
308
+ for (let i = 0; i < b - a; i += 1) removed.push(summary);
309
+ }
310
+ return { added, removed };
311
+ };
312
+
313
+ // When the same path has a different summary AND the per-summary totals haven't shifted,
314
+ // treat it as a rename (one add + one remove). Catches "button text changed" cases that
315
+ // the count-based diff would miss.
316
+ const detectRenames = (prev: FlatEntry[], curr: FlatEntry[], prevTotals: Map<string, number>, currTotals: Map<string, number>): { added: string[]; removed: string[] } => {
317
+ const added: string[] = [];
318
+ const removed: string[] = [];
319
+ const prevByPath = new Map(prev.map((e) => [e.path, e.summary]));
320
+ const currByPath = new Map(curr.map((e) => [e.path, e.summary]));
321
+
322
+ for (const [path, beforeSummary] of prevByPath) {
323
+ const afterSummary = currByPath.get(path);
324
+ if (!afterSummary || afterSummary === beforeSummary) continue;
325
+ const totalsAfter = (currTotals.get(afterSummary) ?? 0) === (prevTotals.get(afterSummary) ?? 0);
326
+ const totalsBefore = (currTotals.get(beforeSummary) ?? 0) === (prevTotals.get(beforeSummary) ?? 0);
327
+ if (!totalsAfter || !totalsBefore) continue;
328
+ const beforeElsewhere = curr.some((e) => e.path !== path && e.summary === beforeSummary);
329
+ const afterElsewhere = prev.some((e) => e.path !== path && e.summary === afterSummary);
330
+ if (beforeElsewhere && afterElsewhere) continue;
331
+ added.push(afterSummary);
332
+ removed.push(beforeSummary);
294
333
  }
295
- return result;
334
+ return { added, removed };
296
335
  };
297
336
 
298
- const parseAriaSnapshot = (snapshot: string | null, keepNamed = false): AriaNode[] => {
299
- if (!snapshot) {
300
- return [];
337
+ const TOP_DIFF_ITEMS = 10;
338
+
339
+ const formatDiffSection = (label: string, items: string[]): string[] => {
340
+ const summary = countBy(items);
341
+ if (summary.size === 0) return [` ${label}: []`];
342
+
343
+ const sorted = Array.from(summary.entries()).sort(([aItem, aCount], [bItem, bCount]) => bCount - aCount || aItem.localeCompare(bItem));
344
+ const top = sorted.slice(0, TOP_DIFF_ITEMS);
345
+ const rest = sorted.slice(TOP_DIFF_ITEMS);
346
+
347
+ const lines = [` ${label}:`];
348
+ for (const [item, count] of top) {
349
+ let suffix = '';
350
+ if (count > 1) suffix = ` (x${count})`;
351
+ lines.push(` - ${item}${suffix}`);
301
352
  }
302
- const roots: AriaNode[] = [];
303
- const stack: Array<{ depth: number; node: AriaNode }> = [];
304
- const lines = snapshot.split(/\r?\n/);
305
- for (const line of lines) {
306
- if (!line.trim()) {
307
- continue;
308
- }
309
- const indentMatch = line.match(/^\s*/);
310
- const indent = indentMatch ? indentMatch[0].length : 0;
311
- const trimmed = line.slice(indent);
312
- if (!trimmed.startsWith('-')) {
313
- continue;
314
- }
315
- const content = trimmed.slice(1).trim();
316
- if (content === '') {
317
- continue;
318
- }
319
- const { header, value } = splitHeaderValue(content);
320
- const parsedHeader = parseHeader(header);
321
- if (!parsedHeader) {
322
- continue;
323
- }
324
- const node: AriaNode = {
325
- role: parsedHeader.role,
326
- attributes: { ...parsedHeader.attributes },
327
- children: [],
328
- };
329
- if (parsedHeader.name && parsedHeader.name.trim() !== '') {
330
- node.name = parsedHeader.name.trim();
331
- }
332
- if (value !== null) {
333
- const normalizedValue = normalizeScalar(value);
334
- if (normalizedValue !== '' && normalizedValue !== undefined) {
335
- node.value = normalizedValue;
336
- }
337
- }
338
- const depth = Math.floor(indent / 2);
339
- while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
340
- stack.pop();
341
- }
342
- if (stack.length === 0) {
343
- roots.push(node);
344
- } else {
345
- stack[stack.length - 1].node.children.push(node);
346
- }
347
- stack.push({ depth, node });
353
+ if (rest.length > 0) {
354
+ let restTotal = 0;
355
+ for (const [, count] of rest) restTotal += count;
356
+ lines.push(` + ${restTotal} more interactive elements`);
348
357
  }
349
- return pruneNodes(roots, keepNamed);
358
+ return lines;
350
359
  };
351
360
 
361
+ const formatDiff = (added: string[], removed: string[]): string | null => {
362
+ if (added.length === 0 && removed.length === 0) return null;
363
+ return ['ariaDiff:', ...formatDiffSection('added', added), ...formatDiffSection('removed', removed)].join('\n');
364
+ };
365
+
366
+ // ─────────────────────────────────────────────────────────────────
367
+ // Focus area detection (separate concern; consumes pipeline output)
368
+ // ─────────────────────────────────────────────────────────────────
369
+
352
370
  export interface FocusAreaResult {
353
371
  detected: boolean;
354
372
  type: 'dialog' | 'modal' | null;
@@ -375,11 +393,7 @@ const findOverlayByCloseButton = (nodeList: AriaNode[]): FocusAreaResult | null
375
393
  }
376
394
  }
377
395
  }
378
- return {
379
- detected: true,
380
- type: 'dialog',
381
- name: heading?.name || null,
382
- };
396
+ return { detected: true, type: 'dialog', name: heading?.name || null };
383
397
  }
384
398
  for (const node of nodeList) {
385
399
  const inner = findOverlayByCloseButton(node.children);
@@ -388,138 +402,75 @@ const findOverlayByCloseButton = (nodeList: AriaNode[]): FocusAreaResult | null
388
402
  return null;
389
403
  };
390
404
 
391
- export const detectFocusArea = (snapshot: string | null): FocusAreaResult => {
392
- const nodes = parseAriaSnapshot(snapshot, true);
393
-
394
- const findFocusArea = (nodeList: AriaNode[]): FocusAreaResult | null => {
395
- for (const node of nodeList) {
396
- if (node.role === 'dialog' || node.role === 'alertdialog') {
397
- return {
398
- detected: true,
399
- type: 'dialog',
400
- name: node.name || null,
401
- };
402
- }
403
-
404
- if (node.attributes.modal === true || node.attributes.modal === 'true') {
405
- return {
406
- detected: true,
407
- type: 'modal',
408
- name: node.name || null,
409
- };
410
- }
411
-
412
- const childResult = findFocusArea(node.children);
413
- if (childResult) {
414
- return childResult;
415
- }
405
+ const findDialogOrModal = (nodes: AriaNode[]): FocusAreaResult | null => {
406
+ for (const node of nodes) {
407
+ if (node.role === 'dialog' || node.role === 'alertdialog') {
408
+ return { detected: true, type: 'dialog', name: node.name || null };
416
409
  }
417
- return null;
418
- };
419
-
420
- const result = findFocusArea(nodes);
421
- if (result) return result;
410
+ if (node.attributes.modal === true || node.attributes.modal === 'true') {
411
+ return { detected: true, type: 'modal', name: node.name || null };
412
+ }
413
+ const child = findDialogOrModal(node.children);
414
+ if (child) return child;
415
+ }
416
+ return null;
417
+ };
422
418
 
423
- const fallback = findOverlayByCloseButton(nodes);
424
- if (fallback?.name) return fallback;
419
+ // ─────────────────────────────────────────────────────────────────
420
+ // Public API — pipelines composed visibly, top-to-bottom
421
+ // ─────────────────────────────────────────────────────────────────
425
422
 
426
- return { detected: false, type: null, name: null };
423
+ export const compactAriaSnapshot = (snapshot: string | null, keepNamed = false): string => {
424
+ if (!snapshot) return '';
425
+ let tree = parseSnapshot(snapshot);
426
+ tree = unwrapIgnored(tree);
427
+ tree = nameIconButtons(tree);
428
+ tree = dropEmpty(tree, { keepNamed });
429
+ return renderTree(tree);
427
430
  };
428
431
 
429
- export const collectInteractiveNodes = (snapshot: string | null): Array<Record<string, unknown>> => {
430
- const nodes = parseAriaSnapshot(snapshot);
431
- const result: Array<Record<string, unknown>> = [];
432
- const visit = (node: AriaNode) => {
433
- if (IGNORED_CONTAINER_ROLES.has(node.role)) {
434
- node.children.forEach(visit);
435
- return;
436
- }
437
- const entry = buildInteractiveEntry(node);
438
- if (entry) {
439
- result.push(entry);
440
- }
441
- node.children.forEach(visit);
432
+ export const diffAriaSnapshots = (previous: string | null, current: string | null): string | null => {
433
+ const flat = (snap: string | null): FlatEntry[] => {
434
+ let tree = parseSnapshot(snap);
435
+ tree = unwrapIgnored(tree);
436
+ tree = nameIconButtons(tree);
437
+ tree = dropEmpty(tree);
438
+ return flatten(tree);
442
439
  };
443
- nodes.forEach(visit);
444
- return result;
440
+ const prev = flat(previous);
441
+ const curr = flat(current);
442
+ const prevTotals = countBy(prev.map((e) => e.summary));
443
+ const currTotals = countBy(curr.map((e) => e.summary));
444
+ const byCount = diffByCount(prevTotals, currTotals);
445
+ const renames = detectRenames(prev, curr, prevTotals, currTotals);
446
+ return formatDiff([...byCount.added, ...renames.added], [...byCount.removed, ...renames.removed]);
445
447
  };
446
448
 
447
- const renderNodeLine = (role: string, name: string | undefined, attrs: Record<string, string | boolean | null>, value: string | boolean | null | undefined): string => {
448
- let line = role;
449
- const displayName = name?.trim();
450
- if (displayName) line += ` "${displayName}"`;
451
- const attrStr = Object.entries(attrs)
452
- .filter(([, v]) => v !== undefined && v !== null && v !== '')
453
- .map(([k, v]) => (v === true ? k : `${k}=${v}`))
454
- .join(' ');
455
- if (attrStr) line += ` [${attrStr}]`;
456
- if (value !== undefined && value !== null) {
457
- const text = `${value}`.trim();
458
- if (text) line += `: ${text}`;
459
- }
460
- return line;
461
- };
449
+ export const detectFocusArea = (snapshot: string | null): FocusAreaResult => {
450
+ let tree = parseSnapshot(snapshot);
451
+ tree = unwrapIgnored(tree);
452
+ tree = dropEmpty(tree, { keepNamed: true });
462
453
 
463
- const formatSummary = (node: Record<string, unknown>): string => {
464
- const role = typeof node.role === 'string' ? node.role : '';
465
- if (!role) return '';
466
- const name = typeof node.name === 'string' ? node.name : undefined;
467
- const attrs: Record<string, string | boolean | null> = {};
468
- for (const key of Object.keys(node)
469
- .filter((k) => k !== 'role' && k !== 'name' && k !== 'value')
470
- .sort()) {
471
- attrs[key] = node[key] as string | boolean | null;
472
- }
473
- const value = Object.prototype.hasOwnProperty.call(node, 'value') ? (node.value as string | boolean | null) : undefined;
474
- return renderNodeLine(role, name, attrs, value);
475
- };
454
+ const direct = findDialogOrModal(tree);
455
+ if (direct) return direct;
476
456
 
477
- type FlatInteractiveNode = {
478
- path: string;
479
- summary: string;
480
- };
457
+ const fallback = findOverlayByCloseButton(tree);
458
+ if (fallback?.name) return fallback;
481
459
 
482
- const flattenInteractiveNodes = (snapshot: string | null): FlatInteractiveNode[] => {
483
- const nodes = parseAriaSnapshot(snapshot);
484
- const result: FlatInteractiveNode[] = [];
485
- const visit = (node: AriaNode, path: string) => {
486
- if (!IGNORED_CONTAINER_ROLES.has(node.role)) {
487
- const entry = buildInteractiveEntry(node);
488
- if (entry) {
489
- const summary = formatSummary(entry);
490
- if (summary !== '') {
491
- result.push({ path, summary });
492
- }
493
- }
494
- }
495
- node.children.forEach((child, index) => {
496
- const childPath = path === '' ? `${index}` : `${path}.${index}`;
497
- visit(child, childPath);
498
- });
499
- };
500
- nodes.forEach((node, index) => {
501
- visit(node, `${index}`);
502
- });
503
- return result;
460
+ return { detected: false, type: null, name: null };
504
461
  };
505
462
 
506
- const buildCountMap = (items: string[]): Map<string, number> => {
507
- const map = new Map<string, number>();
508
- for (const item of items) {
509
- if (item === '') {
510
- continue;
511
- }
512
- map.set(item, (map.get(item) ?? 0) + 1);
513
- }
514
- return map;
463
+ export const collectInteractiveNodes = (snapshot: string | null): Array<Record<string, unknown>> => {
464
+ let tree = parseSnapshot(snapshot);
465
+ tree = unwrapIgnored(tree);
466
+ tree = nameIconButtons(tree);
467
+ tree = dropEmpty(tree);
468
+ return flatten(tree).map((e) => e.entry);
515
469
  };
516
470
 
517
- const formatDiffItem = (item: string, count: number): string => {
518
- if (count > 1) {
519
- return `${item} (x${count})`;
520
- }
521
- return item;
522
- };
471
+ // ─────────────────────────────────────────────────────────────────
472
+ // Standalone helpers (regex on raw strings — not part of the pipeline)
473
+ // ─────────────────────────────────────────────────────────────────
523
474
 
524
475
  export interface FocusedElementInfo {
525
476
  role: string;
@@ -546,12 +497,10 @@ export function extractFocusedElement(ariaSnapshot: string | null): FocusedEleme
546
497
  }
547
498
  }
548
499
 
549
- return {
550
- role,
551
- name,
552
- ...(value && { value: value.trim() }),
553
- ...(attributes.length > 0 && { attributes }),
554
- };
500
+ const result: FocusedElementInfo = { role, name };
501
+ if (value) result.value = value.trim();
502
+ if (attributes.length > 0) result.attributes = attributes;
503
+ return result;
555
504
  }
556
505
 
557
506
  export function parseAriaLocator(ariaStr: string): { role: string; text: string } | null {
@@ -564,141 +513,22 @@ export function parseAriaLocator(ariaStr: string): { role: string; text: string
564
513
  return { role: match[1], text: match[2] };
565
514
  }
566
515
 
567
- export const diffAriaSnapshots = (previous: string | null, current: string | null): string | null => {
568
- const previousEntries = flattenInteractiveNodes(previous);
569
- const currentEntries = flattenInteractiveNodes(current);
570
- const previousTotals = buildCountMap(previousEntries.map((entry) => entry.summary));
571
- const currentTotals = buildCountMap(currentEntries.map((entry) => entry.summary));
572
- const added: string[] = [];
573
- const removed: string[] = [];
574
- const allSummaries = new Set<string>([...previousTotals.keys(), ...currentTotals.keys()]);
575
- for (const summary of allSummaries) {
576
- const before = previousTotals.get(summary) ?? 0;
577
- const after = currentTotals.get(summary) ?? 0;
578
- if (after > before) {
579
- for (let i = 0; i < after - before; i += 1) {
580
- added.push(summary);
581
- }
582
- }
583
- if (before > after) {
584
- for (let i = 0; i < before - after; i += 1) {
585
- removed.push(summary);
586
- }
587
- }
588
- }
589
- const previousByPath = new Map<string, string>();
590
- for (const entry of previousEntries) {
591
- previousByPath.set(entry.path, entry.summary);
592
- }
593
- const currentByPath = new Map<string, string>();
594
- for (const entry of currentEntries) {
595
- currentByPath.set(entry.path, entry.summary);
596
- }
597
- for (const [path, beforeSummary] of previousByPath.entries()) {
598
- const afterSummary = currentByPath.get(path);
599
- if (!afterSummary || afterSummary === beforeSummary) {
600
- continue;
601
- }
602
- const totalsEqualAfter = (currentTotals.get(afterSummary) ?? 0) === (previousTotals.get(afterSummary) ?? 0);
603
- const totalsEqualBefore = (currentTotals.get(beforeSummary) ?? 0) === (previousTotals.get(beforeSummary) ?? 0);
604
- if (!totalsEqualAfter || !totalsEqualBefore) {
605
- continue;
606
- }
607
- const beforeExistsElsewhere = currentEntries.some((entry) => entry.path !== path && entry.summary === beforeSummary);
608
- const afterExistsElsewhere = previousEntries.some((entry) => entry.path !== path && entry.summary === afterSummary);
609
- if (beforeExistsElsewhere && afterExistsElsewhere) {
610
- continue;
611
- }
612
- added.push(afterSummary);
613
- removed.push(beforeSummary);
614
- }
615
- if (added.length === 0 && removed.length === 0) {
616
- return null;
617
- }
618
- const lines: string[] = ['ariaDiff:'];
619
- const addedSummary = buildCountMap(added);
620
- if (addedSummary.size === 0) {
621
- lines.push(' added: []');
622
- } else {
623
- lines.push(' added:');
624
- Array.from(addedSummary.entries())
625
- .sort(([a], [b]) => a.localeCompare(b))
626
- .forEach(([item, count]) => {
627
- lines.push(` - ${formatDiffItem(item, count)}`);
628
- });
629
- }
630
- if (removed.length === 0) {
631
- lines.push(' removed: []');
632
- } else {
633
- lines.push(` removed: ${removed.length} interactive elements`);
634
- }
635
- return lines.join('\n');
636
- };
637
-
638
- const resolveDisplayName = (node: AriaNode): string | undefined => {
639
- if (node.name) return node.name;
640
- const isButtonOrLink = node.role === 'button' || node.role === 'link';
641
- if (!isButtonOrLink) return undefined;
642
- const childContent = serializeChildContent(node);
643
- if (childContent) return `{${childContent}}`;
644
- return undefined;
645
- };
646
-
647
- const SIBLING_COLLAPSE_THRESHOLD = 50;
648
- const SIBLING_COLLAPSE_KEEP_EACH_SIDE = 5;
516
+ // ─────────────────────────────────────────────────────────────────
517
+ // Types
518
+ // ─────────────────────────────────────────────────────────────────
649
519
 
650
- const serializeAriaNodes = (nodes: AriaNode[], depth = 0): string => {
651
- const lines: string[] = [];
652
- const collapsed = collapseSimilarSiblingRuns(nodes, depth);
653
- for (const entry of collapsed) {
654
- if (entry.placeholder) {
655
- lines.push(entry.placeholder);
656
- continue;
657
- }
658
- const node = entry.node!;
659
- const indent = ' '.repeat(depth);
660
- let line = `${indent}- ${renderNodeLine(node.role, resolveDisplayName(node), node.attributes, node.value)}`;
661
- if (node.children.length > 0) {
662
- line += ':';
663
- }
664
- lines.push(line);
665
- if (node.children.length > 0) {
666
- lines.push(serializeAriaNodes(node.children, depth + 1));
667
- }
668
- }
669
- return lines.join('\n');
520
+ type AriaNode = {
521
+ role: string;
522
+ name?: string;
523
+ value?: string | boolean | null;
524
+ attributes: Record<string, string | boolean | null>;
525
+ children: AriaNode[];
670
526
  };
671
527
 
672
- type SerializeEntry = { node?: AriaNode; placeholder?: string };
673
-
674
- const collapseSimilarSiblingRuns = (nodes: AriaNode[], depth: number): SerializeEntry[] => {
675
- const result: SerializeEntry[] = [];
676
- let i = 0;
677
- while (i < nodes.length) {
678
- const role = nodes[i].role;
679
- let j = i;
680
- while (j < nodes.length && nodes[j].role === role) j++;
681
- const runLength = j - i;
682
- if (runLength > SIBLING_COLLAPSE_THRESHOLD) {
683
- for (let k = i; k < i + SIBLING_COLLAPSE_KEEP_EACH_SIDE; k++) {
684
- result.push({ node: nodes[k] });
685
- }
686
- const omitted = runLength - SIBLING_COLLAPSE_KEEP_EACH_SIDE * 2;
687
- const indent = ' '.repeat(depth);
688
- result.push({ placeholder: `${indent}- ...${omitted} similar "${role}" items omitted...` });
689
- for (let k = j - SIBLING_COLLAPSE_KEEP_EACH_SIDE; k < j; k++) {
690
- result.push({ node: nodes[k] });
691
- }
692
- } else {
693
- for (let k = i; k < j; k++) result.push({ node: nodes[k] });
694
- }
695
- i = j;
696
- }
697
- return result;
698
- };
528
+ type RenderEntry = { node: AriaNode } | { placeholder: string };
699
529
 
700
- export const compactAriaSnapshot = (snapshot: string | null, keepNamed = false): string => {
701
- if (!snapshot) return '';
702
- const nodes = parseAriaSnapshot(snapshot, keepNamed);
703
- return serializeAriaNodes(nodes);
530
+ type FlatEntry = {
531
+ path: string;
532
+ summary: string;
533
+ entry: Record<string, unknown>;
704
534
  };