@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.
Files changed (133) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +297 -0
  4. package/dist/analysis/cluster.cjs +114 -0
  5. package/dist/analysis/cluster.d.cts +68 -0
  6. package/dist/analysis/cluster.d.ts +68 -0
  7. package/dist/analysis/cluster.js +109 -0
  8. package/dist/analysis/distances.cjs +124 -0
  9. package/dist/analysis/distances.d.cts +58 -0
  10. package/dist/analysis/distances.d.ts +58 -0
  11. package/dist/analysis/distances.js +119 -0
  12. package/dist/analysis/geometry.cjs +60 -0
  13. package/dist/analysis/geometry.d.cts +34 -0
  14. package/dist/analysis/geometry.d.ts +34 -0
  15. package/dist/analysis/geometry.js +53 -0
  16. package/dist/analysis/layout-tree.cjs +245 -0
  17. package/dist/analysis/layout-tree.d.cts +45 -0
  18. package/dist/analysis/layout-tree.d.ts +45 -0
  19. package/dist/analysis/layout-tree.js +242 -0
  20. package/dist/analysis/region-query.cjs +71 -0
  21. package/dist/analysis/region-query.d.cts +25 -0
  22. package/dist/analysis/region-query.d.ts +25 -0
  23. package/dist/analysis/region-query.js +67 -0
  24. package/dist/analysis/types.cjs +7 -0
  25. package/dist/analysis/types.d.cts +143 -0
  26. package/dist/analysis/types.d.ts +143 -0
  27. package/dist/analysis/types.js +6 -0
  28. package/dist/bundle.cjs +182 -0
  29. package/dist/bundle.d.cts +91 -0
  30. package/dist/bundle.d.ts +91 -0
  31. package/dist/bundle.js +175 -0
  32. package/dist/cli.cjs +755 -0
  33. package/dist/cli.d.cts +2 -0
  34. package/dist/cli.d.ts +2 -0
  35. package/dist/cli.js +719 -0
  36. package/dist/diff/changes.cjs +656 -0
  37. package/dist/diff/changes.d.cts +2 -0
  38. package/dist/diff/changes.d.ts +2 -0
  39. package/dist/diff/changes.js +653 -0
  40. package/dist/diff/identity.cjs +167 -0
  41. package/dist/diff/identity.d.cts +62 -0
  42. package/dist/diff/identity.d.ts +62 -0
  43. package/dist/diff/identity.js +159 -0
  44. package/dist/diff/index.cjs +84 -0
  45. package/dist/diff/index.d.cts +76 -0
  46. package/dist/diff/index.d.ts +76 -0
  47. package/dist/diff/index.js +68 -0
  48. package/dist/diff/match.cjs +156 -0
  49. package/dist/diff/match.d.cts +17 -0
  50. package/dist/diff/match.d.ts +17 -0
  51. package/dist/diff/match.js +152 -0
  52. package/dist/diff/render.cjs +540 -0
  53. package/dist/diff/render.d.cts +2 -0
  54. package/dist/diff/render.d.ts +2 -0
  55. package/dist/diff/render.js +537 -0
  56. package/dist/diff/types.cjs +2 -0
  57. package/dist/diff/types.d.cts +211 -0
  58. package/dist/diff/types.d.ts +211 -0
  59. package/dist/diff/types.js +1 -0
  60. package/dist/digest/index.cjs +8 -0
  61. package/dist/digest/index.d.cts +4 -0
  62. package/dist/digest/index.d.ts +4 -0
  63. package/dist/digest/index.js +2 -0
  64. package/dist/digest/parse.cjs +163 -0
  65. package/dist/digest/parse.d.cts +80 -0
  66. package/dist/digest/parse.d.ts +80 -0
  67. package/dist/digest/parse.js +160 -0
  68. package/dist/digest/render.cjs +1575 -0
  69. package/dist/digest/render.d.cts +41 -0
  70. package/dist/digest/render.d.ts +41 -0
  71. package/dist/digest/render.js +1571 -0
  72. package/dist/digest/types.cjs +10 -0
  73. package/dist/digest/types.d.cts +86 -0
  74. package/dist/digest/types.d.ts +86 -0
  75. package/dist/digest/types.js +9 -0
  76. package/dist/index.cjs +61 -0
  77. package/dist/index.d.cts +27 -0
  78. package/dist/index.d.ts +27 -0
  79. package/dist/index.js +19 -0
  80. package/dist/page.cjs +35 -0
  81. package/dist/page.d.cts +40 -0
  82. package/dist/page.d.ts +40 -0
  83. package/dist/page.js +31 -0
  84. package/dist/playwright.cjs +138 -0
  85. package/dist/playwright.d.cts +69 -0
  86. package/dist/playwright.d.ts +69 -0
  87. package/dist/playwright.js +131 -0
  88. package/dist/query.cjs +1152 -0
  89. package/dist/query.d.cts +47 -0
  90. package/dist/query.d.ts +47 -0
  91. package/dist/query.js +1146 -0
  92. package/dist/snapshot/capture.cjs +80 -0
  93. package/dist/snapshot/capture.d.cts +34 -0
  94. package/dist/snapshot/capture.d.ts +34 -0
  95. package/dist/snapshot/capture.js +77 -0
  96. package/dist/snapshot/cdp.cjs +450 -0
  97. package/dist/snapshot/cdp.d.cts +36 -0
  98. package/dist/snapshot/cdp.d.ts +36 -0
  99. package/dist/snapshot/cdp.js +447 -0
  100. package/dist/snapshot/clip.cjs +100 -0
  101. package/dist/snapshot/clip.d.cts +26 -0
  102. package/dist/snapshot/clip.d.ts +26 -0
  103. package/dist/snapshot/clip.js +96 -0
  104. package/dist/snapshot/collect.cjs +358 -0
  105. package/dist/snapshot/collect.d.cts +18 -0
  106. package/dist/snapshot/collect.d.ts +18 -0
  107. package/dist/snapshot/collect.js +355 -0
  108. package/dist/snapshot/color.cjs +1039 -0
  109. package/dist/snapshot/color.d.cts +316 -0
  110. package/dist/snapshot/color.d.ts +316 -0
  111. package/dist/snapshot/color.js +1026 -0
  112. package/dist/snapshot/facts.cjs +167 -0
  113. package/dist/snapshot/facts.d.cts +48 -0
  114. package/dist/snapshot/facts.d.ts +48 -0
  115. package/dist/snapshot/facts.js +161 -0
  116. package/dist/snapshot/limits.cjs +69 -0
  117. package/dist/snapshot/limits.d.cts +59 -0
  118. package/dist/snapshot/limits.d.ts +59 -0
  119. package/dist/snapshot/limits.js +65 -0
  120. package/dist/snapshot/not-measured.cjs +44 -0
  121. package/dist/snapshot/not-measured.d.cts +14 -0
  122. package/dist/snapshot/not-measured.d.ts +14 -0
  123. package/dist/snapshot/not-measured.js +41 -0
  124. package/dist/snapshot/types.cjs +2 -0
  125. package/dist/snapshot/types.d.cts +238 -0
  126. package/dist/snapshot/types.d.ts +238 -0
  127. package/dist/snapshot/types.js +1 -0
  128. package/dist/spec/index.cjs +9 -0
  129. package/dist/spec/index.d.cts +15 -0
  130. package/dist/spec/index.d.ts +15 -0
  131. package/dist/spec/index.js +6 -0
  132. package/package.json +141 -0
  133. 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
+ }
@@ -0,0 +1,2 @@
1
+ import type { Change, DiffMeta, DiffOptions } from "./types.cjs";
2
+ export declare function renderDiff(changes: readonly Change[], meta: DiffMeta, options?: DiffOptions): string;
@@ -0,0 +1,2 @@
1
+ import type { Change, DiffMeta, DiffOptions } from "./types.js";
2
+ export declare function renderDiff(changes: readonly Change[], meta: DiffMeta, options?: DiffOptions): string;