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