@optave/codegraph 1.4.1 → 2.0.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/README.md +91 -40
- package/package.json +5 -5
- package/src/builder.js +52 -8
- package/src/cli.js +127 -1
- package/src/constants.js +0 -2
- package/src/cycles.js +2 -2
- package/src/db.js +13 -0
- package/src/export.js +44 -9
- package/src/index.js +21 -0
- package/src/mcp.js +140 -6
- package/src/parser.js +13 -14
- package/src/registry.js +145 -0
- package/src/structure.js +491 -0
- package/src/watcher.js +2 -2
package/src/export.js
CHANGED
|
@@ -24,25 +24,60 @@ export function exportDOT(db, opts = {}) {
|
|
|
24
24
|
`)
|
|
25
25
|
.all();
|
|
26
26
|
|
|
27
|
+
// Try to use directory nodes from DB (built by structure analysis)
|
|
28
|
+
const hasDirectoryNodes =
|
|
29
|
+
db.prepare("SELECT COUNT(*) as c FROM nodes WHERE kind = 'directory'").get().c > 0;
|
|
30
|
+
|
|
27
31
|
const dirs = new Map();
|
|
28
32
|
const allFiles = new Set();
|
|
29
33
|
for (const { source, target } of edges) {
|
|
30
34
|
allFiles.add(source);
|
|
31
35
|
allFiles.add(target);
|
|
32
36
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
|
|
38
|
+
if (hasDirectoryNodes) {
|
|
39
|
+
// Use DB directory structure with cohesion labels
|
|
40
|
+
const dbDirs = db
|
|
41
|
+
.prepare(`
|
|
42
|
+
SELECT n.id, n.name, nm.cohesion
|
|
43
|
+
FROM nodes n
|
|
44
|
+
LEFT JOIN node_metrics nm ON n.id = nm.node_id
|
|
45
|
+
WHERE n.kind = 'directory'
|
|
46
|
+
`)
|
|
47
|
+
.all();
|
|
48
|
+
|
|
49
|
+
for (const d of dbDirs) {
|
|
50
|
+
const containedFiles = db
|
|
51
|
+
.prepare(`
|
|
52
|
+
SELECT n.name FROM edges e
|
|
53
|
+
JOIN nodes n ON e.target_id = n.id
|
|
54
|
+
WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'file'
|
|
55
|
+
`)
|
|
56
|
+
.all(d.id)
|
|
57
|
+
.map((r) => r.name)
|
|
58
|
+
.filter((f) => allFiles.has(f));
|
|
59
|
+
|
|
60
|
+
if (containedFiles.length > 0) {
|
|
61
|
+
dirs.set(d.name, { files: containedFiles, cohesion: d.cohesion });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
// Fallback: reconstruct from path.dirname()
|
|
66
|
+
for (const file of allFiles) {
|
|
67
|
+
const dir = path.dirname(file) || '.';
|
|
68
|
+
if (!dirs.has(dir)) dirs.set(dir, { files: [], cohesion: null });
|
|
69
|
+
dirs.get(dir).files.push(file);
|
|
70
|
+
}
|
|
37
71
|
}
|
|
38
72
|
|
|
39
73
|
let clusterIdx = 0;
|
|
40
|
-
for (const [dir,
|
|
74
|
+
for (const [dir, info] of [...dirs].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
41
75
|
lines.push(` subgraph cluster_${clusterIdx++} {`);
|
|
42
|
-
|
|
76
|
+
const cohLabel = info.cohesion !== null ? ` (cohesion: ${info.cohesion.toFixed(2)})` : '';
|
|
77
|
+
lines.push(` label="${dir}${cohLabel}";`);
|
|
43
78
|
lines.push(` style=dashed;`);
|
|
44
79
|
lines.push(` color="#999999";`);
|
|
45
|
-
for (const f of files) {
|
|
80
|
+
for (const f of info.files) {
|
|
46
81
|
const label = path.basename(f);
|
|
47
82
|
lines.push(` "${f}" [label="${label}"];`);
|
|
48
83
|
}
|
|
@@ -62,7 +97,7 @@ export function exportDOT(db, opts = {}) {
|
|
|
62
97
|
FROM edges e
|
|
63
98
|
JOIN nodes n1 ON e.source_id = n1.id
|
|
64
99
|
JOIN nodes n2 ON e.target_id = n2.id
|
|
65
|
-
WHERE n1.kind IN ('function', 'method', 'class') AND n2.kind IN ('function', 'method', 'class')
|
|
100
|
+
WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module') AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
|
|
66
101
|
AND e.kind = 'calls'
|
|
67
102
|
`)
|
|
68
103
|
.all();
|
|
@@ -111,7 +146,7 @@ export function exportMermaid(db, opts = {}) {
|
|
|
111
146
|
FROM edges e
|
|
112
147
|
JOIN nodes n1 ON e.source_id = n1.id
|
|
113
148
|
JOIN nodes n2 ON e.target_id = n2.id
|
|
114
|
-
WHERE n1.kind IN ('function', 'method', 'class') AND n2.kind IN ('function', 'method', 'class')
|
|
149
|
+
WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module') AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
|
|
115
150
|
AND e.kind = 'calls'
|
|
116
151
|
`)
|
|
117
152
|
.all();
|
package/src/index.js
CHANGED
|
@@ -46,5 +46,26 @@ export {
|
|
|
46
46
|
moduleMapData,
|
|
47
47
|
queryNameData,
|
|
48
48
|
} from './queries.js';
|
|
49
|
+
// Registry (multi-repo)
|
|
50
|
+
export {
|
|
51
|
+
listRepos,
|
|
52
|
+
loadRegistry,
|
|
53
|
+
pruneRegistry,
|
|
54
|
+
REGISTRY_PATH,
|
|
55
|
+
registerRepo,
|
|
56
|
+
resolveRepoDbPath,
|
|
57
|
+
saveRegistry,
|
|
58
|
+
unregisterRepo,
|
|
59
|
+
} from './registry.js';
|
|
60
|
+
// Structure analysis
|
|
61
|
+
export {
|
|
62
|
+
buildStructure,
|
|
63
|
+
formatHotspots,
|
|
64
|
+
formatModuleBoundaries,
|
|
65
|
+
formatStructure,
|
|
66
|
+
hotspotsData,
|
|
67
|
+
moduleBoundariesData,
|
|
68
|
+
structureData,
|
|
69
|
+
} from './structure.js';
|
|
49
70
|
// Watch mode
|
|
50
71
|
export { watchProject } from './watcher.js';
|
package/src/mcp.js
CHANGED
|
@@ -9,7 +9,14 @@ import { createRequire } from 'node:module';
|
|
|
9
9
|
import { findCycles } from './cycles.js';
|
|
10
10
|
import { findDbPath } from './db.js';
|
|
11
11
|
|
|
12
|
-
const
|
|
12
|
+
const REPO_PROP = {
|
|
13
|
+
repo: {
|
|
14
|
+
type: 'string',
|
|
15
|
+
description: 'Repository name from the registry (omit for local project)',
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const BASE_TOOLS = [
|
|
13
20
|
{
|
|
14
21
|
name: 'query_function',
|
|
15
22
|
description: 'Find callers and callees of a function by name',
|
|
@@ -143,7 +150,7 @@ const TOOLS = [
|
|
|
143
150
|
{
|
|
144
151
|
name: 'list_functions',
|
|
145
152
|
description:
|
|
146
|
-
'List functions, methods, and
|
|
153
|
+
'List functions, methods, classes, structs, enums, traits, records, and modules in the codebase, optionally filtered by file or name pattern',
|
|
147
154
|
inputSchema: {
|
|
148
155
|
type: 'object',
|
|
149
156
|
properties: {
|
|
@@ -153,15 +160,91 @@ const TOOLS = [
|
|
|
153
160
|
},
|
|
154
161
|
},
|
|
155
162
|
},
|
|
163
|
+
{
|
|
164
|
+
name: 'structure',
|
|
165
|
+
description:
|
|
166
|
+
'Show project structure with directory hierarchy, cohesion scores, and per-file metrics',
|
|
167
|
+
inputSchema: {
|
|
168
|
+
type: 'object',
|
|
169
|
+
properties: {
|
|
170
|
+
directory: { type: 'string', description: 'Filter to a specific directory path' },
|
|
171
|
+
depth: { type: 'number', description: 'Max directory depth to show' },
|
|
172
|
+
sort: {
|
|
173
|
+
type: 'string',
|
|
174
|
+
enum: ['cohesion', 'fan-in', 'fan-out', 'density', 'files'],
|
|
175
|
+
description: 'Sort directories by metric',
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: 'hotspots',
|
|
182
|
+
description:
|
|
183
|
+
'Find structural hotspots: files or directories with extreme fan-in, fan-out, or symbol density',
|
|
184
|
+
inputSchema: {
|
|
185
|
+
type: 'object',
|
|
186
|
+
properties: {
|
|
187
|
+
metric: {
|
|
188
|
+
type: 'string',
|
|
189
|
+
enum: ['fan-in', 'fan-out', 'density', 'coupling'],
|
|
190
|
+
description: 'Metric to rank by',
|
|
191
|
+
},
|
|
192
|
+
level: {
|
|
193
|
+
type: 'string',
|
|
194
|
+
enum: ['file', 'directory'],
|
|
195
|
+
description: 'Rank files or directories',
|
|
196
|
+
},
|
|
197
|
+
limit: { type: 'number', description: 'Number of results to return', default: 10 },
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
156
201
|
];
|
|
157
202
|
|
|
158
|
-
|
|
203
|
+
const LIST_REPOS_TOOL = {
|
|
204
|
+
name: 'list_repos',
|
|
205
|
+
description: 'List all repositories registered in the codegraph registry',
|
|
206
|
+
inputSchema: {
|
|
207
|
+
type: 'object',
|
|
208
|
+
properties: {},
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Build the tool list based on multi-repo mode.
|
|
214
|
+
* @param {boolean} multiRepo - If true, inject `repo` prop into each tool and append `list_repos`
|
|
215
|
+
* @returns {object[]}
|
|
216
|
+
*/
|
|
217
|
+
function buildToolList(multiRepo) {
|
|
218
|
+
if (!multiRepo) return BASE_TOOLS;
|
|
219
|
+
return [
|
|
220
|
+
...BASE_TOOLS.map((tool) => ({
|
|
221
|
+
...tool,
|
|
222
|
+
inputSchema: {
|
|
223
|
+
...tool.inputSchema,
|
|
224
|
+
properties: { ...tool.inputSchema.properties, ...REPO_PROP },
|
|
225
|
+
},
|
|
226
|
+
})),
|
|
227
|
+
LIST_REPOS_TOOL,
|
|
228
|
+
];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Backward-compatible export: full multi-repo tool list
|
|
232
|
+
const TOOLS = buildToolList(true);
|
|
233
|
+
|
|
234
|
+
export { TOOLS, buildToolList };
|
|
159
235
|
|
|
160
236
|
/**
|
|
161
237
|
* Start the MCP server.
|
|
162
238
|
* This function requires @modelcontextprotocol/sdk to be installed.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} [customDbPath] - Path to a specific graph.db
|
|
241
|
+
* @param {object} [options]
|
|
242
|
+
* @param {boolean} [options.multiRepo] - Enable multi-repo access (default: false)
|
|
243
|
+
* @param {string[]} [options.allowedRepos] - Restrict access to these repo names only
|
|
163
244
|
*/
|
|
164
|
-
export async function startMCPServer(customDbPath) {
|
|
245
|
+
export async function startMCPServer(customDbPath, options = {}) {
|
|
246
|
+
const { allowedRepos } = options;
|
|
247
|
+
const multiRepo = options.multiRepo || !!allowedRepos;
|
|
165
248
|
let Server, StdioServerTransport;
|
|
166
249
|
try {
|
|
167
250
|
const sdk = await import('@modelcontextprotocol/sdk/server/index.js');
|
|
@@ -196,13 +279,37 @@ export async function startMCPServer(customDbPath) {
|
|
|
196
279
|
{ capabilities: { tools: {} } },
|
|
197
280
|
);
|
|
198
281
|
|
|
199
|
-
server.setRequestHandler('tools/list', async () => ({ tools:
|
|
282
|
+
server.setRequestHandler('tools/list', async () => ({ tools: buildToolList(multiRepo) }));
|
|
200
283
|
|
|
201
284
|
server.setRequestHandler('tools/call', async (request) => {
|
|
202
285
|
const { name, arguments: args } = request.params;
|
|
203
|
-
const dbPath = customDbPath || undefined;
|
|
204
286
|
|
|
205
287
|
try {
|
|
288
|
+
if (!multiRepo && args.repo) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
'Multi-repo access is disabled. Restart with `codegraph mcp --multi-repo` to access other repositories.',
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
if (!multiRepo && name === 'list_repos') {
|
|
294
|
+
throw new Error(
|
|
295
|
+
'Multi-repo access is disabled. Restart with `codegraph mcp --multi-repo` to list repositories.',
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let dbPath = customDbPath || undefined;
|
|
300
|
+
if (args.repo) {
|
|
301
|
+
if (allowedRepos && !allowedRepos.includes(args.repo)) {
|
|
302
|
+
throw new Error(`Repository "${args.repo}" is not in the allowed repos list.`);
|
|
303
|
+
}
|
|
304
|
+
const { resolveRepoDbPath } = await import('./registry.js');
|
|
305
|
+
const resolved = resolveRepoDbPath(args.repo);
|
|
306
|
+
if (!resolved)
|
|
307
|
+
throw new Error(
|
|
308
|
+
`Repository "${args.repo}" not found in registry or its database is missing.`,
|
|
309
|
+
);
|
|
310
|
+
dbPath = resolved;
|
|
311
|
+
}
|
|
312
|
+
|
|
206
313
|
let result;
|
|
207
314
|
switch (name) {
|
|
208
315
|
case 'query_function':
|
|
@@ -296,6 +403,33 @@ export async function startMCPServer(customDbPath) {
|
|
|
296
403
|
noTests: args.no_tests,
|
|
297
404
|
});
|
|
298
405
|
break;
|
|
406
|
+
case 'structure': {
|
|
407
|
+
const { structureData } = await import('./structure.js');
|
|
408
|
+
result = structureData(dbPath, {
|
|
409
|
+
directory: args.directory,
|
|
410
|
+
depth: args.depth,
|
|
411
|
+
sort: args.sort,
|
|
412
|
+
});
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
case 'hotspots': {
|
|
416
|
+
const { hotspotsData } = await import('./structure.js');
|
|
417
|
+
result = hotspotsData(dbPath, {
|
|
418
|
+
metric: args.metric,
|
|
419
|
+
level: args.level,
|
|
420
|
+
limit: args.limit,
|
|
421
|
+
});
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
case 'list_repos': {
|
|
425
|
+
const { listRepos } = await import('./registry.js');
|
|
426
|
+
let repos = listRepos();
|
|
427
|
+
if (allowedRepos) {
|
|
428
|
+
repos = repos.filter((r) => allowedRepos.includes(r.name));
|
|
429
|
+
}
|
|
430
|
+
result = { repos };
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
299
433
|
default:
|
|
300
434
|
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
301
435
|
}
|
package/src/parser.js
CHANGED
|
@@ -2,7 +2,6 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { Language, Parser } from 'web-tree-sitter';
|
|
5
|
-
import { normalizePath } from './constants.js';
|
|
6
5
|
import { warn } from './logger.js';
|
|
7
6
|
import { loadNative } from './native.js';
|
|
8
7
|
|
|
@@ -731,7 +730,7 @@ export function extractGoSymbols(tree, _filePath) {
|
|
|
731
730
|
if (typeNode.type === 'struct_type') {
|
|
732
731
|
definitions.push({
|
|
733
732
|
name: nameNode.text,
|
|
734
|
-
kind: '
|
|
733
|
+
kind: 'struct',
|
|
735
734
|
line: node.startPosition.row + 1,
|
|
736
735
|
endLine: nodeEndLine(node),
|
|
737
736
|
});
|
|
@@ -876,7 +875,7 @@ export function extractRustSymbols(tree, _filePath) {
|
|
|
876
875
|
if (nameNode) {
|
|
877
876
|
definitions.push({
|
|
878
877
|
name: nameNode.text,
|
|
879
|
-
kind: '
|
|
878
|
+
kind: 'struct',
|
|
880
879
|
line: node.startPosition.row + 1,
|
|
881
880
|
endLine: nodeEndLine(node),
|
|
882
881
|
});
|
|
@@ -889,7 +888,7 @@ export function extractRustSymbols(tree, _filePath) {
|
|
|
889
888
|
if (nameNode) {
|
|
890
889
|
definitions.push({
|
|
891
890
|
name: nameNode.text,
|
|
892
|
-
kind: '
|
|
891
|
+
kind: 'enum',
|
|
893
892
|
line: node.startPosition.row + 1,
|
|
894
893
|
endLine: nodeEndLine(node),
|
|
895
894
|
});
|
|
@@ -902,7 +901,7 @@ export function extractRustSymbols(tree, _filePath) {
|
|
|
902
901
|
if (nameNode) {
|
|
903
902
|
definitions.push({
|
|
904
903
|
name: nameNode.text,
|
|
905
|
-
kind: '
|
|
904
|
+
kind: 'trait',
|
|
906
905
|
line: node.startPosition.row + 1,
|
|
907
906
|
endLine: nodeEndLine(node),
|
|
908
907
|
});
|
|
@@ -1186,7 +1185,7 @@ export function extractJavaSymbols(tree, _filePath) {
|
|
|
1186
1185
|
if (nameNode) {
|
|
1187
1186
|
definitions.push({
|
|
1188
1187
|
name: nameNode.text,
|
|
1189
|
-
kind: '
|
|
1188
|
+
kind: 'enum',
|
|
1190
1189
|
line: node.startPosition.row + 1,
|
|
1191
1190
|
endLine: nodeEndLine(node),
|
|
1192
1191
|
});
|
|
@@ -1320,7 +1319,7 @@ export function extractCSharpSymbols(tree, _filePath) {
|
|
|
1320
1319
|
if (nameNode) {
|
|
1321
1320
|
definitions.push({
|
|
1322
1321
|
name: nameNode.text,
|
|
1323
|
-
kind: '
|
|
1322
|
+
kind: 'struct',
|
|
1324
1323
|
line: node.startPosition.row + 1,
|
|
1325
1324
|
endLine: nodeEndLine(node),
|
|
1326
1325
|
});
|
|
@@ -1334,7 +1333,7 @@ export function extractCSharpSymbols(tree, _filePath) {
|
|
|
1334
1333
|
if (nameNode) {
|
|
1335
1334
|
definitions.push({
|
|
1336
1335
|
name: nameNode.text,
|
|
1337
|
-
kind: '
|
|
1336
|
+
kind: 'record',
|
|
1338
1337
|
line: node.startPosition.row + 1,
|
|
1339
1338
|
endLine: nodeEndLine(node),
|
|
1340
1339
|
});
|
|
@@ -1378,7 +1377,7 @@ export function extractCSharpSymbols(tree, _filePath) {
|
|
|
1378
1377
|
if (nameNode) {
|
|
1379
1378
|
definitions.push({
|
|
1380
1379
|
name: nameNode.text,
|
|
1381
|
-
kind: '
|
|
1380
|
+
kind: 'enum',
|
|
1382
1381
|
line: node.startPosition.row + 1,
|
|
1383
1382
|
endLine: nodeEndLine(node),
|
|
1384
1383
|
});
|
|
@@ -1588,7 +1587,7 @@ export function extractRubySymbols(tree, _filePath) {
|
|
|
1588
1587
|
if (nameNode) {
|
|
1589
1588
|
definitions.push({
|
|
1590
1589
|
name: nameNode.text,
|
|
1591
|
-
kind: '
|
|
1590
|
+
kind: 'module',
|
|
1592
1591
|
line: node.startPosition.row + 1,
|
|
1593
1592
|
endLine: nodeEndLine(node),
|
|
1594
1593
|
});
|
|
@@ -1818,7 +1817,7 @@ export function extractPHPSymbols(tree, _filePath) {
|
|
|
1818
1817
|
if (nameNode) {
|
|
1819
1818
|
definitions.push({
|
|
1820
1819
|
name: nameNode.text,
|
|
1821
|
-
kind: '
|
|
1820
|
+
kind: 'trait',
|
|
1822
1821
|
line: node.startPosition.row + 1,
|
|
1823
1822
|
endLine: nodeEndLine(node),
|
|
1824
1823
|
});
|
|
@@ -1831,7 +1830,7 @@ export function extractPHPSymbols(tree, _filePath) {
|
|
|
1831
1830
|
if (nameNode) {
|
|
1832
1831
|
definitions.push({
|
|
1833
1832
|
name: nameNode.text,
|
|
1834
|
-
kind: '
|
|
1833
|
+
kind: 'enum',
|
|
1835
1834
|
line: node.startPosition.row + 1,
|
|
1836
1835
|
endLine: nodeEndLine(node),
|
|
1837
1836
|
});
|
|
@@ -2145,7 +2144,7 @@ export async function parseFilesAuto(filePaths, rootDir, opts = {}) {
|
|
|
2145
2144
|
const nativeResults = native.parseFiles(filePaths, rootDir);
|
|
2146
2145
|
for (const r of nativeResults) {
|
|
2147
2146
|
if (!r) continue;
|
|
2148
|
-
const relPath =
|
|
2147
|
+
const relPath = path.relative(rootDir, r.file).split(path.sep).join('/');
|
|
2149
2148
|
result.set(relPath, normalizeNativeSymbols(r));
|
|
2150
2149
|
}
|
|
2151
2150
|
return result;
|
|
@@ -2163,7 +2162,7 @@ export async function parseFilesAuto(filePaths, rootDir, opts = {}) {
|
|
|
2163
2162
|
}
|
|
2164
2163
|
const symbols = wasmExtractSymbols(parsers, filePath, code);
|
|
2165
2164
|
if (symbols) {
|
|
2166
|
-
const relPath =
|
|
2165
|
+
const relPath = path.relative(rootDir, filePath).split(path.sep).join('/');
|
|
2167
2166
|
result.set(relPath, symbols);
|
|
2168
2167
|
}
|
|
2169
2168
|
}
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { debug, warn } from './logger.js';
|
|
5
|
+
|
|
6
|
+
export const REGISTRY_PATH = path.join(os.homedir(), '.codegraph', 'registry.json');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Load the registry from disk.
|
|
10
|
+
* Returns `{ repos: {} }` on missing or corrupt file.
|
|
11
|
+
*/
|
|
12
|
+
export function loadRegistry(registryPath = REGISTRY_PATH) {
|
|
13
|
+
try {
|
|
14
|
+
const raw = fs.readFileSync(registryPath, 'utf-8');
|
|
15
|
+
const data = JSON.parse(raw);
|
|
16
|
+
if (!data || typeof data.repos !== 'object') return { repos: {} };
|
|
17
|
+
return data;
|
|
18
|
+
} catch {
|
|
19
|
+
return { repos: {} };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Persist the registry to disk (atomic write via temp + rename).
|
|
25
|
+
* Creates the parent directory if needed.
|
|
26
|
+
*/
|
|
27
|
+
export function saveRegistry(registry, registryPath = REGISTRY_PATH) {
|
|
28
|
+
const dir = path.dirname(registryPath);
|
|
29
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
30
|
+
|
|
31
|
+
const tmp = `${registryPath}.tmp.${process.pid}`;
|
|
32
|
+
fs.writeFileSync(tmp, JSON.stringify(registry, null, 2), 'utf-8');
|
|
33
|
+
fs.renameSync(tmp, registryPath);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Register a project directory. Idempotent.
|
|
38
|
+
* Name defaults to `path.basename(rootDir)`.
|
|
39
|
+
*
|
|
40
|
+
* When no explicit name is provided and the basename already exists
|
|
41
|
+
* pointing to a different path, auto-suffixes (`api` → `api-2`, `api-3`, …).
|
|
42
|
+
* Re-registering the same path updates in place. Explicit names always overwrite.
|
|
43
|
+
*/
|
|
44
|
+
export function registerRepo(rootDir, name, registryPath = REGISTRY_PATH) {
|
|
45
|
+
const absRoot = path.resolve(rootDir);
|
|
46
|
+
const baseName = name || path.basename(absRoot);
|
|
47
|
+
const registry = loadRegistry(registryPath);
|
|
48
|
+
|
|
49
|
+
let repoName = baseName;
|
|
50
|
+
|
|
51
|
+
// Auto-suffix only when no explicit name was provided
|
|
52
|
+
if (!name) {
|
|
53
|
+
const existing = registry.repos[baseName];
|
|
54
|
+
if (existing && path.resolve(existing.path) !== absRoot) {
|
|
55
|
+
// Basename collision with a different path — find next available suffix
|
|
56
|
+
let suffix = 2;
|
|
57
|
+
while (registry.repos[`${baseName}-${suffix}`]) {
|
|
58
|
+
const entry = registry.repos[`${baseName}-${suffix}`];
|
|
59
|
+
if (path.resolve(entry.path) === absRoot) {
|
|
60
|
+
// Already registered under this suffixed name — update in place
|
|
61
|
+
repoName = `${baseName}-${suffix}`;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
suffix++;
|
|
65
|
+
}
|
|
66
|
+
if (repoName === baseName) {
|
|
67
|
+
repoName = `${baseName}-${suffix}`;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
registry.repos[repoName] = {
|
|
73
|
+
path: absRoot,
|
|
74
|
+
dbPath: path.join(absRoot, '.codegraph', 'graph.db'),
|
|
75
|
+
addedAt: new Date().toISOString(),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
saveRegistry(registry, registryPath);
|
|
79
|
+
debug(`Registered repo "${repoName}" at ${absRoot}`);
|
|
80
|
+
return { name: repoName, entry: registry.repos[repoName] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Remove a repo from the registry. Returns false if not found.
|
|
85
|
+
*/
|
|
86
|
+
export function unregisterRepo(name, registryPath = REGISTRY_PATH) {
|
|
87
|
+
const registry = loadRegistry(registryPath);
|
|
88
|
+
if (!registry.repos[name]) return false;
|
|
89
|
+
delete registry.repos[name];
|
|
90
|
+
saveRegistry(registry, registryPath);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* List all registered repos, sorted by name.
|
|
96
|
+
*/
|
|
97
|
+
export function listRepos(registryPath = REGISTRY_PATH) {
|
|
98
|
+
const registry = loadRegistry(registryPath);
|
|
99
|
+
return Object.entries(registry.repos)
|
|
100
|
+
.map(([name, entry]) => ({
|
|
101
|
+
name,
|
|
102
|
+
path: entry.path,
|
|
103
|
+
dbPath: entry.dbPath,
|
|
104
|
+
addedAt: entry.addedAt,
|
|
105
|
+
}))
|
|
106
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve a repo name to its database path.
|
|
111
|
+
* Returns undefined if the repo is not found or its DB file is missing.
|
|
112
|
+
*/
|
|
113
|
+
export function resolveRepoDbPath(name, registryPath = REGISTRY_PATH) {
|
|
114
|
+
const registry = loadRegistry(registryPath);
|
|
115
|
+
const entry = registry.repos[name];
|
|
116
|
+
if (!entry) return undefined;
|
|
117
|
+
if (!fs.existsSync(entry.dbPath)) {
|
|
118
|
+
warn(`Registry: database missing for "${name}" at ${entry.dbPath}`);
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
return entry.dbPath;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Remove registry entries whose repo directory no longer exists on disk.
|
|
126
|
+
* Only checks the repo directory (not the DB file — a missing DB is normal pre-build state).
|
|
127
|
+
* Returns an array of `{ name, path }` for each pruned entry.
|
|
128
|
+
*/
|
|
129
|
+
export function pruneRegistry(registryPath = REGISTRY_PATH) {
|
|
130
|
+
const registry = loadRegistry(registryPath);
|
|
131
|
+
const pruned = [];
|
|
132
|
+
|
|
133
|
+
for (const [name, entry] of Object.entries(registry.repos)) {
|
|
134
|
+
if (!fs.existsSync(entry.path)) {
|
|
135
|
+
pruned.push({ name, path: entry.path });
|
|
136
|
+
delete registry.repos[name];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (pruned.length > 0) {
|
|
141
|
+
saveRegistry(registry, registryPath);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return pruned;
|
|
145
|
+
}
|