@michaelmagan/dnumb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +180 -0
- package/LICENSE +21 -0
- package/README.md +297 -0
- package/dist/analysis/cluster.cjs +114 -0
- package/dist/analysis/cluster.d.cts +68 -0
- package/dist/analysis/cluster.d.ts +68 -0
- package/dist/analysis/cluster.js +109 -0
- package/dist/analysis/distances.cjs +124 -0
- package/dist/analysis/distances.d.cts +58 -0
- package/dist/analysis/distances.d.ts +58 -0
- package/dist/analysis/distances.js +119 -0
- package/dist/analysis/geometry.cjs +60 -0
- package/dist/analysis/geometry.d.cts +34 -0
- package/dist/analysis/geometry.d.ts +34 -0
- package/dist/analysis/geometry.js +53 -0
- package/dist/analysis/layout-tree.cjs +245 -0
- package/dist/analysis/layout-tree.d.cts +45 -0
- package/dist/analysis/layout-tree.d.ts +45 -0
- package/dist/analysis/layout-tree.js +242 -0
- package/dist/analysis/region-query.cjs +71 -0
- package/dist/analysis/region-query.d.cts +25 -0
- package/dist/analysis/region-query.d.ts +25 -0
- package/dist/analysis/region-query.js +67 -0
- package/dist/analysis/types.cjs +7 -0
- package/dist/analysis/types.d.cts +143 -0
- package/dist/analysis/types.d.ts +143 -0
- package/dist/analysis/types.js +6 -0
- package/dist/bundle.cjs +182 -0
- package/dist/bundle.d.cts +91 -0
- package/dist/bundle.d.ts +91 -0
- package/dist/bundle.js +175 -0
- package/dist/cli.cjs +755 -0
- package/dist/cli.d.cts +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +719 -0
- package/dist/diff/changes.cjs +656 -0
- package/dist/diff/changes.d.cts +2 -0
- package/dist/diff/changes.d.ts +2 -0
- package/dist/diff/changes.js +653 -0
- package/dist/diff/identity.cjs +167 -0
- package/dist/diff/identity.d.cts +62 -0
- package/dist/diff/identity.d.ts +62 -0
- package/dist/diff/identity.js +159 -0
- package/dist/diff/index.cjs +84 -0
- package/dist/diff/index.d.cts +76 -0
- package/dist/diff/index.d.ts +76 -0
- package/dist/diff/index.js +68 -0
- package/dist/diff/match.cjs +156 -0
- package/dist/diff/match.d.cts +17 -0
- package/dist/diff/match.d.ts +17 -0
- package/dist/diff/match.js +152 -0
- package/dist/diff/render.cjs +540 -0
- package/dist/diff/render.d.cts +2 -0
- package/dist/diff/render.d.ts +2 -0
- package/dist/diff/render.js +537 -0
- package/dist/diff/types.cjs +2 -0
- package/dist/diff/types.d.cts +211 -0
- package/dist/diff/types.d.ts +211 -0
- package/dist/diff/types.js +1 -0
- package/dist/digest/index.cjs +8 -0
- package/dist/digest/index.d.cts +4 -0
- package/dist/digest/index.d.ts +4 -0
- package/dist/digest/index.js +2 -0
- package/dist/digest/parse.cjs +163 -0
- package/dist/digest/parse.d.cts +80 -0
- package/dist/digest/parse.d.ts +80 -0
- package/dist/digest/parse.js +160 -0
- package/dist/digest/render.cjs +1575 -0
- package/dist/digest/render.d.cts +41 -0
- package/dist/digest/render.d.ts +41 -0
- package/dist/digest/render.js +1571 -0
- package/dist/digest/types.cjs +10 -0
- package/dist/digest/types.d.cts +86 -0
- package/dist/digest/types.d.ts +86 -0
- package/dist/digest/types.js +9 -0
- package/dist/index.cjs +61 -0
- package/dist/index.d.cts +27 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +19 -0
- package/dist/page.cjs +35 -0
- package/dist/page.d.cts +40 -0
- package/dist/page.d.ts +40 -0
- package/dist/page.js +31 -0
- package/dist/playwright.cjs +138 -0
- package/dist/playwright.d.cts +69 -0
- package/dist/playwright.d.ts +69 -0
- package/dist/playwright.js +131 -0
- package/dist/query.cjs +1152 -0
- package/dist/query.d.cts +47 -0
- package/dist/query.d.ts +47 -0
- package/dist/query.js +1146 -0
- package/dist/snapshot/capture.cjs +80 -0
- package/dist/snapshot/capture.d.cts +34 -0
- package/dist/snapshot/capture.d.ts +34 -0
- package/dist/snapshot/capture.js +77 -0
- package/dist/snapshot/cdp.cjs +450 -0
- package/dist/snapshot/cdp.d.cts +36 -0
- package/dist/snapshot/cdp.d.ts +36 -0
- package/dist/snapshot/cdp.js +447 -0
- package/dist/snapshot/clip.cjs +100 -0
- package/dist/snapshot/clip.d.cts +26 -0
- package/dist/snapshot/clip.d.ts +26 -0
- package/dist/snapshot/clip.js +96 -0
- package/dist/snapshot/collect.cjs +358 -0
- package/dist/snapshot/collect.d.cts +18 -0
- package/dist/snapshot/collect.d.ts +18 -0
- package/dist/snapshot/collect.js +355 -0
- package/dist/snapshot/color.cjs +1039 -0
- package/dist/snapshot/color.d.cts +316 -0
- package/dist/snapshot/color.d.ts +316 -0
- package/dist/snapshot/color.js +1026 -0
- package/dist/snapshot/facts.cjs +167 -0
- package/dist/snapshot/facts.d.cts +48 -0
- package/dist/snapshot/facts.d.ts +48 -0
- package/dist/snapshot/facts.js +161 -0
- package/dist/snapshot/limits.cjs +69 -0
- package/dist/snapshot/limits.d.cts +59 -0
- package/dist/snapshot/limits.d.ts +59 -0
- package/dist/snapshot/limits.js +65 -0
- package/dist/snapshot/not-measured.cjs +44 -0
- package/dist/snapshot/not-measured.d.cts +14 -0
- package/dist/snapshot/not-measured.d.ts +14 -0
- package/dist/snapshot/not-measured.js +41 -0
- package/dist/snapshot/types.cjs +2 -0
- package/dist/snapshot/types.d.cts +238 -0
- package/dist/snapshot/types.d.ts +238 -0
- package/dist/snapshot/types.js +1 -0
- package/dist/spec/index.cjs +9 -0
- package/dist/spec/index.d.cts +15 -0
- package/dist/spec/index.d.ts +15 -0
- package/dist/spec/index.js +6 -0
- package/package.json +141 -0
- package/skills/dnumb/SKILL.md +136 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The state diff: what changed between two captures, and why.
|
|
3
|
+
*
|
|
4
|
+
* `look` describes one moment. This describes the transition — which is what
|
|
5
|
+
* makes an agent's own actions legible to it. Click something, get back a
|
|
6
|
+
* bounded account of what the click did, instead of re-reading the whole digest
|
|
7
|
+
* and comparing by hand.
|
|
8
|
+
*
|
|
9
|
+
* The hard part is not the arithmetic. Before anything can say `e12 moved down
|
|
10
|
+
* 21px`, it has to know that `e12` in one capture and `e12` in the other are
|
|
11
|
+
* the same element across a DOM that just changed. `identity.ts` is that, and
|
|
12
|
+
* its central finding is that geometry must count for *zero* when matching.
|
|
13
|
+
*/
|
|
14
|
+
import type { Snapshot } from "../snapshot/types.js";
|
|
15
|
+
import type { DiffOptions, DiffResult } from "./types.js";
|
|
16
|
+
export { computeChanges } from "./changes.js";
|
|
17
|
+
export { geometryTiebreak, hardKey, isGeneratedId, MATCH_THRESHOLD, similarity, textSimilarity, WEAK_ROLES, } from "./identity.js";
|
|
18
|
+
export { match, prepare } from "./match.js";
|
|
19
|
+
export { renderDiff } from "./render.js";
|
|
20
|
+
export type { BoxDelta, Cause, Change, ChangeKind, Derivation, DiffMeta, DiffOptions, DiffResult, Match, Prepared, Subsumption, } from "./types.js";
|
|
21
|
+
export interface DiffCapturesOptions extends DiffOptions {
|
|
22
|
+
/** Names for the two captures, used in the header. Default `before`/`after`. */
|
|
23
|
+
beforeName?: string;
|
|
24
|
+
afterName?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* What `diffCaptures` actually needs beyond a bare `Snapshot`: which capture
|
|
28
|
+
* path produced it, when the caller has one to give.
|
|
29
|
+
*
|
|
30
|
+
* `strategy` lives on `CaptureResult` and `Bundle`, not on `Snapshot` itself —
|
|
31
|
+
* this package's diff layer only depends on `snapshot/types.ts`. Both real
|
|
32
|
+
* capture shapes are structural supersets of `Snapshot`, so a caller passing
|
|
33
|
+
* a real `CaptureResult` or `Bundle` satisfies this type for free; only a
|
|
34
|
+
* hand-built `Snapshot` fixture has no opinion, which is why the field is
|
|
35
|
+
* optional here exactly as `DiffMeta.strategyBefore` is.
|
|
36
|
+
*/
|
|
37
|
+
export interface DiffableSnapshot extends Snapshot {
|
|
38
|
+
strategy?: 'cdp' | 'walk';
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Two captures were taken at different viewports.
|
|
42
|
+
*
|
|
43
|
+
* Nearly every box moves on a viewport change, so a diff would faithfully
|
|
44
|
+
* report hundreds of consequences of something the reader already knows
|
|
45
|
+
* instead of the change they are looking for. `cli.ts` has refused this for
|
|
46
|
+
* as long as the `diff` verb has existed; `diffCaptures` itself did not,
|
|
47
|
+
* which meant any other caller of the library — not just the CLI — could
|
|
48
|
+
* diff 1440×900 against 375×812 and get a diff that describes a resize.
|
|
49
|
+
*/
|
|
50
|
+
export declare class DiffViewportMismatchError extends Error {
|
|
51
|
+
readonly before: {
|
|
52
|
+
width: number;
|
|
53
|
+
height: number;
|
|
54
|
+
};
|
|
55
|
+
readonly after: {
|
|
56
|
+
width: number;
|
|
57
|
+
height: number;
|
|
58
|
+
};
|
|
59
|
+
name: string;
|
|
60
|
+
constructor(before: {
|
|
61
|
+
width: number;
|
|
62
|
+
height: number;
|
|
63
|
+
}, after: {
|
|
64
|
+
width: number;
|
|
65
|
+
height: number;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Diff two captures end to end.
|
|
70
|
+
*
|
|
71
|
+
* Returns both the rendered text and the structured changes, so a caller that
|
|
72
|
+
* wants numbers does not have to parse the text back — the same discipline the
|
|
73
|
+
* query verbs follow, and for the same reason: two renderings built from one
|
|
74
|
+
* computation cannot disagree.
|
|
75
|
+
*/
|
|
76
|
+
export declare function diffCaptures(before: DiffableSnapshot, after: DiffableSnapshot, options?: DiffCapturesOptions): DiffResult;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { computeChanges } from "./changes.js";
|
|
2
|
+
import { match, prepare } from "./match.js";
|
|
3
|
+
import { renderDiff } from "./render.js";
|
|
4
|
+
export { computeChanges } from "./changes.js";
|
|
5
|
+
export { geometryTiebreak, hardKey, isGeneratedId, MATCH_THRESHOLD, similarity, textSimilarity, WEAK_ROLES, } from "./identity.js";
|
|
6
|
+
export { match, prepare } from "./match.js";
|
|
7
|
+
export { renderDiff } from "./render.js";
|
|
8
|
+
/**
|
|
9
|
+
* Two captures were taken at different viewports.
|
|
10
|
+
*
|
|
11
|
+
* Nearly every box moves on a viewport change, so a diff would faithfully
|
|
12
|
+
* report hundreds of consequences of something the reader already knows
|
|
13
|
+
* instead of the change they are looking for. `cli.ts` has refused this for
|
|
14
|
+
* as long as the `diff` verb has existed; `diffCaptures` itself did not,
|
|
15
|
+
* which meant any other caller of the library — not just the CLI — could
|
|
16
|
+
* diff 1440×900 against 375×812 and get a diff that describes a resize.
|
|
17
|
+
*/
|
|
18
|
+
export class DiffViewportMismatchError extends Error {
|
|
19
|
+
before;
|
|
20
|
+
after;
|
|
21
|
+
name = 'DiffViewportMismatchError';
|
|
22
|
+
constructor(before, after) {
|
|
23
|
+
super(`dnumb: these captures were taken at different viewports (${before.width}x${before.height} ` +
|
|
24
|
+
`vs ${after.width}x${after.height}). Nearly every box moved, so the diff would describe ` +
|
|
25
|
+
'the resize rather than the change you are looking for. Capture both states at one ' +
|
|
26
|
+
'viewport, or read each with `look`.');
|
|
27
|
+
this.before = before;
|
|
28
|
+
this.after = after;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Diff two captures end to end.
|
|
33
|
+
*
|
|
34
|
+
* Returns both the rendered text and the structured changes, so a caller that
|
|
35
|
+
* wants numbers does not have to parse the text back — the same discipline the
|
|
36
|
+
* query verbs follow, and for the same reason: two renderings built from one
|
|
37
|
+
* computation cannot disagree.
|
|
38
|
+
*/
|
|
39
|
+
export function diffCaptures(before, after, options = {}) {
|
|
40
|
+
if (before.viewport.width !== after.viewport.width ||
|
|
41
|
+
before.viewport.height !== after.viewport.height) {
|
|
42
|
+
throw new DiffViewportMismatchError(before.viewport, after.viewport);
|
|
43
|
+
}
|
|
44
|
+
const preparedBefore = prepare(before);
|
|
45
|
+
const preparedAfter = prepare(after);
|
|
46
|
+
const matched = match(preparedBefore, preparedAfter);
|
|
47
|
+
const changes = computeChanges(preparedBefore, preparedAfter, matched);
|
|
48
|
+
const viewport = (snapshot) => `${snapshot.viewport.width}x${snapshot.viewport.height}`;
|
|
49
|
+
const text = renderDiff(changes, {
|
|
50
|
+
before: options.beforeName ?? 'before',
|
|
51
|
+
after: options.afterName ?? 'after',
|
|
52
|
+
viewportBefore: viewport(before),
|
|
53
|
+
viewportAfter: viewport(after),
|
|
54
|
+
truncatedBefore: before.truncated,
|
|
55
|
+
truncatedAfter: after.truncated,
|
|
56
|
+
beforeCount: before.elements.length,
|
|
57
|
+
afterCount: after.elements.length,
|
|
58
|
+
// `exactOptionalPropertyTypes` treats `key: undefined` as distinct from
|
|
59
|
+
// the key being absent, so an `undefined` strategy/notMeasured (a
|
|
60
|
+
// hand-built `Snapshot` fixture with no opinion) has to be omitted
|
|
61
|
+
// entirely, not assigned.
|
|
62
|
+
...(before.strategy !== undefined ? { strategyBefore: before.strategy } : {}),
|
|
63
|
+
...(after.strategy !== undefined ? { strategyAfter: after.strategy } : {}),
|
|
64
|
+
...(before.notMeasured !== undefined ? { notMeasuredBefore: before.notMeasured } : {}),
|
|
65
|
+
...(after.notMeasured !== undefined ? { notMeasuredAfter: after.notMeasured } : {}),
|
|
66
|
+
}, options.maxLines === undefined ? {} : { maxLines: options.maxLines });
|
|
67
|
+
return { changes, text, truncated: { before: before.truncated, after: after.truncated } };
|
|
68
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.prepare = prepare;
|
|
4
|
+
exports.match = match;
|
|
5
|
+
const identity_js_1 = require("./identity.cjs");
|
|
6
|
+
/** Matches the accessible-name cap, so one long paragraph cannot dominate a score. */
|
|
7
|
+
const SUBTREE_TEXT_LIMIT = 80;
|
|
8
|
+
const MAX_SUBTREE_DEPTH = 6;
|
|
9
|
+
function computeSubtreeText(byId, el, depth = 0) {
|
|
10
|
+
if (depth > MAX_SUBTREE_DEPTH)
|
|
11
|
+
return el.text;
|
|
12
|
+
let out = el.text;
|
|
13
|
+
for (const childId of el.childIds) {
|
|
14
|
+
const child = byId.get(childId);
|
|
15
|
+
if (!child)
|
|
16
|
+
continue;
|
|
17
|
+
out = `${out} ${computeSubtreeText(byId, child, depth + 1)}`.trim();
|
|
18
|
+
if (out.length > SUBTREE_TEXT_LIMIT)
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
return out.slice(0, SUBTREE_TEXT_LIMIT);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Index a capture for matching, without touching its records — they belong to
|
|
25
|
+
* whoever loaded the bundle.
|
|
26
|
+
*/
|
|
27
|
+
function prepare(snapshot) {
|
|
28
|
+
const byId = new Map(snapshot.elements.map((el) => [el.id, el]));
|
|
29
|
+
const subtreeText = new Map();
|
|
30
|
+
const docIndex = new Map();
|
|
31
|
+
const ordinal = new Map();
|
|
32
|
+
const counters = new Map();
|
|
33
|
+
snapshot.elements.forEach((el, i) => {
|
|
34
|
+
subtreeText.set(el.id, computeSubtreeText(byId, el));
|
|
35
|
+
docIndex.set(el.id, i);
|
|
36
|
+
const key = `${el.parentId ?? 'root'}|${el.tag}`;
|
|
37
|
+
const n = counters.get(key) ?? 0;
|
|
38
|
+
counters.set(key, n + 1);
|
|
39
|
+
ordinal.set(el.id, n);
|
|
40
|
+
});
|
|
41
|
+
// Reverse document order, so every child is counted before its parent asks.
|
|
42
|
+
const descendantCount = new Map();
|
|
43
|
+
for (let i = snapshot.elements.length - 1; i >= 0; i--) {
|
|
44
|
+
const el = snapshot.elements[i];
|
|
45
|
+
if (!el)
|
|
46
|
+
continue;
|
|
47
|
+
descendantCount.set(el.id, el.childIds.reduce((sum, childId) => sum + 1 + (descendantCount.get(childId) ?? 0), 0));
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
elements: snapshot.elements,
|
|
51
|
+
byId,
|
|
52
|
+
ordinal,
|
|
53
|
+
subtreeText,
|
|
54
|
+
docIndex,
|
|
55
|
+
descendantCount,
|
|
56
|
+
roots: snapshot.elements.filter((el) => el.parentId === null).map((el) => el.id),
|
|
57
|
+
viewport: snapshot.viewport,
|
|
58
|
+
truncated: snapshot.truncated,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function match(before, after) {
|
|
62
|
+
const pairs = new Map();
|
|
63
|
+
const reverse = new Map();
|
|
64
|
+
const link = (b, a) => {
|
|
65
|
+
pairs.set(b, a);
|
|
66
|
+
reverse.set(a, b);
|
|
67
|
+
};
|
|
68
|
+
// --- Rungs 1-2: hard keys, matched anywhere in the tree -------------------
|
|
69
|
+
// Absolute by contract, so an element that moved to a different parent still
|
|
70
|
+
// matches itself. Resolved before any scoring, so a strong key is never
|
|
71
|
+
// outvoted by a well-scoring impostor in the right position.
|
|
72
|
+
const afterByHardKey = new Map();
|
|
73
|
+
for (const el of after.elements) {
|
|
74
|
+
const key = (0, identity_js_1.hardKey)(el);
|
|
75
|
+
if (!key)
|
|
76
|
+
continue;
|
|
77
|
+
// A duplicated id is not document-unique in fact, whatever the spec says,
|
|
78
|
+
// so it stops being a key rather than becoming a coin flip.
|
|
79
|
+
afterByHardKey.set(key, afterByHardKey.has(key) ? null : el.id);
|
|
80
|
+
}
|
|
81
|
+
for (const el of before.elements) {
|
|
82
|
+
const key = (0, identity_js_1.hardKey)(el);
|
|
83
|
+
if (!key)
|
|
84
|
+
continue;
|
|
85
|
+
const hit = afterByHardKey.get(key);
|
|
86
|
+
if (hit && !reverse.has(hit))
|
|
87
|
+
link(el.id, hit);
|
|
88
|
+
}
|
|
89
|
+
// There is one <body> on each side; roots pair positionally.
|
|
90
|
+
const rootCount = Math.min(before.roots.length, after.roots.length);
|
|
91
|
+
for (let i = 0; i < rootCount; i++) {
|
|
92
|
+
const b = before.roots[i];
|
|
93
|
+
const a = after.roots[i];
|
|
94
|
+
if (b === undefined || a === undefined)
|
|
95
|
+
continue;
|
|
96
|
+
if (!pairs.has(b) && !reverse.has(a))
|
|
97
|
+
link(b, a);
|
|
98
|
+
}
|
|
99
|
+
// --- Rungs 3-4: scored assignment, breadth-first from every matched pair --
|
|
100
|
+
// Seeded with the hard-keyed pairs as well as the roots: a hard-keyed element
|
|
101
|
+
// deep in the tree is an entry point that its unmatched ancestors are not,
|
|
102
|
+
// which is why this is a queue over matched pairs and not a tree walk.
|
|
103
|
+
const queue = [...pairs];
|
|
104
|
+
const visited = new Set();
|
|
105
|
+
while (queue.length > 0) {
|
|
106
|
+
const next = queue.shift();
|
|
107
|
+
if (!next)
|
|
108
|
+
break;
|
|
109
|
+
const [beforeParent, afterParent] = next;
|
|
110
|
+
if (visited.has(beforeParent))
|
|
111
|
+
continue;
|
|
112
|
+
visited.add(beforeParent);
|
|
113
|
+
const bParent = before.byId.get(beforeParent);
|
|
114
|
+
const aParent = after.byId.get(afterParent);
|
|
115
|
+
if (!bParent || !aParent)
|
|
116
|
+
continue;
|
|
117
|
+
const bKids = bParent.childIds.filter((id) => !pairs.has(id));
|
|
118
|
+
const aKids = aParent.childIds.filter((id) => !reverse.has(id));
|
|
119
|
+
const candidates = [];
|
|
120
|
+
for (const bid of bKids) {
|
|
121
|
+
const b = before.byId.get(bid);
|
|
122
|
+
if (!b)
|
|
123
|
+
continue;
|
|
124
|
+
for (const aid of aKids) {
|
|
125
|
+
const a = after.byId.get(aid);
|
|
126
|
+
if (!a)
|
|
127
|
+
continue;
|
|
128
|
+
const score = (0, identity_js_1.similarity)(b, a, {
|
|
129
|
+
ordinalA: before.ordinal.get(bid),
|
|
130
|
+
ordinalB: after.ordinal.get(aid),
|
|
131
|
+
subtreeTextA: before.subtreeText.get(bid),
|
|
132
|
+
subtreeTextB: after.subtreeText.get(aid),
|
|
133
|
+
});
|
|
134
|
+
if (score < identity_js_1.MATCH_THRESHOLD)
|
|
135
|
+
continue;
|
|
136
|
+
candidates.push({ bid, aid, score: score + (0, identity_js_1.geometryTiebreak)(b, a) });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
candidates.sort((x, y) => y.score - x.score);
|
|
140
|
+
const takenBefore = new Set();
|
|
141
|
+
const takenAfter = new Set();
|
|
142
|
+
for (const candidate of candidates) {
|
|
143
|
+
if (takenBefore.has(candidate.bid) || takenAfter.has(candidate.aid))
|
|
144
|
+
continue;
|
|
145
|
+
takenBefore.add(candidate.bid);
|
|
146
|
+
takenAfter.add(candidate.aid);
|
|
147
|
+
link(candidate.bid, candidate.aid);
|
|
148
|
+
}
|
|
149
|
+
for (const bid of bParent.childIds) {
|
|
150
|
+
const aid = pairs.get(bid);
|
|
151
|
+
if (aid)
|
|
152
|
+
queue.push([bid, aid]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { pairs, reverse };
|
|
156
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global one-to-one assignment, scoped to matched parents.
|
|
3
|
+
*
|
|
4
|
+
* qain and browser-use both take the first hit and cannot express "these two
|
|
5
|
+
* candidates both want the same partner". This assigns instead. Scoping the
|
|
6
|
+
* assignment to a matched parent's children keeps each candidate set small
|
|
7
|
+
* enough that greedy-by-descending-score is near-optimal at O(n log n);
|
|
8
|
+
* Hungarian is O(n³) and unusable at the collector's 5,000-element cap.
|
|
9
|
+
*/
|
|
10
|
+
import type { Snapshot } from "../snapshot/types.cjs";
|
|
11
|
+
import type { Match, Prepared } from "./types.cjs";
|
|
12
|
+
/**
|
|
13
|
+
* Index a capture for matching, without touching its records — they belong to
|
|
14
|
+
* whoever loaded the bundle.
|
|
15
|
+
*/
|
|
16
|
+
export declare function prepare(snapshot: Snapshot): Prepared;
|
|
17
|
+
export declare function match(before: Prepared, after: Prepared): Match;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global one-to-one assignment, scoped to matched parents.
|
|
3
|
+
*
|
|
4
|
+
* qain and browser-use both take the first hit and cannot express "these two
|
|
5
|
+
* candidates both want the same partner". This assigns instead. Scoping the
|
|
6
|
+
* assignment to a matched parent's children keeps each candidate set small
|
|
7
|
+
* enough that greedy-by-descending-score is near-optimal at O(n log n);
|
|
8
|
+
* Hungarian is O(n³) and unusable at the collector's 5,000-element cap.
|
|
9
|
+
*/
|
|
10
|
+
import type { Snapshot } from "../snapshot/types.js";
|
|
11
|
+
import type { Match, Prepared } from "./types.js";
|
|
12
|
+
/**
|
|
13
|
+
* Index a capture for matching, without touching its records — they belong to
|
|
14
|
+
* whoever loaded the bundle.
|
|
15
|
+
*/
|
|
16
|
+
export declare function prepare(snapshot: Snapshot): Prepared;
|
|
17
|
+
export declare function match(before: Prepared, after: Prepared): Match;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { geometryTiebreak, hardKey, MATCH_THRESHOLD, similarity } from "./identity.js";
|
|
2
|
+
/** Matches the accessible-name cap, so one long paragraph cannot dominate a score. */
|
|
3
|
+
const SUBTREE_TEXT_LIMIT = 80;
|
|
4
|
+
const MAX_SUBTREE_DEPTH = 6;
|
|
5
|
+
function computeSubtreeText(byId, el, depth = 0) {
|
|
6
|
+
if (depth > MAX_SUBTREE_DEPTH)
|
|
7
|
+
return el.text;
|
|
8
|
+
let out = el.text;
|
|
9
|
+
for (const childId of el.childIds) {
|
|
10
|
+
const child = byId.get(childId);
|
|
11
|
+
if (!child)
|
|
12
|
+
continue;
|
|
13
|
+
out = `${out} ${computeSubtreeText(byId, child, depth + 1)}`.trim();
|
|
14
|
+
if (out.length > SUBTREE_TEXT_LIMIT)
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
return out.slice(0, SUBTREE_TEXT_LIMIT);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Index a capture for matching, without touching its records — they belong to
|
|
21
|
+
* whoever loaded the bundle.
|
|
22
|
+
*/
|
|
23
|
+
export function prepare(snapshot) {
|
|
24
|
+
const byId = new Map(snapshot.elements.map((el) => [el.id, el]));
|
|
25
|
+
const subtreeText = new Map();
|
|
26
|
+
const docIndex = new Map();
|
|
27
|
+
const ordinal = new Map();
|
|
28
|
+
const counters = new Map();
|
|
29
|
+
snapshot.elements.forEach((el, i) => {
|
|
30
|
+
subtreeText.set(el.id, computeSubtreeText(byId, el));
|
|
31
|
+
docIndex.set(el.id, i);
|
|
32
|
+
const key = `${el.parentId ?? 'root'}|${el.tag}`;
|
|
33
|
+
const n = counters.get(key) ?? 0;
|
|
34
|
+
counters.set(key, n + 1);
|
|
35
|
+
ordinal.set(el.id, n);
|
|
36
|
+
});
|
|
37
|
+
// Reverse document order, so every child is counted before its parent asks.
|
|
38
|
+
const descendantCount = new Map();
|
|
39
|
+
for (let i = snapshot.elements.length - 1; i >= 0; i--) {
|
|
40
|
+
const el = snapshot.elements[i];
|
|
41
|
+
if (!el)
|
|
42
|
+
continue;
|
|
43
|
+
descendantCount.set(el.id, el.childIds.reduce((sum, childId) => sum + 1 + (descendantCount.get(childId) ?? 0), 0));
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
elements: snapshot.elements,
|
|
47
|
+
byId,
|
|
48
|
+
ordinal,
|
|
49
|
+
subtreeText,
|
|
50
|
+
docIndex,
|
|
51
|
+
descendantCount,
|
|
52
|
+
roots: snapshot.elements.filter((el) => el.parentId === null).map((el) => el.id),
|
|
53
|
+
viewport: snapshot.viewport,
|
|
54
|
+
truncated: snapshot.truncated,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function match(before, after) {
|
|
58
|
+
const pairs = new Map();
|
|
59
|
+
const reverse = new Map();
|
|
60
|
+
const link = (b, a) => {
|
|
61
|
+
pairs.set(b, a);
|
|
62
|
+
reverse.set(a, b);
|
|
63
|
+
};
|
|
64
|
+
// --- Rungs 1-2: hard keys, matched anywhere in the tree -------------------
|
|
65
|
+
// Absolute by contract, so an element that moved to a different parent still
|
|
66
|
+
// matches itself. Resolved before any scoring, so a strong key is never
|
|
67
|
+
// outvoted by a well-scoring impostor in the right position.
|
|
68
|
+
const afterByHardKey = new Map();
|
|
69
|
+
for (const el of after.elements) {
|
|
70
|
+
const key = hardKey(el);
|
|
71
|
+
if (!key)
|
|
72
|
+
continue;
|
|
73
|
+
// A duplicated id is not document-unique in fact, whatever the spec says,
|
|
74
|
+
// so it stops being a key rather than becoming a coin flip.
|
|
75
|
+
afterByHardKey.set(key, afterByHardKey.has(key) ? null : el.id);
|
|
76
|
+
}
|
|
77
|
+
for (const el of before.elements) {
|
|
78
|
+
const key = hardKey(el);
|
|
79
|
+
if (!key)
|
|
80
|
+
continue;
|
|
81
|
+
const hit = afterByHardKey.get(key);
|
|
82
|
+
if (hit && !reverse.has(hit))
|
|
83
|
+
link(el.id, hit);
|
|
84
|
+
}
|
|
85
|
+
// There is one <body> on each side; roots pair positionally.
|
|
86
|
+
const rootCount = Math.min(before.roots.length, after.roots.length);
|
|
87
|
+
for (let i = 0; i < rootCount; i++) {
|
|
88
|
+
const b = before.roots[i];
|
|
89
|
+
const a = after.roots[i];
|
|
90
|
+
if (b === undefined || a === undefined)
|
|
91
|
+
continue;
|
|
92
|
+
if (!pairs.has(b) && !reverse.has(a))
|
|
93
|
+
link(b, a);
|
|
94
|
+
}
|
|
95
|
+
// --- Rungs 3-4: scored assignment, breadth-first from every matched pair --
|
|
96
|
+
// Seeded with the hard-keyed pairs as well as the roots: a hard-keyed element
|
|
97
|
+
// deep in the tree is an entry point that its unmatched ancestors are not,
|
|
98
|
+
// which is why this is a queue over matched pairs and not a tree walk.
|
|
99
|
+
const queue = [...pairs];
|
|
100
|
+
const visited = new Set();
|
|
101
|
+
while (queue.length > 0) {
|
|
102
|
+
const next = queue.shift();
|
|
103
|
+
if (!next)
|
|
104
|
+
break;
|
|
105
|
+
const [beforeParent, afterParent] = next;
|
|
106
|
+
if (visited.has(beforeParent))
|
|
107
|
+
continue;
|
|
108
|
+
visited.add(beforeParent);
|
|
109
|
+
const bParent = before.byId.get(beforeParent);
|
|
110
|
+
const aParent = after.byId.get(afterParent);
|
|
111
|
+
if (!bParent || !aParent)
|
|
112
|
+
continue;
|
|
113
|
+
const bKids = bParent.childIds.filter((id) => !pairs.has(id));
|
|
114
|
+
const aKids = aParent.childIds.filter((id) => !reverse.has(id));
|
|
115
|
+
const candidates = [];
|
|
116
|
+
for (const bid of bKids) {
|
|
117
|
+
const b = before.byId.get(bid);
|
|
118
|
+
if (!b)
|
|
119
|
+
continue;
|
|
120
|
+
for (const aid of aKids) {
|
|
121
|
+
const a = after.byId.get(aid);
|
|
122
|
+
if (!a)
|
|
123
|
+
continue;
|
|
124
|
+
const score = similarity(b, a, {
|
|
125
|
+
ordinalA: before.ordinal.get(bid),
|
|
126
|
+
ordinalB: after.ordinal.get(aid),
|
|
127
|
+
subtreeTextA: before.subtreeText.get(bid),
|
|
128
|
+
subtreeTextB: after.subtreeText.get(aid),
|
|
129
|
+
});
|
|
130
|
+
if (score < MATCH_THRESHOLD)
|
|
131
|
+
continue;
|
|
132
|
+
candidates.push({ bid, aid, score: score + geometryTiebreak(b, a) });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
candidates.sort((x, y) => y.score - x.score);
|
|
136
|
+
const takenBefore = new Set();
|
|
137
|
+
const takenAfter = new Set();
|
|
138
|
+
for (const candidate of candidates) {
|
|
139
|
+
if (takenBefore.has(candidate.bid) || takenAfter.has(candidate.aid))
|
|
140
|
+
continue;
|
|
141
|
+
takenBefore.add(candidate.bid);
|
|
142
|
+
takenAfter.add(candidate.aid);
|
|
143
|
+
link(candidate.bid, candidate.aid);
|
|
144
|
+
}
|
|
145
|
+
for (const bid of bParent.childIds) {
|
|
146
|
+
const aid = pairs.get(bid);
|
|
147
|
+
if (aid)
|
|
148
|
+
queue.push([bid, aid]);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { pairs, reverse };
|
|
152
|
+
}
|