codegraph-py 1.0.1__tar.gz → 1.1.1__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 (52) hide show
  1. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/PKG-INFO +1 -1
  2. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/__init__.py +1 -1
  3. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/cli.py +148 -53
  4. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/codegraph.py +75 -7
  5. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/db/queries.py +30 -3
  6. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/graph/__init__.py +24 -16
  7. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/types.py +1 -0
  8. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph_py.egg-info/PKG-INFO +1 -1
  9. codegraph_py-1.1.1/codegraph_py.egg-info/top_level.txt +1 -0
  10. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/pyproject.toml +1 -1
  11. codegraph_py-1.0.1/codegraph_py.egg-info/top_level.txt +0 -2
  12. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/README.md +0 -0
  13. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/__main__.py +0 -0
  14. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/context/__init__.py +0 -0
  15. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/db/__init__.py +0 -0
  16. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/db/connection.py +0 -0
  17. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/db/schema.sql +0 -0
  18. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/directory.py +0 -0
  19. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/errors.py +0 -0
  20. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/extraction/__init__.py +0 -0
  21. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/extraction/languages/__init__.py +0 -0
  22. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/extraction/languages/base.py +0 -0
  23. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/extraction/languages/c_cfg.py +0 -0
  24. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/extraction/languages/cpp_cfg.py +0 -0
  25. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/extraction/languages/dart_cfg.py +0 -0
  26. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/extraction/languages/go_cfg.py +0 -0
  27. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/extraction/languages/java_cfg.py +0 -0
  28. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/extraction/languages/javascript_cfg.py +0 -0
  29. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/extraction/languages/kotlin_cfg.py +0 -0
  30. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/extraction/languages/lua_cfg.py +0 -0
  31. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/extraction/languages/python_cfg.py +0 -0
  32. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/extraction/languages/ruby_cfg.py +0 -0
  33. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/extraction/languages/rust_cfg.py +0 -0
  34. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/extraction/languages/scala_cfg.py +0 -0
  35. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/extraction/languages/swift_cfg.py +0 -0
  36. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/extraction/languages/typescript_cfg.py +0 -0
  37. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/extraction/tree_sitter_extractor.py +0 -0
  38. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/installer/__init__.py +0 -0
  39. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/mcp/__init__.py +0 -0
  40. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/project_config.py +0 -0
  41. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/resolution/__init__.py +0 -0
  42. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/search/__init__.py +0 -0
  43. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/sync/__init__.py +0 -0
  44. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/ui/__init__.py +0 -0
  45. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph/utils.py +0 -0
  46. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph_py.egg-info/SOURCES.txt +0 -0
  47. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph_py.egg-info/dependency_links.txt +0 -0
  48. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph_py.egg-info/entry_points.txt +0 -0
  49. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/codegraph_py.egg-info/requires.txt +0 -0
  50. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/setup.cfg +0 -0
  51. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/tests/test_codegraph.py +0 -0
  52. {codegraph_py-1.0.1 → codegraph_py-1.1.1}/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.1
3
+ Version: 1.1.1
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.1"
8
+ __version__ = "1.1.1"
9
9
 
10
10
  from codegraph.codegraph import CodeGraph
11
11
 
@@ -14,12 +14,13 @@ from typing import Optional, List, Dict
14
14
 
15
15
  import click
16
16
 
17
+ from codegraph import __version__
17
18
  from codegraph.codegraph import CodeGraph
18
19
  from codegraph.directory import (
19
20
  is_initialized, find_nearest_codegraph_root,
20
21
  unsafe_index_root_reason, derive_project_name_tokens,
21
22
  )
22
- from codegraph.types import SearchOptions, GraphStats
23
+ from codegraph.types import SearchOptions, SearchResult, GraphStats
23
24
 
24
25
 
25
26
  # =============================================================================
@@ -68,6 +69,15 @@ def format_number(n: int) -> str:
68
69
  return f'{n:,}'
69
70
 
70
71
 
72
+ def _pluralize(word: str) -> str:
73
+ """Simple English pluralization."""
74
+ if word.endswith(('s', 'x', 'ch', 'sh')):
75
+ return word + 'es'
76
+ if word.endswith('y') and len(word) > 2 and word[-2] not in 'aeiou':
77
+ return word[:-1] + 'ies'
78
+ return word + 's'
79
+
80
+
71
81
  def format_duration(ms: float) -> str:
72
82
  """Format duration in milliseconds to human readable."""
73
83
  if ms < 1000:
@@ -106,13 +116,24 @@ def resolve_project_path(path_arg: Optional[str] = None) -> str:
106
116
  # CLI Commands
107
117
  # =============================================================================
108
118
 
109
- @click.group()
110
- @click.version_option(version='1.0.1', prog_name='codegraph')
119
+ @click.group(context_settings=dict(help_option_names=['-h', '--help']))
120
+ @click.version_option(version=__version__, prog_name='codegraph')
111
121
  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.
122
+ """CodeGraph Semantic code intelligence for AI coding agents.
123
+
124
+ Build a knowledge graph of your codebase for instant symbol lookup,
125
+ call-chain analysis, and surgical context extraction.
126
+
127
+ \b
128
+ Common workflows:
129
+ codegraph init Initialize and index current project
130
+ codegraph status View index statistics
131
+ codegraph query "class" Search for symbols matching "class"
132
+ codegraph query --kind class List all classes in the project
133
+ codegraph explore "main" Show source code and call paths for "main"
134
+ codegraph callers "func" Show what calls the function "func"
135
+ codegraph callees "func" Show what "func" calls
136
+ codegraph files List all indexed files
116
137
  """
117
138
  pass
118
139
 
@@ -308,7 +329,13 @@ def sync(path: Optional[str], quiet: bool):
308
329
  @click.argument('path', required=False, default=None)
309
330
  @click.option('-j', '--json', 'json_output', is_flag=True, help='Output as JSON')
310
331
  def status(path: Optional[str], json_output: bool):
311
- """Show index status and statistics."""
332
+ """Show index status, statistics, and pending changes.
333
+
334
+ Displays file count, node/edge counts, node breakdown by kind,
335
+ and any pending changes since the last index.
336
+
337
+ Use --json/-j for machine-readable output.
338
+ """
312
339
  project_path = resolve_project_path(path)
313
340
 
314
341
  if not is_initialized(project_path):
@@ -403,14 +430,21 @@ def status(path: Optional[str], json_output: bool):
403
430
 
404
431
 
405
432
  @main.command()
406
- @click.argument('search')
433
+ @click.argument('search', required=False, default='')
407
434
  @click.option('-p', '--path', 'path_arg', help='Project path')
408
- @click.option('-l', '--limit', default=10, type=int, help='Maximum results')
435
+ @click.option('-l', '--limit', default=20, type=int, help='Maximum results')
409
436
  @click.option('-k', '--kind', help='Filter by node kind (function, class, etc.)')
437
+ @click.option('--exact', is_flag=True, help='Exact name match (instead of fuzzy/prefix)')
438
+ @click.option('--fuzzy', is_flag=True, help='Force fuzzy name matching (default)')
410
439
  @click.option('-j', '--json', 'json_output', is_flag=True, help='Output as JSON')
411
440
  def query(search: str, path_arg: Optional[str], limit: int,
412
- kind: Optional[str], json_output: bool):
413
- """Search for symbols in the codebase."""
441
+ kind: Optional[str], exact: bool, fuzzy: bool,
442
+ json_output: bool):
443
+ """Search for symbols in the codebase.
444
+
445
+ If SEARCH is omitted and --kind is provided, lists all symbols of that kind.
446
+ Use --exact for precise symbol name lookup.
447
+ """
414
448
  project_path = resolve_project_path(path_arg)
415
449
 
416
450
  if not is_initialized(project_path):
@@ -424,7 +458,15 @@ def query(search: str, path_arg: Optional[str], limit: int,
424
458
  if kind:
425
459
  opts.kinds = [kind]
426
460
 
427
- results = cg.search_nodes(search, opts)
461
+ if exact:
462
+ opts.exact_match = True
463
+
464
+ if not search.strip() and kind:
465
+ # No search term — list by kind only
466
+ nodes = cg._queries.get_nodes_by_kind(kind) if kind else []
467
+ results = [SearchResult(node=n, score=1.0) for n in nodes]
468
+ else:
469
+ results = cg.search_nodes(search, opts)
428
470
 
429
471
  if json_output:
430
472
  output = []
@@ -447,9 +489,15 @@ def query(search: str, path_arg: Optional[str], limit: int,
447
489
  return
448
490
 
449
491
  if not results:
450
- click.echo(f'No results found for "{search}"')
492
+ if search:
493
+ click.echo(f'No results found for "{search}"')
494
+ elif kind:
495
+ click.echo(f'No symbols of kind "{kind}" found')
496
+ else:
497
+ click.echo('No search term provided. Use --kind to browse by type.')
451
498
  else:
452
- click.echo(bold(f'\nSearch Results for "{search}":\n'))
499
+ title = f'\nSearch Results for "{search}":' if search else f'\nAll {_pluralize(kind)}:' if kind else '\nResults:'
500
+ click.echo(bold(title + '\n'))
453
501
  for r in results:
454
502
  node = r.node
455
503
  location = f'{node.file_path}:{node.start_line}'
@@ -473,6 +521,14 @@ def query(search: str, path_arg: Optional[str], limit: int,
473
521
  def explore(query_parts: List[str], path_arg: Optional[str], max_files: int):
474
522
  """Explore code: relevant symbols' source + call paths in one shot.
475
523
 
524
+ QUERY_PARTS is one or more search terms for symbol lookup.
525
+ Combines search, callers/callees, and source display in a single view.
526
+
527
+ \b
528
+ Examples:
529
+ codegraph explore main Find "main" and show source + call context
530
+ codegraph explore ChatBackend Explore ChatBackend class and its relationships
531
+
476
532
  Same output as the codegraph_explore MCP tool.
477
533
  """
478
534
  query = ' '.join(query_parts)
@@ -524,9 +580,14 @@ def explore(query_parts: List[str], path_arg: Optional[str], max_files: int):
524
580
  @main.command()
525
581
  @click.argument('symbol')
526
582
  @click.option('-p', '--path', 'path_arg', help='Project path')
527
- @click.option('-d', '--depth', default=1, type=int, help='Traversal depth')
583
+ @click.option('-d', '--depth', default=1, type=int, help='Traversal depth (1=direct, 2=indirect)')
528
584
  def callers(symbol: str, path_arg: Optional[str], depth: int):
529
- """Find what calls a function/method."""
585
+ """Find what calls a function/method.
586
+
587
+ SYMBOL is the symbol name to find callers for (e.g. "ChatBackend", "main").
588
+
589
+ Use -d 2 to include indirect callers (callers of callers).
590
+ """
530
591
  project_path = resolve_project_path(path_arg)
531
592
 
532
593
  if not is_initialized(project_path):
@@ -569,9 +630,14 @@ def callers(symbol: str, path_arg: Optional[str], depth: int):
569
630
  @main.command()
570
631
  @click.argument('symbol')
571
632
  @click.option('-p', '--path', 'path_arg', help='Project path')
572
- @click.option('-d', '--depth', default=1, type=int, help='Traversal depth')
633
+ @click.option('-d', '--depth', default=1, type=int, help='Traversal depth (1=direct, 2=indirect)')
573
634
  def callees(symbol: str, path_arg: Optional[str], depth: int):
574
- """Find what a function/method calls."""
635
+ """Find what a function/method calls.
636
+
637
+ SYMBOL is the symbol name to find callees for (e.g. "run", "process").
638
+
639
+ Use -d 2 to include indirect callees (callees of callees).
640
+ """
575
641
  project_path = resolve_project_path(path_arg)
576
642
 
577
643
  if not is_initialized(project_path):
@@ -698,7 +764,7 @@ def affected(files: List[str], path_arg: Optional[str]):
698
764
  nodes = cg.get_nodes_by_file(filepath)
699
765
  for node in nodes:
700
766
  # Find what depends on these nodes
701
- dependents = cg.get_impact_radius(node.id, depth=2)
767
+ dependents = cg.get_impact_radius(node.id, max_depth=2)
702
768
  for n in dependents.nodes.values():
703
769
  if n.file_path.endswith('_test.py') or n.file_path.endswith('test_'):
704
770
  affected_files.add(n.file_path)
@@ -730,12 +796,14 @@ def node(name: str, path_arg: Optional[str]):
730
796
  return
731
797
 
732
798
  try:
733
- cg = CodeGraph(project_path)
799
+ cg = CodeGraph.open_sync(project_path)
734
800
 
735
801
  # Search for nodes by name
736
- results = cg.search(name)
802
+ opts = SearchOptions(limit=20)
803
+ results = cg.search_nodes(name, opts)
737
804
  if not results:
738
805
  click.echo(yellow(f'No nodes found matching "{name}"'))
806
+ cg.destroy()
739
807
  return
740
808
 
741
809
  # If exact match, show details
@@ -758,18 +826,18 @@ def node(name: str, path_arg: Optional[str]):
758
826
  click.echo(f' {dim("Exported:")} {green("yes") if target.is_exported else "no"}')
759
827
 
760
828
  # 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})')
829
+ caller_pairs = cg.get_callers(target.id)
830
+ if caller_pairs:
831
+ click.echo(bold(f'\n Callers ({len(caller_pairs)}):'))
832
+ for c_node, c_edge in caller_pairs:
833
+ click.echo(f' ← {c_node.name} ({c_node.file_path}:{c_node.start_line})')
766
834
 
767
835
  # 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})')
836
+ callee_pairs = cg.get_callees(target.id)
837
+ if callee_pairs:
838
+ click.echo(bold(f'\n Callees ({len(callee_pairs)}):'))
839
+ for c_node, c_edge in callee_pairs:
840
+ click.echo(f' → {c_node.name} ({c_node.file_path}:{c_node.start_line})')
773
841
 
774
842
  cg.destroy()
775
843
 
@@ -781,7 +849,9 @@ def node(name: str, path_arg: Optional[str]):
781
849
  @main.command()
782
850
  @click.option('--path', '-p', 'path_arg', default=None, help='Project path')
783
851
  @click.option('--json-output', 'json_output', is_flag=True, help='Output as JSON')
784
- def files(path_arg: Optional[str], json_output: bool):
852
+ @click.option('--by-directory', 'by_directory', is_flag=True, help='Group files by directory')
853
+ @click.option('--stats', 'show_stats', is_flag=True, help='Show per-file symbol stats (default when no flags)')
854
+ def files(path_arg: Optional[str], json_output: bool, by_directory: bool, show_stats: bool):
785
855
  """List indexed files with language and symbol statistics."""
786
856
  project_path = resolve_project_path(path_arg)
787
857
 
@@ -791,44 +861,69 @@ def files(path_arg: Optional[str], json_output: bool):
791
861
  return
792
862
 
793
863
  try:
794
- cg = CodeGraph(project_path)
864
+ cg = CodeGraph.open_sync(project_path)
795
865
  stats = cg.get_stats()
796
- all_files = cg.db.get_all_file_paths()
866
+ all_files = cg._queries.get_all_file_paths()
797
867
 
798
868
  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)
869
+ file_list = []
870
+ for f in sorted(all_files):
871
+ nodes = cg.get_nodes_by_file(f)
807
872
  langs = set(n.language for n in nodes if n.language)
808
- result['files'].append({
873
+ kinds = {}
874
+ for n in nodes:
875
+ kinds[n.kind] = kinds.get(n.kind, 0) + 1
876
+ file_list.append({
809
877
  'path': f,
810
878
  'node_count': len(nodes),
811
879
  'languages': list(langs),
880
+ 'node_kinds': kinds,
812
881
  })
882
+ result = {
883
+ 'total_files': len(all_files),
884
+ 'total_nodes': stats.node_count,
885
+ 'total_edges': stats.edge_count,
886
+ 'files': file_list,
887
+ }
813
888
  click.echo(json.dumps(result, indent=2))
889
+ cg.destroy()
890
+ return
891
+
892
+ show_stats = show_stats or not by_directory
893
+
894
+ if by_directory:
895
+ # Group files by directory
896
+ dirs: Dict[str, List[str]] = {}
897
+ for f in sorted(all_files):
898
+ d = os.path.dirname(f) or '.'
899
+ dirs.setdefault(d, []).append(f)
900
+ click.echo(bold(f'\n📁 Indexed Files ({len(all_files)} total, {len(dirs)} directories)\n'))
901
+ for directory in sorted(dirs):
902
+ click.echo(blue(f' {directory}/'))
903
+ for f in dirs[directory]:
904
+ click.echo(f' {os.path.basename(f)}')
905
+ click.echo()
814
906
  else:
815
907
  click.echo(bold(f'\n📁 Indexed Files ({len(all_files)} total)\n'))
816
908
  for f in sorted(all_files):
817
- nodes = cg.db.get_nodes_by_file(f)
909
+ nodes = cg.get_nodes_by_file(f)
818
910
  lang = ''
819
911
  for n in nodes:
820
912
  if n.language and n.language != 'unknown':
821
913
  lang = n.language
822
914
  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+")")}')
915
+ if show_stats:
916
+ kinds = {}
917
+ for n in nodes:
918
+ k = n.kind
919
+ kinds[k] = kinds.get(k, 0) + 1
920
+ kind_str = ', '.join(f'{v} {k}' for k, v in sorted(kinds.items()) if k != 'file')
921
+ if kind_str:
922
+ click.echo(f' {dim(lang+" ") if lang else ""}{f} {dim("("+kind_str+")")}')
923
+ else:
924
+ click.echo(f' {f}')
830
925
  else:
831
- click.echo(f' {f}')
926
+ click.echo(f' {dim(lang+" ") if lang else ""}{f}')
832
927
 
833
928
  cg.destroy()
834
929
 
@@ -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.1"
34
+ __version__ = "1.1.1"
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(
@@ -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.1
3
+ Version: 1.1.1
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
@@ -0,0 +1 @@
1
+ codegraph
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codegraph-py"
7
- version = "1.0.1"
7
+ version = "1.1.1"
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"
@@ -1,2 +0,0 @@
1
- codegraph
2
- codegraph-main
File without changes
File without changes