diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d264a115..9130e80917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ 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/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..f3c7800543 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -2414,6 +2414,102 @@ 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, +) -> 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* — both spellings + yield ``use_kind == "function"`` here. + """ + 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"): + 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 +2569,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 +2577,12 @@ 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): + 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_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", + " 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", + "