@helping-ai-workflow/md2doc 3.12.0 → 3.13.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.
@@ -14,11 +14,14 @@
14
14
  * be derived wrongly.
15
15
  *
16
16
  * `toPatch` does NOT replay the stack. It compares the current entry against
17
- * the parse of the original text and writes the difference; nothing about how
18
- * the user got there reaches the file. That is what makes an edit and its
19
- * inverse cost zero bytes instead of two edits that cancel, and it is why
20
- * `isDirty` can be a question about the two documents rather than about the
21
- * stack — see the note on it below.
17
+ * the parse of the BASE text and writes the difference; nothing about how the
18
+ * user got there reaches the file. The base starts as the text the store was
19
+ * opened on, and the editor moves it with `rebase` after every write-back that
20
+ * landed (Ruling R16), so a patch is planned for one gesture rather than for a
21
+ * whole session of them. That is what makes an edit and its inverse cost zero
22
+ * bytes instead of two edits that cancel, and it is why `isDirty` — which keeps
23
+ * measuring against the OPENING text, never the base — can be a question about
24
+ * two documents rather than about the stack; see the note on it below.
22
25
  *
23
26
  * Four rules this file does not get to bend:
24
27
  *
@@ -80,44 +83,30 @@ const HISTORY_LIMIT = 200;
80
83
  * than as a throw or a null: the GUI layer above has a block on screen either
81
84
  * way, and `ok` plus a refusing `toPatch` is something it can show.
82
85
  */
83
- function createStore(sourceText) {
84
- const parsed = C.parseSource(sourceText);
85
- if (parsed.ok !== true) return refusingStore(sourceText, parsed);
86
+ function createStore(openingText) {
87
+ const opening = C.parseSource(openingText);
88
+ if (opening.ok !== true) return refusingStore(openingText, opening);
86
89
 
87
- const baseDoc = deepFreeze(parsed.doc, 0);
88
- const basePaths = C.lanePaths(baseDoc);
89
- const history = [{ opName: null, doc: baseDoc, origins: countUp(basePaths.length) }];
90
+ // The BASE is what the next patch is written against: the opening text until
91
+ // the first write-back, then whatever `rebase` was last handed. The OPENING
92
+ // text and its parse stay for `isDirty`, which asks about the whole session,
93
+ // and as the second reading `toPatch` falls back on (Ruling R17).
94
+ let sourceText = openingText;
95
+ let parsed = opening;
96
+ const openingDoc = deepFreeze(opening.doc, 0);
97
+ const openingCtx = contextFor(opening, openingDoc, C.lanePaths(openingDoc));
98
+ let ctx = openingCtx;
99
+ // Each entry carries two answers to "which lane is which": `origins`, against
100
+ // the current base (re-traced by every `rebase`), and `openOrigins`, against
101
+ // the opening text, which nothing but `apply` ever writes.
102
+ const history = [{
103
+ opName: null,
104
+ doc: openingDoc,
105
+ origins: countUp(openingCtx.basePaths.length),
106
+ openOrigins: countUp(openingCtx.basePaths.length),
107
+ }];
90
108
  let at = 0;
91
109
 
92
- /** The span of one path as a range a caller can frame, or null. */
93
- function range(path) {
94
- const span = parsed.spans.get(JSON.stringify(path));
95
- return span === undefined
96
- ? null
97
- : { path: path, spanPath: path, start: span[0], end: span[1] };
98
- }
99
-
100
- /** One range covering several paths — for a refusal about more than one lane. */
101
- function spanning(paths) {
102
- let out = null;
103
- for (const path of paths) {
104
- const one = range(path);
105
- if (one === null) continue;
106
- if (out === null) out = one;
107
- else {
108
- out = {
109
- path: out.path,
110
- spanPath: out.spanPath,
111
- start: Math.min(out.start, one.start),
112
- end: Math.max(out.end, one.end),
113
- };
114
- }
115
- }
116
- return out === null ? range([]) : out;
117
- }
118
-
119
- const ctx = { baseDoc: baseDoc, basePaths: basePaths, range: range, spanning: spanning };
120
-
121
110
  /**
122
111
  * Run one operation and, when it changed anything, push the result.
123
112
  *
@@ -144,11 +133,13 @@ function createStore(sourceText) {
144
133
  if (next === here.doc || !isDocShape(here.doc, next)) return false;
145
134
  const adopted = adoptDocument(next, 0, new Map());
146
135
  const origins = remapOrigins(here.doc, adopted, here.origins);
136
+ const openOrigins = remapOrigins(here.doc, adopted, here.openOrigins);
147
137
  history.length = at + 1;
148
138
  history.push({
149
139
  opName: opName === undefined ? null : opName,
150
140
  doc: adopted,
151
141
  origins: origins,
142
+ openOrigins: openOrigins,
152
143
  });
153
144
  at++;
154
145
  // Cap the stack, not just its growth: apply always leaves `at` on the
@@ -209,7 +200,48 @@ function createStore(sourceText) {
209
200
  * that the patch would not write.
210
201
  */
211
202
  function isDirty() {
212
- return !sameValue(baseDoc, history[at].doc, 0);
203
+ return !sameValue(openingDoc, history[at].doc, 0);
204
+ }
205
+
206
+ /**
207
+ * Move the base to `text`, the bytes the caller has just written back for the
208
+ * CURRENT document, so the next `toPatch` is planned against them and sees
209
+ * one gesture rather than the whole session (Ruling R16).
210
+ *
211
+ * Planning stays a comparison of two documents — the current one against the
212
+ * parse of the base text — and nothing is replayed. What a rebase has to
213
+ * carry over is lane identity: every stack entry's `origins` named lanes of
214
+ * the OLD base, and is re-traced to the new one, outward from the current
215
+ * entry, one operation at a time with the same `remapOrigins` that `apply`
216
+ * runs. The current entry is the new base itself, so its origins count up;
217
+ * an undo entry then reads as the reverse of the gesture that left it, and a
218
+ * redo entry as that gesture again.
219
+ *
220
+ * Refused — answering false and changing nothing — for text that does not
221
+ * read back as the current document: a rebase onto bytes that say something
222
+ * else would plan the next patch against a file that is not there. `isDirty`
223
+ * does not move: it measures the session against the text the editor opened.
224
+ *
225
+ * The entry also remembers `text` as what the file held for it, so an undo or
226
+ * redo that lands on it again writes exactly those bytes (see `toPatch`).
227
+ * Its `openOrigins` are left alone: they are the second reading's (R17).
228
+ */
229
+ function rebase(text) {
230
+ const next = C.parseSource(text);
231
+ if (next.ok !== true || !sameValue(next.doc, history[at].doc, 0)) return false;
232
+ sourceText = text;
233
+ parsed = next;
234
+ const baseDoc = deepFreeze(next.doc, 0);
235
+ ctx = contextFor(next, baseDoc, C.lanePaths(baseDoc));
236
+ history[at].origins = countUp(ctx.basePaths.length);
237
+ history[at].written = text;
238
+ for (let k = at - 1; k >= 0; k--) {
239
+ history[k].origins = remapOrigins(history[k + 1].doc, history[k].doc, history[k + 1].origins);
240
+ }
241
+ for (let k = at + 1; k < history.length; k++) {
242
+ history[k].origins = remapOrigins(history[k - 1].doc, history[k].doc, history[k - 1].origins);
243
+ }
244
+ return true;
213
245
  }
214
246
 
215
247
  /**
@@ -228,47 +260,47 @@ function createStore(sourceText) {
228
260
  */
229
261
  function toPatch() {
230
262
  const here = history[at];
231
- const plan = planEdits(ctx, here.doc, here.origins);
232
- if (plan.ok !== true) return plan;
233
- let refused = null;
234
- let mismatched = null;
235
- for (const edits of plan.alternatives) {
236
- const written = C.patchSource(sourceText, parsed, edits);
237
- if (written.ok !== true) {
238
- if (refused === null) refused = written;
239
- continue;
240
- }
241
- const back = C.parseSource(written.text);
242
- if (back.ok === true && sameValue(back.doc, here.doc, 0)) return written;
243
- if (mismatched === null) mismatched = refuse(NET_REASON, range([]));
244
- }
245
- // Nothing reproduced the document, so this is a refusal — and WHICH refusal
246
- // matters, because the candidate that refused during planning is usually the
247
- // reading that was actually right. A candidate whose plan named a real
248
- // structural conflict says more than "the bytes did not come back the same",
249
- // so it is preferred, then the codec's own words, and only then the net.
250
- //
251
- // When there was more than one reading, the one being described is the first
252
- // by position and not necessarily the drag the user performed, so the message
253
- // says that rather than asserting a gesture nobody made. Naming the "more
254
- // plausible" reading instead would be a tie-break by argument, which is the
255
- // thing that shipped the wrong patch in the first place.
256
- if (plan.refusals.length > 0) {
257
- const first = plan.refusals[0];
258
- if (plan.readings < 2) return first;
259
- return refuse('the lanes were reordered in a way that has more than one reading — ' +
260
- 'which lane was dragged cannot be told apart from the result — and none of them ' +
261
- 'could be written as a local patch. One reading was refused because: ' + first.reason,
262
- first.rewroteRange);
263
+ // Back at the document the editor opened on: the opening bytes ARE its
264
+ // patch. Planned against a moved base, the reverse of the last gesture can
265
+ // read back right and still differ in bytes (a move leaves its trailing
266
+ // comma), and a session that has come back to where it started must hand
267
+ // back the author's own text — `client.js` takes the session's ops off the
268
+ // document stack only when the block is byte-identical to its opening.
269
+ if (sourceText !== openingText && !isDirty()) return { ok: true, text: openingText };
270
+ // An entry the file has already held — the one an undo or a redo lands on —
271
+ // is written back as exactly those bytes: a reverse gesture planned against
272
+ // the base would say the same thing with less of the author's text in it
273
+ // (an undone delete comes back as a fresh value, without the comment that
274
+ // sat beside it), or not at all. They go through the read-back check again
275
+ // first (Ruling R18): they read back as this document when they were
276
+ // written, but a value a caller froze only at its top keeps whatever is
277
+ // inside it mutable, so "this document" may have moved since. On a
278
+ // mismatch the two planners below decide, as for any other entry.
279
+ if (here.written !== undefined) {
280
+ const back = C.parseSource(here.written);
281
+ if (back.ok === true && sameValue(back.doc, here.doc, 0)) return { ok: true, text: here.written };
263
282
  }
264
- if (refused !== null) return refused;
265
- return mismatched === null ? refuse(NET_REASON, range([])) : mismatched;
283
+ const first = writeAgainst(ctx, sourceText, parsed, here.doc, here.origins);
284
+ if (first.ok === true || sourceText === openingText) return first;
285
+ // Ruling R17: the plan against the rebased base refused, so the same
286
+ // document is planned once more against the OPENING text with the
287
+ // opening-relative origins — the whole-session plan that preceded R16.
288
+ // Some changes only that reading can say: an undo that puts a deleted lane
289
+ // back in front of a group at the group's own level, or behind a group's
290
+ // last member, or brings back a group its deletions had emptied. It is
291
+ // still two documents and no replay, the read-back decides, and either
292
+ // way the patch is the whole block text. The base reading goes first, so
293
+ // whatever it could write is written exactly as before; when both refuse,
294
+ // the base reading's refusal is the one reported.
295
+ const second = writeAgainst(openingCtx, openingText, opening, here.doc, here.openOrigins);
296
+ return second.ok === true ? second : first;
266
297
  }
267
298
 
268
299
  const store = {
269
300
  ok: true,
270
301
  error: null,
271
- source: sourceText,
302
+ // The text the editor OPENED with — not the moving base `rebase` keeps.
303
+ source: openingText,
272
304
  apply: apply,
273
305
  undo: undo,
274
306
  redo: redo,
@@ -278,6 +310,7 @@ function createStore(sourceText) {
278
310
  redoName: function () { return at < history.length - 1 ? history[at + 1].opName : null; },
279
311
  isDirty: isDirty,
280
312
  toPatch: toPatch,
313
+ rebase: rebase,
281
314
  };
282
315
  Object.defineProperty(store, 'doc', {
283
316
  enumerable: true,
@@ -286,6 +319,80 @@ function createStore(sourceText) {
286
319
  return store;
287
320
  }
288
321
 
322
+ /**
323
+ * Everything the planner reads about the text a patch is written against: its
324
+ * document, its lane paths, and ranges into its spans for refusals to frame.
325
+ */
326
+ function contextFor(parsed, baseDoc, basePaths) {
327
+ function range(path) {
328
+ const span = parsed.spans.get(JSON.stringify(path));
329
+ return span === undefined
330
+ ? null
331
+ : { path: path, spanPath: path, start: span[0], end: span[1] };
332
+ }
333
+ /** One range covering several paths — for a refusal about more than one lane. */
334
+ function spanning(paths) {
335
+ let out = null;
336
+ for (const path of paths) {
337
+ const one = range(path);
338
+ if (one === null) continue;
339
+ if (out === null) out = one;
340
+ else {
341
+ out = {
342
+ path: out.path,
343
+ spanPath: out.spanPath,
344
+ start: Math.min(out.start, one.start),
345
+ end: Math.max(out.end, one.end),
346
+ };
347
+ }
348
+ }
349
+ return out === null ? range([]) : out;
350
+ }
351
+ return { baseDoc: baseDoc, basePaths: basePaths, range: range, spanning: spanning };
352
+ }
353
+
354
+ /**
355
+ * Plan `doc` against one text, write every reading, and hand back the first
356
+ * that reads back as `doc` — or the refusal that best says why none did.
357
+ */
358
+ function writeAgainst(ctx, text, parsed, doc, origins) {
359
+ const plan = planEdits(ctx, doc, origins);
360
+ if (plan.ok !== true) return plan;
361
+ let refused = null;
362
+ let mismatched = null;
363
+ for (const edits of plan.alternatives) {
364
+ const written = C.patchSource(text, parsed, edits);
365
+ if (written.ok !== true) {
366
+ if (refused === null) refused = written;
367
+ continue;
368
+ }
369
+ const back = C.parseSource(written.text);
370
+ if (back.ok === true && sameValue(back.doc, doc, 0)) return written;
371
+ if (mismatched === null) mismatched = refuse(NET_REASON, ctx.range([]));
372
+ }
373
+ // Nothing reproduced the document, so this is a refusal — and WHICH refusal
374
+ // matters, because the candidate that refused during planning is usually the
375
+ // reading that was actually right. A candidate whose plan named a real
376
+ // structural conflict says more than "the bytes did not come back the same",
377
+ // so it is preferred, then the codec's own words, and only then the net.
378
+ //
379
+ // When there was more than one reading, the one being described is the first
380
+ // by position and not necessarily the drag the user performed, so the message
381
+ // says that rather than asserting a gesture nobody made. Naming the "more
382
+ // plausible" reading instead would be a tie-break by argument, which is the
383
+ // thing that shipped the wrong patch in the first place.
384
+ if (plan.refusals.length > 0) {
385
+ const first = plan.refusals[0];
386
+ if (plan.readings < 2) return first;
387
+ return refuse('the lanes were reordered in a way that has more than one reading — ' +
388
+ 'which lane was dragged cannot be told apart from the result — and none of them ' +
389
+ 'could be written as a local patch. One reading was refused because: ' + first.reason,
390
+ first.rewroteRange);
391
+ }
392
+ if (refused !== null) return refused;
393
+ return mismatched === null ? refuse(NET_REASON, ctx.range([])) : mismatched;
394
+ }
395
+
289
396
  /** A store over a source that could not be read back: it refuses, it never throws. */
290
397
  function refusingStore(sourceText, parsed) {
291
398
  const why = 'the source could not be read back as WaveJSON (' + parsed.message +
@@ -302,6 +409,7 @@ function refusingStore(sourceText, parsed) {
302
409
  undoName: function () { return null; },
303
410
  redoName: function () { return null; },
304
411
  isDirty: function () { return false; },
412
+ rebase: function () { return false; },
305
413
  // No span table and no edit, so there is no range to frame: `rewroteRange`
306
414
  // is null on purpose here, and `error.offset` is where the trouble is.
307
415
  toPatch: function () { return refuse(why, null); },
@@ -531,6 +639,11 @@ function countUp(n) {
531
639
  * address lanes through the bridge, never by a path built here — and the
532
640
  * content half is then done pairwise between lanes that survived. That half can
533
641
  * offer more than one candidate; `toPatch` decides between them by reproduction.
642
+ *
643
+ * A third route is for the changes to the GROUPS themselves, which neither of
644
+ * those can say: a run of lanes wrapped into a new group, a group dissolved, a
645
+ * whole group moved among its siblings. See `planTree`; its readings are added
646
+ * to whichever of the two routes above ran, after that route's own.
534
647
  */
535
648
  function planEdits(ctx, doc, origins) {
536
649
  const common = [];
@@ -539,18 +652,28 @@ function planEdits(ctx, doc, origins) {
539
652
  const top = diffMembers(ctx, [], ctx.baseDoc, doc, skip, common);
540
653
  if (top.ok !== true) return top;
541
654
  if (!laneAware) return { ok: true, alternatives: [common], refusals: [], readings: 1 };
655
+ // The group-level readings (a group wrapped, unwrapped or moved as a whole)
656
+ // come AFTER the lane-level ones in every list below: where a lane-level plan
657
+ // already reproduced the document before, it still wins and writes exactly
658
+ // the bytes it always wrote. They only ever turn a refusal into a write.
659
+ const tree = planTree(ctx, doc, origins, common);
542
660
  if (origins.length === ctx.basePaths.length && isCountUp(origins)) {
543
- const lanes = diffValue(ctx, ['signal'], signalOf(ctx.baseDoc), signalOf(doc), common);
661
+ const same = common.slice();
662
+ const lanes = diffValue(ctx, ['signal'], signalOf(ctx.baseDoc), signalOf(doc), same);
544
663
  if (lanes.ok !== true) return lanes;
545
- return { ok: true, alternatives: [common], refusals: [], readings: 1 };
664
+ return { ok: true, alternatives: [same].concat(tree), refusals: [], readings: 1 + tree.length };
546
665
  }
547
666
  const structural = planStructural(ctx, doc, origins);
548
- if (structural.ok !== true) return structural;
667
+ if (structural.ok !== true) {
668
+ if (tree.length === 0) return structural;
669
+ return { ok: true, alternatives: tree, refusals: [structural], readings: 1 + tree.length };
670
+ }
549
671
  return {
550
672
  ok: true,
551
- alternatives: structural.alternatives.map(function (extra) { return common.concat(extra); }),
673
+ alternatives: structural.alternatives.map(function (extra) { return common.concat(extra); })
674
+ .concat(tree),
552
675
  refusals: structural.refusals,
553
- readings: structural.readings,
676
+ readings: structural.readings + tree.length,
554
677
  };
555
678
  }
556
679
 
@@ -579,20 +702,25 @@ function planStructural(ctx, doc, origins) {
579
702
  }
580
703
 
581
704
  // A group title is not a lane, so nothing above tracks one, and a structural
582
- // walk would carry a renamed title silently past the patch. No codec
583
- // operation renames one — only a hand-written operation reaches this — so
584
- // rather than guess which title became which, anything the current doc says
585
- // that the base doc did not is refused. A title that vanished is ordinary: it
586
- // went with the group its last lane emptied. (A group DISSOLVED by hand, with
587
- // its lanes kept, says nothing new here — that one is caught by the
588
- // read-back check in `toPatch`, because the patch would still hold the group.)
705
+ // walk would carry a renamed or new title silently past the patch. Titles do
706
+ // change — `wave-panels`' commitGroupTitle renames one, `groupLanes` writes a
707
+ // new one — but each is its own gesture, and after the editor rebases on a
708
+ // write-back this lane-level route only ever sees a title change that rode in
709
+ // with a lane change since the last write-back that landed (or a hand-written
710
+ // operation). Rather than guess which title became which, anything the
711
+ // current doc says that the base doc did not is refused here; the group-level
712
+ // route (`planTree`) still gets its chance, and covers a new group's title.
713
+ // A title that vanished is ordinary: it went with the group its last lane
714
+ // emptied. (A group DISSOLVED with its lanes kept says nothing new here —
715
+ // `planTree` plans that one, and the read-back check catches any other.)
589
716
  const titles = titleCensus(ctx.baseDoc);
590
717
  for (const title of titlesOf(doc)) {
591
718
  const seen = titles.get(title);
592
719
  if (seen === undefined || seen === 0) {
593
- return refuse('a group title changed at the same time as the lane roster, and a ' +
594
- 'title is not a lane, so which one it used to be is not recoverable; apply the ' +
595
- 'title change and the lane change as two edits', ctx.range(['signal']));
720
+ return refuse('a group title changed together with the lane order since the last ' +
721
+ 'write-back, and a title is not a lane, so which one it used to be is not ' +
722
+ 'recoverable; undo back to the last change that was written, then make the title ' +
723
+ 'change and the lane change one at a time', ctx.range(['signal']));
596
724
  }
597
725
  titles.set(title, seen - 1);
598
726
  }
@@ -835,6 +963,273 @@ function isLane(item) {
835
963
  return item !== null && typeof item === 'object' && !Array.isArray(item);
836
964
  }
837
965
 
966
+ // ---------------------------------------------------------------------------
967
+ // Group-level structure: wrap, unwrap, move a whole element
968
+ // ---------------------------------------------------------------------------
969
+
970
+ /**
971
+ * The candidate edit lists for a change to the lane tree's GROUPS — one list per
972
+ * reading, each beginning with `common` — or `[]` when there is none to offer.
973
+ *
974
+ * The lane-level planner above sees a change as lanes added, removed and moved,
975
+ * and every one of its destinations is a lane position. That cannot say three
976
+ * things the name column does: `groupLanes` and `ungroupLanes` leave every lane
977
+ * in its display position and change only the arrays around them, and a group
978
+ * moved past its neighbour looks like several lanes moving — or like one lane
979
+ * moving to "just before the group, at the group's own level", which no lane
980
+ * position resolves to (`laneInsertPath` joins a group at its head). So this
981
+ * plans on the tree instead, with the codec's element-level edits: `wrap`,
982
+ * `unwrap`, and `move` of a whole element.
983
+ *
984
+ * It plans from the two documents only, through a skeleton of each (`skeletonOf`):
985
+ * the arrays, the titles and other scalars with their values, and every lane as
986
+ * the base lane it came from — `origins`, the same identity the lane planner
987
+ * uses. A reading is ONE structural edit that turns the base skeleton into the
988
+ * current one exactly:
989
+ *
990
+ * - unwrap: a base group whose dissolving gives the current skeleton;
991
+ * - wrap: a current group whose dissolving gives the BASE skeleton — the same
992
+ * test run backwards, which also hands over the new title as written;
993
+ * - move: a base element (lane or group) that, lifted out, leaves the same
994
+ * skeleton the current one leaves without that element.
995
+ *
996
+ * Every reading that fits is offered, not one chosen by argument — a swap of two
997
+ * neighbours fits "the upper one moved down" and "the lower one moved up", and
998
+ * those can be different patches. `toPatch` writes each and keeps the first
999
+ * that reads back as the document, as it does for the lane planner's readings.
1000
+ *
1001
+ * Only the shape is decided here; what changed INSIDE a lane is added to every
1002
+ * reading as the ordinary per-lane diff, at the lane's base path. That is safe
1003
+ * beside a wrap or unwrap, whose edits sit outside every member, and refused by
1004
+ * the codec as overlapping beside a move of that same lane — which is the
1005
+ * answer the lane planner gives for a moved lane that was also edited.
1006
+ *
1007
+ * Only offered when the roster is intact — every base lane present once, none
1008
+ * new — and the skeletons differ. Lanes added or removed in the same session
1009
+ * as a group change would need two structural edits measured against the same
1010
+ * text, and the lane planner's own refusal is the honest answer there.
1011
+ */
1012
+ function planTree(ctx, doc, origins, common) {
1013
+ if (!isPermutation(origins, ctx.basePaths.length)) return [];
1014
+ if (ctx.baseTree === undefined) ctx.baseTree = skeletonOf(ctx.baseDoc, countUp(ctx.basePaths.length));
1015
+ const base = ctx.baseTree;
1016
+ const now = skeletonOf(doc, origins);
1017
+ if (base === null || now === null) return [];
1018
+ const baseSig = sigOf(base, null);
1019
+ const nowSig = sigOf(now, null);
1020
+ if (baseSig === nowSig) return [];
1021
+
1022
+ const structural = [];
1023
+ for (const group of groupsOf(base)) {
1024
+ if (unwrappedSig(base, group) === nowSig) structural.push({ op: 'unwrap', path: group.path });
1025
+ }
1026
+ for (const group of groupsOf(now)) {
1027
+ if (unwrappedSig(now, group) !== baseSig) continue;
1028
+ const titled = group.items.length > 0 && group.items[0].kind === 'other' &&
1029
+ typeof group.items[0].value === 'string';
1030
+ const members = group.items.slice(titled ? 1 : 0);
1031
+ if (members.length === 0 || members.some(function (m) { return m.kind === 'other'; })) continue;
1032
+ // Dissolving it in the current tree gives the base tree, so everything in
1033
+ // front of it — its parent's path and its own index — is the same in both.
1034
+ const edit = { op: 'wrap', path: group.path, count: members.length };
1035
+ if (titled) edit.title = group.items[0].value;
1036
+ structural.push(edit);
1037
+ }
1038
+ const bySig = new Map();
1039
+ for (const node of elementsOf(now)) {
1040
+ const sig = sigOf(node, null);
1041
+ const list = bySig.get(sig);
1042
+ if (list === undefined) bySig.set(sig, [node]);
1043
+ else list.push(node);
1044
+ }
1045
+ for (const node of elementsOf(base)) {
1046
+ const cut = liftedOut(node);
1047
+ const without = sigOf(base, cut);
1048
+ for (const there of bySig.get(sigOf(node, null)) || []) {
1049
+ if (sigOf(now, there) !== without) continue;
1050
+ structural.push({ op: 'move', path: node.path, to: destinationIn(cut, there) });
1051
+ }
1052
+ }
1053
+
1054
+ const alternatives = [];
1055
+ for (const edit of structural) {
1056
+ const edits = common.concat([edit]);
1057
+ const r = laneContent(ctx, doc, now, edits);
1058
+ if (r.ok === true) alternatives.push(edits);
1059
+ }
1060
+ return alternatives;
1061
+ }
1062
+
1063
+ /** Whether `origins` names every base lane exactly once and nothing new. */
1064
+ function isPermutation(origins, n) {
1065
+ if (origins.length !== n) return false;
1066
+ const seen = new Array(n).fill(false);
1067
+ for (const o of origins) {
1068
+ if (o === null || !(o >= 0 && o < n) || seen[o]) return false;
1069
+ seen[o] = true;
1070
+ }
1071
+ return true;
1072
+ }
1073
+
1074
+ /**
1075
+ * The lane tree of `doc` as plain nodes: `{kind:'group', path, items}` for every
1076
+ * array (the root is `signal` itself), `{kind:'lane', path, id}` for a lane —
1077
+ * `id` being `origins` at its display position, so the two trees name a lane
1078
+ * the same way — and `{kind:'other', path, value}` for a title or any other
1079
+ * scalar. Each node knows its `parent` and its `index` there.
1080
+ *
1081
+ * Display positions come from the codec's own walk (`lanePaths`), not from a
1082
+ * count kept here: a lane's number and its path are matched through the bridge,
1083
+ * the one rule at the top of this file. `null` when the tree is deeper than the
1084
+ * reader would go, or when the two walks disagree about where a lane is.
1085
+ */
1086
+ function skeletonOf(doc, origins) {
1087
+ const lanes = signalOf(doc);
1088
+ if (!Array.isArray(lanes)) return null;
1089
+ const display = new Map();
1090
+ const paths = C.lanePaths(doc);
1091
+ for (let j = 0; j < paths.length; j++) display.set(JSON.stringify(paths[j]), j);
1092
+ let sound = true;
1093
+ let counted = 0;
1094
+ const walk = function (arr, path, depth, parent, index) {
1095
+ const node = { kind: 'group', path: path, items: [], parent: parent, index: index };
1096
+ if (depth > MAX_DEPTH) {
1097
+ sound = false;
1098
+ return node;
1099
+ }
1100
+ for (let i = 0; i < arr.length; i++) {
1101
+ const item = arr[i];
1102
+ const here = path.concat([i]);
1103
+ if (Array.isArray(item)) {
1104
+ node.items.push(walk(item, here, depth + 1, node, i));
1105
+ } else if (isLane(item)) {
1106
+ const j = display.get(JSON.stringify(here));
1107
+ if (j === undefined) sound = false;
1108
+ counted++;
1109
+ node.items.push({ kind: 'lane', path: here, id: origins[j], parent: node, index: i });
1110
+ } else {
1111
+ node.items.push({ kind: 'other', path: here, value: item, parent: node, index: i });
1112
+ }
1113
+ }
1114
+ return node;
1115
+ };
1116
+ const root = walk(lanes, ['signal'], 0, null, null);
1117
+ return sound && counted === paths.length ? root : null;
1118
+ }
1119
+
1120
+ /**
1121
+ * A string that two skeletons share exactly when they are the same tree: nesting,
1122
+ * every scalar with its value, every lane by the base lane it came from. `cut`,
1123
+ * when given, is left out as though it were not there.
1124
+ */
1125
+ function sigOf(node, cut) {
1126
+ if (node === cut) return '';
1127
+ if (node.kind === 'lane') return 'L' + node.id + ';';
1128
+ if (node.kind === 'other') return 'S' + scalarKey(node.value) + ';';
1129
+ let out = '[';
1130
+ for (const item of node.items) out += sigOf(item, cut);
1131
+ return out + ']';
1132
+ }
1133
+
1134
+ /** `sigOf(root)` with `group` dissolved the way `ungroupLanes` dissolves one. */
1135
+ function unwrappedSig(root, group) {
1136
+ const walk = function (node) {
1137
+ if (node.kind !== 'group') return sigOf(node, null);
1138
+ const items = node === group ? membersOf(node) : node.items;
1139
+ let inner = '';
1140
+ for (const item of items) inner += walk(item);
1141
+ return node === group ? inner : '[' + inner + ']';
1142
+ };
1143
+ return walk(root);
1144
+ }
1145
+
1146
+ /** A group's members: everything but a leading title string. */
1147
+ function membersOf(group) {
1148
+ const titled = group.items.length > 0 && group.items[0].kind === 'other' &&
1149
+ typeof group.items[0].value === 'string';
1150
+ return group.items.slice(titled ? 1 : 0);
1151
+ }
1152
+
1153
+ function scalarKey(value) {
1154
+ return typeof value + ':' + (typeof value === 'string' ? JSON.stringify(value) : String(value));
1155
+ }
1156
+
1157
+ /** Every group below the root, in document order. */
1158
+ function groupsOf(root) {
1159
+ return elementsOf(root).filter(function (n) { return n.kind === 'group'; });
1160
+ }
1161
+
1162
+ /** Every lane and group below the root, in document order (titles left out). */
1163
+ function elementsOf(root) {
1164
+ const out = [];
1165
+ const walk = function (node) {
1166
+ for (const item of node.items) {
1167
+ if (item.kind === 'other') continue;
1168
+ out.push(item);
1169
+ if (item.kind === 'group') walk(item);
1170
+ }
1171
+ };
1172
+ walk(root);
1173
+ return out;
1174
+ }
1175
+
1176
+ /**
1177
+ * What actually leaves the base tree when `node` is moved: the node, or the
1178
+ * group it empties by leaving — the codec's `move` takes that too, by the same
1179
+ * rule `removeLane` follows (a group left holding no lane and no group goes).
1180
+ */
1181
+ function liftedOut(node) {
1182
+ let cut = node;
1183
+ while (cut.parent !== null && cut.parent.parent !== null) {
1184
+ const stays = cut.parent.items.some(function (item) {
1185
+ return item !== cut && item.kind !== 'other';
1186
+ });
1187
+ if (stays) break;
1188
+ cut = cut.parent;
1189
+ }
1190
+ return cut;
1191
+ }
1192
+
1193
+ /**
1194
+ * Where a moved element lands, as a `move` destination in the BASE document,
1195
+ * given the node it became in the current tree.
1196
+ *
1197
+ * With the element lifted out of both, the two trees are the same, so the
1198
+ * current parent's path and index are already right for the base tree minus
1199
+ * `cut`. Putting `cut` back shifts every position at or after it in its own
1200
+ * array up by one — which is what turns that answer into one in the base text.
1201
+ */
1202
+ function destinationIn(cut, there) {
1203
+ const cutParent = cut.parent.path;
1204
+ const at = cut.index;
1205
+ const parent = there.parent.path.slice();
1206
+ const depth = cutParent.length;
1207
+ if (parent.length > depth && isPrefixPath(cutParent, parent) && parent[depth] >= at) {
1208
+ parent[depth] += 1;
1209
+ }
1210
+ let index = there.index;
1211
+ if (parent.length === depth && isPrefixPath(cutParent, parent) && index >= at) index += 1;
1212
+ return parent.concat([index]);
1213
+ }
1214
+
1215
+ /**
1216
+ * Append to `edits` what changed inside each lane, at its base path. The lanes
1217
+ * are matched by origin through the skeleton, so a lane that moved is still
1218
+ * compared with itself.
1219
+ */
1220
+ function laneContent(ctx, doc, now, edits) {
1221
+ for (const node of elementsOf(now)) {
1222
+ if (node.kind !== 'lane') continue;
1223
+ const basePath = ctx.basePaths[node.id];
1224
+ const was = valueAt(ctx.baseDoc, basePath);
1225
+ const is = valueAt(doc, node.path);
1226
+ if (was === is) continue;
1227
+ const r = diffValue(ctx, basePath, was, is, edits);
1228
+ if (r.ok !== true) return r;
1229
+ }
1230
+ return OK;
1231
+ }
1232
+
838
1233
  // ---------------------------------------------------------------------------
839
1234
  // Value-level difference
840
1235
  // ---------------------------------------------------------------------------