@optave/codegraph 2.4.0 → 2.5.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/mcp.js CHANGED
@@ -8,6 +8,7 @@
8
8
  import { createRequire } from 'node:module';
9
9
  import { findCycles } from './cycles.js';
10
10
  import { findDbPath } from './db.js';
11
+ import { MCP_DEFAULTS, MCP_MAX_LIMIT } from './paginate.js';
11
12
  import { ALL_SYMBOL_KINDS, diffImpactMermaid, VALID_ROLES } from './queries.js';
12
13
 
13
14
  const REPO_PROP = {
@@ -17,6 +18,11 @@ const REPO_PROP = {
17
18
  },
18
19
  };
19
20
 
21
+ const PAGINATION_PROPS = {
22
+ limit: { type: 'number', description: 'Max results to return (pagination)' },
23
+ offset: { type: 'number', description: 'Skip this many results (pagination, default: 0)' },
24
+ };
25
+
20
26
  const BASE_TOOLS = [
21
27
  {
22
28
  name: 'query_function',
@@ -31,6 +37,7 @@ const BASE_TOOLS = [
31
37
  default: 2,
32
38
  },
33
39
  no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
40
+ ...PAGINATION_PROPS,
34
41
  },
35
42
  required: ['name'],
36
43
  },
@@ -123,6 +130,33 @@ const BASE_TOOLS = [
123
130
  required: ['name'],
124
131
  },
125
132
  },
133
+ {
134
+ name: 'symbol_path',
135
+ description: 'Find the shortest path between two symbols in the call graph (A calls...calls B)',
136
+ inputSchema: {
137
+ type: 'object',
138
+ properties: {
139
+ from: { type: 'string', description: 'Source symbol name (partial match)' },
140
+ to: { type: 'string', description: 'Target symbol name (partial match)' },
141
+ max_depth: { type: 'number', description: 'Maximum BFS depth', default: 10 },
142
+ edge_kinds: {
143
+ type: 'array',
144
+ items: { type: 'string' },
145
+ description: 'Edge kinds to follow (default: ["calls"])',
146
+ },
147
+ reverse: { type: 'boolean', description: 'Follow edges backward', default: false },
148
+ from_file: { type: 'string', description: 'Disambiguate source by file (partial match)' },
149
+ to_file: { type: 'string', description: 'Disambiguate target by file (partial match)' },
150
+ kind: {
151
+ type: 'string',
152
+ enum: ALL_SYMBOL_KINDS,
153
+ description: 'Filter both symbols by kind',
154
+ },
155
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
156
+ },
157
+ required: ['from', 'to'],
158
+ },
159
+ },
126
160
  {
127
161
  name: 'context',
128
162
  description:
@@ -187,6 +221,7 @@ const BASE_TOOLS = [
187
221
  default: false,
188
222
  },
189
223
  no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
224
+ ...PAGINATION_PROPS,
190
225
  },
191
226
  required: ['target'],
192
227
  },
@@ -239,6 +274,7 @@ const BASE_TOOLS = [
239
274
  description: 'File-level graph (true) or function-level (false)',
240
275
  default: true,
241
276
  },
277
+ ...PAGINATION_PROPS,
242
278
  },
243
279
  required: ['format'],
244
280
  },
@@ -253,13 +289,14 @@ const BASE_TOOLS = [
253
289
  file: { type: 'string', description: 'Filter by file path (partial match)' },
254
290
  pattern: { type: 'string', description: 'Filter by function name (partial match)' },
255
291
  no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
292
+ ...PAGINATION_PROPS,
256
293
  },
257
294
  },
258
295
  },
259
296
  {
260
297
  name: 'structure',
261
298
  description:
262
- 'Show project structure with directory hierarchy, cohesion scores, and per-file metrics',
299
+ 'Show project structure with directory hierarchy, cohesion scores, and per-file metrics. Per-file details are capped at 25 files by default; use full=true to show all.',
263
300
  inputSchema: {
264
301
  type: 'object',
265
302
  properties: {
@@ -270,6 +307,11 @@ const BASE_TOOLS = [
270
307
  enum: ['cohesion', 'fan-in', 'fan-out', 'density', 'files'],
271
308
  description: 'Sort directories by metric',
272
309
  },
310
+ full: {
311
+ type: 'boolean',
312
+ description: 'Return all files without limit',
313
+ default: false,
314
+ },
273
315
  },
274
316
  },
275
317
  },
@@ -287,6 +329,7 @@ const BASE_TOOLS = [
287
329
  },
288
330
  file: { type: 'string', description: 'Scope to a specific file (partial match)' },
289
331
  no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
332
+ ...PAGINATION_PROPS,
290
333
  },
291
334
  },
292
335
  },
@@ -333,6 +376,121 @@ const BASE_TOOLS = [
333
376
  },
334
377
  },
335
378
  },
379
+ {
380
+ name: 'execution_flow',
381
+ description:
382
+ 'Trace execution flow forward from an entry point (route, command, event) through callees to leaf functions. Answers "what happens when X is called?"',
383
+ inputSchema: {
384
+ type: 'object',
385
+ properties: {
386
+ name: {
387
+ type: 'string',
388
+ description:
389
+ 'Entry point or function name (e.g. "POST /login", "build"). Supports prefix-stripped matching.',
390
+ },
391
+ depth: { type: 'number', description: 'Max forward traversal depth', default: 10 },
392
+ file: {
393
+ type: 'string',
394
+ description: 'Scope search to functions in this file (partial match)',
395
+ },
396
+ kind: {
397
+ type: 'string',
398
+ enum: ALL_SYMBOL_KINDS,
399
+ description: 'Filter to a specific symbol kind',
400
+ },
401
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
402
+ },
403
+ required: ['name'],
404
+ },
405
+ },
406
+ {
407
+ name: 'list_entry_points',
408
+ description:
409
+ 'List all framework entry points (routes, commands, events) in the codebase, grouped by type',
410
+ inputSchema: {
411
+ type: 'object',
412
+ properties: {
413
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
414
+ ...PAGINATION_PROPS,
415
+ },
416
+ },
417
+ },
418
+ {
419
+ name: 'complexity',
420
+ description:
421
+ 'Show per-function complexity metrics (cognitive, cyclomatic, nesting, Halstead, Maintainability Index). Sorted by most complex first.',
422
+ inputSchema: {
423
+ type: 'object',
424
+ properties: {
425
+ name: { type: 'string', description: 'Function name filter (partial match)' },
426
+ file: { type: 'string', description: 'Scope to file (partial match)' },
427
+ limit: { type: 'number', description: 'Max results', default: 20 },
428
+ sort: {
429
+ type: 'string',
430
+ enum: ['cognitive', 'cyclomatic', 'nesting', 'mi', 'volume', 'effort', 'bugs', 'loc'],
431
+ description: 'Sort metric',
432
+ default: 'cognitive',
433
+ },
434
+ above_threshold: {
435
+ type: 'boolean',
436
+ description: 'Only functions exceeding warn thresholds',
437
+ default: false,
438
+ },
439
+ health: {
440
+ type: 'boolean',
441
+ description: 'Include Halstead and Maintainability Index metrics',
442
+ default: false,
443
+ },
444
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
445
+ kind: {
446
+ type: 'string',
447
+ description: 'Filter by symbol kind (function, method, class, etc.)',
448
+ },
449
+ },
450
+ },
451
+ },
452
+ {
453
+ name: 'manifesto',
454
+ description:
455
+ 'Evaluate manifesto rules and return pass/fail verdicts for code health. Checks function complexity, file metrics, and cycle rules against configured thresholds.',
456
+ inputSchema: {
457
+ type: 'object',
458
+ properties: {
459
+ file: { type: 'string', description: 'Scope to file (partial match)' },
460
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
461
+ kind: {
462
+ type: 'string',
463
+ description: 'Filter by symbol kind (function, method, class, etc.)',
464
+ },
465
+ },
466
+ },
467
+ },
468
+ {
469
+ name: 'communities',
470
+ description:
471
+ 'Detect natural module boundaries using Louvain community detection. Compares discovered communities against directory structure and surfaces architectural drift.',
472
+ inputSchema: {
473
+ type: 'object',
474
+ properties: {
475
+ functions: {
476
+ type: 'boolean',
477
+ description: 'Function-level instead of file-level',
478
+ default: false,
479
+ },
480
+ resolution: {
481
+ type: 'number',
482
+ description: 'Louvain resolution parameter (higher = more communities)',
483
+ default: 1.0,
484
+ },
485
+ drift: {
486
+ type: 'boolean',
487
+ description: 'Show only drift analysis (omit community member lists)',
488
+ default: false,
489
+ },
490
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
491
+ },
492
+ },
493
+ },
336
494
  ];
337
495
 
338
496
  const LIST_REPOS_TOOL = {
@@ -405,6 +563,7 @@ export async function startMCPServer(customDbPath, options = {}) {
405
563
  fileDepsData,
406
564
  fnDepsData,
407
565
  fnImpactData,
566
+ pathData,
408
567
  contextData,
409
568
  explainData,
410
569
  whereData,
@@ -457,7 +616,11 @@ export async function startMCPServer(customDbPath, options = {}) {
457
616
  let result;
458
617
  switch (name) {
459
618
  case 'query_function':
460
- result = queryNameData(args.name, dbPath, { noTests: args.no_tests });
619
+ result = queryNameData(args.name, dbPath, {
620
+ noTests: args.no_tests,
621
+ limit: Math.min(args.limit ?? MCP_DEFAULTS.query_function, MCP_MAX_LIMIT),
622
+ offset: args.offset ?? 0,
623
+ });
461
624
  break;
462
625
  case 'file_deps':
463
626
  result = fileDepsData(args.file, dbPath, { noTests: args.no_tests });
@@ -491,6 +654,17 @@ export async function startMCPServer(customDbPath, options = {}) {
491
654
  noTests: args.no_tests,
492
655
  });
493
656
  break;
657
+ case 'symbol_path':
658
+ result = pathData(args.from, args.to, dbPath, {
659
+ maxDepth: args.max_depth,
660
+ edgeKinds: args.edge_kinds,
661
+ reverse: args.reverse,
662
+ fromFile: args.from_file,
663
+ toFile: args.to_file,
664
+ kind: args.kind,
665
+ noTests: args.no_tests,
666
+ });
667
+ break;
494
668
  case 'context':
495
669
  result = contextData(args.name, dbPath, {
496
670
  depth: args.depth,
@@ -508,6 +682,8 @@ export async function startMCPServer(customDbPath, options = {}) {
508
682
  result = whereData(args.target, dbPath, {
509
683
  file: args.file_mode,
510
684
  noTests: args.no_tests,
685
+ limit: Math.min(args.limit ?? MCP_DEFAULTS.where, MCP_MAX_LIMIT),
686
+ offset: args.offset ?? 0,
511
687
  });
512
688
  break;
513
689
  case 'diff_impact':
@@ -547,15 +723,21 @@ export async function startMCPServer(customDbPath, options = {}) {
547
723
  const { exportDOT, exportMermaid, exportJSON } = await import('./export.js');
548
724
  const db = new Database(findDbPath(dbPath), { readonly: true });
549
725
  const fileLevel = args.file_level !== false;
726
+ const exportLimit = args.limit
727
+ ? Math.min(args.limit, MCP_MAX_LIMIT)
728
+ : MCP_DEFAULTS.export_graph;
550
729
  switch (args.format) {
551
730
  case 'dot':
552
- result = exportDOT(db, { fileLevel });
731
+ result = exportDOT(db, { fileLevel, limit: exportLimit });
553
732
  break;
554
733
  case 'mermaid':
555
- result = exportMermaid(db, { fileLevel });
734
+ result = exportMermaid(db, { fileLevel, limit: exportLimit });
556
735
  break;
557
736
  case 'json':
558
- result = exportJSON(db);
737
+ result = exportJSON(db, {
738
+ limit: exportLimit,
739
+ offset: args.offset ?? 0,
740
+ });
559
741
  break;
560
742
  default:
561
743
  db.close();
@@ -577,6 +759,8 @@ export async function startMCPServer(customDbPath, options = {}) {
577
759
  file: args.file,
578
760
  pattern: args.pattern,
579
761
  noTests: args.no_tests,
762
+ limit: Math.min(args.limit ?? MCP_DEFAULTS.list_functions, MCP_MAX_LIMIT),
763
+ offset: args.offset ?? 0,
580
764
  });
581
765
  break;
582
766
  case 'node_roles':
@@ -584,6 +768,8 @@ export async function startMCPServer(customDbPath, options = {}) {
584
768
  role: args.role,
585
769
  file: args.file,
586
770
  noTests: args.no_tests,
771
+ limit: Math.min(args.limit ?? MCP_DEFAULTS.node_roles, MCP_MAX_LIMIT),
772
+ offset: args.offset ?? 0,
587
773
  });
588
774
  break;
589
775
  case 'structure': {
@@ -592,6 +778,7 @@ export async function startMCPServer(customDbPath, options = {}) {
592
778
  directory: args.directory,
593
779
  depth: args.depth,
594
780
  sort: args.sort,
781
+ full: args.full,
595
782
  });
596
783
  break;
597
784
  }
@@ -620,6 +807,58 @@ export async function startMCPServer(customDbPath, options = {}) {
620
807
  });
621
808
  break;
622
809
  }
810
+ case 'execution_flow': {
811
+ const { flowData } = await import('./flow.js');
812
+ result = flowData(args.name, dbPath, {
813
+ depth: args.depth,
814
+ file: args.file,
815
+ kind: args.kind,
816
+ noTests: args.no_tests,
817
+ });
818
+ break;
819
+ }
820
+ case 'list_entry_points': {
821
+ const { listEntryPointsData } = await import('./flow.js');
822
+ result = listEntryPointsData(dbPath, {
823
+ noTests: args.no_tests,
824
+ limit: Math.min(args.limit ?? MCP_DEFAULTS.list_entry_points, MCP_MAX_LIMIT),
825
+ offset: args.offset ?? 0,
826
+ });
827
+ break;
828
+ }
829
+ case 'complexity': {
830
+ const { complexityData } = await import('./complexity.js');
831
+ result = complexityData(dbPath, {
832
+ target: args.name,
833
+ file: args.file,
834
+ limit: args.limit,
835
+ sort: args.sort,
836
+ aboveThreshold: args.above_threshold,
837
+ health: args.health,
838
+ noTests: args.no_tests,
839
+ kind: args.kind,
840
+ });
841
+ break;
842
+ }
843
+ case 'manifesto': {
844
+ const { manifestoData } = await import('./manifesto.js');
845
+ result = manifestoData(dbPath, {
846
+ file: args.file,
847
+ noTests: args.no_tests,
848
+ kind: args.kind,
849
+ });
850
+ break;
851
+ }
852
+ case 'communities': {
853
+ const { communitiesData } = await import('./communities.js');
854
+ result = communitiesData(dbPath, {
855
+ functions: args.functions,
856
+ resolution: args.resolution,
857
+ drift: args.drift,
858
+ noTests: args.no_tests,
859
+ });
860
+ break;
861
+ }
623
862
  case 'list_repos': {
624
863
  const { listRepos, pruneRegistry } = await import('./registry.js');
625
864
  pruneRegistry();
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Pagination utilities for bounded, context-friendly query results.
3
+ *
4
+ * Offset/limit pagination — the DB is a read-only snapshot so data doesn't
5
+ * change between pages; offset/limit is simpler and maps directly to SQL.
6
+ */
7
+
8
+ /** Default limits applied by MCP tool handlers (not by the programmatic API). */
9
+ export const MCP_DEFAULTS = {
10
+ list_functions: 100,
11
+ query_function: 50,
12
+ where: 50,
13
+ node_roles: 100,
14
+ list_entry_points: 100,
15
+ export_graph: 500,
16
+ };
17
+
18
+ /** Hard cap to prevent abuse via MCP. */
19
+ export const MCP_MAX_LIMIT = 1000;
20
+
21
+ /**
22
+ * Paginate an array.
23
+ *
24
+ * When `limit` is undefined the input is returned unchanged (no-op).
25
+ *
26
+ * @param {any[]} items
27
+ * @param {{ limit?: number, offset?: number }} opts
28
+ * @returns {{ items: any[], pagination?: { total: number, offset: number, limit: number, hasMore: boolean, returned: number } }}
29
+ */
30
+ export function paginate(items, { limit, offset } = {}) {
31
+ if (limit === undefined) {
32
+ return { items };
33
+ }
34
+ const total = items.length;
35
+ const off = Math.max(0, Math.min(offset || 0, total));
36
+ const lim = Math.max(0, limit);
37
+ const page = items.slice(off, off + lim);
38
+ return {
39
+ items: page,
40
+ pagination: {
41
+ total,
42
+ offset: off,
43
+ limit: lim,
44
+ hasMore: off + lim < total,
45
+ returned: page.length,
46
+ },
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Apply pagination to a named array field on a result object.
52
+ *
53
+ * When `limit` is undefined the result is returned unchanged (backward compat).
54
+ * When active, `_pagination` metadata is added to the result.
55
+ *
56
+ * @param {object} result - The result object (e.g. `{ count: 42, functions: [...] }`)
57
+ * @param {string} field - The array field name to paginate (e.g. `'functions'`)
58
+ * @param {{ limit?: number, offset?: number }} opts
59
+ * @returns {object} - Result with paginated field + `_pagination` (if active)
60
+ */
61
+ export function paginateResult(result, field, { limit, offset } = {}) {
62
+ if (limit === undefined) {
63
+ return result;
64
+ }
65
+ const arr = result[field];
66
+ if (!Array.isArray(arr)) return result;
67
+
68
+ const { items, pagination } = paginate(arr, { limit, offset });
69
+ return { ...result, [field]: items, _pagination: pagination };
70
+ }
package/src/parser.js CHANGED
@@ -125,12 +125,23 @@ function resolveEngine(opts = {}) {
125
125
  */
126
126
  function normalizeNativeSymbols(result) {
127
127
  return {
128
+ _lineCount: result.lineCount ?? result.line_count ?? null,
128
129
  definitions: (result.definitions || []).map((d) => ({
129
130
  name: d.name,
130
131
  kind: d.kind,
131
132
  line: d.line,
132
133
  endLine: d.endLine ?? d.end_line ?? null,
133
134
  decorators: d.decorators,
135
+ complexity: d.complexity
136
+ ? {
137
+ cognitive: d.complexity.cognitive,
138
+ cyclomatic: d.complexity.cyclomatic,
139
+ maxNesting: d.complexity.maxNesting,
140
+ halstead: d.complexity.halstead ?? null,
141
+ loc: d.complexity.loc ?? null,
142
+ maintainabilityIndex: d.complexity.maintainabilityIndex ?? null,
143
+ }
144
+ : null,
134
145
  })),
135
146
  calls: (result.calls || []).map((c) => ({
136
147
  name: c.name,
@@ -279,7 +290,8 @@ function wasmExtractSymbols(parsers, filePath, code) {
279
290
  const entry = _extToLang.get(ext);
280
291
  if (!entry) return null;
281
292
  const query = _queryCache.get(entry.id) || null;
282
- return entry.extractor(tree, filePath, query);
293
+ const symbols = entry.extractor(tree, filePath, query);
294
+ return symbols ? { symbols, tree, langId: entry.id } : null;
283
295
  }
284
296
 
285
297
  /**
@@ -300,7 +312,8 @@ export async function parseFileAuto(filePath, source, opts = {}) {
300
312
 
301
313
  // WASM path
302
314
  const parsers = await createParsers();
303
- return wasmExtractSymbols(parsers, filePath, source);
315
+ const extracted = wasmExtractSymbols(parsers, filePath, source);
316
+ return extracted ? extracted.symbols : null;
304
317
  }
305
318
 
306
319
  /**
@@ -335,10 +348,13 @@ export async function parseFilesAuto(filePaths, rootDir, opts = {}) {
335
348
  warn(`Skipping ${path.relative(rootDir, filePath)}: ${err.message}`);
336
349
  continue;
337
350
  }
338
- const symbols = wasmExtractSymbols(parsers, filePath, code);
339
- if (symbols) {
351
+ const extracted = wasmExtractSymbols(parsers, filePath, code);
352
+ if (extracted) {
340
353
  const relPath = path.relative(rootDir, filePath).split(path.sep).join('/');
341
- result.set(relPath, symbols);
354
+ extracted.symbols._tree = extracted.tree;
355
+ extracted.symbols._langId = extracted.langId;
356
+ extracted.symbols._lineCount = code.split('\n').length;
357
+ result.set(relPath, extracted.symbols);
342
358
  }
343
359
  }
344
360
  return result;