@prisma-next/cli 0.3.0-pr.99.6 → 0.4.0-dev.1

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 (257) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +381 -128
  3. package/dist/agent-skill-mongo.md +106 -0
  4. package/dist/agent-skill-postgres.md +106 -0
  5. package/dist/cli-errors-BDCYR5ap.mjs +4 -0
  6. package/dist/cli-errors-DStABy9d.d.mts +3 -0
  7. package/dist/cli.d.mts +1 -0
  8. package/dist/cli.js +1 -2910
  9. package/dist/cli.mjs +254 -0
  10. package/dist/cli.mjs.map +1 -0
  11. package/dist/client-DiUkJAeN.mjs +987 -0
  12. package/dist/client-DiUkJAeN.mjs.map +1 -0
  13. package/dist/commands/contract-emit.d.mts +7 -0
  14. package/dist/commands/contract-emit.d.mts.map +1 -0
  15. package/dist/commands/contract-emit.mjs +4 -0
  16. package/dist/commands/contract-infer.d.mts +7 -0
  17. package/dist/commands/contract-infer.d.mts.map +1 -0
  18. package/dist/commands/contract-infer.mjs +4 -0
  19. package/dist/commands/db-init.d.mts +7 -0
  20. package/dist/commands/db-init.d.mts.map +1 -0
  21. package/dist/commands/db-init.mjs +125 -0
  22. package/dist/commands/db-init.mjs.map +1 -0
  23. package/dist/commands/db-schema.d.mts +7 -0
  24. package/dist/commands/db-schema.d.mts.map +1 -0
  25. package/dist/commands/db-schema.mjs +53 -0
  26. package/dist/commands/db-schema.mjs.map +1 -0
  27. package/dist/commands/db-sign.d.mts +7 -0
  28. package/dist/commands/db-sign.d.mts.map +1 -0
  29. package/dist/commands/db-sign.mjs +136 -0
  30. package/dist/commands/db-sign.mjs.map +1 -0
  31. package/dist/commands/db-update.d.mts +7 -0
  32. package/dist/commands/db-update.d.mts.map +1 -0
  33. package/dist/commands/db-update.mjs +122 -0
  34. package/dist/commands/db-update.mjs.map +1 -0
  35. package/dist/commands/db-verify.d.mts +7 -0
  36. package/dist/commands/db-verify.d.mts.map +1 -0
  37. package/dist/commands/db-verify.mjs +322 -0
  38. package/dist/commands/db-verify.mjs.map +1 -0
  39. package/dist/commands/migration-apply.d.mts +36 -0
  40. package/dist/commands/migration-apply.d.mts.map +1 -0
  41. package/dist/commands/migration-apply.mjs +244 -0
  42. package/dist/commands/migration-apply.mjs.map +1 -0
  43. package/dist/commands/migration-new.d.mts +8 -0
  44. package/dist/commands/migration-new.d.mts.map +1 -0
  45. package/dist/commands/migration-new.mjs +152 -0
  46. package/dist/commands/migration-new.mjs.map +1 -0
  47. package/dist/commands/migration-plan.d.mts +47 -0
  48. package/dist/commands/migration-plan.d.mts.map +1 -0
  49. package/dist/commands/migration-plan.mjs +313 -0
  50. package/dist/commands/migration-plan.mjs.map +1 -0
  51. package/dist/commands/migration-ref.d.mts +43 -0
  52. package/dist/commands/migration-ref.d.mts.map +1 -0
  53. package/dist/commands/migration-ref.mjs +195 -0
  54. package/dist/commands/migration-ref.mjs.map +1 -0
  55. package/dist/commands/migration-show.d.mts +28 -0
  56. package/dist/commands/migration-show.d.mts.map +1 -0
  57. package/dist/commands/migration-show.mjs +140 -0
  58. package/dist/commands/migration-show.mjs.map +1 -0
  59. package/dist/commands/migration-status.d.mts +86 -0
  60. package/dist/commands/migration-status.d.mts.map +1 -0
  61. package/dist/commands/migration-status.mjs +4 -0
  62. package/dist/commands/migration-verify.d.mts +16 -0
  63. package/dist/commands/migration-verify.d.mts.map +1 -0
  64. package/dist/commands/migration-verify.mjs +110 -0
  65. package/dist/commands/migration-verify.mjs.map +1 -0
  66. package/dist/config-loader-C4VXKl8f.mjs +43 -0
  67. package/dist/config-loader-C4VXKl8f.mjs.map +1 -0
  68. package/dist/{config-loader.d.ts → config-loader.d.mts} +8 -3
  69. package/dist/config-loader.d.mts.map +1 -0
  70. package/dist/config-loader.mjs +3 -0
  71. package/dist/contract-emit-D2wDXfyo.mjs +191 -0
  72. package/dist/contract-emit-D2wDXfyo.mjs.map +1 -0
  73. package/dist/contract-emit-D9WOShFz.mjs +4 -0
  74. package/dist/contract-emit-Zm_sd1wQ.mjs +112 -0
  75. package/dist/contract-emit-Zm_sd1wQ.mjs.map +1 -0
  76. package/dist/contract-enrichment-CGW6mm-E.mjs +79 -0
  77. package/dist/contract-enrichment-CGW6mm-E.mjs.map +1 -0
  78. package/dist/contract-infer-DozZT511.mjs +90 -0
  79. package/dist/contract-infer-DozZT511.mjs.map +1 -0
  80. package/dist/exports/config-types.d.mts +2 -0
  81. package/dist/exports/config-types.mjs +3 -0
  82. package/dist/exports/control-api.d.mts +624 -0
  83. package/dist/exports/control-api.d.mts.map +1 -0
  84. package/dist/exports/control-api.mjs +6 -0
  85. package/dist/{load-ts-contract.d.ts → exports/index.d.mts} +12 -7
  86. package/dist/exports/index.d.mts.map +1 -0
  87. package/dist/exports/index.mjs +137 -0
  88. package/dist/exports/index.mjs.map +1 -0
  89. package/dist/extract-operation-statements-DZUJNmL3.mjs +13 -0
  90. package/dist/extract-operation-statements-DZUJNmL3.mjs.map +1 -0
  91. package/dist/extract-sql-ddl-DDMX-9mz.mjs +26 -0
  92. package/dist/extract-sql-ddl-DDMX-9mz.mjs.map +1 -0
  93. package/dist/framework-components-BAsliT4V.mjs +59 -0
  94. package/dist/framework-components-BAsliT4V.mjs.map +1 -0
  95. package/dist/init-DQ8auNB4.mjs +430 -0
  96. package/dist/init-DQ8auNB4.mjs.map +1 -0
  97. package/dist/inspect-live-schema-BYnhztxZ.mjs +91 -0
  98. package/dist/inspect-live-schema-BYnhztxZ.mjs.map +1 -0
  99. package/dist/migration-command-scaffold-CntCcntR.mjs +105 -0
  100. package/dist/migration-command-scaffold-CntCcntR.mjs.map +1 -0
  101. package/dist/migration-status-CJANY4yr.mjs +1583 -0
  102. package/dist/migration-status-CJANY4yr.mjs.map +1 -0
  103. package/dist/migrations-DTZBYXm1.mjs +173 -0
  104. package/dist/migrations-DTZBYXm1.mjs.map +1 -0
  105. package/dist/progress-adapter-B-YvmcDu.mjs +43 -0
  106. package/dist/progress-adapter-B-YvmcDu.mjs.map +1 -0
  107. package/dist/quick-reference-mongo.md +93 -0
  108. package/dist/quick-reference-postgres.md +91 -0
  109. package/dist/result-handler-oK_vA-Fn.mjs +697 -0
  110. package/dist/result-handler-oK_vA-Fn.mjs.map +1 -0
  111. package/dist/terminal-ui-C5k88MmW.mjs +274 -0
  112. package/dist/terminal-ui-C5k88MmW.mjs.map +1 -0
  113. package/dist/validate-contract-deps-esa-VQ0h.mjs +37 -0
  114. package/dist/validate-contract-deps-esa-VQ0h.mjs.map +1 -0
  115. package/dist/verify-DlFQ2FOw.mjs +385 -0
  116. package/dist/verify-DlFQ2FOw.mjs.map +1 -0
  117. package/package.json +87 -40
  118. package/src/cli.ts +118 -58
  119. package/src/commands/contract-emit.ts +101 -78
  120. package/src/commands/contract-infer-paths.ts +32 -0
  121. package/src/commands/contract-infer.ts +143 -0
  122. package/src/commands/db-init.ts +97 -219
  123. package/src/commands/db-schema.ts +77 -0
  124. package/src/commands/db-sign.ts +46 -73
  125. package/src/commands/db-update.ts +236 -0
  126. package/src/commands/db-verify.ts +409 -119
  127. package/src/commands/init/detect-package-manager.ts +47 -0
  128. package/src/commands/init/index.ts +21 -0
  129. package/src/commands/init/init.ts +203 -0
  130. package/src/commands/init/templates/agent-skill-mongo.md +106 -0
  131. package/src/commands/init/templates/agent-skill-postgres.md +106 -0
  132. package/src/commands/init/templates/agent-skill.ts +19 -0
  133. package/src/commands/init/templates/code-templates.ts +168 -0
  134. package/src/commands/init/templates/quick-reference-mongo.md +93 -0
  135. package/src/commands/init/templates/quick-reference-postgres.md +91 -0
  136. package/src/commands/init/templates/quick-reference.ts +19 -0
  137. package/src/commands/init/templates/render.ts +20 -0
  138. package/src/commands/init/templates/tsconfig.ts +35 -0
  139. package/src/commands/inspect-live-schema.ts +170 -0
  140. package/src/commands/migration-apply.ts +427 -0
  141. package/src/commands/migration-new.ts +260 -0
  142. package/src/commands/migration-plan.ts +519 -0
  143. package/src/commands/migration-ref.ts +305 -0
  144. package/src/commands/migration-show.ts +246 -0
  145. package/src/commands/migration-status.ts +864 -0
  146. package/src/commands/migration-verify.ts +180 -0
  147. package/src/config-loader.ts +13 -3
  148. package/src/control-api/client.ts +205 -183
  149. package/src/control-api/contract-enrichment.ts +119 -0
  150. package/src/control-api/errors.ts +9 -0
  151. package/src/control-api/operations/contract-emit.ts +181 -0
  152. package/src/control-api/operations/db-init.ts +53 -49
  153. package/src/control-api/operations/db-update.ts +220 -0
  154. package/src/control-api/operations/extract-operation-statements.ts +14 -0
  155. package/src/control-api/operations/extract-sql-ddl.ts +47 -0
  156. package/src/control-api/operations/migration-apply.ts +191 -0
  157. package/src/control-api/operations/migration-helpers.ts +49 -0
  158. package/src/control-api/types.ts +274 -52
  159. package/src/exports/config-types.ts +4 -3
  160. package/src/exports/control-api.ts +15 -5
  161. package/src/load-ts-contract.ts +30 -19
  162. package/src/utils/cli-errors.ts +14 -8
  163. package/src/utils/command-helpers.ts +302 -3
  164. package/src/utils/formatters/emit.ts +67 -0
  165. package/src/utils/formatters/errors.ts +82 -0
  166. package/src/utils/formatters/graph-migration-mapper.ts +240 -0
  167. package/src/utils/formatters/graph-render.ts +1323 -0
  168. package/src/utils/formatters/graph-types.ts +120 -0
  169. package/src/utils/formatters/help.ts +380 -0
  170. package/src/utils/formatters/helpers.ts +28 -0
  171. package/src/utils/formatters/migrations.ts +346 -0
  172. package/src/utils/formatters/styled.ts +212 -0
  173. package/src/utils/formatters/verify.ts +621 -0
  174. package/src/utils/framework-components.ts +13 -10
  175. package/src/utils/global-flags.ts +41 -23
  176. package/src/utils/migration-command-scaffold.ts +184 -0
  177. package/src/utils/migration-types.ts +12 -0
  178. package/src/utils/progress-adapter.ts +18 -29
  179. package/src/utils/result-handler.ts +12 -13
  180. package/src/utils/shutdown.ts +92 -0
  181. package/src/utils/suggest-command.ts +31 -0
  182. package/src/utils/terminal-ui.ts +276 -0
  183. package/src/utils/validate-contract-deps.ts +49 -0
  184. package/dist/chunk-AGOTG4L3.js +0 -965
  185. package/dist/chunk-AGOTG4L3.js.map +0 -1
  186. package/dist/chunk-HLLI4YL7.js +0 -180
  187. package/dist/chunk-HLLI4YL7.js.map +0 -1
  188. package/dist/chunk-HWYQOCAJ.js +0 -47
  189. package/dist/chunk-HWYQOCAJ.js.map +0 -1
  190. package/dist/chunk-VG2R7DGF.js +0 -735
  191. package/dist/chunk-VG2R7DGF.js.map +0 -1
  192. package/dist/cli.d.ts +0 -2
  193. package/dist/cli.d.ts.map +0 -1
  194. package/dist/cli.js.map +0 -1
  195. package/dist/commands/contract-emit.d.ts +0 -3
  196. package/dist/commands/contract-emit.d.ts.map +0 -1
  197. package/dist/commands/contract-emit.js +0 -10
  198. package/dist/commands/contract-emit.js.map +0 -1
  199. package/dist/commands/db-init.d.ts +0 -3
  200. package/dist/commands/db-init.d.ts.map +0 -1
  201. package/dist/commands/db-init.js +0 -257
  202. package/dist/commands/db-init.js.map +0 -1
  203. package/dist/commands/db-introspect.d.ts +0 -3
  204. package/dist/commands/db-introspect.d.ts.map +0 -1
  205. package/dist/commands/db-introspect.js +0 -155
  206. package/dist/commands/db-introspect.js.map +0 -1
  207. package/dist/commands/db-schema-verify.d.ts +0 -3
  208. package/dist/commands/db-schema-verify.d.ts.map +0 -1
  209. package/dist/commands/db-schema-verify.js +0 -171
  210. package/dist/commands/db-schema-verify.js.map +0 -1
  211. package/dist/commands/db-sign.d.ts +0 -3
  212. package/dist/commands/db-sign.d.ts.map +0 -1
  213. package/dist/commands/db-sign.js +0 -195
  214. package/dist/commands/db-sign.js.map +0 -1
  215. package/dist/commands/db-verify.d.ts +0 -3
  216. package/dist/commands/db-verify.d.ts.map +0 -1
  217. package/dist/commands/db-verify.js +0 -193
  218. package/dist/commands/db-verify.js.map +0 -1
  219. package/dist/config-loader.d.ts.map +0 -1
  220. package/dist/config-loader.js +0 -7
  221. package/dist/config-loader.js.map +0 -1
  222. package/dist/control-api/client.d.ts +0 -13
  223. package/dist/control-api/client.d.ts.map +0 -1
  224. package/dist/control-api/operations/db-init.d.ts +0 -29
  225. package/dist/control-api/operations/db-init.d.ts.map +0 -1
  226. package/dist/control-api/types.d.ts +0 -387
  227. package/dist/control-api/types.d.ts.map +0 -1
  228. package/dist/exports/config-types.d.ts +0 -3
  229. package/dist/exports/config-types.d.ts.map +0 -1
  230. package/dist/exports/config-types.js +0 -6
  231. package/dist/exports/config-types.js.map +0 -1
  232. package/dist/exports/control-api.d.ts +0 -13
  233. package/dist/exports/control-api.d.ts.map +0 -1
  234. package/dist/exports/control-api.js +0 -7
  235. package/dist/exports/control-api.js.map +0 -1
  236. package/dist/exports/index.d.ts +0 -4
  237. package/dist/exports/index.d.ts.map +0 -1
  238. package/dist/exports/index.js +0 -176
  239. package/dist/exports/index.js.map +0 -1
  240. package/dist/load-ts-contract.d.ts.map +0 -1
  241. package/dist/utils/cli-errors.d.ts +0 -7
  242. package/dist/utils/cli-errors.d.ts.map +0 -1
  243. package/dist/utils/command-helpers.d.ts +0 -12
  244. package/dist/utils/command-helpers.d.ts.map +0 -1
  245. package/dist/utils/framework-components.d.ts +0 -70
  246. package/dist/utils/framework-components.d.ts.map +0 -1
  247. package/dist/utils/global-flags.d.ts +0 -25
  248. package/dist/utils/global-flags.d.ts.map +0 -1
  249. package/dist/utils/output.d.ts +0 -142
  250. package/dist/utils/output.d.ts.map +0 -1
  251. package/dist/utils/progress-adapter.d.ts +0 -26
  252. package/dist/utils/progress-adapter.d.ts.map +0 -1
  253. package/dist/utils/result-handler.d.ts +0 -15
  254. package/dist/utils/result-handler.d.ts.map +0 -1
  255. package/src/commands/db-introspect.ts +0 -227
  256. package/src/commands/db-schema-verify.ts +0 -238
  257. package/src/utils/output.ts +0 -1471
@@ -0,0 +1,1323 @@
1
+ /**
2
+ * Terminal graph renderer.
3
+ *
4
+ * Renders directed graphs as ASCII/box-drawing art for terminal output. Uses
5
+ * dagre for automatic layout (rank assignment + coordinate placement), then
6
+ * stamps the result onto a {@link CharGrid} — a sparse character canvas that
7
+ * resolves box-drawing junctions, color priority, and label placement.
8
+ *
9
+ * ## Rendering pipeline
10
+ *
11
+ * 1. **Layout** — dagre assigns (x, y) coordinates to nodes and polyline
12
+ * control points to edges. We use `rankdir: 'TB'` (top-to-bottom).
13
+ * 2. **Orthogonalization** — dagre's polylines may contain diagonal segments.
14
+ * {@link selectBestVariant} resolves each diagonal into an L-shaped bend,
15
+ * enumerating all 2^N combinations and picking the variant with fewest
16
+ * corners and shortest total length.
17
+ * 3. **Edge stamping** — orthogonal segments are stamped onto the CharGrid as
18
+ * directional bitmasks. The grid resolves overlapping directions into the
19
+ * correct box-drawing character (│, ─, ┌, ┼, etc.).
20
+ * 4. **Label placement** — edge labels are placed adjacent to their polyline
21
+ * segments, preferring horizontal (branch-specific) segments over shared
22
+ * vertical trunks to avoid ambiguity.
23
+ * 5. **Arrowheads** — ▾ ▴ ◂ ▸ placed one cell before the terminal point.
24
+ * 6. **Node stamping** — `○ nodeId` with inline marker tags (db, contract,
25
+ * ref names).
26
+ * 7. **Elided indicator** — when truncation is active, `┊ (N earlier
27
+ * migrations)` is stamped above the visible root.
28
+ * 8. **Detached nodes** — rendered below the graph with `◇` and a dotted
29
+ * connector.
30
+ *
31
+ * ## Graph filtering
32
+ *
33
+ * The caller controls what graph is rendered: the full graph, or a subgraph
34
+ * extracted via {@link extractRelevantSubgraph} (union of relevant paths).
35
+ * The renderer itself is agnostic — it renders whatever graph it receives.
36
+ *
37
+ * Truncation is supported via `options.limit`.
38
+ *
39
+ * ## Color accessibility
40
+ *
41
+ * Uses a CVD-safe palette — no red/green contrast. Shape and icon always
42
+ * carry meaning; color only reinforces.
43
+ */
44
+ import dagre from '@dagrejs/dagre';
45
+ import { bold, cyan, dim, magenta, yellow } from 'colorette';
46
+ import {
47
+ type GraphEdge,
48
+ type GraphNode,
49
+ type GraphRenderOptions,
50
+ type NodeMarker,
51
+ RenderGraph,
52
+ } from './graph-types';
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Types
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /** A 2D point on the character grid (integer coordinates). */
59
+ interface Point {
60
+ x: number;
61
+ y: number;
62
+ }
63
+
64
+ /** An orthogonal line segment between two points on the character grid. */
65
+ interface Segment {
66
+ readonly from: Point;
67
+ readonly to: Point;
68
+ }
69
+
70
+ function segment(from: Point, to: Point): Segment {
71
+ return { from, to };
72
+ }
73
+
74
+ function isVertical(seg: Segment): boolean {
75
+ return seg.from.x === seg.to.x;
76
+ }
77
+
78
+ function manhattanLength(seg: Segment): number {
79
+ return Math.abs(seg.to.x - seg.from.x) + Math.abs(seg.to.y - seg.from.y);
80
+ }
81
+
82
+ /** A function that wraps a string with an ANSI color escape sequence. */
83
+ type ColorFn = (s: string) => string;
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // CVD-safe color palette
87
+ //
88
+ // No red/green contrast. Shape/icon always carries meaning; color reinforces.
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /** Color functions for each semantic role in the graph. */
92
+ interface GraphColors {
93
+ spine: ColorFn;
94
+ branch: ColorFn;
95
+ backward: ColorFn;
96
+ applied: ColorFn;
97
+ pending: ColorFn;
98
+ unreachable: ColorFn;
99
+ node: ColorFn;
100
+ label: ColorFn;
101
+ marker: ColorFn;
102
+ /** Rotating color for ref markers — cycles through the palette by index. */
103
+ ref: (index: number) => ColorFn;
104
+ }
105
+
106
+ /** Rotating palette for ref marker names, cycling through these for each ref. */
107
+ const REF_COLORS: ColorFn[] = [yellow, magenta, bold, cyan];
108
+
109
+ /** Build the color palette, respecting the `colorize` flag. When false, all color functions become identity. */
110
+ function buildColors(colorize: boolean): GraphColors {
111
+ const c = (fn: ColorFn): ColorFn => (colorize ? fn : (s) => s);
112
+ return {
113
+ spine: c(cyan),
114
+ branch: c(dim),
115
+ backward: c(magenta),
116
+ applied: c(cyan),
117
+ pending: c(yellow),
118
+ unreachable: c(magenta),
119
+ node: c(cyan),
120
+ label: c(dim),
121
+ marker: c(bold),
122
+ ref: (index: number) => c(REF_COLORS[index % REF_COLORS.length]!),
123
+ };
124
+ }
125
+
126
+ /** Map a `colorHint` value to its color function, or `undefined` for no hint. */
127
+ function resolveHintColor(hint: GraphEdge['colorHint'], colors: GraphColors): ColorFn | undefined {
128
+ if (hint === 'applied') return colors.applied;
129
+ if (hint === 'pending') return colors.pending;
130
+ if (hint === 'unreachable') return colors.unreachable;
131
+ return undefined;
132
+ }
133
+
134
+ /**
135
+ * Edge drawing priorities — higher priority wins when edges overlap on the
136
+ * same grid cell. Backward edges are drawn on top so rollback paths remain
137
+ * visible over spine and branch edges.
138
+ */
139
+ const PRIORITY = {
140
+ branch: 1,
141
+ spine: 2,
142
+ backward: 3,
143
+ } as const;
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Direction bitmask → box-drawing character
147
+ //
148
+ // Each grid cell accumulates a bitmask of connected directions (UP, DOWN,
149
+ // LEFT, RIGHT). The bitmask is then mapped to the appropriate Unicode
150
+ // box-drawing character. For example, UP|RIGHT → └, all four → ┼.
151
+ // ---------------------------------------------------------------------------
152
+
153
+ const DIR = {
154
+ up: 1,
155
+ down: 2,
156
+ left: 4,
157
+ right: 8,
158
+ dashed: 16,
159
+ } as const;
160
+
161
+ /** Arrow characters for edge termination (one cell before the target node). */
162
+ const ARROW = { up: '▴', down: '▾', left: '◂', right: '▸' };
163
+
164
+ /** Maps a direction bitmask to its box-drawing character. */
165
+ const BOX_CHAR: Record<number, string> = {
166
+ 0: ' ',
167
+ [DIR.up]: '│',
168
+ [DIR.down]: '│',
169
+ [DIR.up | DIR.down]: '│',
170
+ [DIR.left]: '─',
171
+ [DIR.right]: '─',
172
+ [DIR.left | DIR.right]: '─',
173
+ [DIR.down | DIR.right]: '┌',
174
+ [DIR.down | DIR.left]: '┐',
175
+ [DIR.up | DIR.right]: '└',
176
+ [DIR.up | DIR.left]: '┘',
177
+ [DIR.up | DIR.down | DIR.right]: '├',
178
+ [DIR.up | DIR.down | DIR.left]: '┤',
179
+ [DIR.left | DIR.right | DIR.down]: '┬',
180
+ [DIR.left | DIR.right | DIR.up]: '┴',
181
+ [DIR.up | DIR.down | DIR.left | DIR.right]: '┼',
182
+ // Dashed variants — straight segments only, corners fall back to solid
183
+ [DIR.up | DIR.dashed]: '┊',
184
+ [DIR.down | DIR.dashed]: '┊',
185
+ [DIR.up | DIR.down | DIR.dashed]: '┊',
186
+ [DIR.left | DIR.dashed]: '┈',
187
+ [DIR.right | DIR.dashed]: '┈',
188
+ [DIR.left | DIR.right | DIR.dashed]: '┈',
189
+ };
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Inline marker tags
193
+ //
194
+ // Markers (db, contract, ref, custom) are rendered inline after the node id:
195
+ // ○ abc1234 ◆ db prod
196
+ // ---------------------------------------------------------------------------
197
+
198
+ /** A single rendered tag: the display text and the color function to apply. */
199
+ interface InlineTag {
200
+ text: string;
201
+ color: ColorFn;
202
+ }
203
+
204
+ /**
205
+ * Convert a node's markers into renderable inline tags.
206
+ *
207
+ * - `db` → `◆ db`
208
+ * - `contract` → `◆ contract` (applied) or `◇ contract` (planned)
209
+ * - `ref` → the ref name, colored from the rotating {@link REF_COLORS} palette
210
+ * - `custom` → the custom label
211
+ */
212
+ function buildInlineTags(markers: readonly NodeMarker[], colors: GraphColors): InlineTag[] {
213
+ const tags: InlineTag[] = [];
214
+ const refNames = markers
215
+ .filter((m): m is NodeMarker & { kind: 'ref' } => m.kind === 'ref')
216
+ .map((m) => m.name);
217
+
218
+ for (const m of markers) {
219
+ if (m.kind === 'db') {
220
+ tags.push({ text: '◆ db', color: colors.marker });
221
+ } else if (m.kind === 'contract') {
222
+ tags.push({ text: m.planned ? '◆ contract' : '◇ contract', color: colors.marker });
223
+ } else if (m.kind === 'ref') {
224
+ tags.push({ text: m.name, color: colors.ref(refNames.indexOf(m.name)) });
225
+ } else if (m.kind === 'custom') {
226
+ tags.push({ text: m.label, color: colors.marker });
227
+ }
228
+ }
229
+ return tags;
230
+ }
231
+
232
+ /** Total character width of inline tags including leading spaces (0 if no tags). */
233
+ function inlineTagsWidth(tags: InlineTag[]): number {
234
+ if (tags.length === 0) return 0;
235
+ return tags.reduce((w, t) => w + 1 + t.text.length, 0);
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Character grid with color priority
240
+ //
241
+ // The grid is the central rendering canvas. It supports two layers:
242
+ //
243
+ // 1. **Connections** — direction bitmasks at (x, y) cells, resolved to
244
+ // box-drawing characters at render time. When multiple edges cross the
245
+ // same cell, their direction bits are OR'd together (e.g. UP|RIGHT → └).
246
+ // Color follows a priority system so higher-priority edges (backward >
247
+ // spine > branch) visually dominate at intersections.
248
+ //
249
+ // 2. **Text stamps** — literal characters placed at (x, y), such as node
250
+ // ids, labels, arrowheads, and markers. Text stamps override connections
251
+ // at the same position.
252
+ //
253
+ // The grid also tracks **reserved areas** (node label regions) so that
254
+ // label-placement heuristics can avoid overlapping node text.
255
+ // ---------------------------------------------------------------------------
256
+
257
+ /** Tracks the winning color for a grid cell based on edge priority. */
258
+ interface CellColor {
259
+ color: ColorFn | undefined;
260
+ priority: number;
261
+ }
262
+
263
+ /**
264
+ * Sparse character canvas for terminal graph rendering.
265
+ *
266
+ * Coordinates are unbounded integers — the grid auto-expands as content is
267
+ * added and trims to the bounding box on {@link render}.
268
+ */
269
+ class CharGrid {
270
+ private connections = new Map<string, number>();
271
+ private cellColors = new Map<string, CellColor>();
272
+ private chars = new Map<string, { ch: string; color: ColorFn | undefined }>();
273
+ private reserved = new Set<string>();
274
+ private minX = Number.POSITIVE_INFINITY;
275
+ private maxX = Number.NEGATIVE_INFINITY;
276
+ private minY = Number.POSITIVE_INFINITY;
277
+ private maxY = Number.NEGATIVE_INFINITY;
278
+
279
+ private key(x: number, y: number): string {
280
+ return `${x},${y}`;
281
+ }
282
+
283
+ /** Expand the bounding box to include (x, y). */
284
+ private touch(x: number, y: number): void {
285
+ if (x < this.minX) this.minX = x;
286
+ if (x > this.maxX) this.maxX = x;
287
+ if (y < this.minY) this.minY = y;
288
+ if (y > this.maxY) this.maxY = y;
289
+ }
290
+
291
+ /**
292
+ * Add a directional connection at (x, y). Multiple calls at the same cell
293
+ * are OR'd together — e.g. `addConnection(x, y, UP)` then
294
+ * `addConnection(x, y, RIGHT)` produces a └ corner. Color follows
295
+ * priority: higher-priority edges win at shared cells.
296
+ */
297
+ addConnection(
298
+ x: number,
299
+ y: number,
300
+ dir: number,
301
+ color?: ColorFn,
302
+ priority: number = PRIORITY.branch,
303
+ ): void {
304
+ this.touch(x, y);
305
+ const k = this.key(x, y);
306
+ this.connections.set(k, (this.connections.get(k) ?? 0) | dir);
307
+ const existing = this.cellColors.get(k);
308
+ if (!existing || priority >= existing.priority) {
309
+ this.cellColors.set(k, { color, priority });
310
+ }
311
+ }
312
+
313
+ /** Stamp a horizontal edge segment from x1 to x2 at row y. */
314
+ markHorizontal(
315
+ y: number,
316
+ x1: number,
317
+ x2: number,
318
+ color?: ColorFn,
319
+ priority?: number,
320
+ extraBits = 0,
321
+ ): void {
322
+ const lo = Math.min(x1, x2);
323
+ const hi = Math.max(x1, x2);
324
+ /* v8 ignore next -- @preserve */
325
+ if (lo === hi) return;
326
+ this.addConnection(lo, y, DIR.right | extraBits, color, priority);
327
+ for (let x = lo + 1; x < hi; x++)
328
+ this.addConnection(x, y, DIR.left | DIR.right | extraBits, color, priority);
329
+ this.addConnection(hi, y, DIR.left | extraBits, color, priority);
330
+ }
331
+
332
+ /** Stamp a vertical edge segment from y1 to y2 at column x. */
333
+ markVertical(
334
+ x: number,
335
+ y1: number,
336
+ y2: number,
337
+ color?: ColorFn,
338
+ priority?: number,
339
+ extraBits = 0,
340
+ ): void {
341
+ const lo = Math.min(y1, y2);
342
+ const hi = Math.max(y1, y2);
343
+ /* v8 ignore next -- @preserve */
344
+ if (lo === hi) return;
345
+ this.addConnection(x, lo, DIR.down | extraBits, color, priority);
346
+ for (let y = lo + 1; y < hi; y++)
347
+ this.addConnection(x, y, DIR.up | DIR.down | extraBits, color, priority);
348
+ this.addConnection(x, hi, DIR.up | extraBits, color, priority);
349
+ }
350
+
351
+ /** Place literal text at (x, y). Each character occupies one cell. Text stamps override connections. */
352
+ stampText(x: number, y: number, text: string, color?: ColorFn): void {
353
+ for (let i = 0; i < text.length; i++) {
354
+ const cx = x + i;
355
+ this.touch(cx, y);
356
+ this.chars.set(this.key(cx, y), { ch: text[i]!, color });
357
+ }
358
+ }
359
+
360
+ /** True if (x, y) has stamped text or is in a reserved area (node labels). */
361
+ hasLabel(x: number, y: number): boolean {
362
+ return this.chars.has(this.key(x, y)) || this.reserved.has(this.key(x, y));
363
+ }
364
+
365
+ /** True if (x, y) has any directional connection (an edge passes through). */
366
+ hasConnection(x: number, y: number): boolean {
367
+ return (this.connections.get(this.key(x, y)) ?? 0) !== 0;
368
+ }
369
+
370
+ /** True if (x, y) has stamped text (not just a reserved area). */
371
+ hasText(x: number, y: number): boolean {
372
+ return this.chars.has(this.key(x, y));
373
+ }
374
+
375
+ /** Reserve a horizontal span so label placement avoids it. Used for node id + marker regions. */
376
+ reserveArea(x: number, y: number, width: number): void {
377
+ for (let i = 0; i < width; i++) this.reserved.add(this.key(x + i, y));
378
+ }
379
+
380
+ /** The largest y coordinate with content — used for positioning detached nodes below the graph. */
381
+ getMaxY(): number {
382
+ return this.maxY;
383
+ }
384
+
385
+ /**
386
+ * Render the grid to a multi-line string.
387
+ *
388
+ * Iterates row by row over the bounding box, resolving each cell to either
389
+ * its stamped text character or the box-drawing character for its
390
+ * connection bitmask. Consecutive characters with the same color are
391
+ * batched into a single ANSI-wrapped run for efficiency.
392
+ */
393
+ render(): string {
394
+ /* v8 ignore next -- @preserve */
395
+ if (this.minX === Number.POSITIVE_INFINITY) return '(empty)';
396
+
397
+ const rows: string[] = [];
398
+ for (let y = this.minY; y <= this.maxY; y++) {
399
+ let row = '';
400
+ let runChars = '';
401
+ let runColor: ColorFn | undefined;
402
+
403
+ const flush = () => {
404
+ if (runChars.length === 0) return;
405
+ row += runColor ? runColor(runChars) : runChars;
406
+ runChars = '';
407
+ };
408
+
409
+ for (let x = this.minX; x <= this.maxX; x++) {
410
+ const k = this.key(x, y);
411
+ let ch: string;
412
+ let color: ColorFn | undefined;
413
+
414
+ const label = this.chars.get(k);
415
+ if (label) {
416
+ ch = label.ch;
417
+ color = label.color;
418
+ } else {
419
+ const conn = this.connections.get(k) ?? 0;
420
+ // Dashed corners don't exist — strip the bit and fall back to solid
421
+ ch = BOX_CHAR[conn] ?? BOX_CHAR[conn & ~DIR.dashed] ?? ' ';
422
+ color = conn === 0 ? undefined : this.cellColors.get(k)?.color;
423
+ }
424
+
425
+ if (color !== runColor) {
426
+ flush();
427
+ runColor = color;
428
+ }
429
+ runChars += ch;
430
+ }
431
+ flush();
432
+ rows.push(row.trimEnd());
433
+ }
434
+
435
+ while (rows.length > 0 && rows[rows.length - 1] === '') rows.pop();
436
+ return rows.join('\n');
437
+ }
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // Spine detection — BFS shortest path from root to target
442
+ //
443
+ // The renderer operates on generic GraphNode/GraphEdge, not MigrationGraph,
444
+ // so it cannot use domain-specific pathfinding. These two BFS functions
445
+ // re-derive the spine from the generic edge list.
446
+ // ---------------------------------------------------------------------------
447
+
448
+ /**
449
+ * Find the set of edge keys (`"from→to"`) on the shortest path from
450
+ * `rootId` to `targetId`. Used to color spine edges distinctly from
451
+ * branch edges in the rendered output.
452
+ *
453
+ * Returns an empty set if no path exists.
454
+ */
455
+ function findSpineEdges(graph: RenderGraph, rootId: string, targetId: string): Set<string> {
456
+ const visited = new Set([rootId]);
457
+ const parent = new Map<string, GraphEdge>();
458
+ const queue = [rootId];
459
+
460
+ while (queue.length > 0) {
461
+ const current = queue.shift()!;
462
+ if (current === targetId) {
463
+ const spineEdges = new Set<string>();
464
+ let node = targetId;
465
+ while (parent.has(node)) {
466
+ const edge = parent.get(node)!;
467
+ spineEdges.add(`${edge.from}→${edge.to}`);
468
+ node = edge.from;
469
+ }
470
+ return spineEdges;
471
+ }
472
+ for (const edge of graph.outgoing(current)) {
473
+ if (!visited.has(edge.to)) {
474
+ visited.add(edge.to);
475
+ parent.set(edge.to, edge);
476
+ queue.push(edge.to);
477
+ }
478
+ }
479
+ }
480
+ return new Set();
481
+ }
482
+
483
+ // ---------------------------------------------------------------------------
484
+ // Orthogonal polyline builder — variant-based
485
+ //
486
+ // Dagre produces polyline control points that may contain diagonal segments
487
+ // (two consecutive points that differ in both x and y). Terminal rendering
488
+ // requires strictly orthogonal segments (horizontal or vertical only).
489
+ //
490
+ // To resolve diagonals, we insert an L-shaped bend at each one. Each diagonal
491
+ // has two possible resolutions (horizontal-first or vertical-first), so N
492
+ // diagonals produce 2^N candidate polylines. We enumerate all variants and
493
+ // pick the one with the fewest corners and shortest total length.
494
+ // ---------------------------------------------------------------------------
495
+
496
+ /**
497
+ * Prepend `src` and append `tgt` to dagre's control points, round to
498
+ * integers, and deduplicate consecutive identical points.
499
+ */
500
+ function prepareRawPoints(src: Point, dagrePoints: Point[], tgt: Point): Point[] {
501
+ const raw = [src, ...dagrePoints, tgt];
502
+ const rounded = raw.map((p) => ({ x: Math.round(p.x), y: Math.round(p.y) }));
503
+ const deduped: Point[] = [rounded[0]!];
504
+ for (let i = 1; i < rounded.length; i++) {
505
+ const prev = deduped[deduped.length - 1]!;
506
+ const curr = rounded[i]!;
507
+ if (curr.x !== prev.x || curr.y !== prev.y) deduped.push(curr);
508
+ }
509
+ return deduped;
510
+ }
511
+
512
+ function countDiagonals(points: Point[]): number {
513
+ let count = 0;
514
+ for (let i = 1; i < points.length; i++) {
515
+ const prev = points[i - 1]!;
516
+ const curr = points[i]!;
517
+ if (prev.x !== curr.x && prev.y !== curr.y) count++;
518
+ }
519
+ return count;
520
+ }
521
+
522
+ type BendDirection = 'horizontal-first' | 'vertical-first';
523
+
524
+ /**
525
+ * Decode a bitmask into an array of bend directions.
526
+ *
527
+ * Each bit selects how one diagonal is converted to an orthogonal corner:
528
+ * 0 → horizontal-first, 1 → vertical-first. Used by {@link selectBestVariant}
529
+ * to enumerate all 2^N combinations.
530
+ */
531
+ function bitsToBends(bits: number, count: number): BendDirection[] {
532
+ const bends: BendDirection[] = [];
533
+ for (let k = 0; k < count; k++) {
534
+ bends.push((bits >> k) & 1 ? 'vertical-first' : 'horizontal-first');
535
+ }
536
+ return bends;
537
+ }
538
+
539
+ /**
540
+ * Build one polyline variant by resolving each diagonal with the given bend direction.
541
+ *
542
+ * Diagonals are detected inline: when the last emitted point and the next
543
+ * input point differ in both x and y, the segment is diagonal and is
544
+ * resolved using the next value from `bends`. The result is deduplicated
545
+ * to remove zero-length segments created when a bend coincides with an
546
+ * adjacent point.
547
+ */
548
+ function buildVariant(points: Point[], bends: BendDirection[]): Point[] {
549
+ /* v8 ignore next -- @preserve */
550
+ if (points.length < 2) return points;
551
+
552
+ let bendIdx = 0;
553
+ const result: Point[] = [points[0]!];
554
+ for (let i = 1; i < points.length; i++) {
555
+ const prev = result[result.length - 1]!;
556
+ const curr = points[i]!;
557
+
558
+ if (prev.x === curr.x || prev.y === curr.y) {
559
+ result.push(curr);
560
+ } else {
561
+ const bend = bends[bendIdx++] ?? 'horizontal-first';
562
+ if (bend === 'horizontal-first') {
563
+ result.push({ x: curr.x, y: prev.y });
564
+ } else {
565
+ result.push({ x: prev.x, y: curr.y });
566
+ }
567
+ result.push(curr);
568
+ }
569
+ }
570
+
571
+ const final: Point[] = [result[0]!];
572
+ for (let i = 1; i < result.length; i++) {
573
+ const prev = final[final.length - 1]!;
574
+ const curr = result[i]!;
575
+ if (curr.x !== prev.x || curr.y !== prev.y) final.push(curr);
576
+ }
577
+ return final;
578
+ }
579
+
580
+ /** Count the number of direction changes (corners) in an orthogonal polyline. */
581
+ function countCorners(poly: Point[]): number {
582
+ let corners = 0;
583
+ for (let i = 1; i < poly.length - 1; i++) {
584
+ const a = poly[i - 1]!;
585
+ const b = poly[i]!;
586
+ const c = poly[i + 1]!;
587
+ const d1Vert = a.x === b.x;
588
+ const d2Vert = b.x === c.x;
589
+ if (d1Vert !== d2Vert) corners++;
590
+ }
591
+ return corners;
592
+ }
593
+
594
+ /** Manhattan length of a polyline (sum of absolute x and y deltas). */
595
+ function polyLength(poly: Point[]): number {
596
+ let len = 0;
597
+ for (let i = 0; i < poly.length - 1; i++) {
598
+ len += Math.abs(poly[i + 1]!.x - poly[i]!.x) + Math.abs(poly[i + 1]!.y - poly[i]!.y);
599
+ }
600
+ return len;
601
+ }
602
+
603
+ // ---------------------------------------------------------------------------
604
+ // Label placement
605
+ //
606
+ // Edge labels (migration names) are placed adjacent to polyline segments.
607
+ // The algorithm generates candidate positions along each segment, scores
608
+ // them, and picks the best. Key heuristics:
609
+ //
610
+ // - **Horizontal segment preference**: when a polyline has both vertical and
611
+ // horizontal segments, horizontal segments are boosted because they
612
+ // uniquely identify a branch, while vertical segments often share column
613
+ // space with the trunk. This prevents labels from "jumping" when node
614
+ // widths change.
615
+ // - **Source adjacency penalty**: positions within ±1 row of the source node
616
+ // are penalized — labels there look like they belong to an incoming edge.
617
+ // - **Whitespace bonus**: positions with clear space above and below score
618
+ // higher for readability.
619
+ // ---------------------------------------------------------------------------
620
+
621
+ /**
622
+ * Find the best (x, y) position to place an edge label adjacent to its
623
+ * polyline. Returns null if no collision-free position exists.
624
+ *
625
+ * @param poly - The orthogonalized polyline for the edge.
626
+ * @param label - The label text to place.
627
+ * @param grid - The character grid (used for collision checks).
628
+ * @param srcY - Y coordinate of the source node (for adjacency penalty).
629
+ */
630
+ function findLabelPlacement(
631
+ poly: Point[],
632
+ label: string,
633
+ grid: CharGrid,
634
+ srcY?: number,
635
+ ): Point | undefined {
636
+ const segments = polyToSegments(poly);
637
+
638
+ let best: (Point & { score: number }) | undefined;
639
+
640
+ for (const seg of segments) {
641
+ const candidates = segmentLabelCandidates(seg, label.length);
642
+ for (const pos of candidates) {
643
+ if (labelCollides(grid, pos.x, pos.y, label)) continue;
644
+ const score = scoreLabelCandidate(pos, seg, segments, label, grid, srcY);
645
+ if (!best || score > best.score) best = { x: pos.x, y: pos.y, score };
646
+ }
647
+ }
648
+
649
+ return best;
650
+ }
651
+
652
+ /** Convert a polyline into non-zero-length segments. */
653
+ function polyToSegments(poly: readonly Point[]): Segment[] {
654
+ const segments: Segment[] = [];
655
+ for (let i = 0; i < poly.length - 1; i++) {
656
+ const seg = segment(poly[i]!, poly[i + 1]!);
657
+ if (manhattanLength(seg) > 0) segments.push(seg);
658
+ }
659
+ return segments;
660
+ }
661
+
662
+ /**
663
+ * Generate all candidate (x, y) positions for placing a label adjacent
664
+ * to a single segment. Positions are perpendicular to the segment:
665
+ *
666
+ * - Vertical segments: left and right, at every y along the segment.
667
+ * - Horizontal segments: above and below, at every x where the label fits.
668
+ */
669
+ function segmentLabelCandidates(seg: Segment, labelLen: number): Point[] {
670
+ const candidates: Point[] = [];
671
+
672
+ if (isVertical(seg)) {
673
+ const minY = Math.min(seg.from.y, seg.to.y);
674
+ const maxY = Math.max(seg.from.y, seg.to.y);
675
+ for (const x of [seg.from.x + 2, seg.from.x - labelLen - 1]) {
676
+ for (let y = minY; y <= maxY; y++) {
677
+ candidates.push({ x, y });
678
+ }
679
+ }
680
+ } else {
681
+ const minX = Math.min(seg.from.x, seg.to.x);
682
+ const maxX = Math.max(seg.from.x, seg.to.x);
683
+ for (const dy of [-1, 1]) {
684
+ const y = seg.from.y + dy;
685
+ for (let x = minX; x <= maxX - labelLen + 1; x++) {
686
+ candidates.push({ x, y });
687
+ }
688
+ }
689
+ }
690
+
691
+ return candidates;
692
+ }
693
+
694
+ /**
695
+ * Score a candidate label position. Higher is better.
696
+ *
697
+ * Combines: segment length, surrounding whitespace, distance from
698
+ * segment midpoint, source-node proximity penalty, and segment-position
699
+ * bonus (horizontal/later segments preferred when the edge has bends).
700
+ *
701
+ * @param pos - Candidate position (top-left corner of the label text).
702
+ * @param seg - The segment this candidate is adjacent to.
703
+ * @param allSegments - All segments of the edge polyline (for segment-position bonus).
704
+ * @param label - The label text (used for width and whitespace probing).
705
+ * @param grid - The character grid (used for whitespace checks).
706
+ * @param srcY - Y coordinate of the edge's source node (penalizes labels
707
+ * that would appear to belong to the node rather than the edge).
708
+ */
709
+ function scoreLabelCandidate(
710
+ pos: Point,
711
+ seg: Segment,
712
+ allSegments: readonly Segment[],
713
+ label: string,
714
+ grid: CharGrid,
715
+ srcY?: number,
716
+ ): number {
717
+ const len = manhattanLength(seg);
718
+ const midX = Math.round((seg.from.x + seg.to.x) / 2);
719
+ const midY = Math.round((seg.from.y + seg.to.y) / 2);
720
+
721
+ let score = len;
722
+
723
+ // Whitespace above/below the label improves readability.
724
+ for (let dy = 1; dy <= 2; dy++) {
725
+ if (!rowHasContent(grid, pos.x, pos.y - dy, label.length)) score += 3;
726
+ if (!rowHasContent(grid, pos.x, pos.y + dy, label.length)) score += 3;
727
+ }
728
+
729
+ // Prefer positions near the segment midpoint.
730
+ const labelCenterX = pos.x + Math.floor(label.length / 2);
731
+ score -= (Math.abs(labelCenterX - midX) + Math.abs(pos.y - midY)) * 2;
732
+
733
+ const labelCenterY = pos.y + Math.floor(label.length / 2);
734
+ score -= (Math.abs(labelCenterY - midY) + Math.abs(pos.x - midX)) * 2;
735
+
736
+ // Prefer labels to the right of a vertical segment
737
+ if (isVertical(seg) && pos.x > seg.from.x) {
738
+ score += 10;
739
+ }
740
+
741
+ // Penalize positions adjacent to the source node — labels there
742
+ // look like they belong to the incoming edge above.
743
+ if (srcY !== undefined && Math.abs(pos.y - srcY) <= 1) score -= 20;
744
+
745
+ // Horizontal segments uniquely identify a branch, while the initial
746
+ // vertical drop from the source often shares column space with the trunk.
747
+ // Boost horizontal and later segments so labels land on the branch.
748
+ const hasHorizontalSeg = allSegments.some((s) => !isVertical(s));
749
+ if (hasHorizontalSeg) {
750
+ if (!isVertical(seg)) score += 15;
751
+ const segIndex = allSegments.indexOf(seg);
752
+ score += (segIndex / allSegments.length) * 5;
753
+ }
754
+
755
+ return score;
756
+ }
757
+
758
+ /** True if any cell in the horizontal span [x, x+width) at row y has content. */
759
+ function rowHasContent(grid: CharGrid, x: number, y: number, width: number): boolean {
760
+ for (let i = 0; i < width; i++) {
761
+ if (grid.hasLabel(x + i, y) || grid.hasConnection(x + i, y)) return true;
762
+ }
763
+ return false;
764
+ }
765
+
766
+ /**
767
+ * Check if placing `text` at (x, y) would collide with existing content.
768
+ * Checks one cell of padding on each side to keep labels visually separated.
769
+ */
770
+ function labelCollides(grid: CharGrid, x: number, y: number, text: string): boolean {
771
+ for (let i = -1; i <= text.length; i++) {
772
+ const cx = x + i;
773
+ if (grid.hasLabel(cx, y)) return true;
774
+ if (i >= 0 && i < text.length && grid.hasConnection(cx, y)) return true;
775
+ }
776
+ return false;
777
+ }
778
+
779
+ // ---------------------------------------------------------------------------
780
+ // Joint variant selection
781
+ //
782
+ // Combines diagonal resolution with label placement in a single pass. For
783
+ // each of the 2^N polyline variants, we compute a score based on corner
784
+ // count, total length, and whether the label can be placed. The variant
785
+ // with the lowest score wins.
786
+ // ---------------------------------------------------------------------------
787
+
788
+ /** A resolved polyline paired with its best label position (if any). */
789
+ interface PolyWithLabel {
790
+ poly: Point[];
791
+ labelPos: Point | undefined;
792
+ }
793
+
794
+ /**
795
+ * Resolve dagre's polyline into the best orthogonal variant and find the
796
+ * optimal label position in a single pass.
797
+ *
798
+ * Enumerates all 2^N diagonal resolutions, scores each by:
799
+ * - `corners * 10` — fewer corners preferred
800
+ * - `+ manhattan length` — shorter paths preferred
801
+ * - `+ 100` penalty if the label couldn't be placed
802
+ *
803
+ * For edges with no diagonals, the polyline is used as-is.
804
+ */
805
+ function selectBestVariant(
806
+ src: Point,
807
+ dagrePoints: Point[],
808
+ tgt: Point,
809
+ label: string | undefined,
810
+ grid: CharGrid,
811
+ ): PolyWithLabel {
812
+ const rawPoints = prepareRawPoints(src, dagrePoints, tgt);
813
+ const diagCount = countDiagonals(rawPoints);
814
+
815
+ if (diagCount === 0) {
816
+ const poly = buildVariant(rawPoints, []);
817
+ const labelPos = label ? findLabelPlacement(poly, label, grid, src.y) : undefined;
818
+ return { poly, labelPos };
819
+ }
820
+
821
+ // Each diagonal must be converted to an orthogonal corner — either
822
+ // horizontal-first or vertical-first. With N diagonals that's 2^N
823
+ // combinations, enumerated via bitmask: we count from 0 to 2^N - 1,
824
+ // and every number represents a different combination of bend directions.
825
+ const numVariants = 1 << diagCount;
826
+ let bestPoly: Point[] | null = null;
827
+ let bestLabel: Point | undefined;
828
+ let bestScore = Number.POSITIVE_INFINITY;
829
+
830
+ for (let bits = 0; bits < numVariants; bits++) {
831
+ const bends = bitsToBends(bits, diagCount);
832
+ const poly = buildVariant(rawPoints, bends);
833
+
834
+ const corners = countCorners(poly);
835
+ const len = polyLength(poly);
836
+ const labelPos = label ? findLabelPlacement(poly, label, grid, src.y) : undefined;
837
+
838
+ const labelPenalty = label && !labelPos ? 100 : 0;
839
+ const score = corners * 10 + len + labelPenalty;
840
+
841
+ if (score < bestScore) {
842
+ bestScore = score;
843
+ bestPoly = poly;
844
+ bestLabel = labelPos;
845
+ }
846
+ }
847
+
848
+ return {
849
+ poly: bestPoly ?? buildVariant(rawPoints, bitsToBends(0, diagCount)),
850
+ labelPos: bestLabel,
851
+ };
852
+ }
853
+
854
+ // ---------------------------------------------------------------------------
855
+ // Subgraph extraction
856
+ // ---------------------------------------------------------------------------
857
+
858
+ /**
859
+ * Extract the subgraph containing only the nodes and forward-moving edges
860
+ * along the given path.
861
+ *
862
+ * Backward (rollback) edges are excluded even if both endpoints are on the
863
+ * path — only edges where `from` precedes `to` in path order are kept.
864
+ */
865
+ export function extractSubgraph(graph: RenderGraph, path: readonly string[]): RenderGraph {
866
+ const pathIndex = new Map(path.map((id, i) => [id, i]));
867
+ const nodeSet = new Set(path);
868
+ // Always keep dashed edges and their endpoints
869
+ for (const e of graph.edges) {
870
+ if (e.style === 'dashed') {
871
+ nodeSet.add(e.from);
872
+ nodeSet.add(e.to);
873
+ }
874
+ }
875
+ const filteredNodes = graph.nodes.filter((n) => nodeSet.has(n.id));
876
+ const filteredEdges = graph.edges.filter((e) => {
877
+ if (e.style === 'dashed') return true;
878
+ const fromIdx = pathIndex.get(e.from);
879
+ const toIdx = pathIndex.get(e.to);
880
+ return fromIdx !== undefined && toIdx !== undefined && fromIdx < toIdx;
881
+ });
882
+ return new RenderGraph(filteredNodes, filteredEdges);
883
+ }
884
+
885
+ /**
886
+ * Extract the subgraph covering the union of multiple paths.
887
+ *
888
+ * Each path is an ordered list of node ids (root → target). The result
889
+ * contains every node on any path plus every forward edge between
890
+ * consecutive nodes on any path. Detached nodes are always included.
891
+ *
892
+ * When all paths overlap (the common case), the result is identical to
893
+ * a single-path extract. When paths diverge (e.g. DB marker on a
894
+ * different branch than the contract), the result naturally includes the
895
+ * fork and both branches — exactly the minimal information needed.
896
+ */
897
+ export function extractRelevantSubgraph(
898
+ graph: RenderGraph,
899
+ paths: readonly (readonly string[])[],
900
+ ): RenderGraph {
901
+ const nodeSet = new Set<string>();
902
+ const edgePairs = new Set<string>();
903
+
904
+ for (const path of paths) {
905
+ for (let i = 0; i < path.length; i++) {
906
+ nodeSet.add(path[i]!);
907
+ if (i > 0) {
908
+ edgePairs.add(`${path[i - 1]!}\0${path[i]!}`);
909
+ }
910
+ }
911
+ }
912
+
913
+ // Always keep dashed (draft) edges and their endpoints
914
+ const dashedEdges = graph.edges.filter((e) => e.style === 'dashed');
915
+ for (const e of dashedEdges) {
916
+ nodeSet.add(e.from);
917
+ nodeSet.add(e.to);
918
+ }
919
+
920
+ const filteredNodes = graph.nodes.filter((n) => nodeSet.has(n.id));
921
+ const filteredEdges = graph.edges.filter(
922
+ (e) => edgePairs.has(`${e.from}\0${e.to}`) || e.style === 'dashed',
923
+ );
924
+ return new RenderGraph(filteredNodes, filteredEdges);
925
+ }
926
+
927
+ // ---------------------------------------------------------------------------
928
+ // Truncation — keep last N spine edges, expand for markers
929
+ // ---------------------------------------------------------------------------
930
+
931
+ /** Result of {@link truncateGraph} — the visible subgraph plus truncation metadata. */
932
+ export interface TruncationResult {
933
+ readonly graph: RenderGraph;
934
+ /** Number of spine edges hidden by truncation (0 = nothing truncated). */
935
+ readonly elidedCount: number;
936
+ /** The visible portion of the spine (subset of the input spine). */
937
+ readonly spine: readonly string[];
938
+ }
939
+
940
+ /**
941
+ * Truncate a graph to the last `limit` spine edges from the spine target.
942
+ * The window expands to include any node carrying a db or contract marker
943
+ * so those are never truncated away.
944
+ *
945
+ * For the full graph: keeps all branches that fork from the visible spine window.
946
+ * For the spine view: caller should call extractSubgraph first, then truncate.
947
+ */
948
+ export function truncateGraph(
949
+ graph: RenderGraph,
950
+ spine: readonly string[],
951
+ limit: number,
952
+ ): TruncationResult {
953
+ if (spine.length <= 1 || limit >= spine.length - 1) {
954
+ return { graph, elidedCount: 0, spine };
955
+ }
956
+
957
+ // Find the earliest spine node that has a db or contract marker
958
+ let earliestMarkerIdx = spine.length;
959
+ for (let i = 0; i < spine.length; i++) {
960
+ const n = graph.nodeById.get(spine[i]!);
961
+ if (n?.markers?.some((m) => m.kind === 'db' || m.kind === 'contract')) {
962
+ earliestMarkerIdx = i;
963
+ break;
964
+ }
965
+ }
966
+
967
+ // Effective limit: expand to include markers
968
+ // spine has N+1 nodes for N edges; we want the last `effectiveEdges` edges,
969
+ // which means keeping the last `effectiveEdges + 1` nodes
970
+ const markerDistance = spine.length - 1 - earliestMarkerIdx;
971
+ const effectiveEdges = Math.max(limit, markerDistance);
972
+
973
+ if (effectiveEdges >= spine.length - 1) {
974
+ return { graph, elidedCount: 0, spine };
975
+ }
976
+
977
+ const keepFromIdx = spine.length - 1 - effectiveEdges;
978
+ const truncatedSpine = spine.slice(keepFromIdx);
979
+ const visibleSpineSet = new Set(truncatedSpine);
980
+
981
+ // Include any node reachable from visible spine nodes
982
+ // (branches that fork from visible portion)
983
+ const reachable = new Set(visibleSpineSet);
984
+ const queue = [...truncatedSpine];
985
+ while (queue.length > 0) {
986
+ const current = queue.shift()!;
987
+ for (const edge of graph.outgoing(current)) {
988
+ if (!reachable.has(edge.to)) {
989
+ reachable.add(edge.to);
990
+ queue.push(edge.to);
991
+ }
992
+ }
993
+ }
994
+
995
+ const truncatedNodes = graph.nodes.filter((n) => reachable.has(n.id));
996
+ const truncatedEdges = graph.edges.filter((e) => reachable.has(e.from) && reachable.has(e.to));
997
+ const elidedCount = spine.length - 1 - effectiveEdges;
998
+
999
+ return {
1000
+ graph: new RenderGraph(truncatedNodes, truncatedEdges),
1001
+ elidedCount,
1002
+ spine: truncatedSpine,
1003
+ };
1004
+ }
1005
+
1006
+ /**
1007
+ * After truncation the original root may not be in the visible graph.
1008
+ * Find the first node with no incoming edges as a fallback root.
1009
+ */
1010
+ function findVisibleRoot(graph: RenderGraph, layoutNodes: readonly GraphNode[]): string {
1011
+ return layoutNodes.find((n) => !graph.incomingNodes.has(n.id))?.id ?? layoutNodes[0]?.id ?? '∅';
1012
+ }
1013
+
1014
+ // ---------------------------------------------------------------------------
1015
+ // Core layout + render pipeline
1016
+ // ---------------------------------------------------------------------------
1017
+
1018
+ /**
1019
+ * The main rendering pipeline: dagre layout → edge stamping → label
1020
+ * placement → arrowheads → nodes → elided indicator → detached nodes.
1021
+ *
1022
+ * Called by {@link render} after optional truncation. Receives nodes/edges
1023
+ * and produces the final multi-line string.
1024
+ *
1025
+ * @param graph - The graph to render (may include detached nodes, which are
1026
+ * rendered below the main graph rather than laid out by dagre).
1027
+ * @param options - Render options (rootId, spineTarget, colorize).
1028
+ * @param elidedCount - If > 0, a `┊ (N earlier migrations)` indicator
1029
+ * is stamped above the visible root node.
1030
+ */
1031
+ function layoutAndRender(graph: RenderGraph, options: GraphRenderOptions, elidedCount = 0): string {
1032
+ const colorize = options.colorize ?? true;
1033
+ const colors = buildColors(colorize);
1034
+
1035
+ const layoutNodes = graph.nodes;
1036
+ const layoutNodeIds = new Set(layoutNodes.map((n) => n.id));
1037
+ const requestedRoot = options.rootId ?? layoutNodes[0]?.id ?? '∅';
1038
+ const rootId = layoutNodeIds.has(requestedRoot)
1039
+ ? requestedRoot
1040
+ : findVisibleRoot(graph, layoutNodes);
1041
+
1042
+ const spineEdgeKeys = findSpineEdges(graph, rootId, options.spineTarget);
1043
+
1044
+ const g = new dagre.graphlib.Graph({ multigraph: true });
1045
+ const dagreDefaults = { ranksep: 4, nodesep: 6, marginx: 2, marginy: 1 };
1046
+ g.setGraph({ rankdir: 'TB', ...dagreDefaults, ...options.dagreOptions });
1047
+ g.setDefaultEdgeLabel(() => ({}));
1048
+
1049
+ for (const node of layoutNodes) {
1050
+ const tags = buildInlineTags(node.markers ?? [], colors);
1051
+ const tagWidth = inlineTagsWidth(tags);
1052
+ g.setNode(node.id, { width: node.id.length + 6 + tagWidth, height: 1 });
1053
+ }
1054
+
1055
+ const edgeNames: string[] = [];
1056
+ for (let i = 0; i < graph.edges.length; i++) {
1057
+ const edge = graph.edges[i]!;
1058
+ const name = `e${i}`;
1059
+ edgeNames.push(name);
1060
+ g.setEdge(edge.from, edge.to, { label: edge.label ?? '' }, name);
1061
+ }
1062
+
1063
+ dagre.layout(g);
1064
+
1065
+ const nodePos = new Map<string, Point>();
1066
+ for (const id of g.nodes()) {
1067
+ const n = g.node(id);
1068
+ nodePos.set(id, { x: Math.round(n.x), y: Math.round(n.y) });
1069
+ }
1070
+
1071
+ const grid = new CharGrid();
1072
+
1073
+ // Reserve node label areas so edges and labels avoid them
1074
+ for (const node of layoutNodes) {
1075
+ const pos = nodePos.get(node.id);
1076
+ /* v8 ignore next -- @preserve */
1077
+ if (!pos) continue;
1078
+ const tags = buildInlineTags(node.markers ?? [], colors);
1079
+ const tagWidth = inlineTagsWidth(tags);
1080
+ grid.reserveArea(pos.x - 1, pos.y, node.id.length + 4 + tagWidth);
1081
+ }
1082
+
1083
+ // --- Prepare edge metadata ---
1084
+ type EdgeEntry = {
1085
+ idx: number;
1086
+ edge: GraphEdge;
1087
+ dagrePoints: Point[];
1088
+ src: Point;
1089
+ tgt: Point;
1090
+ role: 'spine' | 'branch' | 'backward';
1091
+ edgeColor: ColorFn;
1092
+ priority: number;
1093
+ };
1094
+ const edgeEntries: EdgeEntry[] = [];
1095
+
1096
+ for (let i = 0; i < graph.edges.length; i++) {
1097
+ const edge = graph.edges[i]!;
1098
+ const name = edgeNames[i]!;
1099
+ if (!name || !nodePos.has(edge.from) || !nodePos.has(edge.to)) continue;
1100
+
1101
+ const src = nodePos.get(edge.from)!;
1102
+ const tgt = nodePos.get(edge.to)!;
1103
+ const dagreEdge = g.edge({ v: edge.from, w: edge.to, name });
1104
+ const dagrePoints: Point[] = dagreEdge?.points ?? [];
1105
+
1106
+ const isBackward = tgt.y < src.y;
1107
+ const isSpine = spineEdgeKeys.has(`${edge.from}→${edge.to}`);
1108
+ const role: EdgeEntry['role'] = isBackward ? 'backward' : isSpine ? 'spine' : 'branch';
1109
+ const hintColor = resolveHintColor(edge.colorHint, colors);
1110
+ const edgeColor =
1111
+ hintColor ??
1112
+ (role === 'backward' ? colors.backward : role === 'spine' ? colors.spine : colors.branch);
1113
+ const priority =
1114
+ role === 'backward' ? PRIORITY.backward : role === 'spine' ? PRIORITY.spine : PRIORITY.branch;
1115
+
1116
+ edgeEntries.push({ idx: i, edge, dagrePoints, src, tgt, role, edgeColor, priority });
1117
+ }
1118
+
1119
+ // --- Pass 1: Draw all edges ---
1120
+ type DrawnEdge = { edge: GraphEdge; poly: Point[]; role: EdgeEntry['role']; srcY: number };
1121
+ const drawnEdges: DrawnEdge[] = [];
1122
+
1123
+ for (const entry of edgeEntries) {
1124
+ const { edge, dagrePoints, src, tgt, edgeColor, priority } = entry;
1125
+
1126
+ const { poly } = selectBestVariant(src, dagrePoints, tgt, edge.label, grid);
1127
+
1128
+ const dashedBit = edge.style === 'dashed' ? DIR.dashed : 0;
1129
+ for (let j = 0; j < poly.length - 1; j++) {
1130
+ const a = poly[j]!;
1131
+ const b = poly[j + 1]!;
1132
+ if (a.y === b.y) {
1133
+ grid.markHorizontal(a.y, a.x, b.x, edgeColor, priority, dashedBit);
1134
+ } else if (a.x === b.x) {
1135
+ grid.markVertical(a.x, a.y, b.y, edgeColor, priority, dashedBit);
1136
+ }
1137
+ }
1138
+
1139
+ drawnEdges.push({ edge, poly, role: entry.role, srcY: src.y });
1140
+ }
1141
+
1142
+ // --- Pass 2: Place labels (longest first) ---
1143
+ const labelOrder = [...drawnEdges]
1144
+ .map((de, i) => ({ ...de, i }))
1145
+ .filter((de) => de.edge.label)
1146
+ .sort((a, b) => (b.edge.label?.length ?? 0) - (a.edge.label?.length ?? 0));
1147
+
1148
+ for (const { edge, poly, role, srcY } of labelOrder) {
1149
+ /* v8 ignore next -- @preserve */
1150
+ if (!edge.label) continue;
1151
+ const labelPos = findLabelPlacement(poly, edge.label, grid, srcY);
1152
+ if (labelPos) {
1153
+ const labelColor =
1154
+ resolveHintColor(edge.colorHint, colors) ??
1155
+ (role === 'backward' ? colors.backward : role === 'spine' ? colors.spine : colors.label);
1156
+ grid.stampText(labelPos.x, labelPos.y, edge.label, labelColor);
1157
+ }
1158
+ }
1159
+
1160
+ // --- Pass 3: Arrowheads ---
1161
+ for (const { edge, poly, role } of drawnEdges) {
1162
+ /* v8 ignore next -- @preserve */
1163
+ if (poly.length < 2) continue;
1164
+ const last = poly[poly.length - 1]!;
1165
+ const prev = poly[poly.length - 2]!;
1166
+
1167
+ const edgeColor =
1168
+ resolveHintColor(edge.colorHint, colors) ??
1169
+ (role === 'backward' ? colors.backward : role === 'spine' ? colors.spine : colors.branch);
1170
+
1171
+ let ax: number | undefined;
1172
+ let ay: number | undefined;
1173
+ let arrow: string | undefined;
1174
+
1175
+ if (prev.x === last.x) {
1176
+ if (last.y > prev.y) {
1177
+ ax = last.x;
1178
+ ay = last.y - 1;
1179
+ arrow = ARROW.down;
1180
+ } else {
1181
+ ax = last.x;
1182
+ ay = last.y + 1;
1183
+ arrow = ARROW.up;
1184
+ }
1185
+ } else {
1186
+ if (last.x > prev.x) {
1187
+ ax = last.x - 1;
1188
+ ay = last.y;
1189
+ arrow = ARROW.right;
1190
+ } else {
1191
+ ax = last.x + 1;
1192
+ ay = last.y;
1193
+ arrow = ARROW.left;
1194
+ }
1195
+ }
1196
+
1197
+ if (ax !== undefined && ay !== undefined && arrow && !grid.hasText(ax, ay)) {
1198
+ grid.stampText(ax, ay, arrow, edgeColor);
1199
+ }
1200
+ }
1201
+
1202
+ // --- Draw nodes ---
1203
+ const spineNodeIds = new Set<string>();
1204
+ for (const key of spineEdgeKeys) {
1205
+ const [from, to] = key.split('→');
1206
+ if (from) spineNodeIds.add(from);
1207
+ if (to) spineNodeIds.add(to);
1208
+ }
1209
+
1210
+ for (const node of layoutNodes) {
1211
+ const pos = nodePos.get(node.id);
1212
+ if (!pos) continue;
1213
+
1214
+ const isSpineNode = spineNodeIds.has(node.id);
1215
+ const nodeColor = isSpineNode ? colors.spine : colors.branch;
1216
+
1217
+ grid.stampText(pos.x, pos.y, '○', nodeColor);
1218
+ grid.stampText(pos.x + 1, pos.y, ' ');
1219
+ const hasMarkers = node.markers && node.markers.length > 0;
1220
+ grid.stampText(pos.x + 2, pos.y, node.id, isSpineNode || hasMarkers ? bold : dim);
1221
+
1222
+ const tags = buildInlineTags(node.markers ?? [], colors);
1223
+ if (tags.length > 0) {
1224
+ let bx = pos.x + 2 + node.id.length;
1225
+ for (const tag of tags) {
1226
+ grid.stampText(bx, pos.y, ' ');
1227
+ bx++;
1228
+ grid.stampText(bx, pos.y, tag.text, tag.color);
1229
+ bx += tag.text.length;
1230
+ }
1231
+ }
1232
+ }
1233
+
1234
+ // --- Elided indicator above root ---
1235
+ if (elidedCount > 0) {
1236
+ const topNodeId =
1237
+ layoutNodes.find((n) => !graph.incomingNodes.has(n.id))?.id ?? layoutNodes[0]?.id;
1238
+ const rootPos = topNodeId ? nodePos.get(topNodeId) : undefined;
1239
+ if (rootPos) {
1240
+ const label = elidedCount === 1 ? '1 earlier migration' : `${elidedCount} earlier migrations`;
1241
+ const topY = rootPos.y - 3;
1242
+ grid.stampText(rootPos.x, topY, '┊', colors.label);
1243
+ grid.stampText(rootPos.x, topY + 1, '┊', colors.label);
1244
+ grid.stampText(rootPos.x + 2, topY + 1, `(${label})`, colors.label);
1245
+ grid.stampText(rootPos.x, topY + 2, '┊', colors.label);
1246
+ }
1247
+ }
1248
+
1249
+ return grid.render();
1250
+ }
1251
+
1252
+ // ---------------------------------------------------------------------------
1253
+ // GraphRenderer implementation
1254
+ // ---------------------------------------------------------------------------
1255
+
1256
+ /**
1257
+ * BFS to find the ordered node path from `rootId` to `targetId`.
1258
+ * Used for truncation — the spine path determines which edges to keep.
1259
+ *
1260
+ * Returns `[rootId]` if no path exists.
1261
+ */
1262
+ function findSpinePath(graph: RenderGraph, rootId: string, targetId: string): string[] {
1263
+ const visited = new Set([rootId]);
1264
+ const parent = new Map<string, string>();
1265
+ const queue = [rootId];
1266
+ while (queue.length > 0) {
1267
+ const current = queue.shift()!;
1268
+ if (current === targetId) {
1269
+ const path: string[] = [];
1270
+ let node = targetId;
1271
+ while (node !== rootId) {
1272
+ path.unshift(node);
1273
+ node = parent.get(node)!;
1274
+ }
1275
+ path.unshift(rootId);
1276
+ return path;
1277
+ }
1278
+ for (const edge of graph.outgoing(current)) {
1279
+ if (!visited.has(edge.to)) {
1280
+ visited.add(edge.to);
1281
+ parent.set(edge.to, current);
1282
+ queue.push(edge.to);
1283
+ }
1284
+ }
1285
+ }
1286
+ return [rootId];
1287
+ }
1288
+
1289
+ /**
1290
+ * Render a graph with optional truncation.
1291
+ *
1292
+ * The caller decides what to pass in: the full graph for `--graph`, or a
1293
+ * subgraph extracted via {@link extractRelevantSubgraph} for the default view.
1294
+ */
1295
+ function render(graph: RenderGraph, options: GraphRenderOptions): string {
1296
+ if (options.limit !== undefined) {
1297
+ const spine = findSpinePath(
1298
+ graph,
1299
+ options.rootId ?? graph.nodes[0]?.id ?? '∅',
1300
+ options.spineTarget,
1301
+ );
1302
+ const { graph: truncated, elidedCount } = truncateGraph(graph, spine, options.limit);
1303
+ return layoutAndRender(truncated, options, elidedCount);
1304
+ }
1305
+ return layoutAndRender(graph, options);
1306
+ }
1307
+
1308
+ export interface GraphRenderer {
1309
+ render(graph: RenderGraph, options: GraphRenderOptions): string;
1310
+ }
1311
+
1312
+ export const graphRenderer: GraphRenderer = {
1313
+ render,
1314
+ };
1315
+
1316
+ /** True if the graph is a single linear chain (no branching), ignoring dashed edges. */
1317
+ export function isLinearGraph(graph: RenderGraph): boolean {
1318
+ for (const node of graph.nodes) {
1319
+ const solidOutgoing = graph.outgoing(node.id).filter((e) => e.style !== 'dashed');
1320
+ if (solidOutgoing.length > 1) return false;
1321
+ }
1322
+ return true;
1323
+ }