@psnext/lscg 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -0
- package/dist/bin/lscg.d.ts +3 -0
- package/dist/bin/lscg.js +11 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +524 -0
- package/dist/src/config/paths.d.ts +12 -0
- package/dist/src/config/paths.js +54 -0
- package/dist/src/graph/attribution.d.ts +8 -0
- package/dist/src/graph/attribution.js +112 -0
- package/dist/src/graph/extract.d.ts +4 -0
- package/dist/src/graph/extract.js +199 -0
- package/dist/src/graph/repository.d.ts +93 -0
- package/dist/src/graph/repository.js +361 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +5 -0
- package/dist/src/mcp/server.d.ts +6 -0
- package/dist/src/mcp/server.js +81 -0
- package/dist/src/parser/treeSitter.d.ts +13 -0
- package/dist/src/parser/treeSitter.js +61 -0
- package/dist/src/scanner/discover.d.ts +4 -0
- package/dist/src/scanner/discover.js +62 -0
- package/dist/src/scanner/fingerprint.d.ts +3 -0
- package/dist/src/scanner/fingerprint.js +27 -0
- package/dist/src/storage/database.d.ts +72 -0
- package/dist/src/storage/database.js +563 -0
- package/dist/src/storage/schema.d.ts +4 -0
- package/dist/src/storage/schema.js +93 -0
- package/dist/src/types.d.ts +233 -0
- package/dist/src/types.js +2 -0
- package/dist/src/view/index.d.ts +27 -0
- package/dist/src/view/index.js +42 -0
- package/dist/src/view/layout.d.ts +28 -0
- package/dist/src/view/layout.js +235 -0
- package/dist/src/view/model.d.ts +64 -0
- package/dist/src/view/model.js +396 -0
- package/dist/src/view/open.d.ts +15 -0
- package/dist/src/view/open.js +37 -0
- package/dist/src/view/render.d.ts +9 -0
- package/dist/src/view/render.js +321 -0
- package/dist/src/watch.d.ts +40 -0
- package/dist/src/watch.js +118 -0
- package/package.json +65 -0
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import { startMcpServer } from './mcp/server.js';
|
|
2
|
+
import { buildViewModel, loadViewSnapshot, renderSvgMarkup, viewGraph } from './view/index.js';
|
|
3
|
+
import { watchRepository } from './watch.js';
|
|
4
|
+
import { graphStatus, initGraph, callGraph, contextGraph, listEdges, listNodes, listNodeText, neighbors, runReadOnlySql, scanRepository } from './graph/repository.js';
|
|
5
|
+
const operationalCommands = new Set([
|
|
6
|
+
'init', 'scan', 'watch', 'status', 'nodes', 'edges', 'neighbors',
|
|
7
|
+
'callgraph', 'context', 'query', 'view', 'mcp'
|
|
8
|
+
]);
|
|
9
|
+
export async function runCli(argv) {
|
|
10
|
+
const [command = 'help', ...rest] = argv;
|
|
11
|
+
if (operationalCommands.has(command) && rest.some((arg) => arg === '--help' || arg === '-h')) {
|
|
12
|
+
console.log(commandHelpText(command));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const options = parseOptions(rest);
|
|
16
|
+
switch (command) {
|
|
17
|
+
case 'init':
|
|
18
|
+
printJson(initGraph({ root: options.root, scope: options.scope ?? 'repo' }));
|
|
19
|
+
return;
|
|
20
|
+
case 'scan':
|
|
21
|
+
if (options.watch) {
|
|
22
|
+
await watchRepository({ root: options.root, scope: options.scope ?? 'repo' }, { mode: 'scan --watch' });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
printJson(await scanRepository({ root: options.root, scope: options.scope ?? 'repo' }));
|
|
26
|
+
return;
|
|
27
|
+
case 'watch':
|
|
28
|
+
await watchRepository({ root: options.root, scope: options.scope ?? 'repo' }, { mode: 'watch' });
|
|
29
|
+
return;
|
|
30
|
+
case 'status':
|
|
31
|
+
printJson(graphStatus({ root: options.root, scope: options.scope ?? 'repo' }));
|
|
32
|
+
return;
|
|
33
|
+
case 'nodes': {
|
|
34
|
+
const output = options.output ?? 'json';
|
|
35
|
+
if (output === 'json') {
|
|
36
|
+
printJson(listNodes({
|
|
37
|
+
root: options.root,
|
|
38
|
+
scope: options.scope ?? 'repo',
|
|
39
|
+
kind: options.kind,
|
|
40
|
+
limit: options.limit
|
|
41
|
+
}));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (output === 'text') {
|
|
45
|
+
console.log(formatNodesText(listNodeText({
|
|
46
|
+
root: options.root,
|
|
47
|
+
scope: options.scope ?? 'repo',
|
|
48
|
+
kind: options.kind,
|
|
49
|
+
term: options._[0],
|
|
50
|
+
limit: options.limit
|
|
51
|
+
})));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`nodes output must be json or text, got: ${output}`);
|
|
55
|
+
}
|
|
56
|
+
case 'edges':
|
|
57
|
+
printJson(listEdges({
|
|
58
|
+
root: options.root,
|
|
59
|
+
scope: options.scope ?? 'repo',
|
|
60
|
+
kind: options.kind,
|
|
61
|
+
limit: options.limit
|
|
62
|
+
}));
|
|
63
|
+
return;
|
|
64
|
+
case 'neighbors': {
|
|
65
|
+
const nodeId = options._[0] ?? options.nodeId;
|
|
66
|
+
printJson(neighbors({
|
|
67
|
+
root: options.root,
|
|
68
|
+
scope: options.scope ?? 'repo',
|
|
69
|
+
nodeId,
|
|
70
|
+
depth: options.depth,
|
|
71
|
+
limit: options.limit
|
|
72
|
+
}));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
case 'context': {
|
|
76
|
+
validateContextOptions(options, rest);
|
|
77
|
+
const symbol = options._[0];
|
|
78
|
+
if (!symbol)
|
|
79
|
+
throw new Error('context requires a symbol');
|
|
80
|
+
const result = await contextGraph({
|
|
81
|
+
root: options.root,
|
|
82
|
+
scope: options.scope ?? 'repo',
|
|
83
|
+
symbol,
|
|
84
|
+
kind: options.kind,
|
|
85
|
+
file: options.file,
|
|
86
|
+
depth: options.depth,
|
|
87
|
+
limit: options.limit,
|
|
88
|
+
candidateLimit: options.candidateLimit,
|
|
89
|
+
excerptLines: options.excerptLines,
|
|
90
|
+
excerptBytes: options.excerptBytes,
|
|
91
|
+
excerpts: options.excerpts
|
|
92
|
+
});
|
|
93
|
+
const output = options.output ?? 'text';
|
|
94
|
+
if (output === 'json') {
|
|
95
|
+
printJson(result);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (output === 'text') {
|
|
99
|
+
console.log(formatContextText(result));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (output === 'svg') {
|
|
103
|
+
console.log(formatContextSvg(result, { root: options.root, scope: options.scope ?? 'repo' }));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
throw new Error(`context output must be json, text, or svg, got: ${output}`);
|
|
107
|
+
}
|
|
108
|
+
case 'callgraph': {
|
|
109
|
+
const results = callGraph({
|
|
110
|
+
root: options.root,
|
|
111
|
+
scope: options.scope ?? 'repo',
|
|
112
|
+
term: options._[0],
|
|
113
|
+
kind: options.kind,
|
|
114
|
+
depth: options.depth,
|
|
115
|
+
limit: options.limit
|
|
116
|
+
});
|
|
117
|
+
const output = options.output ?? 'text';
|
|
118
|
+
if (output === 'json') {
|
|
119
|
+
printJson(results);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (output === 'text') {
|
|
123
|
+
console.log(formatCallGraphText(results));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (output === 'svg') {
|
|
127
|
+
console.log(formatCallGraphSvg(results, { root: options.root, scope: options.scope ?? 'repo' }));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
throw new Error(`callgraph output must be json, text, or svg, got: ${output}`);
|
|
131
|
+
}
|
|
132
|
+
case 'query': {
|
|
133
|
+
const sql = options.sql ?? options._.join(' ');
|
|
134
|
+
printJson(runReadOnlySql({
|
|
135
|
+
root: options.root,
|
|
136
|
+
scope: options.scope ?? 'repo',
|
|
137
|
+
sql,
|
|
138
|
+
attachHome: Boolean(options.attachHome),
|
|
139
|
+
limit: options.limit
|
|
140
|
+
}));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
case 'view':
|
|
144
|
+
printJson(await viewGraph({
|
|
145
|
+
root: options.root,
|
|
146
|
+
scope: options.scope ?? 'repo',
|
|
147
|
+
anchor: options.anchor,
|
|
148
|
+
search: options.search,
|
|
149
|
+
output: options.output
|
|
150
|
+
}));
|
|
151
|
+
return;
|
|
152
|
+
case 'mcp':
|
|
153
|
+
await startMcpServer({ root: options.root });
|
|
154
|
+
return;
|
|
155
|
+
case 'help':
|
|
156
|
+
case '--help':
|
|
157
|
+
case '-h':
|
|
158
|
+
console.log(helpText());
|
|
159
|
+
return;
|
|
160
|
+
default:
|
|
161
|
+
throw new Error(`Unknown command: ${command}\n\n${helpText()}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function parseOptions(args) {
|
|
165
|
+
const options = { _: [] };
|
|
166
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
167
|
+
const arg = args[index];
|
|
168
|
+
if (!arg)
|
|
169
|
+
continue;
|
|
170
|
+
const shortOption = /^-([rskdlf])(?:=(.*))?$/.exec(arg);
|
|
171
|
+
if (shortOption) {
|
|
172
|
+
const keyByAlias = {
|
|
173
|
+
r: 'root',
|
|
174
|
+
s: 'scope',
|
|
175
|
+
k: 'kind',
|
|
176
|
+
d: 'depth',
|
|
177
|
+
l: 'limit',
|
|
178
|
+
f: 'file'
|
|
179
|
+
};
|
|
180
|
+
const key = keyByAlias[shortOption[1]];
|
|
181
|
+
const inlineValue = shortOption[2];
|
|
182
|
+
if (inlineValue !== undefined) {
|
|
183
|
+
options[key] = coerce(inlineValue);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const next = args[index + 1];
|
|
187
|
+
if (next && !next.startsWith('-')) {
|
|
188
|
+
options[key] = coerce(next);
|
|
189
|
+
index += 1;
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
options[key] = true;
|
|
193
|
+
}
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (arg === '-o' || arg.startsWith('-o=')) {
|
|
197
|
+
const inlineValue = arg.startsWith('-o=') ? arg.slice(3) : undefined;
|
|
198
|
+
if (inlineValue !== undefined) {
|
|
199
|
+
options.output = coerce(inlineValue);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const next = args[index + 1];
|
|
203
|
+
if (next && !next.startsWith('-')) {
|
|
204
|
+
options.output = coerce(next);
|
|
205
|
+
index += 1;
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
options.output = true;
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (!arg.startsWith('--')) {
|
|
213
|
+
options._.push(arg);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const [rawKey, inlineValue] = arg.slice(2).split('=', 2);
|
|
217
|
+
if (!rawKey)
|
|
218
|
+
continue;
|
|
219
|
+
const key = toCamelCase(rawKey);
|
|
220
|
+
if (inlineValue !== undefined) {
|
|
221
|
+
options[key] = coerce(inlineValue);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const next = args[index + 1];
|
|
225
|
+
const booleanOption = key === 'watch' || key === 'attachHome' || key === 'excerpts' || key === 'help';
|
|
226
|
+
if (!booleanOption && next && !next.startsWith('--')) {
|
|
227
|
+
options[key] = coerce(next);
|
|
228
|
+
index += 1;
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
options[key] = true;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return options;
|
|
235
|
+
}
|
|
236
|
+
function validateContextOptions(options, args) {
|
|
237
|
+
const allowed = new Set(['root', 'scope', 'kind', 'file', 'depth', 'limit', 'candidate-limit', 'excerpt-lines', 'excerpt-bytes', 'excerpts', 'output', 'help']);
|
|
238
|
+
const seen = new Set();
|
|
239
|
+
for (const arg of args) {
|
|
240
|
+
if (!arg.startsWith('--'))
|
|
241
|
+
continue;
|
|
242
|
+
const key = arg.slice(2).split('=', 1)[0] ?? '';
|
|
243
|
+
if (!allowed.has(key))
|
|
244
|
+
throw new Error(`unknown context option --${key}`);
|
|
245
|
+
if (seen.has(key) && key !== 'help')
|
|
246
|
+
throw new Error(`context option --${key} may only be provided once`);
|
|
247
|
+
seen.add(key);
|
|
248
|
+
}
|
|
249
|
+
if (options._.length !== 1)
|
|
250
|
+
throw new Error('context requires exactly one symbol');
|
|
251
|
+
const numeric = [
|
|
252
|
+
['depth', options.depth, 0, 5],
|
|
253
|
+
['limit', options.limit, 1, 500],
|
|
254
|
+
['candidate-limit', options.candidateLimit, 1, 100],
|
|
255
|
+
['excerpt-lines', options.excerptLines, 1, 500],
|
|
256
|
+
['excerpt-bytes', options.excerptBytes, 1, 100_000]
|
|
257
|
+
];
|
|
258
|
+
for (const [name, value, minimum, maximum] of numeric) {
|
|
259
|
+
if (value !== undefined && (!Number.isInteger(value) || value < minimum || value > maximum)) {
|
|
260
|
+
throw new Error(`context --${name} must be an integer between ${minimum} and ${maximum}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (options.scope !== undefined && options.scope !== 'repo' && options.scope !== 'home' && options.scope !== 'both') {
|
|
264
|
+
throw new Error('context --scope must be repo, home, or both');
|
|
265
|
+
}
|
|
266
|
+
if (options.output !== undefined && options.output !== 'json' && options.output !== 'text' && options.output !== 'svg') {
|
|
267
|
+
throw new Error('context --output must be json, text, or svg');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function commandHelpText(command) {
|
|
271
|
+
const help = {
|
|
272
|
+
init: `Usage: lscg init [options]\n\nCreate graph storage for the selected scope.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to initialize (default: repo).\n\nExamples:\n lscg init --scope both`,
|
|
273
|
+
scan: `Usage: lscg scan [options]\n\nParse repository files and write discovered graph data.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to scan (default: repo).\n --watch Watch and rescan after the initial scan.\n\nExamples:\n lscg scan --scope both\n lscg scan --watch`,
|
|
274
|
+
watch: `Usage: lscg watch [options]\n\nKeep the repository current by rescanning on file changes.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo Repository scope to watch (default: repo).\n\nExamples:\n lscg watch`,
|
|
275
|
+
status: `Usage: lscg status [options]\n\nShow graph counts and database paths.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to inspect (default: repo).\n\nExamples:\n lscg status --scope repo`,
|
|
276
|
+
nodes: `Usage: lscg nodes [term] [options]\n\nList graph nodes, optionally filtered by a case-insensitive name term.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to inspect (default: repo).\n --kind node-kind Filter by node kind.\n --limit n Maximum number of nodes (default: 50).\n --output json|text Output format (default: json).\n -o value Alias for --output.\n\nExamples:\n lscg nodes --kind user --limit 20\n lscg nodes --output text`,
|
|
277
|
+
edges: `Usage: lscg edges [options]\n\nList graph edges.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to inspect (default: repo).\n --kind edge-kind Filter by edge kind.\n --limit n Maximum number of edges (default: 50).\n\nExamples:\n lscg edges --kind calls --limit 20`,
|
|
278
|
+
neighbors: `Usage: lscg neighbors <node-id> [options]\n\nShow nearby nodes around a graph node id.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to inspect (default: repo).\n --depth n Traversal depth (default: 1).\n --limit n Maximum number of neighbors (default: 100).\n --node-id id Node ID alternative to the positional argument.\n\nExamples:\n lscg neighbors <node-id> --depth 2`,
|
|
279
|
+
callgraph: `Usage: lscg callgraph <term> [options]\n\nFind nodes whose names contain a term and show upstream/downstream nodes.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to inspect (default: repo).\n --kind node-kind Filter matching nodes by kind.\n --depth n Upstream/downstream traversal depth (default: 5).\n --limit n Maximum matches and relations (default: 100).\n --output json|text|svg Output format (default: text; svg requires repo or home scope).\n -o value Alias for --output.\n\nExamples:\n lscg callgraph greet --depth 2\n lscg callgraph greet --output text`,
|
|
280
|
+
context: `Usage: lscg context <symbol> [options]\n\nRetrieve bounded definition, impact, and dependency context.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to inspect (default: repo).\n --kind node-kind Constrain anchor candidates by node kind.\n --file relative/path Constrain anchor candidates by repository-relative path.\n --depth 0..5 Relationship depth (default: 2).\n --limit 1..500 Relationships per section (default: 50).\n --candidate-limit 1..100 Maximum ranked candidates (default: 20).\n --excerpt-lines 1..500 Opt-in excerpt line budget (default: 80).\n --excerpt-bytes 1..100000 Opt-in excerpt byte budget (default: 12000).\n --excerpts Include bounded source excerpts.\n --output json|text|svg Output format (default: text; svg requires repo or home scope).\n -o value Alias for --output.\n\nExamples:\n lscg context greet --kind symbol\n lscg context greet --output text`,
|
|
281
|
+
query: `Usage: lscg query <sql> [options]\n\nRun a read-only SQLite query against the graph database.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to query (default: repo).\n --sql \"select ...\" SQL alternative to the positional argument.\n --attach-home Attach the home database when querying repo scope.\n --limit n Maximum number of rows (default: 200).\n\nExamples:\n lscg query \"select kind, count(*) as n from nodes group by kind\"`,
|
|
282
|
+
view: `Usage: lscg view [options]\n\nOpen an interactive graph view or export a static SVG.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home Storage scope to view (default: repo).\n --anchor name Focus the view around a node name.\n --search term Filter visible nodes by name.\n --output path Write an SVG snapshot instead of opening a browser.\n -o path Alias for --output.\n\nExamples:\n lscg view --anchor greet --search util\n lscg view --output graph.svg`,
|
|
283
|
+
mcp: `Usage: lscg mcp [options]\n\nStart the MCP server over stdio.\nOptions:\n --root path Repository root for the MCP server (default: current directory).\n\nExamples:\n lscg mcp`
|
|
284
|
+
};
|
|
285
|
+
const commandHelp = help[command];
|
|
286
|
+
if (!commandHelp)
|
|
287
|
+
return helpText();
|
|
288
|
+
return `${commandHelp}\n\nShort aliases (where supported): -r --root, -s --scope, -k --kind, -d --depth, -l --limit, -f --file, -o --output.`;
|
|
289
|
+
}
|
|
290
|
+
function toCamelCase(value) {
|
|
291
|
+
return value.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
|
|
292
|
+
}
|
|
293
|
+
function coerce(value) {
|
|
294
|
+
if (/^\d+$/.test(value))
|
|
295
|
+
return Number(value);
|
|
296
|
+
if (value === 'true')
|
|
297
|
+
return true;
|
|
298
|
+
if (value === 'false')
|
|
299
|
+
return false;
|
|
300
|
+
return value;
|
|
301
|
+
}
|
|
302
|
+
function printJson(value) {
|
|
303
|
+
console.log(JSON.stringify(value, null, 2));
|
|
304
|
+
}
|
|
305
|
+
function formatNodesText(nodes) {
|
|
306
|
+
if (nodes.length === 0)
|
|
307
|
+
return 'No matching nodes.';
|
|
308
|
+
return nodes.map((node) => [
|
|
309
|
+
`${node.scope}: ${node.root}`,
|
|
310
|
+
`node: ${node.name ?? '<unnamed>'} <${node.kind}/${node.type}> (${node.id})`,
|
|
311
|
+
`file: ${node.path ?? '<unknown>'}`,
|
|
312
|
+
`lines: ${node.startPoint.row + 1}-${node.endPoint.row + 1}`
|
|
313
|
+
].join('\n')).join('\n\n');
|
|
314
|
+
}
|
|
315
|
+
function formatContextText(result) {
|
|
316
|
+
return result.scopes.map((scope) => {
|
|
317
|
+
const lines = [`scope: ${scope.scope}`, `state: ${scope.result.state}`, `freshness: ${scope.freshness.state}`];
|
|
318
|
+
const anchor = scope.result.anchor ?? scope.result.candidates?.[0];
|
|
319
|
+
if (anchor) {
|
|
320
|
+
lines.push(`anchor: ${anchor.name ?? '<unnamed>'} [${anchor.kind}/${anchor.type}] ${anchor.path ?? '<unknown>'}:${anchor.span.start.row + 1}`);
|
|
321
|
+
if (anchor.excerpt !== undefined) {
|
|
322
|
+
lines.push('snippet:');
|
|
323
|
+
lines.push(...anchor.excerpt.split(/\r?\n/).map((line) => ` ${line}`));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (scope.result.candidates?.length) {
|
|
327
|
+
lines.push('candidates:');
|
|
328
|
+
lines.push(...scope.result.candidates.map((candidate) => ` - ${candidate.name ?? '<unnamed>'} [${candidate.kind}] ${candidate.path ?? '<unknown>'}:${candidate.span.start.row + 1}`));
|
|
329
|
+
}
|
|
330
|
+
lines.push('impact:');
|
|
331
|
+
lines.push(...formatContextRelations(scope.result.impact));
|
|
332
|
+
lines.push('dependencies:');
|
|
333
|
+
lines.push(...formatContextRelations(scope.result.dependencies));
|
|
334
|
+
if (scope.result.warnings.length)
|
|
335
|
+
lines.push(`warnings: ${scope.result.warnings.join(', ')}`);
|
|
336
|
+
return lines.join('\n');
|
|
337
|
+
}).join('\n\n');
|
|
338
|
+
}
|
|
339
|
+
function formatContextRelations(relations) {
|
|
340
|
+
if (relations.length === 0)
|
|
341
|
+
return [' (none)'];
|
|
342
|
+
return relations.map((relation) => ` - ${relation.direction} ${relation.target.name ?? '<unnamed>'} [${relation.edgeKind}] depth=${relation.depth} ${relation.target.path ?? '<unknown>'}:${relation.target.span.start.row + 1}`);
|
|
343
|
+
}
|
|
344
|
+
function formatContextSvg(result, { root, scope }) {
|
|
345
|
+
if (scope === 'both')
|
|
346
|
+
throw new Error('context output svg does not support --scope both');
|
|
347
|
+
const scopeResult = result.scopes[0];
|
|
348
|
+
if (!scopeResult)
|
|
349
|
+
throw new Error('context output svg has no scope result');
|
|
350
|
+
const snapshot = loadViewSnapshot({ root, scope });
|
|
351
|
+
const ids = new Set();
|
|
352
|
+
const addReference = (reference) => { if (reference)
|
|
353
|
+
ids.add(reference.id); };
|
|
354
|
+
addReference(scopeResult.result.anchor);
|
|
355
|
+
for (const candidate of scopeResult.result.candidates ?? [])
|
|
356
|
+
addReference(candidate);
|
|
357
|
+
for (const relation of [...scopeResult.result.impact, ...scopeResult.result.dependencies]) {
|
|
358
|
+
addReference(relation.source);
|
|
359
|
+
addReference(relation.target);
|
|
360
|
+
}
|
|
361
|
+
const filteredSnapshot = {
|
|
362
|
+
...snapshot,
|
|
363
|
+
nodes: snapshot.nodes.filter((node) => ids.has(node.id)),
|
|
364
|
+
edges: snapshot.edges.filter((edge) => ids.has(edge.sourceId) && ids.has(edge.targetId))
|
|
365
|
+
};
|
|
366
|
+
return renderSvgMarkup(buildViewModel(filteredSnapshot), { includeHiddenNodes: true, interactive: false });
|
|
367
|
+
}
|
|
368
|
+
function formatCallGraphText(results) {
|
|
369
|
+
if (results.length === 0)
|
|
370
|
+
return 'No matching nodes.';
|
|
371
|
+
return results.map((match) => {
|
|
372
|
+
const lines = [`${match.scope}: ${match.name ?? '<unnamed>'} [${match.kind}/${match.type}] (${match.id})`];
|
|
373
|
+
lines.push(' upstream:');
|
|
374
|
+
lines.push(...formatCallGraphRelations(match.upstream));
|
|
375
|
+
lines.push(' downstream:');
|
|
376
|
+
lines.push(...formatCallGraphRelations(match.downstream));
|
|
377
|
+
return lines.join('\n');
|
|
378
|
+
}).join('\n\n');
|
|
379
|
+
}
|
|
380
|
+
function formatCallGraphRelations(relations) {
|
|
381
|
+
if (relations.length === 0)
|
|
382
|
+
return [' (none)'];
|
|
383
|
+
return relations.map((relation) => ` - ${relation.name ?? '<unnamed>'} [${relation.kind}/${relation.type}] depth=${relation.depth} edge=${relation.edgeKind} (${relation.id})`);
|
|
384
|
+
}
|
|
385
|
+
function formatCallGraphSvg(results, { root, scope }) {
|
|
386
|
+
if (scope === 'both')
|
|
387
|
+
throw new Error('callgraph svg output does not support --scope both');
|
|
388
|
+
const snapshot = loadViewSnapshot({ root, scope });
|
|
389
|
+
const visibleIds = new Set(results.flatMap((match) => [
|
|
390
|
+
match.id,
|
|
391
|
+
...match.upstream.map((node) => node.id),
|
|
392
|
+
...match.downstream.map((node) => node.id)
|
|
393
|
+
]));
|
|
394
|
+
const filteredSnapshot = {
|
|
395
|
+
...snapshot,
|
|
396
|
+
nodes: snapshot.nodes.filter((node) => visibleIds.has(node.id)),
|
|
397
|
+
edges: snapshot.edges.filter((edge) => visibleIds.has(edge.sourceId) && visibleIds.has(edge.targetId))
|
|
398
|
+
};
|
|
399
|
+
return renderSvgMarkup(buildViewModel(filteredSnapshot), { includeHiddenNodes: true, interactive: false });
|
|
400
|
+
}
|
|
401
|
+
function helpText() {
|
|
402
|
+
return `lscg - local source context graphs
|
|
403
|
+
|
|
404
|
+
Usage:
|
|
405
|
+
lscg <command> [options]
|
|
406
|
+
|
|
407
|
+
Commands:
|
|
408
|
+
init Create graph storage for the selected scope.
|
|
409
|
+
scan Parse repository files and write discovered graph data.
|
|
410
|
+
watch Keep the repository current by rescanning on file changes.
|
|
411
|
+
status Show graph counts and database paths.
|
|
412
|
+
nodes List graph nodes.
|
|
413
|
+
edges List graph edges.
|
|
414
|
+
neighbors Show nearby nodes around a graph node id.
|
|
415
|
+
callgraph Find nodes whose names contain a term and show upstream/downstream nodes.
|
|
416
|
+
context Retrieve bounded definition, impact, and dependency context.
|
|
417
|
+
query Run a read-only SQLite query against the graph database.
|
|
418
|
+
view Open an interactive graph view or export a static SVG.
|
|
419
|
+
mcp Start the MCP server over stdio.
|
|
420
|
+
help Show this help text.
|
|
421
|
+
|
|
422
|
+
Command options:
|
|
423
|
+
init:
|
|
424
|
+
--root path Repository root (default: current directory).
|
|
425
|
+
--scope repo|home|both Storage scope to initialize (default: repo).
|
|
426
|
+
scan:
|
|
427
|
+
--root path Repository root (default: current directory).
|
|
428
|
+
--scope repo|home|both Storage scope to scan (default: repo).
|
|
429
|
+
--watch Watch and rescan after the initial scan.
|
|
430
|
+
watch:
|
|
431
|
+
--root path Repository root (default: current directory).
|
|
432
|
+
--scope repo Repository scope to watch (default: repo).
|
|
433
|
+
status:
|
|
434
|
+
--root path Repository root (default: current directory).
|
|
435
|
+
--scope repo|home|both Storage scope to inspect (default: repo).
|
|
436
|
+
nodes [term]:
|
|
437
|
+
--root path Repository root (default: current directory).
|
|
438
|
+
--scope repo|home|both Storage scope to inspect (default: repo).
|
|
439
|
+
--kind node-kind Filter by node kind.
|
|
440
|
+
--limit n Maximum number of nodes (default: 50).
|
|
441
|
+
--output json|text Output format (default: json).
|
|
442
|
+
edges:
|
|
443
|
+
--root path Repository root (default: current directory).
|
|
444
|
+
--scope repo|home|both Storage scope to inspect (default: repo).
|
|
445
|
+
--kind edge-kind Filter by edge kind.
|
|
446
|
+
--limit n Maximum number of edges (default: 50).
|
|
447
|
+
neighbors <node-id>:
|
|
448
|
+
--root path Repository root (default: current directory).
|
|
449
|
+
--scope repo|home|both Storage scope to inspect (default: repo).
|
|
450
|
+
--depth n Traversal depth (default: 1).
|
|
451
|
+
--limit n Maximum number of neighbors (default: 100).
|
|
452
|
+
--node-id id Node ID alternative to the positional argument.
|
|
453
|
+
context <symbol>:
|
|
454
|
+
--root path Repository root (default: current directory).
|
|
455
|
+
--scope repo|home|both Storage scope to inspect (default: repo).
|
|
456
|
+
--kind node-kind Constrain anchor candidates by node kind.
|
|
457
|
+
--file relative/path Constrain anchor candidates by repository-relative path.
|
|
458
|
+
--depth n Relationship depth (default: 2, maximum: 5).
|
|
459
|
+
--limit n Relationships per section (default: 50, maximum: 500).
|
|
460
|
+
--candidate-limit n Maximum ranked candidates (default: 20, maximum: 100).
|
|
461
|
+
--excerpt-lines n Opt-in excerpt line budget (default: 80, maximum: 500).
|
|
462
|
+
--excerpt-bytes n Opt-in excerpt byte budget (default: 12000, maximum: 100000).
|
|
463
|
+
--excerpts Include bounded source excerpts.
|
|
464
|
+
--output json|text|svg Output format (default: text).
|
|
465
|
+
callgraph <term>:
|
|
466
|
+
--root path Repository root (default: current directory).
|
|
467
|
+
--scope repo|home|both Storage scope to inspect (default: repo).
|
|
468
|
+
--kind node-kind Filter matching nodes by kind.
|
|
469
|
+
--depth n Upstream/downstream traversal depth (default: 5).
|
|
470
|
+
--limit n Maximum matches and relations (default: 100).
|
|
471
|
+
--output json|text|svg Output format (default: text).
|
|
472
|
+
query <sql>:
|
|
473
|
+
--root path Repository root (default: current directory).
|
|
474
|
+
--scope repo|home|both Storage scope to query (default: repo).
|
|
475
|
+
--sql "select ..." SQL alternative to the positional argument.
|
|
476
|
+
--attach-home Attach the home database when querying repo scope.
|
|
477
|
+
--limit n Maximum rows (default: 200).
|
|
478
|
+
view:
|
|
479
|
+
--root path Repository root (default: current directory).
|
|
480
|
+
--scope repo|home Storage scope to view (default: repo).
|
|
481
|
+
--anchor name Focus the view around a node name.
|
|
482
|
+
--search term Filter visible nodes by name.
|
|
483
|
+
--output path Write an SVG snapshot instead of opening a browser.
|
|
484
|
+
mcp:
|
|
485
|
+
--root path Repository root for the MCP server (default: current directory).
|
|
486
|
+
|
|
487
|
+
Short aliases:
|
|
488
|
+
-r path Alias for --root.
|
|
489
|
+
-s value Alias for --scope.
|
|
490
|
+
-k value Alias for --kind.
|
|
491
|
+
-d n Alias for --depth.
|
|
492
|
+
-l n Alias for --limit.
|
|
493
|
+
-f path Alias for --file.
|
|
494
|
+
-o value Alias for --output.
|
|
495
|
+
|
|
496
|
+
Examples:
|
|
497
|
+
lscg init --scope both
|
|
498
|
+
lscg scan --scope both
|
|
499
|
+
lscg watch
|
|
500
|
+
lscg nodes --kind user --limit 20
|
|
501
|
+
lscg edges --kind calls --limit 20
|
|
502
|
+
lscg neighbors <node-id> --depth 2
|
|
503
|
+
lscg context greet --depth 2 --kind symbol
|
|
504
|
+
lscg context greet --excerpts --excerpt-lines 40
|
|
505
|
+
lscg callgraph greet --depth 2
|
|
506
|
+
lscg callgraph greet --output text
|
|
507
|
+
lscg callgraph greet --output svg
|
|
508
|
+
lscg query "select kind, count(*) as n from nodes group by kind"
|
|
509
|
+
lscg view --anchor greet --search util
|
|
510
|
+
lscg view --output graph.svg
|
|
511
|
+
|
|
512
|
+
Notes:
|
|
513
|
+
- lscg context is for indexed source context; use rg/grep for literal discovery.
|
|
514
|
+
- Context relationships are syntax-grounded and may require source verification.
|
|
515
|
+
- lscg view opens an interactive HTML page by default.
|
|
516
|
+
- Pass --output to write a static SVG snapshot instead of opening the browser.
|
|
517
|
+
- lscg watch supports repo scope only.
|
|
518
|
+
|
|
519
|
+
Storage:
|
|
520
|
+
repo: <root>/.sling/graph.sqlite
|
|
521
|
+
home: ~/.sling/graph.sqlite
|
|
522
|
+
`;
|
|
523
|
+
}
|
|
524
|
+
//# sourceMappingURL=cli.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { GraphScope, StorageScope } from '../types.js';
|
|
2
|
+
export declare const GRAPH_DIR_NAME = ".sling";
|
|
3
|
+
export declare const GRAPH_DATABASE_NAME = "graph.sqlite";
|
|
4
|
+
export declare function resolveProjectRoot(input?: string): string;
|
|
5
|
+
export declare function repoGraphDir(root?: string): string;
|
|
6
|
+
export declare function homeGraphDir(): string;
|
|
7
|
+
export declare function repoDatabasePath(root?: string): string;
|
|
8
|
+
export declare function homeDatabasePath(): string;
|
|
9
|
+
export declare function databasePathForScope(scope: StorageScope, root?: string): string;
|
|
10
|
+
export declare function ensureGraphDirForScope(scope: StorageScope, root?: string): string;
|
|
11
|
+
export declare function normalizeScope(scope?: GraphScope): StorageScope[];
|
|
12
|
+
//# sourceMappingURL=paths.d.ts.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { mkdirSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
export const GRAPH_DIR_NAME = '.sling';
|
|
6
|
+
export const GRAPH_DATABASE_NAME = 'graph.sqlite';
|
|
7
|
+
export function resolveProjectRoot(input = process.cwd()) {
|
|
8
|
+
const resolvedPath = realpathSync(path.resolve(input));
|
|
9
|
+
const gitRoot = detectGitRoot(resolvedPath);
|
|
10
|
+
return gitRoot ?? resolvedPath;
|
|
11
|
+
}
|
|
12
|
+
export function repoGraphDir(root = process.cwd()) {
|
|
13
|
+
return path.join(resolveProjectRoot(root), GRAPH_DIR_NAME);
|
|
14
|
+
}
|
|
15
|
+
export function homeGraphDir() {
|
|
16
|
+
return path.join(os.homedir(), GRAPH_DIR_NAME);
|
|
17
|
+
}
|
|
18
|
+
export function repoDatabasePath(root = process.cwd()) {
|
|
19
|
+
return path.join(repoGraphDir(root), GRAPH_DATABASE_NAME);
|
|
20
|
+
}
|
|
21
|
+
export function homeDatabasePath() {
|
|
22
|
+
return path.join(homeGraphDir(), GRAPH_DATABASE_NAME);
|
|
23
|
+
}
|
|
24
|
+
export function databasePathForScope(scope, root = process.cwd()) {
|
|
25
|
+
if (scope === 'repo')
|
|
26
|
+
return repoDatabasePath(root);
|
|
27
|
+
if (scope === 'home')
|
|
28
|
+
return homeDatabasePath();
|
|
29
|
+
throw new Error(`Unknown graph scope: ${scope}`);
|
|
30
|
+
}
|
|
31
|
+
export function ensureGraphDirForScope(scope, root = process.cwd()) {
|
|
32
|
+
const dir = scope === 'repo' ? repoGraphDir(root) : homeGraphDir();
|
|
33
|
+
mkdirSync(dir, { recursive: true });
|
|
34
|
+
return dir;
|
|
35
|
+
}
|
|
36
|
+
export function normalizeScope(scope = 'repo') {
|
|
37
|
+
if (scope === 'both')
|
|
38
|
+
return ['repo', 'home'];
|
|
39
|
+
if (scope === 'repo' || scope === 'home')
|
|
40
|
+
return [scope];
|
|
41
|
+
throw new Error(`Expected --scope repo|home|both, got ${scope}`);
|
|
42
|
+
}
|
|
43
|
+
function detectGitRoot(startPath) {
|
|
44
|
+
const result = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
|
45
|
+
cwd: startPath,
|
|
46
|
+
encoding: 'utf8'
|
|
47
|
+
});
|
|
48
|
+
if (result.status !== 0 || result.error) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const gitRoot = result.stdout.trim();
|
|
52
|
+
return gitRoot.length > 0 ? gitRoot : null;
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=paths.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { GraphNode, FileAttribution } from '../types.js';
|
|
2
|
+
export declare function buildFileAttribution({ root, relativePath, source, nodes }: {
|
|
3
|
+
root: string;
|
|
4
|
+
relativePath: string;
|
|
5
|
+
source: string;
|
|
6
|
+
nodes: GraphNode[];
|
|
7
|
+
}): FileAttribution | null;
|
|
8
|
+
//# sourceMappingURL=attribution.d.ts.map
|