@git-stunts/git-warp 10.4.2 → 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/SECURITY.md +89 -1
- 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 +206 -534
- package/index.d.ts +24 -0
- package/package.json +2 -2
- package/src/domain/WarpGraph.js +72 -15
- package/src/domain/services/HttpSyncServer.js +74 -6
- package/src/domain/services/SyncAuthService.js +396 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +9 -56
- package/src/infrastructure/adapters/InMemoryGraphAdapter.js +488 -0
- package/src/infrastructure/adapters/adapterValidation.js +90 -0
- package/src/visualization/renderers/ascii/seek.js +172 -22
package/bin/warp-graph.js
CHANGED
|
@@ -25,16 +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
|
-
import {
|
|
30
|
+
import { diffStates } from '../src/domain/services/StateDiff.js';
|
|
35
31
|
import { renderGraphView } from '../src/visualization/renderers/ascii/graph.js';
|
|
36
32
|
import { renderSvg } from '../src/visualization/renderers/svg/index.js';
|
|
37
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';
|
|
38
37
|
|
|
39
38
|
/**
|
|
40
39
|
* @typedef {Object} Persistence
|
|
@@ -63,6 +62,7 @@ import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../s
|
|
|
63
62
|
* @property {() => Promise<Map<string, any>>} getFrontier
|
|
64
63
|
* @property {() => {totalTombstones: number, tombstoneRatio: number}} getGCMetrics
|
|
65
64
|
* @property {() => Promise<number>} getPropertyCount
|
|
65
|
+
* @property {() => Promise<import('../src/domain/services/JoinReducer.js').WarpStateV5 | null>} getStateSnapshot
|
|
66
66
|
* @property {() => Promise<{ticks: number[], maxTick: number, perWriter: Map<string, WriterTickInfo>}>} discoverTicks
|
|
67
67
|
* @property {(sha: string) => Promise<{ops?: any[]}>} loadPatchBySha
|
|
68
68
|
* @property {(cache: any) => void} setSeekCache
|
|
@@ -91,6 +91,7 @@ import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../s
|
|
|
91
91
|
* @typedef {Object} CliOptions
|
|
92
92
|
* @property {string} repo
|
|
93
93
|
* @property {boolean} json
|
|
94
|
+
* @property {boolean} ndjson
|
|
94
95
|
* @property {string|null} view
|
|
95
96
|
* @property {string|null} graph
|
|
96
97
|
* @property {string} writer
|
|
@@ -113,6 +114,8 @@ import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../s
|
|
|
113
114
|
* @property {string|null} tickValue
|
|
114
115
|
* @property {string|null} name
|
|
115
116
|
* @property {boolean} noPersistentCache
|
|
117
|
+
* @property {boolean} diff
|
|
118
|
+
* @property {number} diffLimit
|
|
116
119
|
*/
|
|
117
120
|
|
|
118
121
|
const EXIT_CODES = {
|
|
@@ -138,7 +141,8 @@ Commands:
|
|
|
138
141
|
|
|
139
142
|
Options:
|
|
140
143
|
--repo <path> Path to git repo (default: cwd)
|
|
141
|
-
--json Emit JSON output
|
|
144
|
+
--json Emit JSON output (pretty-printed, sorted keys)
|
|
145
|
+
--ndjson Emit compact single-line JSON (for piping/scripting)
|
|
142
146
|
--view [mode] Visual output (ascii, browser, svg:FILE, html:FILE)
|
|
143
147
|
--graph <name> Graph name (required if repo has multiple graphs)
|
|
144
148
|
--writer <id> Writer id (default: cli)
|
|
@@ -171,6 +175,8 @@ Seek options:
|
|
|
171
175
|
--load <name> Restore a saved cursor
|
|
172
176
|
--list List all saved cursors
|
|
173
177
|
--drop <name> Delete a saved cursor
|
|
178
|
+
--diff Show structural diff (added/removed nodes, edges, props)
|
|
179
|
+
--diff-limit <N> Max diff entries (default 2000)
|
|
174
180
|
`;
|
|
175
181
|
|
|
176
182
|
/**
|
|
@@ -202,27 +208,6 @@ function notFoundError(message) {
|
|
|
202
208
|
return new CliError(message, { code: 'E_NOT_FOUND', exitCode: EXIT_CODES.NOT_FOUND });
|
|
203
209
|
}
|
|
204
210
|
|
|
205
|
-
/** @param {*} value */
|
|
206
|
-
function stableStringify(value) {
|
|
207
|
-
/** @param {*} input @returns {*} */
|
|
208
|
-
const normalize = (input) => {
|
|
209
|
-
if (Array.isArray(input)) {
|
|
210
|
-
return input.map(normalize);
|
|
211
|
-
}
|
|
212
|
-
if (input && typeof input === 'object') {
|
|
213
|
-
/** @type {Record<string, *>} */
|
|
214
|
-
const sorted = {};
|
|
215
|
-
for (const key of Object.keys(input).sort()) {
|
|
216
|
-
sorted[key] = normalize(input[key]);
|
|
217
|
-
}
|
|
218
|
-
return sorted;
|
|
219
|
-
}
|
|
220
|
-
return input;
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
return JSON.stringify(normalize(value), null, 2);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
211
|
/** @param {string[]} argv */
|
|
227
212
|
function parseArgs(argv) {
|
|
228
213
|
const options = createDefaultOptions();
|
|
@@ -250,6 +235,7 @@ function createDefaultOptions() {
|
|
|
250
235
|
return {
|
|
251
236
|
repo: process.cwd(),
|
|
252
237
|
json: false,
|
|
238
|
+
ndjson: false,
|
|
253
239
|
view: null,
|
|
254
240
|
graph: null,
|
|
255
241
|
writer: 'cli',
|
|
@@ -278,6 +264,11 @@ function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
|
|
|
278
264
|
return { consumed: 0 };
|
|
279
265
|
}
|
|
280
266
|
|
|
267
|
+
if (arg === '--ndjson') {
|
|
268
|
+
options.ndjson = true;
|
|
269
|
+
return { consumed: 0 };
|
|
270
|
+
}
|
|
271
|
+
|
|
281
272
|
if (arg === '--view') {
|
|
282
273
|
// Valid view modes: ascii, browser, svg:FILE, html:FILE
|
|
283
274
|
// Don't consume known commands as modes
|
|
@@ -787,299 +778,6 @@ function patchTouchesNode(patch, nodeId) {
|
|
|
787
778
|
return false;
|
|
788
779
|
}
|
|
789
780
|
|
|
790
|
-
/** @param {*} payload */
|
|
791
|
-
function renderInfo(payload) {
|
|
792
|
-
const lines = [`Repo: ${payload.repo}`];
|
|
793
|
-
lines.push(`Graphs: ${payload.graphs.length}`);
|
|
794
|
-
for (const graph of payload.graphs) {
|
|
795
|
-
const writers = graph.writers ? ` writers=${graph.writers.count}` : '';
|
|
796
|
-
lines.push(`- ${graph.name}${writers}`);
|
|
797
|
-
if (graph.checkpoint?.sha) {
|
|
798
|
-
lines.push(` checkpoint: ${graph.checkpoint.sha}`);
|
|
799
|
-
}
|
|
800
|
-
if (graph.coverage?.sha) {
|
|
801
|
-
lines.push(` coverage: ${graph.coverage.sha}`);
|
|
802
|
-
}
|
|
803
|
-
if (graph.cursor?.active) {
|
|
804
|
-
lines.push(` cursor: tick ${graph.cursor.tick} (${graph.cursor.mode})`);
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
return `${lines.join('\n')}\n`;
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
/** @param {*} payload */
|
|
811
|
-
function renderQuery(payload) {
|
|
812
|
-
const lines = [
|
|
813
|
-
`Graph: ${payload.graph}`,
|
|
814
|
-
`State: ${payload.stateHash}`,
|
|
815
|
-
`Nodes: ${payload.nodes.length}`,
|
|
816
|
-
];
|
|
817
|
-
|
|
818
|
-
for (const node of payload.nodes) {
|
|
819
|
-
const id = node.id ?? '(unknown)';
|
|
820
|
-
lines.push(`- ${id}`);
|
|
821
|
-
if (node.props && Object.keys(node.props).length > 0) {
|
|
822
|
-
lines.push(` props: ${JSON.stringify(node.props)}`);
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
return `${lines.join('\n')}\n`;
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
/** @param {*} payload */
|
|
830
|
-
function renderPath(payload) {
|
|
831
|
-
const lines = [
|
|
832
|
-
`Graph: ${payload.graph}`,
|
|
833
|
-
`From: ${payload.from}`,
|
|
834
|
-
`To: ${payload.to}`,
|
|
835
|
-
`Found: ${payload.found ? 'yes' : 'no'}`,
|
|
836
|
-
`Length: ${payload.length}`,
|
|
837
|
-
];
|
|
838
|
-
|
|
839
|
-
if (payload.path && payload.path.length > 0) {
|
|
840
|
-
lines.push(`Path: ${payload.path.join(' -> ')}`);
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
return `${lines.join('\n')}\n`;
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
const ANSI_GREEN = '\x1b[32m';
|
|
847
|
-
const ANSI_YELLOW = '\x1b[33m';
|
|
848
|
-
const ANSI_RED = '\x1b[31m';
|
|
849
|
-
const ANSI_DIM = '\x1b[2m';
|
|
850
|
-
const ANSI_RESET = '\x1b[0m';
|
|
851
|
-
|
|
852
|
-
/** @param {string} state */
|
|
853
|
-
function colorCachedState(state) {
|
|
854
|
-
if (state === 'fresh') {
|
|
855
|
-
return `${ANSI_GREEN}${state}${ANSI_RESET}`;
|
|
856
|
-
}
|
|
857
|
-
if (state === 'stale') {
|
|
858
|
-
return `${ANSI_YELLOW}${state}${ANSI_RESET}`;
|
|
859
|
-
}
|
|
860
|
-
return `${ANSI_RED}${ANSI_DIM}${state}${ANSI_RESET}`;
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
/** @param {*} payload */
|
|
864
|
-
function renderCheck(payload) {
|
|
865
|
-
const lines = [
|
|
866
|
-
`Graph: ${payload.graph}`,
|
|
867
|
-
`Health: ${payload.health.status}`,
|
|
868
|
-
];
|
|
869
|
-
|
|
870
|
-
if (payload.status) {
|
|
871
|
-
lines.push(`Cached State: ${colorCachedState(payload.status.cachedState)}`);
|
|
872
|
-
lines.push(`Patches Since Checkpoint: ${payload.status.patchesSinceCheckpoint}`);
|
|
873
|
-
lines.push(`Tombstone Ratio: ${payload.status.tombstoneRatio.toFixed(2)}`);
|
|
874
|
-
lines.push(`Writers: ${payload.status.writers}`);
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
if (payload.checkpoint?.sha) {
|
|
878
|
-
lines.push(`Checkpoint: ${payload.checkpoint.sha}`);
|
|
879
|
-
if (payload.checkpoint.ageSeconds !== null) {
|
|
880
|
-
lines.push(`Checkpoint Age: ${payload.checkpoint.ageSeconds}s`);
|
|
881
|
-
}
|
|
882
|
-
} else {
|
|
883
|
-
lines.push('Checkpoint: none');
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
if (!payload.status) {
|
|
887
|
-
lines.push(`Writers: ${payload.writers.count}`);
|
|
888
|
-
}
|
|
889
|
-
for (const head of payload.writers.heads) {
|
|
890
|
-
lines.push(`- ${head.writerId}: ${head.sha}`);
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
if (payload.coverage?.sha) {
|
|
894
|
-
lines.push(`Coverage: ${payload.coverage.sha}`);
|
|
895
|
-
lines.push(`Coverage Missing: ${payload.coverage.missingWriters.length}`);
|
|
896
|
-
} else {
|
|
897
|
-
lines.push('Coverage: none');
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
if (payload.gc) {
|
|
901
|
-
lines.push(`Tombstones: ${payload.gc.totalTombstones}`);
|
|
902
|
-
if (!payload.status) {
|
|
903
|
-
lines.push(`Tombstone Ratio: ${payload.gc.tombstoneRatio}`);
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
if (payload.hook) {
|
|
908
|
-
lines.push(formatHookStatusLine(payload.hook));
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
return `${lines.join('\n')}\n`;
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
/** @param {*} hook */
|
|
915
|
-
function formatHookStatusLine(hook) {
|
|
916
|
-
if (!hook.installed && hook.foreign) {
|
|
917
|
-
return "Hook: foreign hook present — run 'git warp install-hooks'";
|
|
918
|
-
}
|
|
919
|
-
if (!hook.installed) {
|
|
920
|
-
return "Hook: not installed — run 'git warp install-hooks'";
|
|
921
|
-
}
|
|
922
|
-
if (hook.current) {
|
|
923
|
-
return `Hook: installed (v${hook.version}) — up to date`;
|
|
924
|
-
}
|
|
925
|
-
return `Hook: installed (v${hook.version}) — upgrade available, run 'git warp install-hooks'`;
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
/** @param {*} payload */
|
|
929
|
-
function renderHistory(payload) {
|
|
930
|
-
const lines = [
|
|
931
|
-
`Graph: ${payload.graph}`,
|
|
932
|
-
`Writer: ${payload.writer}`,
|
|
933
|
-
`Entries: ${payload.entries.length}`,
|
|
934
|
-
];
|
|
935
|
-
|
|
936
|
-
if (payload.nodeFilter) {
|
|
937
|
-
lines.push(`Node Filter: ${payload.nodeFilter}`);
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
for (const entry of payload.entries) {
|
|
941
|
-
lines.push(`- ${entry.sha} (lamport: ${entry.lamport}, ops: ${entry.opCount})`);
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
return `${lines.join('\n')}\n`;
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
/** @param {*} payload */
|
|
948
|
-
function renderError(payload) {
|
|
949
|
-
return `Error: ${payload.error.message}\n`;
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
/**
|
|
953
|
-
* Wraps SVG content in a minimal HTML document and writes it to disk.
|
|
954
|
-
* @param {string} filePath - Destination file path
|
|
955
|
-
* @param {string} svgContent - SVG markup to embed
|
|
956
|
-
*/
|
|
957
|
-
function writeHtmlExport(filePath, svgContent) {
|
|
958
|
-
const html = `<!DOCTYPE html>\n<html><head><meta charset="utf-8"><title>git-warp</title></head><body>\n${svgContent}\n</body></html>`;
|
|
959
|
-
fs.writeFileSync(filePath, html);
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
/**
|
|
963
|
-
* Writes a command result to stdout/stderr in the appropriate format.
|
|
964
|
-
* Dispatches to JSON, SVG file, HTML file, ASCII view, or plain text
|
|
965
|
-
* based on the combination of flags.
|
|
966
|
-
* @param {*} payload - Command result payload
|
|
967
|
-
* @param {{json: boolean, command: string, view: string|null}} options
|
|
968
|
-
*/
|
|
969
|
-
function emit(payload, { json, command, view }) {
|
|
970
|
-
if (json) {
|
|
971
|
-
process.stdout.write(`${stableStringify(payload)}\n`);
|
|
972
|
-
return;
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
if (command === 'info') {
|
|
976
|
-
if (view) {
|
|
977
|
-
process.stdout.write(renderInfoView(payload));
|
|
978
|
-
} else {
|
|
979
|
-
process.stdout.write(renderInfo(payload));
|
|
980
|
-
}
|
|
981
|
-
return;
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
if (command === 'query') {
|
|
985
|
-
if (view && typeof view === 'string' && view.startsWith('svg:')) {
|
|
986
|
-
const svgPath = view.slice(4);
|
|
987
|
-
if (!payload._renderedSvg) {
|
|
988
|
-
process.stderr.write('No graph data — skipping SVG export.\n');
|
|
989
|
-
} else {
|
|
990
|
-
fs.writeFileSync(svgPath, payload._renderedSvg);
|
|
991
|
-
process.stderr.write(`SVG written to ${svgPath}\n`);
|
|
992
|
-
}
|
|
993
|
-
} else if (view && typeof view === 'string' && view.startsWith('html:')) {
|
|
994
|
-
const htmlPath = view.slice(5);
|
|
995
|
-
if (!payload._renderedSvg) {
|
|
996
|
-
process.stderr.write('No graph data — skipping HTML export.\n');
|
|
997
|
-
} else {
|
|
998
|
-
writeHtmlExport(htmlPath, payload._renderedSvg);
|
|
999
|
-
process.stderr.write(`HTML written to ${htmlPath}\n`);
|
|
1000
|
-
}
|
|
1001
|
-
} else if (view) {
|
|
1002
|
-
process.stdout.write(`${payload._renderedAscii}\n`);
|
|
1003
|
-
} else {
|
|
1004
|
-
process.stdout.write(renderQuery(payload));
|
|
1005
|
-
}
|
|
1006
|
-
return;
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
if (command === 'path') {
|
|
1010
|
-
if (view && typeof view === 'string' && view.startsWith('svg:')) {
|
|
1011
|
-
const svgPath = view.slice(4);
|
|
1012
|
-
if (!payload._renderedSvg) {
|
|
1013
|
-
process.stderr.write('No path found — skipping SVG export.\n');
|
|
1014
|
-
} else {
|
|
1015
|
-
fs.writeFileSync(svgPath, payload._renderedSvg);
|
|
1016
|
-
process.stderr.write(`SVG written to ${svgPath}\n`);
|
|
1017
|
-
}
|
|
1018
|
-
} else if (view && typeof view === 'string' && view.startsWith('html:')) {
|
|
1019
|
-
const htmlPath = view.slice(5);
|
|
1020
|
-
if (!payload._renderedSvg) {
|
|
1021
|
-
process.stderr.write('No path found — skipping HTML export.\n');
|
|
1022
|
-
} else {
|
|
1023
|
-
writeHtmlExport(htmlPath, payload._renderedSvg);
|
|
1024
|
-
process.stderr.write(`HTML written to ${htmlPath}\n`);
|
|
1025
|
-
}
|
|
1026
|
-
} else if (view) {
|
|
1027
|
-
process.stdout.write(renderPathView(payload));
|
|
1028
|
-
} else {
|
|
1029
|
-
process.stdout.write(renderPath(payload));
|
|
1030
|
-
}
|
|
1031
|
-
return;
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
if (command === 'check') {
|
|
1035
|
-
if (view) {
|
|
1036
|
-
process.stdout.write(renderCheckView(payload));
|
|
1037
|
-
} else {
|
|
1038
|
-
process.stdout.write(renderCheck(payload));
|
|
1039
|
-
}
|
|
1040
|
-
return;
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
if (command === 'history') {
|
|
1044
|
-
if (view) {
|
|
1045
|
-
process.stdout.write(renderHistoryView(payload));
|
|
1046
|
-
} else {
|
|
1047
|
-
process.stdout.write(renderHistory(payload));
|
|
1048
|
-
}
|
|
1049
|
-
return;
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
if (command === 'materialize') {
|
|
1053
|
-
if (view) {
|
|
1054
|
-
process.stdout.write(renderMaterializeView(payload));
|
|
1055
|
-
} else {
|
|
1056
|
-
process.stdout.write(renderMaterialize(payload));
|
|
1057
|
-
}
|
|
1058
|
-
return;
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
if (command === 'seek') {
|
|
1062
|
-
if (view) {
|
|
1063
|
-
process.stdout.write(renderSeekView(payload));
|
|
1064
|
-
} else {
|
|
1065
|
-
process.stdout.write(renderSeek(payload));
|
|
1066
|
-
}
|
|
1067
|
-
return;
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
if (command === 'install-hooks') {
|
|
1071
|
-
process.stdout.write(renderInstallHooks(payload));
|
|
1072
|
-
return;
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
if (payload?.error) {
|
|
1076
|
-
process.stderr.write(renderError(payload));
|
|
1077
|
-
return;
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
process.stdout.write(`${stableStringify(payload)}\n`);
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
781
|
/**
|
|
1084
782
|
* Handles the `info` command: summarizes graphs in the repository.
|
|
1085
783
|
* @param {{options: CliOptions}} params
|
|
@@ -1583,38 +1281,6 @@ async function handleMaterialize({ options }) {
|
|
|
1583
1281
|
};
|
|
1584
1282
|
}
|
|
1585
1283
|
|
|
1586
|
-
/** @param {*} payload */
|
|
1587
|
-
function renderMaterialize(payload) {
|
|
1588
|
-
if (payload.graphs.length === 0) {
|
|
1589
|
-
return 'No graphs found in repo.\n';
|
|
1590
|
-
}
|
|
1591
|
-
|
|
1592
|
-
const lines = [];
|
|
1593
|
-
for (const entry of payload.graphs) {
|
|
1594
|
-
if (entry.error) {
|
|
1595
|
-
lines.push(`${entry.graph}: error — ${entry.error}`);
|
|
1596
|
-
} else {
|
|
1597
|
-
lines.push(`${entry.graph}: ${entry.nodes} nodes, ${entry.edges} edges, checkpoint ${entry.checkpoint}`);
|
|
1598
|
-
}
|
|
1599
|
-
}
|
|
1600
|
-
return `${lines.join('\n')}\n`;
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
/** @param {*} payload */
|
|
1604
|
-
function renderInstallHooks(payload) {
|
|
1605
|
-
if (payload.action === 'up-to-date') {
|
|
1606
|
-
return `Hook: already up to date (v${payload.version}) at ${payload.hookPath}\n`;
|
|
1607
|
-
}
|
|
1608
|
-
if (payload.action === 'skipped') {
|
|
1609
|
-
return 'Hook: installation skipped\n';
|
|
1610
|
-
}
|
|
1611
|
-
const lines = [`Hook: ${payload.action} (v${payload.version})`, `Path: ${payload.hookPath}`];
|
|
1612
|
-
if (payload.backupPath) {
|
|
1613
|
-
lines.push(`Backup: ${payload.backupPath}`);
|
|
1614
|
-
}
|
|
1615
|
-
return `${lines.join('\n')}\n`;
|
|
1616
|
-
}
|
|
1617
|
-
|
|
1618
1284
|
function createHookInstaller() {
|
|
1619
1285
|
const __filename = new URL(import.meta.url).pathname;
|
|
1620
1286
|
const __dirname = path.dirname(__filename);
|
|
@@ -1953,9 +1619,64 @@ function handleSeekBooleanFlag(arg, spec) {
|
|
|
1953
1619
|
spec.action = 'clear-cache';
|
|
1954
1620
|
} else if (arg === '--no-persistent-cache') {
|
|
1955
1621
|
spec.noPersistentCache = true;
|
|
1622
|
+
} else if (arg === '--diff') {
|
|
1623
|
+
spec.diff = true;
|
|
1956
1624
|
}
|
|
1957
1625
|
}
|
|
1958
1626
|
|
|
1627
|
+
/**
|
|
1628
|
+
* Parses --diff-limit / --diff-limit=N into the seek spec.
|
|
1629
|
+
* @param {string} arg
|
|
1630
|
+
* @param {string[]} args
|
|
1631
|
+
* @param {number} i
|
|
1632
|
+
* @param {SeekSpec} spec
|
|
1633
|
+
*/
|
|
1634
|
+
function handleDiffLimitFlag(arg, args, i, spec) {
|
|
1635
|
+
let raw;
|
|
1636
|
+
if (arg.startsWith('--diff-limit=')) {
|
|
1637
|
+
raw = arg.slice('--diff-limit='.length);
|
|
1638
|
+
} else {
|
|
1639
|
+
raw = args[i + 1];
|
|
1640
|
+
if (raw === undefined) {
|
|
1641
|
+
throw usageError('Missing value for --diff-limit');
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
const n = Number(raw);
|
|
1645
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
|
|
1646
|
+
throw usageError(`Invalid --diff-limit value: ${raw}. Must be a positive integer.`);
|
|
1647
|
+
}
|
|
1648
|
+
spec.diffLimit = n;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* Parses a named action flag (--save, --load, --drop) with its value.
|
|
1653
|
+
* @param {string} flagName - e.g. 'save'
|
|
1654
|
+
* @param {string} arg - Current arg token
|
|
1655
|
+
* @param {string[]} args - All args
|
|
1656
|
+
* @param {number} i - Current index
|
|
1657
|
+
* @param {SeekSpec} spec
|
|
1658
|
+
* @returns {number} Number of extra args consumed (0 or 1)
|
|
1659
|
+
*/
|
|
1660
|
+
function parseSeekNamedAction(flagName, arg, args, i, spec) {
|
|
1661
|
+
if (spec.action !== 'status') {
|
|
1662
|
+
throw usageError(`--${flagName} cannot be combined with other seek flags`);
|
|
1663
|
+
}
|
|
1664
|
+
spec.action = flagName;
|
|
1665
|
+
if (arg === `--${flagName}`) {
|
|
1666
|
+
const val = args[i + 1];
|
|
1667
|
+
if (val === undefined || val.startsWith('-')) {
|
|
1668
|
+
throw usageError(`Missing name for --${flagName}`);
|
|
1669
|
+
}
|
|
1670
|
+
spec.name = val;
|
|
1671
|
+
return 1;
|
|
1672
|
+
}
|
|
1673
|
+
spec.name = arg.slice(`--${flagName}=`.length);
|
|
1674
|
+
if (!spec.name) {
|
|
1675
|
+
throw usageError(`Missing name for --${flagName}`);
|
|
1676
|
+
}
|
|
1677
|
+
return 0;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1959
1680
|
/**
|
|
1960
1681
|
* Parses CLI arguments for the `seek` command into a structured spec.
|
|
1961
1682
|
* @param {string[]} args - Raw CLI arguments following the `seek` subcommand
|
|
@@ -1968,7 +1689,10 @@ function parseSeekArgs(args) {
|
|
|
1968
1689
|
tickValue: null,
|
|
1969
1690
|
name: null,
|
|
1970
1691
|
noPersistentCache: false,
|
|
1692
|
+
diff: false,
|
|
1693
|
+
diffLimit: 2000,
|
|
1971
1694
|
};
|
|
1695
|
+
let diffLimitProvided = false;
|
|
1972
1696
|
|
|
1973
1697
|
for (let i = 0; i < args.length; i++) {
|
|
1974
1698
|
const arg = args[i];
|
|
@@ -1995,78 +1719,39 @@ function parseSeekArgs(args) {
|
|
|
1995
1719
|
throw usageError('--latest cannot be combined with other seek flags');
|
|
1996
1720
|
}
|
|
1997
1721
|
spec.action = 'latest';
|
|
1998
|
-
} else if (arg === '--save') {
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
spec.action = 'save';
|
|
2003
|
-
const val = args[i + 1];
|
|
2004
|
-
if (val === undefined || val.startsWith('-')) {
|
|
2005
|
-
throw usageError('Missing name for --save');
|
|
2006
|
-
}
|
|
2007
|
-
spec.name = val;
|
|
2008
|
-
i += 1;
|
|
2009
|
-
} else if (arg.startsWith('--save=')) {
|
|
2010
|
-
if (spec.action !== 'status') {
|
|
2011
|
-
throw usageError('--save cannot be combined with other seek flags');
|
|
2012
|
-
}
|
|
2013
|
-
spec.action = 'save';
|
|
2014
|
-
spec.name = arg.slice('--save='.length);
|
|
2015
|
-
if (!spec.name) {
|
|
2016
|
-
throw usageError('Missing name for --save');
|
|
2017
|
-
}
|
|
2018
|
-
} else if (arg === '--load') {
|
|
2019
|
-
if (spec.action !== 'status') {
|
|
2020
|
-
throw usageError('--load cannot be combined with other seek flags');
|
|
2021
|
-
}
|
|
2022
|
-
spec.action = 'load';
|
|
2023
|
-
const val = args[i + 1];
|
|
2024
|
-
if (val === undefined || val.startsWith('-')) {
|
|
2025
|
-
throw usageError('Missing name for --load');
|
|
2026
|
-
}
|
|
2027
|
-
spec.name = val;
|
|
2028
|
-
i += 1;
|
|
2029
|
-
} else if (arg.startsWith('--load=')) {
|
|
2030
|
-
if (spec.action !== 'status') {
|
|
2031
|
-
throw usageError('--load cannot be combined with other seek flags');
|
|
2032
|
-
}
|
|
2033
|
-
spec.action = 'load';
|
|
2034
|
-
spec.name = arg.slice('--load='.length);
|
|
2035
|
-
if (!spec.name) {
|
|
2036
|
-
throw usageError('Missing name for --load');
|
|
2037
|
-
}
|
|
1722
|
+
} else if (arg === '--save' || arg.startsWith('--save=')) {
|
|
1723
|
+
i += parseSeekNamedAction('save', arg, args, i, spec);
|
|
1724
|
+
} else if (arg === '--load' || arg.startsWith('--load=')) {
|
|
1725
|
+
i += parseSeekNamedAction('load', arg, args, i, spec);
|
|
2038
1726
|
} else if (arg === '--list') {
|
|
2039
1727
|
if (spec.action !== 'status') {
|
|
2040
1728
|
throw usageError('--list cannot be combined with other seek flags');
|
|
2041
1729
|
}
|
|
2042
1730
|
spec.action = 'list';
|
|
2043
|
-
} else if (arg === '--drop') {
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
}
|
|
2047
|
-
spec.action = 'drop';
|
|
2048
|
-
const val = args[i + 1];
|
|
2049
|
-
if (val === undefined || val.startsWith('-')) {
|
|
2050
|
-
throw usageError('Missing name for --drop');
|
|
2051
|
-
}
|
|
2052
|
-
spec.name = val;
|
|
2053
|
-
i += 1;
|
|
2054
|
-
} else if (arg.startsWith('--drop=')) {
|
|
2055
|
-
if (spec.action !== 'status') {
|
|
2056
|
-
throw usageError('--drop cannot be combined with other seek flags');
|
|
2057
|
-
}
|
|
2058
|
-
spec.action = 'drop';
|
|
2059
|
-
spec.name = arg.slice('--drop='.length);
|
|
2060
|
-
if (!spec.name) {
|
|
2061
|
-
throw usageError('Missing name for --drop');
|
|
2062
|
-
}
|
|
2063
|
-
} else if (arg === '--clear-cache' || arg === '--no-persistent-cache') {
|
|
1731
|
+
} else if (arg === '--drop' || arg.startsWith('--drop=')) {
|
|
1732
|
+
i += parseSeekNamedAction('drop', arg, args, i, spec);
|
|
1733
|
+
} else if (arg === '--clear-cache' || arg === '--no-persistent-cache' || arg === '--diff') {
|
|
2064
1734
|
handleSeekBooleanFlag(arg, spec);
|
|
1735
|
+
} else if (arg === '--diff-limit' || arg.startsWith('--diff-limit=')) {
|
|
1736
|
+
handleDiffLimitFlag(arg, args, i, spec);
|
|
1737
|
+
diffLimitProvided = true;
|
|
1738
|
+
if (arg === '--diff-limit') {
|
|
1739
|
+
i += 1;
|
|
1740
|
+
}
|
|
2065
1741
|
} else if (arg.startsWith('-')) {
|
|
2066
1742
|
throw usageError(`Unknown seek option: ${arg}`);
|
|
2067
1743
|
}
|
|
2068
1744
|
}
|
|
2069
1745
|
|
|
1746
|
+
// --diff is only meaningful for actions that navigate to a tick
|
|
1747
|
+
const DIFF_ACTIONS = new Set(['tick', 'latest', 'load']);
|
|
1748
|
+
if (spec.diff && !DIFF_ACTIONS.has(spec.action)) {
|
|
1749
|
+
throw usageError(`--diff cannot be used with --${spec.action}`);
|
|
1750
|
+
}
|
|
1751
|
+
if (diffLimitProvided && !spec.diff) {
|
|
1752
|
+
throw usageError('--diff-limit requires --diff');
|
|
1753
|
+
}
|
|
1754
|
+
|
|
2070
1755
|
return spec;
|
|
2071
1756
|
}
|
|
2072
1757
|
|
|
@@ -2189,8 +1874,16 @@ async function handleSeek({ options, args }) {
|
|
|
2189
1874
|
};
|
|
2190
1875
|
}
|
|
2191
1876
|
if (seekSpec.action === 'latest') {
|
|
1877
|
+
const prevTick = activeCursor ? activeCursor.tick : null;
|
|
1878
|
+
let sdResult = null;
|
|
1879
|
+
if (seekSpec.diff) {
|
|
1880
|
+
sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: maxTick, diffLimit: seekSpec.diffLimit });
|
|
1881
|
+
}
|
|
2192
1882
|
await clearActiveCursor(persistence, graphName);
|
|
2193
|
-
|
|
1883
|
+
// When --diff already materialized at maxTick, skip redundant re-materialize
|
|
1884
|
+
if (!sdResult) {
|
|
1885
|
+
await graph.materialize({ ceiling: maxTick });
|
|
1886
|
+
}
|
|
2194
1887
|
const nodes = await graph.getNodes();
|
|
2195
1888
|
const edges = await graph.getEdges();
|
|
2196
1889
|
const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
|
|
@@ -2209,6 +1902,7 @@ async function handleSeek({ options, args }) {
|
|
|
2209
1902
|
diff,
|
|
2210
1903
|
tickReceipt,
|
|
2211
1904
|
cursor: { active: false },
|
|
1905
|
+
...sdResult,
|
|
2212
1906
|
},
|
|
2213
1907
|
exitCode: EXIT_CODES.OK,
|
|
2214
1908
|
};
|
|
@@ -2234,7 +1928,15 @@ async function handleSeek({ options, args }) {
|
|
|
2234
1928
|
if (!saved) {
|
|
2235
1929
|
throw notFoundError(`Saved cursor not found: ${loadName}`);
|
|
2236
1930
|
}
|
|
2237
|
-
|
|
1931
|
+
const prevTick = activeCursor ? activeCursor.tick : null;
|
|
1932
|
+
let sdResult = null;
|
|
1933
|
+
if (seekSpec.diff) {
|
|
1934
|
+
sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: saved.tick, diffLimit: seekSpec.diffLimit });
|
|
1935
|
+
}
|
|
1936
|
+
// When --diff already materialized at saved.tick, skip redundant call
|
|
1937
|
+
if (!sdResult) {
|
|
1938
|
+
await graph.materialize({ ceiling: saved.tick });
|
|
1939
|
+
}
|
|
2238
1940
|
const nodes = await graph.getNodes();
|
|
2239
1941
|
const edges = await graph.getEdges();
|
|
2240
1942
|
await writeActiveCursor(persistence, graphName, { tick: saved.tick, mode: saved.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
|
|
@@ -2255,6 +1957,7 @@ async function handleSeek({ options, args }) {
|
|
|
2255
1957
|
diff,
|
|
2256
1958
|
tickReceipt,
|
|
2257
1959
|
cursor: { active: true, mode: saved.mode, tick: saved.tick, maxTick, name: seekSpec.name },
|
|
1960
|
+
...sdResult,
|
|
2258
1961
|
},
|
|
2259
1962
|
exitCode: EXIT_CODES.OK,
|
|
2260
1963
|
};
|
|
@@ -2262,7 +1965,14 @@ async function handleSeek({ options, args }) {
|
|
|
2262
1965
|
if (seekSpec.action === 'tick') {
|
|
2263
1966
|
const currentTick = activeCursor ? activeCursor.tick : null;
|
|
2264
1967
|
const resolvedTick = resolveTickValue(/** @type {string} */ (seekSpec.tickValue), currentTick, ticks, maxTick);
|
|
2265
|
-
|
|
1968
|
+
let sdResult = null;
|
|
1969
|
+
if (seekSpec.diff) {
|
|
1970
|
+
sdResult = await computeStructuralDiff({ graph, prevTick: currentTick, currentTick: resolvedTick, diffLimit: seekSpec.diffLimit });
|
|
1971
|
+
}
|
|
1972
|
+
// When --diff already materialized at resolvedTick, skip redundant call
|
|
1973
|
+
if (!sdResult) {
|
|
1974
|
+
await graph.materialize({ ceiling: resolvedTick });
|
|
1975
|
+
}
|
|
2266
1976
|
const nodes = await graph.getNodes();
|
|
2267
1977
|
const edges = await graph.getEdges();
|
|
2268
1978
|
await writeActiveCursor(persistence, graphName, { tick: resolvedTick, mode: 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
|
|
@@ -2282,12 +1992,22 @@ async function handleSeek({ options, args }) {
|
|
|
2282
1992
|
diff,
|
|
2283
1993
|
tickReceipt,
|
|
2284
1994
|
cursor: { active: true, mode: 'lamport', tick: resolvedTick, maxTick, name: 'active' },
|
|
1995
|
+
...sdResult,
|
|
2285
1996
|
},
|
|
2286
1997
|
exitCode: EXIT_CODES.OK,
|
|
2287
1998
|
};
|
|
2288
1999
|
}
|
|
2289
2000
|
|
|
2290
2001
|
// status (bare seek)
|
|
2002
|
+
return await handleSeekStatus({ graph, graphName, persistence, activeCursor, ticks, maxTick, perWriter, frontierHash });
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
/**
|
|
2006
|
+
* Handles the `status` sub-action of `seek` (bare seek with no action flag).
|
|
2007
|
+
* @param {{graph: WarpGraphInstance, graphName: string, persistence: Persistence, activeCursor: CursorBlob|null, ticks: number[], maxTick: number, perWriter: Map<string, WriterTickInfo>, frontierHash: string}} params
|
|
2008
|
+
* @returns {Promise<{payload: *, exitCode: number}>}
|
|
2009
|
+
*/
|
|
2010
|
+
async function handleSeekStatus({ graph, graphName, persistence, activeCursor, ticks, maxTick, perWriter, frontierHash }) {
|
|
2291
2011
|
if (activeCursor) {
|
|
2292
2012
|
await graph.materialize({ ceiling: activeCursor.tick });
|
|
2293
2013
|
const nodes = await graph.getNodes();
|
|
@@ -2469,132 +2189,76 @@ async function buildTickReceipt({ tick, perWriter, graph }) {
|
|
|
2469
2189
|
}
|
|
2470
2190
|
|
|
2471
2191
|
/**
|
|
2472
|
-
*
|
|
2192
|
+
* Computes a structural diff between the state at a previous tick and
|
|
2193
|
+
* the state at the current tick.
|
|
2473
2194
|
*
|
|
2474
|
-
*
|
|
2195
|
+
* Materializes the baseline tick first, snapshots the state, then
|
|
2196
|
+
* materializes the target tick and calls diffStates() between the two.
|
|
2197
|
+
* Applies diffLimit truncation when the total change count exceeds the cap.
|
|
2475
2198
|
*
|
|
2476
|
-
* @param {
|
|
2477
|
-
* @returns {
|
|
2199
|
+
* @param {{graph: WarpGraphInstance, prevTick: number|null, currentTick: number, diffLimit: number}} params
|
|
2200
|
+
* @returns {Promise<{structuralDiff: *, diffBaseline: string, baselineTick: number|null, truncated: boolean, totalChanges: number, shownChanges: number}>}
|
|
2478
2201
|
*/
|
|
2479
|
-
function
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
}
|
|
2484
|
-
const sign = n > 0 ? '+' : '';
|
|
2485
|
-
return ` (${sign}${n})`;
|
|
2486
|
-
};
|
|
2487
|
-
|
|
2488
|
-
const formatOpSummaryPlain = (/** @type {*} */ summary) => { // TODO(ts-cleanup): type CLI payload
|
|
2489
|
-
const order = [
|
|
2490
|
-
['NodeAdd', '+', 'node'],
|
|
2491
|
-
['EdgeAdd', '+', 'edge'],
|
|
2492
|
-
['PropSet', '~', 'prop'],
|
|
2493
|
-
['NodeTombstone', '-', 'node'],
|
|
2494
|
-
['EdgeTombstone', '-', 'edge'],
|
|
2495
|
-
['BlobValue', '+', 'blob'],
|
|
2496
|
-
];
|
|
2497
|
-
|
|
2498
|
-
const parts = [];
|
|
2499
|
-
for (const [opType, symbol, label] of order) {
|
|
2500
|
-
const n = summary?.[opType];
|
|
2501
|
-
if (typeof n === 'number' && Number.isFinite(n) && n > 0) {
|
|
2502
|
-
parts.push(`${symbol}${n}${label}`);
|
|
2503
|
-
}
|
|
2504
|
-
}
|
|
2505
|
-
return parts.length > 0 ? parts.join(' ') : '(empty)';
|
|
2506
|
-
};
|
|
2507
|
-
|
|
2508
|
-
const appendReceiptSummary = (/** @type {string} */ baseLine) => {
|
|
2509
|
-
const tickReceipt = payload?.tickReceipt;
|
|
2510
|
-
if (!tickReceipt || typeof tickReceipt !== 'object') {
|
|
2511
|
-
return `${baseLine}\n`;
|
|
2512
|
-
}
|
|
2513
|
-
|
|
2514
|
-
const entries = Object.entries(tickReceipt)
|
|
2515
|
-
.filter(([writerId, entry]) => writerId && entry && typeof entry === 'object')
|
|
2516
|
-
.sort(([a], [b]) => a.localeCompare(b));
|
|
2517
|
-
|
|
2518
|
-
if (entries.length === 0) {
|
|
2519
|
-
return `${baseLine}\n`;
|
|
2520
|
-
}
|
|
2521
|
-
|
|
2522
|
-
const maxWriterLen = Math.max(5, ...entries.map(([writerId]) => writerId.length));
|
|
2523
|
-
const receiptLines = [` Tick ${payload.tick}:`];
|
|
2524
|
-
for (const [writerId, entry] of entries) {
|
|
2525
|
-
const sha = typeof entry.sha === 'string' ? entry.sha.slice(0, 7) : '';
|
|
2526
|
-
const opSummary = entry.opSummary && typeof entry.opSummary === 'object' ? entry.opSummary : entry;
|
|
2527
|
-
receiptLines.push(` ${writerId.padEnd(maxWriterLen)} ${sha.padEnd(7)} ${formatOpSummaryPlain(opSummary)}`);
|
|
2528
|
-
}
|
|
2529
|
-
|
|
2530
|
-
return `${baseLine}\n${receiptLines.join('\n')}\n`;
|
|
2531
|
-
};
|
|
2532
|
-
|
|
2533
|
-
const buildStateStrings = () => {
|
|
2534
|
-
const nodeLabel = payload.nodes === 1 ? 'node' : 'nodes';
|
|
2535
|
-
const edgeLabel = payload.edges === 1 ? 'edge' : 'edges';
|
|
2536
|
-
const patchLabel = payload.patchCount === 1 ? 'patch' : 'patches';
|
|
2537
|
-
return {
|
|
2538
|
-
nodesStr: `${payload.nodes} ${nodeLabel}${formatDelta(payload.diff?.nodes)}`,
|
|
2539
|
-
edgesStr: `${payload.edges} ${edgeLabel}${formatDelta(payload.diff?.edges)}`,
|
|
2540
|
-
patchesStr: `${payload.patchCount} ${patchLabel}`,
|
|
2541
|
-
};
|
|
2542
|
-
};
|
|
2543
|
-
|
|
2544
|
-
if (payload.action === 'clear-cache') {
|
|
2545
|
-
return `${payload.message}\n`;
|
|
2546
|
-
}
|
|
2547
|
-
|
|
2548
|
-
if (payload.action === 'list') {
|
|
2549
|
-
if (payload.cursors.length === 0) {
|
|
2550
|
-
return 'No saved cursors.\n';
|
|
2551
|
-
}
|
|
2552
|
-
const lines = [];
|
|
2553
|
-
for (const c of payload.cursors) {
|
|
2554
|
-
const active = c.tick === payload.activeTick ? ' (active)' : '';
|
|
2555
|
-
lines.push(` ${c.name}: tick ${c.tick}${active}`);
|
|
2556
|
-
}
|
|
2557
|
-
return `${lines.join('\n')}\n`;
|
|
2558
|
-
}
|
|
2202
|
+
async function computeStructuralDiff({ graph, prevTick, currentTick, diffLimit }) {
|
|
2203
|
+
let beforeState = null;
|
|
2204
|
+
let diffBaseline = 'empty';
|
|
2205
|
+
let baselineTick = null;
|
|
2559
2206
|
|
|
2560
|
-
|
|
2561
|
-
|
|
2207
|
+
// Short-circuit: same tick produces an empty diff
|
|
2208
|
+
if (prevTick !== null && prevTick === currentTick) {
|
|
2209
|
+
const empty = { nodes: { added: [], removed: [] }, edges: { added: [], removed: [] }, props: { set: [], removed: [] } };
|
|
2210
|
+
return { structuralDiff: empty, diffBaseline: 'tick', baselineTick: prevTick, truncated: false, totalChanges: 0, shownChanges: 0 };
|
|
2562
2211
|
}
|
|
2563
2212
|
|
|
2564
|
-
if (
|
|
2565
|
-
|
|
2213
|
+
if (prevTick !== null && prevTick > 0) {
|
|
2214
|
+
await graph.materialize({ ceiling: prevTick });
|
|
2215
|
+
beforeState = await graph.getStateSnapshot();
|
|
2216
|
+
diffBaseline = 'tick';
|
|
2217
|
+
baselineTick = prevTick;
|
|
2566
2218
|
}
|
|
2567
2219
|
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
`${payload.graph}: returned to present (tick ${payload.maxTick}, ${nodesStr}, ${edgesStr})`,
|
|
2572
|
-
);
|
|
2573
|
-
}
|
|
2220
|
+
await graph.materialize({ ceiling: currentTick });
|
|
2221
|
+
const afterState = /** @type {*} */ (await graph.getStateSnapshot()); // TODO(ts-cleanup): narrow WarpStateV5
|
|
2222
|
+
const diff = diffStates(beforeState, afterState);
|
|
2574
2223
|
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
return appendReceiptSummary(
|
|
2578
|
-
`${payload.graph}: loaded cursor "${payload.name}" at tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr})`,
|
|
2579
|
-
);
|
|
2580
|
-
}
|
|
2224
|
+
return applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit);
|
|
2225
|
+
}
|
|
2581
2226
|
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2227
|
+
/**
|
|
2228
|
+
* Applies truncation limits to a structural diff result.
|
|
2229
|
+
*
|
|
2230
|
+
* @param {*} diff
|
|
2231
|
+
* @param {string} diffBaseline
|
|
2232
|
+
* @param {number|null} baselineTick
|
|
2233
|
+
* @param {number} diffLimit
|
|
2234
|
+
* @returns {{structuralDiff: *, diffBaseline: string, baselineTick: number|null, truncated: boolean, totalChanges: number, shownChanges: number}}
|
|
2235
|
+
*/
|
|
2236
|
+
function applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit) {
|
|
2237
|
+
const totalChanges =
|
|
2238
|
+
diff.nodes.added.length + diff.nodes.removed.length +
|
|
2239
|
+
diff.edges.added.length + diff.edges.removed.length +
|
|
2240
|
+
diff.props.set.length + diff.props.removed.length;
|
|
2241
|
+
|
|
2242
|
+
if (totalChanges <= diffLimit) {
|
|
2243
|
+
return { structuralDiff: diff, diffBaseline, baselineTick, truncated: false, totalChanges, shownChanges: totalChanges };
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
// Truncate sequentially (nodes → edges → props), keeping sort order within each category
|
|
2247
|
+
let remaining = diffLimit;
|
|
2248
|
+
const cap = (/** @type {any[]} */ arr) => {
|
|
2249
|
+
const take = Math.min(arr.length, remaining);
|
|
2250
|
+
remaining -= take;
|
|
2251
|
+
return arr.slice(0, take);
|
|
2252
|
+
};
|
|
2588
2253
|
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
);
|
|
2595
|
-
}
|
|
2254
|
+
const capped = {
|
|
2255
|
+
nodes: { added: cap(diff.nodes.added), removed: cap(diff.nodes.removed) },
|
|
2256
|
+
edges: { added: cap(diff.edges.added), removed: cap(diff.edges.removed) },
|
|
2257
|
+
props: { set: cap(diff.props.set), removed: cap(diff.props.removed) },
|
|
2258
|
+
};
|
|
2596
2259
|
|
|
2597
|
-
|
|
2260
|
+
const shownChanges = diffLimit - remaining;
|
|
2261
|
+
return { structuralDiff: capped, diffBaseline, baselineTick, truncated: true, totalChanges, shownChanges };
|
|
2598
2262
|
}
|
|
2599
2263
|
|
|
2600
2264
|
/**
|
|
@@ -2701,6 +2365,12 @@ async function main() {
|
|
|
2701
2365
|
if (options.json && options.view) {
|
|
2702
2366
|
throw usageError('--json and --view are mutually exclusive');
|
|
2703
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
|
+
}
|
|
2704
2374
|
|
|
2705
2375
|
const command = positionals[0];
|
|
2706
2376
|
if (!command) {
|
|
@@ -2731,7 +2401,8 @@ async function main() {
|
|
|
2731
2401
|
: { payload: result, exitCode: EXIT_CODES.OK };
|
|
2732
2402
|
|
|
2733
2403
|
if (normalized.payload !== undefined) {
|
|
2734
|
-
|
|
2404
|
+
const format = options.ndjson ? 'ndjson' : options.json ? 'json' : 'text';
|
|
2405
|
+
present(normalized.payload, { format, command, view: options.view });
|
|
2735
2406
|
}
|
|
2736
2407
|
// Use process.exit() to avoid waiting for fire-and-forget I/O (e.g. seek cache writes).
|
|
2737
2408
|
process.exit(normalized.exitCode ?? EXIT_CODES.OK);
|
|
@@ -2748,8 +2419,9 @@ main().catch((error) => {
|
|
|
2748
2419
|
payload.error.cause = error.cause instanceof Error ? error.cause.message : error.cause;
|
|
2749
2420
|
}
|
|
2750
2421
|
|
|
2751
|
-
if (process.argv.includes('--json')) {
|
|
2752
|
-
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`);
|
|
2753
2425
|
} else {
|
|
2754
2426
|
process.stderr.write(renderError(payload));
|
|
2755
2427
|
}
|