@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.
- package/bin/presenters/index.js +208 -0
- package/bin/presenters/json.js +66 -0
- package/bin/presenters/text.js +407 -0
- package/bin/warp-graph.js +24 -488
- package/package.json +2 -1
|
@@ -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 {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|