Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
45 changes: 28 additions & 17 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────
Expand Down
136 changes: 102 additions & 34 deletions graphify/extractors/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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":
Expand All @@ -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:
Expand Down
156 changes: 156 additions & 0 deletions tests/test_php_group_use_kind.py
Original file line number Diff line number Diff line change
@@ -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",
"<?php\nnamespace App\\A;\n"
"use function Vendor\\Sdk\\{Render};\n"
"class UsesGroup extends Render {}\n",
)
plain = _write(
tmp_path / "app/B/UsesPlain.php",
"<?php\nnamespace App\\B;\n"
"use function Vendor\\Sdk\\Render;\n"
"class UsesPlain extends Render {}\n",
)
result = extract([group, plain], cache_root=tmp_path)

group_bases = _targets(result, "inherits", "usesgroup")
plain_bases = _targets(result, "inherits", "usesplain")
assert len(group_bases) == 1 and len(plain_bases) == 1, (group_bases, plain_bases)
assert group_bases[0] is not None and plain_bases[0] is not None
assert group_bases[0]["label"] == "Render", group_bases[0]
assert group_bases[0]["id"] == plain_bases[0]["id"], (group_bases, plain_bases)

# The `imports` edges agree too, and no FQN-labeled class stub was minted
# for a name the files only ever imported as a function.
group_imports = _targets(result, "imports", "usesgroup")
plain_imports = _targets(result, "imports", "usesplain")
assert [n["id"] for n in group_imports] == [n["id"] for n in plain_imports], (
group_imports,
plain_imports,
)
assert "Vendor\\Sdk\\Render" not in _labels(result), sorted(_labels(result))


def test_php_group_use_const_behaves_like_the_plain_form(tmp_path: Path):
group = _write(
tmp_path / "app/A/UsesConstGroup.php",
"<?php\nnamespace App\\A;\n"
"use const Vendor\\Sdk\\{LIMIT};\n"
"class UsesConstGroup extends LIMIT {}\n",
)
plain = _write(
tmp_path / "app/B/UsesConstPlain.php",
"<?php\nnamespace App\\B;\n"
"use const Vendor\\Sdk\\LIMIT;\n"
"class UsesConstPlain extends LIMIT {}\n",
)
result = extract([group, plain], cache_root=tmp_path)

group_bases = _targets(result, "inherits", "usesconstgroup")
plain_bases = _targets(result, "inherits", "usesconstplain")
assert len(group_bases) == 1 and len(plain_bases) == 1, (group_bases, plain_bases)
assert group_bases[0] is not None and plain_bases[0] is not None
assert group_bases[0]["label"] == "LIMIT", group_bases[0]
assert group_bases[0]["id"] == plain_bases[0]["id"], (group_bases, plain_bases)
assert "Vendor\\Sdk\\LIMIT" not in _labels(result), sorted(_labels(result))


def test_php_group_use_function_with_multiple_members(tmp_path: Path):
# Both members of `use function A\{f, g};` are rejected, not just the first.
group = _write(
tmp_path / "app/A/Uses.php",
"<?php\nnamespace App\\A;\n"
"use function Vendor\\Sdk\\{Render, Compile};\n"
"class UsesFirst extends Render {}\n"
"class UsesSecond extends Compile {}\n",
)
result = extract([group], cache_root=tmp_path)

labels = _labels(result)
assert "Vendor\\Sdk\\Render" not in labels, sorted(labels)
assert "Vendor\\Sdk\\Compile" not in labels, sorted(labels)
assert {"Render", "Compile"} <= labels, sorted(labels)


def test_php_group_use_class_still_claims_the_short_name(tmp_path: Path):
# Guard against over-subtraction: a *class* group use (no keyword) must keep
# claiming its members. `App\Cms\Page` is the import; `App\Models\Page` is
# the decoy the bare-name rewire would otherwise collapse onto.
_write(
tmp_path / "app/Cms/Page.php",
"<?php\nnamespace App\\Cms;\nclass Page {}\n",
)
decoy = _write(
tmp_path / "app/Models/Page.php",
"<?php\nnamespace App\\Models;\nclass Page {}\n",
)
editor = _write(
tmp_path / "app/Edit/Editor.php",
"<?php\nnamespace App\\Edit;\n"
"use App\\Cms\\{Page};\n"
"class Editor extends Page {}\n",
)
result = extract(
[tmp_path / "app/Cms/Page.php", decoy, editor],
cache_root=tmp_path,
)

bases = _targets(result, "inherits", "editor")
assert len(bases) == 1 and bases[0] is not None, bases
assert "Cms" in bases[0].get("source_file", ""), bases[0]

decoy_ids = {
n["id"] for n in result["nodes"]
if n.get("label") == "Page" and "Models" in (n.get("source_file") or "")
}
assert decoy_ids, result["nodes"]
assert not [
e for e in result["edges"]
if e.get("relation") == "inherits" and e.get("target") in decoy_ids
], "the decoy App\\Models\\Page must get no inherits edge"
Loading