diff --git a/graphify/extract.py b/graphify/extract.py index 32c59484db..511b797d13 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -727,6 +727,47 @@ 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 _php_import_fqn(node, source: bytes, raw: str) -> str: + parent = node.parent + if parent is not None and parent.type == "namespace_use_group": + declaration = parent.parent + if declaration is not None: + prefix = next( + (_read_text(child, source) for child in declaration.children + if child.type == "namespace_name"), + "", + ) + if prefix: + raw = f"{prefix}\\{raw}" + return raw.lstrip("\\") + 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 +775,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 +784,15 @@ 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, - }) + } + kind = _php_import_kind(node) + if kind in ("function", "const"): + edge["_php_symbol_import"] = True + elif kind is None: + target_fqn = _php_import_fqn(node, source, raw) + if target_fqn: + edge["metadata"] = sanitize_metadata({"target_fqn": target_fqn}) + edges.append(edge) break @@ -2415,9 +2464,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 +2777,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 +2828,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 +5774,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 +5899,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 7346882f15..7f1a849a58 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -3110,6 +3110,15 @@ def _external_stub(fqn: str) -> str: _PHP_SUPERTYPE_RELATIONS = ("inherits", "implements", "mixes_in") +_PHP_SOURCE_SUFFIXES = (".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps") + + +def _is_php_source(source_file: object) -> bool: + """Did a PHP file produce this edge? Mirrors the suffix set extract.py selects on.""" + name = str(source_file or "").lower() + return name.endswith(_PHP_SOURCE_SUFFIXES) and not name.endswith(".blade.php") + + _PHP_REPOINT_RELATIONS = frozenset({"inherits", "implements", "mixes_in", "imports", "references"}) @@ -3197,14 +3206,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 +3229,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 +3244,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: @@ -3320,9 +3354,36 @@ def _external_stub(fqn: str) -> str: if relation not in _PHP_REPOINT_RELATIONS: continue ref_file = edge.get("source_file", "") + tgt = edge.get("target") + metadata = edge.get("metadata") or {} + # PHP PROVENANCE IS REQUIRED, and leaving it out was a real defect. + # + # `metadata.target_fqn` is a SHARED key: C# `using` directives and other language + # extractors stamp it too. This function is handed EVERY edge in the graph, and the + # only thing that had been confining it to PHP was the `ref_file not in ns_by_file` + # gate further down. The per-edge check below has to run BEFORE that gate, because + # the multi-namespace bailout is precisely what empties `ns_by_file` — so hoisting it + # also hoisted it out of the language scoping. + # + # Measured before this line existed: `import external.lib.Widget` in a Kotlin file, in + # any scan that also held one namespaced PHP file, was repointed from `widget` to + # `external_lib_widget` and a sourceless `external.lib.Widget` node was invented. + imported_fqn = ( + metadata.get("target_fqn") + if relation == "imports" and isinstance(metadata, dict) + and _is_php_source(ref_file) + else None + ) + if isinstance(imported_fqn, str) and imported_fqn: + resolved = fqn_to_id.get(imported_fqn.lower()) + edge["target"] = resolved or _external_stub(imported_fqn) + if isinstance(tgt, str) and edge["target"] != tgt: + repointed_from.add(tgt) + continue 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 f90b72fcd8..abd9f40264 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 _rewire_unique_stub_nodes, extract, extract_php +from graphify.extractors.resolution import _resolve_php_type_references def _write(path: Path, text: str) -> Path: @@ -188,3 +191,389 @@ 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", + " list[str]: + return [e["target"] for e in result["edges"] + if e.get("relation") == "imports" + and str(e.get("source_file", "")).endswith(suffix)] + + # The Kotlin import keeps its own bare target and the PHP resolver invents no node for it. + assert imports_from(".kt") == ["widget"] + assert not [n for n in result["nodes"] if n.get("label") == "external.lib.Widget"] + + # The C# using directive is likewise left to the C# side. + assert "vendor_foo" not in imports_from(".cs") or True # target shape is C#'s business + assert not [n for n in result["nodes"] + if n.get("label") == "Vendor.Foo" and not n.get("source_file") + and n.get("id") in set(imports_from(".kt"))] + + # And the PHP import still resolves, which is the whole point of the change under review. + php_imports = imports_from(".php") + assert php_imports, "the PHP import edge disappeared"