@prisma-next/cli 0.12.0-dev.28 → 0.12.0-dev.29

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 (102) hide show
  1. package/dist/cli.mjs +12 -12
  2. package/dist/{client-nygCs15r.mjs → client-xeWpMlq1.mjs} +4 -4
  3. package/dist/client-xeWpMlq1.mjs.map +1 -0
  4. package/dist/{command-helpers-D7TK5Y9e.mjs → command-helpers-DK_5ItoJ.mjs} +16 -2
  5. package/dist/command-helpers-DK_5ItoJ.mjs.map +1 -0
  6. package/dist/commands/contract-emit.mjs +1 -1
  7. package/dist/commands/contract-infer.mjs +1 -1
  8. package/dist/commands/db-init.mjs +3 -3
  9. package/dist/commands/db-schema.mjs +3 -3
  10. package/dist/commands/db-sign.mjs +4 -4
  11. package/dist/commands/db-update.mjs +4 -4
  12. package/dist/commands/db-verify.mjs +1 -1
  13. package/dist/commands/migrate.d.mts +1 -1
  14. package/dist/commands/migrate.mjs +4 -4
  15. package/dist/commands/migration-check.mjs +1 -1
  16. package/dist/commands/migration-graph.d.mts +12 -15
  17. package/dist/commands/migration-graph.d.mts.map +1 -1
  18. package/dist/commands/migration-graph.mjs +84 -51
  19. package/dist/commands/migration-graph.mjs.map +1 -1
  20. package/dist/commands/migration-list.d.mts +13 -4
  21. package/dist/commands/migration-list.d.mts.map +1 -1
  22. package/dist/commands/migration-list.mjs +1 -187
  23. package/dist/commands/migration-log.d.mts +2 -2
  24. package/dist/commands/migration-log.mjs +1 -1
  25. package/dist/commands/migration-new.mjs +3 -3
  26. package/dist/commands/migration-plan.d.mts +1 -1
  27. package/dist/commands/migration-plan.mjs +1 -1
  28. package/dist/commands/migration-show.d.mts +1 -1
  29. package/dist/commands/migration-show.mjs +2 -2
  30. package/dist/commands/migration-status.d.mts +20 -2
  31. package/dist/commands/migration-status.d.mts.map +1 -1
  32. package/dist/commands/migration-status.mjs +2 -3
  33. package/dist/commands/ref.d.mts +1 -1
  34. package/dist/commands/ref.mjs +2 -2
  35. package/dist/commands/telemetry/index.mjs +1 -1
  36. package/dist/{contract-at-errors-CK3qoqZf.mjs → contract-at-errors-DG3kjgoz.mjs} +2 -2
  37. package/dist/{contract-at-errors-CK3qoqZf.mjs.map → contract-at-errors-DG3kjgoz.mjs.map} +1 -1
  38. package/dist/{contract-emit-Dzf73HdD.mjs → contract-emit-BO0l6fnT.mjs} +3 -3
  39. package/dist/{contract-emit-Dzf73HdD.mjs.map → contract-emit-BO0l6fnT.mjs.map} +1 -1
  40. package/dist/{contract-emit-DwlIz5Zg.mjs → contract-emit-C0Bs0VRj.mjs} +3 -3
  41. package/dist/{contract-emit-DwlIz5Zg.mjs.map → contract-emit-C0Bs0VRj.mjs.map} +1 -1
  42. package/dist/{contract-infer-Bzh___GO.mjs → contract-infer-2wtPflGH.mjs} +3 -3
  43. package/dist/{contract-infer-Bzh___GO.mjs.map → contract-infer-2wtPflGH.mjs.map} +1 -1
  44. package/dist/{contract-space-aggregate-loader-5zmOENc4.mjs → contract-space-aggregate-loader-Dbr3-jHF.mjs} +2 -2
  45. package/dist/{contract-space-aggregate-loader-5zmOENc4.mjs.map → contract-space-aggregate-loader-Dbr3-jHF.mjs.map} +1 -1
  46. package/dist/{db-verify-CNz036sw.mjs → db-verify-CxHiSiTG.mjs} +4 -4
  47. package/dist/{db-verify-CNz036sw.mjs.map → db-verify-CxHiSiTG.mjs.map} +1 -1
  48. package/dist/exports/control-api.d.mts +1 -1
  49. package/dist/exports/control-api.mjs +2 -2
  50. package/dist/exports/index.mjs +1 -1
  51. package/dist/exports/init-output.mjs +1 -1
  52. package/dist/{framework-components-CyM_xYCY.mjs → framework-components-CxOVKAAh.mjs} +2 -2
  53. package/dist/{framework-components-CyM_xYCY.mjs.map → framework-components-CxOVKAAh.mjs.map} +1 -1
  54. package/dist/{global-flags-DEHjV8_s.d.mts → global-flags-DG4uY5tV.d.mts} +1 -1
  55. package/dist/{global-flags-DEHjV8_s.d.mts.map → global-flags-DG4uY5tV.d.mts.map} +1 -1
  56. package/dist/{init-DJsQpr_6.mjs → init-R272pxux.mjs} +4 -4
  57. package/dist/{init-DJsQpr_6.mjs.map → init-R272pxux.mjs.map} +1 -1
  58. package/dist/{inspect-live-schema-DE76Ou4D.mjs → inspect-live-schema-RekOwfi5.mjs} +3 -3
  59. package/dist/{inspect-live-schema-DE76Ou4D.mjs.map → inspect-live-schema-RekOwfi5.mjs.map} +1 -1
  60. package/dist/{migration-check-CL2MzDRX.mjs → migration-check-Dc0cOhKH.mjs} +2 -2
  61. package/dist/{migration-check-CL2MzDRX.mjs.map → migration-check-Dc0cOhKH.mjs.map} +1 -1
  62. package/dist/{migration-command-scaffold-194pA8F5.mjs → migration-command-scaffold-ApB3NxWY.mjs} +3 -3
  63. package/dist/{migration-command-scaffold-194pA8F5.mjs.map → migration-command-scaffold-ApB3NxWY.mjs.map} +1 -1
  64. package/dist/{migration-graph-tree-render-CVmV9sWr.mjs → migration-graph-space-render-dmLLWift.mjs} +389 -210
  65. package/dist/migration-graph-space-render-dmLLWift.mjs.map +1 -0
  66. package/dist/migration-list-C5sXrl0U.mjs +228 -0
  67. package/dist/migration-list-C5sXrl0U.mjs.map +1 -0
  68. package/dist/{migration-log-CP6skD5b.mjs → migration-log-DD_vCbYW.mjs} +4 -4
  69. package/dist/{migration-log-CP6skD5b.mjs.map → migration-log-DD_vCbYW.mjs.map} +1 -1
  70. package/dist/{migration-plan-D61N1hID.mjs → migration-plan-CeTjQOIG.mjs} +5 -5
  71. package/dist/{migration-plan-D61N1hID.mjs.map → migration-plan-CeTjQOIG.mjs.map} +1 -1
  72. package/dist/{migration-status--ejfYqWS.mjs → migration-status-qV8ctwPy.mjs} +61 -45
  73. package/dist/migration-status-qV8ctwPy.mjs.map +1 -0
  74. package/dist/{output-B60Gw5fu.mjs → output-CF_hqzI-.mjs} +1 -1
  75. package/dist/{output-B60Gw5fu.mjs.map → output-CF_hqzI-.mjs.map} +1 -1
  76. package/dist/{telemetry-CnfdMrpv.mjs → telemetry-S-NGi9U6.mjs} +2 -2
  77. package/dist/{telemetry-CnfdMrpv.mjs.map → telemetry-S-NGi9U6.mjs.map} +1 -1
  78. package/dist/{types-BYwWOyYJ.d.mts → types-Mh7mdPHM.d.mts} +1 -1
  79. package/dist/{types-BYwWOyYJ.d.mts.map → types-Mh7mdPHM.d.mts.map} +1 -1
  80. package/dist/{verify-By66Zu3y.mjs → verify-BdI-BgYi.mjs} +2 -2
  81. package/dist/{verify-By66Zu3y.mjs.map → verify-BdI-BgYi.mjs.map} +1 -1
  82. package/package.json +18 -18
  83. package/src/commands/migration-graph.ts +125 -58
  84. package/src/commands/migration-list.ts +43 -9
  85. package/src/commands/migration-status.ts +106 -74
  86. package/src/control-api/operations/db-apply.ts +7 -4
  87. package/src/utils/cli-errors.ts +17 -0
  88. package/src/utils/formatters/migration-graph-lane-colors.ts +164 -1
  89. package/src/utils/formatters/migration-graph-rows.ts +128 -15
  90. package/src/utils/formatters/migration-graph-space-render.ts +138 -0
  91. package/src/utils/formatters/migration-graph-tree-render.ts +149 -239
  92. package/src/utils/formatters/migration-list-data-column.ts +6 -0
  93. package/src/utils/formatters/migration-list-render.ts +43 -23
  94. package/src/utils/formatters/migration-list-styler.ts +48 -5
  95. package/src/utils/legend.ts +38 -0
  96. package/dist/client-nygCs15r.mjs.map +0 -1
  97. package/dist/command-helpers-D7TK5Y9e.mjs.map +0 -1
  98. package/dist/commands/migration-list.mjs.map +0 -1
  99. package/dist/migration-graph-tree-render-CVmV9sWr.mjs.map +0 -1
  100. package/dist/migration-status--ejfYqWS.mjs.map +0 -1
  101. package/dist/migration-types-D2FW63pr.d.mts +0 -15
  102. package/dist/migration-types-D2FW63pr.d.mts.map +0 -1
@@ -3,18 +3,13 @@ import type {
3
3
  ContractMarkerRecordLike,
4
4
  ContractSpaceMember,
5
5
  } from '@prisma-next/migration-tools/aggregate';
6
- import { requireHeadRef } from '@prisma-next/migration-tools/aggregate';
7
6
  import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
8
7
  import {
9
8
  errorNoInvariantPath,
10
9
  errorUnknownInvariant,
11
10
  MigrationToolsError,
12
11
  } from '@prisma-next/migration-tools/errors';
13
- import {
14
- findPath,
15
- findPathWithDecision,
16
- findReachableLeaves,
17
- } from '@prisma-next/migration-tools/migration-graph';
12
+ import { findPath, findPathWithDecision } from '@prisma-next/migration-tools/migration-graph';
18
13
  import { parseContractRef } from '@prisma-next/migration-tools/ref-resolution';
19
14
  import type { RefEntry, Refs } from '@prisma-next/migration-tools/refs';
20
15
  import { readRefs } from '@prisma-next/migration-tools/refs';
@@ -47,16 +42,19 @@ import {
47
42
  loadContractRawSafely,
48
43
  refusePackageCorruptionOnAggregate,
49
44
  } from '../utils/contract-space-aggregate-loader';
50
- import { buildMigrationGraphLayout } from '../utils/formatters/migration-graph-layout';
51
- import { buildMigrationGraphRows } from '../utils/formatters/migration-graph-rows';
52
45
  import {
53
- type MigrationEdgeAnnotation,
54
- renderMigrationGraphTree,
55
- } from '../utils/formatters/migration-graph-tree-render';
46
+ computeGlobalMaxDirNameWidth,
47
+ computeGlobalMaxEdgeTreePrefixWidth,
48
+ indentMigrationGraphTreeBlock,
49
+ renderMigrationGraphSpaceTree,
50
+ } from '../utils/formatters/migration-graph-space-render';
51
+ import type { MigrationEdgeAnnotation } from '../utils/formatters/migration-graph-tree-render';
52
+ import { renderMigrationGraphLegend } from '../utils/formatters/migration-graph-tree-render';
56
53
  import type { MigrationListEntry } from '../utils/formatters/migration-list-types';
57
54
  import { formatStyledHeader } from '../utils/formatters/styled';
58
55
  import type { CommonCommandOptions } from '../utils/global-flags';
59
56
  import { type GlobalFlags, parseGlobalFlagsOrExit } from '../utils/global-flags';
57
+ import { shouldShowLegend, validateLegendOptions } from '../utils/legend';
60
58
  import type { StatusDiagnostic } from '../utils/migration-types';
61
59
  import { handleResult } from '../utils/result-handler';
62
60
  import { createTerminalUI, type TerminalUI } from '../utils/terminal-ui';
@@ -77,6 +75,7 @@ interface MigrationStatusOptions extends CommonCommandOptions {
77
75
  readonly to?: string;
78
76
  readonly from?: string;
79
77
  readonly space?: string;
78
+ readonly legend?: boolean;
80
79
  }
81
80
 
82
81
  export interface MigrationStatusMigrationEntry extends MigrationListEntry {
@@ -112,26 +111,8 @@ function shortDisplayHash(hash: string): string {
112
111
  return stripped.slice(0, 12);
113
112
  }
114
113
 
115
- function resolveTargetHashForSpace(
116
- member: ContractSpaceMember,
117
- contractHash: string,
118
- activeRefHash: string | undefined,
119
- ): string | undefined {
120
- const graph = member.graph();
121
- if (activeRefHash !== undefined && graph.nodes.has(activeRefHash)) {
122
- return activeRefHash;
123
- }
124
- if (graph.nodes.has(contractHash)) {
125
- return contractHash;
126
- }
127
- if (graph.nodes.size === 0) {
128
- return requireHeadRef(member).hash;
129
- }
130
- const leaves = findReachableLeaves(graph, EMPTY_CONTRACT_HASH);
131
- if (leaves.length === 1) {
132
- return leaves[0];
133
- }
134
- return undefined;
114
+ function resolveTarget(contractHash: string, activeRefHash: string | undefined): string {
115
+ return activeRefHash ?? contractHash;
135
116
  }
136
117
 
137
118
  function buildStatusMigrations(
@@ -146,28 +127,35 @@ function buildStatusMigrations(
146
127
 
147
128
  function renderSpaceTree(args: {
148
129
  readonly member: ContractSpaceMember;
149
- readonly contractHash: string;
130
+ readonly liveContractHash: string;
131
+ readonly migrations: readonly MigrationListEntry[];
150
132
  readonly markerHash: string | undefined;
151
133
  readonly showDbMarker: boolean;
152
- readonly targetHash: string;
153
- readonly edgeAnnotations: ReadonlyMap<string, MigrationEdgeAnnotation>;
134
+ readonly statusOverlay: ReadonlyMap<string, MigrationEdgeAnnotation>;
154
135
  readonly colorize: boolean;
155
136
  readonly glyphMode: 'unicode' | 'ascii';
137
+ readonly globalMaxEdgeTreePrefixWidth?: number;
138
+ readonly globalMaxDirNameWidth?: number;
156
139
  }): string {
157
140
  const graph = args.member.graph();
158
141
  if (graph.nodes.size === 0) {
159
142
  return '';
160
143
  }
161
- const refsByHash = listRefsByContractHash(args.member);
162
- const rowModel = buildMigrationGraphRows(graph, { contractHash: args.contractHash });
163
- const layout = buildMigrationGraphLayout(rowModel);
164
- return renderMigrationGraphTree(layout, {
165
- refsByHash,
166
- ...(args.showDbMarker && args.markerHash !== undefined ? { dbHash: args.markerHash } : {}),
167
- contractHash: args.contractHash,
168
- edgeAnnotationsByHash: args.edgeAnnotations,
144
+ return renderMigrationGraphSpaceTree({
145
+ graph,
146
+ migrations: args.migrations,
147
+ liveContractHash: args.liveContractHash,
148
+ refsByHash: listRefsByContractHash(args.member),
149
+ statusOverlayByHash: args.statusOverlay,
169
150
  colorize: args.colorize,
170
151
  glyphMode: args.glyphMode,
152
+ ...(args.showDbMarker && args.markerHash !== undefined ? { dbHash: args.markerHash } : {}),
153
+ ...(args.globalMaxEdgeTreePrefixWidth !== undefined
154
+ ? { globalMaxEdgeTreePrefixWidth: args.globalMaxEdgeTreePrefixWidth }
155
+ : {}),
156
+ ...(args.globalMaxDirNameWidth !== undefined
157
+ ? { globalMaxDirNameWidth: args.globalMaxDirNameWidth }
158
+ : {}),
171
159
  });
172
160
  }
173
161
 
@@ -175,6 +163,27 @@ function countPending(migrations: readonly MigrationStatusMigrationEntry[]): num
175
163
  return migrations.filter((m) => m.status === 'pending').length;
176
164
  }
177
165
 
166
+ export function buildNoPathSummary(args: {
167
+ readonly markerHash: string | undefined;
168
+ readonly targetHash: string;
169
+ readonly explicitTarget: boolean;
170
+ readonly refName: string | undefined;
171
+ }): string {
172
+ const markerPart =
173
+ args.markerHash !== undefined
174
+ ? `the database state (${shortDisplayHash(args.markerHash)})`
175
+ : 'the database state';
176
+ const targetShort = shortDisplayHash(args.targetHash);
177
+ if (!args.explicitTarget) {
178
+ return `No migration path from ${markerPart} to the application's contract (${targetShort}). Run \`prisma-next migration plan --name <name>\` to author one.`;
179
+ }
180
+ const targetLabel =
181
+ args.refName !== undefined
182
+ ? `the target (${targetShort} via \`${args.refName}\`)`
183
+ : `the target (${targetShort})`;
184
+ return `No migration path from ${markerPart} to ${targetLabel}. Run \`prisma-next migration plan --name <name>\` to author one, or pass \`--to <contract>\` to pick a reachable target.`;
185
+ }
186
+
178
187
  export function buildStatusHeadline(args: {
179
188
  readonly pendingCount: number;
180
189
  readonly targetHash: string;
@@ -185,7 +194,7 @@ export function buildStatusHeadline(args: {
185
194
  return `Database marker ${shortDisplayHash(args.markerHash)} is not in the on-disk migration graph`;
186
195
  }
187
196
  if (args.pendingCount === 0) {
188
- return 'up to date';
197
+ return 'Up to date';
189
198
  }
190
199
  return `${args.pendingCount} pending — run \`prisma-next migrate --to ${shortDisplayHash(args.targetHash)}\``;
191
200
  }
@@ -361,6 +370,15 @@ async function executeMigrationStatusCommand(
361
370
  flags,
362
371
  });
363
372
  ui.stderr(header);
373
+ if (shouldShowLegend(options, flags)) {
374
+ ui.stderr(
375
+ renderMigrationGraphLegend({
376
+ colorize: flags.color !== false,
377
+ glyphMode: ui.resolveGlyphMode(false),
378
+ }),
379
+ );
380
+ ui.stderr('');
381
+ }
364
382
  }
365
383
 
366
384
  const listSpaces = await migrationSpaceListEntriesFromAggregate(aggregate, migrationsDir);
@@ -440,7 +458,21 @@ async function executeMigrationStatusCommand(
440
458
  let markerCannotReachTarget = false;
441
459
  let headlineTargetHash = activeRefHash ?? contractHash;
442
460
  let totalPending = 0;
443
- let hasAmbiguousTarget = false;
461
+
462
+ const globalLayoutInputs = showSpaceHeadings
463
+ ? scopedSpaces
464
+ .filter((spaceEntry) => spaceEntry.migrations.length > 0)
465
+ .map((spaceEntry) => ({
466
+ graph: aggregate.space(spaceEntry.spaceId)!.graph(),
467
+ liveContractHash: contractHash,
468
+ }))
469
+ : [];
470
+ const globalMaxEdgeTreePrefixWidth =
471
+ globalLayoutInputs.length > 0
472
+ ? computeGlobalMaxEdgeTreePrefixWidth(globalLayoutInputs)
473
+ : undefined;
474
+ const globalMaxDirNameWidth =
475
+ globalLayoutInputs.length > 0 ? computeGlobalMaxDirNameWidth(globalLayoutInputs) : undefined;
444
476
 
445
477
  for (const spaceEntry of scopedSpaces) {
446
478
  const member = aggregate.space(spaceEntry.spaceId);
@@ -449,20 +481,7 @@ async function executeMigrationStatusCommand(
449
481
  }
450
482
  const graph = member.graph();
451
483
  const spaceContractHash = member.contract().storage.storageHash;
452
- const targetHash = resolveTargetHashForSpace(member, spaceContractHash, activeRefHash);
453
- if (targetHash === undefined) {
454
- hasAmbiguousTarget = true;
455
- diagnostics.push({
456
- code: 'MIGRATION.DIVERGED',
457
- severity: 'warn',
458
- message: 'There are multiple valid migration paths — you must select a target',
459
- hints: [
460
- "Use '--to <contract>' to select a target",
461
- "Or 'prisma-next ref set <name> <hash>' to create one",
462
- ],
463
- });
464
- continue;
465
- }
484
+ const targetHash = resolveTarget(spaceContractHash, activeRefHash);
466
485
  if (spaceEntry.spaceId === aggregate.app.spaceId) {
467
486
  headlineTargetHash = targetHash;
468
487
  }
@@ -477,9 +496,8 @@ async function executeMigrationStatusCommand(
477
496
  if (
478
497
  connected &&
479
498
  !usingFromOverride &&
480
- markerHash !== undefined &&
481
499
  markerInGraph &&
482
- markerHash !== targetHash &&
500
+ originHash !== targetHash &&
483
501
  findPath(graph, originHash, targetHash) === null
484
502
  ) {
485
503
  markerCannotReachTarget = true;
@@ -511,13 +529,15 @@ async function executeMigrationStatusCommand(
511
529
  });
512
530
  const tree = renderSpaceTree({
513
531
  member,
514
- contractHash: spaceContractHash,
532
+ liveContractHash: contractHash,
533
+ migrations: spaceEntry.migrations,
515
534
  markerHash,
516
535
  showDbMarker,
517
- targetHash,
518
- edgeAnnotations: annotations,
536
+ statusOverlay: annotations,
519
537
  colorize,
520
538
  glyphMode,
539
+ ...(globalMaxEdgeTreePrefixWidth !== undefined ? { globalMaxEdgeTreePrefixWidth } : {}),
540
+ ...(globalMaxDirNameWidth !== undefined ? { globalMaxDirNameWidth } : {}),
521
541
  });
522
542
  const migrations = buildStatusMigrations(spaceEntry.migrations, annotations);
523
543
  const pending = countPending(migrations);
@@ -529,9 +549,11 @@ async function executeMigrationStatusCommand(
529
549
  targetHash,
530
550
  migrations,
531
551
  });
552
+ const displayTree =
553
+ showSpaceHeadings && tree.length > 0 ? indentMigrationGraphTreeBlock(tree, ' ') : tree;
532
554
  treeSections.push({
533
555
  spaceId: spaceEntry.spaceId,
534
- tree,
556
+ tree: displayTree,
535
557
  showHeading: showSpaceHeadings,
536
558
  });
537
559
  }
@@ -567,16 +589,19 @@ async function executeMigrationStatusCommand(
567
589
  }
568
590
 
569
591
  const appMarkerHash = markersBySpace.get(aggregate.app.spaceId)?.storageHash;
570
- const summary = hasAmbiguousTarget
571
- ? 'Multiple valid migration paths — select a target with --to'
572
- : markerCannotReachTarget
573
- ? 'Database marker cannot reach the selected target'
574
- : buildStatusHeadline({
575
- pendingCount: totalPending,
576
- targetHash: headlineTargetHash,
577
- markerDiverged,
578
- markerHash: appMarkerHash,
579
- });
592
+ const summary = markerCannotReachTarget
593
+ ? buildNoPathSummary({
594
+ markerHash: appMarkerHash,
595
+ targetHash: headlineTargetHash,
596
+ explicitTarget: options.to !== undefined,
597
+ refName: activeRefName,
598
+ })
599
+ : buildStatusHeadline({
600
+ pendingCount: totalPending,
601
+ targetHash: headlineTargetHash,
602
+ markerDiverged,
603
+ markerHash: appMarkerHash,
604
+ });
580
605
 
581
606
  if (scopedSpaces.every((s) => s.migrations.length === 0)) {
582
607
  return ok({
@@ -613,6 +638,7 @@ export function createMigrationStatusCommand(): Command {
613
638
  'prisma-next migration status --db $DATABASE_URL',
614
639
  'prisma-next migration status --to production --db $DATABASE_URL',
615
640
  'prisma-next migration status --from sha256:abc --to production',
641
+ 'prisma-next migration status --legend --from sha256:abc --to production',
616
642
  ]);
617
643
  setCommandSeeAlso(command, [
618
644
  { verb: 'migration log', oneLiner: 'Show executed migration history' },
@@ -632,10 +658,16 @@ export function createMigrationStatusCommand(): Command {
632
658
  '--from <contract>',
633
659
  'Origin contract reference; same grammar as --to. Supplying --from switches to offline path computation.',
634
660
  )
661
+ .option('--legend', 'Print a key for the tree glyphs and lane colors')
635
662
  .action(async (options: MigrationStatusOptions) => {
636
663
  const flags = parseGlobalFlagsOrExit(options);
637
664
  const ui = createTerminalUI(flags);
638
665
 
666
+ const legendValidation = validateLegendOptions(options, flags);
667
+ if (!legendValidation.ok) {
668
+ process.exit(handleResult(legendValidation, flags, ui));
669
+ }
670
+
639
671
  const result = await executeMigrationStatusCommand(options, flags, ui);
640
672
 
641
673
  const exitCode = handleResult(result, flags, ui, (statusResult) => {
@@ -33,7 +33,12 @@ import type {
33
33
  OnControlProgress,
34
34
  PerSpaceExecutionEntry,
35
35
  } from '../types';
36
- import { applyMigration, buildPerSpaceBreakdown, collectOrdered } from './apply';
36
+ import {
37
+ applyMigration,
38
+ buildPerSpaceBreakdown,
39
+ collectOrdered,
40
+ type OrderedResolution,
41
+ } from './apply';
37
42
  import { stripOperations } from './migration-helpers';
38
43
 
39
44
  /**
@@ -255,9 +260,7 @@ export async function executeApply<TFamilyId extends string, TTargetId extends s
255
260
  }
256
261
 
257
262
  function aggregatePlannerWarnings(
258
- orderedResolutions: ReadonlyArray<{
259
- readonly entry: { readonly warnings?: readonly MigrationPlannerConflict[] };
260
- }>,
263
+ orderedResolutions: readonly OrderedResolution[],
261
264
  ): readonly MigrationPlannerConflict[] | undefined {
262
265
  const warnings = orderedResolutions.flatMap((r) => r.entry.warnings ?? []);
263
266
  return warnings.length > 0 ? warnings : undefined;
@@ -108,6 +108,23 @@ export function errorRefSetEmptySentinel(hash: string): CliStructuredError {
108
108
  });
109
109
  }
110
110
 
111
+ /**
112
+ * `--legend` was combined with a machine-readable or silent output flag.
113
+ * The legend is human-only decoration on stderr.
114
+ */
115
+ export function errorLegendHumanOnly(
116
+ conflictingFlag: '--json' | '--dot' | '--quiet',
117
+ ): CliStructuredError {
118
+ return errorRuntime('`--legend` is only available for human-readable output', {
119
+ why: `\`--legend\` prints a glyph key to stderr and cannot be combined with ${conflictingFlag}.`,
120
+ fix: `Omit ${conflictingFlag} to print the legend alongside the tree, or omit --legend when using ${conflictingFlag}.`,
121
+ meta: {
122
+ code: 'MIGRATION.LEGEND_HUMAN_ONLY',
123
+ conflictingFlag,
124
+ },
125
+ });
126
+ }
127
+
111
128
  /**
112
129
  * `--space <id>` was given a value that doesn't satisfy the contract-space
113
130
  * naming rule (`[a-z][a-z0-9_-]{0,63}` per `isValidSpaceId`). Fires before
@@ -1,4 +1,5 @@
1
1
  import { createColors } from 'colorette';
2
+ import type { StructuralCell } from './migration-graph-layout';
2
3
 
3
4
  export type LaneColorizer = (text: string) => string;
4
5
 
@@ -13,6 +14,12 @@ export const LANE_COLOR_CYCLE: readonly LaneColorizer[] = [
13
14
  red,
14
15
  ];
15
16
 
17
+ /**
18
+ * The leftmost lane (column 0) renders neutral — no palette hue. Columns ≥ 1
19
+ * rotate through {@link LANE_COLOR_CYCLE}.
20
+ */
21
+ export const NEUTRAL_LANE_COLUMN = 0;
22
+
16
23
  /**
17
24
  * The hue for a gutter column. The leftmost lane (column 0) is **neutral** — it
18
25
  * has nothing to be told apart from in the common single-lane linear case, so
@@ -23,9 +30,165 @@ export const LANE_COLOR_CYCLE: readonly LaneColorizer[] = [
23
30
  * branch identity, exactly like `git log --graph`.
24
31
  */
25
32
  export function laneColorForColumn(column: number): LaneColorizer {
26
- if (column <= 0) {
33
+ if (column <= NEUTRAL_LANE_COLUMN) {
27
34
  return (text) => text;
28
35
  }
29
36
  const colorizer = LANE_COLOR_CYCLE[(column - 1) % LANE_COLOR_CYCLE.length];
30
37
  return colorizer ?? ((text) => text);
31
38
  }
39
+
40
+ /**
41
+ * Style a structural glyph by its resolved colour column. Column 0 and the
42
+ * neutral sentinel render dim (`dimLane`); columns ≥ 1 take a palette hue.
43
+ */
44
+ export function stylerForLaneColumn(
45
+ colorColumn: number,
46
+ colorize: boolean,
47
+ dimLane: (text: string) => string,
48
+ ): LaneColorizer {
49
+ if (!colorize || colorColumn <= NEUTRAL_LANE_COLUMN) {
50
+ return dimLane;
51
+ }
52
+ return laneColorForColumn(colorColumn);
53
+ }
54
+
55
+ /**
56
+ * The colour-source column for each cell of a row, resolved together because a
57
+ * routed back-arc spans columns and must read as **one hue** rather than a
58
+ * per-column "rainbow".
59
+ */
60
+ export interface RowArcLaneColors {
61
+ /** Colour column for a cell's structural glyph (lane / spine / arc body). */
62
+ readonly lane: readonly number[];
63
+ /** Colour column for a node arc-pair's connector half (`◂` / `─`). */
64
+ readonly connector: readonly number[];
65
+ /**
66
+ * Colour column for the trailing `─` of a landing tee (`┴─`). The junction
67
+ * (`lane`) keeps its own column; the dash leads into the next converging arc.
68
+ */
69
+ readonly dash: readonly number[];
70
+ }
71
+
72
+ /**
73
+ * Resolve per-cell colour columns for a node/arc row. Scanning right-to-left
74
+ * lets each arc segment inherit the hue of the arc it leads into.
75
+ */
76
+ export function resolveRowArcLaneColors(cells: readonly StructuralCell[]): RowArcLaneColors {
77
+ const lane = new Array<number>(cells.length);
78
+ const connector = new Array<number>(cells.length);
79
+ const dash = new Array<number>(cells.length);
80
+ let arcCorner = NEUTRAL_LANE_COLUMN;
81
+ let landingAnchor = NEUTRAL_LANE_COLUMN;
82
+ for (let column = cells.length - 1; column >= 0; column--) {
83
+ const cell = cells[column];
84
+ connector[column] = landingAnchor !== NEUTRAL_LANE_COLUMN ? landingAnchor : arcCorner;
85
+ switch (cell?.kind) {
86
+ case 'arc-branch-corner':
87
+ arcCorner = column;
88
+ lane[column] = column;
89
+ dash[column] = column;
90
+ break;
91
+ case 'arc-land-corner':
92
+ arcCorner = column;
93
+ landingAnchor = column;
94
+ lane[column] = column;
95
+ dash[column] = column;
96
+ break;
97
+ case 'arc-branch-tee':
98
+ lane[column] = column;
99
+ dash[column] = column;
100
+ break;
101
+ case 'arc-land-tee':
102
+ lane[column] = column;
103
+ dash[column] = landingAnchor === NEUTRAL_LANE_COLUMN ? column : landingAnchor;
104
+ landingAnchor = column;
105
+ break;
106
+ case 'arc-crossing':
107
+ case 'arc-land-bridge': {
108
+ const served = landingAnchor !== NEUTRAL_LANE_COLUMN ? landingAnchor : arcCorner;
109
+ lane[column] = served;
110
+ dash[column] = served;
111
+ break;
112
+ }
113
+ case 'horizontal-pass':
114
+ lane[column] = arcCorner === NEUTRAL_LANE_COLUMN ? column : arcCorner;
115
+ dash[column] = lane[column] ?? column;
116
+ break;
117
+ case 'node':
118
+ lane[column] = column;
119
+ dash[column] = column;
120
+ arcCorner = NEUTRAL_LANE_COLUMN;
121
+ landingAnchor = NEUTRAL_LANE_COLUMN;
122
+ break;
123
+ default:
124
+ lane[column] = column;
125
+ dash[column] = column;
126
+ arcCorner = NEUTRAL_LANE_COLUMN;
127
+ landingAnchor = NEUTRAL_LANE_COLUMN;
128
+ }
129
+ }
130
+ return { lane, connector, dash };
131
+ }
132
+
133
+ /**
134
+ * Per-cell colour for a forward branch/merge connector row, split into the
135
+ * cell's junction `glyph` and its trailing `dash`.
136
+ */
137
+ export interface ConnectorLaneColors {
138
+ /** Colour column for a cell's junction glyph (`├` / `┬` / `┴` / `╮` / `╯`). */
139
+ readonly glyph: readonly number[];
140
+ /** Colour column for a tee's trailing `─` — the branch it leads into. */
141
+ readonly dash: readonly number[];
142
+ }
143
+
144
+ /**
145
+ * Resolve per-cell connector colours. Scanning right-to-left, a corner or an
146
+ * intermediate tee anchors its own lane, but a tee's trailing dash leads into
147
+ * the branch on its right.
148
+ */
149
+ export function resolveConnectorLaneColors(
150
+ cells: readonly StructuralCell[],
151
+ startLane: number,
152
+ ): ConnectorLaneColors {
153
+ const glyph = new Array<number>(cells.length);
154
+ const dash = new Array<number>(cells.length);
155
+ let owner = NEUTRAL_LANE_COLUMN;
156
+ for (let column = cells.length - 1; column >= 0; column--) {
157
+ const cell = cells[column];
158
+ switch (cell?.kind) {
159
+ case 'branch-corner':
160
+ case 'merge-corner':
161
+ owner = column;
162
+ glyph[column] = column;
163
+ dash[column] = column;
164
+ break;
165
+ case 'branch-tee':
166
+ case 'merge-tee':
167
+ if (column === startLane) {
168
+ const served = owner === NEUTRAL_LANE_COLUMN ? column : owner;
169
+ glyph[column] = column;
170
+ dash[column] = served;
171
+ } else {
172
+ dash[column] = owner === NEUTRAL_LANE_COLUMN ? column : owner;
173
+ glyph[column] = column;
174
+ owner = column;
175
+ }
176
+ break;
177
+ case 'arc-crossing':
178
+ glyph[column] = column;
179
+ dash[column] = owner === NEUTRAL_LANE_COLUMN ? column : owner;
180
+ owner = column;
181
+ break;
182
+ case 'horizontal-pass': {
183
+ const served = owner === NEUTRAL_LANE_COLUMN ? column : owner;
184
+ glyph[column] = served;
185
+ dash[column] = served;
186
+ break;
187
+ }
188
+ default:
189
+ glyph[column] = column;
190
+ dash[column] = column;
191
+ }
192
+ }
193
+ return { glyph, dash };
194
+ }