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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ Full release notes with details on each version: [GitHub Releases](https://githu
- Fix: a Java local class and a same-named external annotation (e.g. a local `class Component` and Spring's `@Component`) no longer collapse into one node (#2504, thanks @te7ina-honey). The Java type resolver now runs before the unique-label stub rewire and parks an imported-but-external type on its fully-qualified name, and cross-file import resolution checks the package. In-corpus annotation resolution is unchanged.
- Fix: `graphify callflow` now respects edge direction, so the caller/callee columns are correct (#2508, thanks @Tomaskobel). The call-flow HTML loads the graph directed and recovers direction from the stored `_src`/`_tgt` markers (consistent with the `path` fix), and indirect calls are counted.
- Fix: relational-intent verbs in a `query` ("calls", "uses", "extends", ...) no longer seat spurious seeds (#2507, thanks @filipechagas). Such a verb is excluded from the per-term seed guarantee, so a decoy matching only the verb no longer becomes a traversal root, while a verb that is a genuine symbol name can still be seeded on merit.
- Fix: a PHP `parent::`, `self::` or `static::` call no longer names its *scope* as the callee, so it can no longer bind to an unrelated method that merely shares that word as a label. The `scoped_call_expression` handler read the scope text as the callee name — right for `Helper::format()`, wrong for the three relative scopes: `parent::setUp()` minted a raw call to a callee literally named `parent`, and since no in-file definition answers that name, the fact reached the cross-file pass, which matches by normalized label — so it bound to whatever `->parent()` accessor the corpus happened to hold (on a 46k-node Laravel corpus, 1,698 fabricated inbound `calls` edges on one model's `parent()` accessor, credited with calling the `setUp()` of test classes across the corpus). Which class a relative scope denotes needs the inheritance context the raw-call facts do not carry, so the extractor refuses rather than guesses. Absolute scopes (`Helper::format()`, `\App\Foo::bar()`) are unaffected, and the refusal drops the fabricated edge without inventing a replacement: binding a relative scope to its real target needs the resolved base class, which is separate work. Extraction-side, so a PHP corpus needs re-extracting (`graphify update .`) to shed these edges.
- Fix: a sourceless stub can no longer shadow a real declaration in `explain`, and N stubs no longer count as one rival. `find_node_ambiguity` grouped the winning match tier by `source_file` to decide whether a tie was worth reporting, but every stub the extractor mints for a reference it could not resolve carries `source_file == ""` — so N unrelated stubs collapsed into a single `""` bucket and looked like N members of one file: no ambiguity was reported, and the caller answered with `matches[0]`, whichever stub `G.nodes()` happened to yield first. Reorder the graph and the same query answered with a different stub, equally confidently, while `graphify affected` on that same name refused with "No unique node match". Two changes fix it. `_find_node_tiers` drops sourceless nodes from the exact tier when that tier also holds a sourced one — a stub is a broken duplicate of the real declaration, never the better answer, and never something the caller could disambiguate anyway, since it has no path to retry with. And sourceless nodes are keyed individually in the ambiguity grouping rather than by their shared empty source, so a tier made *only* of stubs reports rivals instead of picking one silently. `affected`'s `resolve_seed` learns the same sourced-beats-sourceless rule in both its exact-label and bare-name passes, so the two commands now agree: both resolve to the sourced declaration when one exists, and both refuse when every rival is a stub. Sourced-vs-sourced ties are untouched, and a lone stub with no sourced rival still resolves as before.
- Fix: a PHP `interface`, `trait` or `enum` now mints a declaration node, exactly as a `class` does. `_PHP_CONFIG.class_types` held `class_declaration` alone, so no node was ever created for any of the three — 142 interfaces, 30 traits and 119 enums (291 declarations) on one 46k-node Laravel corpus. Every resolution pass that could canonicalize an edge therefore had nothing to land on, and the fan-in scattered three ways: the `implements`/`extends`/trait-`use` base minted a bare *sourceless* stub which, having an empty source key, kept the un-salted id and so shadowed the real name in `explain`; `Foo::CONST` fan-in fragmented across one salted per-file stub per referencing file (17 of them, holding 20 `references_constant` edges, for one interface); and `imports`/parameter-type `references` parked on the *file* node by the PSR-4 id-collision accident that makes `_make_id(FQN)` equal the file id — or, when the filename differs from the type name, on a sourceless FQN-labeled stub instead. The three kinds join `class_declaration` in `class_types`, mirroring Java and Groovy, which have always had `interface_declaration`. Two grammar details ride along: an enum's body is an `enum_declaration_list` rather than the `declaration_list` every other PHP declaration uses, so `body_fallback_child_types` learns it; and the `_resolve_php_type_references` raw-scan — which reads the written extends/implements/`use` text so a qualified name is resolved as written instead of guessed — scanned `class_declaration` bodies only, so `interface Reader extends Sub\Repo` and `enum Status { use Sub\Describes; }` recorded nothing and fell through to the same-namespace fallback, silently binding a rival `App\Contracts\Repo` / `App\Enums\Describes`. It now scans all four declaration kinds and both body shapes.
- Behavior change riding on the above: methods of a PHP interface, trait or enum move from file-scoped ids to type-scoped ids, and their labels gain the leading dot that a class member has always carried — `app_repo_foorepository_find` / `find()` becomes `app_repo_foorepository_foorepository_find` / `.find()`, hanging off the new declaration node rather than directly off the file. A PHP corpus must be re-extracted (`graphify update .`) to pick this up, and re-extraction is genuinely required rather than merely advisable, because AST cache entries are keyed by content hash within the version namespace — a same-version rebuild replays the pre-fix nodes untouched. `graphify update` re-extracts the whole code corpus in one pass, so it lands consistently; a *hook-driven* incremental rebuild against a graph built before this release will drop edges from files it did not re-extract to the old ids (the merge requires both endpoints live, so a stale-id edge is dropped, never repointed — loss, never misdirection), and the next full update restores them.

## 0.9.34 (2026-08-05)

Expand Down
31 changes: 30 additions & 1 deletion graphify/affected.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,22 @@ def _prefer_file_node(
return None


def _is_sourced(graph: nx.Graph, node_id: str) -> bool:
return bool(str(graph.nodes[node_id].get("source_file") or ""))


def _prefer_sourced_node(graph: nx.Graph, node_ids: list[str]) -> str | None:
"""Return the one node with a source file among label rivals, else None.

Mirrors serve's `_find_node_tiers` exact-tier rule: a sourceless node is
an unresolved-reference placeholder, so a real declaration sharing its label is
the answer rather than a tie. Without this, `explain` resolved the sourced node
while `affected` refused with "No unique node match".
"""
sourced = [node_id for node_id in node_ids if _is_sourced(graph, node_id)]
return sourced[0] if len(sourced) == 1 else None


def resolve_seed(graph: nx.Graph, query: str) -> str | None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionresolve_seed()

16 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

# A trailing path separator must not change a source-file match — serve's
# _find_node tokenizes the path (which drops it), so strip it here for parity
Expand All @@ -109,8 +125,17 @@ def resolve_seed(graph: nx.Graph, query: str) -> str | None:
for node_id, data in graph.nodes(data=True)
if _normalize_label(str(data.get("label", ""))) == query_lower
]
if len(exact_label_matches) == 1:
if len(exact_label_matches) == 1 and _is_sourced(graph, exact_label_matches[0]):
return exact_label_matches[0]
if exact_label_matches:
sourced = _prefer_sourced_node(graph, exact_label_matches)
if sourced is not None:
return sourced
# A lone match that is a sourceless stub is NOT yet an answer: the real
# declaration may carry a decorated label ("handle()") that only the
# bare-name pass below reaches, and serve's exact tier — which matches
# both forms — would prefer it. That pass is a superset of this
# one, so a stub with no sourced rival still resolves there.
# Callable labels are decorated ("name()"), so a bare "name" query falls
# through exact matching and then ties with any "name*" sibling in the
# contains pass. Match on the undecorated name before giving up.
Expand All @@ -122,6 +147,10 @@ def resolve_seed(graph: nx.Graph, query: str) -> str | None:
]
if len(bare_name_matches) == 1:
return bare_name_matches[0]
if bare_name_matches:
sourced = _prefer_sourced_node(graph, bare_name_matches)
if sourced is not None:
return sourced
exact_source_matches = [
str(node_id)
for node_id, data in graph.nodes(data=True)
Expand Down
17 changes: 15 additions & 2 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -921,7 +921,17 @@ def _get_c_func_name(node, source: bytes) -> str | None:
_PHP_CONFIG = LanguageConfig(
ts_module="tree_sitter_php",
ts_language_fn="language_php",
class_types=frozenset({"class_declaration"}),
# `interface`, `trait` and `enum` are declaration kinds exactly like `class`
# (all four carry a `name` field and a body of member declarations), so they
# mint definition nodes too — mirroring Java (above) and Groovy. Leaving them
# out meant no PHP interface/trait/enum ever had a canonical node, so every
# resolution pass had nothing to land on: implements/mixes_in kept bare
# sourceless stubs, `Foo::CONST` fan-in fragmented across per-file stubs, and
# imports/param-type references parked on the *file* node or a FQN stub.
class_types=frozenset({
"class_declaration", "interface_declaration", "trait_declaration",
"enum_declaration",
}),
function_types=frozenset({"function_definition", "method_declaration"}),
import_types=frozenset({"namespace_use_clause"}),
call_types=frozenset({"function_call_expression", "member_call_expression", "scoped_call_expression", "class_constant_access_expression"}),
Expand All @@ -933,7 +943,10 @@ def _get_c_func_name(node, source: bytes) -> str | None:
call_accessor_node_types=frozenset({"member_call_expression"}),
call_accessor_field="name",
name_fallback_child_types=("name",),
body_fallback_child_types=("declaration_list", "compound_statement"),
# An enum's body is an `enum_declaration_list`, not a `declaration_list`; it
# is reachable through the `body` field, but the fallback has to know it too
# for the paths that scan children by type.
body_fallback_child_types=("declaration_list", "enum_declaration_list", "compound_statement"),
function_boundary_types=frozenset({"function_definition", "method_declaration"}),
import_handler=_import_php,
)
Expand Down
15 changes: 14 additions & 1 deletion graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,11 @@ def _php_name_text(node, source: bytes) -> str | None:
return None
return _read_text(node, source).rsplit("\\", 1)[-1] or None

Comment on lines 583 to 584

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relative-scope PHP calls (parent::/self::/static::) silently dropped — agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review

Graphify suggests a fix:

Suggested change
return _read_text(node, source).rsplit("\\", 1)[-1] or None
text = _read_text(node, source).rsplit("\\", 1)[-1]
if not text:
return None
# PHP keywords are case-insensitive; relative scopes (parent::/self::/static::)
# name no concrete class, so report them as "no scope" and let the caller emit
# an unqualified call instead of dropping the fact entirely.
if text.lower() in _PHP_RELATIVE_SCOPE_NAMES:
return None
return text

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked into this properly — measured rather than reasoned about — and I'd like to decline it, for two independent reasons.

The suggested edit is anchored on a function this path never calls. It patches _php_name_text (engine.py:579-586), but the scoped_call_expression branch reads its scope through _read_text(scope_node, source) at engine.py:4386; _php_name_text is called only at 600, 606, 2715, 2720 and 2734. Applied literally, the edit changes _php_collect_type_refs and _php_emit_base and leaves relative-scope calls untouched — while removing the references edges that function make(): static currently mints for static/self/parent, as an unreviewed side effect.

Applied as intended, it reintroduces the defect in a different place. I patched the actual branch so a relative scope emits the method name as an unqualified callee, and re-ran extraction on a PHPUnit-shaped corpus (base class out of corpus, one unrelated in-corpus setUp). Baseline: 0 edges. With the change: one INFERRED calls edge, FooTest::prepare()App\Models\ServiceCategory::setUp(). The raw call has is_member_call=False, so it is not skipped at extract.py:5572 and is not claimed by any typed resolver — it falls to the shared normalized-label loop at extract.py:5564+, whose index keys .setUp() and setUp() identically, making a single same-named method anywhere in the corpus an unconditional single-candidate bind. That is the same fabricated-edge shape as the bug this commit fixes, aimed at a different label. A corpus with two same-named candidates produced 0 edges in both trees, so the damage lands precisely where the bind is most confident.

And it cannot be resolved correctly at that point. The raw-call record carries caller, callee, receiver and receiver type — no enclosing or base class. The inherits edges that would answer "which class does parent denote" are never consulted by that loop, so an unqualified callee can only be name-matched. Refusing is also what the pipeline already does with every PHP member call it cannot type (extract.py:5572), so this keeps the existing policy rather than introducing a new class of drop.

On "silently": fair as a description of runtime behaviour, and the drop has no diagnostic counter. It is not undocumented, though — four tests in tests/test_php_relative_scope_calls.py pin it (including a positive control that absolute scoped calls still resolve), and the changelog entry states it outright. Binding a relative scope to its real target needs a base-class-aware resolution pass; I've opened that as its own issue on our fork rather than land an edge that is wrong in a different direction, and I'm happy to pick it up here if you'd want it.

# PHP's relative scopes. Each is resolvable only against the inheritance context
# of the class it is written in, which the raw-call facts do not carry, so none of
# them ever names a concrete callee.
_PHP_RELATIVE_SCOPE_NAMES = frozenset({"self", "static", "parent"})

def _php_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None:
"""Walk a PHP type expression; append (name, role) tuples."""
if node is None:
Expand Down Expand Up @@ -4378,7 +4383,15 @@ def walk_calls(
# Static method call: Helper::format() → callee = "Helper"
scope_node = node.child_by_field_name("scope")
if scope_node:
callee_name = _read_text(scope_node, source)
scope_text = _read_text(scope_node, source)
# `parent::m()` / `self::m()` / `static::m()` name no
# callee: which class the scope denotes needs inheritance
# context the raw-call facts do not carry. Naming
# the scope anyway let the cross-file label match bind
# them to any unrelated `->parent()` method in the
# corpus. Absolute scopes are unaffected.
if scope_text.lower() not in _PHP_RELATIVE_SCOPE_NAMES:
callee_name = scope_text
else:
# member_call_expression: $obj->method()
is_member_call = True
Expand Down
13 changes: 11 additions & 2 deletions graphify/extractors/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -2489,6 +2489,15 @@ def _external_stub(fqn: str) -> str:
_PHP_SUPERTYPE_RELATIONS = ("inherits", "implements", "mixes_in")
_PHP_REPOINT_RELATIONS = frozenset({"inherits", "implements", "mixes_in", "imports", "references"})

# Every PHP declaration kind that mints a definition node (`_PHP_CONFIG.class_types`)
# and can therefore carry an extends/implements/`use`-trait clause. The raw-scan
# below used to read `class_declaration` only, so `interface Reader extends
# Sub\Repo` recorded no raw text and fell through to the same-namespace guess —
# which resolves to the wrong `Repo` when both exist.
_PHP_DECLARATION_TYPES = frozenset({
"class_declaration", "interface_declaration", "trait_declaration", "enum_declaration",
})


def _php_fqn_from_raw(raw: str, ns: str, uses: dict[str, str]) -> str:
"""Resolve a raw (possibly qualified) PHP class reference to an FQN.
Expand Down Expand Up @@ -2617,7 +2626,7 @@ def walk(n) -> None:
if c.type == "namespace_use_clause":
_record_use_clause(c, prefix)
return
elif t == "class_declaration":
elif t in _PHP_DECLARATION_TYPES:
for child in n.children:
if child.type == "base_clause":
for sub in child.children:
Expand All @@ -2627,7 +2636,7 @@ def walk(n) -> None:
for sub in child.children:
if sub.type in ("name", "qualified_name"):
_record_raw("implements", _read_text(sub, source))
elif child.type == "declaration_list":
elif child.type in ("declaration_list", "enum_declaration_list"):
for member in child.children:
if member.type != "use_declaration":
continue
Expand Down
26 changes: 25 additions & 1 deletion graphify/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -1217,6 +1217,16 @@ def _find_node_tiers(
elif term in norm_label or term in label_tokens or norm_query in norm_label:
substring.append(nid)

# A sourceless node is a placeholder the extractor minted for a reference it
# could not resolve (an unresolved base type, a dangling import target). When
# a real, sourced declaration carries the same label, the stub is a broken
# duplicate of it — never the better answer, and never something the caller
# could disambiguate anyway, since it has no path to retry with. Drop the
# stubs so the exact tier holds only real declarations.
sourced_exact = [nid for nid in exact if str(G.nodes[nid].get("source_file") or "")]
if sourced_exact:
exact = sourced_exact

if source_exact:
query_basename = _strip_diacritics(Path(label).name).lower()
preferred = []
Expand Down Expand Up @@ -1258,6 +1268,19 @@ def find_node_ambiguity(G: nx.Graph, label: str) -> list[str]:
tier is split that way, else `[]`. Several matches *within one file* (a file
node plus its members) are ordinary precedence, not ambiguity, and return `[]`.

Sourceless nodes are the exception to the per-file grouping, in *every* tier:
each one counts as its own rival. They all carry `source_file == ""`, so keying
them by source made N unrelated stub nodes look like N members of a single
file — the 18 stubs shadowing `BalanceitemRepository` reported no ambiguity at
all, and the caller answered with `matches[0]`.

Note the two halves of this fix have different reach. `_find_node_tiers` drops
stubs from a mixed tier only for the *exact* tier, so an exact tier arrives
here already reduced to real declarations and the per-stub keying changes
nothing for it. A winning prefix or substring tier is not reduced and can
still arrive mixed; there the keying is what stops its stubs from hiding
behind one another.

`_disambiguate_file_node_labels` (#2032) already relabels colliding *file*
nodes; this covers the symbol case it does not reach.
"""
Expand All @@ -1267,7 +1290,8 @@ def find_node_ambiguity(G: nx.Graph, label: str) -> list[str]:
by_source: dict[str, str] = {}
for nid in tier:
source = str(G.nodes[nid].get("source_file") or "")
by_source.setdefault(source, nid)
# "\0" can't occur in a path, so a stub never joins a real file's group.
by_source.setdefault(source or "\0" + nid, nid)
return list(by_source.values()) if len(by_source) > 1 else []
return []

Expand Down
Loading