@git-stunts/git-warp 10.3.2 → 10.7.0

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 (108) hide show
  1. package/README.md +6 -3
  2. package/SECURITY.md +89 -1
  3. package/bin/warp-graph.js +574 -208
  4. package/index.d.ts +55 -0
  5. package/index.js +4 -0
  6. package/package.json +8 -4
  7. package/src/domain/WarpGraph.js +334 -161
  8. package/src/domain/crdt/LWW.js +1 -1
  9. package/src/domain/crdt/ORSet.js +10 -6
  10. package/src/domain/crdt/VersionVector.js +5 -1
  11. package/src/domain/errors/EmptyMessageError.js +2 -4
  12. package/src/domain/errors/ForkError.js +4 -0
  13. package/src/domain/errors/IndexError.js +4 -0
  14. package/src/domain/errors/OperationAbortedError.js +4 -0
  15. package/src/domain/errors/QueryError.js +4 -0
  16. package/src/domain/errors/SchemaUnsupportedError.js +4 -0
  17. package/src/domain/errors/ShardCorruptionError.js +2 -6
  18. package/src/domain/errors/ShardLoadError.js +2 -6
  19. package/src/domain/errors/ShardValidationError.js +2 -7
  20. package/src/domain/errors/StorageError.js +2 -6
  21. package/src/domain/errors/SyncError.js +4 -0
  22. package/src/domain/errors/TraversalError.js +4 -0
  23. package/src/domain/errors/WarpError.js +2 -4
  24. package/src/domain/errors/WormholeError.js +4 -0
  25. package/src/domain/services/AnchorMessageCodec.js +1 -4
  26. package/src/domain/services/BitmapIndexBuilder.js +10 -6
  27. package/src/domain/services/BitmapIndexReader.js +27 -21
  28. package/src/domain/services/BoundaryTransitionRecord.js +22 -15
  29. package/src/domain/services/CheckpointMessageCodec.js +1 -7
  30. package/src/domain/services/CheckpointSerializerV5.js +20 -19
  31. package/src/domain/services/CheckpointService.js +18 -18
  32. package/src/domain/services/CommitDagTraversalService.js +13 -1
  33. package/src/domain/services/DagPathFinding.js +40 -18
  34. package/src/domain/services/DagTopology.js +7 -6
  35. package/src/domain/services/DagTraversal.js +5 -3
  36. package/src/domain/services/Frontier.js +7 -6
  37. package/src/domain/services/HealthCheckService.js +15 -14
  38. package/src/domain/services/HookInstaller.js +64 -13
  39. package/src/domain/services/HttpSyncServer.js +88 -19
  40. package/src/domain/services/IndexRebuildService.js +12 -12
  41. package/src/domain/services/IndexStalenessChecker.js +13 -6
  42. package/src/domain/services/JoinReducer.js +28 -27
  43. package/src/domain/services/LogicalTraversal.js +7 -6
  44. package/src/domain/services/MessageCodecInternal.js +2 -0
  45. package/src/domain/services/ObserverView.js +6 -6
  46. package/src/domain/services/PatchBuilderV2.js +9 -9
  47. package/src/domain/services/PatchMessageCodec.js +1 -7
  48. package/src/domain/services/ProvenanceIndex.js +6 -8
  49. package/src/domain/services/ProvenancePayload.js +1 -2
  50. package/src/domain/services/QueryBuilder.js +29 -23
  51. package/src/domain/services/StateDiff.js +7 -7
  52. package/src/domain/services/StateSerializerV5.js +8 -6
  53. package/src/domain/services/StreamingBitmapIndexBuilder.js +29 -23
  54. package/src/domain/services/SyncAuthService.js +396 -0
  55. package/src/domain/services/SyncProtocol.js +23 -26
  56. package/src/domain/services/TemporalQuery.js +4 -3
  57. package/src/domain/services/TranslationCost.js +4 -4
  58. package/src/domain/services/WormholeService.js +19 -15
  59. package/src/domain/types/TickReceipt.js +10 -6
  60. package/src/domain/types/WarpTypesV2.js +2 -3
  61. package/src/domain/utils/CachedValue.js +1 -1
  62. package/src/domain/utils/LRUCache.js +3 -3
  63. package/src/domain/utils/MinHeap.js +2 -2
  64. package/src/domain/utils/RefLayout.js +19 -0
  65. package/src/domain/utils/WriterId.js +2 -2
  66. package/src/domain/utils/defaultCodec.js +9 -2
  67. package/src/domain/utils/defaultCrypto.js +36 -0
  68. package/src/domain/utils/roaring.js +5 -5
  69. package/src/domain/utils/seekCacheKey.js +32 -0
  70. package/src/domain/warp/PatchSession.js +3 -3
  71. package/src/domain/warp/Writer.js +2 -2
  72. package/src/infrastructure/adapters/BunHttpAdapter.js +21 -8
  73. package/src/infrastructure/adapters/CasSeekCacheAdapter.js +311 -0
  74. package/src/infrastructure/adapters/ClockAdapter.js +2 -2
  75. package/src/infrastructure/adapters/DenoHttpAdapter.js +22 -9
  76. package/src/infrastructure/adapters/GitGraphAdapter.js +25 -83
  77. package/src/infrastructure/adapters/InMemoryGraphAdapter.js +488 -0
  78. package/src/infrastructure/adapters/NodeCryptoAdapter.js +16 -3
  79. package/src/infrastructure/adapters/NodeHttpAdapter.js +33 -11
  80. package/src/infrastructure/adapters/WebCryptoAdapter.js +21 -11
  81. package/src/infrastructure/adapters/adapterValidation.js +90 -0
  82. package/src/infrastructure/codecs/CborCodec.js +16 -8
  83. package/src/ports/BlobPort.js +2 -2
  84. package/src/ports/CodecPort.js +2 -2
  85. package/src/ports/CommitPort.js +8 -21
  86. package/src/ports/ConfigPort.js +3 -3
  87. package/src/ports/CryptoPort.js +7 -7
  88. package/src/ports/GraphPersistencePort.js +12 -14
  89. package/src/ports/HttpServerPort.js +1 -5
  90. package/src/ports/IndexStoragePort.js +1 -0
  91. package/src/ports/LoggerPort.js +9 -9
  92. package/src/ports/RefPort.js +5 -5
  93. package/src/ports/SeekCachePort.js +73 -0
  94. package/src/ports/TreePort.js +3 -3
  95. package/src/visualization/layouts/converters.js +14 -7
  96. package/src/visualization/layouts/elkAdapter.js +17 -4
  97. package/src/visualization/layouts/elkLayout.js +23 -7
  98. package/src/visualization/layouts/index.js +3 -3
  99. package/src/visualization/renderers/ascii/check.js +30 -17
  100. package/src/visualization/renderers/ascii/graph.js +92 -1
  101. package/src/visualization/renderers/ascii/history.js +28 -26
  102. package/src/visualization/renderers/ascii/info.js +9 -7
  103. package/src/visualization/renderers/ascii/materialize.js +20 -16
  104. package/src/visualization/renderers/ascii/opSummary.js +15 -7
  105. package/src/visualization/renderers/ascii/path.js +1 -1
  106. package/src/visualization/renderers/ascii/seek.js +187 -23
  107. package/src/visualization/renderers/ascii/table.js +1 -1
  108. package/src/visualization/renderers/svg/index.js +5 -1
@@ -10,6 +10,10 @@ import { padRight } from '../../utils/unicode.js';
10
10
  import { timeAgo } from '../../utils/time.js';
11
11
  import { TIMELINE } from './symbols.js';
12
12
 
13
+ /**
14
+ * @typedef {{ name: string, writers?: { count?: number, ids?: string[] }, checkpoint?: { sha?: string, date?: string | Date }, coverage?: { sha?: string }, writerPatches?: Record<string, number> }} GraphInfo
15
+ */
16
+
13
17
  // Box drawing characters (info.js uses verbose key names for card rendering)
14
18
  const BOX = {
15
19
  topLeft: '\u250C', // ┌
@@ -77,7 +81,7 @@ function formatWriterNames(writerIds) {
77
81
 
78
82
  /**
79
83
  * Renders the header lines for a graph card.
80
- * @param {Object} graph - Graph info object
84
+ * @param {GraphInfo} graph - Graph info object
81
85
  * @param {number} contentWidth - Available content width
82
86
  * @returns {string[]} Header lines
83
87
  */
@@ -102,7 +106,7 @@ function renderCardHeader(graph, contentWidth) {
102
106
 
103
107
  /**
104
108
  * Renders writer timeline lines for a graph card.
105
- * @param {Object} writerPatches - Map of writerId to patch count
109
+ * @param {Record<string, number> | undefined} writerPatches - Map of writerId to patch count
106
110
  * @param {number} contentWidth - Available content width
107
111
  * @returns {string[]} Timeline lines
108
112
  */
@@ -133,7 +137,7 @@ function renderWriterTimelines(writerPatches, contentWidth) {
133
137
 
134
138
  /**
135
139
  * Renders checkpoint and coverage lines for a graph card.
136
- * @param {Object} graph - Graph info object
140
+ * @param {GraphInfo} graph - Graph info object
137
141
  * @param {number} contentWidth - Available content width
138
142
  * @returns {string[]} Status lines
139
143
  */
@@ -169,7 +173,7 @@ function renderCardStatus(graph, contentWidth) {
169
173
 
170
174
  /**
171
175
  * Renders a single graph card.
172
- * @param {Object} graph - Graph info object
176
+ * @param {GraphInfo} graph - Graph info object
173
177
  * @param {number} innerWidth - Available width inside the card
174
178
  * @returns {string[]} Array of lines for this graph card
175
179
  */
@@ -186,9 +190,7 @@ function renderGraphCard(graph, innerWidth) {
186
190
 
187
191
  /**
188
192
  * Renders the info view with ASCII box art.
189
- * @param {Object} data - Info payload from handleInfo
190
- * @param {string} data.repo - Repository path
191
- * @param {Object[]} data.graphs - Array of graph info objects
193
+ * @param {{ repo?: string, graphs: GraphInfo[] }} data - Info payload from handleInfo
192
194
  * @returns {string} Formatted ASCII output
193
195
  */
194
196
  export function renderInfoView(data) {
@@ -12,6 +12,11 @@ import { padRight } from '../../utils/unicode.js';
12
12
  import { truncate } from '../../utils/truncate.js';
13
13
  import { formatNumber, formatSha } from './formatters.js';
14
14
 
15
+ /**
16
+ * @typedef {{ graph: string, error?: string, noOp?: boolean, patchCount?: number, nodes?: number, edges?: number, properties?: number, writers?: Record<string, number>, checkpoint?: string | null }} GraphResult
17
+ * @typedef {{ maxNodes: number, maxEdges: number, maxProps: number }} MaxValues
18
+ */
19
+
15
20
  // Bar chart settings
16
21
  const BAR_WIDTH = 20;
17
22
  const STAT_LABEL_WIDTH = 12;
@@ -55,15 +60,15 @@ function renderErrorState(errorMessage) {
55
60
 
56
61
  /**
57
62
  * Render no-op state (already materialized).
58
- * @param {Object} graph - Graph data
63
+ * @param {GraphResult} graph - Graph data
59
64
  * @returns {string[]} No-op state lines
60
65
  */
61
66
  function renderNoOpState(graph) {
62
67
  const lines = [
63
68
  ` ${colors.success('\u2713')} Already materialized (no new patches)`,
64
69
  '',
65
- ` ${padRight('Nodes:', STAT_LABEL_WIDTH)} ${formatNumber(graph.nodes)}`,
66
- ` ${padRight('Edges:', STAT_LABEL_WIDTH)} ${formatNumber(graph.edges)}`,
70
+ ` ${padRight('Nodes:', STAT_LABEL_WIDTH)} ${formatNumber(graph.nodes || 0)}`,
71
+ ` ${padRight('Edges:', STAT_LABEL_WIDTH)} ${formatNumber(graph.edges || 0)}`,
67
72
  ];
68
73
  if (typeof graph.properties === 'number') {
69
74
  lines.push(` ${padRight('Properties:', STAT_LABEL_WIDTH)} ${formatNumber(graph.properties)}`);
@@ -73,7 +78,7 @@ function renderNoOpState(graph) {
73
78
 
74
79
  /**
75
80
  * Render empty graph state (0 patches).
76
- * @param {Object} graph - Graph data
81
+ * @param {GraphResult} graph - Graph data
77
82
  * @returns {string[]} Empty state lines
78
83
  */
79
84
  function renderEmptyState(graph) {
@@ -86,7 +91,7 @@ function renderEmptyState(graph) {
86
91
 
87
92
  /**
88
93
  * Render writer progress section.
89
- * @param {Object} writers - Writer patch counts
94
+ * @param {Record<string, number> | undefined} writers - Writer patch counts
90
95
  * @returns {string[]} Writer lines
91
96
  */
92
97
  function renderWriterSection(writers) {
@@ -108,15 +113,15 @@ function renderWriterSection(writers) {
108
113
 
109
114
  /**
110
115
  * Render statistics section with bar charts.
111
- * @param {Object} graph - Graph data
112
- * @param {Object} maxValues - Max values for scaling
116
+ * @param {GraphResult} graph - Graph data
117
+ * @param {MaxValues} maxValues - Max values for scaling
113
118
  * @returns {string[]} Statistics lines
114
119
  */
115
120
  function renderStatsSection(graph, { maxNodes, maxEdges, maxProps }) {
116
121
  const lines = [
117
122
  ` ${colors.dim('Statistics:')}`,
118
- ` ${padRight('Nodes:', STAT_LABEL_WIDTH)} ${statBar(graph.nodes, maxNodes)} ${formatNumber(graph.nodes)}`,
119
- ` ${padRight('Edges:', STAT_LABEL_WIDTH)} ${statBar(graph.edges, maxEdges)} ${formatNumber(graph.edges)}`,
123
+ ` ${padRight('Nodes:', STAT_LABEL_WIDTH)} ${statBar(graph.nodes || 0, maxNodes)} ${formatNumber(graph.nodes || 0)}`,
124
+ ` ${padRight('Edges:', STAT_LABEL_WIDTH)} ${statBar(graph.edges || 0, maxEdges)} ${formatNumber(graph.edges || 0)}`,
120
125
  ];
121
126
  if (typeof graph.properties === 'number') {
122
127
  lines.push(` ${padRight('Properties:', STAT_LABEL_WIDTH)} ${statBar(graph.properties, maxProps)} ${formatNumber(graph.properties)}`);
@@ -139,8 +144,8 @@ function renderCheckpointInfo(checkpoint) {
139
144
 
140
145
  /**
141
146
  * Render a single graph's materialization result.
142
- * @param {Object} graph - Graph result from materialize
143
- * @param {Object} maxValues - Max values for scaling bars
147
+ * @param {GraphResult} graph - Graph result from materialize
148
+ * @param {MaxValues} maxValues - Max values for scaling bars
144
149
  * @returns {string[]} Array of lines for this graph
145
150
  */
146
151
  function renderGraphResult(graph, maxValues) {
@@ -158,14 +163,14 @@ function renderGraphResult(graph, maxValues) {
158
163
 
159
164
  lines.push(...renderWriterSection(graph.writers));
160
165
  lines.push(...renderStatsSection(graph, maxValues));
161
- lines.push(...renderCheckpointInfo(graph.checkpoint));
166
+ lines.push(...renderCheckpointInfo(graph.checkpoint ?? null));
162
167
  return lines;
163
168
  }
164
169
 
165
170
  /**
166
171
  * Calculate max values for scaling bar charts.
167
- * @param {Object[]} graphs - Array of graph results
168
- * @returns {Object} Max values object
172
+ * @param {GraphResult[]} graphs - Array of graph results
173
+ * @returns {MaxValues} Max values object
169
174
  */
170
175
  function calculateMaxValues(graphs) {
171
176
  const successfulGraphs = graphs.filter((g) => !g.error);
@@ -212,8 +217,7 @@ function getBorderColor(successCount, errorCount) {
212
217
 
213
218
  /**
214
219
  * Render the materialize view dashboard.
215
- * @param {Object} payload - The materialize command payload
216
- * @param {Object[]} payload.graphs - Array of graph results
220
+ * @param {{ graphs: GraphResult[] }} payload - The materialize command payload
217
221
  * @returns {string} Formatted dashboard string
218
222
  */
219
223
  export function renderMaterializeView(payload) {
@@ -8,6 +8,11 @@
8
8
  import { colors } from './colors.js';
9
9
  import { truncate } from '../../utils/truncate.js';
10
10
 
11
+ /**
12
+ * @typedef {'NodeAdd' | 'EdgeAdd' | 'PropSet' | 'NodeTombstone' | 'EdgeTombstone' | 'BlobValue'} OpType
13
+ * @typedef {Record<OpType, number>} OpSummary
14
+ */
15
+
11
16
  // Operation type to display info mapping
12
17
  export const OP_DISPLAY = Object.freeze({
13
18
  NodeAdd: { symbol: '+', label: 'node', color: colors.success },
@@ -30,14 +35,16 @@ export const EMPTY_OP_SUMMARY = Object.freeze({
30
35
 
31
36
  /**
32
37
  * Summarizes operations in a patch.
33
- * @param {Object[]} ops - Array of patch operations
34
- * @returns {Object} Summary with counts by operation type
38
+ * @param {Array<{ type: string }>} ops - Array of patch operations
39
+ * @returns {OpSummary} Summary with counts by operation type
35
40
  */
36
41
  export function summarizeOps(ops) {
42
+ /** @type {OpSummary} */
37
43
  const summary = { ...EMPTY_OP_SUMMARY };
38
44
  for (const op of ops) {
39
- if (op.type && summary[op.type] !== undefined) {
40
- summary[op.type]++;
45
+ const t = /** @type {OpType} */ (op.type);
46
+ if (t && summary[t] !== undefined) {
47
+ summary[t]++;
41
48
  }
42
49
  }
43
50
  return summary;
@@ -45,17 +52,18 @@ export function summarizeOps(ops) {
45
52
 
46
53
  /**
47
54
  * Formats operation summary as a colored string.
48
- * @param {Object} summary - Operation counts by type
55
+ * @param {OpSummary | Record<string, number>} summary - Operation counts by type
49
56
  * @param {number} maxWidth - Maximum width for the summary string
50
57
  * @returns {string} Formatted summary string
51
58
  */
52
59
  export function formatOpSummary(summary, maxWidth = 40) {
60
+ /** @type {OpType[]} */
53
61
  const order = ['NodeAdd', 'EdgeAdd', 'PropSet', 'NodeTombstone', 'EdgeTombstone', 'BlobValue'];
54
62
  const parts = order
55
- .filter((opType) => summary[opType] > 0)
63
+ .filter((opType) => (/** @type {Record<string, number>} */ (summary))[opType] > 0)
56
64
  .map((opType) => {
57
65
  const display = OP_DISPLAY[opType];
58
- return { text: `${display.symbol}${summary[opType]}${display.label}`, color: display.color };
66
+ return { text: `${display.symbol}${(/** @type {Record<string, number>} */ (summary))[opType]}${display.label}`, color: display.color };
59
67
  });
60
68
 
61
69
  if (parts.length === 0) {
@@ -75,7 +75,7 @@ function createPathSegment({ nodeId, index, pathLength, edges }) {
75
75
  * Builds path segments that fit within the terminal width.
76
76
  * Wraps long paths to multiple lines.
77
77
  * @param {string[]} path - Array of node IDs
78
- * @param {string[]} [edges] - Optional array of edge labels (one fewer than nodes)
78
+ * @param {string[] | undefined} edges - Optional array of edge labels (one fewer than nodes)
79
79
  * @param {number} maxWidth - Maximum line width
80
80
  * @returns {string[]} Array of line strings
81
81
  */
@@ -15,6 +15,30 @@ import { formatSha, formatWriterName } from './formatters.js';
15
15
  import { TIMELINE } from './symbols.js';
16
16
  import { formatOpSummary } from './opSummary.js';
17
17
 
18
+ /**
19
+ * @typedef {{ ticks: number[], tipSha?: string, tickShas?: Record<number, string> }} WriterInfo
20
+ */
21
+
22
+ /**
23
+ * @typedef {Object} SeekPayload
24
+ * @property {string} graph
25
+ * @property {number} tick
26
+ * @property {number} maxTick
27
+ * @property {number[]} ticks
28
+ * @property {number} nodes
29
+ * @property {number} edges
30
+ * @property {number} patchCount
31
+ * @property {Map<string, WriterInfo> | Record<string, WriterInfo>} perWriter
32
+ * @property {{ nodes?: number, edges?: number }} [diff]
33
+ * @property {Record<string, any>} [tickReceipt]
34
+ * @property {import('../../../domain/services/StateDiff.js').StateDiffResult | null} [structuralDiff]
35
+ * @property {string} [diffBaseline]
36
+ * @property {number | null} [baselineTick]
37
+ * @property {boolean} [truncated]
38
+ * @property {number} [totalChanges]
39
+ * @property {number} [shownChanges]
40
+ */
41
+
18
42
  /** Maximum number of tick columns shown in the windowed view. */
19
43
  const MAX_COLS = 9;
20
44
 
@@ -30,6 +54,7 @@ const DOT_MID = '\u00B7'; // ·
30
54
  /** Open circle used for excluded-zone patch markers. */
31
55
  const CIRCLE_OPEN = '\u25CB'; // ○
32
56
 
57
+ /** @param {number} n @returns {string} */
33
58
  function formatDelta(n) {
34
59
  if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) {
35
60
  return '';
@@ -38,10 +63,17 @@ function formatDelta(n) {
38
63
  return ` (${sign}${n})`;
39
64
  }
40
65
 
66
+ /**
67
+ * @param {number} n
68
+ * @param {string} singular
69
+ * @param {string} plural
70
+ * @returns {string}
71
+ */
41
72
  function pluralize(n, singular, plural) {
42
73
  return n === 1 ? singular : plural;
43
74
  }
44
75
 
76
+ /** @param {Record<string, any> | undefined} tickReceipt @returns {string[]} */
45
77
  function buildReceiptLines(tickReceipt) {
46
78
  if (!tickReceipt || typeof tickReceipt !== 'object') {
47
79
  return [];
@@ -200,7 +232,7 @@ function buildLane(patchSet, points, currentTick) {
200
232
  *
201
233
  * @param {Object} opts
202
234
  * @param {string} opts.writerId
203
- * @param {Object} opts.writerInfo - `{ ticks, tipSha, tickShas }`
235
+ * @param {WriterInfo} opts.writerInfo - `{ ticks, tipSha, tickShas }`
204
236
  * @param {{ points: number[] }} opts.win - Computed window
205
237
  * @param {number} opts.currentTick - Active seek cursor tick
206
238
  * @returns {string} Formatted, indented swimlane line
@@ -252,14 +284,45 @@ function buildTickPoints(ticks, tick) {
252
284
  return { allPoints, currentIdx };
253
285
  }
254
286
 
287
+ // ============================================================================
288
+ // Structural Diff
289
+ // ============================================================================
290
+
291
+ /** Maximum structural diff lines shown in ASCII view. */
292
+ const MAX_DIFF_LINES = 20;
293
+
255
294
  /**
256
- * Builds the body lines for the seek dashboard.
257
- *
258
- * @param {Object} payload - Seek payload from the CLI handler
259
- * @returns {string[]} Lines for the box body
295
+ * Builds the state summary, receipt, and structural diff footer lines.
296
+ * @param {SeekPayload} payload
297
+ * @returns {string[]}
260
298
  */
299
+ function buildFooterLines(payload) {
300
+ const { tick, nodes, edges, patchCount, diff, tickReceipt } = payload;
301
+ const lines = [];
302
+ lines.push('');
303
+ const nodesStr = `${nodes} ${pluralize(nodes, 'node', 'nodes')}${formatDelta(diff?.nodes ?? 0)}`;
304
+ const edgesStr = `${edges} ${pluralize(edges, 'edge', 'edges')}${formatDelta(diff?.edges ?? 0)}`;
305
+ lines.push(` ${colors.bold('State:')} ${nodesStr}, ${edgesStr}, ${patchCount} ${pluralize(patchCount, 'patch', 'patches')}`);
306
+
307
+ const receiptLines = buildReceiptLines(tickReceipt);
308
+ if (receiptLines.length > 0) {
309
+ lines.push('');
310
+ lines.push(` ${colors.bold(`Tick ${tick}:`)}`);
311
+ lines.push(...receiptLines);
312
+ }
313
+
314
+ const sdLines = buildStructuralDiffLines(payload, MAX_DIFF_LINES);
315
+ if (sdLines.length > 0) {
316
+ lines.push('');
317
+ lines.push(...sdLines);
318
+ }
319
+ lines.push('');
320
+ return lines;
321
+ }
322
+
323
+ /** @param {SeekPayload} payload @returns {string[]} */
261
324
  function buildSeekBodyLines(payload) {
262
- const { graph, tick, maxTick, ticks, nodes, edges, patchCount, perWriter, diff, tickReceipt } = payload;
325
+ const { graph, tick, maxTick, ticks, perWriter } = payload;
263
326
  const lines = [];
264
327
 
265
328
  lines.push('');
@@ -272,11 +335,9 @@ function buildSeekBodyLines(payload) {
272
335
  } else {
273
336
  const { allPoints, currentIdx } = buildTickPoints(ticks, tick);
274
337
  const win = computeWindow(allPoints, currentIdx);
275
-
276
- // Column headers with relative offsets
277
338
  lines.push(buildHeaderRow(win));
278
339
 
279
- // Per-writer swimlanes
340
+ /** @type {Array<[string, WriterInfo]>} */
280
341
  const writerEntries = perWriter instanceof Map
281
342
  ? [...perWriter.entries()]
282
343
  : Object.entries(perWriter).map(([k, v]) => [k, v]);
@@ -286,34 +347,137 @@ function buildSeekBodyLines(payload) {
286
347
  }
287
348
  }
288
349
 
289
- lines.push('');
290
- const edgeLabel = pluralize(edges, 'edge', 'edges');
291
- const nodeLabel = pluralize(nodes, 'node', 'nodes');
292
- const patchLabel = pluralize(patchCount, 'patch', 'patches');
350
+ lines.push(...buildFooterLines(payload));
351
+ return lines;
352
+ }
293
353
 
294
- const nodesStr = `${nodes} ${nodeLabel}${formatDelta(diff?.nodes)}`;
295
- const edgesStr = `${edges} ${edgeLabel}${formatDelta(diff?.edges)}`;
296
- lines.push(` ${colors.bold('State:')} ${nodesStr}, ${edgesStr}, ${patchCount} ${patchLabel}`);
354
+ /**
355
+ * Builds a truncation hint line when entries exceed the display or data limit.
356
+ * @param {{totalEntries: number, shown: number, maxLines: number, truncated?: boolean, totalChanges?: number, shownChanges?: number}} opts
357
+ * @returns {string|null}
358
+ */
359
+ function buildTruncationHint(opts) {
360
+ const { totalEntries, shown, maxLines, truncated, totalChanges, shownChanges } = opts;
361
+ if (totalEntries > maxLines && truncated) {
362
+ const remaining = Math.max(0, (totalChanges || 0) - shown);
363
+ return `... and ${remaining} more changes (${totalChanges} total, use --diff-limit to increase)`;
364
+ }
365
+ if (totalEntries > maxLines) {
366
+ return `... and ${Math.max(0, totalEntries - maxLines)} more changes`;
367
+ }
368
+ if (truncated) {
369
+ return `... and ${Math.max(0, (totalChanges || 0) - (shownChanges || 0))} more changes (use --diff-limit to increase)`;
370
+ }
371
+ return null;
372
+ }
297
373
 
298
- const receiptLines = buildReceiptLines(tickReceipt);
299
- if (receiptLines.length > 0) {
300
- lines.push('');
301
- lines.push(` ${colors.bold(`Tick ${tick}:`)}`);
302
- lines.push(...receiptLines);
374
+ /**
375
+ * @param {SeekPayload} payload
376
+ * @param {number} maxLines
377
+ * @returns {string[]}
378
+ */
379
+ function buildStructuralDiffLines(payload, maxLines) {
380
+ const { structuralDiff, diffBaseline, baselineTick, truncated, totalChanges, shownChanges } = payload;
381
+ if (!structuralDiff) {
382
+ return [];
383
+ }
384
+
385
+ const lines = [];
386
+ const baselineLabel = diffBaseline === 'tick'
387
+ ? `baseline: tick ${baselineTick}`
388
+ : 'baseline: empty';
389
+
390
+ lines.push(` ${colors.bold(`Changes (${baselineLabel}):`)}`);
391
+
392
+ let shown = 0;
393
+ const entries = collectDiffEntries(structuralDiff);
394
+
395
+ for (const entry of entries) {
396
+ if (shown >= maxLines) {
397
+ break;
398
+ }
399
+ lines.push(` ${entry}`);
400
+ shown++;
401
+ }
402
+
403
+ const hint = buildTruncationHint({ totalEntries: entries.length, shown, maxLines, truncated, totalChanges, shownChanges });
404
+ if (hint) {
405
+ lines.push(` ${colors.muted(hint)}`);
303
406
  }
304
- lines.push('');
305
407
 
306
408
  return lines;
307
409
  }
308
410
 
411
+ /**
412
+ * Collects formatted diff entries from a structural diff result.
413
+ *
414
+ * @param {import('../../../domain/services/StateDiff.js').StateDiffResult} diff
415
+ * @returns {string[]} Formatted entries with +/-/~ prefixes
416
+ */
417
+ function collectDiffEntries(diff) {
418
+ const entries = [];
419
+
420
+ for (const nodeId of diff.nodes.added) {
421
+ entries.push(colors.success(`+ node ${nodeId}`));
422
+ }
423
+ for (const nodeId of diff.nodes.removed) {
424
+ entries.push(colors.error(`- node ${nodeId}`));
425
+ }
426
+ for (const edge of diff.edges.added) {
427
+ entries.push(colors.success(`+ edge ${edge.from} -[${edge.label}]-> ${edge.to}`));
428
+ }
429
+ for (const edge of diff.edges.removed) {
430
+ entries.push(colors.error(`- edge ${edge.from} -[${edge.label}]-> ${edge.to}`));
431
+ }
432
+ for (const prop of diff.props.set) {
433
+ const old = prop.oldValue !== undefined ? formatPropValue(prop.oldValue) : null;
434
+ const arrow = old !== null ? `${old} -> ${formatPropValue(prop.newValue)}` : formatPropValue(prop.newValue);
435
+ entries.push(colors.warning(`~ ${prop.nodeId}.${prop.propKey}: ${arrow}`));
436
+ }
437
+ for (const prop of diff.props.removed) {
438
+ entries.push(colors.error(`- ${prop.nodeId}.${prop.propKey}: ${formatPropValue(prop.oldValue)}`));
439
+ }
440
+
441
+ return entries;
442
+ }
443
+
444
+ /**
445
+ * Formats a property value for display (truncated if too long).
446
+ * @param {*} value
447
+ * @returns {string}
448
+ */
449
+ function formatPropValue(value) {
450
+ if (value === undefined) {
451
+ return 'undefined';
452
+ }
453
+ const s = typeof value === 'string' ? `"${value}"` : String(value);
454
+ return s.length > 40 ? `${s.slice(0, 37)}...` : s;
455
+ }
456
+
309
457
  // ============================================================================
310
458
  // Public API
311
459
  // ============================================================================
312
460
 
461
+ /**
462
+ * Formats a structural diff as a plain-text string (no boxen).
463
+ *
464
+ * Used by the non-view renderSeek() path in the CLI.
465
+ *
466
+ * @param {SeekPayload} payload - Seek payload containing structuralDiff
467
+ * @returns {string} Formatted diff section, or empty string if no diff
468
+ */
469
+ export function formatStructuralDiff(payload) {
470
+ const lines = buildStructuralDiffLines(payload, MAX_DIFF_LINES);
471
+ if (lines.length === 0) {
472
+ return '';
473
+ }
474
+ return `${lines.join('\n')}\n`;
475
+ }
476
+
313
477
  /**
314
478
  * Renders the seek view dashboard inside a double-bordered box.
315
479
  *
316
- * @param {Object} payload - Seek payload from the CLI handler
480
+ * @param {SeekPayload} payload - Seek payload from the CLI handler
317
481
  * @returns {string} Boxen-wrapped ASCII dashboard with trailing newline
318
482
  */
319
483
  export function renderSeekView(payload) {
@@ -6,7 +6,7 @@ import Table from 'cli-table3';
6
6
  * @param {Object} [options] - Options forwarded to cli-table3 constructor
7
7
  * @param {string[]} [options.head] - Column headers
8
8
  * @param {Object} [options.style] - Style overrides (defaults: head=cyan, border=gray)
9
- * @returns {import('cli-table3')} A cli-table3 instance
9
+ * @returns {InstanceType<typeof Table>} A cli-table3 instance
10
10
  */
11
11
  export function createTable(options = {}) {
12
12
  const defaultStyle = { head: ['cyan'], border: ['gray'] };
@@ -16,6 +16,7 @@ const PALETTE = {
16
16
  arrowFill: '#a6adc8',
17
17
  };
18
18
 
19
+ /** @param {string} str @returns {string} */
19
20
  function escapeXml(str) {
20
21
  return String(str)
21
22
  .replace(/&/g, '&amp;')
@@ -46,6 +47,7 @@ function renderStyle() {
46
47
  ].join('\n');
47
48
  }
48
49
 
50
+ /** @param {{ id: string, x: number, y: number, width: number, height: number, label?: string }} node @returns {string} */
49
51
  function renderNode(node) {
50
52
  const { x, y, width, height } = node;
51
53
  const label = escapeXml(node.label ?? node.id);
@@ -59,6 +61,7 @@ function renderNode(node) {
59
61
  ].join('\n');
60
62
  }
61
63
 
64
+ /** @param {{ startPoint?: { x: number, y: number }, bendPoints?: Array<{ x: number, y: number }>, endPoint?: { x: number, y: number } }} section @returns {Array<{ x: number, y: number }>} */
62
65
  function sectionToPoints(section) {
63
66
  const pts = [];
64
67
  if (section.startPoint) {
@@ -73,6 +76,7 @@ function sectionToPoints(section) {
73
76
  return pts;
74
77
  }
75
78
 
79
+ /** @param {{ sections?: Array<{ startPoint?: { x: number, y: number }, bendPoints?: Array<{ x: number, y: number }>, endPoint?: { x: number, y: number } }>, label?: string }} edge @returns {string} */
76
80
  function renderEdge(edge) {
77
81
  const { sections } = edge;
78
82
  if (!sections || sections.length === 0) {
@@ -115,7 +119,7 @@ function renderEdge(edge) {
115
119
  /**
116
120
  * Renders a PositionedGraph as an SVG string.
117
121
  *
118
- * @param {Object} positionedGraph - PositionedGraph from runLayout()
122
+ * @param {{ nodes?: Array<{ id: string, x: number, y: number, width: number, height: number, label?: string }>, edges?: Array<{ sections?: Array<any>, label?: string }>, width?: number, height?: number }} positionedGraph - PositionedGraph from runLayout()
119
123
  * @param {{ title?: string }} [options]
120
124
  * @returns {string} Complete SVG markup
121
125
  */