@git-stunts/git-warp 10.1.2 → 10.4.2

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 (106) hide show
  1. package/README.md +31 -4
  2. package/bin/warp-graph.js +1242 -59
  3. package/index.d.ts +31 -0
  4. package/index.js +4 -0
  5. package/package.json +13 -3
  6. package/src/domain/WarpGraph.js +487 -140
  7. package/src/domain/crdt/LWW.js +1 -1
  8. package/src/domain/crdt/ORSet.js +10 -6
  9. package/src/domain/crdt/VersionVector.js +5 -1
  10. package/src/domain/errors/EmptyMessageError.js +2 -4
  11. package/src/domain/errors/ForkError.js +4 -0
  12. package/src/domain/errors/IndexError.js +4 -0
  13. package/src/domain/errors/OperationAbortedError.js +4 -0
  14. package/src/domain/errors/QueryError.js +4 -0
  15. package/src/domain/errors/SchemaUnsupportedError.js +4 -0
  16. package/src/domain/errors/ShardCorruptionError.js +2 -6
  17. package/src/domain/errors/ShardLoadError.js +2 -6
  18. package/src/domain/errors/ShardValidationError.js +2 -7
  19. package/src/domain/errors/StorageError.js +2 -6
  20. package/src/domain/errors/SyncError.js +4 -0
  21. package/src/domain/errors/TraversalError.js +4 -0
  22. package/src/domain/errors/WarpError.js +2 -4
  23. package/src/domain/errors/WormholeError.js +4 -0
  24. package/src/domain/services/AnchorMessageCodec.js +1 -4
  25. package/src/domain/services/BitmapIndexBuilder.js +10 -6
  26. package/src/domain/services/BitmapIndexReader.js +27 -21
  27. package/src/domain/services/BoundaryTransitionRecord.js +22 -15
  28. package/src/domain/services/CheckpointMessageCodec.js +1 -7
  29. package/src/domain/services/CheckpointSerializerV5.js +20 -19
  30. package/src/domain/services/CheckpointService.js +18 -18
  31. package/src/domain/services/CommitDagTraversalService.js +13 -1
  32. package/src/domain/services/DagPathFinding.js +40 -18
  33. package/src/domain/services/DagTopology.js +7 -6
  34. package/src/domain/services/DagTraversal.js +5 -3
  35. package/src/domain/services/Frontier.js +7 -6
  36. package/src/domain/services/HealthCheckService.js +15 -14
  37. package/src/domain/services/HookInstaller.js +64 -13
  38. package/src/domain/services/HttpSyncServer.js +15 -14
  39. package/src/domain/services/IndexRebuildService.js +12 -12
  40. package/src/domain/services/IndexStalenessChecker.js +13 -6
  41. package/src/domain/services/JoinReducer.js +28 -27
  42. package/src/domain/services/LogicalTraversal.js +7 -6
  43. package/src/domain/services/MessageCodecInternal.js +2 -0
  44. package/src/domain/services/ObserverView.js +6 -6
  45. package/src/domain/services/PatchBuilderV2.js +9 -9
  46. package/src/domain/services/PatchMessageCodec.js +1 -7
  47. package/src/domain/services/ProvenanceIndex.js +6 -8
  48. package/src/domain/services/ProvenancePayload.js +1 -2
  49. package/src/domain/services/QueryBuilder.js +29 -23
  50. package/src/domain/services/StateDiff.js +7 -7
  51. package/src/domain/services/StateSerializerV5.js +8 -6
  52. package/src/domain/services/StreamingBitmapIndexBuilder.js +29 -23
  53. package/src/domain/services/SyncProtocol.js +23 -26
  54. package/src/domain/services/TemporalQuery.js +4 -3
  55. package/src/domain/services/TranslationCost.js +4 -4
  56. package/src/domain/services/WormholeService.js +19 -15
  57. package/src/domain/types/TickReceipt.js +10 -6
  58. package/src/domain/types/WarpTypesV2.js +2 -3
  59. package/src/domain/utils/CachedValue.js +1 -1
  60. package/src/domain/utils/LRUCache.js +3 -3
  61. package/src/domain/utils/MinHeap.js +2 -2
  62. package/src/domain/utils/RefLayout.js +106 -15
  63. package/src/domain/utils/WriterId.js +2 -2
  64. package/src/domain/utils/defaultCodec.js +9 -2
  65. package/src/domain/utils/defaultCrypto.js +36 -0
  66. package/src/domain/utils/parseCursorBlob.js +51 -0
  67. package/src/domain/utils/roaring.js +5 -5
  68. package/src/domain/utils/seekCacheKey.js +32 -0
  69. package/src/domain/warp/PatchSession.js +3 -3
  70. package/src/domain/warp/Writer.js +2 -2
  71. package/src/infrastructure/adapters/BunHttpAdapter.js +21 -8
  72. package/src/infrastructure/adapters/CasSeekCacheAdapter.js +311 -0
  73. package/src/infrastructure/adapters/ClockAdapter.js +2 -2
  74. package/src/infrastructure/adapters/DenoHttpAdapter.js +22 -9
  75. package/src/infrastructure/adapters/GitGraphAdapter.js +16 -27
  76. package/src/infrastructure/adapters/NodeCryptoAdapter.js +16 -3
  77. package/src/infrastructure/adapters/NodeHttpAdapter.js +33 -11
  78. package/src/infrastructure/adapters/WebCryptoAdapter.js +21 -11
  79. package/src/infrastructure/codecs/CborCodec.js +16 -8
  80. package/src/ports/BlobPort.js +2 -2
  81. package/src/ports/CodecPort.js +2 -2
  82. package/src/ports/CommitPort.js +8 -21
  83. package/src/ports/ConfigPort.js +3 -3
  84. package/src/ports/CryptoPort.js +7 -7
  85. package/src/ports/GraphPersistencePort.js +12 -14
  86. package/src/ports/HttpServerPort.js +1 -5
  87. package/src/ports/IndexStoragePort.js +1 -0
  88. package/src/ports/LoggerPort.js +9 -9
  89. package/src/ports/RefPort.js +5 -5
  90. package/src/ports/SeekCachePort.js +73 -0
  91. package/src/ports/TreePort.js +3 -3
  92. package/src/visualization/layouts/converters.js +14 -7
  93. package/src/visualization/layouts/elkAdapter.js +24 -11
  94. package/src/visualization/layouts/elkLayout.js +23 -7
  95. package/src/visualization/layouts/index.js +3 -3
  96. package/src/visualization/renderers/ascii/check.js +30 -17
  97. package/src/visualization/renderers/ascii/graph.js +122 -16
  98. package/src/visualization/renderers/ascii/history.js +29 -90
  99. package/src/visualization/renderers/ascii/index.js +1 -1
  100. package/src/visualization/renderers/ascii/info.js +9 -7
  101. package/src/visualization/renderers/ascii/materialize.js +20 -16
  102. package/src/visualization/renderers/ascii/opSummary.js +81 -0
  103. package/src/visualization/renderers/ascii/path.js +1 -1
  104. package/src/visualization/renderers/ascii/seek.js +344 -0
  105. package/src/visualization/renderers/ascii/table.js +1 -1
  106. package/src/visualization/renderers/svg/index.js +5 -1
@@ -2,24 +2,34 @@
2
2
  * ELK adapter: converts normalised graph data into ELK JSON input.
3
3
  */
4
4
 
5
+ /**
6
+ * @typedef {{ id: string, label: string, props?: Record<string, any> }} GraphDataNode
7
+ * @typedef {{ from: string, to: string, label?: string }} GraphDataEdge
8
+ * @typedef {{ nodes: GraphDataNode[], edges: GraphDataEdge[] }} GraphData
9
+ * @typedef {{ text: string }} ElkLabel
10
+ * @typedef {{ id: string, sources: string[], targets: string[], labels?: ElkLabel[] }} ElkEdge
11
+ * @typedef {{ id: string, width: number, height: number, labels: ElkLabel[] }} ElkChild
12
+ * @typedef {{ id: string, layoutOptions: Record<string, string>, children: ElkChild[], edges: ElkEdge[] }} ElkGraph
13
+ */
14
+
5
15
  const LAYOUT_PRESETS = {
6
16
  query: {
7
17
  'elk.algorithm': 'layered',
8
18
  'elk.direction': 'DOWN',
9
- 'elk.spacing.nodeNode': '40',
10
- 'elk.layered.spacing.nodeNodeBetweenLayers': '60',
19
+ 'elk.spacing.nodeNode': '30',
20
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '40',
11
21
  },
12
22
  path: {
13
23
  'elk.algorithm': 'layered',
14
24
  'elk.direction': 'RIGHT',
15
- 'elk.spacing.nodeNode': '40',
16
- 'elk.layered.spacing.nodeNodeBetweenLayers': '60',
25
+ 'elk.spacing.nodeNode': '30',
26
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '40',
17
27
  },
18
28
  slice: {
19
29
  'elk.algorithm': 'layered',
20
30
  'elk.direction': 'DOWN',
21
- 'elk.spacing.nodeNode': '40',
22
- 'elk.layered.spacing.nodeNodeBetweenLayers': '60',
31
+ 'elk.spacing.nodeNode': '30',
32
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '40',
23
33
  },
24
34
  };
25
35
 
@@ -29,7 +39,7 @@ const DEFAULT_PRESET = LAYOUT_PRESETS.query;
29
39
  * Returns ELK layout options for a given visualisation type.
30
40
  *
31
41
  * @param {'query'|'path'|'slice'} type
32
- * @returns {Object} ELK layout options
42
+ * @returns {Record<string, string>} ELK layout options
33
43
  */
34
44
  export function getDefaultLayoutOptions(type) {
35
45
  return LAYOUT_PRESETS[type] ?? DEFAULT_PRESET;
@@ -38,6 +48,8 @@ export function getDefaultLayoutOptions(type) {
38
48
  /**
39
49
  * Estimates pixel width for a node label.
40
50
  * Approximates monospace glyph width at ~9px with 24px padding.
51
+ * @param {string | undefined} label
52
+ * @returns {number}
41
53
  */
42
54
  function estimateNodeWidth(label) {
43
55
  const charWidth = 9;
@@ -46,14 +58,14 @@ function estimateNodeWidth(label) {
46
58
  return Math.max((label?.length ?? 0) * charWidth + padding, minWidth);
47
59
  }
48
60
 
49
- const NODE_HEIGHT = 40;
61
+ const NODE_HEIGHT = 30;
50
62
 
51
63
  /**
52
64
  * Converts normalised graph data to an ELK graph JSON object.
53
65
  *
54
- * @param {{ nodes: Array, edges: Array }} graphData
55
- * @param {{ type?: string, layoutOptions?: Object }} [options]
56
- * @returns {Object} ELK-format graph
66
+ * @param {GraphData} graphData
67
+ * @param {{ type?: 'query'|'path'|'slice', layoutOptions?: Record<string, string> }} [options]
68
+ * @returns {ElkGraph} ELK-format graph
57
69
  */
58
70
  export function toElkGraph(graphData, options = {}) {
59
71
  const { type = 'query', layoutOptions } = options;
@@ -66,6 +78,7 @@ export function toElkGraph(graphData, options = {}) {
66
78
  }));
67
79
 
68
80
  const edges = (graphData.edges ?? []).map((e, i) => {
81
+ /** @type {ElkEdge} */
69
82
  const edge = {
70
83
  id: `e${i}`,
71
84
  sources: [e.from],
@@ -5,11 +5,22 @@
5
5
  * a layout is actually requested, keeping normal CLI startup fast.
6
6
  */
7
7
 
8
+ /**
9
+ * @typedef {{ id: string, x?: number, y?: number, width?: number, height?: number, labels?: Array<{ text: string }> }} ElkResultChild
10
+ * @typedef {{ id: string, sources?: string[], targets?: string[], labels?: Array<{ text: string }>, sections?: any[] }} ElkResultEdge
11
+ * @typedef {{ children?: ElkResultChild[], edges?: ElkResultEdge[], width?: number, height?: number }} ElkResult
12
+ * @typedef {{ id: string, x: number, y: number, width: number, height: number, label: string }} PosNode
13
+ * @typedef {{ id: string, source: string, target: string, label?: string, sections: any[] }} PosEdge
14
+ * @typedef {{ nodes: PosNode[], edges: PosEdge[], width: number, height: number }} PositionedGraph
15
+ * @typedef {{ id: string, children?: Array<{ id: string, width?: number, height?: number, labels?: Array<{ text: string }> }>, edges?: Array<{ id: string, sources?: string[], targets?: string[], labels?: Array<{ text: string }> }>, layoutOptions?: Record<string, string> }} ElkGraphInput
16
+ */
17
+
18
+ /** @type {Promise<any> | null} */
8
19
  let elkPromise = null;
9
20
 
10
21
  /**
11
22
  * Returns (or creates) a singleton ELK instance.
12
- * @returns {Promise<Object>} ELK instance
23
+ * @returns {Promise<any>} ELK instance
13
24
  */
14
25
  function getElk() {
15
26
  if (!elkPromise) {
@@ -21,10 +32,11 @@ function getElk() {
21
32
  /**
22
33
  * Runs ELK layout on a graph and returns a PositionedGraph.
23
34
  *
24
- * @param {Object} elkGraph - ELK-format graph from toElkGraph()
25
- * @returns {Promise<Object>} PositionedGraph
35
+ * @param {ElkGraphInput} elkGraph - ELK-format graph from toElkGraph()
36
+ * @returns {Promise<PositionedGraph>} PositionedGraph
26
37
  */
27
38
  export async function runLayout(elkGraph) {
39
+ /** @type {ElkResult | undefined} */
28
40
  let result;
29
41
  try {
30
42
  const elk = await getElk();
@@ -37,9 +49,11 @@ export async function runLayout(elkGraph) {
37
49
 
38
50
  /**
39
51
  * Converts ELK output to a PositionedGraph.
52
+ * @param {ElkResult | undefined} result
53
+ * @returns {PositionedGraph}
40
54
  */
41
55
  function toPositionedGraph(result) {
42
- const nodes = (result.children ?? []).map((c) => ({
56
+ const nodes = (result?.children ?? []).map((c) => ({
43
57
  id: c.id,
44
58
  x: c.x ?? 0,
45
59
  y: c.y ?? 0,
@@ -48,7 +62,7 @@ function toPositionedGraph(result) {
48
62
  label: c.labels?.[0]?.text ?? c.id,
49
63
  }));
50
64
 
51
- const edges = (result.edges ?? []).map((e) => ({
65
+ const edges = (result?.edges ?? []).map((e) => ({
52
66
  id: e.id,
53
67
  source: e.sources?.[0] ?? '',
54
68
  target: e.targets?.[0] ?? '',
@@ -59,13 +73,15 @@ function toPositionedGraph(result) {
59
73
  return {
60
74
  nodes,
61
75
  edges,
62
- width: result.width ?? 0,
63
- height: result.height ?? 0,
76
+ width: result?.width ?? 0,
77
+ height: result?.height ?? 0,
64
78
  };
65
79
  }
66
80
 
67
81
  /**
68
82
  * Fallback: line nodes up horizontally when ELK fails.
83
+ * @param {ElkGraphInput} elkGraph
84
+ * @returns {PositionedGraph}
69
85
  */
70
86
  function fallbackLayout(elkGraph) {
71
87
  let x = 20;
@@ -19,9 +19,9 @@ import { runLayout } from './elkLayout.js';
19
19
  /**
20
20
  * Full pipeline: graphData → PositionedGraph.
21
21
  *
22
- * @param {{ nodes: Array, edges: Array }} graphData - Normalised graph data
23
- * @param {{ type?: string, layoutOptions?: Object }} [options]
24
- * @returns {Promise<Object>} PositionedGraph
22
+ * @param {{ nodes: Array<{ id: string, label: string }>, edges: Array<{ from: string, to: string, label?: string }> }} graphData - Normalised graph data
23
+ * @param {{ type?: 'query'|'path'|'slice', layoutOptions?: Record<string, string> }} [options]
24
+ * @returns {Promise<{ nodes: any[], edges: any[], width: number, height: number }>} PositionedGraph
25
25
  */
26
26
  export async function layoutGraph(graphData, options = {}) {
27
27
  const elkGraph = toElkGraph(graphData, options);
@@ -12,6 +12,18 @@ import { colors } from './colors.js';
12
12
  import { padRight } from '../../utils/unicode.js';
13
13
  import { formatAge } from './formatters.js';
14
14
 
15
+ /**
16
+ * @typedef {{ cachedState?: string, tombstoneRatio?: number, patchesSinceCheckpoint?: number }} CheckStatus
17
+ * @typedef {{ writerId?: string, sha?: string }} WriterHead
18
+ * @typedef {{ sha?: string, ageSeconds?: number | null }} CheckpointInfo
19
+ * @typedef {{ installed?: boolean, foreign?: boolean, current?: boolean, version?: string }} HookInfo
20
+ * @typedef {{ sha?: string, missingWriters?: string[] }} CoverageInfo
21
+ * @typedef {{ status?: string }} HealthInfo
22
+ * @typedef {{ tombstoneRatio?: number }} GCInfo
23
+ * @typedef {{ heads?: WriterHead[] }} WritersInfo
24
+ * @typedef {{ graph: string, health: HealthInfo, status: CheckStatus, writers: WritersInfo, checkpoint: CheckpointInfo, coverage: CoverageInfo, gc: GCInfo, hook: HookInfo | null }} CheckPayload
25
+ */
26
+
15
27
  // Health thresholds
16
28
  const TOMBSTONE_HEALTHY_MAX = 0.15; // < 15% tombstones = healthy
17
29
  const TOMBSTONE_WARNING_MAX = 0.30; // < 30% tombstones = warning
@@ -19,7 +31,7 @@ const CACHE_STALE_PENALTY = 20; // Reduce "freshness" score for stale ca
19
31
 
20
32
  /**
21
33
  * Get cache freshness percentage and state.
22
- * @param {Object} status - The status object from check payload
34
+ * @param {CheckStatus | null} status - The status object from check payload
23
35
  * @returns {{ percent: number, label: string }}
24
36
  */
25
37
  function getCacheFreshness(status) {
@@ -78,7 +90,7 @@ function tombstoneBar(percent, width = 20) {
78
90
 
79
91
  /**
80
92
  * Format writer information for display.
81
- * @param {Object[]} heads - Writer heads array
93
+ * @param {WriterHead[] | undefined} heads - Writer heads array
82
94
  * @returns {string}
83
95
  */
84
96
  function formatWriters(heads) {
@@ -94,7 +106,7 @@ function formatWriters(heads) {
94
106
 
95
107
  /**
96
108
  * Format checkpoint status line.
97
- * @param {Object} checkpoint - Checkpoint info
109
+ * @param {CheckpointInfo | null} checkpoint - Checkpoint info
98
110
  * @returns {string}
99
111
  */
100
112
  function formatCheckpoint(checkpoint) {
@@ -103,13 +115,14 @@ function formatCheckpoint(checkpoint) {
103
115
  }
104
116
 
105
117
  const sha = colors.muted(checkpoint.sha.slice(0, 7));
106
- const age = formatAge(checkpoint.ageSeconds);
118
+ const ageSeconds = checkpoint.ageSeconds ?? null;
119
+ const age = formatAge(ageSeconds);
107
120
 
108
121
  // Add checkmark for recent checkpoints (< 5 min), warning for older
109
122
  let status;
110
- if (checkpoint.ageSeconds !== null && checkpoint.ageSeconds < 300) {
123
+ if (ageSeconds !== null && ageSeconds < 300) {
111
124
  status = colors.success('\u2713');
112
- } else if (checkpoint.ageSeconds !== null && checkpoint.ageSeconds < 3600) {
125
+ } else if (ageSeconds !== null && ageSeconds < 3600) {
113
126
  status = colors.warning('\u2713');
114
127
  } else {
115
128
  status = colors.muted('\u2713');
@@ -120,7 +133,7 @@ function formatCheckpoint(checkpoint) {
120
133
 
121
134
  /**
122
135
  * Format hook status line.
123
- * @param {Object|null} hook - Hook status
136
+ * @param {HookInfo|null} hook - Hook status
124
137
  * @returns {string}
125
138
  */
126
139
  function formatHook(hook) {
@@ -145,7 +158,7 @@ function formatHook(hook) {
145
158
 
146
159
  /**
147
160
  * Format coverage status line.
148
- * @param {Object} coverage - Coverage info
161
+ * @param {CoverageInfo | null} coverage - Coverage info
149
162
  * @returns {string}
150
163
  */
151
164
  function formatCoverage(coverage) {
@@ -164,7 +177,7 @@ function formatCoverage(coverage) {
164
177
 
165
178
  /**
166
179
  * Get overall health status with color and symbol.
167
- * @param {Object} health - Health object
180
+ * @param {HealthInfo | null} health - Health object
168
181
  * @returns {{ text: string, symbol: string, color: Function }}
169
182
  */
170
183
  function getOverallHealth(health) {
@@ -190,8 +203,8 @@ function getOverallHealth(health) {
190
203
 
191
204
  /**
192
205
  * Build the state section lines (cache, tombstones, patches).
193
- * @param {Object} status - Status object
194
- * @param {Object} gc - GC metrics
206
+ * @param {CheckStatus | null} status - Status object
207
+ * @param {GCInfo | null} gc - GC metrics
195
208
  * @returns {string[]}
196
209
  */
197
210
  function buildStateLines(status, gc) {
@@ -215,10 +228,10 @@ function buildStateLines(status, gc) {
215
228
  /**
216
229
  * Build the metadata section lines (writers, checkpoint, coverage, hooks).
217
230
  * @param {Object} opts - Metadata options
218
- * @param {Object} opts.writers - Writers info
219
- * @param {Object} opts.checkpoint - Checkpoint info
220
- * @param {Object} opts.coverage - Coverage info
221
- * @param {Object} opts.hook - Hook status
231
+ * @param {WritersInfo} opts.writers - Writers info
232
+ * @param {CheckpointInfo} opts.checkpoint - Checkpoint info
233
+ * @param {CoverageInfo} opts.coverage - Coverage info
234
+ * @param {HookInfo | null} opts.hook - Hook status
222
235
  * @returns {string[]}
223
236
  */
224
237
  function buildMetadataLines({ writers, checkpoint, coverage, hook }) {
@@ -232,7 +245,7 @@ function buildMetadataLines({ writers, checkpoint, coverage, hook }) {
232
245
 
233
246
  /**
234
247
  * Determine border color based on health status.
235
- * @param {Object} overall - Overall health info
248
+ * @param {{ text: string, symbol: string, color: Function }} overall - Overall health info
236
249
  * @returns {string}
237
250
  */
238
251
  function getBorderColor(overall) {
@@ -243,7 +256,7 @@ function getBorderColor(overall) {
243
256
 
244
257
  /**
245
258
  * Render the check view dashboard.
246
- * @param {Object} payload - The check command payload
259
+ * @param {CheckPayload} payload - The check command payload
247
260
  * @returns {string} Formatted dashboard string
248
261
  */
249
262
  export function renderCheckView(payload) {
@@ -2,17 +2,27 @@
2
2
  * ASCII graph renderer: maps ELK-positioned nodes and edges onto a character grid.
3
3
  *
4
4
  * Pixel-to-character scaling:
5
- * cellW = 8 px/char, cellH = 4 px/char (approximate monospace aspect ratio)
5
+ * cellW = 10, cellH = 10
6
+ * ELK uses NODE_HEIGHT=40, nodeNode=40, betweenLayers=60.
7
+ * At cellH=10: 40px → 4 rows, compact 3-row nodes fit with natural gaps.
6
8
  */
7
9
 
8
10
  import { createBox } from './box.js';
9
11
  import { colors } from './colors.js';
10
12
  import { ARROW } from './symbols.js';
11
13
 
14
+ /**
15
+ * @typedef {{ x: number, y: number }} Point
16
+ * @typedef {{ startPoint?: Point, endPoint?: Point, bendPoints?: Point[] }} Section
17
+ * @typedef {{ id: string, x: number, y: number, width: number, height: number, label?: string }} PositionedNode
18
+ * @typedef {{ id: string, source: string, target: string, label?: string, sections?: Section[] }} PositionedEdge
19
+ * @typedef {{ nodes: PositionedNode[], edges: PositionedEdge[], width: number, height: number }} PositionedGraph
20
+ */
21
+
12
22
  // ── Scaling constants ────────────────────────────────────────────────────────
13
23
 
14
- const CELL_W = 8;
15
- const CELL_H = 4;
24
+ const CELL_W = 10;
25
+ const CELL_H = 10;
16
26
  const MARGIN = 2;
17
27
 
18
28
  // ── Box-drawing characters (short keys for tight grid-stamping loops) ───────
@@ -28,23 +38,33 @@ const BOX = {
28
38
 
29
39
  // ── Grid helpers ─────────────────────────────────────────────────────────────
30
40
 
41
+ /** @param {number} px */
31
42
  function toCol(px) {
32
43
  return Math.round(px / CELL_W) + MARGIN;
33
44
  }
34
45
 
46
+ /** @param {number} px */
35
47
  function toRow(px) {
36
48
  return Math.round(px / CELL_H) + MARGIN;
37
49
  }
38
50
 
51
+ /** @param {number} px */
39
52
  function scaleW(px) {
40
53
  return Math.round(px / CELL_W);
41
54
  }
42
55
 
56
+ /** @param {number} px */
43
57
  function scaleH(px) {
44
58
  return Math.round(px / CELL_H);
45
59
  }
46
60
 
61
+ /**
62
+ * @param {number} rows
63
+ * @param {number} cols
64
+ * @returns {string[][]}
65
+ */
47
66
  function createGrid(rows, cols) {
67
+ /** @type {string[][]} */
48
68
  const grid = [];
49
69
  for (let r = 0; r < rows; r++) {
50
70
  grid.push(new Array(cols).fill(' '));
@@ -52,12 +72,24 @@ function createGrid(rows, cols) {
52
72
  return grid;
53
73
  }
54
74
 
75
+ /**
76
+ * @param {string[][]} grid
77
+ * @param {number} r
78
+ * @param {number} c
79
+ * @param {string} ch
80
+ */
55
81
  function writeChar(grid, r, c, ch) {
56
82
  if (r >= 0 && r < grid.length && c >= 0 && c < grid[0].length) {
57
83
  grid[r][c] = ch;
58
84
  }
59
85
  }
60
86
 
87
+ /**
88
+ * @param {string[][]} grid
89
+ * @param {number} r
90
+ * @param {number} c
91
+ * @returns {string}
92
+ */
61
93
  function readChar(grid, r, c) {
62
94
  if (r >= 0 && r < grid.length && c >= 0 && c < grid[0].length) {
63
95
  return grid[r][c];
@@ -65,6 +97,12 @@ function readChar(grid, r, c) {
65
97
  return ' ';
66
98
  }
67
99
 
100
+ /**
101
+ * @param {string[][]} grid
102
+ * @param {number} r
103
+ * @param {number} c
104
+ * @param {string} str
105
+ */
68
106
  function writeString(grid, r, c, str) {
69
107
  for (let i = 0; i < str.length; i++) {
70
108
  writeChar(grid, r, c + i, str[i]);
@@ -73,11 +111,15 @@ function writeString(grid, r, c, str) {
73
111
 
74
112
  // ── Node stamping ────────────────────────────────────────────────────────────
75
113
 
114
+ /**
115
+ * @param {string[][]} grid
116
+ * @param {PositionedNode} node
117
+ */
76
118
  function stampNode(grid, node) {
77
119
  const r = toRow(node.y);
78
120
  const c = toCol(node.x);
79
- const w = Math.max(scaleW(node.width), 4);
80
- const h = Math.max(scaleH(node.height), 3);
121
+ const w = Math.max(toCol(node.width), 4);
122
+ const h = 3; // Always: border + label + border
81
123
 
82
124
  // Top border
83
125
  writeChar(grid, r, c, BOX.tl);
@@ -87,10 +129,8 @@ function stampNode(grid, node) {
87
129
  writeChar(grid, r, c + w - 1, BOX.tr);
88
130
 
89
131
  // Side borders
90
- for (let j = 1; j < h - 1; j++) {
91
- writeChar(grid, r + j, c, BOX.v);
92
- writeChar(grid, r + j, c + w - 1, BOX.v);
93
- }
132
+ writeChar(grid, r + 1, c, BOX.v);
133
+ writeChar(grid, r + 1, c + w - 1, BOX.v);
94
134
 
95
135
  // Bottom border
96
136
  writeChar(grid, r + h - 1, c, BOX.bl);
@@ -99,19 +139,24 @@ function stampNode(grid, node) {
99
139
  }
100
140
  writeChar(grid, r + h - 1, c + w - 1, BOX.br);
101
141
 
102
- // Label (centered)
142
+ // Label (always on row 1)
103
143
  const label = node.label ?? node.id;
104
144
  const maxLabel = w - 4;
105
145
  const truncated = label.length > maxLabel
106
146
  ? `${label.slice(0, Math.max(maxLabel - 1, 1))}\u2026`
107
147
  : label;
108
- const labelRow = r + Math.floor(h / 2);
148
+ const labelRow = r + 1;
109
149
  const labelCol = c + Math.max(1, Math.floor((w - truncated.length) / 2));
110
150
  writeString(grid, labelRow, labelCol, truncated);
111
151
  }
112
152
 
113
153
  // ── Edge tracing ─────────────────────────────────────────────────────────────
114
154
 
155
+ /**
156
+ * @param {string[][]} grid
157
+ * @param {PositionedEdge} edge
158
+ * @param {Set<string>} nodeSet
159
+ */
115
160
  function traceEdge(grid, edge, nodeSet) {
116
161
  const { sections } = edge;
117
162
  if (!sections || sections.length === 0) {
@@ -136,6 +181,7 @@ function traceEdge(grid, edge, nodeSet) {
136
181
  }
137
182
  }
138
183
 
184
+ /** @param {Section} section @returns {Point[]} */
139
185
  function buildPointList(section) {
140
186
  const points = [];
141
187
  if (section.startPoint) {
@@ -150,6 +196,11 @@ function buildPointList(section) {
150
196
  return points;
151
197
  }
152
198
 
199
+ /**
200
+ * @param {string[][]} grid
201
+ * @param {Point[]} points
202
+ * @param {Set<string>} nodeSet
203
+ */
153
204
  function drawSegments(grid, points, nodeSet) {
154
205
  for (let i = 0; i < points.length - 1; i++) {
155
206
  const r1 = toRow(points[i].y);
@@ -160,6 +211,14 @@ function drawSegments(grid, points, nodeSet) {
160
211
  }
161
212
  }
162
213
 
214
+ /**
215
+ * @param {string[][]} grid
216
+ * @param {number} r1
217
+ * @param {number} c1
218
+ * @param {number} r2
219
+ * @param {number} c2
220
+ * @param {Set<string>} nodeSet
221
+ */
163
222
  function drawLine(grid, r1, c1, r2, c2, nodeSet) {
164
223
  if (r1 === r2) {
165
224
  drawHorizontal(grid, r1, c1, c2, nodeSet);
@@ -172,6 +231,13 @@ function drawLine(grid, r1, c1, r2, c2, nodeSet) {
172
231
  }
173
232
  }
174
233
 
234
+ /**
235
+ * @param {string[][]} grid
236
+ * @param {number} row
237
+ * @param {number} c1
238
+ * @param {number} c2
239
+ * @param {Set<string>} nodeSet
240
+ */
175
241
  function drawHorizontal(grid, row, c1, c2, nodeSet) {
176
242
  const start = Math.min(c1, c2);
177
243
  const end = Math.max(c1, c2);
@@ -187,6 +253,13 @@ function drawHorizontal(grid, row, c1, c2, nodeSet) {
187
253
  }
188
254
  }
189
255
 
256
+ /**
257
+ * @param {string[][]} grid
258
+ * @param {number} col
259
+ * @param {number} r1
260
+ * @param {number} r2
261
+ * @param {Set<string>} nodeSet
262
+ */
190
263
  function drawVertical(grid, col, r1, r2, nodeSet) {
191
264
  const start = Math.min(r1, r2);
192
265
  const end = Math.max(r1, r2);
@@ -202,6 +275,11 @@ function drawVertical(grid, col, r1, r2, nodeSet) {
202
275
  }
203
276
  }
204
277
 
278
+ /**
279
+ * @param {string[][]} grid
280
+ * @param {Section} section
281
+ * @param {Set<string>} nodeSet
282
+ */
205
283
  function drawArrowhead(grid, section, nodeSet) {
206
284
  const ep = section.endPoint;
207
285
  if (!ep) {
@@ -220,6 +298,8 @@ function drawArrowhead(grid, section, nodeSet) {
220
298
  const pc = toCol(prev.x);
221
299
 
222
300
  let arrow;
301
+ let ar = er;
302
+ let ac = ec;
223
303
  if (er > pr) {
224
304
  arrow = ARROW.down;
225
305
  } else if (er < pr) {
@@ -230,11 +310,30 @@ function drawArrowhead(grid, section, nodeSet) {
230
310
  arrow = ARROW.left;
231
311
  }
232
312
 
233
- if (!isNodeCell(nodeSet, er, ec)) {
234
- writeChar(grid, er, ec, arrow);
313
+ // If the endpoint is inside a node box, step back one cell into free space
314
+ if (isNodeCell(nodeSet, ar, ac)) {
315
+ if (er > pr) {
316
+ ar = er - 1;
317
+ } else if (er < pr) {
318
+ ar = er + 1;
319
+ } else if (ec > pc) {
320
+ ac = ec - 1;
321
+ } else {
322
+ ac = ec + 1;
323
+ }
324
+ }
325
+
326
+ if (!isNodeCell(nodeSet, ar, ac)) {
327
+ writeChar(grid, ar, ac, arrow);
235
328
  }
236
329
  }
237
330
 
331
+ /**
332
+ * @param {string[][]} grid
333
+ * @param {Section[]} sections
334
+ * @param {string} label
335
+ * @param {Set<string>} nodeSet
336
+ */
238
337
  function placeEdgeLabel(grid, sections, label, nodeSet) {
239
338
  // Find midpoint of the full path
240
339
  const allPoints = [];
@@ -277,13 +376,14 @@ function placeEdgeLabel(grid, sections, label, nodeSet) {
277
376
 
278
377
  // ── Node occupancy set ───────────────────────────────────────────────────────
279
378
 
379
+ /** @param {PositionedNode[]} nodes @returns {Set<string>} */
280
380
  function buildNodeSet(nodes) {
281
381
  const set = new Set();
282
382
  for (const node of nodes) {
283
383
  const r = toRow(node.y);
284
384
  const c = toCol(node.x);
285
- const w = Math.max(scaleW(node.width), 4);
286
- const h = Math.max(scaleH(node.height), 3);
385
+ const w = Math.max(toCol(node.width), 4);
386
+ const h = 3; // Match compact node height
287
387
  for (let dr = 0; dr < h; dr++) {
288
388
  for (let dc = 0; dc < w; dc++) {
289
389
  set.add(`${r + dr},${c + dc}`);
@@ -293,6 +393,12 @@ function buildNodeSet(nodes) {
293
393
  return set;
294
394
  }
295
395
 
396
+ /**
397
+ * @param {Set<string>} nodeSet
398
+ * @param {number} r
399
+ * @param {number} c
400
+ * @returns {boolean}
401
+ */
296
402
  function isNodeCell(nodeSet, r, c) {
297
403
  return nodeSet.has(`${r},${c}`);
298
404
  }
@@ -302,7 +408,7 @@ function isNodeCell(nodeSet, r, c) {
302
408
  /**
303
409
  * Renders a PositionedGraph (from ELK) as an ASCII box-drawing string.
304
410
  *
305
- * @param {Object} positionedGraph - PositionedGraph from runLayout()
411
+ * @param {PositionedGraph} positionedGraph - PositionedGraph from runLayout()
306
412
  * @param {{ title?: string }} [options]
307
413
  * @returns {string} Rendered ASCII art wrapped in a box
308
414
  */