@prisma-next/cli 0.12.0-dev.6 → 0.12.0-dev.60

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 (201) 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-DQvxtihf.mjs} +194 -105
  5. package/dist/client-DQvxtihf.mjs.map +1 -0
  6. package/dist/{command-helpers-Bbw1GbwL.mjs → command-helpers-CxHSiwEg.mjs} +318 -25
  7. package/dist/command-helpers-CxHSiwEg.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 +291 -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 +4 -4
  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-gBvfOS8r.mjs} +2 -2
  67. package/dist/{contract-at-errors-BxP-TOMl.mjs.map → contract-at-errors-gBvfOS8r.mjs.map} +1 -1
  68. package/dist/{contract-emit-DxcGl4Uq.mjs → contract-emit-By59Nmmn.mjs} +5 -5
  69. package/dist/{contract-emit-DxcGl4Uq.mjs.map → contract-emit-By59Nmmn.mjs.map} +1 -1
  70. package/dist/{contract-emit-D-4jrNve.mjs → contract-emit-OpMbysHj.mjs} +7 -7
  71. package/dist/{contract-emit-D-4jrNve.mjs.map → contract-emit-OpMbysHj.mjs.map} +1 -1
  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-BkyyYGDf.mjs} +3 -3
  75. package/dist/{contract-infer-D8uEbJuu.mjs.map → contract-infer-BkyyYGDf.mjs.map} +1 -1
  76. package/dist/{contract-space-aggregate-loader-DvZwdkrr.mjs → contract-space-aggregate-loader-Bup14UkI.mjs} +63 -5
  77. package/dist/{contract-space-aggregate-loader-DvZwdkrr.mjs.map → contract-space-aggregate-loader-Bup14UkI.mjs.map} +1 -1
  78. package/dist/{db-verify-v_vUKXTU.mjs → db-verify-DbmfgeYc.mjs} +6 -6
  79. package/dist/{db-verify-v_vUKXTU.mjs.map → db-verify-DbmfgeYc.mjs.map} +1 -1
  80. package/dist/exports/control-api.d.mts +2 -2
  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/{framework-components-fYXjz_in.mjs → framework-components-CmBpbvzV.mjs} +2 -2
  89. package/dist/{framework-components-fYXjz_in.mjs.map → framework-components-CmBpbvzV.mjs.map} +1 -1
  90. package/dist/{global-flags-DEHjV8_s.d.mts → global-flags-BpoOYtNZ.d.mts} +1 -1
  91. package/dist/{global-flags-DEHjV8_s.d.mts.map → global-flags-BpoOYtNZ.d.mts.map} +1 -1
  92. package/dist/{init-Cv9UzWL5.mjs → init-tidQpK21.mjs} +5 -58
  93. package/dist/init-tidQpK21.mjs.map +1 -0
  94. package/dist/{inspect-live-schema-C6ohV_oQ.mjs → inspect-live-schema-CUvD_9uF.mjs} +5 -5
  95. package/dist/{inspect-live-schema-C6ohV_oQ.mjs.map → inspect-live-schema-CUvD_9uF.mjs.map} +1 -1
  96. package/dist/migration-check-BjNlXTGF.mjs +572 -0
  97. package/dist/migration-check-BjNlXTGF.mjs.map +1 -0
  98. package/dist/migration-cli.mjs +1 -1
  99. package/dist/migration-cli.mjs.map +1 -1
  100. package/dist/{migration-command-scaffold-CjvwO6at.mjs → migration-command-scaffold-DWlpBp98.mjs} +5 -5
  101. package/dist/{migration-command-scaffold-CjvwO6at.mjs.map → migration-command-scaffold-DWlpBp98.mjs.map} +1 -1
  102. package/dist/migration-graph-space-render-Cpg0ql8v.mjs +2370 -0
  103. package/dist/migration-graph-space-render-Cpg0ql8v.mjs.map +1 -0
  104. package/dist/migration-list-zP59uUBC.mjs +230 -0
  105. package/dist/migration-list-zP59uUBC.mjs.map +1 -0
  106. package/dist/migration-log-DoytJNuF.mjs +222 -0
  107. package/dist/migration-log-DoytJNuF.mjs.map +1 -0
  108. package/dist/migration-path-target-DjbhWi_5.mjs +38 -0
  109. package/dist/migration-path-target-DjbhWi_5.mjs.map +1 -0
  110. package/dist/{migration-plan-9DJ7q7_z.mjs → migration-plan-CgCXpjYD.mjs} +6 -7
  111. package/dist/{migration-plan-9DJ7q7_z.mjs.map → migration-plan-CgCXpjYD.mjs.map} +1 -1
  112. package/dist/migration-status-ByptVtRZ.mjs +446 -0
  113. package/dist/migration-status-ByptVtRZ.mjs.map +1 -0
  114. package/dist/{output-B60Gw5fu.mjs → output-mEQ74_nd.mjs} +1 -1
  115. package/dist/{output-B60Gw5fu.mjs.map → output-mEQ74_nd.mjs.map} +1 -1
  116. package/dist/{progress-adapter-C644QK8l.mjs → progress-adapter-CjAeTxY_.mjs} +1 -1
  117. package/dist/{progress-adapter-C644QK8l.mjs.map → progress-adapter-CjAeTxY_.mjs.map} +1 -1
  118. package/dist/{ref-advancement-DUZqsue6.mjs → ref-advancement-BkXlikCA.mjs} +1 -1
  119. package/dist/{ref-advancement-DUZqsue6.mjs.map → ref-advancement-BkXlikCA.mjs.map} +1 -1
  120. package/dist/schemas-CeGMYFYX.d.mts +191 -0
  121. package/dist/schemas-CeGMYFYX.d.mts.map +1 -0
  122. package/dist/schemas-KhXMzNA_.mjs +112 -0
  123. package/dist/schemas-KhXMzNA_.mjs.map +1 -0
  124. package/dist/telemetry-DVv3V0gj.mjs +122 -0
  125. package/dist/telemetry-DVv3V0gj.mjs.map +1 -0
  126. package/dist/{terminal-ui-5Y6mrg93.d.mts → terminal-ui-DGRNFWna.d.mts} +1 -1
  127. package/dist/terminal-ui-DGRNFWna.d.mts.map +1 -0
  128. package/dist/{types-Dt_SfqFm.d.mts → types-BepB6ydp.d.mts} +44 -31
  129. package/dist/types-BepB6ydp.d.mts.map +1 -0
  130. package/dist/{verify-DCA9Sldu.mjs → verify-CJpG9m7-.mjs} +2 -2
  131. package/dist/{verify-DCA9Sldu.mjs.map → verify-CJpG9m7-.mjs.map} +1 -1
  132. package/package.json +25 -22
  133. package/src/cli.ts +5 -0
  134. package/src/commands/db-update.ts +7 -1
  135. package/src/commands/init/index.ts +6 -35
  136. package/src/commands/init/init.ts +1 -14
  137. package/src/commands/init/inputs.ts +0 -75
  138. package/src/commands/json/schemas.ts +195 -0
  139. package/src/commands/migrate.ts +518 -8
  140. package/src/commands/migration-check.ts +469 -134
  141. package/src/commands/migration-graph.ts +164 -91
  142. package/src/commands/migration-list.ts +72 -39
  143. package/src/commands/migration-log.ts +52 -102
  144. package/src/commands/migration-show.ts +31 -66
  145. package/src/commands/migration-status-overlay.ts +61 -0
  146. package/src/commands/migration-status.ts +457 -1067
  147. package/src/commands/telemetry/index.ts +107 -0
  148. package/src/commands/telemetry/status.ts +67 -0
  149. package/src/control-api/client.ts +20 -9
  150. package/src/control-api/operations/contract-emit.ts +2 -2
  151. package/src/control-api/operations/db-init.ts +3 -3
  152. package/src/control-api/operations/{db-apply.ts → db-run.ts} +51 -13
  153. package/src/control-api/operations/db-update.ts +4 -4
  154. package/src/control-api/operations/db-verify.ts +15 -5
  155. package/src/control-api/operations/{migration-apply.ts → migrate.ts} +181 -80
  156. package/src/control-api/operations/{apply.ts → run-migration.ts} +33 -27
  157. package/src/control-api/types.ts +46 -29
  158. package/src/utils/cli-errors.ts +70 -2
  159. package/src/utils/formatters/errors.ts +11 -0
  160. package/src/utils/formatters/migration-graph-lane-colors.ts +194 -0
  161. package/src/utils/formatters/migration-graph-layout.ts +227 -38
  162. package/src/utils/formatters/migration-graph-rows.ts +128 -15
  163. package/src/utils/formatters/migration-graph-space-render.ts +148 -0
  164. package/src/utils/formatters/migration-graph-tree-render.ts +959 -81
  165. package/src/utils/formatters/migration-list-data-column.ts +4 -91
  166. package/src/utils/formatters/migration-list-graph-topology.ts +72 -94
  167. package/src/utils/formatters/migration-list-render.ts +135 -71
  168. package/src/utils/formatters/migration-list-styler.ts +46 -5
  169. package/src/utils/formatters/migration-list-types.ts +5 -21
  170. package/src/utils/formatters/migration-log-table.ts +205 -0
  171. package/src/utils/formatters/migrations.ts +33 -11
  172. package/src/utils/global-flags.ts +35 -0
  173. package/src/utils/integrity-violation-to-check-failure.ts +28 -19
  174. package/src/utils/legend.ts +38 -0
  175. package/src/utils/migration-path-target.ts +60 -0
  176. package/src/utils/telemetry.ts +68 -32
  177. package/dist/client-KgJorIvG.mjs.map +0 -1
  178. package/dist/command-helpers-Bbw1GbwL.mjs.map +0 -1
  179. package/dist/commands/migration-list.mjs.map +0 -1
  180. package/dist/commands/migration-log.mjs.map +0 -1
  181. package/dist/commands/migration-status.mjs.map +0 -1
  182. package/dist/extension-pack-inputs-IDvjRCi3.mjs +0 -62
  183. package/dist/extension-pack-inputs-IDvjRCi3.mjs.map +0 -1
  184. package/dist/graph-render-rFAqZujX.mjs +0 -1081
  185. package/dist/graph-render-rFAqZujX.mjs.map +0 -1
  186. package/dist/init-Cv9UzWL5.mjs.map +0 -1
  187. package/dist/migration-check-BiBJoYYW.mjs +0 -341
  188. package/dist/migration-check-BiBJoYYW.mjs.map +0 -1
  189. package/dist/migration-graph-D7DVUElV.mjs +0 -1232
  190. package/dist/migration-graph-D7DVUElV.mjs.map +0 -1
  191. package/dist/migration-list-styler-BRwF4-gy.mjs +0 -399
  192. package/dist/migration-list-styler-BRwF4-gy.mjs.map +0 -1
  193. package/dist/migration-types-D2FW63pr.d.mts +0 -15
  194. package/dist/migration-types-D2FW63pr.d.mts.map +0 -1
  195. package/dist/migrations-Cv2jxNNK.mjs +0 -228
  196. package/dist/migrations-Cv2jxNNK.mjs.map +0 -1
  197. package/dist/terminal-ui-5Y6mrg93.d.mts.map +0 -1
  198. package/dist/types-Dt_SfqFm.d.mts.map +0 -1
  199. package/src/utils/formatters/graph-migration-mapper.ts +0 -235
  200. package/src/utils/formatters/graph-render.ts +0 -1323
  201. package/src/utils/formatters/graph-types.ts +0 -120
@@ -1,7 +1,18 @@
1
1
  import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
2
- import { bold } from 'colorette';
2
+ import { bold, createColors, green, yellow } from 'colorette';
3
3
  import stringWidth from 'string-width';
4
4
  import type { GlyphMode } from '../glyph-mode';
5
+ import {
6
+ laneColorForColumn,
7
+ NEUTRAL_LANE_COLUMN,
8
+ type RowArcLaneColors,
9
+ resolveConnectorLaneColors,
10
+ resolveRowArcLaneColors,
11
+ stylerForLaneColumn,
12
+ } from './migration-graph-lane-colors';
13
+
14
+ export { resolveConnectorLaneColors } from './migration-graph-lane-colors';
15
+
5
16
  import type {
6
17
  MigrationGraphGridModel,
7
18
  MigrationGraphGridRow,
@@ -12,10 +23,15 @@ import {
12
23
  MIGRATION_LIST_HASH_WIDTH,
13
24
  migrationListEmptySource,
14
25
  migrationListForwardArrow,
26
+ padFromHashColumn,
15
27
  } from './migration-list-data-column';
16
28
  import type { MigrationEdgeKind } from './migration-list-graph-topology';
17
29
  import type { MigrationListStyler } from './migration-list-render';
18
- import { CONTRACT_MARKER_NAME, createAnsiMigrationListStyler } from './migration-list-styler';
30
+ import {
31
+ CONTRACT_MARKER_NAME,
32
+ createAnsiMigrationListStyler,
33
+ formatContractNodeOverlays,
34
+ } from './migration-list-styler';
19
35
 
20
36
  const LABEL_GAP = 2;
21
37
 
@@ -26,14 +42,37 @@ const LABEL_GAP = 2;
26
42
  */
27
43
  const DB_MARKER_NAME = 'db';
28
44
 
45
+ export interface MigrationEdgeAnnotation {
46
+ readonly status?: 'applied' | 'pending';
47
+ readonly operationCount?: number;
48
+ readonly invariants?: readonly string[];
49
+ /**
50
+ * Path-highlight annotation for `migrate --show` preview.
51
+ * - `'on-path'`: migration is on the chosen path; rendered in bright green (nodes, hashes, names, lane lines).
52
+ * - `'off-path'`: migration is off the chosen path; fully drawn but in uniform dim grey.
53
+ */
54
+ readonly pathHighlight?: 'on-path' | 'off-path';
55
+ }
56
+
29
57
  export interface RenderMigrationGraphTreeOptions {
30
58
  readonly refsByHash?: ReadonlyMap<string, readonly string[]>;
59
+ readonly edgeAnnotationsByHash?: ReadonlyMap<string, MigrationEdgeAnnotation>;
31
60
  readonly dbHash?: string;
32
61
  readonly contractHash?: string;
62
+ /**
63
+ * Whether this render is for the app space. When false, the `@contract`
64
+ * marker is suppressed — `@contract` is an app-space concept and must not
65
+ * appear in extension spaces (e.g. `pgvector:`). Defaults to `true` so
66
+ * single-space callers that do not pass this option are unaffected.
67
+ */
68
+ readonly isAppSpace?: boolean;
33
69
  readonly activeRefName?: string;
34
70
  readonly hashLength?: number;
71
+ readonly globalMaxEdgeTreePrefixWidth?: number;
72
+ readonly globalMaxDirNameWidth?: number;
35
73
  readonly colorize: boolean;
36
74
  readonly glyphMode?: GlyphMode;
75
+ readonly styler?: MigrationListStyler;
37
76
  }
38
77
 
39
78
  interface MigrationGraphTreeGlyphPalette {
@@ -48,6 +87,7 @@ interface MigrationGraphTreeGlyphPalette {
48
87
  readonly arcBranchCorner: string;
49
88
  readonly arcBranchTee: string;
50
89
  readonly arcLandCorner: string;
90
+ readonly arcLandTee: string;
51
91
  readonly arcCrossing: string;
52
92
  readonly arcLandBridge: string;
53
93
  readonly horizontalPass: string;
@@ -71,6 +111,7 @@ const UNICODE_PALETTE: MigrationGraphTreeGlyphPalette = {
71
111
  arcBranchCorner: '╮ ',
72
112
  arcBranchTee: '┬─',
73
113
  arcLandCorner: '╯ ',
114
+ arcLandTee: '┴─',
74
115
  arcCrossing: '┼─',
75
116
  arcLandBridge: '──',
76
117
  horizontalPass: '──',
@@ -94,6 +135,7 @@ const ASCII_PALETTE: MigrationGraphTreeGlyphPalette = {
94
135
  arcBranchCorner: '\\ ',
95
136
  arcBranchTee: '+-',
96
137
  arcLandCorner: '/ ',
138
+ arcLandTee: '+-',
97
139
  arcCrossing: '+-',
98
140
  arcLandBridge: '--',
99
141
  horizontalPass: '--',
@@ -109,6 +151,13 @@ function paletteFor(mode: GlyphMode): MigrationGraphTreeGlyphPalette {
109
151
  return mode === 'ascii' ? ASCII_PALETTE : UNICODE_PALETTE;
110
152
  }
111
153
 
154
+ function overlayStatusGlyphs(mode: GlyphMode): {
155
+ readonly applied: string;
156
+ readonly pending: string;
157
+ } {
158
+ return mode === 'ascii' ? { applied: '+', pending: '>' } : { applied: '✓', pending: '⧗' };
159
+ }
160
+
112
161
  function arrowForEdgeKind(
113
162
  kind: MigrationEdgeKind,
114
163
  palette: MigrationGraphTreeGlyphPalette,
@@ -116,92 +165,385 @@ function arrowForEdgeKind(
116
165
  return palette.edgeArrow[kind];
117
166
  }
118
167
 
168
+ /**
169
+ * Forced-color functions that always emit ANSI regardless of the ambient TTY
170
+ * environment (NO_COLOR, piped output). Used for:
171
+ *
172
+ * - `forcedBold`: branch-coloured migration names pair their lane hue with bold;
173
+ * both must emit so the name is deterministically bold + hue.
174
+ * - `forcedDim`: off-path path-highlight override (migrate --show).
175
+ * The renderer gates this behind `opts.colorize`; the forced variant ensures
176
+ * ANSI is emitted in controlled environments (e.g. tests with `NO_COLOR=1`)
177
+ * when the caller explicitly requests colour. Without forcing, `dim()` from
178
+ * the ambient module-level import no-ops under NO_COLOR, making the
179
+ * path-highlight unreachable in tests.
180
+ */
181
+ const {
182
+ bold: forcedBold,
183
+ dim: forcedDim,
184
+ greenBright: forcedGreen,
185
+ } = createColors({ useColor: true });
186
+
187
+ /**
188
+ * The two styles used in `migrate --show` path-highlight mode.
189
+ *
190
+ * In path-highlight mode the normal by-branch rotating-colour logic
191
+ * (`LANE_COLOR_CYCLE` / `laneStylerForColumn`) is suppressed entirely.
192
+ * Every glyph, name, and hash is styled by its on-path / off-path role,
193
+ * never by lane column index.
194
+ *
195
+ * - `onPath`: neutral single-path style — exactly how a linear (no-branch)
196
+ * section renders today. Lane glyphs are dim, names are bold, hashes use
197
+ * the default `sourceHash`/`destHash` colours. No rotation hue is applied.
198
+ * This is identical to how the pgvector single-path section renders.
199
+ * - `offPath`: uniform dim grey on every cell (name, hashes, lane glyphs,
200
+ * direction arrows).
201
+ *
202
+ * To change the on-path or off-path colour in future, edit this object only.
203
+ */
204
+ export const PATH_HIGHLIGHT_STYLES = {
205
+ /**
206
+ * Lane/glyph/arrow stylers for on-path cells.
207
+ *
208
+ * - lane: `forcedGreen` when colour is on — bright green so the on-path
209
+ * branch glyphs (`│ ├ ╯ ↑`) and node markers (`○`/`∅`) are visually
210
+ * distinct from off-path (dim grey). Uses forced ANSI so it survives
211
+ * NO_COLOR in tests. Identity when `colorize` is false.
212
+ * - arrow: identity (plain, no colouring)
213
+ * - dirName: `bold` (ambient bold — name stays white/bold, not green)
214
+ * - hashOverride: undefined — `style.sourceHash`/`style.destHash` apply
215
+ * normally (cyan) so hashes keep their existing neutral colour.
216
+ *
217
+ * `style` is the same `MigrationListStyler` the tree renderer uses.
218
+ * Rotation (`LANE_COLOR_CYCLE`) is never applied to on-path cells.
219
+ */
220
+ onPath: (_style: MigrationListStyler, colorize: boolean) => ({
221
+ lane: colorize ? forcedGreen : (text: string) => text,
222
+ arrow: (text: string) => text,
223
+ dirName: (text: string) => bold(text),
224
+ hashOverride: undefined,
225
+ }),
226
+ /**
227
+ * Lane/glyph/arrow/hash stylers for off-path cells.
228
+ * Uniform dim grey on everything — uses `forcedDim` so ANSI is emitted even
229
+ * under NO_COLOR (test environments use `colorize:true` + NO_COLOR=1 to verify dim).
230
+ * Returns identity functions when colour is off (`colorize: false`).
231
+ */
232
+ offPath: (colorize: boolean) => ({
233
+ lane: colorize ? forcedDim : (text: string) => text,
234
+ arrow: colorize ? forcedDim : (text: string) => text,
235
+ dirName: colorize ? forcedDim : (text: string) => text,
236
+ hashOverride: colorize ? forcedDim : undefined,
237
+ }),
238
+ } as const;
239
+
240
+ function laneStylerForColumn(
241
+ colorColumn: number,
242
+ colorize: boolean,
243
+ style: MigrationListStyler,
244
+ ): (text: string) => string {
245
+ return stylerForLaneColumn(colorColumn, colorize, style.lane);
246
+ }
247
+
248
+ /**
249
+ * Tint a branch-owned token (direction arrow, migration name) by its edge's
250
+ * lane so the whole branch row reads in one colour. Column 0 has nothing to be
251
+ * told apart from in the common linear chain, so it keeps the token's existing
252
+ * default styling (`fallback`) rather than a palette hue; only lanes ≥ 1 take a
253
+ * colour. With colour off, the fallback (also colourless) is used unchanged.
254
+ */
255
+ function branchStylerOrDefault(
256
+ column: number,
257
+ colorize: boolean,
258
+ fallback: (text: string) => string,
259
+ ): (text: string) => string {
260
+ if (!colorize || column <= NEUTRAL_LANE_COLUMN) {
261
+ return fallback;
262
+ }
263
+ return laneColorForColumn(column);
264
+ }
265
+
266
+ /**
267
+ * Render a crossing tee (`┼─`): the junction stays dim/neutral so neither arc
268
+ * steals the cell; the trailing dash takes the served lane hue.
269
+ */
270
+ function renderArcCrossing(
271
+ pair: string,
272
+ dashColumn: number,
273
+ colorize: boolean,
274
+ style: MigrationListStyler,
275
+ ): string {
276
+ const junction = colorize ? style.lane : (text: string) => text;
277
+ const dash = laneStylerForColumn(dashColumn, colorize, style);
278
+ return junction(pair.slice(0, 1)) + dash(pair.slice(1));
279
+ }
280
+
281
+ /**
282
+ * Render a connector tee (`├─` / `┬─` / `┴─`) with its junction glyph and its
283
+ * trailing dash coloured independently: the junction anchors its own lane while
284
+ * the dash leads into the branch on its right.
285
+ */
286
+ function renderConnectorTee(
287
+ pair: string,
288
+ glyphColumn: number,
289
+ dashColumn: number,
290
+ colorize: boolean,
291
+ style: MigrationListStyler,
292
+ ): string {
293
+ const glyph = laneStylerForColumn(glyphColumn, colorize, style);
294
+ if (glyphColumn === dashColumn) {
295
+ return glyph(pair);
296
+ }
297
+ return glyph(pair.slice(0, 1)) + laneStylerForColumn(dashColumn, colorize, style)(pair.slice(1));
298
+ }
299
+
119
300
  /**
120
301
  * A node-marker glyph pair (`○◂`, `○─`, `*<`, `*-`) is the contract node
121
- * marker (`○` / `*`) followed by an arc connector (`◂` / `─` / `<` / `-`).
122
- * The marker is the signal and stays bright (`style.kind`); the connector is
123
- * gutter and stays dim (`style.lane`) consistent with the plain node marker,
124
- * which is never dimmed.
302
+ * marker (`○` / `*`) followed by an arc connector (`◂` / `─` / `<` / `-`). The
303
+ * marker takes its own lane's hue (so each node visibly belongs to its branch);
304
+ * the connector follows the arc it belongs to (its owning back-lane hue).
305
+ * Direction arrows are handled elsewhere — they take their edge's lane hue too.
306
+ *
307
+ * When `laneOverride` is provided (for path-highlight rows), it replaces the
308
+ * marker styler. `arcLaneOverride` (if provided) replaces the connector styler
309
+ * independently — this matters when the node is on-path but the arc belongs to
310
+ * an off-path rollback edge, which must render dim rather than green.
125
311
  */
126
- function renderNodeMarkerPair(pair: string, style: MigrationListStyler): string {
127
- return style.kind(pair.slice(0, 1)) + style.lane(pair.slice(1));
312
+ function renderNodeMarkerPair(
313
+ pair: string,
314
+ nodeColumn: number,
315
+ arcColumn: number,
316
+ colorize: boolean,
317
+ style: MigrationListStyler,
318
+ laneOverride?: (text: string) => string,
319
+ arcLaneOverride?: (text: string) => string,
320
+ ): string {
321
+ const marker = laneOverride ?? laneStylerForColumn(nodeColumn, colorize, style);
322
+ const connector =
323
+ arcLaneOverride ?? laneOverride ?? laneStylerForColumn(arcColumn, colorize, style);
324
+ return marker(pair.slice(0, 1)) + connector(pair.slice(1));
128
325
  }
129
326
 
130
327
  function renderCellPair(
131
328
  cell: StructuralCell,
329
+ column: number,
330
+ colors: RowArcLaneColors,
331
+ colorize: boolean,
132
332
  style: MigrationListStyler,
133
333
  palette: MigrationGraphTreeGlyphPalette,
334
+ laneOverride?: (text: string) => string,
335
+ arrowOverride?: (text: string) => string,
336
+ arcLaneOverride?: (text: string) => string,
134
337
  ): string {
338
+ const laneColumn = colors.lane[column] ?? column;
339
+ // In path-highlight mode (`laneOverride` present), the rotating lane colour is
340
+ // bypassed entirely — the override applies to every structural glyph. Without an
341
+ // override (normal graph/status/list mode), the existing rotation logic applies.
342
+ const lane = laneOverride ?? laneStylerForColumn(laneColumn, colorize, style);
343
+ // `arrowOverride` is used only for the direction arrow on edge-lane cells.
344
+ // When absent, the normal `branchStylerOrDefault` logic applies (rotation for lanes ≥ 1).
345
+ // In path-highlight mode it is always set alongside `laneOverride`.
346
+ const arrow =
347
+ arrowOverride ?? ((text: string) => branchStylerOrDefault(column, colorize, style.kind)(text));
135
348
  switch (cell.kind) {
136
- case 'node':
137
- if (cell.arcLand === true) return renderNodeMarkerPair(palette.arcLand, style);
138
- if (cell.arcTee === true) return renderNodeMarkerPair(palette.arcTee, style);
139
- return style.kind(palette.node);
349
+ case 'node': {
350
+ const arcColumn = colors.connector[column] ?? NEUTRAL_LANE_COLUMN;
351
+ if (cell.arcLand === true) {
352
+ return renderNodeMarkerPair(
353
+ palette.arcLand,
354
+ column,
355
+ arcColumn,
356
+ colorize,
357
+ style,
358
+ laneOverride,
359
+ arcLaneOverride,
360
+ );
361
+ }
362
+ if (cell.arcTee === true) {
363
+ return renderNodeMarkerPair(
364
+ palette.arcTee,
365
+ column,
366
+ arcColumn,
367
+ colorize,
368
+ style,
369
+ laneOverride,
370
+ arcLaneOverride,
371
+ );
372
+ }
373
+ return lane(palette.node);
374
+ }
140
375
  case 'vertical-pass':
141
- return style.lane(palette.verticalPass);
376
+ return lane(palette.verticalPass);
142
377
  case 'edge-lane':
143
- // The lane stays dim; the direction arrow (↑ / ↓ / ⟲) is the signal and
144
- // stays bright, like the contract-node marker.
145
378
  return cell.ownsLabel
146
- ? style.lane(palette.verticalPass.trimEnd()) +
147
- style.kind(arrowForEdgeKind(cell.edgeKind, palette))
148
- : style.lane(palette.verticalPass);
379
+ ? lane(palette.verticalPass.trimEnd()) + arrow(arrowForEdgeKind(cell.edgeKind, palette))
380
+ : lane(palette.verticalPass);
149
381
  case 'branch-tee':
150
- return style.lane(palette.branchTee);
382
+ return lane(palette.branchTee);
151
383
  case 'merge-tee':
152
- return style.lane(palette.mergeTee);
384
+ return lane(palette.mergeTee);
153
385
  case 'branch-corner':
154
- return style.lane(palette.branchCorner);
386
+ return lane(palette.branchCorner);
155
387
  case 'merge-corner':
156
- return style.lane(palette.mergeCorner);
388
+ return lane(palette.mergeCorner);
157
389
  case 'arc-branch-corner':
158
- return style.lane(palette.arcBranchCorner);
390
+ return lane(palette.arcBranchCorner);
159
391
  case 'arc-branch-tee':
160
- return style.lane(palette.arcBranchTee);
392
+ return lane(palette.arcBranchTee);
161
393
  case 'arc-land-corner':
162
- return style.lane(palette.arcLandCorner);
394
+ return lane(palette.arcLandCorner);
395
+ case 'arc-land-tee':
396
+ // When a lane override is active, apply it uniformly to both glyph and dash parts
397
+ // so neither part emits a rotation hue.
398
+ return laneOverride !== undefined
399
+ ? laneOverride(palette.arcLandTee)
400
+ : renderConnectorTee(
401
+ palette.arcLandTee,
402
+ laneColumn,
403
+ colors.dash[column] ?? laneColumn,
404
+ colorize,
405
+ style,
406
+ );
163
407
  case 'arc-crossing':
164
- return style.lane(palette.arcCrossing);
408
+ return lane(palette.arcLandBridge);
165
409
  case 'arc-land-bridge':
166
- return style.lane(palette.arcLandBridge);
410
+ return lane(palette.arcLandBridge);
167
411
  case 'horizontal-pass':
168
- return style.lane(palette.horizontalPass);
412
+ return lane(palette.horizontalPass);
169
413
  case 'empty':
170
414
  return ' ';
171
415
  }
172
416
  }
173
417
 
418
+ /**
419
+ * Render a branch-connector or merge-connector row.
420
+ *
421
+ * `columnLaneOverride` is an optional per-column map populated when path-highlight
422
+ * annotations are active (`migrate --show`). For each column in the connector's
423
+ * lane range, the map supplies the override styler (dim for off-path) that should
424
+ * replace the normal rotating-lane colour for that column. Columns absent from the
425
+ * map (on-path or unannotated) use the standard `laneStylerForColumn` logic unchanged.
426
+ * This ensures off-path branch connectors appear dim rather than in their rotation
427
+ * colour (e.g. magenta).
428
+ */
174
429
  function renderConnectorRow(
175
430
  row: MigrationGraphGridRow,
176
431
  gridWidth: number,
432
+ colorize: boolean,
177
433
  style: MigrationListStyler,
178
434
  palette: MigrationGraphTreeGlyphPalette,
435
+ columnLaneOverride?: ReadonlyMap<number, (text: string) => string>,
179
436
  ): string {
437
+ const resolvedLane = (column: number): ((text: string) => string) =>
438
+ columnLaneOverride?.get(column) ?? laneStylerForColumn(column, colorize, style);
439
+
180
440
  const isMerge = row.kind === 'merge-connector';
181
441
  if (row.cells.length > 0) {
442
+ const colors = resolveConnectorLaneColors(row.cells, row.startLane ?? 0);
182
443
  let seenTee = false;
183
444
  let out = '';
184
- for (const cell of row.cells) {
445
+ for (let column = 0; column < row.cells.length; column++) {
446
+ const cell = row.cells[column];
447
+ if (cell === undefined) continue;
448
+ const glyphColumn = colors.glyph[column] ?? column;
449
+ const dashColumn = colors.dash[column] ?? glyphColumn;
450
+ const override = columnLaneOverride?.get(glyphColumn);
451
+ // In path-highlight mode, the dash column's override is used for the trailing dash
452
+ // even when the glyph column has no override. This handles branch-tee cells whose
453
+ // migrationHash is undefined (no previous edge occupied that lane) — the tee's dash
454
+ // belongs to the connector run and should follow the corner's annotation.
455
+ const dashOverrideForPathHighlight = columnLaneOverride?.get(dashColumn) ?? override;
456
+ if (
457
+ override !== undefined ||
458
+ (columnLaneOverride !== undefined && dashOverrideForPathHighlight !== undefined)
459
+ ) {
460
+ // When an override is active for this column (or when a dash override is available
461
+ // via the connected corner), apply the glyph column's override to the junction glyph
462
+ // (├/┬/┴), and the dash column's override to the trailing dash.
463
+ // This matters for merge/branch connectors: the on-path trunk's tee (├) is green
464
+ // while the dash (─) and corner (╯) bridging to an OFF-path column are dim.
465
+ // For non-tee cells (corner, pass, crossing), the single-column override is fine.
466
+ const effectiveOverride = override ?? dashOverrideForPathHighlight;
467
+ if (effectiveOverride === undefined) {
468
+ out += ' ';
469
+ continue;
470
+ }
471
+ switch (cell.kind) {
472
+ case 'branch-tee':
473
+ case 'merge-tee': {
474
+ const pair = seenTee ? palette.connectorBranchTeeCo : palette.connectorBranchTee;
475
+ // Both the junction glyph and its trailing dash belong to this tee cell's
476
+ // own edge — use effectiveOverride for both so an off-path tee's dash is dim
477
+ // even when the next column (dashColumn) belongs to an on-path edge.
478
+ out += effectiveOverride(pair.slice(0, 1)) + effectiveOverride(pair.slice(1));
479
+ seenTee = true;
480
+ break;
481
+ }
482
+ case 'branch-corner':
483
+ out += effectiveOverride(palette.branchCorner);
484
+ break;
485
+ case 'merge-corner':
486
+ out += effectiveOverride(palette.mergeCorner);
487
+ break;
488
+ case 'vertical-pass':
489
+ out += effectiveOverride(palette.verticalPass);
490
+ break;
491
+ case 'horizontal-pass':
492
+ out += effectiveOverride(palette.horizontalPass);
493
+ break;
494
+ case 'arc-crossing': {
495
+ // The junction glyph (┼) belongs to the vertical lane (effectiveOverride).
496
+ // The trailing dash (─) runs horizontally into the next column — it belongs
497
+ // to that column's owner (dashColumn). Use the dash column's override so an
498
+ // off-path horizontal continuation is dim even when the crossing is on-path.
499
+ const arcCrossingDashOverride =
500
+ columnLaneOverride?.get(dashColumn) ?? effectiveOverride;
501
+ out +=
502
+ effectiveOverride(palette.arcCrossing.slice(0, 1)) +
503
+ arcCrossingDashOverride(palette.arcCrossing.slice(1));
504
+ break;
505
+ }
506
+ default:
507
+ out += ' ';
508
+ }
509
+ continue;
510
+ }
511
+ const lane = laneStylerForColumn(glyphColumn, colorize, style);
185
512
  switch (cell.kind) {
186
513
  case 'branch-tee':
187
- out += style.lane(seenTee ? palette.connectorBranchTeeCo : palette.connectorBranchTee);
514
+ out += renderConnectorTee(
515
+ seenTee ? palette.connectorBranchTeeCo : palette.connectorBranchTee,
516
+ glyphColumn,
517
+ dashColumn,
518
+ colorize,
519
+ style,
520
+ );
188
521
  seenTee = true;
189
522
  break;
190
523
  case 'merge-tee':
191
- out += style.lane(seenTee ? palette.connectorMergeTeeCo : palette.connectorBranchTee);
524
+ out += renderConnectorTee(
525
+ seenTee ? palette.connectorMergeTeeCo : palette.connectorBranchTee,
526
+ glyphColumn,
527
+ dashColumn,
528
+ colorize,
529
+ style,
530
+ );
192
531
  seenTee = true;
193
532
  break;
194
533
  case 'branch-corner':
195
- out += style.lane(palette.branchCorner);
534
+ out += lane(palette.branchCorner);
196
535
  break;
197
536
  case 'merge-corner':
198
- out += style.lane(palette.mergeCorner);
537
+ out += lane(palette.mergeCorner);
199
538
  break;
200
539
  case 'vertical-pass':
201
- out += style.lane(palette.verticalPass);
540
+ out += lane(palette.verticalPass);
202
541
  break;
203
542
  case 'horizontal-pass':
204
- out += style.lane(palette.horizontalPass);
543
+ out += lane(palette.horizontalPass);
544
+ break;
545
+ case 'arc-crossing':
546
+ out += renderArcCrossing(palette.arcCrossing, dashColumn, colorize, style);
205
547
  break;
206
548
  default:
207
549
  out += ' ';
@@ -218,13 +560,15 @@ function renderConnectorRow(
218
560
 
219
561
  const start = row.startLane ?? 0;
220
562
  const end = row.endLane ?? start;
563
+ // The whole fork/merge run reads as one line in the served lane's hue (the
564
+ // corner it reaches); pass-through columns outside the run keep their own.
565
+ const runLane = resolvedLane(end);
221
566
  let out = '';
222
567
  for (let column = 0; column < gridWidth; column++) {
223
568
  if (column < start || column > end) out += ' ';
224
- else if (column === start) out += style.lane(palette.connectorBranchTee);
225
- else if (column === end)
226
- out += style.lane(isMerge ? palette.mergeCorner : palette.branchCorner);
227
- else out += style.lane(isMerge ? palette.connectorMergeTeeCo : palette.connectorBranchTeeCo);
569
+ else if (column === start) out += runLane(palette.connectorBranchTee);
570
+ else if (column === end) out += runLane(isMerge ? palette.mergeCorner : palette.branchCorner);
571
+ else out += runLane(isMerge ? palette.connectorMergeTeeCo : palette.connectorBranchTeeCo);
228
572
  }
229
573
  return out;
230
574
  }
@@ -239,26 +583,45 @@ function abbreviateHash(hash: string, hashLength: number, emptySource: string):
239
583
 
240
584
  const MIN_HASH_DATA_COLUMN = 25;
241
585
 
586
+ interface ContractOverlayNames {
587
+ readonly markers: readonly string[];
588
+ readonly refs: readonly string[];
589
+ }
590
+
242
591
  function overlayNamesForContract(
243
592
  contractHash: string,
244
593
  opts: RenderMigrationGraphTreeOptions,
245
- ): readonly string[] {
246
- const names: string[] = [];
594
+ ): ContractOverlayNames {
595
+ const markers: string[] = [];
596
+ const refs: string[] = [];
247
597
  const userRefs = opts.refsByHash?.get(contractHash);
248
598
  if (userRefs) {
249
- names.push(...[...userRefs].sort((a, b) => a.localeCompare(b)));
599
+ refs.push(...[...userRefs].sort((a, b) => a.localeCompare(b)));
250
600
  }
251
- if (opts.dbHash === contractHash) {
252
- names.push(DB_MARKER_NAME);
601
+ if (
602
+ opts.isAppSpace !== false &&
603
+ opts.contractHash === contractHash &&
604
+ contractHash !== EMPTY_CONTRACT_HASH
605
+ ) {
606
+ markers.push(CONTRACT_MARKER_NAME);
253
607
  }
254
- if (opts.contractHash === contractHash && contractHash !== EMPTY_CONTRACT_HASH) {
255
- names.push(CONTRACT_MARKER_NAME);
608
+ if (opts.dbHash === contractHash) {
609
+ markers.push(DB_MARKER_NAME);
256
610
  }
257
- return names;
611
+ markers.sort((a, b) => {
612
+ if (a === CONTRACT_MARKER_NAME) {
613
+ return -1;
614
+ }
615
+ if (b === CONTRACT_MARKER_NAME) {
616
+ return 1;
617
+ }
618
+ return a.localeCompare(b);
619
+ });
620
+ return { markers, refs };
258
621
  }
259
622
 
260
623
  function createTreeStyler(opts: RenderMigrationGraphTreeOptions): MigrationListStyler {
261
- const base = createAnsiMigrationListStyler({ useColor: opts.colorize });
624
+ const base = opts.styler ?? createAnsiMigrationListStyler({ useColor: opts.colorize });
262
625
  const activeRefName = opts.activeRefName;
263
626
  if (!opts.colorize || activeRefName === undefined) {
264
627
  return base;
@@ -272,23 +635,78 @@ function createTreeStyler(opts: RenderMigrationGraphTreeOptions): MigrationListS
272
635
  };
273
636
  }
274
637
 
638
+ function formatEdgeAnnotationSuffix(
639
+ migrationHash: string,
640
+ opts: RenderMigrationGraphTreeOptions,
641
+ style: MigrationListStyler,
642
+ ): string {
643
+ const annotation = opts.edgeAnnotationsByHash?.get(migrationHash);
644
+ if (annotation === undefined) {
645
+ return '';
646
+ }
647
+ const isOffPath = annotation.pathHighlight === 'off-path';
648
+ const segments: string[] = [];
649
+ if (annotation.operationCount !== undefined) {
650
+ segments.push(`${annotation.operationCount} ops`);
651
+ }
652
+ if (annotation.invariants !== undefined && annotation.invariants.length > 0) {
653
+ segments.push(style.invariants(annotation.invariants));
654
+ }
655
+ const status = annotation.status;
656
+ if (status !== undefined) {
657
+ const glyphs = overlayStatusGlyphs(opts.glyphMode ?? 'unicode');
658
+ const glyph = status === 'applied' ? glyphs.applied : glyphs.pending;
659
+ const label = status === 'applied' ? 'applied' : 'pending';
660
+ if (!opts.colorize) {
661
+ segments.push(`${glyph} ${label}`);
662
+ } else {
663
+ const styler = status === 'applied' ? green : yellow;
664
+ segments.push(styler(`${glyph} ${label}`));
665
+ }
666
+ }
667
+ if (annotation.pathHighlight === 'on-path') {
668
+ const glyph = opts.glyphMode === 'ascii' ? '>' : '↑';
669
+ segments.push(`${glyph} will run`);
670
+ }
671
+ if (segments.length === 0) {
672
+ return '';
673
+ }
674
+ const suffix = ` ${segments.join(' ')}`;
675
+ return opts.colorize && isOffPath ? forcedDim(suffix) : suffix;
676
+ }
677
+
678
+ /**
679
+ * Format the `from → to` hash data column for an edge row.
680
+ *
681
+ * When `hashOverride` is provided (off-path → `dim`), it replaces ALL sub-stylers
682
+ * (`sourceHash`, `destHash`, arrow `glyph`) so dim reaches every character without
683
+ * inner ANSI codes (e.g. the dim+cyan of `sourceHash`) overriding it. On-path edges
684
+ * carry no override. Without an override, the normal `style` sub-stylers apply.
685
+ */
275
686
  function formatEdgeHashColumn(
276
687
  edge: ClassifiedEdge,
277
688
  style: MigrationListStyler,
278
689
  hashLength: number,
279
690
  palette: MigrationGraphTreeGlyphPalette,
691
+ hashOverride?: (text: string) => string,
280
692
  ): string {
693
+ const src = hashOverride ?? style.sourceHash;
694
+ const dst = hashOverride ?? style.destHash;
695
+ const glyph = hashOverride ?? style.glyph;
281
696
  if (edge.kind === 'self') {
282
697
  const hash = abbreviateHash(edge.from, hashLength, palette.emptySource);
283
- return `${style.sourceHash(hash)} ${style.glyph(palette.forwardArrow)} ${style.destHash(hash)}`;
698
+ const source = padFromHashColumn(src(hash), hashLength);
699
+ return `${source} ${glyph(palette.forwardArrow)} ${dst(hash)}`;
284
700
  }
285
701
  const source =
286
702
  edge.from === EMPTY_CONTRACT_HASH
287
- ? style.glyph(palette.emptySource) +
288
- ' '.repeat(Math.max(0, hashLength - palette.emptySource.length))
289
- : style.sourceHash(abbreviateHash(edge.from, hashLength, palette.emptySource));
290
- const arrow = style.glyph(palette.forwardArrow);
291
- const dest = style.destHash(abbreviateHash(edge.to, hashLength, palette.emptySource));
703
+ ? padFromHashColumn(glyph(palette.emptySource), hashLength)
704
+ : padFromHashColumn(
705
+ src(abbreviateHash(edge.from, hashLength, palette.emptySource)),
706
+ hashLength,
707
+ );
708
+ const arrow = glyph(palette.forwardArrow);
709
+ const dest = dst(abbreviateHash(edge.to, hashLength, palette.emptySource));
292
710
  return `${source} ${arrow} ${dest}`;
293
711
  }
294
712
 
@@ -297,6 +715,13 @@ function padVisible(text: string, targetWidth: number): string {
297
715
  return text + ' '.repeat(padding);
298
716
  }
299
717
 
718
+ const ANSI_ESCAPE = '\x1b';
719
+
720
+ function trimTrailingWhitespace(line: string): string {
721
+ const trailingSpaceBeforeReset = new RegExp(`[\\t ]+((?:${ANSI_ESCAPE}\\[[0-9;]*m)+)$`);
722
+ return line.replace(trailingSpaceBeforeReset, '$1').replace(/\s+$/, '');
723
+ }
724
+
300
725
  function gridWidthForModel(rows: readonly MigrationGraphGridRow[]): number {
301
726
  return rows.reduce(
302
727
  (max, row) =>
@@ -336,6 +761,35 @@ function edgeLabelColumn(row: MigrationGraphGridRow, wideLabelColumn: number | u
336
761
  return usesFullRowGutter ? row.cells.length * 2 + LABEL_GAP : (laneIndex + 1) * 2 + LABEL_GAP;
337
762
  }
338
763
 
764
+ function maxEdgeTreePrefixWidth(
765
+ rows: readonly MigrationGraphGridRow[],
766
+ wideLabelColumn: number | undefined,
767
+ ): number {
768
+ let max = 0;
769
+ for (const row of rows) {
770
+ if (row.kind !== 'edge' || row.edge === undefined) continue;
771
+ max = Math.max(max, edgeLabelColumn(row, wideLabelColumn));
772
+ }
773
+ return max;
774
+ }
775
+
776
+ export function computeMaxEdgeTreePrefixWidthForLayout(model: MigrationGraphGridModel): number {
777
+ const wideLabelColumn = gridUsesSkipRollbackArcs(model.rows)
778
+ ? gridWidthForModel(model.rows) * 2 + 4
779
+ : undefined;
780
+ return maxEdgeTreePrefixWidth(model.rows, wideLabelColumn);
781
+ }
782
+
783
+ export function computeMaxDirNameLengthForLayout(model: MigrationGraphGridModel): number {
784
+ const allEdges = model.rows
785
+ .filter(
786
+ (row): row is MigrationGraphGridRow & { edge: ClassifiedEdge } =>
787
+ row.kind === 'edge' && row.edge !== undefined,
788
+ )
789
+ .map((row) => row.edge);
790
+ return maxDirNameLength(allEdges);
791
+ }
792
+
339
793
  function nodeHasArcDecoration(row: MigrationGraphGridRow): boolean {
340
794
  return row.cells.some(
341
795
  (cell) => cell.kind === 'node' && (cell.arcTee === true || cell.arcLand === true),
@@ -360,6 +814,89 @@ export function renderMigrationGraphTree(
360
814
  )
361
815
  .map((row) => row.edge);
362
816
  const maxDirNameLen = maxDirNameLength(allEdges);
817
+ const effectiveMaxDirNameLen = opts.globalMaxDirNameWidth ?? maxDirNameLen;
818
+ const maxEdgePrefixWidth =
819
+ opts.globalMaxEdgeTreePrefixWidth ?? maxEdgeTreePrefixWidth(model.rows, wideLabelColumn);
820
+ const edgeDirNameWidth = rowDirNameWidth(maxEdgePrefixWidth, effectiveMaxDirNameLen, dirNameGap);
821
+
822
+ // Build a contract-hash → path-highlight map so node rows can be coloured correctly.
823
+ // On-path wins: if a contract is both `from` of an on-path edge and `to` of an off-path
824
+ // edge (or vice-versa), it is treated as on-path.
825
+ // This map is only populated when edgeAnnotationsByHash is provided (migrate --show);
826
+ // for every other command (graph/status/list) it is empty and the code below is a no-op.
827
+ // NOTE: this is ONLY used for node-marker (○/∅) classification. Connector rows and
828
+ // structural cells (tees, corners, arcs) use their per-cell migrationHash directly —
829
+ // not this map and not any column-level aggregate.
830
+ const contractHighlights = new Map<string, 'on-path' | 'off-path'>();
831
+ if (opts.edgeAnnotationsByHash) {
832
+ for (const row of model.rows) {
833
+ if (row.kind !== 'edge' || row.edge === undefined) continue;
834
+ const annotation = opts.edgeAnnotationsByHash.get(row.edge.migrationHash);
835
+ if (annotation?.pathHighlight === undefined) continue;
836
+ const highlight = annotation.pathHighlight;
837
+ for (const hash of [row.edge.from, row.edge.to]) {
838
+ if (hash === EMPTY_CONTRACT_HASH) continue;
839
+ const existing = contractHighlights.get(hash);
840
+ // On-path wins over off-path when a contract hash appears in both.
841
+ if (existing !== 'on-path') {
842
+ contractHighlights.set(hash, highlight);
843
+ }
844
+ }
845
+ }
846
+ }
847
+
848
+ // In path-highlight mode (`opts.edgeAnnotationsByHash` present), the by-branch rotating
849
+ // colour logic is suppressed entirely. Every glyph is styled by on-path / off-path role
850
+ // via PATH_HIGHLIGHT_STYLES — never by lane column index. In normal mode (no annotations)
851
+ // `pathHighlightActive` is false and the code below is a complete no-op; rotation applies.
852
+ const pathHighlightActive = opts.edgeAnnotationsByHash !== undefined;
853
+
854
+ /**
855
+ * Resolve the lane and arrow overrides for a row in path-highlight mode.
856
+ * - on-path → neutral single-path style (style.lane for glyphs, plain arrow, bold name).
857
+ * Rotation colour is suppressed; `style.sourceHash`/`style.destHash` apply for hashes.
858
+ * - off-path → uniform dim grey (forcedDim) on every glyph, arrow, name, and hash.
859
+ * - undefined → `undefined` (no override). Unannotated rows use normal rotation. This covers
860
+ * both non-path-highlight commands (graph/status/list) and any annotation without pathHighlight.
861
+ * - When pathHighlightActive is false: always returns undefined, preserving normal rotation.
862
+ */
863
+ function pathStyleForHighlight(highlight: 'on-path' | 'off-path' | undefined):
864
+ | {
865
+ lane: ((text: string) => string) | undefined;
866
+ arrow: ((text: string) => string) | undefined;
867
+ dirName: ((text: string) => string) | undefined;
868
+ hashOverride: ((text: string) => string) | undefined;
869
+ }
870
+ | undefined {
871
+ if (!pathHighlightActive || highlight === undefined) return undefined;
872
+ if (highlight === 'off-path') {
873
+ const s = PATH_HIGHLIGHT_STYLES.offPath(opts.colorize);
874
+ return { lane: s.lane, arrow: s.arrow, dirName: s.dirName, hashOverride: s.hashOverride };
875
+ }
876
+ // on-path → green lane glyphs, bold name, neutral hashes
877
+ const s = PATH_HIGHLIGHT_STYLES.onPath(style, opts.colorize);
878
+ return { lane: s.lane, arrow: s.arrow, dirName: s.dirName, hashOverride: s.hashOverride };
879
+ }
880
+
881
+ /**
882
+ * Lane override for a given highlight in path-highlight mode.
883
+ * Returns the `lane` part only — used for per-cell overrides.
884
+ */
885
+ function pathLaneFor(
886
+ highlight: 'on-path' | 'off-path' | undefined,
887
+ ): ((text: string) => string) | undefined {
888
+ return pathStyleForHighlight(highlight)?.lane;
889
+ }
890
+
891
+ /**
892
+ * Arrow override for a given highlight in path-highlight mode.
893
+ * Returns the `arrow` part only — used for edge-lane cell arrow rendering.
894
+ */
895
+ function pathArrowFor(
896
+ highlight: 'on-path' | 'off-path' | undefined,
897
+ ): ((text: string) => string) | undefined {
898
+ return pathStyleForHighlight(highlight)?.arrow;
899
+ }
363
900
 
364
901
  const lines: string[] = [];
365
902
 
@@ -373,24 +910,165 @@ export function renderMigrationGraphTree(
373
910
  }
374
911
 
375
912
  if (row.kind === 'branch-connector' || row.kind === 'merge-connector') {
376
- lines.push(renderConnectorRow(row, gridWidth, style, palette).replace(/\s+$/, ''));
913
+ // In path-highlight mode, build a per-column lane override from each cell's own
914
+ // migrationHash. Each structural cell (branch-tee, branch-corner, merge-tee,
915
+ // merge-corner, vertical-pass, arc-crossing) carries the migrationHash of the
916
+ // edge it visually belongs to (set by Stage 2). We look up that edge's annotation
917
+ // directly — no column-level aggregate, no "on-path wins" across columns.
918
+ let connectorColumnOverride: Map<number, (text: string) => string> | undefined;
919
+ if (pathHighlightActive && opts.colorize) {
920
+ connectorColumnOverride = new Map();
921
+ for (let col = 0; col < row.cells.length; col++) {
922
+ const cell = row.cells[col];
923
+ if (cell === undefined || cell.kind === 'empty') continue;
924
+ // arc-crossing: colour by the vertical lane's owner (migrationHash), not the arc.
925
+ const hashForCell =
926
+ 'migrationHash' in cell && cell.migrationHash !== undefined
927
+ ? cell.migrationHash
928
+ : undefined;
929
+ if (hashForCell === undefined) continue;
930
+ const highlight = opts.edgeAnnotationsByHash?.get(hashForCell)?.pathHighlight;
931
+ const override = pathLaneFor(highlight);
932
+ if (override !== undefined) {
933
+ connectorColumnOverride.set(col, override);
934
+ }
935
+ }
936
+ if (connectorColumnOverride.size === 0) {
937
+ connectorColumnOverride = undefined;
938
+ }
939
+ }
940
+ lines.push(
941
+ trimTrailingWhitespace(
942
+ renderConnectorRow(
943
+ row,
944
+ gridWidth,
945
+ opts.colorize,
946
+ style,
947
+ palette,
948
+ connectorColumnOverride,
949
+ ),
950
+ ),
951
+ );
377
952
  continue;
378
953
  }
379
954
 
380
- let gutter = row.cells.map((cell) => renderCellPair(cell, style, palette)).join('');
381
- const prevRow = model.rows[rowIndex - 1];
955
+ // Determine the per-row path-highlight style for path-highlight rendering.
956
+ // For edge rows: derived from the edge's annotation.
957
+ // For node rows: derived from the contract hash's membership in on/off-path edges.
958
+ // When pathHighlightActive is false, pathStyleForHighlight returns undefined and
959
+ // the normal rotating-colour lane styler applies everywhere (no-op for non-show commands).
960
+ let rowPathHighlight: 'on-path' | 'off-path' | undefined;
961
+ if (row.kind === 'edge' && row.edge !== undefined) {
962
+ rowPathHighlight = opts.edgeAnnotationsByHash?.get(row.edge.migrationHash)?.pathHighlight;
963
+ } else if (row.kind === 'node' && row.contractHash !== undefined) {
964
+ rowPathHighlight = contractHighlights.get(row.contractHash);
965
+ }
966
+ const rowStyle = pathStyleForHighlight(rowPathHighlight);
967
+ const rowLaneOverride = rowStyle?.lane;
968
+ const rowArrowOverride = rowStyle?.arrow;
969
+
970
+ // Classify every cell by its own edge's annotation (migrationHash → edgeAnnotationsByHash).
971
+ // Each structural cell (vertical-pass, branch-tee, arc-land-corner, etc.) carries the
972
+ // migrationHash of the edge it visually belongs to (set by the layout builder, Stage 2).
973
+ // We read that hash directly — no column-level aggregate, no "on-path wins" across columns.
974
+ //
975
+ // - vertical-pass: classifies by cell.migrationHash (the edge passing through), NOT by column.
976
+ // - edge-lane: classifies by cell.migrationHash (the edge's own row).
977
+ // - branch-tee/corner, merge-tee/corner, arc-*: classifies by cell.migrationHash.
978
+ // - arc-crossing: classifies by cell.migrationHash (the vertical lane's owner), so the
979
+ // crossing reads as the lane passing THROUGH, not the arc skipping over.
980
+ // - node (○/∅): classifies by rowPathHighlight derived from contractHighlights (the
981
+ // node's incident edges); falls through to rowLaneOverride.
982
+ //
983
+ // When pathHighlightActive is false (normal graph/status/list mode), all overrides are
984
+ // undefined and the normal rotating-colour lane styler applies unchanged.
985
+ const cellColors = resolveRowArcLaneColors(row.cells);
986
+ let gutter = row.cells
987
+ .map((cell, column) => {
988
+ let laneOverride = rowLaneOverride;
989
+ let arrowOverride = rowArrowOverride;
990
+ let arcLaneOverride: ((text: string) => string) | undefined;
991
+ if (pathHighlightActive) {
992
+ if (cell.kind === 'edge-lane') {
993
+ // Own cell: colour comes from this cell's own edge annotation.
994
+ const cellHighlight = opts.edgeAnnotationsByHash?.get(
995
+ cell.migrationHash,
996
+ )?.pathHighlight;
997
+ laneOverride = pathLaneFor(cellHighlight);
998
+ arrowOverride = pathArrowFor(cellHighlight);
999
+ } else if (cell.kind === 'node' && (cell.arcTee === true || cell.arcLand === true)) {
1000
+ // Node with arc decoration: the node marker takes the node's own row highlight
1001
+ // (rowLaneOverride), but the arc connector belongs to the back-arc edge which may
1002
+ // have a different annotation. Look up the arc cell's migrationHash to derive the
1003
+ // arc connector's colour independently.
1004
+ const arcColumn = cellColors.connector[column] ?? NEUTRAL_LANE_COLUMN;
1005
+ const arcCell = row.cells[arcColumn];
1006
+ const arcHash =
1007
+ arcCell !== undefined && 'migrationHash' in arcCell
1008
+ ? arcCell.migrationHash
1009
+ : undefined;
1010
+ if (arcHash !== undefined) {
1011
+ const arcHighlight = opts.edgeAnnotationsByHash?.get(arcHash)?.pathHighlight;
1012
+ arcLaneOverride = pathLaneFor(arcHighlight);
1013
+ }
1014
+ // laneOverride stays as rowLaneOverride (the node marker colour)
1015
+ } else if (cell.kind !== 'node' && cell.kind !== 'empty') {
1016
+ // Routing cells (vertical-pass, branch-tee, merge-corner, arc-*, horizontal-pass):
1017
+ // each carries a migrationHash for the edge it belongs to. Classify by that hash.
1018
+ //
1019
+ // arc-crossing in node/edge rows renders as '──' (the arc bridge over the crossing),
1020
+ // not '┼─'. Colour by the arc edge (arcMigrationHash) so an off-path arc bridge is
1021
+ // dim even when the crossed vertical lane (migrationHash) is on-path.
1022
+ // In connector rows, arc-crossing renders '┼─' where the junction belongs to the
1023
+ // vertical lane — handled separately in renderConnectorRow.
1024
+ const hashForCell =
1025
+ cell.kind === 'arc-crossing' &&
1026
+ 'arcMigrationHash' in cell &&
1027
+ cell.arcMigrationHash !== undefined
1028
+ ? cell.arcMigrationHash
1029
+ : 'migrationHash' in cell && cell.migrationHash !== undefined
1030
+ ? cell.migrationHash
1031
+ : undefined;
1032
+ if (hashForCell !== undefined) {
1033
+ const cellHighlight = opts.edgeAnnotationsByHash?.get(hashForCell)?.pathHighlight;
1034
+ laneOverride = pathLaneFor(cellHighlight);
1035
+ arrowOverride = pathArrowFor(cellHighlight);
1036
+ }
1037
+ }
1038
+ // plain node cells (no arcTee/arcLand) fall through to rowLaneOverride
1039
+ }
1040
+ return renderCellPair(
1041
+ cell,
1042
+ column,
1043
+ cellColors,
1044
+ opts.colorize,
1045
+ style,
1046
+ palette,
1047
+ laneOverride,
1048
+ arrowOverride,
1049
+ arcLaneOverride,
1050
+ );
1051
+ })
1052
+ .join('');
382
1053
  let laneSpan = row.cells.length;
383
1054
  if (row.kind === 'node') {
384
1055
  const contractHash = row.contractHash ?? EMPTY_CONTRACT_HASH;
385
- if (prevRow?.kind === 'merge-connector' || contractHash === EMPTY_CONTRACT_HASH) {
1056
+ if (contractHash === EMPTY_CONTRACT_HASH) {
386
1057
  laneSpan = 1;
387
1058
  } else {
388
- laneSpan = row.cells.length;
1059
+ let lastActiveColumn = -1;
1060
+ for (let column = row.cells.length - 1; column >= 0; column--) {
1061
+ if (row.cells[column]?.kind !== 'empty') {
1062
+ lastActiveColumn = column;
1063
+ break;
1064
+ }
1065
+ }
1066
+ laneSpan = lastActiveColumn >= 0 ? lastActiveColumn + 1 : 1;
389
1067
  }
390
1068
  }
391
1069
  const labelColumn =
392
1070
  row.kind === 'edge'
393
- ? edgeLabelColumn(row, wideLabelColumn)
1071
+ ? maxEdgePrefixWidth
394
1072
  : wideLabelColumn !== undefined &&
395
1073
  (nodeHasArcDecoration(row) || row.contractHash !== undefined)
396
1074
  ? wideLabelColumn
@@ -400,49 +1078,130 @@ export function renderMigrationGraphTree(
400
1078
  row.edge?.from === EMPTY_CONTRACT_HASH &&
401
1079
  (row.laneIndex ?? 0) === 0
402
1080
  ) {
1081
+ // Init edge (∅ → first): only the first cell is rendered (the edge-lane cell).
1082
+ // rowLaneOverride is correct here — it comes from the edge's own annotation.
403
1083
  gutter = row.cells
404
1084
  .slice(0, 1)
405
- .map((cell) => renderCellPair(cell, style, palette))
1085
+ .map((cell, column) =>
1086
+ renderCellPair(
1087
+ cell,
1088
+ column,
1089
+ cellColors,
1090
+ opts.colorize,
1091
+ style,
1092
+ palette,
1093
+ rowLaneOverride,
1094
+ rowArrowOverride,
1095
+ ),
1096
+ )
406
1097
  .join('');
407
1098
  } else if (row.kind === 'node' && laneSpan < row.cells.length && !nodeHasArcDecoration(row)) {
1099
+ // Node gutter slice: may contain vertical-pass cells belonging to other edges.
1100
+ // Classify each cell by its own migrationHash so pass-through lanes carry the
1101
+ // correct colour, not the node's highlight.
408
1102
  gutter = row.cells
409
1103
  .slice(0, laneSpan)
410
- .map((cell) => renderCellPair(cell, style, palette))
1104
+ .map((cell, column) => {
1105
+ let cellLaneOverride = rowLaneOverride;
1106
+ let cellArrowOverride = rowArrowOverride;
1107
+ if (pathHighlightActive && cell.kind !== 'node' && cell.kind !== 'empty') {
1108
+ const hashForCell =
1109
+ cell.kind === 'arc-crossing' &&
1110
+ 'arcMigrationHash' in cell &&
1111
+ cell.arcMigrationHash !== undefined
1112
+ ? cell.arcMigrationHash
1113
+ : 'migrationHash' in cell && cell.migrationHash !== undefined
1114
+ ? cell.migrationHash
1115
+ : undefined;
1116
+ if (hashForCell !== undefined) {
1117
+ const cellHighlight = opts.edgeAnnotationsByHash?.get(hashForCell)?.pathHighlight;
1118
+ cellLaneOverride = pathLaneFor(cellHighlight);
1119
+ cellArrowOverride = pathArrowFor(cellHighlight);
1120
+ }
1121
+ }
1122
+ return renderCellPair(
1123
+ cell,
1124
+ column,
1125
+ cellColors,
1126
+ opts.colorize,
1127
+ style,
1128
+ palette,
1129
+ cellLaneOverride,
1130
+ cellArrowOverride,
1131
+ );
1132
+ })
411
1133
  .join('');
412
1134
  } else if (gutter.length < laneSpan * 2) {
413
1135
  gutter = gutter.padEnd(laneSpan * 2, ' ');
414
1136
  }
415
- const dirNameWidth = rowDirNameWidth(labelColumn, maxDirNameLen, dirNameGap);
416
- const dataColumn = labelColumn + dirNameWidth;
1137
+ const dirNameWidth =
1138
+ row.kind === 'edge'
1139
+ ? edgeDirNameWidth
1140
+ : rowDirNameWidth(labelColumn, maxDirNameLen, dirNameGap);
417
1141
  const gutterPad = padVisible(gutter, labelColumn);
418
1142
 
419
1143
  if (row.kind === 'node') {
420
1144
  const contractHash = row.contractHash ?? EMPTY_CONTRACT_HASH;
421
1145
  if (contractHash === EMPTY_CONTRACT_HASH) {
1146
+ // The ∅ node row's trailing cells are vertical-pass lanes belonging to arc edges.
1147
+ // Classify each by its own migrationHash so they carry the correct path-highlight
1148
+ // colour rather than the rotation code that falls out of the ambient lane styler.
422
1149
  const trailingLanes = row.cells
423
1150
  .slice(1)
424
- .map((cell) => renderCellPair(cell, style, palette))
1151
+ .map((cell, offset) => {
1152
+ let cellLaneOverride = rowLaneOverride;
1153
+ let cellArrowOverride = rowArrowOverride;
1154
+ if (pathHighlightActive && cell.kind !== 'node' && cell.kind !== 'empty') {
1155
+ const hashForCell =
1156
+ cell.kind === 'arc-crossing' &&
1157
+ 'arcMigrationHash' in cell &&
1158
+ cell.arcMigrationHash !== undefined
1159
+ ? cell.arcMigrationHash
1160
+ : 'migrationHash' in cell && cell.migrationHash !== undefined
1161
+ ? cell.migrationHash
1162
+ : undefined;
1163
+ if (hashForCell !== undefined) {
1164
+ const cellHighlight = opts.edgeAnnotationsByHash?.get(hashForCell)?.pathHighlight;
1165
+ cellLaneOverride = pathLaneFor(cellHighlight);
1166
+ cellArrowOverride = pathArrowFor(cellHighlight);
1167
+ }
1168
+ }
1169
+ return renderCellPair(
1170
+ cell,
1171
+ offset + 1,
1172
+ cellColors,
1173
+ opts.colorize,
1174
+ style,
1175
+ palette,
1176
+ cellLaneOverride,
1177
+ cellArrowOverride,
1178
+ );
1179
+ })
425
1180
  .join('');
426
1181
  const emptyGutter = palette.emptySource.padEnd(2, ' ') + trailingLanes;
427
- const overlayNames = overlayNamesForContract(contractHash, opts);
428
- if (overlayNames.length === 0) {
429
- lines.push(emptyGutter.replace(/\s+$/, ''));
1182
+ const overlays = overlayNamesForContract(contractHash, opts);
1183
+ if (overlays.markers.length === 0 && overlays.refs.length === 0) {
1184
+ lines.push(trimTrailingWhitespace(emptyGutter));
430
1185
  continue;
431
1186
  }
432
- const overlay = style.refs(overlayNames);
433
- lines.push(`${padVisible(emptyGutter, dataColumn)}${overlay}`.replace(/\s+$/, ''));
1187
+ const overlay = formatContractNodeOverlays(style, overlays.markers, overlays.refs);
1188
+ lines.push(trimTrailingWhitespace(`${emptyGutter}${' '.repeat(LABEL_GAP)}${overlay}`));
434
1189
  continue;
435
1190
  }
436
- const hashText = style.sourceHash(
1191
+ // In path-highlight mode, off-path nodes use `rowStyle.hashOverride` (uniform dim) so
1192
+ // inner ANSI codes (e.g. dim+cyan of `style.sourceHash`) cannot override the outer dim.
1193
+ // On-path nodes use `style.sourceHash` as normal (neutral purple-ish hash colour).
1194
+ const hashTextStyler = rowStyle?.hashOverride ?? style.sourceHash;
1195
+ const hashText = hashTextStyler(
437
1196
  abbreviateHash(contractHash, hashLength, palette.emptySource),
438
1197
  );
439
- const overlayNames = overlayNamesForContract(contractHash, opts);
440
- const overlayPad =
441
- overlayNames.length > 0
442
- ? ' '.repeat(Math.max(0, dataColumn - labelColumn - stringWidth(hashText)))
443
- : '';
444
- const overlay = overlayNames.length > 0 ? style.refs(overlayNames) : '';
445
- lines.push(`${gutterPad}${hashText}${overlayPad}${overlay}`.replace(/\s+$/, ''));
1198
+ const overlays = overlayNamesForContract(contractHash, opts);
1199
+ const hasOverlays = overlays.markers.length > 0 || overlays.refs.length > 0;
1200
+ const overlayPad = hasOverlays ? ' '.repeat(LABEL_GAP) : '';
1201
+ const overlay = hasOverlays
1202
+ ? formatContractNodeOverlays(style, overlays.markers, overlays.refs)
1203
+ : '';
1204
+ lines.push(trimTrailingWhitespace(`${gutterPad}${hashText}${overlayPad}${overlay}`));
446
1205
  continue;
447
1206
  }
448
1207
 
@@ -450,10 +1209,129 @@ export function renderMigrationGraphTree(
450
1209
  if (edge === undefined) continue;
451
1210
 
452
1211
  const dirNamePadding = ' '.repeat(Math.max(0, dirNameWidth - edge.dirName.length));
453
- const dirName = `${style.dirName(edge.dirName)}${dirNamePadding}`;
454
- const hashColumn = formatEdgeHashColumn(edge, style, hashLength, palette);
455
- lines.push(`${gutterPad}${dirName}${hashColumn}`.replace(/\s+$/, ''));
1212
+ const laneIndex = row.laneIndex ?? 0;
1213
+
1214
+ // The gutter is already coloured via the per-cell overrides threaded into renderCellPair.
1215
+ const edgeGutterPad = padVisible(gutter, labelColumn);
1216
+
1217
+ let dirName: string;
1218
+ if (rowStyle !== undefined) {
1219
+ // Path-highlight mode (on-path or off-path annotation present):
1220
+ // `rowStyle.dirName` is set by PATH_HIGHLIGHT_STYLES — bold for on-path, forcedDim for off-path.
1221
+ // Rotation is suppressed entirely for both roles.
1222
+ // When rowStyle is undefined (unannotated row or non-show command), this branch is not entered.
1223
+ const dirNameStyler = rowStyle.dirName ?? style.dirName;
1224
+ dirName = `${dirNameStyler(edge.dirName)}${dirNamePadding}`;
1225
+ } else {
1226
+ // Normal mode: lane hue for branched lanes (column ≥ 1), bold-only for column 0.
1227
+ const dirNameStyler =
1228
+ opts.colorize && laneIndex > NEUTRAL_LANE_COLUMN
1229
+ ? (text: string) => forcedBold(laneColorForColumn(laneIndex)(text))
1230
+ : style.dirName;
1231
+ dirName = `${dirNameStyler(edge.dirName)}${dirNamePadding}`;
1232
+ }
1233
+
1234
+ // Pass hashOverride from path-highlight styles so formatEdgeHashColumn applies it to ALL
1235
+ // sub-stylers (sourceHash, destHash, arrow glyph). Wrapping already-styled text in an outer
1236
+ // colour does not work — inner ANSI codes override the outer at the terminal level.
1237
+ const hashColumnOverride = rowStyle?.hashOverride;
1238
+ const hashColumn = formatEdgeHashColumn(edge, style, hashLength, palette, hashColumnOverride);
1239
+ const annotationSuffix = formatEdgeAnnotationSuffix(edge.migrationHash, opts, style);
1240
+ lines.push(
1241
+ trimTrailingWhitespace(`${edgeGutterPad}${dirName}${hashColumn}${annotationSuffix}`),
1242
+ );
1243
+ }
1244
+
1245
+ return lines.join('\n');
1246
+ }
1247
+
1248
+ /**
1249
+ * Format a single on-path migration row for the `migrate --show` run-list.
1250
+ *
1251
+ * Uses the SAME styling as the tree renderer's on-path rows (PATH_HIGHLIGHT_STYLES.onPath)
1252
+ * so the run-list and graph tree are byte-for-byte identical in their name/hash columns.
1253
+ * The gutter is omitted — the list has no graph structure.
1254
+ *
1255
+ * This is the SINGLE code path for on-path row styling shared by both the graph tree
1256
+ * and the "Will run, in order:" list. To change the on-path colour, edit PATH_HIGHLIGHT_STYLES.
1257
+ */
1258
+ export function formatOnPathMigrationRow(
1259
+ dirName: string,
1260
+ from: string,
1261
+ to: string,
1262
+ dirNameWidth: number,
1263
+ colorize: boolean,
1264
+ glyphMode: GlyphMode,
1265
+ ): string {
1266
+ const palette = paletteFor(glyphMode);
1267
+ const style = createAnsiMigrationListStyler({ useColor: colorize });
1268
+ // Use PATH_HIGHLIGHT_STYLES.onPath as the single seam for on-path colour.
1269
+ // Pass `style` and `colorize` so the lane/glyph stylers respect the colour gate.
1270
+ const s = PATH_HIGHLIGHT_STYLES.onPath(style, colorize);
1271
+ const styledDirName = `${s.dirName(dirName)}${' '.repeat(Math.max(0, dirNameWidth - dirName.length))}`;
1272
+ const hashLength = MIGRATION_LIST_HASH_WIDTH;
1273
+ const emptySource = palette.emptySource;
1274
+ const fromAbbr =
1275
+ from === EMPTY_CONTRACT_HASH
1276
+ ? padFromHashColumn(style.glyph(emptySource), hashLength)
1277
+ : padFromHashColumn(style.sourceHash(abbreviateHashShort(from, hashLength)), hashLength);
1278
+ const toAbbr =
1279
+ to === EMPTY_CONTRACT_HASH
1280
+ ? style.glyph(emptySource)
1281
+ : style.destHash(abbreviateHashShort(to, hashLength));
1282
+ const arrow = style.glyph(palette.forwardArrow);
1283
+ return `${styledDirName} ${fromAbbr} ${arrow} ${toAbbr}`;
1284
+ }
1285
+
1286
+ function abbreviateHashShort(hash: string, length: number): string {
1287
+ const stripped = hash.startsWith('sha256:') ? hash.slice(7) : hash;
1288
+ return stripped.slice(0, length);
1289
+ }
1290
+
1291
+ export interface RenderMigrationGraphLegendOptions {
1292
+ readonly colorize: boolean;
1293
+ readonly glyphMode?: GlyphMode;
1294
+ }
1295
+
1296
+ function formatLegendExampleMarkers(colorize: boolean): string {
1297
+ if (!colorize) {
1298
+ return '@contract @db';
456
1299
  }
1300
+ const sigil = green('@');
1301
+ return `${sigil + bold(green('contract'))} ${sigil}${green('db')}`;
1302
+ }
457
1303
 
1304
+ /**
1305
+ * A compact key for the tree visual language: the contract node glyph, the
1306
+ * in-lane direction arrows, the empty baseline, the system-marker `<…>` and
1307
+ * user-ref `(…)` bracket conventions (two illustrative example lines), and a
1308
+ * worked sample of the data-column `from → to` migration hash arrow.
1309
+ *
1310
+ * Honors the same glyph palette (unicode vs ASCII) and `colorize` gate as the
1311
+ * tree renderer, so the key matches whatever the graph itself drew and stays
1312
+ * pipe-safe (zero ANSI when color is off). The caller adds the trailing blank
1313
+ * line that separates this stderr key from the tree on stdout.
1314
+ */
1315
+ export function renderMigrationGraphLegend(opts: RenderMigrationGraphLegendOptions): string {
1316
+ const palette = paletteFor(opts.glyphMode ?? 'unicode');
1317
+ const style = createAnsiMigrationListStyler({ useColor: opts.colorize });
1318
+ const node = palette.node.trimEnd();
1319
+ const sampleArrow = `${style.sourceHash('aaaaaa')} ${style.glyph(palette.forwardArrow)} ${style.destHash('bbbbbb')}`;
1320
+ const statusGlyphs = overlayStatusGlyphs(opts.glyphMode ?? 'unicode');
1321
+ const appliedPending = opts.colorize
1322
+ ? ` ${green(statusGlyphs.applied)} ${style.summary('applied')} ${yellow(statusGlyphs.pending)} ${style.summary('pending')}`
1323
+ : ` ${statusGlyphs.applied} ${style.summary('applied')} ${statusGlyphs.pending} ${style.summary('pending')}`;
1324
+ const exampleMarkers = formatLegendExampleMarkers(opts.colorize);
1325
+ const exampleRefs = opts.colorize ? style.refs(['prod', 'staging']) : '(prod, staging)';
1326
+ const lines = [
1327
+ 'Legend:',
1328
+ ` ${style.kind(node)} ${style.summary('contract')} ${style.kind(palette.edgeArrow.forward)} ${style.summary('forward')} ${style.kind(palette.edgeArrow.rollback)} ${style.summary('rollback')}`,
1329
+ ` ${style.kind(palette.edgeArrow.self)} ${style.summary('migration without schema change')}`,
1330
+ appliedPending,
1331
+ ` ${style.kind(palette.emptySource)} ${style.summary('empty database (baseline)')}`,
1332
+ ` ${exampleMarkers} ${style.summary('reserved markers — also typeable as --from/--to tokens')}`,
1333
+ ` ${exampleRefs} ${style.summary('user-defined refs')}`,
1334
+ ` ${sampleArrow} ${style.summary('migration from contract aaaaaa to bbbbbb')}`,
1335
+ ];
458
1336
  return lines.join('\n');
459
1337
  }