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

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.
@@ -1,7 +1,8 @@
1
1
  import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
2
- import { bold } from 'colorette';
2
+ import { bold, createColors } from 'colorette';
3
3
  import stringWidth from 'string-width';
4
4
  import type { GlyphMode } from '../glyph-mode';
5
+ import { laneColorForColumn } from './migration-graph-lane-colors';
5
6
  import type {
6
7
  MigrationGraphGridModel,
7
8
  MigrationGraphGridRow,
@@ -116,56 +117,276 @@ function arrowForEdgeKind(
116
117
  return palette.edgeArrow[kind];
117
118
  }
118
119
 
120
+ /**
121
+ * The leftmost lane (column 0) renders with the neutral dim lane style rather
122
+ * than a palette hue — in the common single-lane case it has nothing to be told
123
+ * apart from. Used as the "no owning arc" sentinel during colour resolution.
124
+ */
125
+ const NEUTRAL_LANE = 0;
126
+
127
+ /**
128
+ * Forced bold for branch-coloured names. A branched name pairs its lane hue
129
+ * (also forced, via {@link laneColorForColumn}) with bold; both must emit even
130
+ * when colorette's ambient TTY detection is off, so the colorized branch name
131
+ * is deterministically bold + hue rather than hue-only.
132
+ */
133
+ const { bold: forcedBold } = createColors({ useColor: true });
134
+
135
+ /**
136
+ * The colour-source column for each cell of a row, resolved together because a
137
+ * routed back-arc spans columns and must read as **one hue** rather than a
138
+ * per-column "rainbow". An arc's horizontal bridges, corners, and node-pair
139
+ * connector all take the arc's owning back-lane column (the corner that closes
140
+ * the arc), not the column they pass through.
141
+ */
142
+ interface RowLaneColors {
143
+ /** Colour column for a cell's structural glyph (lane / spine / arc body). */
144
+ readonly lane: readonly number[];
145
+ /** Colour column for a node arc-pair's connector half (`◂` / `─`). */
146
+ readonly connector: readonly number[];
147
+ }
148
+
149
+ /**
150
+ * Resolve per-cell colour columns for a row. Scanning right-to-left lets each
151
+ * arc bridge inherit the corner column that closes it (the arc's back-lane), so
152
+ * the whole arc — vertical run (already its own column), horizontal bridges,
153
+ * corners, crossings, and the `◂`/`─` connector — reads as a single continuous
154
+ * hue. A crossing can only be one colour, so rather than leave it dim (wrong for
155
+ * both crossing lines) it takes the arc owning the horizontal run at this row
156
+ * (the nearest corner to its right); the crossed vertical lane is simply
157
+ * occluded at that one cell and reappears on the next row.
158
+ */
159
+ function resolveRowLaneColors(cells: readonly StructuralCell[]): RowLaneColors {
160
+ const lane = new Array<number>(cells.length);
161
+ const connector = new Array<number>(cells.length);
162
+ let arcCorner = NEUTRAL_LANE;
163
+ for (let column = cells.length - 1; column >= 0; column--) {
164
+ const cell = cells[column];
165
+ connector[column] = arcCorner;
166
+ switch (cell?.kind) {
167
+ case 'arc-branch-corner':
168
+ case 'arc-land-corner':
169
+ arcCorner = column;
170
+ lane[column] = column;
171
+ break;
172
+ case 'arc-branch-tee':
173
+ // An inner co-sourced arc's own back-lane junction: its vertical run
174
+ // continues below in this column, so it keeps its own column hue.
175
+ lane[column] = column;
176
+ break;
177
+ case 'arc-crossing':
178
+ case 'arc-land-bridge':
179
+ lane[column] = arcCorner;
180
+ break;
181
+ case 'horizontal-pass':
182
+ lane[column] = arcCorner === NEUTRAL_LANE ? column : arcCorner;
183
+ break;
184
+ case 'node':
185
+ lane[column] = column;
186
+ arcCorner = NEUTRAL_LANE;
187
+ break;
188
+ default:
189
+ lane[column] = column;
190
+ arcCorner = NEUTRAL_LANE;
191
+ }
192
+ }
193
+ return { lane, connector };
194
+ }
195
+
196
+ /**
197
+ * Per-cell colour for a forward branch/merge connector row, split into the
198
+ * cell's junction `glyph` and its trailing `dash`. A connector's horizontal run
199
+ * is one logical line (a fork into new lanes, or a merge into a surviving lane)
200
+ * and reads best as the colour of the lane each segment serves — not dim-gray
201
+ * or a per-pass-through-column "rainbow".
202
+ */
203
+ interface ConnectorLaneColors {
204
+ /** Colour column for a cell's junction glyph (`├` / `┬` / `┴` / `╮` / `╯`). */
205
+ readonly glyph: readonly number[];
206
+ /** Colour column for a tee's trailing `─` — the branch it leads into. */
207
+ readonly dash: readonly number[];
208
+ }
209
+
210
+ /**
211
+ * Resolve per-cell connector colours. Scanning right-to-left, a corner or an
212
+ * intermediate tee anchors its own lane (its junction glyph takes that column),
213
+ * but a tee's **trailing dash leads into the branch on its right** (the next
214
+ * branch point), so `┬─` reads as "this lane, then on toward the next" rather
215
+ * than tinting the dash with the left lane. The leading tee at `startLane` (the
216
+ * fork/merge origin) and pure horizontal segments inherit the nearest branch
217
+ * point to their right whole-cell, so the run into a branch — or collapsing
218
+ * into a merge corner — stays continuous. Pass-through verticals outside the
219
+ * run keep their own column (column 0 stays neutral).
220
+ */
221
+ function resolveConnectorLaneColors(
222
+ cells: readonly StructuralCell[],
223
+ startLane: number,
224
+ ): ConnectorLaneColors {
225
+ const glyph = new Array<number>(cells.length);
226
+ const dash = new Array<number>(cells.length);
227
+ let owner = NEUTRAL_LANE;
228
+ for (let column = cells.length - 1; column >= 0; column--) {
229
+ const cell = cells[column];
230
+ switch (cell?.kind) {
231
+ case 'branch-corner':
232
+ case 'merge-corner':
233
+ owner = column;
234
+ glyph[column] = column;
235
+ dash[column] = column;
236
+ break;
237
+ case 'branch-tee':
238
+ case 'merge-tee':
239
+ if (column === startLane) {
240
+ const served = owner === NEUTRAL_LANE ? column : owner;
241
+ glyph[column] = column;
242
+ dash[column] = served;
243
+ } else {
244
+ dash[column] = owner === NEUTRAL_LANE ? column : owner;
245
+ glyph[column] = column;
246
+ owner = column;
247
+ }
248
+ break;
249
+ case 'arc-crossing':
250
+ glyph[column] = column;
251
+ dash[column] = column;
252
+ break;
253
+ case 'horizontal-pass': {
254
+ const served = owner === NEUTRAL_LANE ? column : owner;
255
+ glyph[column] = served;
256
+ dash[column] = served;
257
+ break;
258
+ }
259
+ default:
260
+ glyph[column] = column;
261
+ dash[column] = column;
262
+ }
263
+ }
264
+ return { glyph, dash };
265
+ }
266
+
267
+ /**
268
+ * Style a structural glyph by its resolved colour column. Column 0 and the
269
+ * neutral sentinel render dim (`style.lane`); columns ≥ 1 take a palette hue.
270
+ */
271
+ function laneStylerForColumn(
272
+ colorColumn: number,
273
+ colorize: boolean,
274
+ style: MigrationListStyler,
275
+ ): (text: string) => string {
276
+ if (!colorize || colorColumn <= NEUTRAL_LANE) {
277
+ return (text) => style.lane(text);
278
+ }
279
+ return laneColorForColumn(colorColumn);
280
+ }
281
+
282
+ /**
283
+ * Tint a branch-owned token (direction arrow, migration name) by its edge's
284
+ * lane so the whole branch row reads in one colour. Column 0 has nothing to be
285
+ * told apart from in the common linear chain, so it keeps the token's existing
286
+ * default styling (`fallback`) rather than a palette hue; only lanes ≥ 1 take a
287
+ * colour. With colour off, the fallback (also colourless) is used unchanged.
288
+ */
289
+ function branchStylerOrDefault(
290
+ column: number,
291
+ colorize: boolean,
292
+ fallback: (text: string) => string,
293
+ ): (text: string) => string {
294
+ if (!colorize || column <= NEUTRAL_LANE) {
295
+ return fallback;
296
+ }
297
+ return laneColorForColumn(column);
298
+ }
299
+
300
+ /**
301
+ * Render a connector tee (`├─` / `┬─` / `┴─`) with its junction glyph and its
302
+ * trailing dash coloured independently: the junction anchors its own lane while
303
+ * the dash leads into the branch on its right.
304
+ */
305
+ function renderConnectorTee(
306
+ pair: string,
307
+ glyphColumn: number,
308
+ dashColumn: number,
309
+ colorize: boolean,
310
+ style: MigrationListStyler,
311
+ ): string {
312
+ const glyph = laneStylerForColumn(glyphColumn, colorize, style);
313
+ if (glyphColumn === dashColumn) {
314
+ return glyph(pair);
315
+ }
316
+ return glyph(pair.slice(0, 1)) + laneStylerForColumn(dashColumn, colorize, style)(pair.slice(1));
317
+ }
318
+
119
319
  /**
120
320
  * 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.
321
+ * marker (`○` / `*`) followed by an arc connector (`◂` / `─` / `<` / `-`). The
322
+ * marker takes its own lane's hue (so each node visibly belongs to its branch);
323
+ * the connector follows the arc it belongs to (its owning back-lane hue).
324
+ * Direction arrows are handled elsewhere — they take their edge's lane hue too.
125
325
  */
126
- function renderNodeMarkerPair(pair: string, style: MigrationListStyler): string {
127
- return style.kind(pair.slice(0, 1)) + style.lane(pair.slice(1));
326
+ function renderNodeMarkerPair(
327
+ pair: string,
328
+ nodeColumn: number,
329
+ arcColumn: number,
330
+ colorize: boolean,
331
+ style: MigrationListStyler,
332
+ ): string {
333
+ const marker = laneStylerForColumn(nodeColumn, colorize, style);
334
+ const connector = laneStylerForColumn(arcColumn, colorize, style);
335
+ return marker(pair.slice(0, 1)) + connector(pair.slice(1));
128
336
  }
129
337
 
130
338
  function renderCellPair(
131
339
  cell: StructuralCell,
340
+ column: number,
341
+ colors: RowLaneColors,
342
+ colorize: boolean,
132
343
  style: MigrationListStyler,
133
344
  palette: MigrationGraphTreeGlyphPalette,
134
345
  ): string {
346
+ const laneColumn = colors.lane[column] ?? column;
347
+ const lane = laneStylerForColumn(laneColumn, colorize, style);
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;
351
+ if (cell.arcLand === true) {
352
+ return renderNodeMarkerPair(palette.arcLand, column, arcColumn, colorize, style);
353
+ }
354
+ if (cell.arcTee === true) {
355
+ return renderNodeMarkerPair(palette.arcTee, column, arcColumn, colorize, style);
356
+ }
357
+ return lane(palette.node);
358
+ }
140
359
  case 'vertical-pass':
141
- return style.lane(palette.verticalPass);
360
+ return lane(palette.verticalPass);
142
361
  case 'edge-lane':
143
- // The lane stays dim; the direction arrow (↑ / ↓ / ⟲) is the signal and
144
- // stays bright, like the contract-node marker.
145
362
  return cell.ownsLabel
146
- ? style.lane(palette.verticalPass.trimEnd()) +
147
- style.kind(arrowForEdgeKind(cell.edgeKind, palette))
148
- : style.lane(palette.verticalPass);
363
+ ? lane(palette.verticalPass.trimEnd()) +
364
+ branchStylerOrDefault(
365
+ column,
366
+ colorize,
367
+ style.kind,
368
+ )(arrowForEdgeKind(cell.edgeKind, palette))
369
+ : lane(palette.verticalPass);
149
370
  case 'branch-tee':
150
- return style.lane(palette.branchTee);
371
+ return lane(palette.branchTee);
151
372
  case 'merge-tee':
152
- return style.lane(palette.mergeTee);
373
+ return lane(palette.mergeTee);
153
374
  case 'branch-corner':
154
- return style.lane(palette.branchCorner);
375
+ return lane(palette.branchCorner);
155
376
  case 'merge-corner':
156
- return style.lane(palette.mergeCorner);
377
+ return lane(palette.mergeCorner);
157
378
  case 'arc-branch-corner':
158
- return style.lane(palette.arcBranchCorner);
379
+ return lane(palette.arcBranchCorner);
159
380
  case 'arc-branch-tee':
160
- return style.lane(palette.arcBranchTee);
381
+ return lane(palette.arcBranchTee);
161
382
  case 'arc-land-corner':
162
- return style.lane(palette.arcLandCorner);
383
+ return lane(palette.arcLandCorner);
163
384
  case 'arc-crossing':
164
- return style.lane(palette.arcCrossing);
385
+ return lane(palette.arcLandBridge);
165
386
  case 'arc-land-bridge':
166
- return style.lane(palette.arcLandBridge);
387
+ return lane(palette.arcLandBridge);
167
388
  case 'horizontal-pass':
168
- return style.lane(palette.horizontalPass);
389
+ return lane(palette.horizontalPass);
169
390
  case 'empty':
170
391
  return ' ';
171
392
  }
@@ -174,34 +395,56 @@ function renderCellPair(
174
395
  function renderConnectorRow(
175
396
  row: MigrationGraphGridRow,
176
397
  gridWidth: number,
398
+ colorize: boolean,
177
399
  style: MigrationListStyler,
178
400
  palette: MigrationGraphTreeGlyphPalette,
179
401
  ): string {
180
402
  const isMerge = row.kind === 'merge-connector';
181
403
  if (row.cells.length > 0) {
404
+ const colors = resolveConnectorLaneColors(row.cells, row.startLane ?? 0);
182
405
  let seenTee = false;
183
406
  let out = '';
184
- for (const cell of row.cells) {
407
+ for (let column = 0; column < row.cells.length; column++) {
408
+ const cell = row.cells[column];
409
+ if (cell === undefined) continue;
410
+ const glyphColumn = colors.glyph[column] ?? column;
411
+ const dashColumn = colors.dash[column] ?? glyphColumn;
412
+ const lane = laneStylerForColumn(glyphColumn, colorize, style);
185
413
  switch (cell.kind) {
186
414
  case 'branch-tee':
187
- out += style.lane(seenTee ? palette.connectorBranchTeeCo : palette.connectorBranchTee);
415
+ out += renderConnectorTee(
416
+ seenTee ? palette.connectorBranchTeeCo : palette.connectorBranchTee,
417
+ glyphColumn,
418
+ dashColumn,
419
+ colorize,
420
+ style,
421
+ );
188
422
  seenTee = true;
189
423
  break;
190
424
  case 'merge-tee':
191
- out += style.lane(seenTee ? palette.connectorMergeTeeCo : palette.connectorBranchTee);
425
+ out += renderConnectorTee(
426
+ seenTee ? palette.connectorMergeTeeCo : palette.connectorBranchTee,
427
+ glyphColumn,
428
+ dashColumn,
429
+ colorize,
430
+ style,
431
+ );
192
432
  seenTee = true;
193
433
  break;
194
434
  case 'branch-corner':
195
- out += style.lane(palette.branchCorner);
435
+ out += lane(palette.branchCorner);
196
436
  break;
197
437
  case 'merge-corner':
198
- out += style.lane(palette.mergeCorner);
438
+ out += lane(palette.mergeCorner);
199
439
  break;
200
440
  case 'vertical-pass':
201
- out += style.lane(palette.verticalPass);
441
+ out += lane(palette.verticalPass);
202
442
  break;
203
443
  case 'horizontal-pass':
204
- out += style.lane(palette.horizontalPass);
444
+ out += lane(palette.horizontalPass);
445
+ break;
446
+ case 'arc-crossing':
447
+ out += renderConnectorTee(palette.arcCrossing, glyphColumn, dashColumn, colorize, style);
205
448
  break;
206
449
  default:
207
450
  out += ' ';
@@ -218,13 +461,15 @@ function renderConnectorRow(
218
461
 
219
462
  const start = row.startLane ?? 0;
220
463
  const end = row.endLane ?? start;
464
+ // The whole fork/merge run reads as one line in the served lane's hue (the
465
+ // corner it reaches); pass-through columns outside the run keep their own.
466
+ const runLane = laneStylerForColumn(end, colorize, style);
221
467
  let out = '';
222
468
  for (let column = 0; column < gridWidth; column++) {
223
469
  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);
470
+ else if (column === start) out += runLane(palette.connectorBranchTee);
471
+ else if (column === end) out += runLane(isMerge ? palette.mergeCorner : palette.branchCorner);
472
+ else out += runLane(isMerge ? palette.connectorMergeTeeCo : palette.connectorBranchTeeCo);
228
473
  }
229
474
  return out;
230
475
  }
@@ -297,6 +542,13 @@ function padVisible(text: string, targetWidth: number): string {
297
542
  return text + ' '.repeat(padding);
298
543
  }
299
544
 
545
+ const ANSI_ESCAPE = '\x1b';
546
+
547
+ function trimTrailingWhitespace(line: string): string {
548
+ const trailingSpaceBeforeReset = new RegExp(`[\\t ]+((?:${ANSI_ESCAPE}\\[[0-9;]*m)+)$`);
549
+ return line.replace(trailingSpaceBeforeReset, '$1').replace(/\s+$/, '');
550
+ }
551
+
300
552
  function gridWidthForModel(rows: readonly MigrationGraphGridRow[]): number {
301
553
  return rows.reduce(
302
554
  (max, row) =>
@@ -373,19 +625,32 @@ export function renderMigrationGraphTree(
373
625
  }
374
626
 
375
627
  if (row.kind === 'branch-connector' || row.kind === 'merge-connector') {
376
- lines.push(renderConnectorRow(row, gridWidth, style, palette).replace(/\s+$/, ''));
628
+ lines.push(
629
+ trimTrailingWhitespace(renderConnectorRow(row, gridWidth, opts.colorize, style, palette)),
630
+ );
377
631
  continue;
378
632
  }
379
633
 
380
- let gutter = row.cells.map((cell) => renderCellPair(cell, style, palette)).join('');
381
- const prevRow = model.rows[rowIndex - 1];
634
+ const cellColors = resolveRowLaneColors(row.cells);
635
+ let gutter = row.cells
636
+ .map((cell, column) =>
637
+ renderCellPair(cell, column, cellColors, opts.colorize, style, palette),
638
+ )
639
+ .join('');
382
640
  let laneSpan = row.cells.length;
383
641
  if (row.kind === 'node') {
384
642
  const contractHash = row.contractHash ?? EMPTY_CONTRACT_HASH;
385
- if (prevRow?.kind === 'merge-connector' || contractHash === EMPTY_CONTRACT_HASH) {
643
+ if (contractHash === EMPTY_CONTRACT_HASH) {
386
644
  laneSpan = 1;
387
645
  } else {
388
- laneSpan = row.cells.length;
646
+ let lastActiveColumn = -1;
647
+ for (let column = row.cells.length - 1; column >= 0; column--) {
648
+ if (row.cells[column]?.kind !== 'empty') {
649
+ lastActiveColumn = column;
650
+ break;
651
+ }
652
+ }
653
+ laneSpan = lastActiveColumn >= 0 ? lastActiveColumn + 1 : 1;
389
654
  }
390
655
  }
391
656
  const labelColumn =
@@ -402,12 +667,16 @@ export function renderMigrationGraphTree(
402
667
  ) {
403
668
  gutter = row.cells
404
669
  .slice(0, 1)
405
- .map((cell) => renderCellPair(cell, style, palette))
670
+ .map((cell, column) =>
671
+ renderCellPair(cell, column, cellColors, opts.colorize, style, palette),
672
+ )
406
673
  .join('');
407
674
  } else if (row.kind === 'node' && laneSpan < row.cells.length && !nodeHasArcDecoration(row)) {
408
675
  gutter = row.cells
409
676
  .slice(0, laneSpan)
410
- .map((cell) => renderCellPair(cell, style, palette))
677
+ .map((cell, column) =>
678
+ renderCellPair(cell, column, cellColors, opts.colorize, style, palette),
679
+ )
411
680
  .join('');
412
681
  } else if (gutter.length < laneSpan * 2) {
413
682
  gutter = gutter.padEnd(laneSpan * 2, ' ');
@@ -421,16 +690,18 @@ export function renderMigrationGraphTree(
421
690
  if (contractHash === EMPTY_CONTRACT_HASH) {
422
691
  const trailingLanes = row.cells
423
692
  .slice(1)
424
- .map((cell) => renderCellPair(cell, style, palette))
693
+ .map((cell, offset) =>
694
+ renderCellPair(cell, offset + 1, cellColors, opts.colorize, style, palette),
695
+ )
425
696
  .join('');
426
697
  const emptyGutter = palette.emptySource.padEnd(2, ' ') + trailingLanes;
427
698
  const overlayNames = overlayNamesForContract(contractHash, opts);
428
699
  if (overlayNames.length === 0) {
429
- lines.push(emptyGutter.replace(/\s+$/, ''));
700
+ lines.push(trimTrailingWhitespace(emptyGutter));
430
701
  continue;
431
702
  }
432
703
  const overlay = style.refs(overlayNames);
433
- lines.push(`${padVisible(emptyGutter, dataColumn)}${overlay}`.replace(/\s+$/, ''));
704
+ lines.push(trimTrailingWhitespace(`${padVisible(emptyGutter, dataColumn)}${overlay}`));
434
705
  continue;
435
706
  }
436
707
  const hashText = style.sourceHash(
@@ -442,7 +713,7 @@ export function renderMigrationGraphTree(
442
713
  ? ' '.repeat(Math.max(0, dataColumn - labelColumn - stringWidth(hashText)))
443
714
  : '';
444
715
  const overlay = overlayNames.length > 0 ? style.refs(overlayNames) : '';
445
- lines.push(`${gutterPad}${hashText}${overlayPad}${overlay}`.replace(/\s+$/, ''));
716
+ lines.push(trimTrailingWhitespace(`${gutterPad}${hashText}${overlayPad}${overlay}`));
446
717
  continue;
447
718
  }
448
719
 
@@ -450,10 +721,48 @@ export function renderMigrationGraphTree(
450
721
  if (edge === undefined) continue;
451
722
 
452
723
  const dirNamePadding = ' '.repeat(Math.max(0, dirNameWidth - edge.dirName.length));
453
- const dirName = `${style.dirName(edge.dirName)}${dirNamePadding}`;
724
+ const laneIndex = row.laneIndex ?? 0;
725
+ // A branched name keeps its bold (via `style.dirName`) and adds the lane
726
+ // hue, so it reads as one with its lane/arrow; column-0 names stay bold-only.
727
+ const dirNameStyler =
728
+ opts.colorize && laneIndex > NEUTRAL_LANE
729
+ ? (text: string) => forcedBold(laneColorForColumn(laneIndex)(text))
730
+ : style.dirName;
731
+ const dirName = `${dirNameStyler(edge.dirName)}${dirNamePadding}`;
454
732
  const hashColumn = formatEdgeHashColumn(edge, style, hashLength, palette);
455
- lines.push(`${gutterPad}${dirName}${hashColumn}`.replace(/\s+$/, ''));
733
+ lines.push(trimTrailingWhitespace(`${gutterPad}${dirName}${hashColumn}`));
456
734
  }
457
735
 
458
736
  return lines.join('\n');
459
737
  }
738
+
739
+ export interface RenderMigrationGraphLegendOptions {
740
+ readonly colorize: boolean;
741
+ readonly glyphMode?: GlyphMode;
742
+ }
743
+
744
+ /**
745
+ * A compact key for the `--tree` visual language: the contract marker, the
746
+ * in-lane direction arrows, the empty baseline, the `(refs)` overlay (including
747
+ * the reserved `db` live-database and `contract` working-schema markers), and a
748
+ * worked sample of the data-column `from → to` migration hash arrow.
749
+ *
750
+ * Honors the same glyph palette (unicode vs ASCII) and `colorize` gate as the
751
+ * tree renderer, so the key matches whatever the graph itself drew and stays
752
+ * pipe-safe (zero ANSI when color is off). The caller adds the trailing blank
753
+ * line that separates this stderr key from the graph on stdout.
754
+ */
755
+ export function renderMigrationGraphLegend(opts: RenderMigrationGraphLegendOptions): string {
756
+ const palette = paletteFor(opts.glyphMode ?? 'unicode');
757
+ const style = createAnsiMigrationListStyler({ useColor: opts.colorize });
758
+ const node = palette.node.trimEnd();
759
+ const sampleArrow = `${style.sourceHash('aaaaaa')} ${style.glyph(palette.forwardArrow)} ${style.destHash('bbbbbb')}`;
760
+ return [
761
+ 'Legend:',
762
+ ` ${style.kind(node)} contract ${style.kind(palette.edgeArrow.forward)} forward ${style.kind(palette.edgeArrow.rollback)} rollback`,
763
+ ` ${style.kind(palette.edgeArrow.self)} migration without schema change`,
764
+ ` ${style.glyph(palette.emptySource)} empty database (baseline)`,
765
+ ` ${style.refs(['refs'])} ${DB_MARKER_NAME} / ${CONTRACT_MARKER_NAME} markers`,
766
+ ` ${sampleArrow} migration from contract aaaaaa to bbbbbb`,
767
+ ].join('\n');
768
+ }