From f6e8f07e7e211ad249261ef024a89d0d478f22db Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Thu, 10 Sep 2026 06:06:20 +0530 Subject: [PATCH 1/2] test: add failing regression for grouped PHP symbol imports --- tests/test_php_type_resolution.py | 236 +++++++++++++++++++++++++++++- 1 file changed, 235 insertions(+), 1 deletion(-) 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", + " Date: Thu, 10 Sep 2026 06:10:23 +0530 Subject: [PATCH 2/2] fix: propagate PHP symbol import kind across use clauses Problem PHP grouped function and const imports acquire class-style graph identities. Their imports edges can be redirected to sourceless FQNs or unrelated classes, and same-named class and symbol imports can collapse into one wrong target. Root cause The PHP import boundary discarded effective import kind before emitting edges. Tree-sitter stores homogeneous group kinds on namespace_use_declaration and homogeneous comma-list kinds only on the first clause, while mixed groups keep kind on each symbol clause. The PHP namespace resolver also classified clauses without this inherited kind. Once class and symbol edges shared a bare ID, the PHP FQN path or generic unique-stub rewire treated both as class references. Approach Derive effective kind from the clause, its declaration, or the first governed clause when the internal PHP extractor creates each edge. Mark function/const edges before deduplication, propagate the same kind when building the class-use map, and skip only marked targets during generic stub rewiring. Public extract_php strips the marker immediately; internal aggregate dispatch retains it through cache and resolution, then removes it before returning the graph. This preserves same-line class/symbol imports, mixed groups, multi-namespace files, and legitimate class relations while retaining symbol targets. Rejected alternatives reconstructed kind from line/target pairs, disabled class FQN resolution, or deleted bad stubs after other relations were corrupted. Verification INITIAL RED: FF [100%] FAILED tests/test_php_type_resolution.py::test_php_grouped_symbol_aliases_are_not_class_imports[function] FAILED tests/test_php_type_resolution.py::test_php_grouped_symbol_aliases_are_not_class_imports[const] 2 failed in 0.14s SHARED-STUB RED: FFFF [100%] 4 failed, 1 warning in 0.15s PRODUCER-IDENTITY RED: FFF [100%] FAILED tests/test_php_type_resolution.py::test_php_symbol_import_does_not_hide_same_named_class_import FAILED tests/test_php_type_resolution.py::test_php_mixed_group_preserves_same_named_class_and_function_imports FAILED tests/test_php_type_resolution.py::test_php_symbol_import_survives_multi_namespace_resolution_skip 3 failed, 1 warning in 0.35s PUBLIC-EXTRACTOR RED: F [100%] FAILED tests/test_php_type_resolution.py::test_php_single_file_extractor_hides_symbol_import_marker 1 failed, 1 warning in 0.14s GREEN: ................... [100%] 19 passed, 1 warning in 0.13s PHP LANGUAGE CONTROLS: ................... [100%] 19 passed, 388 deselected, 1 warning in 0.11s FINAL TEST-ONLY MUTATION: FFFFFFF [100%] FAILED tests/test_php_type_resolution.py::test_php_grouped_symbol_aliases_are_not_class_imports[function] FAILED tests/test_php_type_resolution.py::test_php_grouped_symbol_aliases_are_not_class_imports[const] FAILED tests/test_php_type_resolution.py::test_php_grouped_symbol_import_does_not_share_class_rewire[function] FAILED tests/test_php_type_resolution.py::test_php_grouped_symbol_import_does_not_share_class_rewire[const] FAILED tests/test_php_type_resolution.py::test_php_symbol_import_does_not_hide_same_named_class_import FAILED tests/test_php_type_resolution.py::test_php_mixed_group_preserves_same_named_class_and_function_imports FAILED tests/test_php_type_resolution.py::test_php_symbol_import_survives_multi_namespace_resolution_skip 7 failed in 0.59s mutation_test_exit=1 The repository gate is run after this commit so its record can certify the final commit SHA; the run artifacts carry the gate result and record path. Impact Grouped, homogeneous comma-separated, aliased, same-line, mixed, and multi-namespace function/const imports retain symbol targets and no longer influence class-style resolution. No dependency, public API, or output schema changes. Risk / rollback Internal AST/cache edges temporarily carry a private marker; public direct and aggregate extraction paths are covered for cleanup. Roll back with git revert HEAD while this commit is the branch tip. Closes none --- graphify/extract.py | 52 +++++++++++++++++++++++++++---- graphify/extractors/resolution.py | 37 +++++++++++++++++++--- 2 files changed, 78 insertions(+), 11 deletions(-) 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":