fix(php): member-call follow-ups — FCC relation, union refusal, qualified receiver types, use-claim refusal - #2505
Conversation
…ype (#2) PHP member calls were resolved by bare method name only, so a Laravel-style `$this->service->method()` either linked nothing or bound to whichever same-named method happened to be in the file. Cut the full path for the narrowest receiver family — `this` and `this.<prop>`. Extraction (engine.py): - capture the receiver of member/nullsafe member calls as `this` or `this.<prop>`; anything else stays uncaptured (behavior unchanged) - build a per-class table of concrete property types from typed properties and constructor-promoted params; unions, intersections, primitives and self/static/parent are refused, `?Foo` unwraps to Foo - stamp `lang: "php"` and the resolved `receiver_type` on raw calls - defer the in-file bare-name match only when a receiver type was actually stamped, so plain `$this->m()` and untyped receivers keep today's edges Resolution (extract.py): - `nullsafe_member_call_expression` joins the PHP call types - new `_resolve_php_member_calls`, a case-insensitive clone of the Java pass: exactly one type definition in the corpus and exactly one matching method, or no edge at all — never a bare-name fallback - registered as the `php_member_calls` language resolver Edges are INFERRED (0.8) for typed receivers and EXTRACTED (1.0) for `this`. Refs #2 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An inline instantiation names its class outright, so the receiver needs no type table — but the lookup that finds the class node still goes by SHORT name and ignores the namespace. Treating every inline new as exact would label a mis-bound short name EXTRACTED, so the namespace is checked as independent evidence. Extraction (engine.py): - capture `(new X())->m()` / `(new \NS\X())->m()` as the `(new)` receiver key, keeping the short name for lookup and the written text for corroboration - `new self()` / `new static()` / `new parent()` are refused by the same non-concrete type-name set as declared types - anonymous classes carry no name node at all (probe-verified on tree-sitter-php 0.24.1), so the receiver stays uncaptured and the call is inert; a bare `new X();` statement is still not a call node Resolution (extract.py): - new `_php_qualified_corroborates`: every segment of the written name must line up, case-insensitively, with the tail of the resolved node's path (PSR-4). A bare name corroborates nothing; a mismatching namespace downgrades to INFERRED rather than refusing, since the class name itself still resolved unambiguously Refs #3 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…#4) `$svc = new Service(); $svc->method()` and `function handle(Service $svc)` now carry a receiver type, so the call binds to the declared class instead of the first same-named method in the corpus. Raw calls retain no lexical scope, which makes shadowing the hard part: a call written inside a closure is attributed to the enclosing method, so a closure parameter reusing an outer name is indistinguishable from the outer binding. Rather than guess, `_php_method_receiver_types` POISONS any name whose binding is not provably single-typed and drops it from the table: - rebind to anything but a `new`, or two conflicting `new` types - augmented assignment (`$svc ??= new Other()`) - closure and arrow-function parameters shadowing the name - foreach targets, including `$k => &$v` and destructured elements - list destructuring, `[$a, [$b]] = …` and `list(…) = …` alike Anonymous-class bodies are skipped outright — a `new` inside one belongs to a different scope and must not type the enclosing method's variables. Variadic params are left unbound (`T ...$xs` is an array of T, not a T), and `self` / `static` in type position reuse the non-concrete name set. The bare `$var->m()` receiver key also required carving PHP out of the shared capitalized-receiver defer rule: PHP receivers are never bare class names, so that rule could only have stripped in-file edges off an untypable `$Svc->m()`. Chained receivers stay inert — `$a->b()->c()` resolves the inner call and leaves the outer one alone. Refs #4 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… TypeScript member-call resolvers (#6) The Swift, Python and TypeScript resolvers walked every raw call in the corpus and claimed any entry with `is_member_call`, regardless of which language produced it. Since #2 stamped `lang: "php"` and a truthy receiver on PHP raw calls, a mixed PHP/Python corpus put foreign receiver data in front of three resolvers that had no way to tell it apart from their own. Add a `lang` tag skip at the top of each of the three loops. The extractor stamps `lang` for cpp, csharp, java and php (engine.py) and objc stamps its own (extractors/objc.py); Swift, Python and TypeScript raw calls carry no tag, so "tagged" is exactly "not mine". This also shuts the pre-existing path for objc-tagged raw calls, whose receivers ARE capitalized and so could reach the Python resolver's class arm. The Ruby resolver is deliberately untouched: ruby_resolution.py:47-48 already filters raw calls to `.rb`/`.rake` source files, so a `.php` entry cannot reach it. Tests: a mixed-corpus `extract()` test (.php + .py in one call) asserting a PHP receiver mints no edge into an identically named Python method, plus a positive control proving the skip did not simply disable the Python resolver. Scope note: with PHP's current receiver forms this guard is defensive rather than corrective. `engine.py:4410-4426` only ever emits `this` or `this.<prop>`, neither of which is capitalized, so no PHP raw call reaches the Python class arm today and both new tests pass with or without this change. The reachable cross-language leak found while verifying #6 has a different root cause -- the corpus-global, language-unscoped `type_def_nids` index inside _resolve_php_member_calls (extract.py:3068-3075) and its objc twin (extract.py:3211) -- and is left for a follow-up rather than widened into #2's resolver here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…llision (#5) PHP `interface_declaration` is not in `_PHP_CONFIG.class_types`, so an interface mints no definition node. That looked safe — an interface-typed receiver simply found nothing — but it is not: Laravel's Contracts convention routinely puts `App\Contracts\Notifier` beside an unrelated `App\Support\Notifier` class, and then exactly ONE definition exists under that short name. The single-definition guard cannot see a problem, so the receiver silently bound to a total stranger. Measured before the fix: all three receiver entry points — typed property, typed parameter (#4) and inline new (#3) — minted the wrong edge in that corpus. Pre-scan interface names per file (the C# `_csharp_pre_scan_interfaces` pattern), thread them out on the extractor result, and refuse in the resolver any receiver type whose name matches one, case-insensitively. The check sits where the receiver type is first read, so every entry point is covered by construction. Implementations are never guessed, and the refusal is name-scoped: a class-typed receiver still resolves with an interface of another name in the corpus. Refs #5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`lang`-tagging (#6) keeps one language's raw calls out of another language's resolver, but the DEFINITION index each resolver builds was assembled from every type-like node in the corpus. A PHP receiver type name was therefore matched against classes written in any language, and that cut both ways: - a Python `class Lead` could be bound as the PHP receiver's type, minting a cross-language INFERRED edge from PHP into Python - worse, a Python class merely SHARING the name pushed the single-definition guard to two candidates, so the correct PHP-to-PHP edge was silently suppressed — any polyglot repo with a colliding class name lost PHP member-call resolution entirely Scope both indexes by the resolver's own registered source suffixes. The suffix tuples now have one definition each and feed both the registration and the index, so the two cannot drift apart. `_resolve_objc_member_calls` carries the identical defect (pre-existing, not introduced by the PHP work) and gets the same fix here. Its `.h` dual-routing is unaffected: raw calls are still claimed by the extractor-stamped `lang`, and `.h` belongs in the ObjC definition scope because an @interface lives in one. Refs #8 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#12) `enum_declaration` and `trait_declaration` are absent from `_PHP_CONFIG.class_types` just like `interface_declaration`, so they mint no definition node — and the #5 pre-scan only collected interfaces. An enum-typed receiver was therefore invisible to BOTH the resolver and the refusal set, so an unrelated class merely sharing its short name became the single visible definition and sailed through the god-node guard: `App\Enums\Status` (enum) beside `App\Legacy\Status` (class) bound `$this->status->label()` to the stranger at INFERRED 0.8. All four receiver entry points leaked, including the FQN-written one, where the source names the enum unambiguously. Generalize the pre-scan to every PHP declaration kind that mints no node — interface, enum, trait — and refuse those receiver types. Refusal side only: enums and traits still mint no definition nodes, so nothing else about extraction changes. That leaves the recall gap named in #12 (an enum's own methods are not resolvable call targets) deliberately open; minting nodes for these declarations is a separate decision. The resolver still reads the pre-#12 `php_interfaces` result key so an AST cache entry written before this change keeps refusing interfaces.
…ements (#13) #4's scope poisoning covered every way a local can be REASSIGNED, but not the two statements that rebind a name to DIFFERENT STORAGE. `$svc = new Alpha(); global $svc;` leaves the name aliased to the global slot and `static $svc;` rebinds it to the function-static slot (initially null), yet the table kept the `Alpha` binding and minted an INFERRED 0.8 edge to a method the receiver can never reach at runtime. Both idioms are native to the pre-PSR-4 codebases this feature targets: `global $db;` and `static $conn;` memoization. Poison every name a `global_declaration` or `function_static_declaration` names, in the same unordered walk that already poisons foreach targets and closure params. Name-targeted, not statement-targeted — `global $other;` leaves `$svc` resolvable, which the tests pin. Multi-name forms carry one `variable_name` per declared name and a static initializer is a constant expression, so sweeping the statement names exactly the rebound variables (AST shapes probed against tree-sitter-php 0.24.1).
#5 refuses an interface-typed receiver, but only on a full build. Interface names reached the resolver through `per_file`, which aligns 1:1 with the files dispatched THIS run, and the incremental widening path (Graphify-Labs#2406/Graphify-Labs#2437/Graphify-Labs#2438) carried nodes and contains/method edges — no channel for names. So on a rebuild where the interface's own file was unchanged and therefore not dispatched, the refusal silently stopped applying and the lone same-short-named CLASS satisfied the single-definition guard. Worse than a missing edge: a wrong one. Measured before the fix, `graphify extract` twice on the Laravel Contracts collision (an `App\Contracts\Notifier` interface, an unrelated `App\Support\Notifier` class, a `private Notifier $notifier` receiver): FULL -> notify calls: [] INCREMENTAL -> notify calls: [(dispatcher_go, support_notifier_notify)] Of the two directions recorded on the issue, this takes B (stamp a marker on a node the incremental path already carries) over A (a third `extract()` context parameter): A would still need somewhere to persist the names, so it buys a wider public signature for the same node-marker plumbing. The host is the PHP FILE node — an interface mints no node of its own, and no definition nodes change — carrying `_php_interfaces` with the names listed explicitly, never inferred from the `<Name>.php` label (that holds only under one-interface- per-file PSR-4 convention). Like `_callable` (Graphify-Labs#2438) the marker is deliberately not popped, so it persists into graph.json, and watch.py / cli.py hand it back on the resolution-context nodes. extract() turns those names back into the resolver's EXISTING single channel: one synthetic `php_interfaces`-only `per_file` entry on the scratch list, so `_resolve_php_member_calls` reads one union and full/incremental agree by construction. The names are harvested from the RAW context-node list, not from the merged `resolution_nodes`: a changed caller that does `use App\Contracts\Notifier;` mints a sourceless import stub whose id IS the interface file node's id, and the merge drops the colliding context node (fresh wins) — measured, and it takes the marker with it exactly when the refusal is needed. Tests: four in test_php_member_calls.py drive the incremental path through the public extract() seam with resolution context assembled the way watch.py builds it from graph.json (field subset + markers, contains/method edges) — refusal, short-name collision, case-insensitivity, plus a positive control that a class-typed receiver still resolves. Three in test_watch.py go end-to-end through `_rebuild_code(changed_paths=...)`: refusal held, marker persisted on the interface file only, and a pre-marker graph neither crashes nor blocks the next full rebuild from self-healing. The first three of each are red without this change. Suite: 3994 passed / 36 skipped, plus the pre-existing environment-specific test_collect_files_skips_hidden failure (dotted worktree path). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ace (#14) `_php_qualified_corroborates` promoted a member call to EXTRACTED 1.0 whenever the written class name matched the TAIL of the resolved node's file path. Its docstring justified that with "PHP nodes carry no namespace" — but the `namespace` declaration sits in the source and was simply never read, and PSR-4 is a convention, not an invariant. Two names were stamped at maximum confidence while denoting a class that exists nowhere in the corpus: * `app/Services/Client.php` declaring `namespace App\Vendor;` made `(new \App\Services\Client())` corroborate `App\Vendor\Client` — a wrong TARGET at 1.0, not just an inflated score. PSR-0 leftovers, classmap autoloaders, moved files and generated code all produce this. * `\Services\Client` corroborated `App\Services\Client`, because a proper suffix of the real name still matched the path tail. A missing `use` plus a leading backslash is a common bug; it was being rewarded. Pre-scan each PHP file's `namespace` declarations (both the statement and the braced-block form) at extraction time, map every class it declares to its fully qualified name, and thread that to the resolver keyed by defining file. When the declaration is known the comparison is whole-name, so neither exploit promotes. Only files that declare NO namespace fall back to the PSR-4 path check — with nothing declared, the path is the only evidence there is. A mismatch still downgrades rather than refusing, per #3's shipped policy, and a bare `new Svc()` still stays INFERRED.
…11, #12) #11 gave the refusal a second channel for rebuilds: a PHP file stamps the names it declares onto its own file node, that marker persists into graph.json, and watch/`graphify extract` hand it back as resolution context, which extract() folds into the resolver's single `per_file` channel. It carried INTERFACE names only. #12 had meanwhile widened the refusal to enums and traits — on the full build. So an unchanged `App\Enums\Status` file reached the resolver through nothing at all on a rebuild, `App\Legacy\Status` became the one visible definition, and #12's wrong edge came straight back on the incremental path. Measured, `/tmp/rt6/probe_incr_enum.py` (context marker stripped to simulate the interface-only channel): `FULL -> (no calls)`, `INCREMENTAL -> .go() -> label()`. Extend the channel to all three declaration kinds. The marker is renamed `_php_interfaces` -> `_php_non_class_types` since its contents no longer match the old name, and every reader — extract()'s context harvester, watch.py's and cli.py's marker tuples — still accepts the old spelling, so a graph.json written before this keeps refusing the interfaces it names instead of losing the channel outright. Same dual-read tolerance the per-file `php_interfaces` key already has. Verified end to end: `graphify extract . --code-only` twice over an enum corpus reports "2 files cached/unchanged, 1 re-extracted" with the marker persisted and no wrong edge (`/tmp/rt6/probe_cli_enum.py`). Tests: 5 through the extract() seam (enum property, enum typed param, trait, plus a class-typed positive control and one pinning that the legacy `_php_interfaces` spelling still refuses), 2 end-to-end through `_rebuild_code(changed_paths=…)`. With the marker temporarily reduced to interfaces, exactly the 3 refusal tests and the 2 watch tests go red.
#4/#5/#6/#8/#11/#12/#13/#14) Ships the PHP receiver-typed member-call work as 0.9.34. The changelog entry names what now resolves (typed properties and constructor-promoted params via `$this->prop`, nullsafe receivers, typed params, `$var = new T()` locals, and inline `(new T())->m()` as the one EXTRACTED form, gated on declared-namespace corroboration) and, at equal length, what is deliberately refused — untyped, union- and intersection-typed receivers, interface/enum/trait-typed receivers including across incremental rebuilds, corpus-duplicate short names, methods the receiver's class does not declare (so `__call` fabricates nothing), chained and array-element receivers, locals rebound or rebound by `global`/`static`, closure /arrow/foreach/destructuring shadowing, anonymous classes, and `self`/`static`/`parent`. Three behaviour deltas are called out because consumers weigh edges by confidence: a same-file call through a TYPED receiver moves EXTRACTED -> INFERRED 0.8 (measured on the base commit vs head, `/tmp/probe7_changelog_claims.py`); a qualified inline `new` is EXTRACTED while the same name written as a local stays INFERRED; and the language-scoped receiver index is two fixes, not one — polyglot corpora stop leaking cross-language edges AND regain PHP/ObjC edges a foreign same-short-named class used to suppress. Recall gaps (traits, inherited methods, enum methods as targets, typed params in top-level functions) and the use-alias-outside-corpus false-positive risk are named, as are the three items still open against this work. The docs confidence section gains a note that the member-call resolvers are a deterministic 0.8 INFERRED source distinct from the LLM rubric, plus the PHP refusal policy. The version bump rolls the version-namespaced AST cache. Verified end to end on the live repro corpus: pre-feature code fills `cache/ast/v0.9.33/`; head code at 0.9.33 serves those stale entries and produces NO receiver-aware edges even with all five files re-dispatched; at 0.9.34 the namespace misses, the corpus is re-parsed, and all three expected edges appear — `leadcontroller_index -> leadhunterservice_search` INFERRED 0.8, `paymentcontroller_store -> mixedpaymentservice_resolve` EXTRACTED 1.0, and the static control `paymentcontroller_store -> sucursalcontext` INFERRED 0.8 unchanged (`/tmp/probe7_bump_control.py`, `/tmp/probe7_cache_boundary.py`). The bump does not by itself force a re-extraction — an unchanged stat index short-circuits before the AST cache is consulted — so the changelog tells users to run `graphify update .` or drop `manifest.json`. The AST shapes the resolution reads are probed across every tree-sitter-php version pyproject accepts (0.23.0 through 0.24.1, twelve releases): 80/80 shape assertions hold on each, including the anonymous-class and `self`/`static` in type position cases, so the floor stays at >=0.23 (`/tmp/probe7_php_grammar.py`, `/tmp/probe7_php_versions.sh`). The 85 PHP tests also pass under 0.23.0, 0.23.5 and 0.23.11. uv.lock carries the one line that has to change: uv 0.12.1 rewrites 106 marker lines on a full `uv lock`, so the graphifyy version line is edited on its own. `uv lock --check` passes afterwards, which it did not before (the lock had been left at 0.9.31 across the 0.9.32 and 0.9.33 bumps). Suite: 4024 passed, 36 skipped — unchanged from the pre-release baseline.
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 #29
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 #30
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…into upstream-feat/php-name-resolver * origin/upstream-feat/php-use-metadata: fix(php): stop group-form use function/const from claiming class names feat(php): capture use FQN/alias/kind metadata on imports edges fix(hyperedge,skill): merge/load hyperedge integrity + community labels; bump to 0.9.34 fix(path): respect edge direction by default in path and shortest_path (Graphify-Labs#2487) fix(csharp): scope receiver types per declaration so an untypeable rebind can't drop a true call (Graphify-Labs#2472) # Conflicts: # CHANGELOG.md # graphify/extractors/engine.py
PHP 8.1 `$obj->method(...)` creates a Closure — it names the method without invoking it — but the 8.1 grammar reuses `member_call_expression` for it, so the shared `node.type in config.call_types` gate saw an ordinary call and the PHP branch never inspected the `arguments` field. The edge landed as `calls`, claiming control flow transfers at that line. Maintainer decision on #15 (option 2): re-tag as `indirect_call`, the relation this repo already uses for "named but not invoked". No sibling resolver emits `calls` for the equivalent syntax — C# method groups, Java method references and TS bare member references are never captured at all — so PHP was the outlier, and deleting the edge would lose a real dependency that suppression cannot express. Detection is stamped at capture as `fcc` on the raw-call fact, keyed on the argument list being exactly the `...` placeholder: probe-verified on the pinned tree-sitter-php 0.24.1, `m(...)` parses as `arguments: (arguments (variadic_placeholder))` — one named child of that type — while `m()`, `m(1)` and the spread `m(...$args)` do not. `_resolve_php_member_calls` reads the marker and flips only the relation: receiver typing, the single-definition and interface/enum/trait refusals, and the confidence ladder are unchanged. The in-file path (`$this->m(...)` binding to a method in the same file) re-tags too, at unchanged EXTRACTED confidence. Ordinary invocations keep `calls`, and a caller that both invokes and references the same method keeps the `calls` edge regardless of source order — the fcc dedup uses its own pair set, and the cross-file pass sorts direct calls ahead of references. Static (`Helper::fmt(...)`) and plain-function (`strlen(...)`) first-class callables are out of scope: neither resolves to a method target today. Tests: 7 new cases through the public extract() seam — plain, nullsafe and `$this` forms (each with a same-named decoy asserted to get no edge), plus regression guards for the ordinary member call, the ordinary `$this` call, the `...$args` spread, and direct-call precedence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…receivers (#9) User story 11 promised no `calls` edge for a union- or intersection-typed receiver. The cross-file resolver honoured it, but the extractor's legacy in-file bare-name arm did not: `_php_defer` was derived from whether a `receiver_type` had been STAMPED, so an annotation REFUSED by the concrete-type policy looked exactly like no annotation at all. A one-file `private Alpha|Beta $svc; $this->svc->run();` therefore bound to whichever `run()` the file's label index saw last — file order — at EXTRACTED confidence. Pre-existing, not a branch regression: it reproduces at the merge-base 4e7e6b1. The receiver table now distinguishes three states for a key: a concrete type (resolve it), PRESENT-but-None (annotation refused as multi-class, defer), and ABSENT (no annotation, keep today's in-file match). Precedence is concrete > refusal > absent, so a union-typed param later assigned a `new T()` still resolves to T while a poisoned one stays refused. Deletion scope is deliberately narrow, since deferring removes edges that exist today: only union (`A|B`) and intersection (`A&B`) annotations defer — including `A|null`, which is semantically `?A` but parses as a union node. The concrete-type policy's other refusals declare no multiplicity and keep their in-file edge: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched, preserving #2's accepted deviation and user story 9. Named in the CHANGELOG. Tests (all through the `extract()` seam): same-file union and intersection variants for properties, params and a promoted param — the separate-file negatives at tests/test_php_member_calls.py:211 spread `**_CORPUS`, which puts the decoys in other files, so the in-file arm never ran and they passed for the wrong reason; no intersection test existed at all. Plus regression guards that untyped properties/params, `$this->method()` and a `self`-typed property keep their same-file edges, locking the deletion scope. 5 red before the fix, 9 green after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`private (A&B)|C $x;` parses as a `disjunctive_normal_form_type` node, which
neither the property scanner nor the promoted-param scanner named among the
type shapes they accept. A DNF-typed property was therefore skipped outright
and invisible twice over:
* it never reached the receiver type table, so it kept minting the
same-file bare-name `calls` edge the previous commit removes — a DNF
type is a union at top level, so it has no single receiver class either;
* `_php_collect_type_refs` never walked it, so none of its classes got a
`references` edge, unlike the plain union property beside it.
Naming the node in both scanners fixes both halves at once — they read the
same type node, one for the receiver table and one for the reference walk,
so the two cannot be separated without a throwaway DNF-only scan. Split out
from the union/intersection commit because the reference edges are a
behavior addition beyond issue 9's letter.
`_php_multi_typed_annotation` gains the node, so DNF refuses exactly like
`A|B` does; the deletion scope stated in the previous commit widens by this
one shape and the CHANGELOG says so.
Test asserts both halves through the `extract()` seam: no `calls` edge, and
`references` edges to the DNF's classes. Red before, green after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_php_name_text` flattens every written PHP type annotation to its short name, so `private \Vendor\Sdk\Client $c;` was indistinguishable from `private Client $c;` — the compounding half of the #16 false-edge bug. Only inline-`new` kept the written form (`receiver_qualified`). Thread the written qualified form alongside the short name through the receiver-type table for all four annotation positions that type a receiver — properties, constructor-promoted params, ordinary params and `new`-bound locals — and stamp it on the raw-call fact as `receiver_type_qualified`. The table's values become a `_PhpReceiverType` (short, qualified) pair; `qualified` is set only when the annotation carried a namespace separator, so unqualified annotations produce the facts they produced before. Strictly additive: every decision — binding, poisoning, the #9 multi-class refusal (key present, value None) and the resolver's short-name lookup — is still taken on the short name alone. Two `new`s naming the same short name through different written forms keep today's binding and drop the conflicting qualified evidence rather than poisoning the name. Nothing consults the new field yet; the decisive refusal that closes #16 is #21. Verified beyond the suite: over a PHP corpus exercising all four positions plus unions, inline-`new`, same-short-name decoys and the conflicting-written-forms case, the extract() graph is byte-identical to v8 @ e188ff6.
A PHP file that writes `use Vendor\Sdk\Client;` has said which `Client` it means, but `_resolve_php_member_calls` never read `use` statements: it bound the receiver's short type name through a corpus-wide index whose only refusal rule was "more than one candidate", so the lone unrelated `App\Local\Client` satisfied the single-definition guard and minted an INFERRED 0.8 edge into a class the file never imported (#16). `PhpNameResolver` mirrors `CsharpNameResolver`: it answers with a `(node_id, decisive)` verdict built from the `use` metadata on `imports` edges (#19), the declared-FQN payload (#14) and the same type-definition index the fallback uses, and is consulted in FRONT of that fallback exactly like the C# call site. A claimed name that lands on no in-corpus class refuses instead of falling back. Written qualified annotations (#20) resolve the same way, absolute or namespace-relative. Strictly subtractive by construction: every node the resolver returns is looked up under the receiver's WRITTEN short name in the very index the fallback consults, so a positive verdict is always the answer the fallback would have given, and the only behavior change is the refusal. Binding an alias to a class its short name does not name is a recall addition and stays with #22. Verified differentially against v8 over a 34-file corpus of PHP receiver-typing shapes: 3 edges deleted, none added, re-pointed or re-scored. The refusal needs no new persisted marker — the `use` map belongs to the calling file, which an incremental rebuild always re-dispatches — and a path SHORTER than the written name is read as a stripped composer prefix rather than as a contradiction, so a class off its PSR-4 path keeps its edge on both paths. This is the fix for #16; the issue stays open for the orchestrator's gate.
There was a problem hiding this comment.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify review — findings
This pull request centers on PHP member-call resolution in what appears to be a code-graph extraction tool, changing how instance-method calls on typed receivers are resolved (e.g., binding $this->prop->method() to the property's declared type) and introducing a new PhpNameResolver that reads use statements to resolve claimed type names against the corpus. It also reworks confidence tagging (EXTRACTED vs INFERRED), routes PHP 8.1 first-class callables to indirect_call instead of calls, scopes member-call resolvers to same-language definitions, and handles additional PHP syntax cases (DNF types, group use function/use const, union/intersection receivers). Surface area spans the PHP and Objective-C extractors/resolvers, the extraction engine, watch/CLI paths, a version bump that rolls the AST cache, and a substantial set of tests plus extensive CHANGELOG and docs updates. The changed test files cover watch behavior, PHP name resolution, member calls, first-class callables, and group-use kind labels.
No blocking issues surfaced.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 2474 functions depend on the 1357 functions this change touches.
Health — this change adds coupling hotspots:
- worse:
extract()— 392 callers, 40 callees - worse:
_rebuild_code()— 92 callers, 51 callees - worse:
_extract_generic()— 18 callers, 23 callees - worse:
walk()— 1 callers, 54 callees - new:
_full_then_incremental()— 9 callers, 3 callees - worse:
walk_calls()— 1 callers, 14 callees - new:
_php_method_receiver_types()— 1 callers, 7 callees - new:
_resolve_php_member_calls()— 0 callers, 7 callees
Verification — 2474 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 2431 function(s) in the blast radius were not formally verified this run
· 4 grounded finding(s) anchored inline below; 4 more finding(s) on lines outside this diff (see the check run).
| return {"php_non_class_types": names} if names else None | ||
|
|
||
|
|
||
| def _resolve_php_member_calls( |
There was a problem hiding this comment.
_resolve_php_member_calls()
fans out to 7 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| _PHP_CLOSURE_TYPES = frozenset({"anonymous_function", "arrow_function"}) | ||
|
|
||
|
|
||
| def _php_method_receiver_types( |
There was a problem hiding this comment.
_php_method_receiver_types()
fans out to 7 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -4201,9 +4616,13 @@ def _php_class_const_scope(n) -> str | None: | |||
| def walk_calls( | |||
There was a problem hiding this comment.
walk_calls()
fans out to 14 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return nodes, edges | ||
|
|
||
|
|
||
| def _full_then_incremental(tmp_path: Path, files: dict[str, str], changed: str): |
There was a problem hiding this comment.
_full_then_incremental()
9 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
|
Consolidating a reply to the four automated coupling threads on this PR rather than answering each inline. None of them is a regression in the sense the label implies, and I'm not proposing a change for any — reasoning and numbers below, all from an AST scan against base Two of the four functions do not exist at base. Both are introduced by
There is no prior fan-out for either, so there is no delta. Whether a 155- or 185-line helper should itself be decomposed is a fair question, but it is a design question about new code, not a coupling regression.
I did look for a cheap behaviour-preserving improvement here, and there is one —
|
Four coupled PHP member-call fixes, backported from our fork where they landed with full test evidence (lawnstarter#27, lawnstarter#25, lawnstarter#31, lawnstarter#32).
Stacked on #2492 and #2502 — this branch is their merge plus five commits; only the last five commits (
804dc81and up) are new here. Once those two PRs merge intov8, this diff reduces to exactly those five.Commit-by-commit
indirect_callfor PHP 8.1 first-class callables (lawnstarter/graphify#15) —$obj->method(...)creates aClosurewithout invoking it, so the edge moves fromcallsto the relation this repo already uses for a callback passed by name. Target resolution, refusals and confidence are unchanged; detection keys on the argument list being exactlyvariadic_placeholderon the pinned grammar (tree-sitter-php 0.24.1). A real call outranks a reference for the same (caller, method) pair.lawnstarter/graphify#9) — the legacy in-file matcher couldn't tell "annotation refused" from "no annotation", soprivate Alpha|Beta $svc;bound whichever same-named method the file's label index saw last, at EXTRACTED. The receiver table now records the refusal, and a refused multi-class annotation defers to the receiver-typed resolver. Deletion scope: only union/intersection-typed receivers' same-file edges.lawnstarter/graphify#9) —private (A&B)|C $x;parses as its own node the scanners didn't name; it now refuses like a union and emitsreferencesto A, B and C like one.lawnstarter/graphify#20on the fork) — the extractor stops flattening\Vendor\Sdk\ClienttoClientfor property/param/promoted-param/local type positions; the receiver table's PHP values become a(short, qualified)pair.PhpNameResolver: a claimed name refuses instead of guessing (lawnstarter/graphify#21, closes the fork's measured false-edgelawnstarter/graphify#16) — the PHP twin ofCsharpNameResolver, consulted in front of the corpus-wide short-name index in the exact shape of the C# call site. A file that writesuse Vendor\Sdk\Client;has claimed the name; when the claim lands on no in-corpus class the resolver refuses rather than binding the lone same-short-named stranger. Strictly subtractive; verified on the fork by a differential extract (3 edges deleted — exactly the support zig and powershell #16 class — 0 added, 0 re-pointed).Verification
Full suite on this branch: 4122 passed, 36 skipped (the merged #2492+#2502 base alone: 4079 passed, so every added test rides with its fix). Conflict resolutions against the #2472 scoped C# receiver tables are confined to the shared
receiver_typesannotation/merge comments; body ids are language-disjoint, so each language's table only meets its own reader.A follow-up PR stacks the recall counterpart (declared-FQN positive binding + its incremental-parity marker) on top of this one.
🤖 Generated with Claude Code