@dogsbay/tree-diff 0.0.0-stage → 0.2.0-beta.131

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/README.md CHANGED
@@ -1,3 +1,44 @@
1
- # Temporary Holding Version
1
+ # @dogsbay/tree-diff
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `diffTrees(oldTree, newTree)` — identity-driven version diffing over
4
+ `TreeNode[]`, producing one **DiffTree** that every view renders:
5
+ comparison pages (side-by-side or merged), PR review, the
6
+ version-switcher compare mode, the IDE's change review, and
7
+ `@dogsbay/render`'s `compareWith` (the artifact viewer's Changes view).
8
+
9
+ ```ts
10
+ import { diffTrees } from "@dogsbay/tree-diff";
11
+ import { dogsbayMdToTree } from "@dogsbay/format-dogsbay-md";
12
+
13
+ const oldTree = dogsbayMdToTree(oldMarkdown).tree;
14
+ const newTree = dogsbayMdToTree(newMarkdown).tree;
15
+ const { tree, stats, unchanged } = diffTrees(oldTree, newTree);
16
+ ```
17
+
18
+ The output is the union tree in new-side order (removed content
19
+ spliced at its old anchors). Nodes carry optional marks a
20
+ mark-unaware renderer simply ignores:
21
+
22
+ | Field | Meaning |
23
+ |---|---|
24
+ | `diff` | `added` / `removed` / `changed` / `moved` — set on subtree roots for added/removed |
25
+ | `diffInline` | merged word-level `eq/ins/del` segments on changed prose (render all = merged view; filter by op = either side) |
26
+ | `diffPeer` | the other side's node for changed pairs (code samples, table rows) |
27
+ | `diffMovedFrom` | old heading path for moved sections |
28
+
29
+ Identity drives matching: sections by heading **path** (which gives
30
+ `## POST /path` endpoint sections `(method, path)` identity for
31
+ free), code samples by `(language, title, occurrence)`, table rows by
32
+ first cell, containers/lists by `(type, occurrence)` with recursive
33
+ children, prose by position + token similarity. Relocated sections
34
+ are **moves**, never delete+add — the reorg-vs-removal discipline,
35
+ enforced in the renderer layer.
36
+
37
+ Golden reference: the real better-auth `plugins/2fa.md` across the
38
+ hand-reviewed v1.5.6 → v1.6.23 transition (fixtures in `tests/`),
39
+ including the known `password` required→optional drift surfacing as
40
+ word-level marks.
41
+
42
+ ## License
43
+
44
+ MIT
package/dist/diff.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * diffTrees — identity-driven diff over two parsed pages
3
+ * (plans/tree-diff-rendering.md §1).
4
+ *
5
+ * The parsed page is FLAT (headings are siblings of content), so the
6
+ * differ first sectionizes both sides by heading level, then matches:
7
+ *
8
+ * sections → heading PATH (text within parent — not text alone:
9
+ * the Addendum-7 lesson; `## POST /path` endpoint
10
+ * sections get (method, path) identity for free)
11
+ * moves → unmatched removed+added sections with equal heading
12
+ * text or equal content — marked, never delete+add
13
+ * (the reorg-vs-removal discipline, in the renderer)
14
+ * code → (language, title, occurrence) within the section —
15
+ * the chunk-identity fields whose page/headingPath
16
+ * components are equal inside a matched section
17
+ * containers → (type, occurrence); children recurse as block lists
18
+ * table rows → first-cell text within the matched table
19
+ * prose → position + token-similarity fallback, with
20
+ * word-level ins/del segments on changed nodes
21
+ *
22
+ * Output is the union tree in NEW-side order with removed content
23
+ * spliced at its old anchor; marks are `diff` / `diffInline` /
24
+ * `diffPeer` / `diffMovedFrom` fields a mark-unaware renderer simply
25
+ * ignores.
26
+ */
27
+ import type { TreeNode } from "@dogsbay/types";
28
+ import type { DiffResult } from "./types.js";
29
+ interface Section {
30
+ /** Heading texts from the page root to this section. */
31
+ path: string[];
32
+ heading: TreeNode | null;
33
+ level: number;
34
+ blocks: TreeNode[];
35
+ children: Section[];
36
+ /** Set when an added section elsewhere claims this one as a MOVE. */
37
+ claimedAsMove?: boolean;
38
+ }
39
+ /** Group a flat page into a section tree by heading level. */
40
+ export declare function sectionize(nodes: TreeNode[]): Section;
41
+ /**
42
+ * Diff two parsed pages. `oldTree`/`newTree` are page bodies as any
43
+ * dogsbay format loader produces them (flat heading structure).
44
+ */
45
+ export declare function diffTrees(oldTree: TreeNode[], newTree: TreeNode[]): DiffResult;
46
+ export {};
package/dist/diff.js ADDED
@@ -0,0 +1,556 @@
1
+ import { decodeNumericEntities, inlineText, nodeText, similarity, wordDiff } from "./text.js";
2
+ /** Prose blocks whose change renders as word-level inline marks. */
3
+ const PROSE_TYPES = new Set(["paragraph", "blockquote", "list-item", "li"]);
4
+ /** Containers whose children are matched recursively as block lists. */
5
+ const CONTAINER_TYPES = new Set([
6
+ "callout",
7
+ "tabs",
8
+ "tab",
9
+ "steps",
10
+ "step",
11
+ "details",
12
+ "blockquote",
13
+ // lists recurse so their ITEMS prose-diff individually — the
14
+ // endpoint request-body rows (`- \`password\` … — required`) are
15
+ // list items, and the required→optional drift must surface as
16
+ // word-level marks, not an opaque changed list.
17
+ "unordered-list",
18
+ "ordered-list",
19
+ ]);
20
+ const SIMILARITY_THRESHOLD = 0.5;
21
+ function headingText(node) {
22
+ return nodeText(node);
23
+ }
24
+ /** Group a flat page into a section tree by heading level. */
25
+ export function sectionize(nodes) {
26
+ const root = { path: [], heading: null, level: 0, blocks: [], children: [] };
27
+ const stack = [root];
28
+ for (const node of nodes) {
29
+ if (node.type === "heading") {
30
+ const level = Number(node.props?.level ?? 1);
31
+ while (stack.length > 1 && stack[stack.length - 1].level >= level)
32
+ stack.pop();
33
+ const parent = stack[stack.length - 1];
34
+ const section = {
35
+ path: [...parent.path, headingText(node)],
36
+ heading: node,
37
+ level,
38
+ blocks: [],
39
+ children: [],
40
+ };
41
+ parent.children.push(section);
42
+ stack.push(section);
43
+ }
44
+ else {
45
+ stack[stack.length - 1].blocks.push(node);
46
+ }
47
+ }
48
+ return root;
49
+ }
50
+ /**
51
+ * Whether two nodes have the same content. A node's source line (`meta.line`, from a parser's `sourceLines`)
52
+ * says where it is, not what it is: compared, every block below an edit would show as changed.
53
+ */
54
+ const withoutLines = (key, value) => {
55
+ if (key !== "meta" || !value || typeof value !== "object" || !("line" in value))
56
+ return value;
57
+ const { line: _line, ...rest } = value;
58
+ return Object.keys(rest).length ? rest : undefined;
59
+ };
60
+ const sameContent = (a, b) => JSON.stringify(a, withoutLines) === JSON.stringify(b, withoutLines);
61
+ /** Stable content fingerprint of a section (its own blocks + subtree). */
62
+ function sectionFingerprint(s) {
63
+ const parts = [s.blocks.map(nodeText).join("")];
64
+ for (const c of s.children)
65
+ parts.push(c.path[c.path.length - 1] ?? "", sectionFingerprint(c));
66
+ return parts.join("");
67
+ }
68
+ const mark = (node, diff, extra) => ({
69
+ ...node,
70
+ diff,
71
+ ...extra,
72
+ });
73
+ /** Deep: does this node or any descendant carry a diff mark? */
74
+ const hasAnyMark = (node) => Boolean(node.diff || node.diffInline) ||
75
+ (node.children ?? []).some(hasAnyMark);
76
+ /** Containers whose content is hidden until interacted with. */
77
+ const HIDDEN_CONTAINERS = new Set(["tabs", "tab", "details"]);
78
+ /**
79
+ * Callout interior: drop the block bar where it merely duplicates
80
+ * word-level marks (the container's own mark covers the box); KEEP
81
+ * code marks — they drive before/after pair rendering, which is
82
+ * content, not a bar.
83
+ */
84
+ const stripBlockMarks = (node) => {
85
+ const keep = node.type === "code" || !node.diffInline;
86
+ const out = {
87
+ ...node,
88
+ ...(node.children ? { children: node.children.map(stripBlockMarks) } : {}),
89
+ };
90
+ if (!keep && out.diff === "changed")
91
+ delete out.diff;
92
+ return out;
93
+ };
94
+ class Differ {
95
+ stats = {
96
+ sectionsAdded: 0,
97
+ sectionsRemoved: 0,
98
+ sectionsChanged: 0,
99
+ sectionsMoved: 0,
100
+ blocksChanged: 0,
101
+ };
102
+ /** Removed old-side sections, indexed for move detection. */
103
+ removedByFingerprint = new Map();
104
+ /** heading text → ALL removed sections with that heading (no last-writer-wins). */
105
+ removedByHeading = new Map();
106
+ /** added new-side section → the old-side section it relocates. */
107
+ moveClaims = new Map();
108
+ diff(oldTree, newTree) {
109
+ const oldRoot = sectionize(oldTree);
110
+ const newRoot = sectionize(newTree);
111
+ this.indexRemoved(oldRoot, newRoot);
112
+ this.claimMoves(oldRoot, newRoot);
113
+ return this.diffSectionInto(oldRoot, newRoot);
114
+ }
115
+ /**
116
+ * Sibling keys are occurrence-disambiguated (`name#n`, mirroring
117
+ * blockKeys) — duplicate heading texts among siblings must not
118
+ * collapse in a Map (a page diffed against itself must be
119
+ * unchanged).
120
+ */
121
+ childKeys(sec) {
122
+ const seen = new Map();
123
+ const out = new Map();
124
+ for (const c of sec.children) {
125
+ const name = c.path[c.path.length - 1] ?? "";
126
+ const n = seen.get(name) ?? 0;
127
+ seen.set(name, n + 1);
128
+ out.set(`${name}#${n}`, c);
129
+ }
130
+ return out;
131
+ }
132
+ /**
133
+ * Pre-pass 2: added new-side sections claim removed old-side
134
+ * sections as MOVES — globally, before any emission, so a parent's
135
+ * removed loop can't emit a section that relocates deeper in the
136
+ * tree. Guards (review findings 1–2):
137
+ * - claims require identical content (fingerprint) OR same heading
138
+ * text WITH similar content — a coincidental name never claims;
139
+ * - claims never recurse INTO an unclaimed added subtree, because
140
+ * emitSection renders added subtrees whole and a claim there
141
+ * would silently swallow the removed section's content.
142
+ */
143
+ claimMoves(oldSec, newSec) {
144
+ const oldKeys = this.childKeys(oldSec);
145
+ const newKeys = this.childKeys(newSec);
146
+ for (const [key, newChild] of newKeys) {
147
+ const match = oldKeys.get(key);
148
+ if (match) {
149
+ this.claimMoves(match, newChild);
150
+ continue;
151
+ }
152
+ const name = newChild.path[newChild.path.length - 1] ?? "";
153
+ const fp = sectionFingerprint(newChild);
154
+ let moved = this.removedByFingerprint.get(fp);
155
+ if (moved?.claimedAsMove)
156
+ moved = undefined;
157
+ if (!moved) {
158
+ // same heading text: pick the best UNCLAIMED candidate whose
159
+ // content is actually similar
160
+ let best;
161
+ let bestScore = 0;
162
+ for (const cand of this.removedByHeading.get(name) ?? []) {
163
+ if (cand.claimedAsMove)
164
+ continue;
165
+ const score = similarity(sectionFingerprint(cand), fp);
166
+ if (score > bestScore) {
167
+ best = cand;
168
+ bestScore = score;
169
+ }
170
+ }
171
+ if (best && bestScore >= SIMILARITY_THRESHOLD)
172
+ moved = best;
173
+ }
174
+ if (moved) {
175
+ moved.claimedAsMove = true;
176
+ this.moveClaims.set(newChild, moved);
177
+ // nested claims are safe within a claimed (rendered) pair
178
+ this.claimMoves(moved, newChild);
179
+ }
180
+ // unclaimed added subtree: do NOT recurse — see guard note above
181
+ }
182
+ }
183
+ /**
184
+ * Pre-pass: find sections present only on the old side and index
185
+ * them, so additions elsewhere can claim them as MOVES.
186
+ */
187
+ indexRemoved(oldSec, newSec) {
188
+ const oldKeys = this.childKeys(oldSec);
189
+ const newKeys = this.childKeys(newSec);
190
+ for (const [key, oldChild] of oldKeys) {
191
+ const match = newKeys.get(key);
192
+ if (match)
193
+ this.indexRemoved(oldChild, match);
194
+ else {
195
+ const name = oldChild.path[oldChild.path.length - 1] ?? "";
196
+ this.removedByFingerprint.set(sectionFingerprint(oldChild), oldChild);
197
+ const list = this.removedByHeading.get(name) ?? [];
198
+ list.push(oldChild);
199
+ this.removedByHeading.set(name, list);
200
+ }
201
+ }
202
+ }
203
+ /** Union of one matched section pair, marks applied. */
204
+ diffSectionInto(oldSec, newSec) {
205
+ const out = [];
206
+ const blocksBefore = this.stats.blocksChanged;
207
+ out.push(...this.diffBlocks(oldSec.blocks, newSec.blocks));
208
+ // Captured BEFORE child recursion: sectionsChanged counts the
209
+ // section whose OWN blocks changed — once, at the deepest level,
210
+ // not once per ancestor (review finding 5). Structure events
211
+ // (child add/remove/move) have their own stats.
212
+ if (this.stats.blocksChanged !== blocksBefore)
213
+ this.stats.sectionsChanged++;
214
+ const oldKeys = this.childKeys(oldSec);
215
+ const newKeys = this.childKeys(newSec);
216
+ // removed child sections splice in old-side order after the blocks
217
+ // of the section they lived in.
218
+ for (const [key, oldChild] of oldKeys) {
219
+ if (newKeys.has(key))
220
+ continue;
221
+ if (oldChild.claimedAsMove)
222
+ continue; // rendered at its new home
223
+ this.stats.sectionsRemoved++;
224
+ out.push(...this.emitSection(oldChild, "removed"));
225
+ }
226
+ for (const [key, newChild] of newKeys) {
227
+ const oldChild = oldKeys.get(key);
228
+ if (oldChild) {
229
+ const body = this.diffSectionInto(oldChild, newChild);
230
+ // render-trail mark on the heading when ANYTHING inside changed
231
+ const changedInside = body.some(hasAnyMark);
232
+ if (newChild.heading) {
233
+ out.push(changedInside ? mark(newChild.heading, "changed") : { ...newChild.heading });
234
+ }
235
+ out.push(...body);
236
+ }
237
+ else {
238
+ const moved = this.moveClaims.get(newChild);
239
+ if (moved) {
240
+ this.stats.sectionsMoved++;
241
+ if (newChild.heading) {
242
+ out.push(mark(newChild.heading, "moved", { diffMovedFrom: moved.path }));
243
+ }
244
+ // content may ALSO have changed across the move
245
+ out.push(...this.diffSectionInto(moved, newChild));
246
+ }
247
+ else {
248
+ this.stats.sectionsAdded++;
249
+ out.push(...this.emitSection(newChild, "added"));
250
+ }
251
+ }
252
+ }
253
+ return out;
254
+ }
255
+ /** Emit a whole section subtree under one mark (root-marked). */
256
+ emitSection(sec, diff) {
257
+ const out = [];
258
+ if (sec.heading)
259
+ out.push(mark(sec.heading, diff));
260
+ for (const block of sec.blocks)
261
+ out.push(mark(block, diff));
262
+ for (const child of sec.children)
263
+ out.push(...this.emitSection(child, diff));
264
+ return out;
265
+ }
266
+ /**
267
+ * Merge consecutive raw-HTML siblings into one block on BOTH sides
268
+ * before matching. Markdown parsers split one HTML structure (a
269
+ * hand-written table) into fragments at internal blank lines, and
270
+ * a blank line added upstream would otherwise shard the diff into
271
+ * "1 changed + N added" pieces of the same table (found via
272
+ * better-auth adapters/mysql). Joining with the original separator
273
+ * renders identically.
274
+ */
275
+ coalesceHtml(blocks) {
276
+ const out = [];
277
+ for (const b of blocks) {
278
+ const prev = out[out.length - 1];
279
+ if (b.type === "html" && prev?.type === "html") {
280
+ out[out.length - 1] = { ...prev, html: `${prev.html ?? ""}\n${b.html ?? ""}` };
281
+ }
282
+ else {
283
+ out.push(b);
284
+ }
285
+ }
286
+ return out;
287
+ }
288
+ /** Match the non-heading blocks of one matched section pair. */
289
+ diffBlocks(oldBlocksIn, newBlocksIn) {
290
+ const oldBlocks = this.coalesceHtml(oldBlocksIn);
291
+ const newBlocks = this.coalesceHtml(newBlocksIn);
292
+ const out = [];
293
+ const usedOld = new Set();
294
+ // identity pass: code by (language, title, occurrence); containers
295
+ // and tables by (type, occurrence)
296
+ const oldKey = this.blockKeys(oldBlocks);
297
+ const newKey = this.blockKeys(newBlocks);
298
+ const oldByKey = new Map();
299
+ oldKey.forEach((k, i) => {
300
+ if (k && !oldByKey.has(k))
301
+ oldByKey.set(k, i);
302
+ });
303
+ const matchedPairs = new Map(); // new index → old index
304
+ newKey.forEach((k, j) => {
305
+ if (!k)
306
+ return;
307
+ const i = oldByKey.get(k);
308
+ if (i !== undefined && !usedOld.has(i)) {
309
+ matchedPairs.set(j, i);
310
+ usedOld.add(i);
311
+ }
312
+ });
313
+ // positional prose pass over the leftovers, in order (texts cached
314
+ // once per block — nodeText recurses whole subtrees)
315
+ const oldText = oldBlocks.map((b) => nodeText(b));
316
+ const newText = newBlocks.map((b) => nodeText(b));
317
+ const oldLeft = oldBlocks.map((_, i) => i).filter((i) => !usedOld.has(i) && !oldKey[i]);
318
+ const newLeft = newBlocks.map((_, j) => j).filter((j) => !matchedPairs.has(j) && !newKey[j]);
319
+ let ol = 0;
320
+ for (const j of newLeft) {
321
+ // find the next sufficiently-similar old block at or after ol
322
+ for (let k = ol; k < oldLeft.length; k++) {
323
+ const i = oldLeft[k];
324
+ if (oldBlocks[i].type === newBlocks[j].type &&
325
+ similarity(oldText[i], newText[j]) >= SIMILARITY_THRESHOLD) {
326
+ matchedPairs.set(j, i);
327
+ usedOld.add(i);
328
+ // removed blocks skipped over are emitted at this anchor
329
+ ol = k + 1;
330
+ break;
331
+ }
332
+ }
333
+ }
334
+ // emit: new order, removed old blocks spliced before their next
335
+ // matched old sibling's pair
336
+ const removedBefore = new Map(); // old index → removed nodes before it
337
+ let pending = [];
338
+ for (let i = 0; i < oldBlocks.length; i++) {
339
+ if (usedOld.has(i)) {
340
+ if (pending.length)
341
+ removedBefore.set(i, pending);
342
+ pending = [];
343
+ }
344
+ else {
345
+ pending.push(oldBlocks[i]);
346
+ }
347
+ }
348
+ const removedAtEnd = pending;
349
+ for (let j = 0; j < newBlocks.length; j++) {
350
+ const i = matchedPairs.get(j);
351
+ if (i !== undefined) {
352
+ for (const r of removedBefore.get(i) ?? []) {
353
+ this.stats.blocksChanged++;
354
+ out.push(mark(r, "removed"));
355
+ }
356
+ out.push(this.diffMatchedBlock(oldBlocks[i], newBlocks[j]));
357
+ }
358
+ else {
359
+ this.stats.blocksChanged++;
360
+ out.push(mark(newBlocks[j], "added"));
361
+ }
362
+ }
363
+ for (const r of removedAtEnd) {
364
+ this.stats.blocksChanged++;
365
+ out.push(mark(r, "removed"));
366
+ }
367
+ return out;
368
+ }
369
+ /** Identity keys per block; empty string = positional/prose block. */
370
+ blockKeys(blocks) {
371
+ const seen = new Map();
372
+ return blocks.map((b) => {
373
+ let base = "";
374
+ if (b.type === "code") {
375
+ base = `code${b.props?.language ?? ""}${b.props?.title ?? ""}`;
376
+ }
377
+ else if (b.type === "table") {
378
+ base = "table";
379
+ }
380
+ else if (CONTAINER_TYPES.has(b.type)) {
381
+ base = `container${b.type}${b.props?.type ?? ""}`;
382
+ }
383
+ else {
384
+ return "";
385
+ }
386
+ const n = seen.get(base) ?? 0;
387
+ seen.set(base, n + 1);
388
+ return `${base}#${n}`;
389
+ });
390
+ }
391
+ /** Same-identity pair: unchanged, or changed with the right marks. */
392
+ diffMatchedBlock(oldB, newB) {
393
+ if (sameContent(oldB, newB))
394
+ return { ...newB };
395
+ if (newB.type === "code") {
396
+ this.stats.blocksChanged++;
397
+ return mark(newB, "changed", { diffPeer: oldB });
398
+ }
399
+ if (newB.type === "table") {
400
+ return this.diffTable(oldB, newB);
401
+ }
402
+ if (CONTAINER_TYPES.has(newB.type)) {
403
+ const children = this.diffBlocks(oldB.children ?? [], newB.children ?? []);
404
+ const propsChanged = JSON.stringify(oldB.props ?? {}) !== JSON.stringify(newB.props ?? {});
405
+ const childChanged = children.some(hasAnyMark);
406
+ // A container that carries its OWN prose (a `step` keeps its text in
407
+ // `inline`, children usually empty) must diff that text too — otherwise
408
+ // an edit to a step is a silent false negative (review 2026-08-18).
409
+ const ownOld = oldB.inline ? inlineText(oldB.inline) : "";
410
+ const ownNew = newB.inline ? inlineText(newB.inline) : "";
411
+ if (ownOld !== ownNew) {
412
+ this.stats.blocksChanged++;
413
+ const segments = wordDiff(ownOld, ownNew);
414
+ return { ...newB, diff: "changed", ...(segments ? { diffInline: segments } : {}), diffPeer: oldB, children };
415
+ }
416
+ // Visibility-aware container marking (user feedback 2026-07-13):
417
+ // - HIDDEN-content containers (tabs, details): an inner change
418
+ // is invisible until opened — the container MUST carry the
419
+ // mark so the closed state signals it; inner marks stay for
420
+ // when it opens.
421
+ // - Small bounded boxes (callout): one outer mark reads
422
+ // cleaner than bars inside a colored box — mark the
423
+ // container, strip inner BLOCK bars, keep word-level marks.
424
+ // - Always-visible sequences (lists, steps, blockquote): the
425
+ // item marks ARE the diff; a container bar spanning a long
426
+ // list is noise. Container marks only for its own props.
427
+ if (HIDDEN_CONTAINERS.has(newB.type) && (childChanged || propsChanged)) {
428
+ this.stats.blocksChanged++;
429
+ return { ...newB, diff: "changed", children };
430
+ }
431
+ if (newB.type === "callout" && (childChanged || propsChanged)) {
432
+ this.stats.blocksChanged++;
433
+ return {
434
+ ...newB,
435
+ diff: "changed",
436
+ children: children.map(stripBlockMarks),
437
+ };
438
+ }
439
+ if (propsChanged) {
440
+ this.stats.blocksChanged++;
441
+ return { ...newB, diff: "changed", children };
442
+ }
443
+ return { ...newB, children };
444
+ }
445
+ if (newB.type === "html") {
446
+ // Render-equivalent markup is NOT a change: entity-form flips
447
+ // (&#9989; ↔ ✅) and whitespace-run differences (HTML collapses
448
+ // them outside <pre>). Anything else is opaque markup: block
449
+ // mark + peer.
450
+ const comparable = (s) => {
451
+ const decoded = decodeNumericEntities(s);
452
+ return decoded.includes("<pre") ? decoded : decoded.replace(/\s+/g, " ").trim();
453
+ };
454
+ if (comparable(String(oldB.html ?? "")) === comparable(String(newB.html ?? ""))) {
455
+ return { ...newB };
456
+ }
457
+ this.stats.blocksChanged++;
458
+ return mark(newB, "changed", { diffPeer: oldB });
459
+ }
460
+ if (PROSE_TYPES.has(newB.type) || newB.inline) {
461
+ this.stats.blocksChanged++;
462
+ // wordDiff returns null over its size cap — fall back to a plain
463
+ // changed mark with diffPeer (views render before/after blocks)
464
+ const segments = wordDiff(nodeText(oldB), nodeText(newB));
465
+ return mark(newB, "changed", {
466
+ ...(segments ? { diffInline: segments } : {}),
467
+ diffPeer: oldB,
468
+ });
469
+ }
470
+ this.stats.blocksChanged++;
471
+ return mark(newB, "changed", { diffPeer: oldB });
472
+ }
473
+ /**
474
+ * Row-level table diff. Rows are keyed by section (thead/tbody) +
475
+ * first-cell text + occurrence, so duplicate first cells never
476
+ * collapse (a deleted duplicate row MUST surface — silent
477
+ * false-negatives are the worst failure class here).
478
+ */
479
+ diffTable(oldT, newT) {
480
+ const keyRows = (t) => {
481
+ const seen = new Map();
482
+ const out = new Map();
483
+ for (const part of t.children ?? []) {
484
+ if (part.type !== "tbody" && part.type !== "thead")
485
+ continue;
486
+ for (const tr of part.children ?? []) {
487
+ const base = `${part.type}:${nodeText((tr.children ?? [])[0] ?? tr)}`;
488
+ const n = seen.get(base) ?? 0;
489
+ seen.set(base, n + 1);
490
+ out.set(`${base}#${n}`, tr);
491
+ }
492
+ }
493
+ return out;
494
+ };
495
+ const oldRows = keyRows(oldT);
496
+ const newRowKeys = keyRows(newT);
497
+ const keyOfNew = new Map();
498
+ for (const [k, tr] of newRowKeys)
499
+ keyOfNew.set(tr, k);
500
+ let changed = false;
501
+ const mapPart = (part) => {
502
+ if (part.type !== "tbody" && part.type !== "thead")
503
+ return { ...part };
504
+ const children = (part.children ?? []).map((tr) => {
505
+ const key = keyOfNew.get(tr);
506
+ const old = oldRows.get(key);
507
+ if (!old) {
508
+ changed = true;
509
+ return mark(tr, "added");
510
+ }
511
+ oldRows.delete(key);
512
+ if (!sameContent(old, tr)) {
513
+ changed = true;
514
+ return mark(tr, "changed", { diffPeer: old });
515
+ }
516
+ return { ...tr };
517
+ });
518
+ return { ...part, children };
519
+ };
520
+ let parts = (newT.children ?? []).map(mapPart);
521
+ // rows that vanished (keys left in oldRows) render as removed —
522
+ // in the last tbody, or a synthesized one when the new table has
523
+ // none (a removed row must never be dropped).
524
+ const leftover = [...oldRows.values()];
525
+ if (leftover.length) {
526
+ changed = true;
527
+ let lastBody = [...parts].reverse().find((p) => p.type === "tbody");
528
+ if (!lastBody) {
529
+ lastBody = { type: "tbody", children: [] };
530
+ parts = [...parts, lastBody];
531
+ }
532
+ lastBody.children = [
533
+ ...(lastBody.children ?? []),
534
+ ...leftover.map((r) => mark(r, "removed")),
535
+ ];
536
+ }
537
+ if (changed)
538
+ this.stats.blocksChanged++;
539
+ return changed ? { ...newT, diff: "changed", children: parts } : { ...newT, children: parts };
540
+ }
541
+ }
542
+ /**
543
+ * Diff two parsed pages. `oldTree`/`newTree` are page bodies as any
544
+ * dogsbay format loader produces them (flat heading structure).
545
+ */
546
+ export function diffTrees(oldTree, newTree) {
547
+ const differ = new Differ();
548
+ const tree = differ.diff(oldTree, newTree);
549
+ const stats = differ.stats;
550
+ const unchanged = stats.sectionsAdded === 0 &&
551
+ stats.sectionsRemoved === 0 &&
552
+ stats.sectionsChanged === 0 &&
553
+ stats.sectionsMoved === 0 &&
554
+ stats.blocksChanged === 0;
555
+ return { tree, stats, unchanged };
556
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @dogsbay/tree-diff — version diffing as a core renderer capability.
3
+ *
4
+ * `diffTrees(oldTree, newTree)` produces one DiffTree that every view
5
+ * renders: comparison pages (side-by-side or merged), PR review, the
6
+ * version-switcher compare mode, and the IDE's change review.
7
+ * See plans/tree-diff-rendering.md.
8
+ */
9
+ export { diffTrees, sectionize } from "./diff.js";
10
+ export { foldUnchangedSections, type FoldResult } from "./views.js";
11
+ export { wordDiff, similarity, nodeText, inlineText } from "./text.js";
12
+ export type { DiffKind, DiffNode, DiffResult, DiffStats, InlineDiffSegment, } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @dogsbay/tree-diff — version diffing as a core renderer capability.
3
+ *
4
+ * `diffTrees(oldTree, newTree)` produces one DiffTree that every view
5
+ * renders: comparison pages (side-by-side or merged), PR review, the
6
+ * version-switcher compare mode, and the IDE's change review.
7
+ * See plans/tree-diff-rendering.md.
8
+ */
9
+ export { diffTrees, sectionize } from "./diff.js";
10
+ export { foldUnchangedSections } from "./views.js";
11
+ export { wordDiff, similarity, nodeText, inlineText } from "./text.js";
package/dist/text.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Text utilities: plain-text extraction from TreeNode/inline spans,
3
+ * tokenization, and a compact LCS word-differ producing the merged
4
+ * ins/del/eq segments prose nodes carry as `diffInline`.
5
+ */
6
+ import type { InlineNode, TreeNode } from "@dogsbay/types";
7
+ import type { InlineDiffSegment } from "./types.js";
8
+ /**
9
+ * Decode numeric character references so `&#9989;` and `✅` compare
10
+ * equal — upstream docs flip between entity and literal forms without
11
+ * any semantic change (found via better-auth adapters/mysql).
12
+ */
13
+ export declare function decodeNumericEntities(s: string): string;
14
+ /** Plain text of a node's inline spans (or html fallback), recursively. */
15
+ export declare function nodeText(node: TreeNode): string;
16
+ export declare function inlineText(spans: InlineNode[]): string;
17
+ /**
18
+ * Guard for the quadratic LCS table: beyond this many tokens PER SIDE
19
+ * the differ returns null and the caller falls back to a block-level
20
+ * changed mark with diffPeer (a 10k-word pair would allocate ~800 MB;
21
+ * corpus pages are untrusted-size upstream input).
22
+ */
23
+ export declare const WORD_DIFF_MAX_TOKENS = 1500;
24
+ /**
25
+ * Merged word-level diff (classic LCS over word tokens). Adjacent
26
+ * same-op runs are coalesced; output order is old-side for deletions
27
+ * interleaved at their anchors — i.e. the "merged reading view".
28
+ * Returns null when either side exceeds WORD_DIFF_MAX_TOKENS.
29
+ */
30
+ export declare function wordDiff(oldText: string, newText: string): InlineDiffSegment[] | null;
31
+ /**
32
+ * Token-set similarity in [0, 1] — the positional prose matcher's
33
+ * "same block, edited" test.
34
+ */
35
+ export declare function similarity(a: string, b: string): number;
package/dist/text.js ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Decode numeric character references so `&#9989;` and `✅` compare
3
+ * equal — upstream docs flip between entity and literal forms without
4
+ * any semantic change (found via better-auth adapters/mysql).
5
+ */
6
+ export function decodeNumericEntities(s) {
7
+ return s
8
+ .replace(/&#(\d+);/g, (m, d) => {
9
+ const cp = Number(d);
10
+ return cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
11
+ })
12
+ .replace(/&#x([0-9a-f]+);/gi, (m, h) => {
13
+ const cp = parseInt(h, 16);
14
+ return cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
15
+ });
16
+ }
17
+ /** Plain text of a node's inline spans (or html fallback), recursively. */
18
+ export function nodeText(node) {
19
+ const parts = [];
20
+ if (node.inline)
21
+ parts.push(inlineText(node.inline));
22
+ else if (typeof node.html === "string") {
23
+ parts.push(decodeNumericEntities(node.html.replace(/<[^>]+>/g, "")));
24
+ }
25
+ for (const child of node.children ?? [])
26
+ parts.push(nodeText(child));
27
+ return parts.join(" ").replace(/\s+/g, " ").trim();
28
+ }
29
+ export function inlineText(spans) {
30
+ return spans
31
+ .map((s) => {
32
+ switch (s.type) {
33
+ case "text":
34
+ case "code":
35
+ return s.text ?? "";
36
+ case "link":
37
+ return inlineText(s.children ?? []);
38
+ default:
39
+ return s.text ?? "";
40
+ }
41
+ })
42
+ .join("");
43
+ }
44
+ /** Word tokens with attached trailing whitespace, so joins reconstruct. */
45
+ function tokenize(text) {
46
+ return text.match(/\S+\s*/g) ?? [];
47
+ }
48
+ /**
49
+ * Guard for the quadratic LCS table: beyond this many tokens PER SIDE
50
+ * the differ returns null and the caller falls back to a block-level
51
+ * changed mark with diffPeer (a 10k-word pair would allocate ~800 MB;
52
+ * corpus pages are untrusted-size upstream input).
53
+ */
54
+ export const WORD_DIFF_MAX_TOKENS = 1500;
55
+ /**
56
+ * Merged word-level diff (classic LCS over word tokens). Adjacent
57
+ * same-op runs are coalesced; output order is old-side for deletions
58
+ * interleaved at their anchors — i.e. the "merged reading view".
59
+ * Returns null when either side exceeds WORD_DIFF_MAX_TOKENS.
60
+ */
61
+ export function wordDiff(oldText, newText) {
62
+ const a = tokenize(oldText);
63
+ const b = tokenize(newText);
64
+ const m = a.length;
65
+ const n = b.length;
66
+ if (m > WORD_DIFF_MAX_TOKENS || n > WORD_DIFF_MAX_TOKENS)
67
+ return null;
68
+ const lcs = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
69
+ for (let i = m - 1; i >= 0; i--) {
70
+ for (let j = n - 1; j >= 0; j--) {
71
+ lcs[i][j] =
72
+ a[i].trim() === b[j].trim()
73
+ ? lcs[i + 1][j + 1] + 1
74
+ : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
75
+ }
76
+ }
77
+ const segments = [];
78
+ const push = (op, text) => {
79
+ const last = segments[segments.length - 1];
80
+ if (last && last.op === op)
81
+ last.text += text;
82
+ else
83
+ segments.push({ op, text });
84
+ };
85
+ let i = 0;
86
+ let j = 0;
87
+ while (i < m && j < n) {
88
+ if (a[i].trim() === b[j].trim()) {
89
+ push("eq", b[j]); // new side's whitespace wins for reconstruction
90
+ i++;
91
+ j++;
92
+ }
93
+ else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
94
+ push("del", a[i]);
95
+ i++;
96
+ }
97
+ else {
98
+ push("ins", b[j]);
99
+ j++;
100
+ }
101
+ }
102
+ while (i < m)
103
+ push("del", a[i++]);
104
+ while (j < n)
105
+ push("ins", b[j++]);
106
+ return segments;
107
+ }
108
+ /**
109
+ * Token-set similarity in [0, 1] — the positional prose matcher's
110
+ * "same block, edited" test.
111
+ */
112
+ export function similarity(a, b) {
113
+ if (a === b)
114
+ return 1;
115
+ const ta = new Set(a.toLowerCase().split(/\s+/).filter(Boolean));
116
+ const tb = new Set(b.toLowerCase().split(/\s+/).filter(Boolean));
117
+ if (ta.size === 0 && tb.size === 0)
118
+ return 1;
119
+ let common = 0;
120
+ for (const t of ta)
121
+ if (tb.has(t))
122
+ common++;
123
+ return common / Math.max(ta.size, tb.size);
124
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * DiffTree — the annotated union tree `diffTrees` produces
3
+ * (plans/tree-diff-rendering.md). A DiffNode IS a TreeNode plus diff
4
+ * marks; renderers that don't know the marks render it unchanged,
5
+ * which is the design's core property: one transform, normal
6
+ * renderers, many views.
7
+ */
8
+ import type { TreeNode } from "@dogsbay/types";
9
+ export type DiffKind = "added" | "removed" | "changed" | "moved";
10
+ /** One run of the merged word-level diff of a prose node's text. */
11
+ export interface InlineDiffSegment {
12
+ op: "eq" | "ins" | "del";
13
+ text: string;
14
+ }
15
+ export interface DiffNode extends TreeNode {
16
+ /**
17
+ * The node's diff mark. Set on the ROOT of an added/removed subtree
18
+ * (children are implied), and on individual changed/moved nodes.
19
+ * Absent = unchanged.
20
+ */
21
+ diff?: DiffKind;
22
+ /**
23
+ * Word-level merged diff of a changed prose node's plain text.
24
+ * Render all segments for the merged view; filter `ins` for the
25
+ * old side / `del` for the new side in side-by-side.
26
+ */
27
+ diffInline?: InlineDiffSegment[];
28
+ /**
29
+ * For changed leaf PAIRS (code samples, table rows): the other
30
+ * side's node, so views can render before/after.
31
+ */
32
+ diffPeer?: TreeNode;
33
+ /** For moved sections: the heading path on the other side. */
34
+ diffMovedFrom?: string[];
35
+ children?: DiffNode[];
36
+ }
37
+ /**
38
+ * Summary counts a comparison page's docs-ops view reports.
39
+ * Added/removed count subtree ROOTS: a new section with three
40
+ * subsections is ONE added section (its children are implied by the
41
+ * root mark), matching how the marks render.
42
+ */
43
+ export interface DiffStats {
44
+ sectionsAdded: number;
45
+ sectionsRemoved: number;
46
+ sectionsChanged: number;
47
+ sectionsMoved: number;
48
+ blocksChanged: number;
49
+ }
50
+ export interface DiffResult {
51
+ /** Union tree in new-side order; removed content spliced at anchors. */
52
+ tree: DiffNode[];
53
+ stats: DiffStats;
54
+ /** True when the two inputs are identical (no marks anywhere). */
55
+ unchanged: boolean;
56
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ import type { DiffNode } from "./types.js";
2
+ /** Number of foldable (level ≥ 2) sections that were folded. */
3
+ export interface FoldResult {
4
+ tree: DiffNode[];
5
+ folded: number;
6
+ }
7
+ export declare function foldUnchangedSections(tree: DiffNode[]): FoldResult;
package/dist/views.js ADDED
@@ -0,0 +1,46 @@
1
+ import { sectionize } from "./diff.js";
2
+ import { nodeText } from "./text.js";
3
+ const hasAnyMark = (node) => Boolean(node.diff || node.diffInline) ||
4
+ (node.children ?? []).some(hasAnyMark);
5
+ const sectionNodes = (sec) => [
6
+ ...(sec.heading ? [sec.heading] : []),
7
+ ...sec.blocks,
8
+ ...sec.children.flatMap(sectionNodes),
9
+ ];
10
+ const sectionUnchanged = (sec) => !sectionNodes(sec).some(hasAnyMark);
11
+ export function foldUnchangedSections(tree) {
12
+ const root = sectionize(tree);
13
+ let folded = 0;
14
+ const emitSection = (sec) => {
15
+ if (sec.level >= 2 && sec.heading && sectionUnchanged(sec)) {
16
+ folded++;
17
+ return [
18
+ {
19
+ type: "details",
20
+ props: {
21
+ title: `Unchanged: ${nodeText(sec.heading)}`,
22
+ // `plain` = chrome, not content: a fold is collapsed
23
+ // CONTEXT, not a note, so it gets no alert semantics, no
24
+ // icon, and no ARIA role (the previous `note` variant put
25
+ // role="note" on a <details>, which axe rejects — details
26
+ // has an implicit `group` role that ARIA won't let you
27
+ // override). Styled as dashed/dimmed chrome by DIFF_CSS.
28
+ variant: "plain",
29
+ class: "db-diff-fold",
30
+ },
31
+ children: sectionNodes(sec),
32
+ },
33
+ ];
34
+ }
35
+ return [
36
+ ...(sec.heading ? [sec.heading] : []),
37
+ ...sec.blocks,
38
+ ...sec.children.flatMap(emitSection),
39
+ ];
40
+ };
41
+ const out = [
42
+ ...root.blocks, // page lead never folds
43
+ ...root.children.flatMap(emitSection),
44
+ ];
45
+ return { tree: out, folded };
46
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@dogsbay/tree-diff",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0-beta.131",
4
+ "license": "MIT",
5
+ "description": "diffTrees: identity-driven version diffing over TreeNode — one DiffTree, many views (comparison pages, PR review, version-switcher compare, IDE change review)",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "devDependencies": {
20
+ "@types/node": "^25.9.5",
21
+ "typescript": "^5.9.3",
22
+ "vitest": "^4.1.10",
23
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.131"
24
+ },
25
+ "dependencies": {
26
+ "@dogsbay/types": "0.2.0-beta.131"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/dogsbay/dogsbay.git",
31
+ "directory": "packages/tree-diff"
32
+ },
33
+ "homepage": "https://github.com/dogsbay/dogsbay/tree/main/packages/tree-diff",
34
+ "bugs": {
35
+ "url": "https://github.com/dogsbay/dogsbay/issues"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc",
39
+ "test": "vitest run",
40
+ "test:watch": "vitest"
41
+ }
6
42
  }