diff --git a/graphify/extract.py b/graphify/extract.py index 32c59484d..91ea838a6 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -727,6 +727,33 @@ def _import_scala(node, source: bytes, file_nid: str, stem: str, edges: list, st break +def _php_import_kind(node) -> str | None: + kinds = ("function", "const") + local_kind = next((child.type for child in node.children if child.type in kinds), None) + if local_kind: + return local_kind + + parent = node.parent + declaration = parent.parent if parent is not None and parent.type == "namespace_use_group" else parent + if declaration is None or declaration.type != "namespace_use_declaration": + return None + + declaration_kind = next( + (child.type for child in declaration.children if child.type in kinds), + None, + ) + if declaration_kind: + return declaration_kind + + first_clause = next( + (child for child in declaration.children if child.type == "namespace_use_clause"), + None, + ) + if first_clause is None: + return None + return next((child.type for child in first_clause.children if child.type in kinds), None) + + def _import_php(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None: for child in node.children: if child.type in ("qualified_name", "name", "identifier"): @@ -734,7 +761,7 @@ def _import_php(node, source: bytes, file_nid: str, stem: str, edges: list, str_ module_name = raw.split("\\")[-1].strip() if module_name: tgt_nid = _make_id(module_name) - edges.append({ + edge = { "source": file_nid, "target": tgt_nid, "relation": "imports", @@ -743,7 +770,10 @@ def _import_php(node, source: bytes, file_nid: str, stem: str, edges: list, str_ "source_file": str_path, "source_location": f"L{node.start_point[0] + 1}", "weight": 1.0, - }) + } + if _php_import_kind(node) in ("function", "const"): + edge["_php_symbol_import"] = True + edges.append(edge) break @@ -2415,9 +2445,16 @@ def extract_scala(path: Path) -> dict: return _extract_generic(path, _SCALA_CONFIG) +def _extract_php_with_symbol_markers(path: Path) -> dict: + return _extract_generic(path, _PHP_CONFIG) + + def extract_php(path: Path) -> dict: """Extract classes, functions, methods, namespace uses, and calls from a .php file.""" - return _extract_generic(path, _PHP_CONFIG) + result = _extract_php_with_symbol_markers(path) + for edge in result.get("edges", []): + edge.pop("_php_symbol_import", None) + return result # One level of balanced parens (e.g. `Foo #(Bar #(int))`) — bounded so malformed @@ -2721,6 +2758,8 @@ def _rewire_unique_stub_nodes(nodes: list[dict], edges: list[dict]) -> None: remap[stub_id] = target_id if not remap: + for edge in edges: + edge.pop("_php_symbol_import", None) return by_id = {node.get("id"): node for node in nodes if node.get("id")} @@ -2770,7 +2809,8 @@ def _names_own_builtin_base(edge: dict, stub_id: str, remapped_id: str) -> bool: ): edge["source"] = remapped_source target = edge.get("target") - if target in remap: + is_php_symbol_import = bool(edge.pop("_php_symbol_import", False)) + if target in remap and not is_php_symbol_import: remapped_target = remap[str(target)] if not ( is_csharp_scoped_edge @@ -5715,7 +5755,7 @@ def add_existing_edge(edge: dict) -> None: ".kt": extract_kotlin, ".kts": extract_kotlin, ".scala": extract_scala, - ".php": extract_php, + ".php": _extract_php_with_symbol_markers, ".swift": extract_swift, ".lua": extract_lua, ".luau": extract_lua, @@ -5840,7 +5880,7 @@ def add_existing_edge(edge: dict) -> None: "nodejs": extract_js, "ruby": extract_ruby, "lua": extract_lua, - "php": extract_php, + "php": _extract_php_with_symbol_markers, "julia": extract_julia, } diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 7346882f1..95feca02b 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -3197,14 +3197,19 @@ def _record_raw(relation: str, raw: str) -> None: else: raws.setdefault(key, raw) - def _record_use_clause(clause, prefix: str) -> None: + def _record_use_clause( + clause, + prefix: str, + declaration_kind: str | None = None, + ) -> None: + clause_kind = declaration_kind target = None alias = None saw_as = False for c in clause.children: if c.type in ("function", "const"): - return # not a class import - if c.type == "as": + clause_kind = c.type + elif c.type == "as": saw_as = True elif c.type in ("qualified_name", "name"): if saw_as: @@ -3215,6 +3220,8 @@ def _record_use_clause(clause, prefix: str) -> None: return fqn = (f"{prefix}\\{target}" if prefix else target).lstrip("\\") key = (alias or fqn.rsplit("\\", 1)[-1]).strip().lower() + if clause_kind in ("function", "const"): + return if key: uses.setdefault(key, fqn) @@ -3228,17 +3235,35 @@ def walk(n) -> None: elif t == "namespace_use_declaration": prefix = "" group = None + declaration_kind = next( + (c.type for c in n.children if c.type in ("function", "const")), + None, + ) + if declaration_kind is None: + first_clause = next( + (c for c in n.children if c.type == "namespace_use_clause"), + None, + ) + if first_clause is not None: + declaration_kind = next( + ( + c.type + for c in first_clause.children + if c.type in ("function", "const") + ), + None, + ) for c in n.children: if c.type == "namespace_name": prefix = _read_text(c, source) # group-use prefix elif c.type == "namespace_use_group": group = c elif c.type == "namespace_use_clause": - _record_use_clause(c, "") + _record_use_clause(c, "", declaration_kind) if group is not None: for c in group.children: if c.type == "namespace_use_clause": - _record_use_clause(c, prefix) + _record_use_clause(c, prefix, declaration_kind) return elif t == "class_declaration": for child in n.children: @@ -3323,6 +3348,8 @@ def _external_stub(fqn: str) -> str: if ref_file not in ns_by_file: continue tgt = edge.get("target") + if relation == "imports" and edge.get("_php_symbol_import"): + continue label = stub_label.get(tgt) uses = uses_by_file.get(ref_file, {}) if not label and relation == "imports": diff --git a/tests/test_php_type_resolution.py b/tests/test_php_type_resolution.py index f90b72fcd..8bb23bb41 100644 --- a/tests/test_php_type_resolution.py +++ b/tests/test_php_type_resolution.py @@ -2,7 +2,10 @@ from pathlib import Path -from graphify.extract import extract +import pytest + +from graphify.extract import extract, extract_php +from graphify.extractors.resolution import _resolve_php_type_references def _write(path: Path, text: str) -> Path: @@ -188,3 +191,234 @@ def test_php_import_resolves_when_target_name_prefixes_sibling_classes(tmp_path: assert len(imports) == 1 assert imports[0]["target"] == pivot_id + + +@pytest.mark.parametrize( + ("kind", "symbol", "alias"), + [ + ("function", "slug", "s"), + ("const", "LIMIT", "L"), + ], + ids=["function", "const"], +) +def test_php_grouped_symbol_aliases_are_not_class_imports( + tmp_path: Path, + kind: str, + symbol: str, + alias: str, +): + user = _write( + tmp_path / "User.php", + "