From a149cb548de63c3c9996caaedbc177b5f4360b4b Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Thu, 6 Aug 2026 08:17:01 -0300 Subject: [PATCH 1/2] feat(php): capture use FQN/alias/kind metadata on imports edges PHP `imports` edges now carry `use_kind` / `alias` / `target_fqn` metadata, mirroring `_import_csharp`. The already-correct `use`-parser inside `_resolve_php_type_references` was extracted into shared helpers (`_php_use_clause_fact`, `_php_use_clause_context`, `_php_use_declaration_facts`) consumed by both the resolution pass and the capture path, replacing `_import_php`'s lossy `raw.split("\\")[-1]`. Group use `use A\{B, C as X};`, aliases, leading-backslash absolutes and `use function` / `use const` are all handled in one place, so a clause dispatched on its own (as `_import_php` is) can still spell its own FQN by reading the group prefix and keyword off the parent declaration. Strictly metadata-only: no resolver behavior change, `_PHP_CONFIG.import_types` untouched, edge targets still keyed on the imported short name. Full `extract()` output with metadata stripped, before vs after, over a corpus covering plain / aliased / group / aliased-group / `function` / `const` / group-function / group-const / leading-backslash `use`, trait `use`, inheritance, interfaces and a typed member call: 16 nodes / 27 edges, byte-identical (sha256 f6c6168f). Group-form `use function A\{f, g};` and `use const A\{K};` put the keyword on the declaration rather than the clause, so those names enter the class-name map today; that pre-existing bug is deliberately preserved bit-for-bit here via `apply_declaration_kind=False` and fixed in the follow-up commit. The new metadata already reports the correct kind. Note for consumers: the `use_kind` vocabulary is `class`/`function`/`const` with `alias` as a separate key (unlike C#'s `using_kind == "alias"`), and `_resolve_php_type_references` re-points `imports` edges without touching metadata, so `metadata.target_fqn` is the reliable read rather than the target node's label. Tests: 8 new, all through the public `extract()` seam; 7 failed against unfixed code (the 8th is the targets-unchanged guard, green by construction). Full suite 3984 passed / 36 skipped (baseline 3976/36 + 8). Adapted from fork PR https://github.com/lawnstarter/graphify/pull/29 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + graphify/extract.py | 45 +++++---- graphify/extractors/resolution.py | 144 +++++++++++++++++++++------- tests/test_php_use_imports.py | 152 ++++++++++++++++++++++++++++++ 4 files changed, 291 insertions(+), 51 deletions(-) create mode 100644 tests/test_php_use_imports.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d264a115..26c83830b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: `graphify merge-graphs` no longer drops hyperedges (#2484, thanks @sortakool, and @oleksii-tumanov for the approach in #1691). Hyperedge member ids and ids are now relabeled with the per-repo prefix, both inputs' hyperedges are unioned instead of one clobbering the other, and they are written to both the top-level and nested slots. - Fix: `build_from_json` now reads hyperedges from both the top-level and nested `graph` slots, so label and re-cluster runs no longer silently empty a graph's hyperedge set (#2485, thanks @sortakool); a full validation wipeout is now reported loudly. - Fix: the skill flow now passes the curated community labels to `to_json`, so `graph.json` ships with `community_name` on nodes instead of dropping it (#2490, thanks @PapiScholz). +- PHP `imports` edges now carry `use_kind` (`class`/`function`/`const`), `alias` and `target_fqn` metadata, mirroring the C# `using` capture. The `use`-statement parser is now shared between the capture path and the PHP type-reference pass, so group use `use A\{B, C as X};`, aliases and leading-backslash absolutes are spelled out once. Edge targets are unchanged — they stay keyed on the imported short name — so this is additive for existing consumers; note that the type-reference pass re-points `imports` edges without rewriting metadata, making `metadata.target_fqn` the reliable read rather than the target node's label. ## 0.9.33 (2026-08-05) diff --git a/graphify/extract.py b/graphify/extract.py index dc7540d5fa..cb7d80adc3 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -106,6 +106,8 @@ _pascal_resolve_class, _pascal_resolve_unit, _pascal_unit_cache, + _php_use_clause_context, + _php_use_clause_fact, _pnpm_workspace_globs, _python_call_identifier, _python_import_from_module, @@ -662,23 +664,32 @@ def _import_scala(node, source: bytes, file_nid: str, stem: str, edges: list, st 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"): - raw = _read_text(child, source) - module_name = raw.split("\\")[-1].strip() - if module_name: - tgt_nid = _make_id(module_name) - edges.append({ - "source": file_nid, - "target": tgt_nid, - "relation": "imports", - "context": "import", - "confidence": "EXTRACTED", - "source_file": str_path, - "source_location": f"L{node.start_point[0] + 1}", - "weight": 1.0, - }) - break + # `node` is a single `namespace_use_clause`; the group-use prefix and the + # `function`/`const` keyword of a group use live on the parent declaration, + # so the clause alone cannot spell its own FQN. Shared parser with + # `_resolve_php_type_references` — see resolution.py. + fact = _php_use_clause_fact(node, source, *_php_use_clause_context(node, source)) + if fact is None: + return + target_fqn, alias, use_kind = fact + # The edge target stays keyed on the imported short name: re-pointing it is + # the resolvers' job (`_resolve_php_type_references`), not the capture's. + module_name = target_fqn.rsplit("\\", 1)[-1].strip() + if not module_name: + return + edges.append({ + "source": file_nid, + "target": _make_id(module_name), + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + "metadata": sanitize_metadata({k: v for k, v in + {"use_kind": use_kind, "alias": alias, "target_fqn": target_fqn}.items() + if v is not None}), + }) # ── C/C++ function name helpers ─────────────────────────────────────────────── diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 097c32b6a7..7ce1f87364 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -2414,6 +2414,108 @@ def _php_fqn_from_raw(raw: str, ns: str, uses: dict[str, str]) -> str: return f"{ns}\\{raw}" if ns else raw +# ── Shared PHP `use`-statement parser ───────────────────────────────────────── +# One parser for both consumers: the `uses_by_file` map below and the `imports` +# edge capture in `_import_php` (extract.py). Group use `use A\{B, C as X};`, +# aliases, leading-backslash absolutes and `use function` / `use const` are all +# handled here so neither consumer has to re-derive them. + +def _php_use_clause_fact( + clause, + source: bytes, + prefix: str = "", + kind: str = "class", +) -> tuple[str, str | None, str] | None: + """Parse one ``namespace_use_clause`` into ``(target_fqn, alias, use_kind)``. + + ``prefix`` is the group-use prefix (empty for a standalone clause) and + ``kind`` the declaration-level ``function``/``const`` keyword, if any; a + clause-level keyword overrides it. Returns ``None`` when the clause names + no target (e.g. a parse error). + """ + target: str | None = None + alias: str | None = None + saw_as = False + for c in clause.children: + if c.type in ("function", "const"): + kind = c.type + elif c.type == "as": + saw_as = True + elif c.type in ("qualified_name", "name"): + if saw_as: + alias = _read_text(c, source) + elif target is None: + target = _read_text(c, source) + if not target: + return None + fqn = (f"{prefix}\\{target}" if prefix else target).lstrip("\\") + return fqn, alias, kind + + +def _php_use_clause_context(clause, source: bytes) -> tuple[str, str]: + """``(group prefix, use kind)`` a ``namespace_use_clause`` inherits from its + parent ``namespace_use_declaration``. + + For consumers that are dispatched per clause and never see the declaration + (`_import_php`). The prefix only applies to clauses inside a + ``namespace_use_group``; a standalone clause carries its own full name. + """ + parent = getattr(clause, "parent", None) + in_group = parent is not None and parent.type == "namespace_use_group" + decl = parent.parent if in_group else parent + prefix, kind = "", "class" + if decl is None or decl.type != "namespace_use_declaration": + return prefix, kind + for c in decl.children: + if c.type == "namespace_name" and in_group: + prefix = _read_text(c, source) + elif c.type in ("function", "const"): + kind = c.type + return prefix, kind + + +def _php_use_declaration_facts( + decl, + source: bytes, + *, + apply_declaration_kind: bool = True, +) -> list[tuple[str, str | None, str]]: + """Every ``(target_fqn, alias, use_kind)`` a ``namespace_use_declaration`` declares. + + ``use function A\\f;`` puts the keyword on the *clause*, while + ``use function A\\{f, g};`` puts it on the *declaration*. + ``_resolve_php_type_references`` has only ever honored the clause-level + keyword, so it passes ``apply_declaration_kind=False`` to keep that + behavior byte-identical while this change stays metadata-only. New + consumers should leave it on. + """ + prefix, kind, group = "", "class", None + direct = [] + for c in decl.children: + if c.type == "namespace_name": + prefix = _read_text(c, source) + elif c.type in ("function", "const"): + if apply_declaration_kind: + kind = c.type + elif c.type == "namespace_use_group": + group = c + elif c.type == "namespace_use_clause": + direct.append(c) + + facts: list[tuple[str, str | None, str]] = [] + for c in direct: + fact = _php_use_clause_fact(c, source, "", kind) + if fact: + facts.append(fact) + if group is not None: + for c in group.children: + if c.type == "namespace_use_clause": + fact = _php_use_clause_fact(c, source, prefix, kind) + if fact: + facts.append(fact) + return facts + + def _resolve_php_type_references( per_file: list[dict], paths: list[Path], @@ -2473,27 +2575,6 @@ def _record_raw(relation: str, raw: str) -> None: else: raws.setdefault(key, raw) - def _record_use_clause(clause, prefix: str) -> None: - 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": - saw_as = True - elif c.type in ("qualified_name", "name"): - if saw_as: - alias = _read_text(c, source) - elif target is None: - target = _read_text(c, source) - if not target: - return - fqn = (f"{prefix}\\{target}" if prefix else target).lstrip("\\") - key = (alias or fqn.rsplit("\\", 1)[-1]).strip().lower() - if key: - uses.setdefault(key, fqn) - def walk(n) -> None: t = n.type if t == "namespace_definition": @@ -2502,19 +2583,14 @@ def walk(n) -> None: namespaces.append(_read_text(c, source)) break elif t == "namespace_use_declaration": - prefix = "" - group = 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, "") - if group is not None: - for c in group.children: - if c.type == "namespace_use_clause": - _record_use_clause(c, prefix) + for fqn, alias, use_kind in _php_use_declaration_facts( + n, source, apply_declaration_kind=False + ): + if use_kind != "class": + continue # `use function` / `use const` are not class imports + key = (alias or fqn.rsplit("\\", 1)[-1]).strip().lower() + if key: + uses.setdefault(key, fqn) return elif t == "class_declaration": for child in n.children: diff --git a/tests/test_php_use_imports.py b/tests/test_php_use_imports.py new file mode 100644 index 0000000000..933e9be40e --- /dev/null +++ b/tests/test_php_use_imports.py @@ -0,0 +1,152 @@ +"""PHP `use`-import capture. + +Every assertion goes through the public `extract()` seam. These are +metadata-shape tests: the `imports` edges themselves (their targets) must stay +exactly as they were — only `target_fqn` / `alias` / `use_kind` are new. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _import_facts(result: dict) -> set[tuple[str | None, str | None, str | None]]: + """(target_fqn, alias, use_kind) for every `imports` edge in the graph.""" + facts = set() + for e in result["edges"]: + if e.get("relation") != "imports": + continue + md = e.get("metadata") or {} + facts.add((md.get("target_fqn"), md.get("alias"), md.get("use_kind"))) + return facts + + +def _labels(result: dict) -> set[str]: + return {n.get("label") for n in result["nodes"]} + + +def test_php_plain_use_captures_target_fqn(tmp_path: Path): + f = _write( + tmp_path / "app/Http/I.php", + " Date: Thu, 6 Aug 2026 08:17:06 -0300 Subject: [PATCH 2/2] fix(php): stop group-form use function/const from claiming class names Group-form `use function A\{f, g};` and `use const A\{K};` put the keyword on the *declaration* node, not the clause, so those names wrongly entered `_resolve_php_type_references`'s class-name map. A group-imported function or constant whose short name was also used in a class position in the same file therefore re-pointed that `inherits`/`implements`/`mixes_in`/`imports`/ `references` edge onto an external stub labeled with an FQN that names a function or a constant, not a class. The shared parser added in the previous commit already computed the correct kind behind an `apply_declaration_kind=False` compatibility flag, which existed only to keep that commit metadata-only. This removes the flag and its call site, leaving one code path that always honors the declaration-level keyword, so both spellings agree. Strictly subtractive: it can only remove a class-name claim, never add one. The reference then falls back to the namespace-relative FQN or to the legacy unique-label rewire, exactly as the unbraced form always did. Pre-existing, and rare in practice because it needs the same short name used both as a group-imported function/constant and in a class position within one file. Tests: 4 new, all through the public `extract()` seam, each braced form paired with its semantically equivalent unbraced control; 3 failed against unfixed code, and the over-subtraction guard (`use App\Cms\{Page};` still claims the class name, decoy `App\Models\Page` gets no edge) passes on both sides by design. Full suite 3988 passed / 36 skipped (3984/36 + 4). `grep -rn apply_declaration_kind` across the repo now returns nothing. Adapted from fork PR https://github.com/lawnstarter/graphify/pull/30 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + graphify/extractors/resolution.py | 16 +-- tests/test_php_group_use_kind.py | 156 ++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 tests/test_php_group_use_kind.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 26c83830b9..9130e80917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: `graphify merge-graphs` no longer drops hyperedges (#2484, thanks @sortakool, and @oleksii-tumanov for the approach in #1691). Hyperedge member ids and ids are now relabeled with the per-repo prefix, both inputs' hyperedges are unioned instead of one clobbering the other, and they are written to both the top-level and nested slots. - Fix: `build_from_json` now reads hyperedges from both the top-level and nested `graph` slots, so label and re-cluster runs no longer silently empty a graph's hyperedge set (#2485, thanks @sortakool); a full validation wipeout is now reported loudly. - Fix: the skill flow now passes the curated community labels to `to_json`, so `graph.json` ships with `community_name` on nodes instead of dropping it (#2490, thanks @PapiScholz). +- Fix: a group-form `use function A\{f, g};` or `use const A\{K};` no longer claims `f`, `g` or `K` as an imported CLASS name. tree-sitter-php puts the `function`/`const` keyword on the *declaration* for the braced form but on the *clause* for the unbraced one, and the PHP type-reference pass only ever inspected the clause — so a group-imported function or constant whose short name was also used in a class position in the SAME file re-pointed that `inherits`/`implements`/`mixes_in`/`imports`/`references` edge onto an external stub labeled with an FQN that names a function or a constant, not a class. Both spellings now agree: the reference falls back to the namespace-relative FQN or to the legacy unique-label rewire, exactly as the unbraced form always did. Strictly subtractive — it can only REMOVE a class-name claim, never add one — and it needs the same short name used both ways in one file, which is why it is rare in practice. - PHP `imports` edges now carry `use_kind` (`class`/`function`/`const`), `alias` and `target_fqn` metadata, mirroring the C# `using` capture. The `use`-statement parser is now shared between the capture path and the PHP type-reference pass, so group use `use A\{B, C as X};`, aliases and leading-backslash absolutes are spelled out once. Edge targets are unchanged — they stay keyed on the imported short name — so this is additive for existing consumers; note that the type-reference pass re-points `imports` edges without rewriting metadata, making `metadata.target_fqn` the reliable read rather than the target node's label. ## 0.9.33 (2026-08-05) diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 7ce1f87364..f3c7800543 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -2477,17 +2477,12 @@ def _php_use_clause_context(clause, source: bytes) -> tuple[str, str]: def _php_use_declaration_facts( decl, source: bytes, - *, - apply_declaration_kind: bool = True, ) -> list[tuple[str, str | None, str]]: """Every ``(target_fqn, alias, use_kind)`` a ``namespace_use_declaration`` declares. ``use function A\\f;`` puts the keyword on the *clause*, while - ``use function A\\{f, g};`` puts it on the *declaration*. - ``_resolve_php_type_references`` has only ever honored the clause-level - keyword, so it passes ``apply_declaration_kind=False`` to keep that - behavior byte-identical while this change stays metadata-only. New - consumers should leave it on. + ``use function A\\{f, g};`` puts it on the *declaration* — both spellings + yield ``use_kind == "function"`` here. """ prefix, kind, group = "", "class", None direct = [] @@ -2495,8 +2490,7 @@ def _php_use_declaration_facts( if c.type == "namespace_name": prefix = _read_text(c, source) elif c.type in ("function", "const"): - if apply_declaration_kind: - kind = c.type + kind = c.type elif c.type == "namespace_use_group": group = c elif c.type == "namespace_use_clause": @@ -2583,9 +2577,7 @@ def walk(n) -> None: namespaces.append(_read_text(c, source)) break elif t == "namespace_use_declaration": - for fqn, alias, use_kind in _php_use_declaration_facts( - n, source, apply_declaration_kind=False - ): + for fqn, alias, use_kind in _php_use_declaration_facts(n, source): if use_kind != "class": continue # `use function` / `use const` are not class imports key = (alias or fqn.rsplit("\\", 1)[-1]).strip().lower() diff --git a/tests/test_php_group_use_kind.py b/tests/test_php_group_use_kind.py new file mode 100644 index 0000000000..f4f2f3177a --- /dev/null +++ b/tests/test_php_group_use_kind.py @@ -0,0 +1,156 @@ +"""Group-form `use function` / `use const` must not claim class names. + +tree-sitter-php puts the `function` / `const` keyword on the *clause* for the +plain form (`use function A\\f;`) but on the *declaration* for the group form +(`use function A\\{f, g};`). `_resolve_php_type_references` only ever inspected +the clause, so group-form members wrongly entered the per-file class-name map +and re-pointed supertype references onto an FQN-labeled external stub. + +Every assertion goes through the public `extract()` seam, with the semantically +equivalent plain form as the side-by-side control. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _node_by_id(result: dict, nid: str) -> dict | None: + return next((n for n in result["nodes"] if n.get("id") == nid), None) + + +def _labels(result: dict) -> set[str]: + return {n.get("label") for n in result["nodes"]} + + +def _targets(result: dict, relation: str, source_substr: str) -> list[dict]: + """Target nodes of every `relation` edge coming out of a matching source.""" + return [ + _node_by_id(result, e["target"]) + for e in result["edges"] + if e.get("relation") == relation + and source_substr in e.get("source", "").lower() + ] + + +def test_php_group_use_function_behaves_like_the_plain_form(tmp_path: Path): + # `use function Vendor\Sdk\{Render};` imports a *function*, so `Render` in a + # class position is not an explicitly imported class name. The braced form + # must land exactly where the unbraced control lands: on the bare stub the + # legacy unique-label rewire owns, never on an FQN-labeled external stub. + group = _write( + tmp_path / "app/A/UsesGroup.php", + "