codecompass-mcp 2.8.0__tar.gz → 3.0.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 (44) hide show
  1. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/PKG-INFO +1 -1
  2. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/codecompass_mcp.egg-info/PKG-INFO +1 -1
  3. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/graph/code_graph_client.py +38 -18
  4. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/ingestion/code_parser.py +137 -6
  5. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/ingestion/hierarchy_builder.py +1 -1
  6. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/models/code_types.py +6 -0
  7. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/pyproject.toml +1 -1
  8. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/tests/test_accuracy_fixes.py +24 -0
  9. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/LICENSE +0 -0
  10. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/README.md +0 -0
  11. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/codecompass_mcp.egg-info/SOURCES.txt +0 -0
  12. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/codecompass_mcp.egg-info/dependency_links.txt +0 -0
  13. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/codecompass_mcp.egg-info/entry_points.txt +0 -0
  14. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/codecompass_mcp.egg-info/requires.txt +0 -0
  15. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/codecompass_mcp.egg-info/top_level.txt +0 -0
  16. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/config.py +0 -0
  17. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/graph/__init__.py +0 -0
  18. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/graph/cli.py +0 -0
  19. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/graph/code_query_cli.py +0 -0
  20. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/graph/setup.py +0 -0
  21. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/ingestion/__init__.py +0 -0
  22. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/ingestion/chunker.py +0 -0
  23. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/ingestion/code_normalizer.py +0 -0
  24. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/ingestion/description_enricher.py +0 -0
  25. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/ingestion/entity_resolver.py +0 -0
  26. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/ingestion/file_watcher.py +0 -0
  27. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/ingestion/graph_writer.py +0 -0
  28. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/ingestion/reader_agent.py +0 -0
  29. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/ingestion/source_context.py +0 -0
  30. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/main.py +0 -0
  31. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/mcp_server.py +0 -0
  32. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/models/__init__.py +0 -0
  33. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/models/types.py +0 -0
  34. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/setup.cfg +0 -0
  35. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/tests/test_batch_impact.py +0 -0
  36. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/tests/test_blast_radius.py +0 -0
  37. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/tests/test_code_parser.py +0 -0
  38. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/tests/test_description_enricher.py +0 -0
  39. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/tests/test_ingestion.py +0 -0
  40. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/tests/test_lit_css_parser.py +0 -0
  41. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/tests/test_mcp_server.py +0 -0
  42. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/tests/test_php_parser.py +0 -0
  43. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/utils/__init__.py +0 -0
  44. {codecompass_mcp-2.8.0 → codecompass_mcp-3.0.0}/utils/formatting.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecompass-mcp
3
- Version: 2.8.0
3
+ Version: 3.0.0
4
4
  Summary: Structural code context for AI coding agents — a local code knowledge graph for blast radius, impact, deps, dead code, and flow tracing
5
5
  Author: CodeCompass contributors
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecompass-mcp
3
- Version: 2.8.0
3
+ Version: 3.0.0
4
4
  Summary: Structural code context for AI coding agents — a local code knowledge graph for blast radius, impact, deps, dead code, and flow tracing
5
5
  Author: CodeCompass contributors
6
6
  License: MIT
@@ -37,12 +37,15 @@ def _now() -> str:
37
37
  return datetime.now(timezone.utc).isoformat()
38
38
 
39
39
 
40
- def _entity_id(project: str, name: str, file: str | None) -> str:
41
- """Node id for an entity — file-qualified so same-named entities in different
42
- files stay distinct. Case is preserved so `Session` (class) and `session`
43
- (function) don't collide. External/module targets with no file are name-only."""
40
+ def _entity_id(project: str, name: str, file: str | None, owner: str | None = None) -> str:
41
+ """Node id for an entity — file- and class-qualified so same-named entities
42
+ stay distinct across files AND across classes in the same file. Case is
43
+ preserved so `Session` (class) and `session` (function) don't collide.
44
+ External/module targets with no file are name-only.
45
+ """
44
46
  if file:
45
- return f"{project}:{file}:{name}"
47
+ local = f"{owner}.{name}" if owner else name
48
+ return f"{project}:{file}:{local}"
46
49
  return f"{project}:{name}"
47
50
 
48
51
 
@@ -225,6 +228,8 @@ class LocalGraphClient:
225
228
  # later non-exported definition clear a flag an earlier one set.
226
229
  if getattr(triple, "is_exported", False):
227
230
  self.graph.nodes[from_id]["is_exported"] = True
231
+ if getattr(triple, "owner_class", None):
232
+ self.graph.nodes[from_id]["owner"] = triple.owner_class
228
233
 
229
234
  # Upsert to entity — only set language/kind/description if not already known
230
235
  self.graph.add_node(to_id)
@@ -260,13 +265,18 @@ class LocalGraphClient:
260
265
  name = triple.to_entity
261
266
  if triple.relation_type == "DEFINED_IN":
262
267
  return f"{project}:{name}" # module container — name-only
263
- cands = defs_by_name.get(name)
268
+ cands = defs_by_name.get(name) # list of (node_id, file, owner)
264
269
  if cands:
265
270
  rt = getattr(triple, "call_receiver_type", None)
266
271
  if rt:
272
+ # strongest: a definition owned by exactly the receiver's class
273
+ for nid, f, owner in cands:
274
+ if owner == rt:
275
+ return nid
276
+ # next: any definition in the receiver class's file
267
277
  cf = class_file.get(rt)
268
278
  if cf:
269
- for nid, f in cands:
279
+ for nid, f, owner in cands:
270
280
  if f == cf:
271
281
  return nid
272
282
  if len(cands) == 1:
@@ -281,19 +291,21 @@ class LocalGraphClient:
281
291
  project: str
282
292
  ) -> int:
283
293
  # Pass 1: index every definition so calls can resolve to a specific one.
284
- defs_by_name: dict[str, list] = {}
294
+ defs_by_name: dict[str, list] = {} # name -> [(node_id, file, owner)]
285
295
  class_file: dict[str, str] = {}
286
296
  for t in triples:
287
297
  if t.relation_type == "DEFINED_IN":
288
- nid = _entity_id(project, t.from_entity, t.source_file)
289
- defs_by_name.setdefault(t.from_entity, []).append((nid, t.source_file))
298
+ owner = getattr(t, "owner_class", None)
299
+ nid = _entity_id(project, t.from_entity, t.source_file, owner)
300
+ defs_by_name.setdefault(t.from_entity, []).append((nid, t.source_file, owner))
290
301
  if t.from_type == "class":
291
302
  class_file.setdefault(t.from_entity, t.source_file)
292
303
 
293
- # Pass 2: write nodes/edges with resolved, file-qualified ids.
304
+ # Pass 2: write nodes/edges with resolved, file+class-qualified ids.
294
305
  for triple in triples:
295
306
  file_id = file_id_map.get(triple.source_file, "")
296
- from_id = _entity_id(project, triple.from_entity, triple.source_file)
307
+ from_id = _entity_id(project, triple.from_entity, triple.source_file,
308
+ getattr(triple, "owner_class", None))
297
309
  to_id = self._resolve_to_id(triple, project, defs_by_name, class_file)
298
310
  self.write_code_triple(triple, file_id, project, from_id=from_id, to_id=to_id)
299
311
 
@@ -361,6 +373,12 @@ class LocalGraphClient:
361
373
  matches.sort(key=lambda n: self.graph.nodes[n].get("file") is None)
362
374
 
363
375
  if qualifier and matches:
376
+ # strongest: nodes whose owner class IS the qualifier (Command.invoke)
377
+ owned = [n for n in matches
378
+ if (self.graph.nodes[n].get("owner") or "").lower() == qualifier.lower()]
379
+ if owned:
380
+ return owned
381
+ # next: nodes in the file where class <qualifier> is defined
364
382
  cls_files = {
365
383
  a.get("file") for n, a in self.graph.nodes(data=True)
366
384
  if a.get("type") == "Entity" and a.get("entity_type") == "class"
@@ -450,12 +468,14 @@ class LocalGraphClient:
450
468
  visited.add(pred)
451
469
  queue.append((pred, depth + 1))
452
470
 
453
- # Qualified query (Type.method): also surface the name-only bucket's
454
- # direct callers — calls whose receiver couldn't be statically typed
455
- # (e.g. `adapter = self.get_adapter(url); adapter.send()`). They MAY hit
456
- # this method, so we don't drop them, but flag resolved=False rather than
457
- # claim false precision. An unqualified query already includes them.
458
- if want_receiver is not None:
471
+ # Qualified query (Type.method): if we found NO precise caller, fall back
472
+ # to the name-only bucket — calls whose receiver couldn't be statically
473
+ # typed (e.g. `adapter = self.get_adapter(url); adapter.send()`). They MAY
474
+ # hit this method, so we surface them flagged resolved=False rather than
475
+ # return nothing. We skip this when there's already a precise answer,
476
+ # because the bucket for a common name (`invoke`) is mostly calls to OTHER
477
+ # same-named methods and would flood a query that already resolved.
478
+ if want_receiver is not None and not results:
459
479
  method = entity_name.rsplit(".", 1)[1]
460
480
  bucket = f"{project}:{method}"
461
481
  if bucket in self.graph and bucket not in target_set:
@@ -221,6 +221,7 @@ def _extract_python(root: Node, source: bytes, file_path: str) -> list[CodeTripl
221
221
  source_file=file_path,
222
222
  line_number=_line(node),
223
223
  is_exported=name in exported,
224
+ owner_class=_enclosing_python_class(node),
224
225
  ))
225
226
 
226
227
  case "class_definition":
@@ -308,6 +309,7 @@ def _extract_python(root: Node, source: bytes, file_path: str) -> list[CodeTripl
308
309
  line_number=_line(node),
309
310
  call_receiver=receiver,
310
311
  call_receiver_type=receiver_type,
312
+ owner_class=_enclosing_python_class(node) if scope else None,
311
313
  ))
312
314
 
313
315
  return triples
@@ -630,11 +632,127 @@ def _php_string_text(string_node: Node) -> str:
630
632
  return _text(frag) if frag else _text(string_node).strip("'\"")
631
633
 
632
634
 
635
+ def _php_type_basename(node: Node | None) -> str | None:
636
+ """Class name from a PHP type node (`named_type`/`type`/`qualified_name`),
637
+ stripping any namespace. Skips scalar/pseudo types (int, string, void, …)."""
638
+ if node is None:
639
+ return None
640
+ for desc in _walk(node):
641
+ if desc.type in ("name", "qualified_name"):
642
+ base = _php_basename(desc)
643
+ if base and base[:1].isupper(): # PSR class-name convention
644
+ return base
645
+ return None
646
+
647
+
648
+ def _php_new_type(node: Node | None) -> str | None:
649
+ """Class name of `new X(...)`, else None."""
650
+ if node is None or node.type != "object_creation_expression":
651
+ return None
652
+ for child in node.children:
653
+ if child.type in ("name", "qualified_name"):
654
+ base = _php_basename(child)
655
+ if base and base.lower() not in _PHP_SELF_REFERENTIAL:
656
+ return base
657
+ return None
658
+
659
+
660
+ def _php_is_public(method_node: Node) -> bool:
661
+ """PHP method visibility — public (or no modifier, which defaults public) is
662
+ API; private/protected is internal. Reads the `visibility_modifier` child."""
663
+ for child in method_node.children:
664
+ if child.type == "visibility_modifier":
665
+ return _text(child).lower() not in ("private", "protected")
666
+ return True # PHP methods default to public
667
+
668
+
669
+ def _php_return_types(root: Node) -> dict[str, str]:
670
+ """Map function/method name -> declared return type (`function f(): Foo`)."""
671
+ rets: dict[str, str] = {}
672
+ conflict: set[str] = set()
673
+ for node in _walk(root):
674
+ if node.type in ("function_definition", "method_declaration"):
675
+ name = _php_name(node)
676
+ rt = node.child_by_field_name("return_type")
677
+ t = _php_type_basename(rt) if rt is not None else None
678
+ if name and t:
679
+ if name in rets and rets[name] != t:
680
+ conflict.add(name)
681
+ else:
682
+ rets[name] = t
683
+ for name in conflict:
684
+ rets.pop(name, None)
685
+ return rets
686
+
687
+
688
+ def _php_type_env(root: Node) -> dict[str, str]:
689
+ """Best-effort PHP variable → class map (no `$` in keys).
690
+
691
+ Binds from `$x = new Foo()`, typed params (`function f(Foo $x)`), and
692
+ return-type inference (`$x = make()` where `make(): Foo`). Conflicts dropped.
693
+ """
694
+ ret_types = _php_return_types(root)
695
+ types: dict[str, str] = {}
696
+ ambiguous: set[str] = set()
697
+
698
+ def bind(name: str | None, t: str | None) -> None:
699
+ if not name or not t:
700
+ return
701
+ if name in types and types[name] != t:
702
+ ambiguous.add(name)
703
+ else:
704
+ types[name] = t
705
+
706
+ for node in _walk(root):
707
+ if node.type == "assignment_expression" and node.children:
708
+ lhs = node.children[0]
709
+ rhs = node.children[-1]
710
+ if lhs.type == "variable_name":
711
+ name = _php_var_name(lhs)
712
+ bind(name, _php_new_type(rhs))
713
+ if rhs.type in ("function_call_expression", "member_call_expression",
714
+ "nullsafe_member_call_expression", "scoped_call_expression"):
715
+ callee = _child_of_type(rhs, "name")
716
+ if callee is not None:
717
+ bind(name, ret_types.get(_text(callee)))
718
+ elif node.type == "simple_parameter":
719
+ var = _child_of_type(node, "variable_name")
720
+ ty = node.child_by_field_name("type")
721
+ if var is not None and ty is not None:
722
+ bind(_php_var_name(var), _php_type_basename(ty))
723
+
724
+ for name in ambiguous:
725
+ types.pop(name, None)
726
+ return types
727
+
728
+
729
+ def _php_call_receiver(node: Node, env: dict[str, str]) -> tuple[str | None, str | None]:
730
+ """Receiver text + inferred type for a member call `$obj->method()`."""
731
+ obj = node.child_by_field_name("object")
732
+ if obj is None:
733
+ obj = node.children[0] if node.children else None
734
+ if obj is None:
735
+ return None, None
736
+ # inline `(new Foo())->m()`
737
+ nt = _php_new_type(obj)
738
+ if nt:
739
+ return "new", nt
740
+ if obj.type == "variable_name":
741
+ var = _php_var_name(obj)
742
+ if var == "this":
743
+ container = _enclosing_php_container(node)
744
+ return "this", (container[0] if container else None)
745
+ return var, env.get(var)
746
+ return _text(obj), None
747
+
748
+
633
749
  def _extract_php(root: Node, source: bytes, file_path: str) -> list[CodeTriple]:
634
750
  module_name = _module_name_from_path(file_path)
635
751
  triples: list[CodeTriple] = []
752
+ type_env = _php_type_env(root)
636
753
 
637
- def defined_in(name: str, entity_type: str, line: int) -> None:
754
+ def defined_in(name: str, entity_type: str, line: int, is_exported: bool = True,
755
+ owner: str | None = None) -> None:
638
756
  triples.append(CodeTriple(
639
757
  from_entity=name,
640
758
  from_type=entity_type,
@@ -643,6 +761,8 @@ def _extract_php(root: Node, source: bytes, file_path: str) -> list[CodeTriple]:
643
761
  to_type=TYPE_MODULE,
644
762
  source_file=file_path,
645
763
  line_number=line,
764
+ is_exported=is_exported,
765
+ owner_class=owner,
646
766
  ))
647
767
 
648
768
  def inherits(from_name: str, from_type: str, to_node: Node, to_type: str) -> None:
@@ -658,11 +778,13 @@ def _extract_php(root: Node, source: bytes, file_path: str) -> list[CodeTriple]:
658
778
  line_number=_line(to_node),
659
779
  ))
660
780
 
661
- def calls(callee: str, callee_type: str, line: int, scope_node: Node) -> None:
781
+ def calls(callee: str, callee_type: str, line: int, scope_node: Node,
782
+ receiver: str | None = None, receiver_type: str | None = None) -> None:
662
783
  if not callee or not _is_meaningful_callee(callee):
663
784
  return
664
785
  scope = _enclosing_php_scope(scope_node)
665
786
  caller, caller_type = scope if scope else (module_name, TYPE_MODULE)
787
+ container = _enclosing_php_container(scope_node)
666
788
  triples.append(CodeTriple(
667
789
  from_entity=caller,
668
790
  from_type=caller_type,
@@ -671,6 +793,9 @@ def _extract_php(root: Node, source: bytes, file_path: str) -> list[CodeTriple]:
671
793
  to_type=callee_type,
672
794
  source_file=file_path,
673
795
  line_number=line,
796
+ call_receiver=receiver,
797
+ call_receiver_type=receiver_type,
798
+ owner_class=container[0] if (scope and container) else None,
674
799
  ))
675
800
 
676
801
  for node in _walk(root):
@@ -684,7 +809,9 @@ def _extract_php(root: Node, source: bytes, file_path: str) -> list[CodeTriple]:
684
809
  case "method_declaration":
685
810
  name = _php_name(node)
686
811
  if name:
687
- defined_in(name, TYPE_FUNCTION, _line(node))
812
+ container = _enclosing_php_container(node)
813
+ defined_in(name, TYPE_FUNCTION, _line(node), is_exported=_php_is_public(node),
814
+ owner=container[0] if container else None)
688
815
 
689
816
  case "anonymous_function" | "arrow_function":
690
817
  parent = node.parent
@@ -851,7 +978,9 @@ def _extract_php(root: Node, source: bytes, file_path: str) -> list[CodeTriple]:
851
978
  case "member_call_expression" | "nullsafe_member_call_expression":
852
979
  callee_node = _child_of_type(node, "name")
853
980
  if callee_node:
854
- calls(_text(callee_node), TYPE_FUNCTION, _line(node), node)
981
+ recv, recv_type = _php_call_receiver(node, type_env)
982
+ calls(_text(callee_node), TYPE_FUNCTION, _line(node), node,
983
+ receiver=recv, receiver_type=recv_type)
855
984
 
856
985
  case "scoped_call_expression":
857
986
  names = _children_of_type(node, "name")
@@ -886,7 +1015,7 @@ def _extract_javascript(root: Node, source: bytes, file_path: str) -> list[CodeT
886
1015
  # Best-effort variable → type map for receiver type inference on calls.
887
1016
  type_env = _js_type_env(root)
888
1017
 
889
- def defined(name: str, line: int) -> None:
1018
+ def defined(name: str, line: int, owner: str | None = None) -> None:
890
1019
  triples.append(CodeTriple(
891
1020
  from_entity=name,
892
1021
  from_type=TYPE_FUNCTION,
@@ -896,6 +1025,7 @@ def _extract_javascript(root: Node, source: bytes, file_path: str) -> list[CodeT
896
1025
  source_file=file_path,
897
1026
  line_number=line,
898
1027
  is_exported=name in exported,
1028
+ owner_class=owner,
899
1029
  ))
900
1030
 
901
1031
  for node in _walk(root):
@@ -908,7 +1038,7 @@ def _extract_javascript(root: Node, source: bytes, file_path: str) -> list[CodeT
908
1038
  case "method_definition":
909
1039
  name_node = _child_of_type(node, "property_identifier")
910
1040
  if name_node:
911
- defined(_text(name_node), _line(name_node))
1041
+ defined(_text(name_node), _line(name_node), owner=_enclosing_class_name(node))
912
1042
 
913
1043
  case "variable_declarator":
914
1044
  # Capture: const foo = () => ...
@@ -970,6 +1100,7 @@ def _extract_javascript(root: Node, source: bytes, file_path: str) -> list[CodeT
970
1100
  line_number=_line(node),
971
1101
  call_receiver=receiver,
972
1102
  call_receiver_type=receiver_type,
1103
+ owner_class=_enclosing_class_name(node) if scope else None,
973
1104
  ))
974
1105
 
975
1106
  return triples
@@ -20,7 +20,7 @@ _SKIP_DIRS = {
20
20
  }
21
21
 
22
22
  # Supported source file extensions (mirrors code_parser.SUPPORTED_EXTENSIONS)
23
- _SOURCE_EXTENSIONS = {".py", ".js", ".ts", ".tsx", ".html", ".css", ".scss"}
23
+ _SOURCE_EXTENSIONS = {".py", ".js", ".jsx", ".ts", ".tsx", ".html", ".css", ".scss", ".php"}
24
24
 
25
25
 
26
26
  def build_hierarchy(project_root: str, project_name: str, client) -> dict[str, str]:
@@ -33,6 +33,12 @@ class CodeTriple:
33
33
  # Lets dead-code analysis avoid flagging intentionally-exported symbols.
34
34
  is_exported: bool = False
35
35
 
36
+ # Class that `from_entity` belongs to — the method's class for a definition,
37
+ # or the caller's class for a call. None for module-level entities. Lets node
38
+ # ids be class-qualified so same-named methods of different classes in the
39
+ # SAME file stay distinct (Command.invoke vs Context.invoke in core.py).
40
+ owner_class: str | None = None
41
+
36
42
 
37
43
  @dataclass
38
44
  class FileNode:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codecompass-mcp"
3
- version = "2.8.0"
3
+ version = "3.0.0"
4
4
  description = "Structural code context for AI coding agents — a local code knowledge graph for blast radius, impact, deps, dead code, and flow tracing"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -339,6 +339,30 @@ def test_python_public_method_marked_exported():
339
339
  assert by_name.get("_private") is False, "underscore method is not exported"
340
340
 
341
341
 
342
+ def test_php_receiver_type_and_exports():
343
+ src = (
344
+ "<?php\n"
345
+ "class Session {\n"
346
+ " public function send($r) { return 1; }\n"
347
+ " private function _helper() { return 2; }\n"
348
+ " public function getAdapter($u): HTTPAdapter { return new HTTPAdapter(); }\n"
349
+ " public function request($m) {\n"
350
+ " $adapter = $this->getAdapter($m);\n"
351
+ " $this->send($m);\n"
352
+ " return $adapter->send($m);\n"
353
+ " }\n"
354
+ "}\n"
355
+ )
356
+ triples = _parse(src, suffix=".php")
357
+ sends = [t for t in triples if t.relation_type == "CALLS" and t.to_entity == "send"]
358
+ types = {t.call_receiver_type for t in sends}
359
+ assert "Session" in types, "$this->send() typed by enclosing class"
360
+ assert "HTTPAdapter" in types, "$adapter->send() typed via getAdapter's return type"
361
+ by_name = {t.from_entity: t.is_exported for t in triples if t.relation_type == "DEFINED_IN"}
362
+ assert by_name.get("_helper") is False, "private method is not exported"
363
+ assert by_name.get("request") is True, "public method is exported"
364
+
365
+
342
366
  def test_graph_grep_regex():
343
367
  triples = [
344
368
  CodeTriple("HTTPAdapter", "class", "DEFINED_IN", "m", "module", "adapters.py", 1),
File without changes
File without changes