@comty.io/core 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,551 @@
1
+ // Shared with Hephaistos' commentaires/resilient-anchor.ts. Keep both copies identical.
2
+ // No framework internals, authored IDs or editable values are required.
3
+ interface Fingerprint {
4
+ tag: string;
5
+ attributes: Record<string, string>;
6
+ classes: string[];
7
+ text: string;
8
+ heading: string;
9
+ shape: string[];
10
+ }
11
+
12
+ export interface ResilientAnchor {
13
+ version: 1;
14
+ target: Fingerprint;
15
+ ancestors: Fingerprint[];
16
+ layer?: Fingerprint | null;
17
+ scope: number;
18
+ twins: number;
19
+ group: number;
20
+ }
21
+
22
+ export type AnchorResolution = {
23
+ element: Element | null;
24
+ status: "matched" | "hidden" | "missing" | "uncertain";
25
+ };
26
+
27
+ const generatedId =
28
+ /^(?:(?:radix|base-ui)-)?(?:_r_[\w-]+_|:r[\w-]+:)$|^mat-|^cdk-|^ng-/i;
29
+ const anchorAttributes = [
30
+ "id",
31
+ "data-testid",
32
+ "data-test",
33
+ "data-cy",
34
+ "role",
35
+ "name",
36
+ "type",
37
+ "href",
38
+ "src",
39
+ "aria-label",
40
+ "alt",
41
+ "title",
42
+ "placeholder",
43
+ ];
44
+
45
+ /** Read saved formats as evidence for the same engine; never reconstruct missing history from the DOM. */
46
+ export function readAnchor(value: unknown): ResilientAnchor | null {
47
+ const record = (item: unknown): Record<string, unknown> =>
48
+ item && typeof item === "object" && !Array.isArray(item)
49
+ ? (item as Record<string, unknown>)
50
+ : {};
51
+ const raw = record(value);
52
+ const saved = raw.resilient ?? raw.anchor ?? value;
53
+ if (isResilientAnchor(saved)) return saved;
54
+ // A malformed current fingerprint must not silently become a selector-only anchor.
55
+ if (
56
+ raw.resilient !== undefined ||
57
+ raw.anchor !== undefined ||
58
+ "shape" in record(raw.target)
59
+ )
60
+ return null;
61
+ const context = record(raw.context);
62
+ const source = context.element ? context : raw;
63
+ const target = record(
64
+ source.element ??
65
+ source.target ??
66
+ (source.tag ? source : { text: source.text }),
67
+ );
68
+ const string = (item: unknown) =>
69
+ typeof item === "string" ? item.trim().slice(0, 160) : "";
70
+ const convert = (item: Record<string, unknown>): Fingerprint => {
71
+ const stored = record(item.attributes);
72
+ const attributes: Record<string, string> = {};
73
+ for (const name of anchorAttributes) {
74
+ const alias =
75
+ name === "aria-label"
76
+ ? "ariaLabel"
77
+ : name === "data-testid"
78
+ ? "testId"
79
+ : name;
80
+ const text = string(stored[name] ?? item[alias]);
81
+ if (text && !(name === "id" && generatedId.test(text)))
82
+ attributes[name] = text;
83
+ }
84
+ const tag = string(item.tag).toLowerCase();
85
+ return {
86
+ tag: /^[a-z][a-z0-9-]{0,79}$/.test(tag) ? tag : "*",
87
+ attributes,
88
+ classes: (Array.isArray(item.classes)
89
+ ? item.classes
90
+ : string(stored.class).split(/\s+/)
91
+ )
92
+ .filter(
93
+ (name): name is string =>
94
+ typeof name === "string" &&
95
+ !!name &&
96
+ name.length <= 80 &&
97
+ !/^(ng-|comty-|hph-)/.test(name),
98
+ )
99
+ .slice(0, 8),
100
+ text: /^(input|textarea|select)$/.test(tag)
101
+ ? ""
102
+ : string(item.text ?? item.ownText),
103
+ heading: "",
104
+ shape: [],
105
+ };
106
+ };
107
+ const ancestors = (Array.isArray(source.ancestors) ? source.ancestors : [])
108
+ .slice(0, 8)
109
+ .map((item) => convert(record(item)))
110
+ .filter((item) => item.tag !== "*" && !/^(body|html)$/.test(item.tag));
111
+ const inferredLayer = ancestors.find(
112
+ (item) =>
113
+ item.tag === "dialog" ||
114
+ /^(dialog|alertdialog)$/.test(item.attributes.role ?? ""),
115
+ );
116
+ return {
117
+ version: 1,
118
+ target: convert(target),
119
+ ancestors,
120
+ layer: source.layer
121
+ ? convert(record(source.layer))
122
+ : source.layer === null
123
+ ? null
124
+ : inferredLayer,
125
+ scope: -1,
126
+ group: -1,
127
+ twins:
128
+ typeof source.candidateCount === "number" &&
129
+ Number.isInteger(source.candidateCount) &&
130
+ source.candidateCount > 1
131
+ ? source.candidateCount
132
+ : 1,
133
+ };
134
+ }
135
+
136
+ export function createResilientAnchors(
137
+ excluded: (element: Element) => boolean,
138
+ presentationLayer = (element: Element): Element | null =>
139
+ element.closest('dialog, [role="dialog"], [role="alertdialog"], [popover]'),
140
+ ) {
141
+ const editable =
142
+ 'input, textarea, select, [contenteditable]:not([contenteditable="false"])';
143
+ const attributes = anchorAttributes;
144
+ const stable = new Set([
145
+ "id",
146
+ "data-testid",
147
+ "data-test",
148
+ "data-cy",
149
+ "name",
150
+ "href",
151
+ ]);
152
+ let fingerprints = new WeakMap<Element, Fingerprint>();
153
+
154
+ function children(element: Element): Element[] {
155
+ return [...element.children].filter((child) => !excluded(child));
156
+ }
157
+
158
+ function semantic(element: Element): string {
159
+ const role = element.getAttribute("role");
160
+ if (role) return role.slice(0, 80);
161
+ const tag = element.localName;
162
+ if (/^h[1-6]$/.test(tag)) return "heading";
163
+ if (tag === "svg" || tag === "img" || tag.endsWith("-icon")) return "image";
164
+ return /^(button|a|input|textarea|select|p|table|tr|td|th|ul|ol|li|dl|dt|dd|canvas|video)$/.test(
165
+ tag,
166
+ )
167
+ ? tag
168
+ : "";
169
+ }
170
+
171
+ function shape(element: Element): string[] {
172
+ const counts = new Map<string, number>();
173
+ const queue = [element];
174
+ let visited = 0;
175
+ while (queue.length && visited++ < 120) {
176
+ const node = queue.shift();
177
+ if (!node || excluded(node)) continue;
178
+ const token = semantic(node);
179
+ if (token) counts.set(token, Math.min(3, (counts.get(token) ?? 0) + 1));
180
+ if (token === "image" || node.matches(editable)) continue;
181
+ const descendants = children(node);
182
+ // Repeated cards are data even when a prototype uses only generic divs.
183
+ const patterns = new Map<string, number>();
184
+ for (const child of descendants.slice(0, 24)) {
185
+ const parts = children(child).map(semantic).filter(Boolean);
186
+ if (parts.length < 2) continue;
187
+ const key = parts.join("/");
188
+ patterns.set(key, (patterns.get(key) ?? 0) + 1);
189
+ }
190
+ if ([...patterns.values()].some((count) => count >= 2))
191
+ counts.set("records", 3);
192
+ queue.push(...descendants.slice(0, 40));
193
+ }
194
+ return [...counts]
195
+ .flatMap(([token, count]) => Array<string>(count).fill(token))
196
+ .sort();
197
+ }
198
+
199
+ function text(element: Element): string {
200
+ if (element.closest(editable)) return "";
201
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
202
+ let result = "",
203
+ count = 0;
204
+ for (
205
+ let node = walker.nextNode();
206
+ node && count++ < 100 && result.length < 160;
207
+ node = walker.nextNode()
208
+ ) {
209
+ const parent = node.parentElement;
210
+ if (
211
+ parent &&
212
+ !excluded(parent) &&
213
+ !parent.closest(`${editable}, script, style`)
214
+ )
215
+ result += ` ${node.textContent ?? ""}`;
216
+ }
217
+ return result.replace(/\s+/g, " ").trim().slice(0, 160);
218
+ }
219
+
220
+ function fingerprint(element: Element): Fingerprint {
221
+ const cached = fingerprints.get(element);
222
+ if (cached) return cached;
223
+ const attrs: Record<string, string> = {};
224
+ for (const name of attributes) {
225
+ const value = element.getAttribute(name)?.trim();
226
+ if (value && !(name === "id" && generatedId.test(value)))
227
+ attrs[name] = value.slice(0, 160);
228
+ }
229
+ const result = {
230
+ tag: element.localName,
231
+ attributes: attrs,
232
+ classes: [...element.classList]
233
+ .filter((name) => name.length <= 80 && !/^(ng-|comty-|hph-)/.test(name))
234
+ .slice(0, 8),
235
+ text: text(element),
236
+ heading: (() => {
237
+ const heading = element.querySelector("h1, h2, h3, h4, h5, h6, legend");
238
+ return heading && !excluded(heading) ? text(heading) : "";
239
+ })(),
240
+ shape: shape(element),
241
+ };
242
+ fingerprints.set(element, result);
243
+ return result;
244
+ }
245
+
246
+ function parents(element: Element): Element[] {
247
+ const result: Element[] = [];
248
+ for (
249
+ let parent = element.parentElement;
250
+ parent && parent !== document.body && result.length < 10;
251
+ parent = parent.parentElement
252
+ ) {
253
+ if (!excluded(parent)) result.push(parent);
254
+ }
255
+ return result;
256
+ }
257
+
258
+ function overlap(left: string[], right: string[]): number {
259
+ const remaining = [...right];
260
+ let count = 0;
261
+ for (const item of left) {
262
+ const index = remaining.indexOf(item);
263
+ if (index >= 0) {
264
+ count++;
265
+ remaining.splice(index, 1);
266
+ }
267
+ }
268
+ return count;
269
+ }
270
+
271
+ function score(saved: Fingerprint, current: Fingerprint): number {
272
+ if (saved.tag !== "*" && saved.tag !== current.tag) return -100;
273
+ let result = 20;
274
+ for (const [name, value] of Object.entries(saved.attributes)) {
275
+ if (current.attributes[name] === value)
276
+ result += stable.has(name) ? 45 : 12;
277
+ else if (stable.has(name) && current.attributes[name]) result -= 35;
278
+ }
279
+ result += Math.min(24, overlap(saved.classes, current.classes) * 12);
280
+ if (saved.text && saved.text === current.text) result += 36;
281
+ else if (saved.text && current.text) {
282
+ const left = [...new Set(saved.text.toLowerCase().split(/\s+/))];
283
+ const right = [...new Set(current.text.toLowerCase().split(/\s+/))];
284
+ if (overlap(left, right) / Math.max(left.length, right.length) >= 0.7)
285
+ result += 18;
286
+ }
287
+ if (saved.heading && saved.heading === current.heading) result += 24;
288
+ const total = Math.max(saved.shape.length, current.shape.length);
289
+ result += saved.shape.length
290
+ ? (12 * overlap(saved.shape, current.shape)) / total
291
+ : 12;
292
+ return result;
293
+ }
294
+
295
+ function differentState(saved: Fingerprint, current: Fingerprint): boolean {
296
+ // A changed label, wrapper, style or list length is never enough to hide a pin.
297
+ if (saved.shape.length < 3 || current.shape.length < 3) return false;
298
+ const hasRecords = (tokens: string[]) =>
299
+ tokens.some((token) => /^(records|tr|li|row|listitem)$/.test(token));
300
+ // Illustration replaced by repeated records is a state change even with the same headings/actions.
301
+ if (
302
+ hasRecords(saved.shape) !== hasRecords(current.shape) &&
303
+ saved.shape.includes("image") !== current.shape.includes("image")
304
+ )
305
+ return true;
306
+ const common = overlap(saved.shape, current.shape);
307
+ return (
308
+ common / saved.shape.length < 0.45 && common / current.shape.length < 0.45
309
+ );
310
+ }
311
+
312
+ function twins(
313
+ element: Element,
314
+ saved: Fingerprint,
315
+ includeText = false,
316
+ ): number {
317
+ return (
318
+ element.parentElement ? children(element.parentElement) : [element]
319
+ ).filter((child) => {
320
+ const candidate = fingerprint(child);
321
+ return (
322
+ candidate.tag === saved.tag &&
323
+ (!includeText || candidate.text === saved.text) &&
324
+ attributes.every(
325
+ (name) => candidate.attributes[name] === saved.attributes[name],
326
+ )
327
+ );
328
+ }).length;
329
+ }
330
+
331
+ function capture(element: Element): ResilientAnchor {
332
+ fingerprints = new WeakMap();
333
+ const target = fingerprint(element);
334
+ const ancestors = parents(element).slice(0, 8).map(fingerprint);
335
+ const layer = presentationLayer(element);
336
+ // Bind to the nearest small composition, not to the state of the entire page.
337
+ const scope = [target, ...ancestors.slice(0, 3)].findIndex(
338
+ (item) =>
339
+ !/^(main|body|html|nav)$/.test(item.tag) &&
340
+ item.shape.length >= 3 &&
341
+ item.shape.length <= 18,
342
+ );
343
+ const group = [element, ...parents(element).slice(0, 8)].findIndex(
344
+ (node) => {
345
+ const saved = fingerprint(node);
346
+ return (
347
+ saved.shape.length >= 2 &&
348
+ node.parentElement &&
349
+ children(node.parentElement).filter((peer) => {
350
+ const other = fingerprint(peer);
351
+ return (
352
+ other.tag === saved.tag &&
353
+ overlap(saved.shape, other.shape) /
354
+ Math.max(saved.shape.length, other.shape.length) >
355
+ 0.8
356
+ );
357
+ }).length > 1
358
+ );
359
+ },
360
+ );
361
+ return {
362
+ version: 1,
363
+ target,
364
+ ancestors,
365
+ layer: layer ? fingerprint(layer) : null,
366
+ scope,
367
+ group,
368
+ twins: twins(element, target, true),
369
+ };
370
+ }
371
+
372
+ function resolve(
373
+ anchor: ResilientAnchor,
374
+ previous: Element | null = null,
375
+ exact: Element | null = null,
376
+ ): AnchorResolution {
377
+ fingerprints = new WeakMap();
378
+ const candidates = (
379
+ anchor.target.tag === "*"
380
+ ? exact
381
+ ? [exact]
382
+ : []
383
+ : [...document.getElementsByTagName(anchor.target.tag)]
384
+ ).filter((element) => !excluded(element));
385
+ const ranked: { element: Element; score: number; changed: boolean }[] = [];
386
+ for (const element of candidates) {
387
+ const layer = presentationLayer(element);
388
+ if (anchor.layer !== undefined && !!layer !== !!anchor.layer) continue;
389
+ if (layer && anchor.layer && score(anchor.layer, fingerprint(layer)) < 45)
390
+ continue;
391
+ const current = fingerprint(element);
392
+ // Never attach a removed, explicitly identified control to another identified one.
393
+ if (
394
+ Object.keys(anchor.target.attributes).some(
395
+ (name) =>
396
+ stable.has(name) &&
397
+ current.attributes[name] &&
398
+ current.attributes[name] !== anchor.target.attributes[name],
399
+ )
400
+ )
401
+ continue;
402
+ if (
403
+ anchor.target.attributes.id &&
404
+ !current.attributes.id &&
405
+ element !== previous &&
406
+ score(anchor.target, current) < 55
407
+ )
408
+ continue;
409
+ let points = score(anchor.target, current);
410
+ const chain = parents(element);
411
+ let next = 0;
412
+ let scope = anchor.scope === 0 ? current : null;
413
+ let group = anchor.group === 0 ? element : null;
414
+ for (const [index, saved] of anchor.ancestors.entries()) {
415
+ let best = -1,
416
+ bestScore = 0;
417
+ // Skip inserted layout wrappers without requiring an exact CSS path.
418
+ for (let i = next; i < Math.min(chain.length, next + 3); i++) {
419
+ const value = score(saved, fingerprint(chain[i]));
420
+ if (value > bestScore) {
421
+ best = i;
422
+ bestScore = value;
423
+ }
424
+ }
425
+ if (best < 0) continue;
426
+ next = best + 1;
427
+ points += Math.min(120, bestScore) * (index < 3 ? 0.6 : 0.4);
428
+ if (anchor.scope === index + 1) scope = fingerprint(chain[best]);
429
+ if (anchor.group === index + 1) group = chain[best];
430
+ }
431
+ // An absent local composition is not permission to borrow a target elsewhere.
432
+ if (anchor.scope >= 0 && !scope) continue;
433
+ const originalGroup = [anchor.target, ...anchor.ancestors][anchor.group];
434
+ // Shared styling cannot identify one record. Distinctive classes, attributes
435
+ // and textual similarity all contribute to the same confidence score.
436
+ if (originalGroup && element !== previous) {
437
+ if (!group) continue;
438
+ const peers = group.parentElement
439
+ ? children(group.parentElement).filter(
440
+ (peer) => peer !== group && peer.localName === group.localName,
441
+ )
442
+ : [];
443
+ const distinctClasses = originalGroup.classes.filter(
444
+ (name) =>
445
+ peers.length > 0 &&
446
+ peers.every((peer) => !peer.classList.contains(name)),
447
+ );
448
+ const distinctAttributes = Object.fromEntries(
449
+ Object.entries(originalGroup.attributes).filter(
450
+ ([name, value]) =>
451
+ /^(id|data-testid|data-test|data-cy)$/.test(name) ||
452
+ (peers.length > 0 &&
453
+ peers.every(
454
+ (peer) => fingerprint(peer).attributes[name] !== value,
455
+ )),
456
+ ),
457
+ );
458
+ const witness = {
459
+ ...originalGroup,
460
+ classes: distinctClasses,
461
+ attributes: distinctAttributes,
462
+ };
463
+ const currentGroup = fingerprint(group);
464
+ const points =
465
+ score(witness, currentGroup) +
466
+ (overlap(distinctClasses, currentGroup.classes) ? 12 : 0);
467
+ if (points < 55) continue;
468
+ }
469
+ const originalScope = [anchor.target, ...anchor.ancestors][anchor.scope];
470
+ const changed =
471
+ !!(scope && originalScope && differentState(originalScope, scope)) ||
472
+ differentState(anchor.target, current);
473
+ if (anchor.twins > 1 && twins(element, anchor.target) < anchor.twins)
474
+ continue;
475
+ // Live identity survives a rewrite, but never bypasses the state comparison.
476
+ if (element === previous && previous.isConnected) points += 24;
477
+ if (candidates.length === 1) points += 24;
478
+ if (element === exact) points += anchor.target.tag === "*" ? 55 : 8;
479
+ ranked.push({ element, score: points, changed });
480
+ }
481
+ ranked.sort((a, b) => b.score - a.score);
482
+ const best = ranked[0];
483
+ if (!best)
484
+ return {
485
+ element: null,
486
+ status: anchor.layer
487
+ ? "hidden"
488
+ : anchor.twins > 1 && candidates.length
489
+ ? "uncertain"
490
+ : "missing",
491
+ };
492
+ // Rank before checking state: don't jump to a weaker lookalike when the original changes.
493
+ if (best.score < 55 || (ranked[1] && best.score - ranked[1].score < 14))
494
+ return { element: null, status: "uncertain" };
495
+ return best.changed
496
+ ? { element: null, status: "hidden" }
497
+ : { element: best.element, status: "matched" };
498
+ }
499
+
500
+ return { capture, resolve };
501
+ }
502
+
503
+ export function isResilientAnchor(value: unknown): value is ResilientAnchor {
504
+ if (!value || typeof value !== "object") return false;
505
+ const anchor = value as Partial<ResilientAnchor>;
506
+ const strings = (items: unknown, max: number): items is string[] =>
507
+ Array.isArray(items) &&
508
+ items.length <= max &&
509
+ items.every((item) => typeof item === "string" && item.length <= 160);
510
+ const fingerprint = (item: unknown): item is Fingerprint => {
511
+ if (!item || typeof item !== "object") return false;
512
+ const part = item as Partial<Fingerprint>;
513
+ return (
514
+ typeof part.tag === "string" &&
515
+ /^[a-z][a-z0-9-]{0,79}$/.test(part.tag) &&
516
+ typeof part.text === "string" &&
517
+ part.text.length <= 160 &&
518
+ typeof part.heading === "string" &&
519
+ part.heading.length <= 160 &&
520
+ strings(part.classes, 8) &&
521
+ strings(part.shape, 120) &&
522
+ !!part.attributes &&
523
+ typeof part.attributes === "object" &&
524
+ !Array.isArray(part.attributes) &&
525
+ Object.entries(part.attributes).length <= 13 &&
526
+ Object.entries(part.attributes).every(
527
+ ([key, val]) =>
528
+ key.length < 40 && typeof val === "string" && val.length <= 160,
529
+ )
530
+ );
531
+ };
532
+ return (
533
+ anchor.version === 1 &&
534
+ fingerprint(anchor.target) &&
535
+ Array.isArray(anchor.ancestors) &&
536
+ anchor.ancestors.length <= 8 &&
537
+ anchor.ancestors.every(fingerprint) &&
538
+ (anchor.layer === null || fingerprint(anchor.layer)) &&
539
+ Number.isInteger(anchor.scope) &&
540
+ typeof anchor.scope === "number" &&
541
+ anchor.scope >= -1 &&
542
+ anchor.scope <= anchor.ancestors.length &&
543
+ Number.isInteger(anchor.group) &&
544
+ typeof anchor.group === "number" &&
545
+ anchor.group >= -1 &&
546
+ anchor.group <= anchor.ancestors.length &&
547
+ typeof anchor.twins === "number" &&
548
+ Number.isInteger(anchor.twins) &&
549
+ anchor.twins >= 1
550
+ );
551
+ }