@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/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
- for (const file of allFiles) {
34
- const dir = path.dirname(file) || '.';
35
- if (!dirs.has(dir)) dirs.set(dir, []);
36
- dirs.get(dir).push(file);
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, files] of [...dirs].sort()) {
74
+ for (const [dir, info] of [...dirs].sort((a, b) => a[0].localeCompare(b[0]))) {
41
75
  lines.push(` subgraph cluster_${clusterIdx++} {`);
42
- lines.push(` label="${dir}";`);
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 TOOLS = [
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 classes in the codebase, optionally filtered by file or name pattern',
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
- export { TOOLS };
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: 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: 'class',
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: 'class',
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: 'class',
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: 'interface',
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: 'class',
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: 'class',
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: 'class',
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: 'class',
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: 'class',
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: 'interface',
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: 'class',
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 = normalizePath(path.relative(rootDir, r.file));
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 = normalizePath(path.relative(rootDir, filePath));
2165
+ const relPath = path.relative(rootDir, filePath).split(path.sep).join('/');
2167
2166
  result.set(relPath, symbols);
2168
2167
  }
2169
2168
  }
@@ -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
+ }