@git-stunts/git-warp 10.7.0 → 10.8.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.
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Unified output dispatcher for CLI commands.
3
+ *
4
+ * Replaces the 112-line emit() function in warp-graph.js with clean
5
+ * format dispatch: text, json, ndjson — plus view mode handling.
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import process from 'node:process';
10
+
11
+ import { stripAnsi } from '../../src/visualization/utils/ansi.js';
12
+ import { renderInfoView } from '../../src/visualization/renderers/ascii/info.js';
13
+ import { renderCheckView } from '../../src/visualization/renderers/ascii/check.js';
14
+ import { renderHistoryView } from '../../src/visualization/renderers/ascii/history.js';
15
+ import { renderPathView } from '../../src/visualization/renderers/ascii/path.js';
16
+ import { renderMaterializeView } from '../../src/visualization/renderers/ascii/materialize.js';
17
+ import { renderSeekView } from '../../src/visualization/renderers/ascii/seek.js';
18
+
19
+ import { stableStringify, compactStringify, sanitizePayload } from './json.js';
20
+ import {
21
+ renderInfo,
22
+ renderQuery,
23
+ renderPath,
24
+ renderCheck,
25
+ renderHistory,
26
+ renderError,
27
+ renderMaterialize,
28
+ renderInstallHooks,
29
+ renderSeek,
30
+ } from './text.js';
31
+
32
+ // ── Color control ────────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Determines whether ANSI color codes should be stripped from output.
36
+ *
37
+ * Precedence: FORCE_COLOR=0 (strip) > FORCE_COLOR!='' (keep) > NO_COLOR > !isTTY > CI.
38
+ * @returns {boolean}
39
+ */
40
+ export function shouldStripColor() {
41
+ if (process.env.FORCE_COLOR === '0') {
42
+ return true;
43
+ }
44
+ if (process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== '') {
45
+ return false;
46
+ }
47
+ if (process.env.NO_COLOR !== undefined) {
48
+ return true;
49
+ }
50
+ if (!process.stdout.isTTY) {
51
+ return true;
52
+ }
53
+ if (process.env.CI !== undefined) {
54
+ return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ // ── Text renderer map ────────────────────────────────────────────────────────
60
+
61
+ /** @type {Map<string, function(*): string>} */
62
+ const TEXT_RENDERERS = new Map(/** @type {[string, function(*): string][]} */ ([
63
+ ['info', renderInfo],
64
+ ['query', renderQuery],
65
+ ['path', renderPath],
66
+ ['check', renderCheck],
67
+ ['history', renderHistory],
68
+ ['materialize', renderMaterialize],
69
+ ['seek', renderSeek],
70
+ ['install-hooks', renderInstallHooks],
71
+ ]));
72
+
73
+ /** @type {Map<string, function(*): string>} */
74
+ const VIEW_RENDERERS = new Map(/** @type {[string, function(*): string][]} */ ([
75
+ ['info', renderInfoView],
76
+ ['check', renderCheckView],
77
+ ['history', renderHistoryView],
78
+ ['path', renderPathView],
79
+ ['materialize', renderMaterializeView],
80
+ ['seek', renderSeekView],
81
+ ]));
82
+
83
+ // ── HTML export ──────────────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Wraps SVG content in a minimal HTML document and writes it to disk.
87
+ * @param {string} filePath
88
+ * @param {string} svgContent
89
+ */
90
+ function writeHtmlExport(filePath, svgContent) {
91
+ const html = `<!DOCTYPE html>\n<html><head><meta charset="utf-8"><title>git-warp</title></head><body>\n${svgContent}\n</body></html>`;
92
+ fs.writeFileSync(filePath, html);
93
+ }
94
+
95
+ // ── SVG / HTML file export ───────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Handles svg:PATH and html:PATH view modes for commands that carry _renderedSvg.
99
+ * @param {*} payload
100
+ * @param {string} view
101
+ * @returns {boolean} true if handled
102
+ */
103
+ function handleFileExport(payload, view) {
104
+ if (typeof view === 'string' && view.startsWith('svg:')) {
105
+ const svgPath = view.slice(4);
106
+ if (!payload._renderedSvg) {
107
+ process.stderr.write('No graph data — skipping SVG export.\n');
108
+ } else {
109
+ fs.writeFileSync(svgPath, payload._renderedSvg);
110
+ process.stderr.write(`SVG written to ${svgPath}\n`);
111
+ }
112
+ return true;
113
+ }
114
+ if (typeof view === 'string' && view.startsWith('html:')) {
115
+ const htmlPath = view.slice(5);
116
+ if (!payload._renderedSvg) {
117
+ process.stderr.write('No graph data — skipping HTML export.\n');
118
+ } else {
119
+ writeHtmlExport(htmlPath, payload._renderedSvg);
120
+ process.stderr.write(`HTML written to ${htmlPath}\n`);
121
+ }
122
+ return true;
123
+ }
124
+ return false;
125
+ }
126
+
127
+ // ── Output helpers ───────────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Writes text to stdout, optionally stripping ANSI codes.
131
+ * @param {string} text
132
+ * @param {boolean} strip
133
+ */
134
+ function writeText(text, strip) {
135
+ process.stdout.write(strip ? stripAnsi(text) : text);
136
+ }
137
+
138
+ // ── Main dispatcher ──────────────────────────────────────────────────────────
139
+
140
+ /**
141
+ * Writes a command result to stdout/stderr in the requested format.
142
+ *
143
+ * @param {*} payload - Command result payload
144
+ * @param {{format: string, command: string, view: string|null|boolean}} options
145
+ */
146
+ export function present(payload, { format, command, view }) {
147
+ // Error payloads always go to stderr as plain text
148
+ if (payload?.error) {
149
+ process.stderr.write(renderError(payload));
150
+ return;
151
+ }
152
+
153
+ // JSON: sanitize + pretty-print
154
+ if (format === 'json') {
155
+ process.stdout.write(`${stableStringify(sanitizePayload(payload))}\n`);
156
+ return;
157
+ }
158
+
159
+ // NDJSON: sanitize + compact single line
160
+ if (format === 'ndjson') {
161
+ process.stdout.write(`${compactStringify(sanitizePayload(payload))}\n`);
162
+ return;
163
+ }
164
+
165
+ // Text with view mode
166
+ if (view) {
167
+ presentView(payload, command, view);
168
+ return;
169
+ }
170
+
171
+ // Plain text
172
+ const renderer = TEXT_RENDERERS.get(command);
173
+ if (renderer) {
174
+ writeText(renderer(payload), shouldStripColor());
175
+ } else {
176
+ // Fallback for unknown commands
177
+ process.stdout.write(`${stableStringify(sanitizePayload(payload))}\n`);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Handles --view output dispatch (ASCII view, SVG file, HTML file).
183
+ * @param {*} payload
184
+ * @param {string} command
185
+ * @param {string|boolean} view
186
+ */
187
+ function presentView(payload, command, view) {
188
+ const strip = shouldStripColor();
189
+
190
+ // File exports: svg:PATH, html:PATH
191
+ if (handleFileExport(payload, /** @type {string} */ (view))) {
192
+ return;
193
+ }
194
+
195
+ // query is special: uses pre-rendered _renderedAscii
196
+ if (command === 'query') {
197
+ writeText(`${payload._renderedAscii ?? ''}\n`, strip);
198
+ return;
199
+ }
200
+
201
+ // Dispatch to view renderer
202
+ const viewRenderer = VIEW_RENDERERS.get(command);
203
+ if (viewRenderer) {
204
+ writeText(viewRenderer(payload), strip);
205
+ } else {
206
+ writeText(`${stableStringify(sanitizePayload(payload))}\n`, strip);
207
+ }
208
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * JSON / NDJSON serialization utilities for CLI output.
3
+ *
4
+ * - stableStringify: pretty-printed, sorted-key JSON (--json)
5
+ * - compactStringify: single-line, sorted-key JSON (--ndjson)
6
+ * - sanitizePayload: strips internal _-prefixed keys before serialization
7
+ */
8
+
9
+ /**
10
+ * Recursively sorts object keys for deterministic JSON output.
11
+ * @param {*} input
12
+ * @returns {*}
13
+ */
14
+ function normalize(input) {
15
+ if (Array.isArray(input)) {
16
+ return input.map(normalize);
17
+ }
18
+ if (input && typeof input === 'object') {
19
+ /** @type {Record<string, *>} */
20
+ const sorted = {};
21
+ for (const key of Object.keys(input).sort()) {
22
+ sorted[key] = normalize(input[key]);
23
+ }
24
+ return sorted;
25
+ }
26
+ return input;
27
+ }
28
+
29
+ /**
30
+ * Pretty-printed JSON with sorted keys (2-space indent).
31
+ * @param {*} value
32
+ * @returns {string}
33
+ */
34
+ export function stableStringify(value) {
35
+ return JSON.stringify(normalize(value), null, 2);
36
+ }
37
+
38
+ /**
39
+ * Single-line JSON with sorted keys (no indent).
40
+ * @param {*} value
41
+ * @returns {string}
42
+ */
43
+ export function compactStringify(value) {
44
+ return JSON.stringify(normalize(value));
45
+ }
46
+
47
+ /**
48
+ * Shallow-clones a payload, removing all top-level underscore-prefixed keys.
49
+ * These are internal rendering artifacts (e.g. _renderedSvg, _renderedAscii)
50
+ * that should not leak into JSON/NDJSON output.
51
+ * @param {*} payload
52
+ * @returns {*}
53
+ */
54
+ export function sanitizePayload(payload) {
55
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
56
+ return payload;
57
+ }
58
+ /** @type {Record<string, *>} */
59
+ const clean = {};
60
+ for (const key of Object.keys(payload)) {
61
+ if (!key.startsWith('_')) {
62
+ clean[key] = payload[key];
63
+ }
64
+ }
65
+ return clean;
66
+ }
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Plain-text renderers for CLI output.
3
+ *
4
+ * Each function accepts a command payload and returns a formatted string
5
+ * (with trailing newline) suitable for process.stdout.write().
6
+ */
7
+
8
+ import { formatStructuralDiff } from '../../src/visualization/renderers/ascii/seek.js';
9
+
10
+ // ── ANSI helpers ─────────────────────────────────────────────────────────────
11
+
12
+ const ANSI_GREEN = '\x1b[32m';
13
+ const ANSI_YELLOW = '\x1b[33m';
14
+ const ANSI_RED = '\x1b[31m';
15
+ const ANSI_DIM = '\x1b[2m';
16
+ const ANSI_RESET = '\x1b[0m';
17
+
18
+ /** @param {string} state */
19
+ function colorCachedState(state) {
20
+ if (state === 'fresh') {
21
+ return `${ANSI_GREEN}${state}${ANSI_RESET}`;
22
+ }
23
+ if (state === 'stale') {
24
+ return `${ANSI_YELLOW}${state}${ANSI_RESET}`;
25
+ }
26
+ return `${ANSI_RED}${ANSI_DIM}${state}${ANSI_RESET}`;
27
+ }
28
+
29
+ /** @param {*} hook */
30
+ function formatHookStatusLine(hook) {
31
+ if (!hook.installed && hook.foreign) {
32
+ return "Hook: foreign hook present — run 'git warp install-hooks'";
33
+ }
34
+ if (!hook.installed) {
35
+ return "Hook: not installed — run 'git warp install-hooks'";
36
+ }
37
+ if (hook.current) {
38
+ return `Hook: installed (v${hook.version}) — up to date`;
39
+ }
40
+ return `Hook: installed (v${hook.version}) — upgrade available, run 'git warp install-hooks'`;
41
+ }
42
+
43
+ // ── Simple renderers ─────────────────────────────────────────────────────────
44
+
45
+ /** @param {*} payload */
46
+ export function renderInfo(payload) {
47
+ const lines = [`Repo: ${payload.repo}`];
48
+ lines.push(`Graphs: ${payload.graphs.length}`);
49
+ for (const graph of payload.graphs) {
50
+ const writers = graph.writers ? ` writers=${graph.writers.count}` : '';
51
+ lines.push(`- ${graph.name}${writers}`);
52
+ if (graph.checkpoint?.sha) {
53
+ lines.push(` checkpoint: ${graph.checkpoint.sha}`);
54
+ }
55
+ if (graph.coverage?.sha) {
56
+ lines.push(` coverage: ${graph.coverage.sha}`);
57
+ }
58
+ if (graph.cursor?.active) {
59
+ lines.push(` cursor: tick ${graph.cursor.tick} (${graph.cursor.mode})`);
60
+ }
61
+ }
62
+ return `${lines.join('\n')}\n`;
63
+ }
64
+
65
+ /** @param {*} payload */
66
+ export function renderQuery(payload) {
67
+ const lines = [
68
+ `Graph: ${payload.graph}`,
69
+ `State: ${payload.stateHash}`,
70
+ `Nodes: ${payload.nodes.length}`,
71
+ ];
72
+
73
+ for (const node of payload.nodes) {
74
+ const id = node.id ?? '(unknown)';
75
+ lines.push(`- ${id}`);
76
+ if (node.props && Object.keys(node.props).length > 0) {
77
+ lines.push(` props: ${JSON.stringify(node.props)}`);
78
+ }
79
+ }
80
+
81
+ return `${lines.join('\n')}\n`;
82
+ }
83
+
84
+ /** @param {*} payload */
85
+ export function renderPath(payload) {
86
+ const lines = [
87
+ `Graph: ${payload.graph}`,
88
+ `From: ${payload.from}`,
89
+ `To: ${payload.to}`,
90
+ `Found: ${payload.found ? 'yes' : 'no'}`,
91
+ `Length: ${payload.length}`,
92
+ ];
93
+
94
+ if (payload.path && payload.path.length > 0) {
95
+ lines.push(`Path: ${payload.path.join(' -> ')}`);
96
+ }
97
+
98
+ return `${lines.join('\n')}\n`;
99
+ }
100
+
101
+ /**
102
+ * Appends checkpoint and writer lines to check output.
103
+ * @param {string[]} lines
104
+ * @param {*} payload
105
+ */
106
+ function appendCheckpointAndWriters(lines, payload) {
107
+ if (payload.checkpoint?.sha) {
108
+ lines.push(`Checkpoint: ${payload.checkpoint.sha}`);
109
+ if (payload.checkpoint.ageSeconds !== null) {
110
+ lines.push(`Checkpoint Age: ${payload.checkpoint.ageSeconds}s`);
111
+ }
112
+ } else {
113
+ lines.push('Checkpoint: none');
114
+ }
115
+
116
+ if (!payload.status) {
117
+ lines.push(`Writers: ${payload.writers.count}`);
118
+ }
119
+ for (const head of payload.writers.heads) {
120
+ lines.push(`- ${head.writerId}: ${head.sha}`);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Appends coverage, gc, and hook lines to check output.
126
+ * @param {string[]} lines
127
+ * @param {*} payload
128
+ */
129
+ function appendCoverageAndExtras(lines, payload) {
130
+ if (payload.coverage?.sha) {
131
+ lines.push(`Coverage: ${payload.coverage.sha}`);
132
+ lines.push(`Coverage Missing: ${payload.coverage.missingWriters.length}`);
133
+ } else {
134
+ lines.push('Coverage: none');
135
+ }
136
+
137
+ if (payload.gc) {
138
+ lines.push(`Tombstones: ${payload.gc.totalTombstones}`);
139
+ if (!payload.status) {
140
+ lines.push(`Tombstone Ratio: ${payload.gc.tombstoneRatio}`);
141
+ }
142
+ }
143
+
144
+ if (payload.hook) {
145
+ lines.push(formatHookStatusLine(payload.hook));
146
+ }
147
+ }
148
+
149
+ /** @param {*} payload */
150
+ export function renderCheck(payload) {
151
+ const lines = [
152
+ `Graph: ${payload.graph}`,
153
+ `Health: ${payload.health.status}`,
154
+ ];
155
+
156
+ if (payload.status) {
157
+ lines.push(`Cached State: ${colorCachedState(payload.status.cachedState)}`);
158
+ lines.push(`Patches Since Checkpoint: ${payload.status.patchesSinceCheckpoint}`);
159
+ lines.push(`Tombstone Ratio: ${payload.status.tombstoneRatio.toFixed(2)}`);
160
+ lines.push(`Writers: ${payload.status.writers}`);
161
+ }
162
+
163
+ appendCheckpointAndWriters(lines, payload);
164
+ appendCoverageAndExtras(lines, payload);
165
+ return `${lines.join('\n')}\n`;
166
+ }
167
+
168
+ /** @param {*} payload */
169
+ export function renderHistory(payload) {
170
+ const lines = [
171
+ `Graph: ${payload.graph}`,
172
+ `Writer: ${payload.writer}`,
173
+ `Entries: ${payload.entries.length}`,
174
+ ];
175
+
176
+ if (payload.nodeFilter) {
177
+ lines.push(`Node Filter: ${payload.nodeFilter}`);
178
+ }
179
+
180
+ for (const entry of payload.entries) {
181
+ lines.push(`- ${entry.sha} (lamport: ${entry.lamport}, ops: ${entry.opCount})`);
182
+ }
183
+
184
+ return `${lines.join('\n')}\n`;
185
+ }
186
+
187
+ /** @param {*} payload */
188
+ export function renderError(payload) {
189
+ return `Error: ${payload.error.message}\n`;
190
+ }
191
+
192
+ /** @param {*} payload */
193
+ export function renderMaterialize(payload) {
194
+ if (payload.graphs.length === 0) {
195
+ return 'No graphs found in repo.\n';
196
+ }
197
+
198
+ const lines = [];
199
+ for (const entry of payload.graphs) {
200
+ if (entry.error) {
201
+ lines.push(`${entry.graph}: error — ${entry.error}`);
202
+ } else {
203
+ lines.push(`${entry.graph}: ${entry.nodes} nodes, ${entry.edges} edges, checkpoint ${entry.checkpoint}`);
204
+ }
205
+ }
206
+ return `${lines.join('\n')}\n`;
207
+ }
208
+
209
+ /** @param {*} payload */
210
+ export function renderInstallHooks(payload) {
211
+ if (payload.action === 'up-to-date') {
212
+ return `Hook: already up to date (v${payload.version}) at ${payload.hookPath}\n`;
213
+ }
214
+ if (payload.action === 'skipped') {
215
+ return 'Hook: installation skipped\n';
216
+ }
217
+ const lines = [`Hook: ${payload.action} (v${payload.version})`, `Path: ${payload.hookPath}`];
218
+ if (payload.backupPath) {
219
+ lines.push(`Backup: ${payload.backupPath}`);
220
+ }
221
+ return `${lines.join('\n')}\n`;
222
+ }
223
+
224
+ // ── Seek helpers (extracted for ESLint 50-line limit) ────────────────────────
225
+
226
+ /**
227
+ * Formats a numeric delta as " (+N)" or " (-N)", or empty string for zero/non-finite.
228
+ * @param {*} n
229
+ * @returns {string}
230
+ */
231
+ function formatDelta(n) { // TODO(ts-cleanup): type CLI payload
232
+ if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) {
233
+ return '';
234
+ }
235
+ const sign = n > 0 ? '+' : '';
236
+ return ` (${sign}${n})`;
237
+ }
238
+
239
+ /**
240
+ * Formats an operation summary object as a compact plain-text string.
241
+ * @param {*} summary
242
+ * @returns {string}
243
+ */
244
+ function formatOpSummaryPlain(summary) { // TODO(ts-cleanup): type CLI payload
245
+ const order = [
246
+ ['NodeAdd', '+', 'node'],
247
+ ['EdgeAdd', '+', 'edge'],
248
+ ['PropSet', '~', 'prop'],
249
+ ['NodeTombstone', '-', 'node'],
250
+ ['EdgeTombstone', '-', 'edge'],
251
+ ['BlobValue', '+', 'blob'],
252
+ ];
253
+
254
+ const parts = [];
255
+ for (const [opType, symbol, label] of order) {
256
+ const n = summary?.[opType];
257
+ if (typeof n === 'number' && Number.isFinite(n) && n > 0) {
258
+ parts.push(`${symbol}${n}${label}`);
259
+ }
260
+ }
261
+ return parts.length > 0 ? parts.join(' ') : '(empty)';
262
+ }
263
+
264
+ /**
265
+ * Appends a per-writer tick receipt summary below a base line.
266
+ * @param {string} baseLine
267
+ * @param {*} payload
268
+ * @returns {string}
269
+ */
270
+ function appendReceiptSummary(baseLine, payload) {
271
+ const tickReceipt = payload?.tickReceipt;
272
+ if (!tickReceipt || typeof tickReceipt !== 'object') {
273
+ return `${baseLine}\n`;
274
+ }
275
+
276
+ const entries = Object.entries(tickReceipt)
277
+ .filter(([writerId, entry]) => writerId && entry && typeof entry === 'object')
278
+ .sort(([a], [b]) => a.localeCompare(b));
279
+
280
+ if (entries.length === 0) {
281
+ return `${baseLine}\n`;
282
+ }
283
+
284
+ const maxWriterLen = Math.max(5, ...entries.map(([writerId]) => writerId.length));
285
+ const receiptLines = [` Tick ${payload.tick}:`];
286
+ for (const [writerId, entry] of entries) {
287
+ const sha = typeof entry.sha === 'string' ? entry.sha.slice(0, 7) : '';
288
+ const opSummary = entry.opSummary && typeof entry.opSummary === 'object' ? entry.opSummary : entry;
289
+ receiptLines.push(` ${writerId.padEnd(maxWriterLen)} ${sha.padEnd(7)} ${formatOpSummaryPlain(opSummary)}`);
290
+ }
291
+
292
+ return `${baseLine}\n${receiptLines.join('\n')}\n`;
293
+ }
294
+
295
+ /**
296
+ * Builds human-readable state count strings from a seek payload.
297
+ * @param {*} payload
298
+ * @returns {{nodesStr: string, edgesStr: string, patchesStr: string}}
299
+ */
300
+ function buildStateStrings(payload) {
301
+ const nodeLabel = payload.nodes === 1 ? 'node' : 'nodes';
302
+ const edgeLabel = payload.edges === 1 ? 'edge' : 'edges';
303
+ const patchLabel = payload.patchCount === 1 ? 'patch' : 'patches';
304
+ return {
305
+ nodesStr: `${payload.nodes} ${nodeLabel}${formatDelta(payload.diff?.nodes)}`,
306
+ edgesStr: `${payload.edges} ${edgeLabel}${formatDelta(payload.diff?.edges)}`,
307
+ patchesStr: `${payload.patchCount} ${patchLabel}`,
308
+ };
309
+ }
310
+
311
+ /**
312
+ * Renders the "tick" / "latest" / "load" seek action with receipt + structural diff.
313
+ * @param {*} payload
314
+ * @param {string} headerLine
315
+ * @returns {string}
316
+ */
317
+ function renderSeekWithDiff(payload, headerLine) {
318
+ const base = appendReceiptSummary(headerLine, payload);
319
+ return base + formatStructuralDiff(payload);
320
+ }
321
+
322
+ // ── Seek simple-action renderers ─────────────────────────────────────────────
323
+
324
+ /**
325
+ * Renders seek actions that don't involve state counts: clear-cache, list, drop, save.
326
+ * @param {*} payload
327
+ * @returns {string|null} Rendered string, or null if action is not simple
328
+ */
329
+ function renderSeekSimple(payload) {
330
+ if (payload.action === 'clear-cache') {
331
+ return `${payload.message}\n`;
332
+ }
333
+ if (payload.action === 'drop') {
334
+ return `Dropped cursor "${payload.name}" (was at tick ${payload.tick}).\n`;
335
+ }
336
+ if (payload.action === 'save') {
337
+ return `Saved cursor "${payload.name}" at tick ${payload.tick}.\n`;
338
+ }
339
+ if (payload.action === 'list') {
340
+ return renderSeekList(payload);
341
+ }
342
+ return null;
343
+ }
344
+
345
+ /**
346
+ * Renders the cursor list action.
347
+ * @param {*} payload
348
+ * @returns {string}
349
+ */
350
+ function renderSeekList(payload) {
351
+ if (payload.cursors.length === 0) {
352
+ return 'No saved cursors.\n';
353
+ }
354
+ const lines = [];
355
+ for (const c of payload.cursors) {
356
+ const active = c.tick === payload.activeTick ? ' (active)' : '';
357
+ lines.push(` ${c.name}: tick ${c.tick}${active}`);
358
+ }
359
+ return `${lines.join('\n')}\n`;
360
+ }
361
+
362
+ // ── Seek state-action renderer ───────────────────────────────────────────────
363
+
364
+ /**
365
+ * Renders seek actions that show state: latest, load, tick, status.
366
+ * @param {*} payload
367
+ * @returns {string}
368
+ */
369
+ function renderSeekState(payload) {
370
+ if (payload.action === 'latest') {
371
+ const { nodesStr, edgesStr } = buildStateStrings(payload);
372
+ return renderSeekWithDiff(
373
+ payload,
374
+ `${payload.graph}: returned to present (tick ${payload.maxTick}, ${nodesStr}, ${edgesStr})`,
375
+ );
376
+ }
377
+ if (payload.action === 'load') {
378
+ const { nodesStr, edgesStr } = buildStateStrings(payload);
379
+ return renderSeekWithDiff(
380
+ payload,
381
+ `${payload.graph}: loaded cursor "${payload.name}" at tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr})`,
382
+ );
383
+ }
384
+ if (payload.action === 'tick') {
385
+ const { nodesStr, edgesStr, patchesStr } = buildStateStrings(payload);
386
+ return renderSeekWithDiff(
387
+ payload,
388
+ `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
389
+ );
390
+ }
391
+ // status (structuralDiff is never populated here; no formatStructuralDiff call)
392
+ if (payload.cursor && payload.cursor.active) {
393
+ const { nodesStr, edgesStr, patchesStr } = buildStateStrings(payload);
394
+ return appendReceiptSummary(
395
+ `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
396
+ payload,
397
+ );
398
+ }
399
+ return `${payload.graph}: no cursor active, ${payload.ticks.length} ticks available\n`;
400
+ }
401
+
402
+ // ── Seek main renderer ──────────────────────────────────────────────────────
403
+
404
+ /** @param {*} payload */
405
+ export function renderSeek(payload) {
406
+ return renderSeekSimple(payload) ?? renderSeekState(payload);
407
+ }
package/bin/warp-graph.js CHANGED
@@ -25,17 +25,15 @@ import {
25
25
  } from '../src/domain/utils/RefLayout.js';
26
26
  import CasSeekCacheAdapter from '../src/infrastructure/adapters/CasSeekCacheAdapter.js';
27
27
  import { HookInstaller, classifyExistingHook } from '../src/domain/services/HookInstaller.js';
28
- import { renderInfoView } from '../src/visualization/renderers/ascii/info.js';
29
- import { renderCheckView } from '../src/visualization/renderers/ascii/check.js';
30
- import { renderHistoryView, summarizeOps } from '../src/visualization/renderers/ascii/history.js';
31
- import { renderPathView } from '../src/visualization/renderers/ascii/path.js';
32
- import { renderMaterializeView } from '../src/visualization/renderers/ascii/materialize.js';
28
+ import { summarizeOps } from '../src/visualization/renderers/ascii/history.js';
33
29
  import { parseCursorBlob } from '../src/domain/utils/parseCursorBlob.js';
34
30
  import { diffStates } from '../src/domain/services/StateDiff.js';
35
- import { renderSeekView, formatStructuralDiff } from '../src/visualization/renderers/ascii/seek.js';
36
31
  import { renderGraphView } from '../src/visualization/renderers/ascii/graph.js';
37
32
  import { renderSvg } from '../src/visualization/renderers/svg/index.js';
38
33
  import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../src/visualization/layouts/index.js';
34
+ import { present } from './presenters/index.js';
35
+ import { stableStringify, compactStringify } from './presenters/json.js';
36
+ import { renderError } from './presenters/text.js';
39
37
 
40
38
  /**
41
39
  * @typedef {Object} Persistence
@@ -93,6 +91,7 @@ import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../s
93
91
  * @typedef {Object} CliOptions
94
92
  * @property {string} repo
95
93
  * @property {boolean} json
94
+ * @property {boolean} ndjson
96
95
  * @property {string|null} view
97
96
  * @property {string|null} graph
98
97
  * @property {string} writer
@@ -142,7 +141,8 @@ Commands:
142
141
 
143
142
  Options:
144
143
  --repo <path> Path to git repo (default: cwd)
145
- --json Emit JSON output
144
+ --json Emit JSON output (pretty-printed, sorted keys)
145
+ --ndjson Emit compact single-line JSON (for piping/scripting)
146
146
  --view [mode] Visual output (ascii, browser, svg:FILE, html:FILE)
147
147
  --graph <name> Graph name (required if repo has multiple graphs)
148
148
  --writer <id> Writer id (default: cli)
@@ -208,27 +208,6 @@ function notFoundError(message) {
208
208
  return new CliError(message, { code: 'E_NOT_FOUND', exitCode: EXIT_CODES.NOT_FOUND });
209
209
  }
210
210
 
211
- /** @param {*} value */
212
- function stableStringify(value) {
213
- /** @param {*} input @returns {*} */
214
- const normalize = (input) => {
215
- if (Array.isArray(input)) {
216
- return input.map(normalize);
217
- }
218
- if (input && typeof input === 'object') {
219
- /** @type {Record<string, *>} */
220
- const sorted = {};
221
- for (const key of Object.keys(input).sort()) {
222
- sorted[key] = normalize(input[key]);
223
- }
224
- return sorted;
225
- }
226
- return input;
227
- };
228
-
229
- return JSON.stringify(normalize(value), null, 2);
230
- }
231
-
232
211
  /** @param {string[]} argv */
233
212
  function parseArgs(argv) {
234
213
  const options = createDefaultOptions();
@@ -256,6 +235,7 @@ function createDefaultOptions() {
256
235
  return {
257
236
  repo: process.cwd(),
258
237
  json: false,
238
+ ndjson: false,
259
239
  view: null,
260
240
  graph: null,
261
241
  writer: 'cli',
@@ -284,6 +264,11 @@ function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
284
264
  return { consumed: 0 };
285
265
  }
286
266
 
267
+ if (arg === '--ndjson') {
268
+ options.ndjson = true;
269
+ return { consumed: 0 };
270
+ }
271
+
287
272
  if (arg === '--view') {
288
273
  // Valid view modes: ascii, browser, svg:FILE, html:FILE
289
274
  // Don't consume known commands as modes
@@ -793,299 +778,6 @@ function patchTouchesNode(patch, nodeId) {
793
778
  return false;
794
779
  }
795
780
 
796
- /** @param {*} payload */
797
- function renderInfo(payload) {
798
- const lines = [`Repo: ${payload.repo}`];
799
- lines.push(`Graphs: ${payload.graphs.length}`);
800
- for (const graph of payload.graphs) {
801
- const writers = graph.writers ? ` writers=${graph.writers.count}` : '';
802
- lines.push(`- ${graph.name}${writers}`);
803
- if (graph.checkpoint?.sha) {
804
- lines.push(` checkpoint: ${graph.checkpoint.sha}`);
805
- }
806
- if (graph.coverage?.sha) {
807
- lines.push(` coverage: ${graph.coverage.sha}`);
808
- }
809
- if (graph.cursor?.active) {
810
- lines.push(` cursor: tick ${graph.cursor.tick} (${graph.cursor.mode})`);
811
- }
812
- }
813
- return `${lines.join('\n')}\n`;
814
- }
815
-
816
- /** @param {*} payload */
817
- function renderQuery(payload) {
818
- const lines = [
819
- `Graph: ${payload.graph}`,
820
- `State: ${payload.stateHash}`,
821
- `Nodes: ${payload.nodes.length}`,
822
- ];
823
-
824
- for (const node of payload.nodes) {
825
- const id = node.id ?? '(unknown)';
826
- lines.push(`- ${id}`);
827
- if (node.props && Object.keys(node.props).length > 0) {
828
- lines.push(` props: ${JSON.stringify(node.props)}`);
829
- }
830
- }
831
-
832
- return `${lines.join('\n')}\n`;
833
- }
834
-
835
- /** @param {*} payload */
836
- function renderPath(payload) {
837
- const lines = [
838
- `Graph: ${payload.graph}`,
839
- `From: ${payload.from}`,
840
- `To: ${payload.to}`,
841
- `Found: ${payload.found ? 'yes' : 'no'}`,
842
- `Length: ${payload.length}`,
843
- ];
844
-
845
- if (payload.path && payload.path.length > 0) {
846
- lines.push(`Path: ${payload.path.join(' -> ')}`);
847
- }
848
-
849
- return `${lines.join('\n')}\n`;
850
- }
851
-
852
- const ANSI_GREEN = '\x1b[32m';
853
- const ANSI_YELLOW = '\x1b[33m';
854
- const ANSI_RED = '\x1b[31m';
855
- const ANSI_DIM = '\x1b[2m';
856
- const ANSI_RESET = '\x1b[0m';
857
-
858
- /** @param {string} state */
859
- function colorCachedState(state) {
860
- if (state === 'fresh') {
861
- return `${ANSI_GREEN}${state}${ANSI_RESET}`;
862
- }
863
- if (state === 'stale') {
864
- return `${ANSI_YELLOW}${state}${ANSI_RESET}`;
865
- }
866
- return `${ANSI_RED}${ANSI_DIM}${state}${ANSI_RESET}`;
867
- }
868
-
869
- /** @param {*} payload */
870
- function renderCheck(payload) {
871
- const lines = [
872
- `Graph: ${payload.graph}`,
873
- `Health: ${payload.health.status}`,
874
- ];
875
-
876
- if (payload.status) {
877
- lines.push(`Cached State: ${colorCachedState(payload.status.cachedState)}`);
878
- lines.push(`Patches Since Checkpoint: ${payload.status.patchesSinceCheckpoint}`);
879
- lines.push(`Tombstone Ratio: ${payload.status.tombstoneRatio.toFixed(2)}`);
880
- lines.push(`Writers: ${payload.status.writers}`);
881
- }
882
-
883
- if (payload.checkpoint?.sha) {
884
- lines.push(`Checkpoint: ${payload.checkpoint.sha}`);
885
- if (payload.checkpoint.ageSeconds !== null) {
886
- lines.push(`Checkpoint Age: ${payload.checkpoint.ageSeconds}s`);
887
- }
888
- } else {
889
- lines.push('Checkpoint: none');
890
- }
891
-
892
- if (!payload.status) {
893
- lines.push(`Writers: ${payload.writers.count}`);
894
- }
895
- for (const head of payload.writers.heads) {
896
- lines.push(`- ${head.writerId}: ${head.sha}`);
897
- }
898
-
899
- if (payload.coverage?.sha) {
900
- lines.push(`Coverage: ${payload.coverage.sha}`);
901
- lines.push(`Coverage Missing: ${payload.coverage.missingWriters.length}`);
902
- } else {
903
- lines.push('Coverage: none');
904
- }
905
-
906
- if (payload.gc) {
907
- lines.push(`Tombstones: ${payload.gc.totalTombstones}`);
908
- if (!payload.status) {
909
- lines.push(`Tombstone Ratio: ${payload.gc.tombstoneRatio}`);
910
- }
911
- }
912
-
913
- if (payload.hook) {
914
- lines.push(formatHookStatusLine(payload.hook));
915
- }
916
-
917
- return `${lines.join('\n')}\n`;
918
- }
919
-
920
- /** @param {*} hook */
921
- function formatHookStatusLine(hook) {
922
- if (!hook.installed && hook.foreign) {
923
- return "Hook: foreign hook present — run 'git warp install-hooks'";
924
- }
925
- if (!hook.installed) {
926
- return "Hook: not installed — run 'git warp install-hooks'";
927
- }
928
- if (hook.current) {
929
- return `Hook: installed (v${hook.version}) — up to date`;
930
- }
931
- return `Hook: installed (v${hook.version}) — upgrade available, run 'git warp install-hooks'`;
932
- }
933
-
934
- /** @param {*} payload */
935
- function renderHistory(payload) {
936
- const lines = [
937
- `Graph: ${payload.graph}`,
938
- `Writer: ${payload.writer}`,
939
- `Entries: ${payload.entries.length}`,
940
- ];
941
-
942
- if (payload.nodeFilter) {
943
- lines.push(`Node Filter: ${payload.nodeFilter}`);
944
- }
945
-
946
- for (const entry of payload.entries) {
947
- lines.push(`- ${entry.sha} (lamport: ${entry.lamport}, ops: ${entry.opCount})`);
948
- }
949
-
950
- return `${lines.join('\n')}\n`;
951
- }
952
-
953
- /** @param {*} payload */
954
- function renderError(payload) {
955
- return `Error: ${payload.error.message}\n`;
956
- }
957
-
958
- /**
959
- * Wraps SVG content in a minimal HTML document and writes it to disk.
960
- * @param {string} filePath - Destination file path
961
- * @param {string} svgContent - SVG markup to embed
962
- */
963
- function writeHtmlExport(filePath, svgContent) {
964
- const html = `<!DOCTYPE html>\n<html><head><meta charset="utf-8"><title>git-warp</title></head><body>\n${svgContent}\n</body></html>`;
965
- fs.writeFileSync(filePath, html);
966
- }
967
-
968
- /**
969
- * Writes a command result to stdout/stderr in the appropriate format.
970
- * Dispatches to JSON, SVG file, HTML file, ASCII view, or plain text
971
- * based on the combination of flags.
972
- * @param {*} payload - Command result payload
973
- * @param {{json: boolean, command: string, view: string|null}} options
974
- */
975
- function emit(payload, { json, command, view }) {
976
- if (json) {
977
- process.stdout.write(`${stableStringify(payload)}\n`);
978
- return;
979
- }
980
-
981
- if (command === 'info') {
982
- if (view) {
983
- process.stdout.write(renderInfoView(payload));
984
- } else {
985
- process.stdout.write(renderInfo(payload));
986
- }
987
- return;
988
- }
989
-
990
- if (command === 'query') {
991
- if (view && typeof view === 'string' && view.startsWith('svg:')) {
992
- const svgPath = view.slice(4);
993
- if (!payload._renderedSvg) {
994
- process.stderr.write('No graph data — skipping SVG export.\n');
995
- } else {
996
- fs.writeFileSync(svgPath, payload._renderedSvg);
997
- process.stderr.write(`SVG written to ${svgPath}\n`);
998
- }
999
- } else if (view && typeof view === 'string' && view.startsWith('html:')) {
1000
- const htmlPath = view.slice(5);
1001
- if (!payload._renderedSvg) {
1002
- process.stderr.write('No graph data — skipping HTML export.\n');
1003
- } else {
1004
- writeHtmlExport(htmlPath, payload._renderedSvg);
1005
- process.stderr.write(`HTML written to ${htmlPath}\n`);
1006
- }
1007
- } else if (view) {
1008
- process.stdout.write(`${payload._renderedAscii}\n`);
1009
- } else {
1010
- process.stdout.write(renderQuery(payload));
1011
- }
1012
- return;
1013
- }
1014
-
1015
- if (command === 'path') {
1016
- if (view && typeof view === 'string' && view.startsWith('svg:')) {
1017
- const svgPath = view.slice(4);
1018
- if (!payload._renderedSvg) {
1019
- process.stderr.write('No path found — skipping SVG export.\n');
1020
- } else {
1021
- fs.writeFileSync(svgPath, payload._renderedSvg);
1022
- process.stderr.write(`SVG written to ${svgPath}\n`);
1023
- }
1024
- } else if (view && typeof view === 'string' && view.startsWith('html:')) {
1025
- const htmlPath = view.slice(5);
1026
- if (!payload._renderedSvg) {
1027
- process.stderr.write('No path found — skipping HTML export.\n');
1028
- } else {
1029
- writeHtmlExport(htmlPath, payload._renderedSvg);
1030
- process.stderr.write(`HTML written to ${htmlPath}\n`);
1031
- }
1032
- } else if (view) {
1033
- process.stdout.write(renderPathView(payload));
1034
- } else {
1035
- process.stdout.write(renderPath(payload));
1036
- }
1037
- return;
1038
- }
1039
-
1040
- if (command === 'check') {
1041
- if (view) {
1042
- process.stdout.write(renderCheckView(payload));
1043
- } else {
1044
- process.stdout.write(renderCheck(payload));
1045
- }
1046
- return;
1047
- }
1048
-
1049
- if (command === 'history') {
1050
- if (view) {
1051
- process.stdout.write(renderHistoryView(payload));
1052
- } else {
1053
- process.stdout.write(renderHistory(payload));
1054
- }
1055
- return;
1056
- }
1057
-
1058
- if (command === 'materialize') {
1059
- if (view) {
1060
- process.stdout.write(renderMaterializeView(payload));
1061
- } else {
1062
- process.stdout.write(renderMaterialize(payload));
1063
- }
1064
- return;
1065
- }
1066
-
1067
- if (command === 'seek') {
1068
- if (view) {
1069
- process.stdout.write(renderSeekView(payload));
1070
- } else {
1071
- process.stdout.write(renderSeek(payload));
1072
- }
1073
- return;
1074
- }
1075
-
1076
- if (command === 'install-hooks') {
1077
- process.stdout.write(renderInstallHooks(payload));
1078
- return;
1079
- }
1080
-
1081
- if (payload?.error) {
1082
- process.stderr.write(renderError(payload));
1083
- return;
1084
- }
1085
-
1086
- process.stdout.write(`${stableStringify(payload)}\n`);
1087
- }
1088
-
1089
781
  /**
1090
782
  * Handles the `info` command: summarizes graphs in the repository.
1091
783
  * @param {{options: CliOptions}} params
@@ -1589,38 +1281,6 @@ async function handleMaterialize({ options }) {
1589
1281
  };
1590
1282
  }
1591
1283
 
1592
- /** @param {*} payload */
1593
- function renderMaterialize(payload) {
1594
- if (payload.graphs.length === 0) {
1595
- return 'No graphs found in repo.\n';
1596
- }
1597
-
1598
- const lines = [];
1599
- for (const entry of payload.graphs) {
1600
- if (entry.error) {
1601
- lines.push(`${entry.graph}: error — ${entry.error}`);
1602
- } else {
1603
- lines.push(`${entry.graph}: ${entry.nodes} nodes, ${entry.edges} edges, checkpoint ${entry.checkpoint}`);
1604
- }
1605
- }
1606
- return `${lines.join('\n')}\n`;
1607
- }
1608
-
1609
- /** @param {*} payload */
1610
- function renderInstallHooks(payload) {
1611
- if (payload.action === 'up-to-date') {
1612
- return `Hook: already up to date (v${payload.version}) at ${payload.hookPath}\n`;
1613
- }
1614
- if (payload.action === 'skipped') {
1615
- return 'Hook: installation skipped\n';
1616
- }
1617
- const lines = [`Hook: ${payload.action} (v${payload.version})`, `Path: ${payload.hookPath}`];
1618
- if (payload.backupPath) {
1619
- lines.push(`Backup: ${payload.backupPath}`);
1620
- }
1621
- return `${lines.join('\n')}\n`;
1622
- }
1623
-
1624
1284
  function createHookInstaller() {
1625
1285
  const __filename = new URL(import.meta.url).pathname;
1626
1286
  const __dirname = path.dirname(__filename);
@@ -2601,138 +2261,6 @@ function applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit) {
2601
2261
  return { structuralDiff: capped, diffBaseline, baselineTick, truncated: true, totalChanges, shownChanges };
2602
2262
  }
2603
2263
 
2604
- /**
2605
- * Renders a seek command payload as a human-readable string for terminal output.
2606
- *
2607
- * Handles all seek actions: list, drop, save, latest, load, tick, and status.
2608
- *
2609
- * @param {*} payload - Seek result payload from handleSeek
2610
- * @returns {string} Formatted output string (includes trailing newline)
2611
- */
2612
- function renderSeek(payload) {
2613
- const formatDelta = (/** @type {*} */ n) => { // TODO(ts-cleanup): type CLI payload
2614
- if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) {
2615
- return '';
2616
- }
2617
- const sign = n > 0 ? '+' : '';
2618
- return ` (${sign}${n})`;
2619
- };
2620
-
2621
- const formatOpSummaryPlain = (/** @type {*} */ summary) => { // TODO(ts-cleanup): type CLI payload
2622
- const order = [
2623
- ['NodeAdd', '+', 'node'],
2624
- ['EdgeAdd', '+', 'edge'],
2625
- ['PropSet', '~', 'prop'],
2626
- ['NodeTombstone', '-', 'node'],
2627
- ['EdgeTombstone', '-', 'edge'],
2628
- ['BlobValue', '+', 'blob'],
2629
- ];
2630
-
2631
- const parts = [];
2632
- for (const [opType, symbol, label] of order) {
2633
- const n = summary?.[opType];
2634
- if (typeof n === 'number' && Number.isFinite(n) && n > 0) {
2635
- parts.push(`${symbol}${n}${label}`);
2636
- }
2637
- }
2638
- return parts.length > 0 ? parts.join(' ') : '(empty)';
2639
- };
2640
-
2641
- const appendReceiptSummary = (/** @type {string} */ baseLine) => {
2642
- const tickReceipt = payload?.tickReceipt;
2643
- if (!tickReceipt || typeof tickReceipt !== 'object') {
2644
- return `${baseLine}\n`;
2645
- }
2646
-
2647
- const entries = Object.entries(tickReceipt)
2648
- .filter(([writerId, entry]) => writerId && entry && typeof entry === 'object')
2649
- .sort(([a], [b]) => a.localeCompare(b));
2650
-
2651
- if (entries.length === 0) {
2652
- return `${baseLine}\n`;
2653
- }
2654
-
2655
- const maxWriterLen = Math.max(5, ...entries.map(([writerId]) => writerId.length));
2656
- const receiptLines = [` Tick ${payload.tick}:`];
2657
- for (const [writerId, entry] of entries) {
2658
- const sha = typeof entry.sha === 'string' ? entry.sha.slice(0, 7) : '';
2659
- const opSummary = entry.opSummary && typeof entry.opSummary === 'object' ? entry.opSummary : entry;
2660
- receiptLines.push(` ${writerId.padEnd(maxWriterLen)} ${sha.padEnd(7)} ${formatOpSummaryPlain(opSummary)}`);
2661
- }
2662
-
2663
- return `${baseLine}\n${receiptLines.join('\n')}\n`;
2664
- };
2665
-
2666
- const buildStateStrings = () => {
2667
- const nodeLabel = payload.nodes === 1 ? 'node' : 'nodes';
2668
- const edgeLabel = payload.edges === 1 ? 'edge' : 'edges';
2669
- const patchLabel = payload.patchCount === 1 ? 'patch' : 'patches';
2670
- return {
2671
- nodesStr: `${payload.nodes} ${nodeLabel}${formatDelta(payload.diff?.nodes)}`,
2672
- edgesStr: `${payload.edges} ${edgeLabel}${formatDelta(payload.diff?.edges)}`,
2673
- patchesStr: `${payload.patchCount} ${patchLabel}`,
2674
- };
2675
- };
2676
-
2677
- if (payload.action === 'clear-cache') {
2678
- return `${payload.message}\n`;
2679
- }
2680
-
2681
- if (payload.action === 'list') {
2682
- if (payload.cursors.length === 0) {
2683
- return 'No saved cursors.\n';
2684
- }
2685
- const lines = [];
2686
- for (const c of payload.cursors) {
2687
- const active = c.tick === payload.activeTick ? ' (active)' : '';
2688
- lines.push(` ${c.name}: tick ${c.tick}${active}`);
2689
- }
2690
- return `${lines.join('\n')}\n`;
2691
- }
2692
-
2693
- if (payload.action === 'drop') {
2694
- return `Dropped cursor "${payload.name}" (was at tick ${payload.tick}).\n`;
2695
- }
2696
-
2697
- if (payload.action === 'save') {
2698
- return `Saved cursor "${payload.name}" at tick ${payload.tick}.\n`;
2699
- }
2700
-
2701
- if (payload.action === 'latest') {
2702
- const { nodesStr, edgesStr } = buildStateStrings();
2703
- const base = appendReceiptSummary(
2704
- `${payload.graph}: returned to present (tick ${payload.maxTick}, ${nodesStr}, ${edgesStr})`,
2705
- );
2706
- return base + formatStructuralDiff(payload);
2707
- }
2708
-
2709
- if (payload.action === 'load') {
2710
- const { nodesStr, edgesStr } = buildStateStrings();
2711
- const base = appendReceiptSummary(
2712
- `${payload.graph}: loaded cursor "${payload.name}" at tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr})`,
2713
- );
2714
- return base + formatStructuralDiff(payload);
2715
- }
2716
-
2717
- if (payload.action === 'tick') {
2718
- const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
2719
- const base = appendReceiptSummary(
2720
- `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
2721
- );
2722
- return base + formatStructuralDiff(payload);
2723
- }
2724
-
2725
- // status (structuralDiff is never populated here; no formatStructuralDiff call)
2726
- if (payload.cursor && payload.cursor.active) {
2727
- const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
2728
- return appendReceiptSummary(
2729
- `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
2730
- );
2731
- }
2732
-
2733
- return `${payload.graph}: no cursor active, ${payload.ticks.length} ticks available\n`;
2734
- }
2735
-
2736
2264
  /**
2737
2265
  * Reads the active cursor and sets `_seekCeiling` on the graph instance
2738
2266
  * so that subsequent materialize calls respect the time-travel boundary.
@@ -2837,6 +2365,12 @@ async function main() {
2837
2365
  if (options.json && options.view) {
2838
2366
  throw usageError('--json and --view are mutually exclusive');
2839
2367
  }
2368
+ if (options.ndjson && options.view) {
2369
+ throw usageError('--ndjson and --view are mutually exclusive');
2370
+ }
2371
+ if (options.json && options.ndjson) {
2372
+ throw usageError('--json and --ndjson are mutually exclusive');
2373
+ }
2840
2374
 
2841
2375
  const command = positionals[0];
2842
2376
  if (!command) {
@@ -2867,7 +2401,8 @@ async function main() {
2867
2401
  : { payload: result, exitCode: EXIT_CODES.OK };
2868
2402
 
2869
2403
  if (normalized.payload !== undefined) {
2870
- emit(normalized.payload, { json: options.json, command, view: options.view });
2404
+ const format = options.ndjson ? 'ndjson' : options.json ? 'json' : 'text';
2405
+ present(normalized.payload, { format, command, view: options.view });
2871
2406
  }
2872
2407
  // Use process.exit() to avoid waiting for fire-and-forget I/O (e.g. seek cache writes).
2873
2408
  process.exit(normalized.exitCode ?? EXIT_CODES.OK);
@@ -2884,8 +2419,9 @@ main().catch((error) => {
2884
2419
  payload.error.cause = error.cause instanceof Error ? error.cause.message : error.cause;
2885
2420
  }
2886
2421
 
2887
- if (process.argv.includes('--json')) {
2888
- process.stdout.write(`${stableStringify(payload)}\n`);
2422
+ if (process.argv.includes('--json') || process.argv.includes('--ndjson')) {
2423
+ const stringify = process.argv.includes('--ndjson') ? compactStringify : stableStringify;
2424
+ process.stdout.write(`${stringify(payload)}\n`);
2889
2425
  } else {
2890
2426
  process.stderr.write(renderError(payload));
2891
2427
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@git-stunts/git-warp",
3
- "version": "10.7.0",
3
+ "version": "10.8.0",
4
4
  "description": "Deterministic WARP graph over Git: graph-native storage, traversal, and tooling.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -42,6 +42,7 @@
42
42
  },
43
43
  "files": [
44
44
  "bin/warp-graph.js",
45
+ "bin/presenters",
45
46
  "bin/git-warp",
46
47
  "src",
47
48
  "index.js",