@prisma-next/cli 0.12.0-dev.51 → 0.12.0-dev.53

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 (65) hide show
  1. package/dist/cli.mjs +7 -7
  2. package/dist/{client-DC-UlBLy.mjs → client-DIcitJdy.mjs} +113 -49
  3. package/dist/client-DIcitJdy.mjs.map +1 -0
  4. package/dist/commands/contract-infer.mjs +1 -1
  5. package/dist/commands/db-init.mjs +2 -2
  6. package/dist/commands/db-schema.mjs +1 -1
  7. package/dist/commands/db-sign.mjs +1 -1
  8. package/dist/commands/db-update.mjs +2 -2
  9. package/dist/commands/db-verify.mjs +1 -1
  10. package/dist/commands/migrate.d.mts +35 -1
  11. package/dist/commands/migrate.d.mts.map +1 -1
  12. package/dist/commands/migrate.mjs +287 -6
  13. package/dist/commands/migrate.mjs.map +1 -1
  14. package/dist/commands/migration-check.mjs +2 -2
  15. package/dist/commands/migration-graph.d.mts.map +1 -1
  16. package/dist/commands/migration-graph.mjs +4 -2
  17. package/dist/commands/migration-graph.mjs.map +1 -1
  18. package/dist/commands/migration-list.d.mts +1 -0
  19. package/dist/commands/migration-list.d.mts.map +1 -1
  20. package/dist/commands/migration-list.mjs +1 -1
  21. package/dist/commands/migration-log.mjs +1 -1
  22. package/dist/commands/migration-show.mjs +2 -2
  23. package/dist/commands/migration-status.d.mts.map +1 -1
  24. package/dist/commands/migration-status.mjs +2 -2
  25. package/dist/{contract-infer-DpGN9SAj.mjs → contract-infer-BAdhYGQH.mjs} +2 -2
  26. package/dist/{contract-infer-DpGN9SAj.mjs.map → contract-infer-BAdhYGQH.mjs.map} +1 -1
  27. package/dist/{db-verify-Cq16Obsw.mjs → db-verify-CiUCDXnv.mjs} +2 -2
  28. package/dist/{db-verify-Cq16Obsw.mjs.map → db-verify-CiUCDXnv.mjs.map} +1 -1
  29. package/dist/exports/control-api.mjs +1 -1
  30. package/dist/{inspect-live-schema-CRDKTNcf.mjs → inspect-live-schema-DegaqKFT.mjs} +2 -2
  31. package/dist/{inspect-live-schema-CRDKTNcf.mjs.map → inspect-live-schema-DegaqKFT.mjs.map} +1 -1
  32. package/dist/{migration-check-BxWlQBOs.mjs → migration-check-B2ccCHe7.mjs} +3 -3
  33. package/dist/{migration-check-BxWlQBOs.mjs.map → migration-check-B2ccCHe7.mjs.map} +1 -1
  34. package/dist/{migration-command-scaffold-BDd9abqW.mjs → migration-command-scaffold-D6UeN71F.mjs} +2 -2
  35. package/dist/{migration-command-scaffold-BDd9abqW.mjs.map → migration-command-scaffold-D6UeN71F.mjs.map} +1 -1
  36. package/dist/{migration-graph-space-render-CeNXh_Wy.mjs → migration-graph-space-render-B0HkTNj3.mjs} +488 -84
  37. package/dist/migration-graph-space-render-B0HkTNj3.mjs.map +1 -0
  38. package/dist/{migration-list-vJWFuXca.mjs → migration-list-mYmj2j33.mjs} +6 -4
  39. package/dist/migration-list-mYmj2j33.mjs.map +1 -0
  40. package/dist/{migration-log-6rcHQSI4.mjs → migration-log-Dzs18GU7.mjs} +3 -3
  41. package/dist/{migration-log-6rcHQSI4.mjs.map → migration-log-Dzs18GU7.mjs.map} +1 -1
  42. package/dist/{migration-path-target-UkxkgXnv.mjs → migration-path-target-DK-B7POa.mjs} +1 -1
  43. package/dist/{migration-path-target-UkxkgXnv.mjs.map → migration-path-target-DK-B7POa.mjs.map} +1 -1
  44. package/dist/{migration-status-Bjv91dE7.mjs → migration-status-BT9eCQsf.mjs} +8 -5
  45. package/dist/migration-status-BT9eCQsf.mjs.map +1 -0
  46. package/dist/{schemas-DJY2O09F.mjs → schemas-B4xeMrNt.mjs} +1 -1
  47. package/dist/{schemas-DJY2O09F.mjs.map → schemas-B4xeMrNt.mjs.map} +1 -1
  48. package/dist/types-qV41eEXH.d.mts.map +1 -1
  49. package/package.json +18 -18
  50. package/src/commands/migrate.ts +512 -2
  51. package/src/commands/migration-graph.ts +2 -0
  52. package/src/commands/migration-list.ts +3 -0
  53. package/src/commands/migration-status.ts +4 -0
  54. package/src/control-api/operations/db-run.ts +14 -3
  55. package/src/control-api/operations/db-verify.ts +15 -5
  56. package/src/control-api/operations/migrate.ts +149 -56
  57. package/src/utils/formatters/migration-graph-layout.ts +187 -42
  58. package/src/utils/formatters/migration-graph-space-render.ts +11 -1
  59. package/src/utils/formatters/migration-graph-tree-render.ts +609 -59
  60. package/src/utils/formatters/migration-list-render.ts +12 -0
  61. package/src/utils/formatters/migration-list-styler.ts +5 -7
  62. package/dist/client-DC-UlBLy.mjs.map +0 -1
  63. package/dist/migration-graph-space-render-CeNXh_Wy.mjs.map +0 -1
  64. package/dist/migration-list-vJWFuXca.mjs.map +0 -1
  65. package/dist/migration-status-Bjv91dE7.mjs.map +0 -1
@@ -46,6 +46,12 @@ export interface MigrationEdgeAnnotation {
46
46
  readonly status?: 'applied' | 'pending';
47
47
  readonly operationCount?: number;
48
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';
49
55
  }
50
56
 
51
57
  export interface RenderMigrationGraphTreeOptions {
@@ -53,6 +59,13 @@ export interface RenderMigrationGraphTreeOptions {
53
59
  readonly edgeAnnotationsByHash?: ReadonlyMap<string, MigrationEdgeAnnotation>;
54
60
  readonly dbHash?: string;
55
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;
56
69
  readonly activeRefName?: string;
57
70
  readonly hashLength?: number;
58
71
  readonly globalMaxEdgeTreePrefixWidth?: number;
@@ -153,12 +166,76 @@ function arrowForEdgeKind(
153
166
  }
154
167
 
155
168
  /**
156
- * Forced bold for branch-coloured names. A branched name pairs its lane hue
157
- * (also forced, via {@link laneColorForColumn}) with bold; both must emit even
158
- * when colorette's ambient TTY detection is off, so the colorized branch name
159
- * is deterministically bold + hue rather than hue-only.
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.
160
180
  */
161
- const { bold: forcedBold } = createColors({ useColor: true });
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;
162
239
 
163
240
  function laneStylerForColumn(
164
241
  colorColumn: number,
@@ -226,6 +303,11 @@ function renderConnectorTee(
226
303
  * marker takes its own lane's hue (so each node visibly belongs to its branch);
227
304
  * the connector follows the arc it belongs to (its owning back-lane hue).
228
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.
229
311
  */
230
312
  function renderNodeMarkerPair(
231
313
  pair: string,
@@ -233,9 +315,12 @@ function renderNodeMarkerPair(
233
315
  arcColumn: number,
234
316
  colorize: boolean,
235
317
  style: MigrationListStyler,
318
+ laneOverride?: (text: string) => string,
319
+ arcLaneOverride?: (text: string) => string,
236
320
  ): string {
237
- const marker = laneStylerForColumn(nodeColumn, colorize, style);
238
- const connector = laneStylerForColumn(arcColumn, colorize, style);
321
+ const marker = laneOverride ?? laneStylerForColumn(nodeColumn, colorize, style);
322
+ const connector =
323
+ arcLaneOverride ?? laneOverride ?? laneStylerForColumn(arcColumn, colorize, style);
239
324
  return marker(pair.slice(0, 1)) + connector(pair.slice(1));
240
325
  }
241
326
 
@@ -246,17 +331,44 @@ function renderCellPair(
246
331
  colorize: boolean,
247
332
  style: MigrationListStyler,
248
333
  palette: MigrationGraphTreeGlyphPalette,
334
+ laneOverride?: (text: string) => string,
335
+ arrowOverride?: (text: string) => string,
336
+ arcLaneOverride?: (text: string) => string,
249
337
  ): string {
250
338
  const laneColumn = colors.lane[column] ?? column;
251
- const lane = laneStylerForColumn(laneColumn, colorize, style);
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));
252
348
  switch (cell.kind) {
253
349
  case 'node': {
254
350
  const arcColumn = colors.connector[column] ?? NEUTRAL_LANE_COLUMN;
255
351
  if (cell.arcLand === true) {
256
- return renderNodeMarkerPair(palette.arcLand, column, arcColumn, colorize, style);
352
+ return renderNodeMarkerPair(
353
+ palette.arcLand,
354
+ column,
355
+ arcColumn,
356
+ colorize,
357
+ style,
358
+ laneOverride,
359
+ arcLaneOverride,
360
+ );
257
361
  }
258
362
  if (cell.arcTee === true) {
259
- return renderNodeMarkerPair(palette.arcTee, column, arcColumn, colorize, style);
363
+ return renderNodeMarkerPair(
364
+ palette.arcTee,
365
+ column,
366
+ arcColumn,
367
+ colorize,
368
+ style,
369
+ laneOverride,
370
+ arcLaneOverride,
371
+ );
260
372
  }
261
373
  return lane(palette.node);
262
374
  }
@@ -264,12 +376,7 @@ function renderCellPair(
264
376
  return lane(palette.verticalPass);
265
377
  case 'edge-lane':
266
378
  return cell.ownsLabel
267
- ? lane(palette.verticalPass.trimEnd()) +
268
- branchStylerOrDefault(
269
- column,
270
- colorize,
271
- style.kind,
272
- )(arrowForEdgeKind(cell.edgeKind, palette))
379
+ ? lane(palette.verticalPass.trimEnd()) + arrow(arrowForEdgeKind(cell.edgeKind, palette))
273
380
  : lane(palette.verticalPass);
274
381
  case 'branch-tee':
275
382
  return lane(palette.branchTee);
@@ -286,13 +393,17 @@ function renderCellPair(
286
393
  case 'arc-land-corner':
287
394
  return lane(palette.arcLandCorner);
288
395
  case 'arc-land-tee':
289
- return renderConnectorTee(
290
- palette.arcLandTee,
291
- laneColumn,
292
- colors.dash[column] ?? laneColumn,
293
- colorize,
294
- style,
295
- );
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
+ );
296
407
  case 'arc-crossing':
297
408
  return lane(palette.arcLandBridge);
298
409
  case 'arc-land-bridge':
@@ -304,13 +415,28 @@ function renderCellPair(
304
415
  }
305
416
  }
306
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
+ */
307
429
  function renderConnectorRow(
308
430
  row: MigrationGraphGridRow,
309
431
  gridWidth: number,
310
432
  colorize: boolean,
311
433
  style: MigrationListStyler,
312
434
  palette: MigrationGraphTreeGlyphPalette,
435
+ columnLaneOverride?: ReadonlyMap<number, (text: string) => string>,
313
436
  ): string {
437
+ const resolvedLane = (column: number): ((text: string) => string) =>
438
+ columnLaneOverride?.get(column) ?? laneStylerForColumn(column, colorize, style);
439
+
314
440
  const isMerge = row.kind === 'merge-connector';
315
441
  if (row.cells.length > 0) {
316
442
  const colors = resolveConnectorLaneColors(row.cells, row.startLane ?? 0);
@@ -321,6 +447,67 @@ function renderConnectorRow(
321
447
  if (cell === undefined) continue;
322
448
  const glyphColumn = colors.glyph[column] ?? column;
323
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
+ }
324
511
  const lane = laneStylerForColumn(glyphColumn, colorize, style);
325
512
  switch (cell.kind) {
326
513
  case 'branch-tee':
@@ -375,7 +562,7 @@ function renderConnectorRow(
375
562
  const end = row.endLane ?? start;
376
563
  // The whole fork/merge run reads as one line in the served lane's hue (the
377
564
  // corner it reaches); pass-through columns outside the run keep their own.
378
- const runLane = laneStylerForColumn(end, colorize, style);
565
+ const runLane = resolvedLane(end);
379
566
  let out = '';
380
567
  for (let column = 0; column < gridWidth; column++) {
381
568
  if (column < start || column > end) out += ' ';
@@ -411,7 +598,11 @@ function overlayNamesForContract(
411
598
  if (userRefs) {
412
599
  refs.push(...[...userRefs].sort((a, b) => a.localeCompare(b)));
413
600
  }
414
- if (opts.contractHash === contractHash && contractHash !== EMPTY_CONTRACT_HASH) {
601
+ if (
602
+ opts.isAppSpace !== false &&
603
+ opts.contractHash === contractHash &&
604
+ contractHash !== EMPTY_CONTRACT_HASH
605
+ ) {
415
606
  markers.push(CONTRACT_MARKER_NAME);
416
607
  }
417
608
  if (opts.dbHash === contractHash) {
@@ -453,6 +644,7 @@ function formatEdgeAnnotationSuffix(
453
644
  if (annotation === undefined) {
454
645
  return '';
455
646
  }
647
+ const isOffPath = annotation.pathHighlight === 'off-path';
456
648
  const segments: string[] = [];
457
649
  if (annotation.operationCount !== undefined) {
458
650
  segments.push(`${annotation.operationCount} ops`);
@@ -472,32 +664,49 @@ function formatEdgeAnnotationSuffix(
472
664
  segments.push(styler(`${glyph} ${label}`));
473
665
  }
474
666
  }
667
+ if (annotation.pathHighlight === 'on-path') {
668
+ const glyph = opts.glyphMode === 'ascii' ? '>' : '↑';
669
+ segments.push(`${glyph} will run`);
670
+ }
475
671
  if (segments.length === 0) {
476
672
  return '';
477
673
  }
478
- return ` ${segments.join(' ')}`;
674
+ const suffix = ` ${segments.join(' ')}`;
675
+ return opts.colorize && isOffPath ? forcedDim(suffix) : suffix;
479
676
  }
480
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
+ */
481
686
  function formatEdgeHashColumn(
482
687
  edge: ClassifiedEdge,
483
688
  style: MigrationListStyler,
484
689
  hashLength: number,
485
690
  palette: MigrationGraphTreeGlyphPalette,
691
+ hashOverride?: (text: string) => string,
486
692
  ): string {
693
+ const src = hashOverride ?? style.sourceHash;
694
+ const dst = hashOverride ?? style.destHash;
695
+ const glyph = hashOverride ?? style.glyph;
487
696
  if (edge.kind === 'self') {
488
697
  const hash = abbreviateHash(edge.from, hashLength, palette.emptySource);
489
- const source = padFromHashColumn(style.sourceHash(hash), hashLength);
490
- return `${source} ${style.glyph(palette.forwardArrow)} ${style.destHash(hash)}`;
698
+ const source = padFromHashColumn(src(hash), hashLength);
699
+ return `${source} ${glyph(palette.forwardArrow)} ${dst(hash)}`;
491
700
  }
492
701
  const source =
493
702
  edge.from === EMPTY_CONTRACT_HASH
494
- ? padFromHashColumn(style.glyph(palette.emptySource), hashLength)
703
+ ? padFromHashColumn(glyph(palette.emptySource), hashLength)
495
704
  : padFromHashColumn(
496
- style.sourceHash(abbreviateHash(edge.from, hashLength, palette.emptySource)),
705
+ src(abbreviateHash(edge.from, hashLength, palette.emptySource)),
497
706
  hashLength,
498
707
  );
499
- const arrow = style.glyph(palette.forwardArrow);
500
- const dest = style.destHash(abbreviateHash(edge.to, hashLength, palette.emptySource));
708
+ const arrow = glyph(palette.forwardArrow);
709
+ const dest = dst(abbreviateHash(edge.to, hashLength, palette.emptySource));
501
710
  return `${source} ${arrow} ${dest}`;
502
711
  }
503
712
 
@@ -610,6 +819,85 @@ export function renderMigrationGraphTree(
610
819
  opts.globalMaxEdgeTreePrefixWidth ?? maxEdgeTreePrefixWidth(model.rows, wideLabelColumn);
611
820
  const edgeDirNameWidth = rowDirNameWidth(maxEdgePrefixWidth, effectiveMaxDirNameLen, dirNameGap);
612
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
+ }
900
+
613
901
  const lines: string[] = [];
614
902
 
615
903
  for (let rowIndex = 0; rowIndex < model.rows.length; rowIndex++) {
@@ -622,17 +910,145 @@ export function renderMigrationGraphTree(
622
910
  }
623
911
 
624
912
  if (row.kind === 'branch-connector' || row.kind === 'merge-connector') {
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
+ }
625
940
  lines.push(
626
- trimTrailingWhitespace(renderConnectorRow(row, gridWidth, opts.colorize, style, palette)),
941
+ trimTrailingWhitespace(
942
+ renderConnectorRow(
943
+ row,
944
+ gridWidth,
945
+ opts.colorize,
946
+ style,
947
+ palette,
948
+ connectorColumnOverride,
949
+ ),
950
+ ),
627
951
  );
628
952
  continue;
629
953
  }
630
954
 
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.
631
985
  const cellColors = resolveRowArcLaneColors(row.cells);
632
986
  let gutter = row.cells
633
- .map((cell, column) =>
634
- renderCellPair(cell, column, cellColors, opts.colorize, style, palette),
635
- )
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
+ })
636
1052
  .join('');
637
1053
  let laneSpan = row.cells.length;
638
1054
  if (row.kind === 'node') {
@@ -662,18 +1078,58 @@ export function renderMigrationGraphTree(
662
1078
  row.edge?.from === EMPTY_CONTRACT_HASH &&
663
1079
  (row.laneIndex ?? 0) === 0
664
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.
665
1083
  gutter = row.cells
666
1084
  .slice(0, 1)
667
1085
  .map((cell, column) =>
668
- renderCellPair(cell, column, cellColors, opts.colorize, style, palette),
1086
+ renderCellPair(
1087
+ cell,
1088
+ column,
1089
+ cellColors,
1090
+ opts.colorize,
1091
+ style,
1092
+ palette,
1093
+ rowLaneOverride,
1094
+ rowArrowOverride,
1095
+ ),
669
1096
  )
670
1097
  .join('');
671
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.
672
1102
  gutter = row.cells
673
1103
  .slice(0, laneSpan)
674
- .map((cell, column) =>
675
- renderCellPair(cell, column, cellColors, opts.colorize, style, palette),
676
- )
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
+ })
677
1133
  .join('');
678
1134
  } else if (gutter.length < laneSpan * 2) {
679
1135
  gutter = gutter.padEnd(laneSpan * 2, ' ');
@@ -687,11 +1143,40 @@ export function renderMigrationGraphTree(
687
1143
  if (row.kind === 'node') {
688
1144
  const contractHash = row.contractHash ?? EMPTY_CONTRACT_HASH;
689
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.
690
1149
  const trailingLanes = row.cells
691
1150
  .slice(1)
692
- .map((cell, offset) =>
693
- renderCellPair(cell, offset + 1, cellColors, opts.colorize, style, palette),
694
- )
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
+ })
695
1180
  .join('');
696
1181
  const emptyGutter = palette.emptySource.padEnd(2, ' ') + trailingLanes;
697
1182
  const overlays = overlayNamesForContract(contractHash, opts);
@@ -703,7 +1188,11 @@ export function renderMigrationGraphTree(
703
1188
  lines.push(trimTrailingWhitespace(`${emptyGutter}${' '.repeat(LABEL_GAP)}${overlay}`));
704
1189
  continue;
705
1190
  }
706
- 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(
707
1196
  abbreviateHash(contractHash, hashLength, palette.emptySource),
708
1197
  );
709
1198
  const overlays = overlayNamesForContract(contractHash, opts);
@@ -721,21 +1210,84 @@ export function renderMigrationGraphTree(
721
1210
 
722
1211
  const dirNamePadding = ' '.repeat(Math.max(0, dirNameWidth - edge.dirName.length));
723
1212
  const laneIndex = row.laneIndex ?? 0;
724
- // A branched name keeps its bold (via `style.dirName`) and adds the lane
725
- // hue, so it reads as one with its lane/arrow; column-0 names stay bold-only.
726
- const dirNameStyler =
727
- opts.colorize && laneIndex > NEUTRAL_LANE_COLUMN
728
- ? (text: string) => forcedBold(laneColorForColumn(laneIndex)(text))
729
- : style.dirName;
730
- const dirName = `${dirNameStyler(edge.dirName)}${dirNamePadding}`;
731
- const hashColumn = formatEdgeHashColumn(edge, style, hashLength, palette);
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);
732
1239
  const annotationSuffix = formatEdgeAnnotationSuffix(edge.migrationHash, opts, style);
733
- lines.push(trimTrailingWhitespace(`${gutterPad}${dirName}${hashColumn}${annotationSuffix}`));
1240
+ lines.push(
1241
+ trimTrailingWhitespace(`${edgeGutterPad}${dirName}${hashColumn}${annotationSuffix}`),
1242
+ );
734
1243
  }
735
1244
 
736
1245
  return lines.join('\n');
737
1246
  }
738
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
+
739
1291
  export interface RenderMigrationGraphLegendOptions {
740
1292
  readonly colorize: boolean;
741
1293
  readonly glyphMode?: GlyphMode;
@@ -743,12 +1295,10 @@ export interface RenderMigrationGraphLegendOptions {
743
1295
 
744
1296
  function formatLegendExampleMarkers(colorize: boolean): string {
745
1297
  if (!colorize) {
746
- return '<contract, db>';
1298
+ return '@contract @db';
747
1299
  }
748
- const open = green('<');
749
- const close = green('>');
750
- const separator = green(', ');
751
- return open + green('contract') + separator + green('db') + close;
1300
+ const sigil = green('@');
1301
+ return `${sigil + bold(green('contract'))} ${sigil}${green('db')}`;
752
1302
  }
753
1303
 
754
1304
  /**
@@ -779,7 +1329,7 @@ export function renderMigrationGraphLegend(opts: RenderMigrationGraphLegendOptio
779
1329
  ` ${style.kind(palette.edgeArrow.self)} ${style.summary('migration without schema change')}`,
780
1330
  appliedPending,
781
1331
  ` ${style.kind(palette.emptySource)} ${style.summary('empty database (baseline)')}`,
782
- ` ${exampleMarkers} ${style.summary('live markers (contract on disk, database state)')}`,
1332
+ ` ${exampleMarkers} ${style.summary('reserved markers also typeable as --from/--to tokens')}`,
783
1333
  ` ${exampleRefs} ${style.summary('user-defined refs')}`,
784
1334
  ` ${sampleArrow} ${style.summary('migration from contract aaaaaa to bbbbbb')}`,
785
1335
  ];