@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,540 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderDiff = renderDiff;
|
|
4
|
+
/**
|
|
5
|
+
* Render a diff as the text an agent reads.
|
|
6
|
+
*
|
|
7
|
+
* The grammar is the step-2 spike's, ported unchanged. Six independent cold
|
|
8
|
+
* readers, one diff each and no other context, scored 6/6 correct accounts with
|
|
9
|
+
* zero fabrications on this exact output — including the control fixture where
|
|
10
|
+
* nothing changed (`docs/notes/02-v1-cold-read.md`). That result is the gate the
|
|
11
|
+
* stateful thesis rested on, so the format is deliberately not redesigned here.
|
|
12
|
+
*
|
|
13
|
+
* Ids are *after*-state ids, because the next thing the agent does is query the
|
|
14
|
+
* state it is now in. Removed elements carry a before-id and are marked `-`, so
|
|
15
|
+
* the two are never confused.
|
|
16
|
+
*/
|
|
17
|
+
const not_measured_js_1 = require("../snapshot/not-measured.cjs");
|
|
18
|
+
/** Sub-pixel layout noise. Matches the tolerance `changes.ts` measures with. */
|
|
19
|
+
const BOX_TOLERANCE = 0.5;
|
|
20
|
+
const DEFAULT_MAX_LINES = 15;
|
|
21
|
+
const n = (value) => (Number.isInteger(value) ? String(value) : value.toFixed(1));
|
|
22
|
+
const vArrow = (d) => (d > 0 ? `↓${n(d)}` : `↑${n(-d)}`);
|
|
23
|
+
const hArrow = (d) => (d > 0 ? `→${n(d)}` : `←${n(-d)}`);
|
|
24
|
+
const signed = (d) => `${d > 0 ? '+' : ''}${n(d)}`;
|
|
25
|
+
const cssName = (prop) => prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
|
|
26
|
+
/** `aria-expanded` is what the line prints; `ariaExpanded` is what the record calls it. */
|
|
27
|
+
const camelName = (prop) => prop.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
|
|
28
|
+
// ---- labels ---------------------------------------------------------------
|
|
29
|
+
const LABEL_TEXT_LIMIT = 32;
|
|
30
|
+
const SUBTREE_TEXT_LIMIT = 80;
|
|
31
|
+
/**
|
|
32
|
+
* Subtree text is a *matching* feature; using it as a label turns `body` into a
|
|
33
|
+
* paragraph. Fall back to it only for small elements, where it is the only
|
|
34
|
+
* thing that says which one this is — `+ e6 li "Desk lamp 1"` beats `+ e6 li`.
|
|
35
|
+
*/
|
|
36
|
+
const SUBTREE_LABEL_MAX_DESCENDANTS = 2;
|
|
37
|
+
/**
|
|
38
|
+
* Records indexed by id, one index per capture.
|
|
39
|
+
*
|
|
40
|
+
* Both collectors number elements `e0`, `e1`, … from zero, so `e6` in the before
|
|
41
|
+
* capture and `e6` in the after capture are different elements. One shared map
|
|
42
|
+
* would resolve a removed element's children against the wrong capture and
|
|
43
|
+
* label it with text from a page it was never on.
|
|
44
|
+
*/
|
|
45
|
+
function indexRecords(changes, side) {
|
|
46
|
+
const byId = new Map();
|
|
47
|
+
for (const change of changes) {
|
|
48
|
+
const record = side === 'before' ? change.before : change.after;
|
|
49
|
+
if (record)
|
|
50
|
+
byId.set(record.id, record);
|
|
51
|
+
}
|
|
52
|
+
return byId;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The subtree-text label, or `null` when it must not be used.
|
|
56
|
+
*
|
|
57
|
+
* `renderDiff` is handed changes rather than the `Prepared` side maps that hold
|
|
58
|
+
* `subtreeText` and `descendantCount`, so both are recomputed from the records
|
|
59
|
+
* in hand. `null` covers two cases that have to behave the same way: the
|
|
60
|
+
* element is too big for this to be a name, or part of its subtree is not among
|
|
61
|
+
* those records. A partial subtree printed as a label would attribute to one
|
|
62
|
+
* element the words of an unknown fraction of it, which is the fabrication this
|
|
63
|
+
* whole format exists to avoid. The descendant cap also bounds the walk, so a
|
|
64
|
+
* malformed record cycle cannot spin here.
|
|
65
|
+
*/
|
|
66
|
+
function subtreeLabel(el, byId) {
|
|
67
|
+
let descendants = 0;
|
|
68
|
+
let text = el.text;
|
|
69
|
+
const visit = (node) => {
|
|
70
|
+
for (const childId of node.childIds) {
|
|
71
|
+
const child = byId.get(childId);
|
|
72
|
+
if (!child)
|
|
73
|
+
return false;
|
|
74
|
+
descendants++;
|
|
75
|
+
if (descendants > SUBTREE_LABEL_MAX_DESCENDANTS)
|
|
76
|
+
return false;
|
|
77
|
+
text = `${text} ${child.text}`.trim();
|
|
78
|
+
if (!visit(child))
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
};
|
|
83
|
+
return visit(el) ? text.slice(0, SUBTREE_TEXT_LIMIT) : null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* `tag#testid` or `tag.role`, matching `digest/render.ts`'s own `label()` and
|
|
87
|
+
* `query.ts`'s `ANNOTATED_QUERY` grammar exactly (B9). This function used to
|
|
88
|
+
* print `@testId` / `#domId` instead — a sigil pair this package's own `q
|
|
89
|
+
* find` cannot parse: `#` there means test id and `.` means role, so a token
|
|
90
|
+
* this renderer produced (`table@testId`, `ul#domId`) either matched nothing
|
|
91
|
+
* (`domId` is not part of the compound annotation at all — `q find` only
|
|
92
|
+
* ever matches it as a whole separate field) or matched the wrong field
|
|
93
|
+
* outright (`#` parsed as *test id*, not the dom id this line meant). An
|
|
94
|
+
* agent that copies a diff line straight into `q find` — the documented
|
|
95
|
+
* round-trip — got zero matches, with a hint asserting the query's own
|
|
96
|
+
* `field "testId"` came up empty when the line never named a test id to
|
|
97
|
+
* begin with. Dropping `domId` from the compound token (rather than
|
|
98
|
+
* inventing a third sigil) keeps this renderer producing exactly the shape
|
|
99
|
+
* the rest of the package already agrees on; `domId` alone still identifies
|
|
100
|
+
* the element via `id`/text/`q describe`, and `q find` already matches it as
|
|
101
|
+
* a bare field.
|
|
102
|
+
*/
|
|
103
|
+
function label(el, byId) {
|
|
104
|
+
let head = `${el.id} ${el.tag}`;
|
|
105
|
+
if (el.testId)
|
|
106
|
+
head += `#${el.testId}`;
|
|
107
|
+
else if (el.role)
|
|
108
|
+
head += `.${el.role}`;
|
|
109
|
+
const name = el.name || el.text || subtreeLabel(el, byId);
|
|
110
|
+
if (!name)
|
|
111
|
+
return head;
|
|
112
|
+
const shown = name.length > LABEL_TEXT_LIMIT ? `${name.slice(0, LABEL_TEXT_LIMIT - 1)}…` : name;
|
|
113
|
+
return `${head} "${shown}"`;
|
|
114
|
+
}
|
|
115
|
+
const at = (el) => `@${n(el.rect.x)},${n(el.rect.y)} ${n(el.rect.width)}x${n(el.rect.height)}`;
|
|
116
|
+
/**
|
|
117
|
+
* `Change.descendants` (set in `changes.ts`'s subsumption pass) counts
|
|
118
|
+
* folded *elements*, deliberately a different unit from the header's `N
|
|
119
|
+
* folded` count of *change records* below — see the comment on `folded` in
|
|
120
|
+
* `renderDiff` for why that is not a bug.
|
|
121
|
+
*/
|
|
122
|
+
const inside = (change) => change.descendants ? ` +${change.descendants} inside` : '';
|
|
123
|
+
// ---- line forms -----------------------------------------------------------
|
|
124
|
+
/** Vertical before horizontal, movement before resize. One reading order, always. */
|
|
125
|
+
function vector(d) {
|
|
126
|
+
if (!d)
|
|
127
|
+
return '';
|
|
128
|
+
return [
|
|
129
|
+
Math.abs(d.dy) > BOX_TOLERANCE ? vArrow(d.dy) : null,
|
|
130
|
+
Math.abs(d.dx) > BOX_TOLERANCE ? hArrow(d.dx) : null,
|
|
131
|
+
Math.abs(d.dh) > BOX_TOLERANCE ? `↕${signed(d.dh)}` : null,
|
|
132
|
+
Math.abs(d.dw) > BOX_TOLERANCE ? `↔${signed(d.dw)}` : null,
|
|
133
|
+
]
|
|
134
|
+
.filter((part) => part !== null)
|
|
135
|
+
.join(' ');
|
|
136
|
+
}
|
|
137
|
+
function boxLine(d, before, after, byId) {
|
|
138
|
+
const parts = [];
|
|
139
|
+
if (Math.abs(d.dx) > BOX_TOLERANCE || Math.abs(d.dy) > BOX_TOLERANCE) {
|
|
140
|
+
parts.push(`@${n(before.rect.x)},${n(before.rect.y)} → @${n(after.rect.x)},${n(after.rect.y)}`);
|
|
141
|
+
}
|
|
142
|
+
if (Math.abs(d.dw) > BOX_TOLERANCE || Math.abs(d.dh) > BOX_TOLERANCE) {
|
|
143
|
+
parts.push(`${n(before.rect.width)}x${n(before.rect.height)} → ` +
|
|
144
|
+
`${n(after.rect.width)}x${n(after.rect.height)}`);
|
|
145
|
+
}
|
|
146
|
+
return `~ ${label(after, byId)} ${parts.join(' ')} (${vector(d)})`;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Clipping is reported as the measurement, the fact that explains it, and the
|
|
150
|
+
* fact that would excuse it — the clipper's own `overflow` value and whether it
|
|
151
|
+
* can be scrolled. A clipped box inside `overflow:auto` is a scroll region; the
|
|
152
|
+
* same box inside `overflow:hidden` is content nobody can reach. Both are said
|
|
153
|
+
* and neither is adjudicated: the cold reader drew "unusable" from
|
|
154
|
+
* `not scrollable` beside `182x5 visible` on its own. Dropping `scrollable`
|
|
155
|
+
* from the line inverts what the rest of it means.
|
|
156
|
+
*/
|
|
157
|
+
function clipLine(after, byId) {
|
|
158
|
+
const clip = after.clip;
|
|
159
|
+
if (!clip)
|
|
160
|
+
return `✓ ${label(after, byId)} no longer clipped`;
|
|
161
|
+
const hidden = clip.hidden;
|
|
162
|
+
const directions = [
|
|
163
|
+
hidden.top ? `${n(hidden.top)}px above` : null,
|
|
164
|
+
hidden.bottom ? `${n(hidden.bottom)}px below` : null,
|
|
165
|
+
hidden.left ? `${n(hidden.left)}px left of` : null,
|
|
166
|
+
hidden.right ? `${n(hidden.right)}px right of` : null,
|
|
167
|
+
]
|
|
168
|
+
.filter((part) => part !== null)
|
|
169
|
+
.join(', ');
|
|
170
|
+
const visible = clip.visible.width < BOX_TOLERANCE || clip.visible.height < BOX_TOLERANCE
|
|
171
|
+
? '0 visible'
|
|
172
|
+
: `${n(clip.visible.width)}x${n(clip.visible.height)} visible`;
|
|
173
|
+
const scroll = clip.scrollable ? ', scrollable' : ', not scrollable';
|
|
174
|
+
return (`! ${label(after, byId)} clipped by ${clip.by} ${clip.byTag} ` +
|
|
175
|
+
`[overflow:${clip.overflow}${scroll}] — ${directions} it, ${visible}`);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* A `removed` or `added` line that the matcher could not resolve to a genuine
|
|
179
|
+
* presence change — the capture whose window could have excluded this
|
|
180
|
+
* element's hypothetical counterpart was truncated (for `removed`, that is
|
|
181
|
+
* the *after* capture; for `added`, the *before* capture — the other side's
|
|
182
|
+
* truncation is irrelevant, see `changes.ts`'s `markBoundaryAmbiguous`), and
|
|
183
|
+
* the document-order shift measured near this element would place that
|
|
184
|
+
* counterpart outside the window. Marked `?`, never `-` or `+`: printing
|
|
185
|
+
* either would assert an answer this diff does not have.
|
|
186
|
+
*/
|
|
187
|
+
function boundaryLine(change, byBefore, byAfter) {
|
|
188
|
+
if (change.capSize === undefined)
|
|
189
|
+
return null;
|
|
190
|
+
if (change.kind === 'removed') {
|
|
191
|
+
if (!change.before)
|
|
192
|
+
return null;
|
|
193
|
+
// `capSize` is the *after* capture's element count (`changes.ts`'s
|
|
194
|
+
// `markBoundaryAmbiguous`): that window is what could have cut this
|
|
195
|
+
// element's surviving counterpart off, so it is the capture named here.
|
|
196
|
+
// Naming "before" instead is F2 — it prints the other capture's count
|
|
197
|
+
// under the wrong capture's label, and can contradict the header line
|
|
198
|
+
// above it whenever the two windows differ in size.
|
|
199
|
+
return (`? ${label(change.before, byBefore)} ${at(change.before)} — after capped at ` +
|
|
200
|
+
`${change.capSize} elements; may be removed, or pushed past the cap by insertions ` +
|
|
201
|
+
`earlier in the document`);
|
|
202
|
+
}
|
|
203
|
+
if (change.kind === 'added') {
|
|
204
|
+
if (!change.after)
|
|
205
|
+
return null;
|
|
206
|
+
// Mirror of the above: `capSize` is the *before* capture's element count
|
|
207
|
+
// — its window is what could have missed an element that already existed.
|
|
208
|
+
return (`? ${label(change.after, byAfter)} ${at(change.after)} — before capped at ` +
|
|
209
|
+
`${change.capSize} elements; may be new, or pulled into the window by removals earlier ` +
|
|
210
|
+
`in the document`);
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* A `style` change names a record property, and `ElementRecord` is not indexable
|
|
216
|
+
* by an arbitrary string, so the read goes through one narrow cast. An absent
|
|
217
|
+
* property renders `∅` rather than the string `undefined`, which a reader would
|
|
218
|
+
* take for a value the page actually had.
|
|
219
|
+
*/
|
|
220
|
+
function propValue(el, prop) {
|
|
221
|
+
const value = el[prop];
|
|
222
|
+
if (value === undefined || value === null)
|
|
223
|
+
return '∅';
|
|
224
|
+
if (typeof value === 'object') {
|
|
225
|
+
if ('top' in value && 'right' in value && 'bottom' in value && 'left' in value) {
|
|
226
|
+
const { top, right, bottom, left } = value;
|
|
227
|
+
return top === right && right === bottom && bottom === left
|
|
228
|
+
? String(top)
|
|
229
|
+
: `${String(top)} ${String(right)} ${String(bottom)} ${String(left)}`;
|
|
230
|
+
}
|
|
231
|
+
if ('row' in value && 'column' in value) {
|
|
232
|
+
const { row, column } = value;
|
|
233
|
+
return row === column ? String(row) : `${String(row)} ${String(column)}`;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return String(value);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The line for one primary change, or `null` when the change does not carry the
|
|
240
|
+
* records its line quotes.
|
|
241
|
+
*
|
|
242
|
+
* Returning `null` rather than printing a half-line keeps the header's counts
|
|
243
|
+
* honest: they are taken from the changes that actually produced a line, so the
|
|
244
|
+
* summary never promises a line the body does not hold.
|
|
245
|
+
*/
|
|
246
|
+
function lineFor(change, byBefore, byAfter) {
|
|
247
|
+
if (change.boundaryAmbiguous)
|
|
248
|
+
return boundaryLine(change, byBefore, byAfter);
|
|
249
|
+
const { before, after } = change;
|
|
250
|
+
switch (change.kind) {
|
|
251
|
+
case 'added':
|
|
252
|
+
return after ? `+ ${label(after, byAfter)} ${at(after)}${inside(change)}` : null;
|
|
253
|
+
case 'removed':
|
|
254
|
+
return before ? `- ${label(before, byBefore)} ${at(before)}${inside(change)}` : null;
|
|
255
|
+
case 'appeared':
|
|
256
|
+
return after
|
|
257
|
+
? `+ ${label(after, byAfter)} ${at(after)} (was ${change.reason ?? 'not visible'})${inside(change)}`
|
|
258
|
+
: null;
|
|
259
|
+
case 'disappeared':
|
|
260
|
+
return before
|
|
261
|
+
? `- ${label(before, byBefore)} ${at(before)} (now ${change.reason ?? 'not visible'})${inside(change)}`
|
|
262
|
+
: null;
|
|
263
|
+
case 'text':
|
|
264
|
+
return before && after
|
|
265
|
+
? `~ ${label(after, byAfter)} text "${before.text}" → "${after.text}"`
|
|
266
|
+
: null;
|
|
267
|
+
case 'attr': {
|
|
268
|
+
if (!before || !after || !change.prop)
|
|
269
|
+
return null;
|
|
270
|
+
const field = camelName(change.prop);
|
|
271
|
+
return (`~ ${label(after, byAfter)} ${change.prop} ` +
|
|
272
|
+
`${propValue(before, field)} → ${propValue(after, field)}`);
|
|
273
|
+
}
|
|
274
|
+
case 'clip':
|
|
275
|
+
return after ? clipLine(after, byAfter) : null;
|
|
276
|
+
case 'overflow': {
|
|
277
|
+
if (!after)
|
|
278
|
+
return null;
|
|
279
|
+
if (!change.now)
|
|
280
|
+
return `✓ ${label(after, byAfter)} overflow-x resolved`;
|
|
281
|
+
const past = n(after.scrollWidth - after.clientWidth);
|
|
282
|
+
return `! ${label(after, byAfter)} overflow-x +${past}px now [${after.overflowX}]`;
|
|
283
|
+
}
|
|
284
|
+
case 'box':
|
|
285
|
+
return before && after && change.delta ? boxLine(change.delta, before, after, byAfter) : null;
|
|
286
|
+
case 'style':
|
|
287
|
+
return before && after && change.prop
|
|
288
|
+
? `~ ${label(after, byAfter)} ${cssName(change.prop)} ` +
|
|
289
|
+
`${propValue(before, change.prop)} → ${propValue(after, change.prop)}`
|
|
290
|
+
: null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// ---- ordering -------------------------------------------------------------
|
|
294
|
+
/**
|
|
295
|
+
* Structure, then visibility, then content, then the facts, then geometry, then
|
|
296
|
+
* paint. An agent reading top-down meets the act before its consequences.
|
|
297
|
+
*/
|
|
298
|
+
const RANK = {
|
|
299
|
+
added: 0,
|
|
300
|
+
removed: 0,
|
|
301
|
+
appeared: 1,
|
|
302
|
+
disappeared: 1,
|
|
303
|
+
text: 2,
|
|
304
|
+
attr: 2,
|
|
305
|
+
clip: 3,
|
|
306
|
+
overflow: 3,
|
|
307
|
+
box: 4,
|
|
308
|
+
style: 5,
|
|
309
|
+
};
|
|
310
|
+
/**
|
|
311
|
+
* Document order, read off the id.
|
|
312
|
+
*
|
|
313
|
+
* `Prepared.docIndex` holds this, but `renderDiff` is handed changes and not the
|
|
314
|
+
* captures they came from. Both collectors number elements `e0`, `e1`, … in the
|
|
315
|
+
* order they walk the tree, so the suffix *is* the document index. An id in any
|
|
316
|
+
* other shape sorts first rather than throwing the whole ordering out.
|
|
317
|
+
*/
|
|
318
|
+
function documentOrder(change) {
|
|
319
|
+
const id = change.after?.id ?? change.before?.id ?? '';
|
|
320
|
+
const digits = /^e(\d+)$/.exec(id)?.[1];
|
|
321
|
+
return digits === undefined ? 0 : Number(digits);
|
|
322
|
+
}
|
|
323
|
+
// ---- the derived tail ------------------------------------------------------
|
|
324
|
+
/**
|
|
325
|
+
* How many of a group's ids are named before the line switches to a count.
|
|
326
|
+
*
|
|
327
|
+
* The rest stay reachable through the cause: they are exactly the elements that
|
|
328
|
+
* moved by this vector because of `rootVia`, and the trailing `[N]` says how
|
|
329
|
+
* many there are. Naming all of them turns one insert into a 2,000-character
|
|
330
|
+
* line without adding any reach.
|
|
331
|
+
*/
|
|
332
|
+
const DERIVED_NAMED = 3;
|
|
333
|
+
/**
|
|
334
|
+
* A folded line's whole content is "these ids, this vector, because of that
|
|
335
|
+
* element". A derived change missing any of the three has no such line, so it
|
|
336
|
+
* stays primary and gets its own — the diff never drops a change on a shape
|
|
337
|
+
* problem, because a change that vanishes is indistinguishable from a page that
|
|
338
|
+
* did not change.
|
|
339
|
+
*/
|
|
340
|
+
const foldable = (change) => change.cause === 'derived' &&
|
|
341
|
+
change.afterId !== undefined &&
|
|
342
|
+
change.rootVia !== undefined &&
|
|
343
|
+
change.rootHow !== undefined;
|
|
344
|
+
const deltaKey = (d) => d ? `${n(d.dx)}|${n(d.dy)}|${n(d.dw)}|${n(d.dh)}` : '';
|
|
345
|
+
/**
|
|
346
|
+
* One line per root cause and vector.
|
|
347
|
+
*
|
|
348
|
+
* Grouping on `rootVia` rather than `via` is what keeps one insertion to one
|
|
349
|
+
* line: a pushed row and the four words inside it share a root, and grouping on
|
|
350
|
+
* the immediate cause made them five groups that all meant "because e6 was
|
|
351
|
+
* inserted".
|
|
352
|
+
*/
|
|
353
|
+
function foldDerived(derived) {
|
|
354
|
+
const groups = new Map();
|
|
355
|
+
for (const change of derived) {
|
|
356
|
+
const key = change.kind === 'style'
|
|
357
|
+
? `inherited|${change.rootVia}|${change.prop ?? ''}`
|
|
358
|
+
: `${change.rootVia}|${change.rootHow}|${deltaKey(change.delta)}`;
|
|
359
|
+
const group = groups.get(key);
|
|
360
|
+
if (group)
|
|
361
|
+
group.push(change);
|
|
362
|
+
else
|
|
363
|
+
groups.set(key, [change]);
|
|
364
|
+
}
|
|
365
|
+
const lines = [];
|
|
366
|
+
for (const group of groups.values()) {
|
|
367
|
+
const first = group[0];
|
|
368
|
+
if (!first)
|
|
369
|
+
continue;
|
|
370
|
+
const ids = group
|
|
371
|
+
.slice(0, DERIVED_NAMED)
|
|
372
|
+
.map((change) => change.afterId)
|
|
373
|
+
.join(' ');
|
|
374
|
+
const more = group.length > DERIVED_NAMED ? ` +${group.length - DERIVED_NAMED} more` : '';
|
|
375
|
+
if (first.kind === 'style') {
|
|
376
|
+
lines.push(`~ ${ids}${more} ${cssName(first.prop ?? '')} inherited from ${first.rootVia} [${group.length}]`);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const moved = vector(first.delta);
|
|
380
|
+
lines.push(`~ ${ids}${more} ${first.rootHow}${moved ? ` ${moved}` : ''} by ${first.rootVia} [${group.length}]`);
|
|
381
|
+
}
|
|
382
|
+
return lines;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* One side's clause of the caveat, or `null` when that side is not truncated.
|
|
386
|
+
*
|
|
387
|
+
* A hand-built `DiffMeta` can say a side is truncated without knowing its
|
|
388
|
+
* count — `diffCaptures` never leaves this gap, but a caller that builds its
|
|
389
|
+
* own `DiffMeta` might. The old fallback printed the literal character `?`
|
|
390
|
+
* for a missing count, which is this diff's own glyph for an unresolved
|
|
391
|
+
* add/remove (F3): a reader would take `before capped at ? elements` for an
|
|
392
|
+
* ambiguity marker rather than a caveat about the whole capture. Naming the
|
|
393
|
+
* gap in words instead can never be confused with that.
|
|
394
|
+
*/
|
|
395
|
+
function truncationClause(side, truncated, count) {
|
|
396
|
+
if (!truncated)
|
|
397
|
+
return null;
|
|
398
|
+
return count === undefined
|
|
399
|
+
? `${side} capped (count not recorded)`
|
|
400
|
+
: `${side} capped at ${count} elements`;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Both `look` and `diff` share the fact a capture can be a window rather than
|
|
404
|
+
* the whole page (`src/snapshot/limits.ts`). `renderDigest` says so on its own
|
|
405
|
+
* header line; this is that line's counterpart here. A reader comparing two
|
|
406
|
+
* 5,000-element windows of a 12,000-element page has to be told even when
|
|
407
|
+
* nothing near the cap looks ambiguous — the absence of a `?` line does not
|
|
408
|
+
* mean the two captures cover everything, only that nothing detectably fell
|
|
409
|
+
* off the edge.
|
|
410
|
+
*/
|
|
411
|
+
function truncationCaveat(meta) {
|
|
412
|
+
const parts = [
|
|
413
|
+
truncationClause('before', meta.truncatedBefore, meta.beforeCount),
|
|
414
|
+
truncationClause('after', meta.truncatedAfter, meta.afterCount),
|
|
415
|
+
].filter((part) => part !== null);
|
|
416
|
+
if (parts.length === 0)
|
|
417
|
+
return null;
|
|
418
|
+
return (`TRUNCATED: ${parts.join(', ')} — this diff compares a window of the page, not the whole ` +
|
|
419
|
+
'thing; content past a cap is not in that capture at all');
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* One side's clause of the not-measured caveat, or `null` when that side
|
|
423
|
+
* measured nothing worth disclosing. `formatNotMeasured` already returns
|
|
424
|
+
* `null` for "nothing to disclose" (`src/snapshot/not-measured.ts`'s own doc
|
|
425
|
+
* comment) — this only adds the side label, the same shape
|
|
426
|
+
* `truncationClause` uses above.
|
|
427
|
+
*/
|
|
428
|
+
function notMeasuredClause(side, notMeasured) {
|
|
429
|
+
if (!notMeasured)
|
|
430
|
+
return null;
|
|
431
|
+
const formatted = (0, not_measured_js_1.formatNotMeasured)(notMeasured);
|
|
432
|
+
return formatted ? `${side}: ${formatted}` : null;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* B7. `notMeasured` is measured by the capture layer, threaded all the way
|
|
436
|
+
* into `DiffMeta` (`diffCaptures` forwards `notMeasuredBefore`/
|
|
437
|
+
* `notMeasuredAfter` from each side's `Snapshot.notMeasured` unconditionally
|
|
438
|
+
* — see `index.ts`), and then rendered nowhere: `dnumb diff` never disclosed
|
|
439
|
+
* shadow DOM or iframe content in its text output, so an agent reading a
|
|
440
|
+
* diff had no way to learn that part of the page was invisible to the
|
|
441
|
+
* capture that produced it, even though the digest's own header
|
|
442
|
+
* (`digest/render.ts`) and every `q` verb already say so for a single
|
|
443
|
+
* capture. This is that same disclosure for a diff: a real change inside a
|
|
444
|
+
* closed shadow root or a cross-origin iframe on either side is not a
|
|
445
|
+
* silence this diff can vouch for, and the two sides need not agree — a
|
|
446
|
+
* `before` walk capture and an `after` CDP capture can disagree on what they
|
|
447
|
+
* could even see, independent of whatever the page actually did.
|
|
448
|
+
*/
|
|
449
|
+
function notMeasuredCaveat(meta) {
|
|
450
|
+
const parts = [
|
|
451
|
+
notMeasuredClause('before', meta.notMeasuredBefore),
|
|
452
|
+
notMeasuredClause('after', meta.notMeasuredAfter),
|
|
453
|
+
].filter((part) => part !== null);
|
|
454
|
+
if (parts.length === 0)
|
|
455
|
+
return null;
|
|
456
|
+
return (`NOT MEASURED: ${parts.join('; ')} — shadow DOM and iframe content neither capture path ` +
|
|
457
|
+
'could see is not reflected in this diff, on either side');
|
|
458
|
+
}
|
|
459
|
+
// ---- the diff --------------------------------------------------------------
|
|
460
|
+
function renderDiff(changes, meta, options = {}) {
|
|
461
|
+
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
|
462
|
+
const byBefore = indexRecords(changes, 'before');
|
|
463
|
+
const byAfter = indexRecords(changes, 'after');
|
|
464
|
+
const live = changes.filter((change) => change.subsumed === undefined);
|
|
465
|
+
const derived = live.filter(foldable);
|
|
466
|
+
const primary = live.filter((change) => !foldable(change));
|
|
467
|
+
// A change marked `derived` with nothing to point at still has to be printed
|
|
468
|
+
// -- a class toggling `open` is real, and dropping it would make a page that
|
|
469
|
+
// changed look like one that did not -- but it is context for the lines above
|
|
470
|
+
// it, not an act of its own. Sinking it below the primaries keeps the
|
|
471
|
+
// `cause` the subsumption pass assigned meaningful in the output, instead of
|
|
472
|
+
// it being a field nothing reads.
|
|
473
|
+
const rank = (change) => change.cause === 'derived' ? RANK.style + 1 : RANK[change.kind];
|
|
474
|
+
primary.sort((a, b) => rank(a) - rank(b) || documentOrder(a) - documentOrder(b));
|
|
475
|
+
const body = [];
|
|
476
|
+
const printed = [];
|
|
477
|
+
for (const change of primary) {
|
|
478
|
+
const line = lineFor(change, byBefore, byAfter);
|
|
479
|
+
if (line === null)
|
|
480
|
+
continue;
|
|
481
|
+
body.push(line);
|
|
482
|
+
printed.push(change);
|
|
483
|
+
}
|
|
484
|
+
body.push(...foldDerived(derived));
|
|
485
|
+
// A boundary-ambiguous change is counted separately, never as a confirmed
|
|
486
|
+
// add or remove — folding it into either count would restate in the header
|
|
487
|
+
// summary the exact fabrication its own line refuses to make.
|
|
488
|
+
const isAmbiguous = (change) => change.boundaryAmbiguous === true;
|
|
489
|
+
const count = (kinds) => printed.filter((change) => kinds.includes(change.kind) && !isAmbiguous(change)).length;
|
|
490
|
+
const added = count(['added', 'appeared']);
|
|
491
|
+
const removed = count(['removed', 'disappeared']);
|
|
492
|
+
const ambiguous = printed.filter(isAmbiguous).length;
|
|
493
|
+
// Every other number on this line — `added`, `removed`, `changed`,
|
|
494
|
+
// `derived` — counts `Change` records, not elements: a single element can
|
|
495
|
+
// contribute several (`box`, multiple `style` props, `text`) and each one
|
|
496
|
+
// is tallied separately, matching what `printed`/`derived` actually hold.
|
|
497
|
+
// `folded` keeps that same unit for consistency with its neighbours on
|
|
498
|
+
// this line. The per-line `+N inside` a body line carries (`inside()`
|
|
499
|
+
// below, sourced from `Change.descendants` in `changes.ts`) is
|
|
500
|
+
// deliberately the other unit — a count of *elements* folded under one
|
|
501
|
+
// parent, because "4 elements are inside this subtree" is the fact a
|
|
502
|
+
// reader wants there, not "4 elements produced 7 change records between
|
|
503
|
+
// them". The two numbers describe the same fold in different units on
|
|
504
|
+
// purpose; neither is a miscount of the other.
|
|
505
|
+
const folded = changes.filter((change) => change.subsumed !== undefined).length;
|
|
506
|
+
const caveat = truncationCaveat(meta);
|
|
507
|
+
const notMeasuredLine = notMeasuredCaveat(meta);
|
|
508
|
+
// `cdp` and `walk` do not see the same page on shadow DOM (`DigestMeta`
|
|
509
|
+
// carries the same fact in the digest header, for the same reason) — a
|
|
510
|
+
// diff across the two capability tiers can report hundreds of changes that
|
|
511
|
+
// are artefacts of which path captured, not of the page (F4). Shown only
|
|
512
|
+
// when both sides know their strategy; a hand-built `DiffMeta` need not.
|
|
513
|
+
const strategyLine = meta.strategyBefore && meta.strategyAfter
|
|
514
|
+
? ` ${meta.strategyBefore} → ${meta.strategyAfter}`
|
|
515
|
+
: '';
|
|
516
|
+
const header = [
|
|
517
|
+
`${meta.before} → ${meta.after} ${meta.viewportBefore} → ${meta.viewportAfter}${strategyLine}`,
|
|
518
|
+
`${added} added ${removed} removed ${printed.length - added - removed - ambiguous} changed ` +
|
|
519
|
+
`${derived.length} derived ${folded} folded` +
|
|
520
|
+
(ambiguous > 0 ? ` ${ambiguous} boundary-ambiguous` : ''),
|
|
521
|
+
...(caveat ? [caveat] : []),
|
|
522
|
+
...(notMeasuredLine ? [notMeasuredLine] : []),
|
|
523
|
+
];
|
|
524
|
+
let shown = body;
|
|
525
|
+
if (shown.length > maxLines) {
|
|
526
|
+
// The notice counts against the budget rather than sitting outside it: a cap
|
|
527
|
+
// the reader cannot see is indistinguishable from complete output, so the
|
|
528
|
+
// one line that says a cap was hit is never the line that gets cut.
|
|
529
|
+
const kept = Math.max(0, maxLines - 1);
|
|
530
|
+
// `--limit` is the CLI flag that actually sets `maxLines` (`dnumb diff
|
|
531
|
+
// --limit N`); this used to name a flag the CLI has never had
|
|
532
|
+
// (`--max-lines`), which fails with "Unknown option" if a reader tries it.
|
|
533
|
+
shown = [...body.slice(0, kept), `…+${body.length - kept} not listed (--limit)`];
|
|
534
|
+
}
|
|
535
|
+
// The fabrication control. Six readers saw this on a page where nothing moved
|
|
536
|
+
// and reported nothing; anything softer invites a reading of "probably fine".
|
|
537
|
+
if (shown.length === 0)
|
|
538
|
+
shown = ['(no observable change)'];
|
|
539
|
+
return `${header.join('\n')}\n\n${shown.join('\n')}\n`;
|
|
540
|
+
}
|