@git-stunts/git-warp 10.1.1

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 (143) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +16 -0
  3. package/README.md +480 -0
  4. package/SECURITY.md +30 -0
  5. package/bin/git-warp +24 -0
  6. package/bin/warp-graph.js +1574 -0
  7. package/index.d.ts +2366 -0
  8. package/index.js +180 -0
  9. package/package.json +129 -0
  10. package/scripts/install-git-warp.sh +258 -0
  11. package/scripts/uninstall-git-warp.sh +139 -0
  12. package/src/domain/WarpGraph.js +3157 -0
  13. package/src/domain/crdt/Dot.js +160 -0
  14. package/src/domain/crdt/LWW.js +154 -0
  15. package/src/domain/crdt/ORSet.js +371 -0
  16. package/src/domain/crdt/VersionVector.js +222 -0
  17. package/src/domain/entities/GraphNode.js +60 -0
  18. package/src/domain/errors/EmptyMessageError.js +47 -0
  19. package/src/domain/errors/ForkError.js +30 -0
  20. package/src/domain/errors/IndexError.js +23 -0
  21. package/src/domain/errors/OperationAbortedError.js +22 -0
  22. package/src/domain/errors/QueryError.js +39 -0
  23. package/src/domain/errors/SchemaUnsupportedError.js +17 -0
  24. package/src/domain/errors/ShardCorruptionError.js +56 -0
  25. package/src/domain/errors/ShardLoadError.js +57 -0
  26. package/src/domain/errors/ShardValidationError.js +61 -0
  27. package/src/domain/errors/StorageError.js +57 -0
  28. package/src/domain/errors/SyncError.js +30 -0
  29. package/src/domain/errors/TraversalError.js +23 -0
  30. package/src/domain/errors/WarpError.js +31 -0
  31. package/src/domain/errors/WormholeError.js +28 -0
  32. package/src/domain/errors/WriterError.js +39 -0
  33. package/src/domain/errors/index.js +21 -0
  34. package/src/domain/services/AnchorMessageCodec.js +99 -0
  35. package/src/domain/services/BitmapIndexBuilder.js +225 -0
  36. package/src/domain/services/BitmapIndexReader.js +435 -0
  37. package/src/domain/services/BoundaryTransitionRecord.js +463 -0
  38. package/src/domain/services/CheckpointMessageCodec.js +147 -0
  39. package/src/domain/services/CheckpointSerializerV5.js +281 -0
  40. package/src/domain/services/CheckpointService.js +384 -0
  41. package/src/domain/services/CommitDagTraversalService.js +156 -0
  42. package/src/domain/services/DagPathFinding.js +712 -0
  43. package/src/domain/services/DagTopology.js +239 -0
  44. package/src/domain/services/DagTraversal.js +245 -0
  45. package/src/domain/services/Frontier.js +108 -0
  46. package/src/domain/services/GCMetrics.js +101 -0
  47. package/src/domain/services/GCPolicy.js +122 -0
  48. package/src/domain/services/GitLogParser.js +205 -0
  49. package/src/domain/services/HealthCheckService.js +246 -0
  50. package/src/domain/services/HookInstaller.js +326 -0
  51. package/src/domain/services/HttpSyncServer.js +262 -0
  52. package/src/domain/services/IndexRebuildService.js +426 -0
  53. package/src/domain/services/IndexStalenessChecker.js +103 -0
  54. package/src/domain/services/JoinReducer.js +582 -0
  55. package/src/domain/services/KeyCodec.js +113 -0
  56. package/src/domain/services/LegacyAnchorDetector.js +67 -0
  57. package/src/domain/services/LogicalTraversal.js +351 -0
  58. package/src/domain/services/MessageCodecInternal.js +132 -0
  59. package/src/domain/services/MessageSchemaDetector.js +145 -0
  60. package/src/domain/services/MigrationService.js +55 -0
  61. package/src/domain/services/ObserverView.js +265 -0
  62. package/src/domain/services/PatchBuilderV2.js +669 -0
  63. package/src/domain/services/PatchMessageCodec.js +140 -0
  64. package/src/domain/services/ProvenanceIndex.js +337 -0
  65. package/src/domain/services/ProvenancePayload.js +242 -0
  66. package/src/domain/services/QueryBuilder.js +835 -0
  67. package/src/domain/services/StateDiff.js +300 -0
  68. package/src/domain/services/StateSerializerV5.js +156 -0
  69. package/src/domain/services/StreamingBitmapIndexBuilder.js +709 -0
  70. package/src/domain/services/SyncProtocol.js +593 -0
  71. package/src/domain/services/TemporalQuery.js +201 -0
  72. package/src/domain/services/TranslationCost.js +221 -0
  73. package/src/domain/services/TraversalService.js +8 -0
  74. package/src/domain/services/WarpMessageCodec.js +29 -0
  75. package/src/domain/services/WarpStateIndexBuilder.js +127 -0
  76. package/src/domain/services/WormholeService.js +353 -0
  77. package/src/domain/types/TickReceipt.js +285 -0
  78. package/src/domain/types/WarpTypes.js +209 -0
  79. package/src/domain/types/WarpTypesV2.js +200 -0
  80. package/src/domain/utils/CachedValue.js +140 -0
  81. package/src/domain/utils/EventId.js +89 -0
  82. package/src/domain/utils/LRUCache.js +112 -0
  83. package/src/domain/utils/MinHeap.js +114 -0
  84. package/src/domain/utils/RefLayout.js +280 -0
  85. package/src/domain/utils/WriterId.js +205 -0
  86. package/src/domain/utils/cancellation.js +33 -0
  87. package/src/domain/utils/canonicalStringify.js +42 -0
  88. package/src/domain/utils/defaultClock.js +20 -0
  89. package/src/domain/utils/defaultCodec.js +51 -0
  90. package/src/domain/utils/nullLogger.js +21 -0
  91. package/src/domain/utils/roaring.js +181 -0
  92. package/src/domain/utils/shardVersion.js +9 -0
  93. package/src/domain/warp/PatchSession.js +217 -0
  94. package/src/domain/warp/Writer.js +181 -0
  95. package/src/hooks/post-merge.sh +60 -0
  96. package/src/infrastructure/adapters/BunHttpAdapter.js +225 -0
  97. package/src/infrastructure/adapters/ClockAdapter.js +57 -0
  98. package/src/infrastructure/adapters/ConsoleLogger.js +150 -0
  99. package/src/infrastructure/adapters/DenoHttpAdapter.js +230 -0
  100. package/src/infrastructure/adapters/GitGraphAdapter.js +787 -0
  101. package/src/infrastructure/adapters/GlobalClockAdapter.js +5 -0
  102. package/src/infrastructure/adapters/NoOpLogger.js +62 -0
  103. package/src/infrastructure/adapters/NodeCryptoAdapter.js +32 -0
  104. package/src/infrastructure/adapters/NodeHttpAdapter.js +98 -0
  105. package/src/infrastructure/adapters/PerformanceClockAdapter.js +5 -0
  106. package/src/infrastructure/adapters/WebCryptoAdapter.js +121 -0
  107. package/src/infrastructure/codecs/CborCodec.js +384 -0
  108. package/src/ports/BlobPort.js +30 -0
  109. package/src/ports/ClockPort.js +25 -0
  110. package/src/ports/CodecPort.js +25 -0
  111. package/src/ports/CommitPort.js +114 -0
  112. package/src/ports/ConfigPort.js +31 -0
  113. package/src/ports/CryptoPort.js +38 -0
  114. package/src/ports/GraphPersistencePort.js +57 -0
  115. package/src/ports/HttpServerPort.js +25 -0
  116. package/src/ports/IndexStoragePort.js +39 -0
  117. package/src/ports/LoggerPort.js +68 -0
  118. package/src/ports/RefPort.js +51 -0
  119. package/src/ports/TreePort.js +51 -0
  120. package/src/visualization/index.js +26 -0
  121. package/src/visualization/layouts/converters.js +75 -0
  122. package/src/visualization/layouts/elkAdapter.js +86 -0
  123. package/src/visualization/layouts/elkLayout.js +95 -0
  124. package/src/visualization/layouts/index.js +29 -0
  125. package/src/visualization/renderers/ascii/box.js +16 -0
  126. package/src/visualization/renderers/ascii/check.js +271 -0
  127. package/src/visualization/renderers/ascii/colors.js +13 -0
  128. package/src/visualization/renderers/ascii/formatters.js +73 -0
  129. package/src/visualization/renderers/ascii/graph.js +344 -0
  130. package/src/visualization/renderers/ascii/history.js +335 -0
  131. package/src/visualization/renderers/ascii/index.js +14 -0
  132. package/src/visualization/renderers/ascii/info.js +245 -0
  133. package/src/visualization/renderers/ascii/materialize.js +255 -0
  134. package/src/visualization/renderers/ascii/path.js +240 -0
  135. package/src/visualization/renderers/ascii/progress.js +32 -0
  136. package/src/visualization/renderers/ascii/symbols.js +33 -0
  137. package/src/visualization/renderers/ascii/table.js +19 -0
  138. package/src/visualization/renderers/browser/index.js +1 -0
  139. package/src/visualization/renderers/svg/index.js +159 -0
  140. package/src/visualization/utils/ansi.js +14 -0
  141. package/src/visualization/utils/time.js +40 -0
  142. package/src/visualization/utils/truncate.js +40 -0
  143. package/src/visualization/utils/unicode.js +52 -0
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Check command ASCII visualization renderer.
3
+ *
4
+ * Renders a visual health dashboard with progress bars, status indicators,
5
+ * and color-coded health status.
6
+ */
7
+
8
+ import chalk from 'chalk';
9
+ import { createBox } from './box.js';
10
+ import { progressBar } from './progress.js';
11
+ import { colors } from './colors.js';
12
+ import { padRight } from '../../utils/unicode.js';
13
+ import { formatAge } from './formatters.js';
14
+
15
+ // Health thresholds
16
+ const TOMBSTONE_HEALTHY_MAX = 0.15; // < 15% tombstones = healthy
17
+ const TOMBSTONE_WARNING_MAX = 0.30; // < 30% tombstones = warning
18
+ const CACHE_STALE_PENALTY = 20; // Reduce "freshness" score for stale cache
19
+
20
+ /**
21
+ * Get cache freshness percentage and state.
22
+ * @param {Object} status - The status object from check payload
23
+ * @returns {{ percent: number, label: string }}
24
+ */
25
+ function getCacheFreshness(status) {
26
+ if (!status) {
27
+ return { percent: 0, label: 'none' };
28
+ }
29
+
30
+ switch (status.cachedState) {
31
+ case 'fresh':
32
+ return { percent: 100, label: 'fresh' };
33
+ case 'stale':
34
+ return { percent: 100 - CACHE_STALE_PENALTY, label: 'stale' };
35
+ case 'none':
36
+ default:
37
+ return { percent: 0, label: 'none' };
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Get tombstone health status and color.
43
+ * @param {number} ratio - Tombstone ratio (0-1)
44
+ * @returns {{ status: string, color: Function }}
45
+ */
46
+ function getTombstoneHealth(ratio) {
47
+ if (ratio < TOMBSTONE_HEALTHY_MAX) {
48
+ return { status: 'healthy', color: colors.success };
49
+ }
50
+ if (ratio < TOMBSTONE_WARNING_MAX) {
51
+ return { status: 'warning', color: colors.warning };
52
+ }
53
+ return { status: 'critical', color: colors.error };
54
+ }
55
+
56
+ /**
57
+ * Create a custom progress bar with inverted colors for tombstones.
58
+ * Lower is better for tombstones.
59
+ * @param {number} percent - Percentage (0-100)
60
+ * @param {number} width - Bar width
61
+ * @returns {string}
62
+ */
63
+ function tombstoneBar(percent, width = 20) {
64
+ const clampedPercent = Math.max(0, Math.min(100, percent));
65
+ const filledCount = Math.round((clampedPercent / 100) * width);
66
+ const emptyCount = width - filledCount;
67
+ const bar = '\u2588'.repeat(filledCount) + '\u2591'.repeat(emptyCount);
68
+
69
+ // Invert: lower tombstone ratio is better (green), higher is bad (red)
70
+ if (percent <= TOMBSTONE_HEALTHY_MAX * 100) {
71
+ return chalk.green(bar);
72
+ }
73
+ if (percent <= TOMBSTONE_WARNING_MAX * 100) {
74
+ return chalk.yellow(bar);
75
+ }
76
+ return chalk.red(bar);
77
+ }
78
+
79
+ /**
80
+ * Format writer information for display.
81
+ * @param {Object[]} heads - Writer heads array
82
+ * @returns {string}
83
+ */
84
+ function formatWriters(heads) {
85
+ if (!heads || heads.length === 0) {
86
+ return colors.muted('none');
87
+ }
88
+
89
+ // For now, just list writer IDs with their SHA prefixes
90
+ return heads
91
+ .map((h) => `${colors.primary(h.writerId ?? 'unknown')} (${colors.muted((h.sha ?? '').slice(0, 7) || '?')})`)
92
+ .join(' | ');
93
+ }
94
+
95
+ /**
96
+ * Format checkpoint status line.
97
+ * @param {Object} checkpoint - Checkpoint info
98
+ * @returns {string}
99
+ */
100
+ function formatCheckpoint(checkpoint) {
101
+ if (!checkpoint?.sha) {
102
+ return `${colors.warning('none')}`;
103
+ }
104
+
105
+ const sha = colors.muted(checkpoint.sha.slice(0, 7));
106
+ const age = formatAge(checkpoint.ageSeconds);
107
+
108
+ // Add checkmark for recent checkpoints (< 5 min), warning for older
109
+ let status;
110
+ if (checkpoint.ageSeconds !== null && checkpoint.ageSeconds < 300) {
111
+ status = colors.success('\u2713');
112
+ } else if (checkpoint.ageSeconds !== null && checkpoint.ageSeconds < 3600) {
113
+ status = colors.warning('\u2713');
114
+ } else {
115
+ status = colors.muted('\u2713');
116
+ }
117
+
118
+ return `${sha} (${age} ago) ${status}`;
119
+ }
120
+
121
+ /**
122
+ * Format hook status line.
123
+ * @param {Object|null} hook - Hook status
124
+ * @returns {string}
125
+ */
126
+ function formatHook(hook) {
127
+ if (!hook) {
128
+ return colors.muted('unknown');
129
+ }
130
+
131
+ if (!hook.installed && hook.foreign) {
132
+ return `${colors.warning('\u26A0')} foreign hook present`;
133
+ }
134
+
135
+ if (!hook.installed) {
136
+ return `${colors.error('\u2717')} not installed`;
137
+ }
138
+
139
+ if (hook.current) {
140
+ return `${colors.success('\u2713')} installed (v${hook.version})`;
141
+ }
142
+
143
+ return `${colors.warning('\u2713')} installed (v${hook.version}) \u2014 upgrade available`;
144
+ }
145
+
146
+ /**
147
+ * Format coverage status line.
148
+ * @param {Object} coverage - Coverage info
149
+ * @returns {string}
150
+ */
151
+ function formatCoverage(coverage) {
152
+ if (!coverage?.sha) {
153
+ return colors.muted('none');
154
+ }
155
+
156
+ const missing = coverage.missingWriters || [];
157
+ if (missing.length === 0) {
158
+ return `${colors.success('\u2713')} all writers merged`;
159
+ }
160
+
161
+ const missingList = missing.map((w) => colors.warning(w)).join(', ');
162
+ return `${colors.warning('\u26A0')} missing: ${missingList}`;
163
+ }
164
+
165
+ /**
166
+ * Get overall health status with color and symbol.
167
+ * @param {Object} health - Health object
168
+ * @returns {{ text: string, symbol: string, color: Function }}
169
+ */
170
+ function getOverallHealth(health) {
171
+ if (!health) {
172
+ return { text: 'UNKNOWN', symbol: '?', color: colors.muted };
173
+ }
174
+
175
+ switch (health.status) {
176
+ case 'healthy':
177
+ return { text: 'HEALTHY', symbol: '\u2713', color: colors.success };
178
+ case 'degraded':
179
+ return { text: 'DEGRADED', symbol: '\u26A0', color: colors.warning };
180
+ case 'unhealthy':
181
+ return { text: 'UNHEALTHY', symbol: '\u2717', color: colors.error };
182
+ default: {
183
+ const safeStatus = typeof health.status === 'string' && health.status.length
184
+ ? health.status
185
+ : 'UNKNOWN';
186
+ return { text: safeStatus.toUpperCase(), symbol: '?', color: colors.muted };
187
+ }
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Build the state section lines (cache, tombstones, patches).
193
+ * @param {Object} status - Status object
194
+ * @param {Object} gc - GC metrics
195
+ * @returns {string[]}
196
+ */
197
+ function buildStateLines(status, gc) {
198
+ const lines = [];
199
+ const cache = getCacheFreshness(status);
200
+ const cacheBar = progressBar(cache.percent, 20, { showPercent: false });
201
+ lines.push(` ${padRight('Cache:', 12)} ${cacheBar} ${cache.percent}% ${cache.label}`);
202
+
203
+ const tombstoneRatio = status?.tombstoneRatio ?? gc?.tombstoneRatio ?? 0;
204
+ const tombstonePercent = Math.round(tombstoneRatio * 100);
205
+ const tombstoneHealth = getTombstoneHealth(tombstoneRatio);
206
+ const tBar = tombstoneBar(tombstonePercent, 20);
207
+ lines.push(` ${padRight('Tombstones:', 12)} ${tBar} ${tombstonePercent}% (${tombstoneHealth.color(tombstoneHealth.status)})`);
208
+
209
+ if (status?.patchesSinceCheckpoint !== undefined) {
210
+ lines.push(` ${padRight('Patches:', 12)} ${status.patchesSinceCheckpoint} since checkpoint`);
211
+ }
212
+ return lines;
213
+ }
214
+
215
+ /**
216
+ * Build the metadata section lines (writers, checkpoint, coverage, hooks).
217
+ * @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
222
+ * @returns {string[]}
223
+ */
224
+ function buildMetadataLines({ writers, checkpoint, coverage, hook }) {
225
+ return [
226
+ ` ${padRight('Writers:', 12)} ${formatWriters(writers?.heads)}`,
227
+ ` ${padRight('Checkpoint:', 12)} ${formatCheckpoint(checkpoint)}`,
228
+ ` ${padRight('Coverage:', 12)} ${formatCoverage(coverage)}`,
229
+ ` ${padRight('Hooks:', 12)} ${formatHook(hook)}`,
230
+ ];
231
+ }
232
+
233
+ /**
234
+ * Determine border color based on health status.
235
+ * @param {Object} overall - Overall health info
236
+ * @returns {string}
237
+ */
238
+ function getBorderColor(overall) {
239
+ if (overall.color === colors.success) {return 'green';}
240
+ if (overall.color === colors.warning) {return 'yellow';}
241
+ return 'red';
242
+ }
243
+
244
+ /**
245
+ * Render the check view dashboard.
246
+ * @param {Object} payload - The check command payload
247
+ * @returns {string} Formatted dashboard string
248
+ */
249
+ export function renderCheckView(payload) {
250
+ const { graph, health, status, writers, checkpoint, coverage, gc, hook } = payload;
251
+ const overall = getOverallHealth(health);
252
+
253
+ const lines = [
254
+ colors.bold(` GRAPH HEALTH: ${graph}`),
255
+ '',
256
+ ...buildStateLines(status, gc),
257
+ '',
258
+ ...buildMetadataLines({ writers, checkpoint, coverage, hook }),
259
+ '',
260
+ ` Overall: ${overall.color(overall.symbol)} ${overall.color(overall.text)}`,
261
+ ];
262
+
263
+ const box = createBox(lines.join('\n'), {
264
+ title: 'HEALTH',
265
+ titleAlignment: 'center',
266
+ borderColor: getBorderColor(overall),
267
+ });
268
+ return `${box}\n`;
269
+ }
270
+
271
+ export default { renderCheckView };
@@ -0,0 +1,13 @@
1
+ import chalk from 'chalk';
2
+
3
+ export const colors = {
4
+ primary: chalk.cyan,
5
+ success: chalk.green,
6
+ warning: chalk.yellow,
7
+ error: chalk.red,
8
+ muted: chalk.gray,
9
+ bold: chalk.bold,
10
+ dim: chalk.dim,
11
+ };
12
+
13
+ export default colors;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Shared formatting functions for ASCII renderers.
3
+ *
4
+ * Extracted from check.js, materialize.js, and info.js to eliminate
5
+ * duplicate formatting logic across renderers.
6
+ */
7
+
8
+ import { colors } from './colors.js';
9
+
10
+ /**
11
+ * Format seconds as human-readable time (e.g., "2m", "1h", "3d").
12
+ * @param {number|null} seconds
13
+ * @returns {string}
14
+ */
15
+ export function formatAge(seconds) {
16
+ if (seconds === null || seconds === undefined) {
17
+ return 'unknown';
18
+ }
19
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) {
20
+ return 'unknown';
21
+ }
22
+ const secs = Math.floor(seconds);
23
+ if (secs < 60) {
24
+ return `${secs}s`;
25
+ }
26
+ const minutes = Math.floor(secs / 60);
27
+ if (minutes < 60) {
28
+ return `${minutes}m`;
29
+ }
30
+ const hours = Math.floor(minutes / 60);
31
+ if (hours < 24) {
32
+ return `${hours}h`;
33
+ }
34
+ const days = Math.floor(hours / 24);
35
+ return `${days}d`;
36
+ }
37
+
38
+ /**
39
+ * Format a number with thousands separator for readability.
40
+ * @param {number} n - Number to format
41
+ * @returns {string} Formatted number string
42
+ */
43
+ export function formatNumber(n) {
44
+ if (typeof n !== 'number' || !Number.isFinite(n)) {
45
+ return '0';
46
+ }
47
+ return n.toLocaleString('en-US');
48
+ }
49
+
50
+ /**
51
+ * Format a SHA for display (first 7 characters, muted color).
52
+ * @param {string|null} sha - Full SHA or null
53
+ * @returns {string} Shortened SHA or 'none'
54
+ */
55
+ export function formatSha(sha) {
56
+ return sha ? colors.muted(sha.slice(0, 7)) : colors.muted('none');
57
+ }
58
+
59
+ /**
60
+ * Format a writer name for display, truncating to a max length.
61
+ * @param {string} writerId - Writer ID to format
62
+ * @param {number} [maxLen=16] - Maximum length before truncation
63
+ * @returns {string} Formatted writer display name
64
+ */
65
+ export function formatWriterName(writerId, maxLen = 16) {
66
+ if (!writerId || typeof writerId !== 'string') {
67
+ return 'unknown';
68
+ }
69
+ if (writerId.length > maxLen) {
70
+ return `${writerId.slice(0, maxLen - 1)}\u2026`;
71
+ }
72
+ return writerId;
73
+ }
@@ -0,0 +1,344 @@
1
+ /**
2
+ * ASCII graph renderer: maps ELK-positioned nodes and edges onto a character grid.
3
+ *
4
+ * Pixel-to-character scaling:
5
+ * cellW = 8 px/char, cellH = 4 px/char (approximate monospace aspect ratio)
6
+ */
7
+
8
+ import { createBox } from './box.js';
9
+ import { colors } from './colors.js';
10
+ import { ARROW } from './symbols.js';
11
+
12
+ // ── Scaling constants ────────────────────────────────────────────────────────
13
+
14
+ const CELL_W = 8;
15
+ const CELL_H = 4;
16
+ const MARGIN = 2;
17
+
18
+ // ── Box-drawing characters (short keys for tight grid-stamping loops) ───────
19
+
20
+ const BOX = {
21
+ tl: '\u250C', // ┌
22
+ tr: '\u2510', // ┐
23
+ bl: '\u2514', // └
24
+ br: '\u2518', // ┘
25
+ h: '\u2500', // ─
26
+ v: '\u2502', // │
27
+ };
28
+
29
+ // ── Grid helpers ─────────────────────────────────────────────────────────────
30
+
31
+ function toCol(px) {
32
+ return Math.round(px / CELL_W) + MARGIN;
33
+ }
34
+
35
+ function toRow(px) {
36
+ return Math.round(px / CELL_H) + MARGIN;
37
+ }
38
+
39
+ function scaleW(px) {
40
+ return Math.round(px / CELL_W);
41
+ }
42
+
43
+ function scaleH(px) {
44
+ return Math.round(px / CELL_H);
45
+ }
46
+
47
+ function createGrid(rows, cols) {
48
+ const grid = [];
49
+ for (let r = 0; r < rows; r++) {
50
+ grid.push(new Array(cols).fill(' '));
51
+ }
52
+ return grid;
53
+ }
54
+
55
+ function writeChar(grid, r, c, ch) {
56
+ if (r >= 0 && r < grid.length && c >= 0 && c < grid[0].length) {
57
+ grid[r][c] = ch;
58
+ }
59
+ }
60
+
61
+ function readChar(grid, r, c) {
62
+ if (r >= 0 && r < grid.length && c >= 0 && c < grid[0].length) {
63
+ return grid[r][c];
64
+ }
65
+ return ' ';
66
+ }
67
+
68
+ function writeString(grid, r, c, str) {
69
+ for (let i = 0; i < str.length; i++) {
70
+ writeChar(grid, r, c + i, str[i]);
71
+ }
72
+ }
73
+
74
+ // ── Node stamping ────────────────────────────────────────────────────────────
75
+
76
+ function stampNode(grid, node) {
77
+ const r = toRow(node.y);
78
+ const c = toCol(node.x);
79
+ const w = Math.max(scaleW(node.width), 4);
80
+ const h = Math.max(scaleH(node.height), 3);
81
+
82
+ // Top border
83
+ writeChar(grid, r, c, BOX.tl);
84
+ for (let i = 1; i < w - 1; i++) {
85
+ writeChar(grid, r, c + i, BOX.h);
86
+ }
87
+ writeChar(grid, r, c + w - 1, BOX.tr);
88
+
89
+ // 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
+ }
94
+
95
+ // Bottom border
96
+ writeChar(grid, r + h - 1, c, BOX.bl);
97
+ for (let i = 1; i < w - 1; i++) {
98
+ writeChar(grid, r + h - 1, c + i, BOX.h);
99
+ }
100
+ writeChar(grid, r + h - 1, c + w - 1, BOX.br);
101
+
102
+ // Label (centered)
103
+ const label = node.label ?? node.id;
104
+ const maxLabel = w - 4;
105
+ const truncated = label.length > maxLabel
106
+ ? `${label.slice(0, Math.max(maxLabel - 1, 1))}\u2026`
107
+ : label;
108
+ const labelRow = r + Math.floor(h / 2);
109
+ const labelCol = c + Math.max(1, Math.floor((w - truncated.length) / 2));
110
+ writeString(grid, labelRow, labelCol, truncated);
111
+ }
112
+
113
+ // ── Edge tracing ─────────────────────────────────────────────────────────────
114
+
115
+ function traceEdge(grid, edge, nodeSet) {
116
+ const { sections } = edge;
117
+ if (!sections || sections.length === 0) {
118
+ return;
119
+ }
120
+
121
+ for (const section of sections) {
122
+ const points = buildPointList(section);
123
+ drawSegments(grid, points, nodeSet);
124
+ }
125
+
126
+ // Arrowhead at the end of the last section
127
+ const lastSection = sections[sections.length - 1];
128
+ const ep = lastSection.endPoint;
129
+ if (ep) {
130
+ drawArrowhead(grid, lastSection, nodeSet);
131
+ }
132
+
133
+ // Edge label at midpoint of the longest section segment
134
+ if (edge.label) {
135
+ placeEdgeLabel(grid, sections, edge.label, nodeSet);
136
+ }
137
+ }
138
+
139
+ function buildPointList(section) {
140
+ const points = [];
141
+ if (section.startPoint) {
142
+ points.push(section.startPoint);
143
+ }
144
+ if (section.bendPoints) {
145
+ points.push(...section.bendPoints);
146
+ }
147
+ if (section.endPoint) {
148
+ points.push(section.endPoint);
149
+ }
150
+ return points;
151
+ }
152
+
153
+ function drawSegments(grid, points, nodeSet) {
154
+ for (let i = 0; i < points.length - 1; i++) {
155
+ const r1 = toRow(points[i].y);
156
+ const c1 = toCol(points[i].x);
157
+ const r2 = toRow(points[i + 1].y);
158
+ const c2 = toCol(points[i + 1].x);
159
+ drawLine(grid, r1, c1, r2, c2, nodeSet);
160
+ }
161
+ }
162
+
163
+ function drawLine(grid, r1, c1, r2, c2, nodeSet) {
164
+ if (r1 === r2) {
165
+ drawHorizontal(grid, r1, c1, c2, nodeSet);
166
+ } else if (c1 === c2) {
167
+ drawVertical(grid, c1, r1, r2, nodeSet);
168
+ } else {
169
+ // Diagonal: draw L-shaped bend (horizontal first, then vertical)
170
+ drawHorizontal(grid, r1, c1, c2, nodeSet);
171
+ drawVertical(grid, c2, r1, r2, nodeSet);
172
+ }
173
+ }
174
+
175
+ function drawHorizontal(grid, row, c1, c2, nodeSet) {
176
+ const start = Math.min(c1, c2);
177
+ const end = Math.max(c1, c2);
178
+ for (let c = start; c <= end; c++) {
179
+ if (!isNodeCell(nodeSet, row, c)) {
180
+ const existing = readChar(grid, row, c);
181
+ if (existing === BOX.v || existing === '|') {
182
+ writeChar(grid, row, c, '+');
183
+ } else {
184
+ writeChar(grid, row, c, BOX.h);
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ function drawVertical(grid, col, r1, r2, nodeSet) {
191
+ const start = Math.min(r1, r2);
192
+ const end = Math.max(r1, r2);
193
+ for (let r = start; r <= end; r++) {
194
+ if (!isNodeCell(nodeSet, r, col)) {
195
+ const existing = readChar(grid, r, col);
196
+ if (existing === BOX.h || existing === '-') {
197
+ writeChar(grid, r, col, '+');
198
+ } else {
199
+ writeChar(grid, r, col, BOX.v);
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ function drawArrowhead(grid, section, nodeSet) {
206
+ const ep = section.endPoint;
207
+ if (!ep) {
208
+ return;
209
+ }
210
+ const er = toRow(ep.y);
211
+ const ec = toCol(ep.x);
212
+
213
+ // Determine direction from last segment
214
+ const bends = section.bendPoints ?? [];
215
+ const prev = bends.length > 0 ? bends[bends.length - 1] : section.startPoint;
216
+ if (!prev) {
217
+ return;
218
+ }
219
+ const pr = toRow(prev.y);
220
+ const pc = toCol(prev.x);
221
+
222
+ let arrow;
223
+ if (er > pr) {
224
+ arrow = ARROW.down;
225
+ } else if (er < pr) {
226
+ arrow = ARROW.up;
227
+ } else if (ec > pc) {
228
+ arrow = ARROW.right;
229
+ } else {
230
+ arrow = ARROW.left;
231
+ }
232
+
233
+ if (!isNodeCell(nodeSet, er, ec)) {
234
+ writeChar(grid, er, ec, arrow);
235
+ }
236
+ }
237
+
238
+ function placeEdgeLabel(grid, sections, label, nodeSet) {
239
+ // Find midpoint of the full path
240
+ const allPoints = [];
241
+ for (const s of sections) {
242
+ allPoints.push(...buildPointList(s));
243
+ }
244
+ if (allPoints.length < 2) {
245
+ return;
246
+ }
247
+
248
+ // Pick midpoint of the longest segment
249
+ let bestLen = 0;
250
+ let midR = 0;
251
+ let midC = 0;
252
+ for (let i = 0; i < allPoints.length - 1; i++) {
253
+ const r1 = toRow(allPoints[i].y);
254
+ const c1 = toCol(allPoints[i].x);
255
+ const r2 = toRow(allPoints[i + 1].y);
256
+ const c2 = toCol(allPoints[i + 1].x);
257
+ const len = Math.abs(r2 - r1) + Math.abs(c2 - c1);
258
+ if (len > bestLen) {
259
+ bestLen = len;
260
+ midR = Math.floor((r1 + r2) / 2);
261
+ midC = Math.floor((c1 + c2) / 2);
262
+ }
263
+ }
264
+
265
+ const trunc = label.length > 10
266
+ ? `${label.slice(0, 9)}\u2026`
267
+ : label;
268
+ const startC = midC - Math.floor(trunc.length / 2);
269
+
270
+ for (let i = 0; i < trunc.length; i++) {
271
+ const tc = startC + i;
272
+ if (!isNodeCell(nodeSet, midR, tc)) {
273
+ writeChar(grid, midR, tc, trunc[i]);
274
+ }
275
+ }
276
+ }
277
+
278
+ // ── Node occupancy set ───────────────────────────────────────────────────────
279
+
280
+ function buildNodeSet(nodes) {
281
+ const set = new Set();
282
+ for (const node of nodes) {
283
+ const r = toRow(node.y);
284
+ const c = toCol(node.x);
285
+ const w = Math.max(scaleW(node.width), 4);
286
+ const h = Math.max(scaleH(node.height), 3);
287
+ for (let dr = 0; dr < h; dr++) {
288
+ for (let dc = 0; dc < w; dc++) {
289
+ set.add(`${r + dr},${c + dc}`);
290
+ }
291
+ }
292
+ }
293
+ return set;
294
+ }
295
+
296
+ function isNodeCell(nodeSet, r, c) {
297
+ return nodeSet.has(`${r},${c}`);
298
+ }
299
+
300
+ // ── Public API ───────────────────────────────────────────────────────────────
301
+
302
+ /**
303
+ * Renders a PositionedGraph (from ELK) as an ASCII box-drawing string.
304
+ *
305
+ * @param {Object} positionedGraph - PositionedGraph from runLayout()
306
+ * @param {{ title?: string }} [options]
307
+ * @returns {string} Rendered ASCII art wrapped in a box
308
+ */
309
+ export function renderGraphView(positionedGraph, options = {}) {
310
+ const { nodes = [], edges = [] } = positionedGraph;
311
+
312
+ if (nodes.length === 0) {
313
+ return createBox(colors.muted(' (empty graph)'), {
314
+ title: options.title ?? 'GRAPH',
315
+ titleAlignment: 'center',
316
+ borderColor: 'cyan',
317
+ });
318
+ }
319
+
320
+ const totalCols = scaleW(positionedGraph.width) + MARGIN * 2;
321
+ const totalRows = scaleH(positionedGraph.height) + MARGIN * 2;
322
+
323
+ const grid = createGrid(totalRows, totalCols);
324
+ const nodeSet = buildNodeSet(nodes);
325
+
326
+ // Edges first (nodes overwrite on overlap)
327
+ for (const edge of edges) {
328
+ traceEdge(grid, edge, nodeSet);
329
+ }
330
+ for (const node of nodes) {
331
+ stampNode(grid, node);
332
+ }
333
+
334
+ // Colorize and join
335
+ const raw = grid
336
+ .map((row) => row.join('').replace(/\s+$/, ''))
337
+ .join('\n');
338
+
339
+ return createBox(raw, {
340
+ title: options.title ?? 'GRAPH',
341
+ titleAlignment: 'center',
342
+ borderColor: 'cyan',
343
+ });
344
+ }