codegraph-py 1.0.0__tar.gz → 1.1.0__tar.gz

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.
Files changed (51) hide show
  1. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/PKG-INFO +1 -1
  2. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/__init__.py +1 -1
  3. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/cli.py +138 -53
  4. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/codegraph.py +75 -7
  5. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/db/queries.py +30 -3
  6. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/graph/__init__.py +24 -16
  7. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/mcp/__init__.py +1 -1
  8. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/types.py +1 -0
  9. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph_py.egg-info/PKG-INFO +1 -1
  10. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/pyproject.toml +3 -4
  11. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/README.md +0 -0
  12. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/__main__.py +0 -0
  13. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/context/__init__.py +0 -0
  14. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/db/__init__.py +0 -0
  15. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/db/connection.py +0 -0
  16. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/db/schema.sql +0 -0
  17. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/directory.py +0 -0
  18. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/errors.py +0 -0
  19. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/extraction/__init__.py +0 -0
  20. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/extraction/languages/__init__.py +0 -0
  21. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/extraction/languages/base.py +0 -0
  22. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/extraction/languages/c_cfg.py +0 -0
  23. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/extraction/languages/cpp_cfg.py +0 -0
  24. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/extraction/languages/dart_cfg.py +0 -0
  25. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/extraction/languages/go_cfg.py +0 -0
  26. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/extraction/languages/java_cfg.py +0 -0
  27. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/extraction/languages/javascript_cfg.py +0 -0
  28. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/extraction/languages/kotlin_cfg.py +0 -0
  29. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/extraction/languages/lua_cfg.py +0 -0
  30. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/extraction/languages/python_cfg.py +0 -0
  31. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/extraction/languages/ruby_cfg.py +0 -0
  32. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/extraction/languages/rust_cfg.py +0 -0
  33. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/extraction/languages/scala_cfg.py +0 -0
  34. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/extraction/languages/swift_cfg.py +0 -0
  35. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/extraction/languages/typescript_cfg.py +0 -0
  36. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/extraction/tree_sitter_extractor.py +0 -0
  37. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/installer/__init__.py +0 -0
  38. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/project_config.py +0 -0
  39. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/resolution/__init__.py +0 -0
  40. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/search/__init__.py +0 -0
  41. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/sync/__init__.py +0 -0
  42. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/ui/__init__.py +0 -0
  43. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph/utils.py +0 -0
  44. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph_py.egg-info/SOURCES.txt +0 -0
  45. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph_py.egg-info/dependency_links.txt +0 -0
  46. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph_py.egg-info/entry_points.txt +0 -0
  47. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph_py.egg-info/requires.txt +0 -0
  48. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/codegraph_py.egg-info/top_level.txt +0 -0
  49. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/setup.cfg +0 -0
  50. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/tests/test_codegraph.py +0 -0
  51. {codegraph_py-1.0.0 → codegraph_py-1.1.0}/tests/test_stress.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codegraph-py
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Supercharge AI coding agents with semantic code intelligence — surgical context, fewer tool calls, faster answers. 100% local.
5
5
  Author-email: mading <martin98@bu.edu>
6
6
  License-Expression: MIT
@@ -5,7 +5,7 @@ A local-first code intelligence system that builds a semantic
5
5
  knowledge graph from any codebase.
6
6
  """
7
7
 
8
- __version__ = "1.0.0"
8
+ __version__ = "1.1.0"
9
9
 
10
10
  from codegraph.codegraph import CodeGraph
11
11
 
@@ -106,13 +106,24 @@ def resolve_project_path(path_arg: Optional[str] = None) -> str:
106
106
  # CLI Commands
107
107
  # =============================================================================
108
108
 
109
- @click.group()
110
- @click.version_option(version='1.0.0', prog_name='codegraph')
109
+ @click.group(context_settings=dict(help_option_names=['-h', '--help']))
110
+ @click.version_option(version='1.0.1', prog_name='codegraph')
111
111
  def main():
112
- """CodeGraph - Semantic code intelligence for AI coding agents.
113
-
114
- Build a knowledge graph of your codebase for surgical context,
115
- fewer tool calls, and faster answers.
112
+ """CodeGraph Semantic code intelligence for AI coding agents.
113
+
114
+ Build a knowledge graph of your codebase for instant symbol lookup,
115
+ call-chain analysis, and surgical context extraction.
116
+
117
+ \b
118
+ Common workflows:
119
+ codegraph init Initialize and index current project
120
+ codegraph status View index statistics
121
+ codegraph query "class" Search for symbols matching "class"
122
+ codegraph query --kind class List all classes in the project
123
+ codegraph explore "main" Show source code and call paths for "main"
124
+ codegraph callers "func" Show what calls the function "func"
125
+ codegraph callees "func" Show what "func" calls
126
+ codegraph files List all indexed files
116
127
  """
117
128
  pass
118
129
 
@@ -308,14 +319,20 @@ def sync(path: Optional[str], quiet: bool):
308
319
  @click.argument('path', required=False, default=None)
309
320
  @click.option('-j', '--json', 'json_output', is_flag=True, help='Output as JSON')
310
321
  def status(path: Optional[str], json_output: bool):
311
- """Show index status and statistics."""
322
+ """Show index status, statistics, and pending changes.
323
+
324
+ Displays file count, node/edge counts, node breakdown by kind,
325
+ and any pending changes since the last index.
326
+
327
+ Use --json/-j for machine-readable output.
328
+ """
312
329
  project_path = resolve_project_path(path)
313
330
 
314
331
  if not is_initialized(project_path):
315
332
  if json_output:
316
333
  click.echo(json.dumps({
317
334
  'initialized': False,
318
- 'version': '1.0.0',
335
+ 'version': '1.0.1',
319
336
  'projectPath': project_path,
320
337
  }))
321
338
  return
@@ -335,7 +352,7 @@ def status(path: Optional[str], json_output: bool):
335
352
  if json_output:
336
353
  click.echo(json.dumps({
337
354
  'initialized': True,
338
- 'version': '1.0.0',
355
+ 'version': '1.0.1',
339
356
  'projectPath': project_path,
340
357
  'fileCount': stats.file_count,
341
358
  'nodeCount': stats.node_count,
@@ -403,14 +420,21 @@ def status(path: Optional[str], json_output: bool):
403
420
 
404
421
 
405
422
  @main.command()
406
- @click.argument('search')
423
+ @click.argument('search', required=False, default='')
407
424
  @click.option('-p', '--path', 'path_arg', help='Project path')
408
- @click.option('-l', '--limit', default=10, type=int, help='Maximum results')
425
+ @click.option('-l', '--limit', default=20, type=int, help='Maximum results')
409
426
  @click.option('-k', '--kind', help='Filter by node kind (function, class, etc.)')
427
+ @click.option('--exact', is_flag=True, help='Exact name match (instead of fuzzy/prefix)')
428
+ @click.option('--fuzzy', is_flag=True, help='Force fuzzy name matching (default)')
410
429
  @click.option('-j', '--json', 'json_output', is_flag=True, help='Output as JSON')
411
430
  def query(search: str, path_arg: Optional[str], limit: int,
412
- kind: Optional[str], json_output: bool):
413
- """Search for symbols in the codebase."""
431
+ kind: Optional[str], exact: bool, fuzzy: bool,
432
+ json_output: bool):
433
+ """Search for symbols in the codebase.
434
+
435
+ If SEARCH is omitted and --kind is provided, lists all symbols of that kind.
436
+ Use --exact for precise symbol name lookup.
437
+ """
414
438
  project_path = resolve_project_path(path_arg)
415
439
 
416
440
  if not is_initialized(project_path):
@@ -424,7 +448,15 @@ def query(search: str, path_arg: Optional[str], limit: int,
424
448
  if kind:
425
449
  opts.kinds = [kind]
426
450
 
427
- results = cg.search_nodes(search, opts)
451
+ if exact:
452
+ opts.exact_match = True
453
+
454
+ if not search.strip() and kind:
455
+ # No search term — list by kind only
456
+ nodes = cg._queries.get_nodes_by_kind(kind) if kind else []
457
+ results = [SearchResult(node=n, score=1.0) for n in nodes]
458
+ else:
459
+ results = cg.search_nodes(search, opts)
428
460
 
429
461
  if json_output:
430
462
  output = []
@@ -447,9 +479,15 @@ def query(search: str, path_arg: Optional[str], limit: int,
447
479
  return
448
480
 
449
481
  if not results:
450
- click.echo(f'No results found for "{search}"')
482
+ if search:
483
+ click.echo(f'No results found for "{search}"')
484
+ elif kind:
485
+ click.echo(f'No symbols of kind "{kind}" found')
486
+ else:
487
+ click.echo('No search term provided. Use --kind to browse by type.')
451
488
  else:
452
- click.echo(bold(f'\nSearch Results for "{search}":\n'))
489
+ title = f'\nSearch Results for "{search}":' if search else f'\nAll {kind}s:' if kind else '\nResults:'
490
+ click.echo(bold(title + '\n'))
453
491
  for r in results:
454
492
  node = r.node
455
493
  location = f'{node.file_path}:{node.start_line}'
@@ -473,6 +511,14 @@ def query(search: str, path_arg: Optional[str], limit: int,
473
511
  def explore(query_parts: List[str], path_arg: Optional[str], max_files: int):
474
512
  """Explore code: relevant symbols' source + call paths in one shot.
475
513
 
514
+ QUERY_PARTS is one or more search terms for symbol lookup.
515
+ Combines search, callers/callees, and source display in a single view.
516
+
517
+ \b
518
+ Examples:
519
+ codegraph explore main Find "main" and show source + call context
520
+ codegraph explore ChatBackend Explore ChatBackend class and its relationships
521
+
476
522
  Same output as the codegraph_explore MCP tool.
477
523
  """
478
524
  query = ' '.join(query_parts)
@@ -524,9 +570,14 @@ def explore(query_parts: List[str], path_arg: Optional[str], max_files: int):
524
570
  @main.command()
525
571
  @click.argument('symbol')
526
572
  @click.option('-p', '--path', 'path_arg', help='Project path')
527
- @click.option('-d', '--depth', default=1, type=int, help='Traversal depth')
573
+ @click.option('-d', '--depth', default=1, type=int, help='Traversal depth (1=direct, 2=indirect)')
528
574
  def callers(symbol: str, path_arg: Optional[str], depth: int):
529
- """Find what calls a function/method."""
575
+ """Find what calls a function/method.
576
+
577
+ SYMBOL is the symbol name to find callers for (e.g. "ChatBackend", "main").
578
+
579
+ Use -d 2 to include indirect callers (callers of callers).
580
+ """
530
581
  project_path = resolve_project_path(path_arg)
531
582
 
532
583
  if not is_initialized(project_path):
@@ -569,9 +620,14 @@ def callers(symbol: str, path_arg: Optional[str], depth: int):
569
620
  @main.command()
570
621
  @click.argument('symbol')
571
622
  @click.option('-p', '--path', 'path_arg', help='Project path')
572
- @click.option('-d', '--depth', default=1, type=int, help='Traversal depth')
623
+ @click.option('-d', '--depth', default=1, type=int, help='Traversal depth (1=direct, 2=indirect)')
573
624
  def callees(symbol: str, path_arg: Optional[str], depth: int):
574
- """Find what a function/method calls."""
625
+ """Find what a function/method calls.
626
+
627
+ SYMBOL is the symbol name to find callees for (e.g. "run", "process").
628
+
629
+ Use -d 2 to include indirect callees (callees of callees).
630
+ """
575
631
  project_path = resolve_project_path(path_arg)
576
632
 
577
633
  if not is_initialized(project_path):
@@ -730,12 +786,14 @@ def node(name: str, path_arg: Optional[str]):
730
786
  return
731
787
 
732
788
  try:
733
- cg = CodeGraph(project_path)
789
+ cg = CodeGraph.open_sync(project_path)
734
790
 
735
791
  # Search for nodes by name
736
- results = cg.search(name)
792
+ opts = SearchOptions(limit=20)
793
+ results = cg.search_nodes(name, opts)
737
794
  if not results:
738
795
  click.echo(yellow(f'No nodes found matching "{name}"'))
796
+ cg.destroy()
739
797
  return
740
798
 
741
799
  # If exact match, show details
@@ -758,18 +816,18 @@ def node(name: str, path_arg: Optional[str]):
758
816
  click.echo(f' {dim("Exported:")} {green("yes") if target.is_exported else "no"}')
759
817
 
760
818
  # Show callers
761
- callers = cg.get_callers(target.id)
762
- if callers:
763
- click.echo(bold(f'\n Callers ({len(callers)}):'))
764
- for c in callers:
765
- click.echo(f' ← {c.name} ({c.file_path}:{c.start_line})')
819
+ caller_pairs = cg.get_callers(target.id)
820
+ if caller_pairs:
821
+ click.echo(bold(f'\n Callers ({len(caller_pairs)}):'))
822
+ for c_node, c_edge in caller_pairs:
823
+ click.echo(f' ← {c_node.name} ({c_node.file_path}:{c_node.start_line})')
766
824
 
767
825
  # Show callees
768
- callees = cg.get_callees(target.id)
769
- if callees:
770
- click.echo(bold(f'\n Callees ({len(callees)}):'))
771
- for c in callees:
772
- click.echo(f' → {c.name} ({c.file_path}:{c.start_line})')
826
+ callee_pairs = cg.get_callees(target.id)
827
+ if callee_pairs:
828
+ click.echo(bold(f'\n Callees ({len(callee_pairs)}):'))
829
+ for c_node, c_edge in callee_pairs:
830
+ click.echo(f' → {c_node.name} ({c_node.file_path}:{c_node.start_line})')
773
831
 
774
832
  cg.destroy()
775
833
 
@@ -781,7 +839,9 @@ def node(name: str, path_arg: Optional[str]):
781
839
  @main.command()
782
840
  @click.option('--path', '-p', 'path_arg', default=None, help='Project path')
783
841
  @click.option('--json-output', 'json_output', is_flag=True, help='Output as JSON')
784
- def files(path_arg: Optional[str], json_output: bool):
842
+ @click.option('--by-directory', 'by_directory', is_flag=True, help='Group files by directory')
843
+ @click.option('--stats', 'show_stats', is_flag=True, help='Show per-file symbol stats (default when no flags)')
844
+ def files(path_arg: Optional[str], json_output: bool, by_directory: bool, show_stats: bool):
785
845
  """List indexed files with language and symbol statistics."""
786
846
  project_path = resolve_project_path(path_arg)
787
847
 
@@ -791,44 +851,69 @@ def files(path_arg: Optional[str], json_output: bool):
791
851
  return
792
852
 
793
853
  try:
794
- cg = CodeGraph(project_path)
854
+ cg = CodeGraph.open_sync(project_path)
795
855
  stats = cg.get_stats()
796
- all_files = cg.db.get_all_file_paths()
856
+ all_files = cg._queries.get_all_file_paths()
797
857
 
798
858
  if json_output:
799
- result = {
800
- 'total_files': len(all_files),
801
- 'total_nodes': stats.node_count,
802
- 'total_edges': stats.edge_count,
803
- 'files': [],
804
- }
805
- for f in all_files:
806
- nodes = cg.db.get_nodes_by_file(f)
859
+ file_list = []
860
+ for f in sorted(all_files):
861
+ nodes = cg.get_nodes_by_file(f)
807
862
  langs = set(n.language for n in nodes if n.language)
808
- result['files'].append({
863
+ kinds = {}
864
+ for n in nodes:
865
+ kinds[n.kind] = kinds.get(n.kind, 0) + 1
866
+ file_list.append({
809
867
  'path': f,
810
868
  'node_count': len(nodes),
811
869
  'languages': list(langs),
870
+ 'node_kinds': kinds,
812
871
  })
872
+ result = {
873
+ 'total_files': len(all_files),
874
+ 'total_nodes': stats.node_count,
875
+ 'total_edges': stats.edge_count,
876
+ 'files': file_list,
877
+ }
813
878
  click.echo(json.dumps(result, indent=2))
879
+ cg.destroy()
880
+ return
881
+
882
+ show_stats = show_stats or not by_directory
883
+
884
+ if by_directory:
885
+ # Group files by directory
886
+ dirs: Dict[str, List[str]] = {}
887
+ for f in sorted(all_files):
888
+ d = os.path.dirname(f) or '.'
889
+ dirs.setdefault(d, []).append(f)
890
+ click.echo(bold(f'\n📁 Indexed Files ({len(all_files)} total, {len(dirs)} directories)\n'))
891
+ for directory in sorted(dirs):
892
+ click.echo(blue(f' {directory}/'))
893
+ for f in dirs[directory]:
894
+ click.echo(f' {os.path.basename(f)}')
895
+ click.echo()
814
896
  else:
815
897
  click.echo(bold(f'\n📁 Indexed Files ({len(all_files)} total)\n'))
816
898
  for f in sorted(all_files):
817
- nodes = cg.db.get_nodes_by_file(f)
899
+ nodes = cg.get_nodes_by_file(f)
818
900
  lang = ''
819
901
  for n in nodes:
820
902
  if n.language and n.language != 'unknown':
821
903
  lang = n.language
822
904
  break
823
- kinds = {}
824
- for n in nodes:
825
- k = n.kind
826
- kinds[k] = kinds.get(k, 0) + 1
827
- kind_str = ', '.join(f'{v} {k}' for k, v in sorted(kinds.items()) if k != 'file')
828
- if kind_str:
829
- click.echo(f' {dim(lang+" ") if lang else ""}{f} {dim("("+kind_str+")")}')
905
+ if show_stats:
906
+ kinds = {}
907
+ for n in nodes:
908
+ k = n.kind
909
+ kinds[k] = kinds.get(k, 0) + 1
910
+ kind_str = ', '.join(f'{v} {k}' for k, v in sorted(kinds.items()) if k != 'file')
911
+ if kind_str:
912
+ click.echo(f' {dim(lang+" ") if lang else ""}{f} {dim("("+kind_str+")")}')
913
+ else:
914
+ click.echo(f' {f}')
830
915
  else:
831
- click.echo(f' {f}')
916
+ click.echo(f' {dim(lang+" ") if lang else ""}{f}')
832
917
 
833
918
  cg.destroy()
834
919
 
@@ -31,7 +31,7 @@ from codegraph.graph import GraphTraverser, GraphQueryManager
31
31
  from codegraph.context import ContextBuilder, create_context_builder
32
32
  from codegraph.sync import FileWatcher, WatchOptions, PendingFile
33
33
 
34
- __version__ = "1.0.0"
34
+ __version__ = "1.1.0"
35
35
 
36
36
 
37
37
  @dataclass
@@ -357,25 +357,93 @@ class CodeGraph:
357
357
  return self._queries.get_nodes_by_file(file_path)
358
358
 
359
359
  def get_callers(self, node_id: str, max_depth: int = 1) -> List[Tuple[Node, Edge]]:
360
- """Find what calls a function/method."""
361
- nodes = self._traverser.getCallers(node_id, max_depth > 1)
362
- result = []
360
+ """Find what calls a function/method.
361
+
362
+ First tries edge-based lookup (resolved references), then falls back
363
+ to name-based matching on unresolved references for maximum coverage.
364
+ """
365
+ result: List[Tuple[Node, Edge]] = []
366
+
367
+ # ── Phase 1: edge-based (resolved references) ──
368
+ nodes = self._traverser.getCallers(node_id, max_depth)
363
369
  for n in nodes:
364
370
  edges = self._queries.get_outgoing_edges(n.id, ['calls', 'references'])
365
371
  for e in edges:
366
372
  if e.target == node_id:
367
373
  result.append((n, e))
374
+
375
+ if result:
376
+ return result
377
+
378
+ # ── Phase 2: fallback via UnresolvedReference name matching ──
379
+ target = self._queries.get_node_by_id(node_id)
380
+ if target:
381
+ target_name = target.name
382
+ # Collect all refs whose name matches the target name (or any dotted suffix)
383
+ refs = self._queries.get_unresolved_refs()
384
+ seen: Set[str] = set()
385
+ for ref in refs:
386
+ ref_name = ref.reference_name
387
+ # Exact match, or last component of dotted name matches
388
+ if ref_name == target_name or (
389
+ '.' in ref_name and ref_name.rsplit('.', 1)[-1] == target_name
390
+ ):
391
+ caller = self._queries.get_node_by_id(ref.from_node_id)
392
+ if caller and caller.id not in seen:
393
+ seen.add(caller.id)
394
+ fake_edge = Edge(
395
+ source=caller.id, target=node_id,
396
+ kind=ref.reference_kind or 'references',
397
+ line=ref.line, column=ref.column,
398
+ provenance='unresolved',
399
+ )
400
+ result.append((caller, fake_edge))
401
+
368
402
  return result
369
403
 
370
404
  def get_callees(self, node_id: str, max_depth: int = 1) -> List[Tuple[Node, Edge]]:
371
- """Find what a function/method calls."""
372
- nodes = self._traverser.getCallees(node_id, max_depth > 1)
373
- result = []
405
+ """Find what a function/method calls.
406
+
407
+ First tries edge-based lookup (resolved references), then falls back
408
+ to name-based matching on unresolved references for maximum coverage.
409
+ """
410
+ result: List[Tuple[Node, Edge]] = []
411
+
412
+ # ── Phase 1: edge-based (resolved references) ──
413
+ nodes = self._traverser.getCallees(node_id, max_depth)
374
414
  for n in nodes:
375
415
  edges = self._queries.get_incoming_edges(n.id, ['calls', 'references'])
376
416
  for e in edges:
377
417
  if e.source == node_id:
378
418
  result.append((n, e))
419
+
420
+ if result:
421
+ return result
422
+
423
+ # ── Phase 2: fallback via UnresolvedReference name matching ──
424
+ source = self._queries.get_node_by_id(node_id)
425
+ if source:
426
+ refs = self._queries.get_unresolved_refs_by_from_node(node_id)
427
+ seen: Set[str] = set()
428
+ for ref in refs:
429
+ # Try to resolve the referenced name to a real node
430
+ callees_found = self._queries.get_nodes_by_name(ref.reference_name)
431
+ # Also try last component of dotted names
432
+ if not callees_found and '.' in ref.reference_name:
433
+ simple = ref.reference_name.rsplit('.', 1)[-1]
434
+ callees_found = self._queries.get_nodes_by_name(simple)
435
+
436
+ for callee in callees_found:
437
+ if callee.id not in seen:
438
+ seen.add(callee.id)
439
+ fake_edge = Edge(
440
+ source=node_id, target=callee.id,
441
+ kind=ref.reference_kind or 'references',
442
+ line=ref.line, column=ref.column,
443
+ provenance='unresolved',
444
+ )
445
+ result.append((callee, fake_edge))
446
+
379
447
  return result
380
448
 
381
449
  def get_impact_radius(self, node_id: str, max_depth: int = 3) -> Subgraph:
@@ -443,6 +443,13 @@ class QueryBuilder:
443
443
  )
444
444
  return [self._row_to_unresolved(row) for row in cur.fetchall()]
445
445
 
446
+ def get_unresolved_refs_by_from_node(self, from_node_id: str) -> List[UnresolvedReference]:
447
+ """Get unresolved references by originating node ID."""
448
+ cur = self._db.execute(
449
+ 'SELECT * FROM unresolved_refs WHERE from_node_id = ?', (from_node_id,)
450
+ )
451
+ return [self._row_to_unresolved(row) for row in cur.fetchall()]
452
+
446
453
  def clear_unresolved_refs(self) -> None:
447
454
  """Clear all unresolved references."""
448
455
  self._db.execute('DELETE FROM unresolved_refs')
@@ -453,12 +460,32 @@ class QueryBuilder:
453
460
 
454
461
  def search_nodes(self, query: str,
455
462
  options: Optional[SearchOptions] = None) -> List[SearchResult]:
456
- """Search nodes using FTS5 full-text search."""
463
+ """Search nodes in the codebase.
464
+
465
+ Supports three modes:
466
+ - Exact match (opts.exact_match=True): precise name lookup via SQL
467
+ - FTS5 full-text: prefix-based ranking search
468
+ - LIKE fallback: substring matching when FTS returns nothing
469
+ """
457
470
  opts = options or SearchOptions()
458
471
  results: List[SearchResult] = []
459
472
 
473
+ # ── Exact match mode ──
474
+ if opts.exact_match and query.strip():
475
+ cur = self._db.execute(
476
+ '''SELECT * FROM nodes WHERE name = ?
477
+ ORDER BY file_path, start_line
478
+ LIMIT ?''',
479
+ (query.strip(), opts.limit)
480
+ )
481
+ for row in cur.fetchall():
482
+ node = self._row_to_node(row)
483
+ if self._matches_filters(node, opts):
484
+ results.append(SearchResult(node=node, score=1.0))
485
+ return results
486
+
487
+ # ── FTS5 full-text search ──
460
488
  try:
461
- # FTS5 query with prefix matching
462
489
  fts_query = ' OR '.join(
463
490
  f'"{word}"*' if len(word) > 1 else word
464
491
  for word in query.split()
@@ -484,7 +511,7 @@ class QueryBuilder:
484
511
  except Exception:
485
512
  pass
486
513
 
487
- # Fallback: LIKE search if FTS returns nothing
514
+ # ── LIKE fallback ──
488
515
  if not results:
489
516
  for word in query.split():
490
517
  if not word.strip():
@@ -174,62 +174,70 @@ class GraphTraverser:
174
174
  dfs(start_id, 0)
175
175
  return result
176
176
 
177
- def getCallers(self, node_id: str, include_indirect: bool = False) -> List[Node]:
177
+ def getCallers(self, node_id: str, max_depth: int = 1) -> List[Node]:
178
178
  """Get nodes that call/ reference the given node.
179
-
179
+
180
180
  Args:
181
181
  node_id: Target node ID
182
- include_indirect: If True, include indirect callers through the call chain
183
-
182
+ max_depth: How many levels up to traverse.
183
+ 1 = direct callers only,
184
+ 2 = callers + their callers, etc.
185
+
184
186
  Returns:
185
187
  List of caller nodes
186
188
  """
187
189
  callers: List[Node] = []
188
190
  seen: Set[str] = set()
189
191
 
190
- def collect_callers(nid: str) -> None:
192
+ def collect_callers(nid: str, depth: int) -> None:
191
193
  if nid in seen:
192
194
  return
193
195
  seen.add(nid)
194
196
 
197
+ if depth >= max_depth:
198
+ return
199
+
195
200
  edges = self._qb.get_incoming_edges(nid, [EdgeKind.CALLS, EdgeKind.REFERENCES])
196
201
  for edge in edges:
197
202
  caller_node = self._qb.get_node_by_id(edge.source)
198
203
  if caller_node and edge.source not in seen:
199
204
  callers.append(caller_node)
200
- if include_indirect:
201
- collect_callers(edge.source)
205
+ collect_callers(edge.source, depth + 1)
202
206
 
203
- collect_callers(node_id)
207
+ collect_callers(node_id, 0)
204
208
  return callers
205
209
 
206
- def getCallees(self, node_id: str, include_indirect: bool = False) -> List[Node]:
210
+ def getCallees(self, node_id: str, max_depth: int = 1) -> List[Node]:
207
211
  """Get nodes that the given node calls/ references.
208
-
212
+
209
213
  Args:
210
214
  node_id: Source node ID
211
- include_indirect: If True, include indirect callees through the call chain
212
-
215
+ max_depth: How many levels down to traverse.
216
+ 1 = direct callees only,
217
+ 2 = callees + their callees, etc.
218
+
213
219
  Returns:
214
220
  List of callee nodes
215
221
  """
216
222
  callees: List[Node] = []
217
223
  seen: Set[str] = set()
218
224
 
219
- def collect_callees(nid: str) -> None:
225
+ def collect_callees(nid: str, depth: int) -> None:
220
226
  if nid in seen:
221
227
  return
222
228
  seen.add(nid)
223
229
 
230
+ if depth >= max_depth:
231
+ return
232
+
224
233
  edges = self._qb.get_outgoing_edges(nid, [EdgeKind.CALLS, EdgeKind.REFERENCES])
225
234
  for edge in edges:
226
235
  callee_node = self._qb.get_node_by_id(edge.target)
227
236
  if callee_node and edge.target not in seen:
228
237
  callees.append(callee_node)
229
- if include_indirect:
230
- collect_callees(edge.target)
238
+ collect_callees(edge.target, depth + 1)
231
239
 
232
- collect_callees(node_id)
240
+ collect_callees(node_id, 0)
233
241
  return callees
234
242
 
235
243
  def getImpactRadius(
@@ -125,7 +125,7 @@ class MCPServer:
125
125
  },
126
126
  'serverInfo': {
127
127
  'name': 'codegraph',
128
- 'version': '1.0.0',
128
+ 'version': '1.0.1',
129
129
  },
130
130
  })
131
131
  elif method == 'tools/list':
@@ -241,6 +241,7 @@ class SearchOptions:
241
241
  limit: int = 20
242
242
  offset: int = 0
243
243
  case_sensitive: bool = False
244
+ exact_match: bool = False
244
245
 
245
246
 
246
247
  @dataclass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codegraph-py
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Supercharge AI coding agents with semantic code intelligence — surgical context, fewer tool calls, faster answers. 100% local.
5
5
  Author-email: mading <martin98@bu.edu>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codegraph-py"
7
- version = "1.0.0"
7
+ version = "1.1.0"
8
8
  description = "Supercharge AI coding agents with semantic code intelligence — surgical context, fewer tool calls, faster answers. 100% local."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -54,12 +54,11 @@ Issues = "https://github.com/martin98-afk/codegraph-py/issues"
54
54
  codegraph = "codegraph.cli:main"
55
55
 
56
56
  [tool.setuptools.packages.find]
57
- where = ["."]
58
57
  include = ["codegraph*"]
59
- exclude = ["tests*"]
58
+ exclude = ["tests"]
60
59
 
61
60
  [tool.setuptools.package-data]
62
- codegraph = ["db/schema.sql"]
61
+ "codegraph.db" = ["schema.sql"]
63
62
 
64
63
  [tool.pytest.ini_options]
65
64
  testpaths = ["tests"]
File without changes
File without changes