@prisma-next/cli 0.12.0 → 0.13.0-dev.10

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 (213) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.mjs +180 -163
  3. package/dist/cli.mjs.map +1 -1
  4. package/dist/{client-KgJorIvG.mjs → client-CJzuo5wX.mjs} +222 -107
  5. package/dist/client-CJzuo5wX.mjs.map +1 -0
  6. package/dist/{command-helpers-Bbw1GbwL.mjs → command-helpers-DGMvGBeX.mjs} +318 -25
  7. package/dist/command-helpers-DGMvGBeX.mjs.map +1 -0
  8. package/dist/commands/contract-emit.d.mts.map +1 -1
  9. package/dist/commands/contract-emit.mjs +1 -1
  10. package/dist/commands/contract-infer.d.mts.map +1 -1
  11. package/dist/commands/contract-infer.mjs +1 -1
  12. package/dist/commands/db-init.d.mts.map +1 -1
  13. package/dist/commands/db-init.mjs +4 -5
  14. package/dist/commands/db-init.mjs.map +1 -1
  15. package/dist/commands/db-schema.d.mts.map +1 -1
  16. package/dist/commands/db-schema.mjs +3 -3
  17. package/dist/commands/db-schema.mjs.map +1 -1
  18. package/dist/commands/db-sign.d.mts.map +1 -1
  19. package/dist/commands/db-sign.mjs +6 -6
  20. package/dist/commands/db-sign.mjs.map +1 -1
  21. package/dist/commands/db-update.d.mts.map +1 -1
  22. package/dist/commands/db-update.mjs +10 -7
  23. package/dist/commands/db-update.mjs.map +1 -1
  24. package/dist/commands/db-verify.d.mts.map +1 -1
  25. package/dist/commands/db-verify.mjs +1 -1
  26. package/dist/commands/migrate.d.mts +37 -3
  27. package/dist/commands/migrate.d.mts.map +1 -1
  28. package/dist/commands/migrate.mjs +298 -12
  29. package/dist/commands/migrate.mjs.map +1 -1
  30. package/dist/commands/migration-check.d.mts +55 -13
  31. package/dist/commands/migration-check.d.mts.map +1 -1
  32. package/dist/commands/migration-check.mjs +3 -2
  33. package/dist/commands/migration-graph.d.mts +17 -8
  34. package/dist/commands/migration-graph.d.mts.map +1 -1
  35. package/dist/commands/migration-graph.mjs +185 -2
  36. package/dist/commands/migration-graph.mjs.map +1 -0
  37. package/dist/commands/migration-list.d.mts +26 -27
  38. package/dist/commands/migration-list.d.mts.map +1 -1
  39. package/dist/commands/migration-list.mjs +2 -190
  40. package/dist/commands/migration-log.d.mts +9 -19
  41. package/dist/commands/migration-log.d.mts.map +1 -1
  42. package/dist/commands/migration-log.mjs +1 -137
  43. package/dist/commands/migration-new.d.mts.map +1 -1
  44. package/dist/commands/migration-new.mjs +6 -5
  45. package/dist/commands/migration-new.mjs.map +1 -1
  46. package/dist/commands/migration-plan.d.mts +1 -1
  47. package/dist/commands/migration-plan.d.mts.map +1 -1
  48. package/dist/commands/migration-plan.mjs +1 -1
  49. package/dist/commands/migration-show.d.mts +17 -21
  50. package/dist/commands/migration-show.d.mts.map +1 -1
  51. package/dist/commands/migration-show.mjs +24 -36
  52. package/dist/commands/migration-show.mjs.map +1 -1
  53. package/dist/commands/migration-status.d.mts +42 -144
  54. package/dist/commands/migration-status.d.mts.map +1 -1
  55. package/dist/commands/migration-status.mjs +3 -759
  56. package/dist/commands/ref.d.mts +1 -1
  57. package/dist/commands/ref.d.mts.map +1 -1
  58. package/dist/commands/ref.mjs +4 -4
  59. package/dist/commands/ref.mjs.map +1 -1
  60. package/dist/commands/telemetry/index.d.mts +7 -0
  61. package/dist/commands/telemetry/index.d.mts.map +1 -0
  62. package/dist/commands/telemetry/index.mjs +2 -0
  63. package/dist/{config-loader-B6sJjXTv.mjs → config-loader-p9JMrekQ.mjs} +1 -1
  64. package/dist/{config-loader-B6sJjXTv.mjs.map → config-loader-p9JMrekQ.mjs.map} +1 -1
  65. package/dist/config-loader.mjs +1 -1
  66. package/dist/{contract-at-errors-BxP-TOMl.mjs → contract-at-errors-CFXsstzm.mjs} +2 -2
  67. package/dist/{contract-at-errors-BxP-TOMl.mjs.map → contract-at-errors-CFXsstzm.mjs.map} +1 -1
  68. package/dist/{contract-emit-DxcGl4Uq.mjs → contract-emit-B_qriF8B.mjs} +5 -5
  69. package/dist/{contract-emit-DxcGl4Uq.mjs.map → contract-emit-B_qriF8B.mjs.map} +1 -1
  70. package/dist/{contract-emit-D-4jrNve.mjs → contract-emit-C8HmtboH.mjs} +12 -7
  71. package/dist/contract-emit-C8HmtboH.mjs.map +1 -0
  72. package/dist/{contract-enrichment-a0V5Y_mL.mjs → contract-enrichment-gn9sWbPw.mjs} +1 -1
  73. package/dist/{contract-enrichment-a0V5Y_mL.mjs.map → contract-enrichment-gn9sWbPw.mjs.map} +1 -1
  74. package/dist/{contract-infer-D8uEbJuu.mjs → contract-infer-BYT_ra_U.mjs} +5 -5
  75. package/dist/contract-infer-BYT_ra_U.mjs.map +1 -0
  76. package/dist/{contract-space-aggregate-loader-DvZwdkrr.mjs → contract-space-aggregate-loader-ClI1KN6d.mjs} +5 -5
  77. package/dist/{contract-space-aggregate-loader-DvZwdkrr.mjs.map → contract-space-aggregate-loader-ClI1KN6d.mjs.map} +1 -1
  78. package/dist/{db-verify-v_vUKXTU.mjs → db-verify-C24FKhb7.mjs} +6 -6
  79. package/dist/{db-verify-v_vUKXTU.mjs.map → db-verify-C24FKhb7.mjs.map} +1 -1
  80. package/dist/exports/control-api.d.mts +5 -3
  81. package/dist/exports/control-api.d.mts.map +1 -1
  82. package/dist/exports/control-api.mjs +3 -3
  83. package/dist/exports/index.mjs +1 -1
  84. package/dist/exports/index.mjs.map +1 -1
  85. package/dist/exports/init-output.d.mts +1 -3
  86. package/dist/exports/init-output.d.mts.map +1 -1
  87. package/dist/exports/init-output.mjs +1 -1
  88. package/dist/{extension-pack-inputs-IDvjRCi3.mjs → extension-pack-inputs-1ySHqxKG.mjs} +1 -1
  89. package/dist/{extension-pack-inputs-IDvjRCi3.mjs.map → extension-pack-inputs-1ySHqxKG.mjs.map} +1 -1
  90. package/dist/{framework-components-fYXjz_in.mjs → framework-components-YVQHhPH7.mjs} +2 -2
  91. package/dist/{framework-components-fYXjz_in.mjs.map → framework-components-YVQHhPH7.mjs.map} +1 -1
  92. package/dist/{global-flags-DEHjV8_s.d.mts → global-flags-BpoOYtNZ.d.mts} +1 -1
  93. package/dist/{global-flags-DEHjV8_s.d.mts.map → global-flags-BpoOYtNZ.d.mts.map} +1 -1
  94. package/dist/{init-Cv9UzWL5.mjs → init-0HwB-Vh8.mjs} +5 -58
  95. package/dist/init-0HwB-Vh8.mjs.map +1 -0
  96. package/dist/{inspect-live-schema-C6ohV_oQ.mjs → inspect-live-schema-DF6IwcDl.mjs} +7 -5
  97. package/dist/inspect-live-schema-DF6IwcDl.mjs.map +1 -0
  98. package/dist/migration-check-soB5uZEQ.mjs +573 -0
  99. package/dist/migration-check-soB5uZEQ.mjs.map +1 -0
  100. package/dist/migration-cli.mjs +1 -1
  101. package/dist/migration-cli.mjs.map +1 -1
  102. package/dist/{migration-command-scaffold-CjvwO6at.mjs → migration-command-scaffold-DA-Lhx6o.mjs} +5 -5
  103. package/dist/{migration-command-scaffold-CjvwO6at.mjs.map → migration-command-scaffold-DA-Lhx6o.mjs.map} +1 -1
  104. package/dist/migration-graph-command-render-CEez7YUK.mjs +1960 -0
  105. package/dist/migration-graph-command-render-CEez7YUK.mjs.map +1 -0
  106. package/dist/migration-list-DlJJ_38Z.mjs +230 -0
  107. package/dist/migration-list-DlJJ_38Z.mjs.map +1 -0
  108. package/dist/migration-log-CG0qQAFm.mjs +222 -0
  109. package/dist/migration-log-CG0qQAFm.mjs.map +1 -0
  110. package/dist/migration-path-target-Ce6OZImp.mjs +38 -0
  111. package/dist/migration-path-target-Ce6OZImp.mjs.map +1 -0
  112. package/dist/{migration-plan-9DJ7q7_z.mjs → migration-plan-z5Ing-TD.mjs} +9 -8
  113. package/dist/migration-plan-z5Ing-TD.mjs.map +1 -0
  114. package/dist/migration-status-CgWSoI_g.mjs +446 -0
  115. package/dist/migration-status-CgWSoI_g.mjs.map +1 -0
  116. package/dist/{output-B60Gw5fu.mjs → output-mEQ74_nd.mjs} +1 -1
  117. package/dist/{output-B60Gw5fu.mjs.map → output-mEQ74_nd.mjs.map} +1 -1
  118. package/dist/{progress-adapter-C644QK8l.mjs → progress-adapter-CjAeTxY_.mjs} +1 -1
  119. package/dist/{progress-adapter-C644QK8l.mjs.map → progress-adapter-CjAeTxY_.mjs.map} +1 -1
  120. package/dist/{ref-advancement-DUZqsue6.mjs → ref-advancement-BkXlikCA.mjs} +1 -1
  121. package/dist/{ref-advancement-DUZqsue6.mjs.map → ref-advancement-BkXlikCA.mjs.map} +1 -1
  122. package/dist/schemas-CeGMYFYX.d.mts +191 -0
  123. package/dist/schemas-CeGMYFYX.d.mts.map +1 -0
  124. package/dist/schemas-KhXMzNA_.mjs +112 -0
  125. package/dist/schemas-KhXMzNA_.mjs.map +1 -0
  126. package/dist/telemetry-BIM4beEO.mjs +122 -0
  127. package/dist/telemetry-BIM4beEO.mjs.map +1 -0
  128. package/dist/{terminal-ui-5Y6mrg93.d.mts → terminal-ui-DGRNFWna.d.mts} +1 -1
  129. package/dist/terminal-ui-DGRNFWna.d.mts.map +1 -0
  130. package/dist/{types-Dt_SfqFm.d.mts → types-C_tYiJYx.d.mts} +53 -31
  131. package/dist/types-C_tYiJYx.d.mts.map +1 -0
  132. package/dist/{verify-DCA9Sldu.mjs → verify-DcOYZ1tH.mjs} +2 -2
  133. package/dist/{verify-DCA9Sldu.mjs.map → verify-DcOYZ1tH.mjs.map} +1 -1
  134. package/package.json +26 -22
  135. package/src/cli.ts +5 -0
  136. package/src/commands/contract-infer.ts +2 -2
  137. package/src/commands/db-update.ts +7 -1
  138. package/src/commands/init/index.ts +6 -35
  139. package/src/commands/init/init.ts +1 -14
  140. package/src/commands/init/inputs.ts +0 -75
  141. package/src/commands/inspect-live-schema.ts +10 -0
  142. package/src/commands/json/schemas.ts +195 -0
  143. package/src/commands/migrate.ts +527 -8
  144. package/src/commands/migration-check.ts +469 -134
  145. package/src/commands/migration-graph.ts +164 -91
  146. package/src/commands/migration-list.ts +72 -39
  147. package/src/commands/migration-log.ts +52 -102
  148. package/src/commands/migration-new.ts +2 -1
  149. package/src/commands/migration-plan.ts +2 -1
  150. package/src/commands/migration-show.ts +31 -66
  151. package/src/commands/migration-status-overlay.ts +61 -0
  152. package/src/commands/migration-status.ts +458 -1066
  153. package/src/commands/telemetry/index.ts +107 -0
  154. package/src/commands/telemetry/status.ts +67 -0
  155. package/src/control-api/client.ts +70 -9
  156. package/src/control-api/operations/contract-emit.ts +22 -2
  157. package/src/control-api/operations/db-init.ts +6 -3
  158. package/src/control-api/operations/{db-apply.ts → db-run.ts} +55 -14
  159. package/src/control-api/operations/db-update.ts +7 -4
  160. package/src/control-api/operations/db-verify.ts +15 -5
  161. package/src/control-api/operations/{migration-apply.ts → migrate.ts} +181 -80
  162. package/src/control-api/operations/{apply.ts → run-migration.ts} +33 -27
  163. package/src/control-api/types.ts +56 -29
  164. package/src/utils/cli-errors.ts +70 -2
  165. package/src/utils/formatters/errors.ts +11 -0
  166. package/src/utils/formatters/migration-graph-command-render.ts +239 -0
  167. package/src/utils/formatters/migration-graph-grid-layout.ts +1134 -0
  168. package/src/utils/formatters/migration-graph-labels.ts +408 -0
  169. package/src/utils/formatters/migration-graph-model.ts +103 -0
  170. package/src/utils/formatters/migration-graph-occlusion-render.ts +258 -0
  171. package/src/utils/formatters/migration-graph-rows.ts +128 -15
  172. package/src/utils/formatters/migration-graph-space-render.ts +188 -0
  173. package/src/utils/formatters/migration-list-data-column.ts +4 -91
  174. package/src/utils/formatters/migration-list-graph-topology.ts +72 -94
  175. package/src/utils/formatters/migration-list-render.ts +135 -71
  176. package/src/utils/formatters/migration-list-styler.ts +46 -5
  177. package/src/utils/formatters/migration-list-types.ts +5 -21
  178. package/src/utils/formatters/migration-log-table.ts +205 -0
  179. package/src/utils/formatters/migrations.ts +33 -11
  180. package/src/utils/global-flags.ts +35 -0
  181. package/src/utils/integrity-violation-to-check-failure.ts +28 -19
  182. package/src/utils/legend.ts +38 -0
  183. package/src/utils/migration-path-target.ts +60 -0
  184. package/src/utils/telemetry.ts +68 -32
  185. package/dist/client-KgJorIvG.mjs.map +0 -1
  186. package/dist/command-helpers-Bbw1GbwL.mjs.map +0 -1
  187. package/dist/commands/migration-list.mjs.map +0 -1
  188. package/dist/commands/migration-log.mjs.map +0 -1
  189. package/dist/commands/migration-status.mjs.map +0 -1
  190. package/dist/contract-emit-D-4jrNve.mjs.map +0 -1
  191. package/dist/contract-infer-D8uEbJuu.mjs.map +0 -1
  192. package/dist/graph-render-rFAqZujX.mjs +0 -1081
  193. package/dist/graph-render-rFAqZujX.mjs.map +0 -1
  194. package/dist/init-Cv9UzWL5.mjs.map +0 -1
  195. package/dist/inspect-live-schema-C6ohV_oQ.mjs.map +0 -1
  196. package/dist/migration-check-BiBJoYYW.mjs +0 -341
  197. package/dist/migration-check-BiBJoYYW.mjs.map +0 -1
  198. package/dist/migration-graph-D7DVUElV.mjs +0 -1232
  199. package/dist/migration-graph-D7DVUElV.mjs.map +0 -1
  200. package/dist/migration-list-styler-BRwF4-gy.mjs +0 -399
  201. package/dist/migration-list-styler-BRwF4-gy.mjs.map +0 -1
  202. package/dist/migration-plan-9DJ7q7_z.mjs.map +0 -1
  203. package/dist/migration-types-D2FW63pr.d.mts +0 -15
  204. package/dist/migration-types-D2FW63pr.d.mts.map +0 -1
  205. package/dist/migrations-Cv2jxNNK.mjs +0 -228
  206. package/dist/migrations-Cv2jxNNK.mjs.map +0 -1
  207. package/dist/terminal-ui-5Y6mrg93.d.mts.map +0 -1
  208. package/dist/types-Dt_SfqFm.d.mts.map +0 -1
  209. package/src/utils/formatters/graph-migration-mapper.ts +0 -235
  210. package/src/utils/formatters/graph-render.ts +0 -1323
  211. package/src/utils/formatters/graph-types.ts +0 -120
  212. package/src/utils/formatters/migration-graph-layout.ts +0 -1119
  213. package/src/utils/formatters/migration-graph-tree-render.ts +0 -459
@@ -0,0 +1,1134 @@
1
+ /**
2
+ * Grid layout for the line/plane/occlusion migration-graph renderer.
3
+ *
4
+ * Produces a Grid (rows × cells) from a MigrationGraphRowModel. Each node
5
+ * emits: fork connector, self-loop rows, node row, merge connector, and
6
+ * inbound migration rows — in display order (tips first, then roots).
7
+ */
8
+
9
+ import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
10
+ import {
11
+ type Cell,
12
+ type CellLine,
13
+ DEFAULT_COLS_PER_LANE,
14
+ type Direction,
15
+ type Grid,
16
+ type GridOptions,
17
+ type Highlight,
18
+ type LineRef,
19
+ type NodeRef,
20
+ type PathRole,
21
+ } from './migration-graph-model';
22
+ import type { ClassifiedEdge, MigrationGraphRowModel } from './migration-graph-rows';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Internal: lane + rank assignment
26
+ // ---------------------------------------------------------------------------
27
+
28
+ interface LaneAssignment {
29
+ nodeLane: Map<string, number>;
30
+ nodeRank: Map<string, number>;
31
+ /**
32
+ * Per-edge lane override. Set for "direct fork-to-merge" branch edges whose
33
+ * endpoints both land on lane 0 after merge reconciliation, but whose BFS
34
+ * traversal allocated them a non-zero branch lane. Using this override lets
35
+ * the branch edge render in its branch column even when the merge tip was
36
+ * pulled back to the trunk lane.
37
+ */
38
+ edgeLane: Map<string, number>;
39
+ /** Total number of lanes allocated. */
40
+ numLanes: number;
41
+ }
42
+
43
+ function buildLaneAssignment(
44
+ nodes: readonly (string | null)[],
45
+ edges: readonly ClassifiedEdge[],
46
+ ): LaneAssignment {
47
+ // Separate forward (non-self) edges
48
+ const fwdEdges = edges.filter((e) => e.kind === 'forward' && e.from !== e.to);
49
+
50
+ // Build outbound/inbound adjacency sorted by dirName
51
+ const outbound = new Map<string, ClassifiedEdge[]>();
52
+ const inbound = new Map<string, ClassifiedEdge[]>();
53
+ for (const edge of fwdEdges) {
54
+ const ob = outbound.get(edge.from);
55
+ if (ob) ob.push(edge);
56
+ else outbound.set(edge.from, [edge]);
57
+
58
+ const ib = inbound.get(edge.to);
59
+ if (ib) ib.push(edge);
60
+ else inbound.set(edge.to, [edge]);
61
+ }
62
+ for (const list of outbound.values()) list.sort((a, b) => a.dirName.localeCompare(b.dirName));
63
+ for (const list of inbound.values()) list.sort((a, b) => a.dirName.localeCompare(b.dirName));
64
+
65
+ // Split nodes into per-component groups (null sentinels separate components)
66
+ const components: string[][] = [];
67
+ let current: string[] = [];
68
+ for (const n of nodes) {
69
+ if (n === null) {
70
+ if (current.length > 0) components.push(current);
71
+ current = [];
72
+ } else {
73
+ current.push(n);
74
+ }
75
+ }
76
+ if (current.length > 0) components.push(current);
77
+
78
+ // Global rank map (longest-forward-path; computed across all nodes together
79
+ // so rollback edges crossing components don't interfere with rank within each)
80
+ const allNodes = new Set<string>();
81
+ for (const n of nodes) {
82
+ if (n !== null) allNodes.add(n);
83
+ }
84
+ const nodeRank = new Map<string, number>();
85
+ for (const n of allNodes) nodeRank.set(n, 0);
86
+ for (let pass = 0; pass < allNodes.size; pass++) {
87
+ let changed = false;
88
+ for (const [from, es] of outbound) {
89
+ const base = nodeRank.get(from) ?? 0;
90
+ for (const e of es) {
91
+ const next = base + 1;
92
+ if (next > (nodeRank.get(e.to) ?? 0)) {
93
+ nodeRank.set(e.to, next);
94
+ changed = true;
95
+ }
96
+ }
97
+ }
98
+ if (!changed) break;
99
+ }
100
+
101
+ // Lane assignment: BFS per component, resetting nextLane to 0 for each.
102
+ // Each component's roots start at lane 0, so disconnected components never
103
+ // interleave lanes.
104
+ const nodeLane = new Map<string, number>();
105
+ // Per-edge lane: records the BFS-allocated branch lane for each edge. Used
106
+ // to preserve branch-column rendering even after merge-tip reconciliation.
107
+ const edgeLane = new Map<string, number>();
108
+ let totalLanes = 0;
109
+
110
+ for (const componentNodes of components) {
111
+ const componentSet = new Set(componentNodes);
112
+ let nextLane = 0;
113
+
114
+ const roots: string[] = [];
115
+ for (const n of componentNodes) {
116
+ if ((inbound.get(n) ?? []).length === 0) roots.push(n);
117
+ }
118
+ roots.sort((a, b) => {
119
+ if (a === EMPTY_CONTRACT_HASH) return -1;
120
+ if (b === EMPTY_CONTRACT_HASH) return 1;
121
+ return a.localeCompare(b);
122
+ });
123
+
124
+ const bfsQueue: Array<{ node: string; lane: number }> = [];
125
+ for (const root of roots) {
126
+ if (!nodeLane.has(root)) {
127
+ nodeLane.set(root, nextLane++);
128
+ bfsQueue.push({ node: root, lane: nodeLane.get(root)! });
129
+ }
130
+ }
131
+
132
+ let head = 0;
133
+ while (head < bfsQueue.length) {
134
+ const item = bfsQueue[head++]!;
135
+ const { node, lane } = item;
136
+ const children = outbound.get(node) ?? [];
137
+ let first = true;
138
+ for (const childEdge of children) {
139
+ const child = childEdge.to;
140
+ if (!componentSet.has(child)) continue;
141
+ if (!nodeLane.has(child)) {
142
+ const childLane = first ? lane : nextLane++;
143
+ nodeLane.set(child, childLane);
144
+ bfsQueue.push({ node: child, lane: childLane });
145
+ edgeLane.set(childEdge.migrationHash, childLane);
146
+ } else {
147
+ // Child already assigned — record this edge's lane as the max of the
148
+ // parent's lane and the child's current lane (same as the original
149
+ // Math.max formula). May be updated by reconciliation below for trunk
150
+ // edges into reconciled merge nodes.
151
+ edgeLane.set(childEdge.migrationHash, Math.max(lane, nodeLane.get(child)!));
152
+ }
153
+ first = false;
154
+ }
155
+ }
156
+
157
+ // Isolated nodes within the component
158
+ for (const n of componentNodes) {
159
+ if (!nodeLane.has(n)) nodeLane.set(n, nextLane++);
160
+ }
161
+
162
+ // Merge-node lane reconciliation: a node with multiple inbound forward edges
163
+ // should sit on the lane of its highest-rank parent (furthest along the
164
+ // longest path). When a short arm and a long arm converge, the merge node
165
+ // follows the long arm's lane.
166
+ //
167
+ // When a merge node's lane changes, update the edgeLane for all edges
168
+ // pointing TO that node so they reflect the reconciled column. Edges from
169
+ // nodes that were on a BRANCH (non-trunk) lane keep their original branch
170
+ // lane so the branch column renders correctly.
171
+ for (const n of componentNodes) {
172
+ const parents = inbound.get(n);
173
+ if (!parents || parents.length <= 1) continue;
174
+ let trunkParent = parents[0]!.from;
175
+ let trunkRank = nodeRank.get(trunkParent) ?? 0;
176
+ let trunkLane = nodeLane.get(trunkParent) ?? 0;
177
+ for (let i = 1; i < parents.length; i++) {
178
+ const parent = parents[i]!.from;
179
+ const rank = nodeRank.get(parent) ?? 0;
180
+ const lane = nodeLane.get(parent) ?? 0;
181
+ if (rank > trunkRank || (rank === trunkRank && lane < trunkLane)) {
182
+ trunkParent = parent;
183
+ trunkRank = rank;
184
+ trunkLane = lane;
185
+ }
186
+ }
187
+ const trunkParentLane = nodeLane.get(trunkParent) ?? 0;
188
+ const currentNodeLane = nodeLane.get(n) ?? 0;
189
+ if (currentNodeLane === trunkParentLane) continue;
190
+
191
+ nodeLane.set(n, trunkParentLane);
192
+
193
+ // Update edgeLane for each inbound edge:
194
+ // - Trunk edge (from the highest-rank parent): use the trunk lane
195
+ // - Branch edges: keep the ORIGINAL edgeLane (the branch column), so
196
+ // the branch edge still renders in its allocated branch column.
197
+ for (const parentEdge of parents) {
198
+ const isFromTrunkParent = parentEdge.from === trunkParent;
199
+ if (isFromTrunkParent) {
200
+ edgeLane.set(parentEdge.migrationHash, trunkParentLane);
201
+ }
202
+ // Branch edges keep whatever lane they were assigned during BFS.
203
+ }
204
+
205
+ // Propagate the lane change to forward descendants that inherited the
206
+ // old lane. BFS from this merge node through outbound edges: any
207
+ // descendant still on oldLane moves to the new (trunk) lane. Stop the
208
+ // traversal at nodes that are already on a different lane — they belong
209
+ // to branches that forked independently and must not move.
210
+ const bfsDescendants: string[] = [n];
211
+ let descHead = 0;
212
+ while (descHead < bfsDescendants.length) {
213
+ const current = bfsDescendants[descHead++]!;
214
+ const children = outbound.get(current) ?? [];
215
+ for (const childEdge of children) {
216
+ const child = childEdge.to;
217
+ if (!componentSet.has(child)) continue;
218
+ if ((nodeLane.get(child) ?? 0) !== currentNodeLane) continue;
219
+ nodeLane.set(child, trunkParentLane);
220
+ // Update the edge lane for the edge from current→child
221
+ const existingEdgeLane = edgeLane.get(childEdge.migrationHash);
222
+ if (existingEdgeLane !== undefined && existingEdgeLane === currentNodeLane) {
223
+ edgeLane.set(childEdge.migrationHash, trunkParentLane);
224
+ }
225
+ bfsDescendants.push(child);
226
+ }
227
+ }
228
+ }
229
+
230
+ if (nextLane > totalLanes) totalLanes = nextLane;
231
+ }
232
+
233
+ return { nodeLane, nodeRank, edgeLane, numLanes: totalLanes };
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Internal: display order
238
+ // ---------------------------------------------------------------------------
239
+
240
+ interface NodeDisplay {
241
+ hash: string;
242
+ lane: number;
243
+ rank: number;
244
+ }
245
+
246
+ /**
247
+ * A `null` sentinel in the display order marks a component boundary.
248
+ * The grid builder emits a separator row at each boundary.
249
+ */
250
+ type NodeDisplayOrSeparator = NodeDisplay | null;
251
+
252
+ function computeDisplayOrder(
253
+ nodes: readonly (string | null)[],
254
+ nodeLane: Map<string, number>,
255
+ nodeRank: Map<string, number>,
256
+ ): NodeDisplayOrSeparator[] {
257
+ const seen = new Set<string>();
258
+ const result: NodeDisplayOrSeparator[] = [];
259
+
260
+ // Collect each component's nodes then sort within it (rank desc, lane asc).
261
+ // null sentinels mark component boundaries; they become separator entries.
262
+ let componentBuffer: NodeDisplay[] = [];
263
+
264
+ function flushComponent(): void {
265
+ componentBuffer.sort((a, b) => b.rank - a.rank || a.lane - b.lane);
266
+ for (const d of componentBuffer) result.push(d);
267
+ componentBuffer = [];
268
+ }
269
+
270
+ for (const n of nodes) {
271
+ if (n === null) {
272
+ flushComponent();
273
+ result.push(null);
274
+ continue;
275
+ }
276
+ if (seen.has(n)) continue;
277
+ seen.add(n);
278
+ componentBuffer.push({ hash: n, lane: nodeLane.get(n) ?? 0, rank: nodeRank.get(n) ?? 0 });
279
+ }
280
+ flushComponent();
281
+
282
+ return result;
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Internal: grid row builder
287
+ // ---------------------------------------------------------------------------
288
+
289
+ type CellsRow = Cell[];
290
+
291
+ /** Create an empty cell. */
292
+ function emptyCell(): Cell {
293
+ return { lines: [] };
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // buildGrid — main entry point
298
+ // ---------------------------------------------------------------------------
299
+
300
+ export function buildGrid(
301
+ rowModel: MigrationGraphRowModel,
302
+ opts: GridOptions = {},
303
+ highlight: Highlight = { mode: 'flat', onPath: new Set() },
304
+ ): Grid {
305
+ const colsPerLane = opts.colsPerLane ?? DEFAULT_COLS_PER_LANE;
306
+ const isFocus = highlight.mode === 'focus';
307
+
308
+ const { nodeLane, nodeRank, edgeLane, numLanes } = buildLaneAssignment(
309
+ rowModel.nodes,
310
+ rowModel.edges,
311
+ );
312
+
313
+ const displayOrder = computeDisplayOrder(rowModel.nodes, nodeLane, nodeRank);
314
+
315
+ // Display index per node (0 = topmost position; nulls skipped).
316
+ const displayIndex = new Map<string, number>();
317
+ let nodeIdx = 0;
318
+ for (const d of displayOrder) {
319
+ if (d !== null) {
320
+ displayIndex.set(d.hash, nodeIdx++);
321
+ }
322
+ }
323
+
324
+ // ── Back-arc planning ────────────────────────────────────────────────────
325
+ // Each rollback edge runs against the forward grain. An *adjacent* rollback
326
+ // (target is the display-neighbour directly below the source) is a plain ↓ in
327
+ // the source's own lane. A *node-skipping* rollback is routed on its own
328
+ // back-lane to the right: it tees off the source node row (○─╮), runs a
329
+ // vertical │ down its back-lane, and lands into the target node (◂╯).
330
+ //
331
+ // Three independent numbers per routed back-arc:
332
+ // geomLane — the column its rail occupies. Outermost (largest) goes to the
333
+ // arc reaching the lowest target (ties: higher source first), so
334
+ // interleaving spans cross and nested spans nest cleanly.
335
+ // colourLane — the lane index used purely for colour (flat mode). Assigned
336
+ // by greedy colouring (bottom-up walk; see below) so that no
337
+ // two concurrently-active lanes/arcs share a palette colour,
338
+ // and no arc reuses its origin branch's colour or green.
339
+ // planeLane — the z-order index for occlusion within a shared back-lane.
340
+ // Arcs sharing the same geomLane are sorted by sourceIndex
341
+ // descending: the arc whose source is lowest in display
342
+ // (largest sourceIndex = bottom-most visually) draws on top
343
+ // (smallest planeLane number). Decoupled from colourLane.
344
+ interface RoutedBackArc {
345
+ readonly edge: ClassifiedEdge;
346
+ readonly sourceIndex: number;
347
+ readonly targetIndex: number;
348
+ readonly geomLane: number;
349
+ readonly colourLane: number;
350
+ readonly planeLane: number;
351
+ }
352
+
353
+ const rollbackEdges = rowModel.edges.filter((e) => e.kind === 'rollback' && e.from !== e.to);
354
+
355
+ const adjacentRollbacks: ClassifiedEdge[] = [];
356
+ const skippingRollbacks: ClassifiedEdge[] = [];
357
+ for (const e of rollbackEdges) {
358
+ const si = displayIndex.get(e.from);
359
+ const ti = displayIndex.get(e.to);
360
+ if (si === undefined || ti === undefined) continue;
361
+ // Adjacent: target sits directly below the source in display order.
362
+ if (ti === si + 1) adjacentRollbacks.push(e);
363
+ else skippingRollbacks.push(e);
364
+ }
365
+
366
+ // Convergence: group skipping rollbacks by their target node. Arcs sharing a
367
+ // target share one geometric lane (rail column). Each distinct target gets its
368
+ // own rail; arcs within the group compose via occlusion.
369
+ //
370
+ // geomLane ordering: outermost rail goes to the group whose target is lowest
371
+ // in display order (largest target index — deepest in the chain). Within a
372
+ // group, the group's representative target index drives the ordering.
373
+ const targetGroups = new Map<string, ClassifiedEdge[]>();
374
+ for (const e of skippingRollbacks) {
375
+ const group = targetGroups.get(e.to);
376
+ if (group) group.push(e);
377
+ else targetGroups.set(e.to, [e]);
378
+ }
379
+ // Sort target-group keys: largest target index (lowest in display) → outermost lane.
380
+ const sortedTargetKeys = [...targetGroups.keys()].sort((a, b) => {
381
+ const ta = displayIndex.get(a) ?? 0;
382
+ const tb = displayIndex.get(b) ?? 0;
383
+ return tb - ta; // largest index first = outermost
384
+ });
385
+ const numTargetGroups = sortedTargetKeys.length;
386
+ const geomLaneOf = new Map<string, number>();
387
+ const outermostGroup = numLanes + numTargetGroups - 1;
388
+ sortedTargetKeys.forEach((targetHash, i) => {
389
+ const groupGeomLane = outermostGroup - i;
390
+ for (const e of targetGroups.get(targetHash)!) {
391
+ geomLaneOf.set(e.migrationHash, groupGeomLane);
392
+ }
393
+ });
394
+
395
+ // ── planeLane: z-order for back-arcs ────────────────────────────────────
396
+ // The arc whose source is furthest down the display (largest sourceIndex)
397
+ // draws on top (lowest planeLane). This applies both within shared back-lanes
398
+ // and at crossing points where arcs on different geomLanes overlap.
399
+ // planeLane = totalNodes - sourceIndex gives: larger sourceIndex → smaller value.
400
+ const totalDisplayNodes = displayOrder.filter((d) => d !== null).length;
401
+ const planeLaneOf = new Map<string, number>();
402
+ for (const e of skippingRollbacks) {
403
+ const si = displayIndex.get(e.from) ?? 0;
404
+ planeLaneOf.set(e.migrationHash, totalDisplayNodes - si);
405
+ }
406
+
407
+ // ── colourLane: greedy assignment (flat mode) ─────────────────────────────
408
+ // Walk displayOrder bottom → top. Maintain the set of concurrently-active
409
+ // palette-colour indices (forward lanes + active back-arc assignments). When
410
+ // a new arc first becomes visible (at its target node, going upward), pick the
411
+ // lowest palette index not in use. Additionally exclude:
412
+ // - the arc's origin lane's colour (nodeLane.get(from) % PALETTE_SIZE)
413
+ // - index 5 (green — reserved for focus on-path)
414
+ // When the arc's source node is processed, release its colour.
415
+ //
416
+ // Forward lanes hold colour = laneIndex % PALETTE_SIZE (unchanged); back-arc
417
+ // colourLane is set to the chosen palette index directly (0–5), so that
418
+ // `colourLane % 6 == chosenIndex`.
419
+ const PALETTE_SIZE = 6;
420
+ const GREEN_PALETTE_IDX = 5;
421
+
422
+ // Precompute per-arc display indices for the walk.
423
+ const arcSourceIndex = new Map<string, number>();
424
+ const arcTargetIndex = new Map<string, number>();
425
+ for (const e of skippingRollbacks) {
426
+ arcSourceIndex.set(e.migrationHash, displayIndex.get(e.from) ?? 0);
427
+ arcTargetIndex.set(e.migrationHash, displayIndex.get(e.to) ?? 0);
428
+ }
429
+
430
+ // Build lookup: arcs by target node hash and source node hash.
431
+ const arcsByTarget = new Map<string, ClassifiedEdge[]>();
432
+ const arcsBySource = new Map<string, ClassifiedEdge[]>();
433
+ for (const e of skippingRollbacks) {
434
+ const tb = arcsByTarget.get(e.to);
435
+ if (tb) tb.push(e);
436
+ else arcsByTarget.set(e.to, [e]);
437
+ const sb = arcsBySource.get(e.from);
438
+ if (sb) sb.push(e);
439
+ else arcsBySource.set(e.from, [e]);
440
+ }
441
+
442
+ // Greedy walk: bottom → top through displayOrder.
443
+ const colourLaneOf = new Map<string, number>();
444
+ // activeArcColours: migHash → palette index currently in use by that arc.
445
+ const activeArcColours = new Map<string, number>();
446
+ // activeFwdLaneColours: set of palette indices held by currently-active forward lanes.
447
+ const activeFwdLaneColours = new Set<number>();
448
+
449
+ for (let i = displayOrder.length - 1; i >= 0; i--) {
450
+ const nd = displayOrder[i];
451
+ if (nd === null || nd === undefined) continue; // separator or missing — skip
452
+
453
+ const { hash: nodeHash } = nd;
454
+ const nodeFwdLane = nodeLane.get(nodeHash) ?? 0;
455
+
456
+ // 1. Activate this node's forward lane (if not already active from a lower node).
457
+ activeFwdLaneColours.add(nodeFwdLane % PALETTE_SIZE);
458
+
459
+ // 2. Assign colour to arcs that TARGET this node. They become visible
460
+ // starting here, running upward to their source.
461
+ const incomingArcs = arcsByTarget.get(nodeHash) ?? [];
462
+ // Process in a stable order (dirName) for determinism.
463
+ const sortedIncoming = [...incomingArcs].sort((a, b) => a.dirName.localeCompare(b.dirName));
464
+ for (const arc of sortedIncoming) {
465
+ const originLaneColour = (nodeLane.get(arc.from) ?? 0) % PALETTE_SIZE;
466
+ // Colours currently occupied.
467
+ const occupied = new Set<number>(activeFwdLaneColours);
468
+ for (const c of activeArcColours.values()) occupied.add(c);
469
+ occupied.add(GREEN_PALETTE_IDX);
470
+ occupied.add(originLaneColour);
471
+ // Pick the lowest free index; if all are taken, pick lowest excluding green.
472
+ let chosen = -1;
473
+ for (let ci = 0; ci < PALETTE_SIZE; ci++) {
474
+ if (!occupied.has(ci)) {
475
+ chosen = ci;
476
+ break;
477
+ }
478
+ }
479
+ if (chosen === -1) {
480
+ // Palette exhausted — forced reuse. Pick lowest excluding green.
481
+ for (let ci = 0; ci < PALETTE_SIZE; ci++) {
482
+ if (ci !== GREEN_PALETTE_IDX) {
483
+ chosen = ci;
484
+ break;
485
+ }
486
+ }
487
+ }
488
+ colourLaneOf.set(arc.migrationHash, chosen === -1 ? 0 : chosen);
489
+ activeArcColours.set(arc.migrationHash, chosen === -1 ? 0 : chosen);
490
+ }
491
+
492
+ // 3. Release arcs that SOURCE at this node. Their rail runs from here
493
+ // downward; above this node they're gone.
494
+ for (const arc of arcsBySource.get(nodeHash) ?? []) {
495
+ activeArcColours.delete(arc.migrationHash);
496
+ }
497
+ }
498
+
499
+ const routedBackArcs: RoutedBackArc[] = skippingRollbacks.map((e) => ({
500
+ edge: e,
501
+ sourceIndex: displayIndex.get(e.from) ?? 0,
502
+ targetIndex: displayIndex.get(e.to) ?? 0,
503
+ geomLane: geomLaneOf.get(e.migrationHash) ?? numLanes,
504
+ colourLane: colourLaneOf.get(e.migrationHash) ?? 0,
505
+ planeLane: planeLaneOf.get(e.migrationHash) ?? numLanes,
506
+ }));
507
+
508
+ const backArcsBySource = new Map<string, RoutedBackArc[]>();
509
+ const backArcsByTarget = new Map<string, RoutedBackArc[]>();
510
+ for (const arc of routedBackArcs) {
511
+ const sb = backArcsBySource.get(arc.edge.from);
512
+ if (sb) sb.push(arc);
513
+ else backArcsBySource.set(arc.edge.from, [arc]);
514
+ const tb = backArcsByTarget.get(arc.edge.to);
515
+ if (tb) tb.push(arc);
516
+ else backArcsByTarget.set(arc.edge.to, [arc]);
517
+ }
518
+
519
+ const adjacentBySource = new Map<string, ClassifiedEdge[]>();
520
+ const adjacentByTarget = new Map<string, ClassifiedEdge[]>();
521
+ for (const e of adjacentRollbacks) {
522
+ const b = adjacentBySource.get(e.from);
523
+ if (b) b.push(e);
524
+ else adjacentBySource.set(e.from, [e]);
525
+ const t = adjacentByTarget.get(e.to);
526
+ if (t) t.push(e);
527
+ else adjacentByTarget.set(e.to, [e]);
528
+ }
529
+ for (const list of adjacentBySource.values())
530
+ list.sort((a, b) => a.dirName.localeCompare(b.dirName));
531
+
532
+ const numBackLanes = numTargetGroups;
533
+ const totalCols = (numLanes + numBackLanes) * colsPerLane;
534
+
535
+ // Build edge lookup maps (classified)
536
+ const fwdEdges = rowModel.edges.filter((e) => e.kind === 'forward' && e.from !== e.to);
537
+ const selfEdges = rowModel.edges.filter((e) => e.kind === 'self');
538
+
539
+ // outbound sorted by migrationHash
540
+ const outboundFwd = new Map<string, ClassifiedEdge[]>();
541
+ const inboundFwd = new Map<string, ClassifiedEdge[]>();
542
+ for (const e of fwdEdges) {
543
+ const ob = outboundFwd.get(e.from);
544
+ if (ob) ob.push(e);
545
+ else outboundFwd.set(e.from, [e]);
546
+ const ib = inboundFwd.get(e.to);
547
+ if (ib) ib.push(e);
548
+ else inboundFwd.set(e.to, [e]);
549
+ }
550
+ for (const list of outboundFwd.values()) list.sort((a, b) => a.dirName.localeCompare(b.dirName));
551
+ for (const list of inboundFwd.values()) list.sort((a, b) => a.dirName.localeCompare(b.dirName));
552
+
553
+ const selfEdgesByNode = new Map<string, ClassifiedEdge[]>();
554
+ for (const e of selfEdges) {
555
+ const bucket = selfEdgesByNode.get(e.from);
556
+ if (bucket) bucket.push(e);
557
+ else selfEdgesByNode.set(e.from, [e]);
558
+ }
559
+ for (const list of selfEdgesByNode.values())
560
+ list.sort((a, b) => a.dirName.localeCompare(b.dirName));
561
+
562
+ // ── Role + plane: mode/z-order seam ──────────────────────────────────────
563
+ // role(migrationHash): focus → on-path/off-path from highlight.onPath; flat → undefined.
564
+ function roleOf(migrationHash: string): PathRole | undefined {
565
+ if (!isFocus) return undefined;
566
+ return highlight.onPath.has(migrationHash) ? 'on-path' : 'off-path';
567
+ }
568
+
569
+ // On-path node set: a node is on-path iff an on-path edge touches it (from or
570
+ // to) — forward, self, OR rollback (a back-arc's endpoints are on its route).
571
+ const onPathNodes = new Set<string>();
572
+ if (isFocus) {
573
+ for (const e of [...fwdEdges, ...selfEdges, ...rollbackEdges]) {
574
+ if (highlight.onPath.has(e.migrationHash)) {
575
+ onPathNodes.add(e.from);
576
+ onPathNodes.add(e.to);
577
+ }
578
+ }
579
+ }
580
+ function nodeRoleOf(hash: string): PathRole | undefined {
581
+ if (!isFocus) return undefined;
582
+ return onPathNodes.has(hash) ? 'on-path' : 'off-path';
583
+ }
584
+
585
+ // planeOf — z-order. Lower number = drawn on top.
586
+ // flat: trunk on top → plane = lane (lane 0 topmost).
587
+ // focus: on-path on top → on-path = plane 0; off-path sits beneath it,
588
+ // ordered by lane so a deterministic owner survives among off-path lines.
589
+ function planeOf(lane: number, role: PathRole | undefined): number {
590
+ if (!isFocus) return lane;
591
+ return role === 'on-path' ? 0 : lane + 1;
592
+ }
593
+
594
+ // ── LineRef + cell builders (role-aware) ─────────────────────────────────
595
+ function lineRefFor(edge: ClassifiedEdge, lane: number): LineRef {
596
+ return {
597
+ migrationHash: edge.migrationHash,
598
+ dirName: edge.dirName,
599
+ lane,
600
+ role: roleOf(edge.migrationHash),
601
+ };
602
+ }
603
+
604
+ /** Synthetic LineRef for a lane carrying a representative edge's role (pass-through). */
605
+ function passLineRef(lane: number, dirName: string, migHash: string): LineRef {
606
+ return { migrationHash: migHash, dirName, lane, role: roleOf(migHash) };
607
+ }
608
+
609
+ function vertCell(line: LineRef): Cell {
610
+ return {
611
+ lines: [
612
+ {
613
+ line,
614
+ directions: new Set<Direction>(['up', 'down']),
615
+ plane: planeOf(line.lane, line.role),
616
+ },
617
+ ],
618
+ };
619
+ }
620
+
621
+ function dirCell(line: LineRef, dirs: ReadonlySet<Direction>): Cell {
622
+ return { lines: [{ line, directions: dirs, plane: planeOf(line.lane, line.role) }] };
623
+ }
624
+
625
+ function nodeCell(nodeRef: NodeRef): Cell {
626
+ return { node: nodeRef, lines: [] };
627
+ }
628
+
629
+ // Pass-through colour follows the edge CURRENTLY occupying a lane at this row,
630
+ // not a lane-wide average. A single lane carries different edges (with different
631
+ // roles) over its vertical extent — e.g. lane 0 below a fork carries the trunk
632
+ // branch (off-path) above the fork node and the trunk's parent edge (on-path)
633
+ // below it. We track the active edge per lane as we descend top-to-bottom and
634
+ // colour pass-through verticals from it. `laneCurrentEdge[L]` = the edge whose
635
+ // vertical body currently runs through lane L at the row being emitted.
636
+ const laneCurrentEdge = new Map<number, ClassifiedEdge>();
637
+
638
+ function getRepLine(lane: number): LineRef {
639
+ const e = laneCurrentEdge.get(lane);
640
+ if (e) return lineRefFor(e, lane);
641
+ return passLineRef(lane, `lane${lane}`, `lane${lane}`);
642
+ }
643
+
644
+ // Active lanes: set of lane indices currently visible (vertical passes through them)
645
+ const activeLanes = new Set<number>();
646
+
647
+ const grid: Cell[][] = [];
648
+
649
+ function makeRow(): CellsRow {
650
+ return Array.from({ length: totalCols }, () => emptyCell());
651
+ }
652
+
653
+ // Place vertical pass-throughs for all active lanes in a row, skipping specified lanes.
654
+ function placeVerticals(row: CellsRow, skip: Set<number>): void {
655
+ for (const lane of activeLanes) {
656
+ if (skip.has(lane)) continue;
657
+ const railCol = lane * colsPerLane;
658
+ const cell = row[railCol];
659
+ if (cell !== undefined && cell.lines.length === 0 && !cell.node) {
660
+ row[railCol] = vertCell(getRepLine(lane));
661
+ }
662
+ }
663
+ }
664
+
665
+ // ── Back-arc helpers ──────────────────────────────────────────────────────
666
+ // Active routed back-arcs whose vertical currently runs through their geomLane.
667
+ const activeBackArcs = new Set<RoutedBackArc>();
668
+
669
+ // A back-arc's LineRef carries its colourLane (not its geomLane) so colour is
670
+ // read off the lane that drives the rotation, independent of column placement.
671
+ function backArcLine(arc: RoutedBackArc): LineRef {
672
+ return {
673
+ migrationHash: arc.edge.migrationHash,
674
+ dirName: arc.edge.dirName,
675
+ lane: arc.colourLane,
676
+ role: roleOf(arc.edge.migrationHash),
677
+ };
678
+ }
679
+
680
+ function backArcPlane(arc: RoutedBackArc): number {
681
+ const role = roleOf(arc.edge.migrationHash);
682
+ if (!isFocus) return arc.planeLane;
683
+ return role === 'on-path' ? 0 : arc.planeLane + 1;
684
+ }
685
+
686
+ // Compose a CellLine into a row cell (never overwrite — occlusion arbitrates).
687
+ function composeLine(
688
+ row: CellsRow,
689
+ col: number,
690
+ line: LineRef,
691
+ dirs: ReadonlySet<Direction>,
692
+ plane: number,
693
+ extra?: { landingArrow?: boolean },
694
+ ): void {
695
+ const existing = row[col];
696
+ const cellLine: CellLine = {
697
+ line,
698
+ directions: dirs,
699
+ plane,
700
+ ...(extra?.landingArrow ? { landingArrow: true } : {}),
701
+ };
702
+ if (existing && (existing.lines.length > 0 || existing.node)) {
703
+ row[col] = { ...existing, lines: [...existing.lines, cellLine] };
704
+ } else {
705
+ row[col] = { lines: [cellLine] };
706
+ }
707
+ }
708
+
709
+ // Place verticals for every active back-arc on this row (in its geomLane rail).
710
+ function placeBackVerticals(row: CellsRow): void {
711
+ for (const arc of activeBackArcs) {
712
+ const railCol = arc.geomLane * colsPerLane;
713
+ composeLine(
714
+ row,
715
+ railCol,
716
+ backArcLine(arc),
717
+ new Set<Direction>(['up', 'down']),
718
+ backArcPlane(arc),
719
+ );
720
+ }
721
+ placeAdjacentOverlays(row);
722
+ }
723
+
724
+ // Adjacent rollbacks share the source's own lane: their vertical body overlays
725
+ // the forward trunk between source and target. In focus, an on-path adjacent
726
+ // rollback lifts that segment of the trunk to the top plane (drawn green); in
727
+ // flat it sits at the same plane/colour as the trunk, so it is a no-op there.
728
+ interface ActiveAdjacent {
729
+ readonly lane: number;
730
+ readonly edge: ClassifiedEdge;
731
+ }
732
+ const activeAdjacent = new Set<ActiveAdjacent>();
733
+
734
+ function placeAdjacentOverlays(row: CellsRow): void {
735
+ for (const adj of activeAdjacent) {
736
+ const railCol = adj.lane * colsPerLane;
737
+ const cell = row[railCol];
738
+ if (cell?.node) continue; // never overlay a node marker
739
+ const line = lineRefFor(adj.edge, adj.lane);
740
+ composeLine(
741
+ row,
742
+ railCol,
743
+ line,
744
+ new Set<Direction>(['up', 'down']),
745
+ planeOf(adj.lane, line.role),
746
+ );
747
+ }
748
+ }
749
+
750
+ // Tee a routed back-arc off its source node row: a horizontal bridge from the
751
+ // node's connector column across to the back-lane rail, ending in a ╮ corner
752
+ // (down+left). Composed (not overwritten) so it occludes / is occluded by any
753
+ // back-arc vertical it crosses.
754
+ function emitBackArcTee(row: CellsRow, nodeLaneNum: number, arc: RoutedBackArc): void {
755
+ const nodeRail = nodeLaneNum * colsPerLane;
756
+ const geomRail = arc.geomLane * colsPerLane;
757
+ const line = backArcLine(arc);
758
+ const plane = backArcPlane(arc);
759
+ for (let col = nodeRail + 1; col < geomRail; col++) {
760
+ composeLine(row, col, line, new Set<Direction>(['left', 'right']), plane);
761
+ }
762
+ composeLine(row, geomRail, line, new Set<Direction>(['down', 'left']), plane);
763
+ }
764
+
765
+ // Land a routed back-arc into its target node row: a ◂ arrowhead in the node's
766
+ // connector column, a horizontal bridge across to the back-lane rail, ending in
767
+ // a ╯ corner (up+left). Composed so the on-top arc draws the anchor and the
768
+ // others yield their corners beneath it (occlusion arbitrates).
769
+ function emitBackArcLanding(row: CellsRow, nodeLaneNum: number, arc: RoutedBackArc): void {
770
+ const nodeRail = nodeLaneNum * colsPerLane;
771
+ const geomRail = arc.geomLane * colsPerLane;
772
+ const line = backArcLine(arc);
773
+ const plane = backArcPlane(arc);
774
+ composeLine(row, nodeRail + 1, line, new Set<Direction>(['left', 'right']), plane, {
775
+ landingArrow: true,
776
+ });
777
+ for (let col = nodeRail + 2; col < geomRail; col++) {
778
+ composeLine(row, col, line, new Set<Direction>(['left', 'right']), plane);
779
+ }
780
+ composeLine(row, geomRail, line, new Set<Direction>(['up', 'left']), plane);
781
+ }
782
+
783
+ // Emit a connector row (fork or merge).
784
+ //
785
+ // The CONTINUOUS lane gets the unbroken vertical/sweep; every other
786
+ // participating lane yields into its own corner. In flat mode the continuous
787
+ // lane is the trunk (lane of the node); in focus mode it is the on-path lane
788
+ // (the inbound/outbound edge whose migration is on-path), so the chosen route
789
+ // is drawn as one continuous green line sweeping the merge/fork.
790
+ //
791
+ // Geometry is identical regardless of which lane is continuous; only the
792
+ // NODE-ANCHOR glyph at the trunk rail changes:
793
+ // continuous == trunk → │ (vertical, the trunk passes straight through)
794
+ // continuous == a branch → corner toward that branch
795
+ // merge: ╰ (up+right) fork: ╭ (down+right)
796
+ // The branch's own rail always carries its yield corner (merge ╮ / fork ╯), and
797
+ // the cells between carry horizontals. The continuous (on-path) sweep is placed
798
+ // on the top plane so it occludes the trunk's vertical at the node anchor.
799
+ function emitConnectorRow(
800
+ trunkLane: number,
801
+ branchEntries: readonly { lane: number; edge: ClassifiedEdge }[],
802
+ connectorType: 'fork' | 'merge',
803
+ trunkEdge: ClassifiedEdge | undefined,
804
+ ): CellsRow {
805
+ const row = makeRow();
806
+ const sorted = [...branchEntries].sort((a, b) => a.lane - b.lane);
807
+ if (sorted.length === 0) return row;
808
+
809
+ const branchByLane = new Map<number, ClassifiedEdge>();
810
+ for (const b of sorted) branchByLane.set(b.lane, b.edge);
811
+
812
+ // Continuous lane: the on-path participant in focus, else the trunk.
813
+ let continuousLane = trunkLane;
814
+ if (isFocus) {
815
+ if (trunkEdge && highlight.onPath.has(trunkEdge.migrationHash)) {
816
+ continuousLane = trunkLane;
817
+ } else {
818
+ const onPathBranch = sorted.find((b) => highlight.onPath.has(b.edge.migrationHash));
819
+ if (onPathBranch) continuousLane = onPathBranch.lane;
820
+ }
821
+ }
822
+
823
+ const trunkRailCol = trunkLane * colsPerLane;
824
+ const continuousRailCol = continuousLane * colsPerLane;
825
+
826
+ // Add a CellLine to a cell (compose, don't overwrite) so occlusion arbitrates.
827
+ function addLine(col: number, line: LineRef, dirs: ReadonlySet<Direction>): void {
828
+ const existing = row[col];
829
+ const cellLine: CellLine = { line, directions: dirs, plane: planeOf(line.lane, line.role) };
830
+ row[col] =
831
+ existing && existing.lines.length > 0
832
+ ? { ...existing, lines: [...existing.lines, cellLine] }
833
+ : { lines: [cellLine] };
834
+ }
835
+
836
+ const cornerLeftDown: ReadonlySet<Direction> =
837
+ connectorType === 'merge'
838
+ ? new Set<Direction>(['left', 'down'])
839
+ : new Set<Direction>(['left', 'up']);
840
+
841
+ // ── Base plane: every yielding branch lays its own corner + the horizontal
842
+ // segment to its left (up to the previous branch's rail). These sit on the
843
+ // branch's lane plane; where the continuous sweep crosses them it occludes.
844
+ for (let i = 0; i < sorted.length; i++) {
845
+ const b = sorted[i]!;
846
+ if (b.lane === continuousLane) continue; // continuous drawn separately, on top
847
+ const branchLine = lineRefFor(b.edge, b.lane);
848
+ const railCol = b.lane * colsPerLane;
849
+ addLine(railCol, branchLine, cornerLeftDown);
850
+ const leftBound = i === 0 ? trunkRailCol + 1 : sorted[i - 1]!.lane * colsPerLane + 1;
851
+ for (let col = leftBound; col < railCol; col++) {
852
+ addLine(col, branchLine, new Set<Direction>(['left', 'right']));
853
+ }
854
+ }
855
+
856
+ // ── The continuous line ──────────────────────────────────────────────────
857
+ const continuousLine: LineRef =
858
+ continuousLane === trunkLane
859
+ ? trunkEdge
860
+ ? lineRefFor(trunkEdge, trunkLane)
861
+ : getRepLine(trunkLane)
862
+ : lineRefFor(branchByLane.get(continuousLane)!, continuousLane);
863
+
864
+ if (continuousLane === trunkLane) {
865
+ // Trunk passes straight through the node anchor (│), branches yield to it.
866
+ addLine(trunkRailCol, continuousLine, new Set<Direction>(['up', 'down']));
867
+ } else {
868
+ // A branch is continuous: it sweeps from the node anchor across to its own
869
+ // rail, on the TOP plane, occluding the trunk vertical and any intermediate
870
+ // yielding branch corners it passes over.
871
+ const anchorDirs: ReadonlySet<Direction> =
872
+ connectorType === 'merge'
873
+ ? new Set<Direction>(['up', 'right'])
874
+ : new Set<Direction>(['down', 'right']);
875
+ addLine(trunkRailCol, continuousLine, anchorDirs);
876
+ for (let col = trunkRailCol + 1; col < continuousRailCol; col++) {
877
+ addLine(col, continuousLine, new Set<Direction>(['left', 'right']));
878
+ }
879
+ addLine(continuousRailCol, continuousLine, cornerLeftDown);
880
+ }
881
+
882
+ // Other active lanes (not trunk, not branch): vertical pass-through.
883
+ const skipSet = new Set<number>([trunkLane, ...sorted.map((b) => b.lane)]);
884
+ placeVerticals(row, skipSet);
885
+ placeBackVerticals(row);
886
+
887
+ return row;
888
+ }
889
+
890
+ // Process each node in display order; null = component boundary → separator row
891
+ for (const nodeDisplay of displayOrder) {
892
+ if (nodeDisplay === null) {
893
+ // Emit one blank separator row between disconnected components.
894
+ const sepRow = makeRow();
895
+ sepRow[0] = { lines: [], separator: true };
896
+ grid.push(sepRow);
897
+ continue;
898
+ }
899
+
900
+ const { hash: nodeHash } = nodeDisplay;
901
+ const nodeLaneNum = nodeLane.get(nodeHash) ?? 0;
902
+
903
+ activeLanes.add(nodeLaneNum);
904
+
905
+ // ── 1. Fork connector (BEFORE the node row) ──────────────────────────
906
+ const outEdges = outboundFwd.get(nodeHash) ?? [];
907
+ if (outEdges.length > 1) {
908
+ // Use the per-edge lane for branch children so that "direct fork-to-merge"
909
+ // edges (whose target was reconciled back to trunk lane) still appear in
910
+ // their allocated branch column.
911
+ const trunkEdgeForFork = outEdges[0]!;
912
+ const trunkChildLane =
913
+ edgeLane.get(trunkEdgeForFork.migrationHash) ??
914
+ nodeLane.get(trunkEdgeForFork.to) ??
915
+ nodeLaneNum;
916
+ const branchEntries = outEdges
917
+ .slice(1)
918
+ .map((e) => ({ lane: edgeLane.get(e.migrationHash) ?? nodeLane.get(e.to) ?? 0, edge: e }))
919
+ .filter((b) => b.lane !== trunkChildLane && activeLanes.has(b.lane));
920
+
921
+ if (branchEntries.length > 0) {
922
+ const trunkEdge = outEdges[0];
923
+ const connRow = emitConnectorRow(nodeLaneNum, branchEntries, 'fork', trunkEdge);
924
+ grid.push(connRow);
925
+ assertSingleOwner(connRow, isFocus);
926
+
927
+ for (const b of branchEntries) activeLanes.delete(b.lane);
928
+ }
929
+ }
930
+
931
+ // ── 2. Self-loop rows (BEFORE the node row) ───────────────────────────
932
+ const selfMigrations = selfEdgesByNode.get(nodeHash) ?? [];
933
+ for (const selfEdge of selfMigrations) {
934
+ const row = makeRow();
935
+ const railCol = nodeLaneNum * colsPerLane;
936
+ const connCol = nodeLaneNum * colsPerLane + 1;
937
+ const line = lineRefFor(selfEdge, nodeLaneNum);
938
+ row[railCol] = vertCell(line);
939
+ row[connCol] = {
940
+ lines: [
941
+ {
942
+ line,
943
+ directions: new Set<Direction>(),
944
+ plane: planeOf(nodeLaneNum, line.role),
945
+ selfLoop: true,
946
+ },
947
+ ],
948
+ };
949
+ placeVerticals(row, new Set([nodeLaneNum]));
950
+ placeBackVerticals(row);
951
+ grid.push(row);
952
+ }
953
+
954
+ // ── 3. Node row ────────────────────────────────────────────────────────
955
+ {
956
+ const row = makeRow();
957
+ const railCol = nodeLaneNum * colsPerLane;
958
+ const nodeRef: NodeRef = {
959
+ contractHash: nodeHash,
960
+ isEmpty: nodeHash === EMPTY_CONTRACT_HASH,
961
+ lane: nodeLaneNum,
962
+ role: nodeRoleOf(nodeHash),
963
+ };
964
+ row[railCol] = nodeCell(nodeRef);
965
+ placeVerticals(row, new Set([nodeLaneNum]));
966
+
967
+ // A back-arc landing ends its vertical at this row, replacing it with a ╯
968
+ // corner — so deactivate landing arcs BEFORE placing back verticals. An
969
+ // adjacent rollback's overlay likewise ends at its target node.
970
+ const landingArcs = backArcsByTarget.get(nodeHash) ?? [];
971
+ for (const arc of landingArcs) activeBackArcs.delete(arc);
972
+ for (const adj of [...activeAdjacent]) {
973
+ if (adj.edge.to === nodeHash) activeAdjacent.delete(adj);
974
+ }
975
+
976
+ placeBackVerticals(row);
977
+
978
+ // Back-arc landing: arcs targeting this node sweep from the node anchor
979
+ // (◂ arrowhead) across to their own rail corner (╯). The on-top arc draws
980
+ // the anchor; others yield their corners beneath (occlusion arbitrates).
981
+ for (const arc of landingArcs) {
982
+ emitBackArcLanding(row, nodeLaneNum, arc);
983
+ }
984
+
985
+ // Back-arc tee: arcs sourced at this node tee off the node row into their
986
+ // back-lane (─ bridge + ╮ corner). The vertical begins on the next row.
987
+ const teeArcs = backArcsBySource.get(nodeHash) ?? [];
988
+ for (const arc of teeArcs) {
989
+ emitBackArcTee(row, nodeLaneNum, arc);
990
+ }
991
+
992
+ grid.push(row);
993
+
994
+ // Activate the back-arc verticals AFTER the node row so the rail runs from
995
+ // the next row down to (but not including) the target landing row.
996
+ for (const arc of teeArcs) activeBackArcs.add(arc);
997
+
998
+ // Activate adjacent-rollback overlays sourced here (their trunk overlay
999
+ // runs from the next row down to the target node).
1000
+ for (const adj of adjacentBySource.get(nodeHash) ?? []) {
1001
+ activeAdjacent.add({ lane: nodeLaneNum, edge: adj });
1002
+ }
1003
+ }
1004
+
1005
+ // Inbound forward edges run down their lanes below this node. Record each as
1006
+ // its lane's current edge NOW (before emitting the back-arc arrow rows, merge
1007
+ // connector, and migration rows) so pass-through verticals colour from the
1008
+ // forward edge actually occupying the trunk below this node.
1009
+ //
1010
+ // edgeLaneFor: resolve the lane for an inbound forward edge. Uses the
1011
+ // per-edge override from edgeLane (set during BFS for branch edges) when
1012
+ // available; falls back to Max(fromLane, toLane) for edges not in the map.
1013
+ function edgeLaneFor(edge: ClassifiedEdge): number {
1014
+ const override = edgeLane.get(edge.migrationHash);
1015
+ if (override !== undefined) return override;
1016
+ return Math.max(nodeLane.get(edge.from) ?? 0, nodeLane.get(edge.to) ?? 0);
1017
+ }
1018
+
1019
+ // Sort inEdges so the trunk edge (lowest edgeLane = trunk column) comes
1020
+ // first. Ties broken by dirName. This ensures the merge connector treats
1021
+ // the trunk-column edge as the trunk regardless of alphabetical order.
1022
+ const inEdges = inboundFwd.get(nodeHash) ?? [];
1023
+ inEdges.sort((a, b) => {
1024
+ const aLane = edgeLaneFor(a);
1025
+ const bLane = edgeLaneFor(b);
1026
+ if (aLane !== bLane) return aLane - bLane;
1027
+ return a.dirName.localeCompare(b.dirName);
1028
+ });
1029
+ for (const edge of inEdges) {
1030
+ laneCurrentEdge.set(edgeLaneFor(edge), edge);
1031
+ }
1032
+
1033
+ // ── 3b. Back-arc arrow rows ──────────────────────────────────────────────
1034
+ // For each routed arc sourced here, a │↓ arrow row in its back-lane sits
1035
+ // directly below the source node (before the source node's forward inbound
1036
+ // migration rows).
1037
+ {
1038
+ const teeArcs = backArcsBySource.get(nodeHash) ?? [];
1039
+ for (const arc of teeArcs) {
1040
+ const row = makeRow();
1041
+ const railCol = arc.geomLane * colsPerLane;
1042
+ const connCol = railCol + 1;
1043
+ const line = backArcLine(arc);
1044
+ const plane = backArcPlane(arc);
1045
+ composeLine(row, railCol, line, new Set<Direction>(['up', 'down']), plane);
1046
+ composeLine(row, connCol, line, new Set<Direction>(['down']), plane);
1047
+ placeVerticals(row, new Set<number>());
1048
+ placeBackVerticals(row);
1049
+ grid.push(row);
1050
+ }
1051
+ }
1052
+
1053
+ // ── 4. Merge connector (AFTER the node row) ────────────────────────────
1054
+ if (inEdges.length > 1) {
1055
+ const branchEntries = inEdges.slice(1).map((e) => ({ lane: edgeLaneFor(e), edge: e }));
1056
+
1057
+ const trunkEdge = inEdges[0];
1058
+ const connRow = emitConnectorRow(nodeLaneNum, branchEntries, 'merge', trunkEdge);
1059
+ grid.push(connRow);
1060
+ assertSingleOwner(connRow, isFocus);
1061
+
1062
+ for (const b of branchEntries) activeLanes.add(b.lane);
1063
+ }
1064
+
1065
+ // ── 5. Migration rows (one per inbound edge, ordered by edge lane) ─────
1066
+ for (const edge of inEdges) {
1067
+ const eLane = edgeLaneFor(edge);
1068
+ const row = makeRow();
1069
+ const railCol = eLane * colsPerLane;
1070
+ const connCol = eLane * colsPerLane + 1;
1071
+ const line = lineRefFor(edge, eLane);
1072
+
1073
+ row[railCol] = vertCell(line);
1074
+ row[connCol] = dirCell(line, new Set<Direction>(['up']));
1075
+
1076
+ placeVerticals(row, new Set([eLane]));
1077
+ placeBackVerticals(row);
1078
+ grid.push(row);
1079
+ }
1080
+
1081
+ // ── 5b. Adjacent rollback ↓ rows ─────────────────────────────────────────
1082
+ // An adjacent rollback (target is the display-neighbour directly below) is a
1083
+ // plain ↓ in the source's own lane — mirror of the forward ↑ — emitted after
1084
+ // the source node's forward inbound rows, directly above the target node.
1085
+ {
1086
+ const adjacents = adjacentBySource.get(nodeHash) ?? [];
1087
+ for (const adj of adjacents) {
1088
+ const row = makeRow();
1089
+ const connCol = nodeLaneNum * colsPerLane + 1;
1090
+ const line = lineRefFor(adj, nodeLaneNum);
1091
+ const plane = planeOf(nodeLaneNum, line.role);
1092
+ // The rail │ belongs to the trunk passing through (drawn by placeVerticals
1093
+ // from the lane's current forward edge); only the ↓ arrow is the rollback.
1094
+ composeLine(row, connCol, line, new Set<Direction>(['down']), plane);
1095
+ placeVerticals(row, new Set<number>());
1096
+ placeBackVerticals(row);
1097
+ grid.push(row);
1098
+ }
1099
+ }
1100
+
1101
+ // ── 6. Root lane deactivation ─────────────────────────────────────────
1102
+ if (inEdges.length === 0) {
1103
+ activeLanes.delete(nodeLaneNum);
1104
+ }
1105
+ }
1106
+
1107
+ return grid;
1108
+ }
1109
+
1110
+ // ---------------------------------------------------------------------------
1111
+ // Single-owner invariant — after building a connector row, assert that every
1112
+ // cell has at most one DRAWABLE owner once occlusion (topmost plane) is applied.
1113
+ // In focus mode a tie at the same plane between an on-path and an off-path line
1114
+ // would be a colour ambiguity, so we additionally assert that at the top plane
1115
+ // of each cell exactly one role survives.
1116
+ // ---------------------------------------------------------------------------
1117
+ function assertSingleOwner(row: CellsRow, isFocus: boolean): void {
1118
+ for (const cell of row) {
1119
+ if (cell.lines.length <= 1) continue;
1120
+ let topPlane = Number.POSITIVE_INFINITY;
1121
+ for (const cl of cell.lines) if (cl.plane < topPlane) topPlane = cl.plane;
1122
+ const top = cell.lines.filter((cl: CellLine) => cl.plane === topPlane);
1123
+ if (top.length > 1) {
1124
+ if (isFocus) {
1125
+ const roles = new Set(top.map((cl) => cl.line.role));
1126
+ if (roles.size > 1) {
1127
+ throw new Error(
1128
+ 'migration-graph layout: single-owner invariant violated — two differently-roled lines share the top plane in one cell',
1129
+ );
1130
+ }
1131
+ }
1132
+ }
1133
+ }
1134
+ }