@optave/codegraph 1.3.0 → 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/cycles.js CHANGED
@@ -31,8 +31,8 @@ export function findCycles(db, opts = {}) {
31
31
  FROM edges e
32
32
  JOIN nodes n1 ON e.source_id = n1.id
33
33
  JOIN nodes n2 ON e.target_id = n2.id
34
- WHERE n1.kind IN ('function', 'method', 'class')
35
- AND n2.kind IN ('function', 'method', 'class')
34
+ WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
35
+ AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
36
36
  AND e.kind = 'calls'
37
37
  AND n1.id != n2.id
38
38
  `)
package/src/db.js CHANGED
@@ -33,6 +33,19 @@ export const MIGRATIONS = [
33
33
  CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id);
34
34
  CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id);
35
35
  CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind);
36
+ CREATE TABLE IF NOT EXISTS node_metrics (
37
+ node_id INTEGER PRIMARY KEY,
38
+ line_count INTEGER,
39
+ symbol_count INTEGER,
40
+ import_count INTEGER,
41
+ export_count INTEGER,
42
+ fan_in INTEGER,
43
+ fan_out INTEGER,
44
+ cohesion REAL,
45
+ file_count INTEGER,
46
+ FOREIGN KEY(node_id) REFERENCES nodes(id)
47
+ );
48
+ CREATE INDEX IF NOT EXISTS idx_node_metrics_node ON node_metrics(node_id);
36
49
  `,
37
50
  },
38
51
  {
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',
@@ -66,15 +73,178 @@ const TOOLS = [
66
73
  },
67
74
  },
68
75
  },
76
+ {
77
+ name: 'fn_deps',
78
+ description: 'Show function-level dependency chain: what a function calls and what calls it',
79
+ inputSchema: {
80
+ type: 'object',
81
+ properties: {
82
+ name: { type: 'string', description: 'Function/method/class name (partial match)' },
83
+ depth: { type: 'number', description: 'Transitive caller depth', default: 3 },
84
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
85
+ },
86
+ required: ['name'],
87
+ },
88
+ },
89
+ {
90
+ name: 'fn_impact',
91
+ description:
92
+ 'Show function-level blast radius: all functions transitively affected by changes to a function',
93
+ inputSchema: {
94
+ type: 'object',
95
+ properties: {
96
+ name: { type: 'string', description: 'Function/method/class name (partial match)' },
97
+ depth: { type: 'number', description: 'Max traversal depth', default: 5 },
98
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
99
+ },
100
+ required: ['name'],
101
+ },
102
+ },
103
+ {
104
+ name: 'diff_impact',
105
+ description: 'Analyze git diff to find which functions changed and their transitive callers',
106
+ inputSchema: {
107
+ type: 'object',
108
+ properties: {
109
+ staged: { type: 'boolean', description: 'Analyze staged changes only', default: false },
110
+ ref: { type: 'string', description: 'Git ref to diff against (default: HEAD)' },
111
+ depth: { type: 'number', description: 'Transitive caller depth', default: 3 },
112
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
113
+ },
114
+ },
115
+ },
116
+ {
117
+ name: 'semantic_search',
118
+ description:
119
+ 'Search code symbols by meaning using embeddings (requires prior `codegraph embed`)',
120
+ inputSchema: {
121
+ type: 'object',
122
+ properties: {
123
+ query: { type: 'string', description: 'Natural language search query' },
124
+ limit: { type: 'number', description: 'Max results to return', default: 15 },
125
+ min_score: { type: 'number', description: 'Minimum similarity score (0-1)', default: 0.2 },
126
+ },
127
+ required: ['query'],
128
+ },
129
+ },
130
+ {
131
+ name: 'export_graph',
132
+ description: 'Export the dependency graph in DOT (Graphviz), Mermaid, or JSON format',
133
+ inputSchema: {
134
+ type: 'object',
135
+ properties: {
136
+ format: {
137
+ type: 'string',
138
+ enum: ['dot', 'mermaid', 'json'],
139
+ description: 'Export format',
140
+ },
141
+ file_level: {
142
+ type: 'boolean',
143
+ description: 'File-level graph (true) or function-level (false)',
144
+ default: true,
145
+ },
146
+ },
147
+ required: ['format'],
148
+ },
149
+ },
150
+ {
151
+ name: 'list_functions',
152
+ description:
153
+ 'List functions, methods, classes, structs, enums, traits, records, and modules in the codebase, optionally filtered by file or name pattern',
154
+ inputSchema: {
155
+ type: 'object',
156
+ properties: {
157
+ file: { type: 'string', description: 'Filter by file path (partial match)' },
158
+ pattern: { type: 'string', description: 'Filter by function name (partial match)' },
159
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
160
+ },
161
+ },
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
+ },
69
201
  ];
70
202
 
71
- 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 };
72
235
 
73
236
  /**
74
237
  * Start the MCP server.
75
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
76
244
  */
77
- export async function startMCPServer(customDbPath) {
245
+ export async function startMCPServer(customDbPath, options = {}) {
246
+ const { allowedRepos } = options;
247
+ const multiRepo = options.multiRepo || !!allowedRepos;
78
248
  let Server, StdioServerTransport;
79
249
  try {
80
250
  const sdk = await import('@modelcontextprotocol/sdk/server/index.js');
@@ -90,9 +260,16 @@ export async function startMCPServer(customDbPath) {
90
260
  }
91
261
 
92
262
  // Lazy import query functions to avoid circular deps at module load
93
- const { queryNameData, impactAnalysisData, moduleMapData, fileDepsData } = await import(
94
- './queries.js'
95
- );
263
+ const {
264
+ queryNameData,
265
+ impactAnalysisData,
266
+ moduleMapData,
267
+ fileDepsData,
268
+ fnDepsData,
269
+ fnImpactData,
270
+ diffImpactData,
271
+ listFunctionsData,
272
+ } = await import('./queries.js');
96
273
 
97
274
  const require = createRequire(import.meta.url);
98
275
  const Database = require('better-sqlite3');
@@ -102,13 +279,37 @@ export async function startMCPServer(customDbPath) {
102
279
  { capabilities: { tools: {} } },
103
280
  );
104
281
 
105
- server.setRequestHandler('tools/list', async () => ({ tools: TOOLS }));
282
+ server.setRequestHandler('tools/list', async () => ({ tools: buildToolList(multiRepo) }));
106
283
 
107
284
  server.setRequestHandler('tools/call', async (request) => {
108
285
  const { name, arguments: args } = request.params;
109
- const dbPath = customDbPath || undefined;
110
286
 
111
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
+
112
313
  let result;
113
314
  switch (name) {
114
315
  case 'query_function':
@@ -130,6 +331,105 @@ export async function startMCPServer(customDbPath) {
130
331
  case 'module_map':
131
332
  result = moduleMapData(dbPath, args.limit || 20);
132
333
  break;
334
+ case 'fn_deps':
335
+ result = fnDepsData(args.name, dbPath, {
336
+ depth: args.depth,
337
+ noTests: args.no_tests,
338
+ });
339
+ break;
340
+ case 'fn_impact':
341
+ result = fnImpactData(args.name, dbPath, {
342
+ depth: args.depth,
343
+ noTests: args.no_tests,
344
+ });
345
+ break;
346
+ case 'diff_impact':
347
+ result = diffImpactData(dbPath, {
348
+ staged: args.staged,
349
+ ref: args.ref,
350
+ depth: args.depth,
351
+ noTests: args.no_tests,
352
+ });
353
+ break;
354
+ case 'semantic_search': {
355
+ const { searchData } = await import('./embedder.js');
356
+ result = await searchData(args.query, dbPath, {
357
+ limit: args.limit,
358
+ minScore: args.min_score,
359
+ });
360
+ if (result === null) {
361
+ return {
362
+ content: [
363
+ { type: 'text', text: 'Semantic search unavailable. Run `codegraph embed` first.' },
364
+ ],
365
+ isError: true,
366
+ };
367
+ }
368
+ break;
369
+ }
370
+ case 'export_graph': {
371
+ const { exportDOT, exportMermaid, exportJSON } = await import('./export.js');
372
+ const db = new Database(findDbPath(dbPath), { readonly: true });
373
+ const fileLevel = args.file_level !== false;
374
+ switch (args.format) {
375
+ case 'dot':
376
+ result = exportDOT(db, { fileLevel });
377
+ break;
378
+ case 'mermaid':
379
+ result = exportMermaid(db, { fileLevel });
380
+ break;
381
+ case 'json':
382
+ result = exportJSON(db);
383
+ break;
384
+ default:
385
+ db.close();
386
+ return {
387
+ content: [
388
+ {
389
+ type: 'text',
390
+ text: `Unknown format: ${args.format}. Use dot, mermaid, or json.`,
391
+ },
392
+ ],
393
+ isError: true,
394
+ };
395
+ }
396
+ db.close();
397
+ break;
398
+ }
399
+ case 'list_functions':
400
+ result = listFunctionsData(dbPath, {
401
+ file: args.file,
402
+ pattern: args.pattern,
403
+ noTests: args.no_tests,
404
+ });
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
+ }
133
433
  default:
134
434
  return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
135
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
  }
package/src/queries.js CHANGED
@@ -566,6 +566,36 @@ export function diffImpactData(customDbPath, opts = {}) {
566
566
  };
567
567
  }
568
568
 
569
+ export function listFunctionsData(customDbPath, opts = {}) {
570
+ const db = openReadonlyOrFail(customDbPath);
571
+ const noTests = opts.noTests || false;
572
+ const kinds = ['function', 'method', 'class'];
573
+ const placeholders = kinds.map(() => '?').join(', ');
574
+
575
+ const conditions = [`kind IN (${placeholders})`];
576
+ const params = [...kinds];
577
+
578
+ if (opts.file) {
579
+ conditions.push('file LIKE ?');
580
+ params.push(`%${opts.file}%`);
581
+ }
582
+ if (opts.pattern) {
583
+ conditions.push('name LIKE ?');
584
+ params.push(`%${opts.pattern}%`);
585
+ }
586
+
587
+ let rows = db
588
+ .prepare(
589
+ `SELECT name, kind, file, line FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY file, line`,
590
+ )
591
+ .all(...params);
592
+
593
+ if (noTests) rows = rows.filter((r) => !isTestFile(r.file));
594
+
595
+ db.close();
596
+ return { count: rows.length, functions: rows };
597
+ }
598
+
569
599
  // ─── Human-readable output (original formatting) ───────────────────────
570
600
 
571
601
  export function queryName(name, customDbPath, opts = {}) {