Skip to content

fix(php): resolve instance-method calls edges from typed receivers (#1682) - #2492

Open
filipechagas wants to merge 15 commits into
Graphify-Labs:v8from
lawnstarter:feat/php-member-calls-1682
Open

fix(php): resolve instance-method calls edges from typed receivers (#1682)#2492
filipechagas wants to merge 15 commits into
Graphify-Labs:v8from
lawnstarter:feat/php-member-calls-1682

Conversation

@filipechagas

@filipechagas filipechagas commented Aug 5, 2026

Copy link
Copy Markdown

Fixes #1682.

The problem

The graph makes calls edges only for static calls. A static call looks like this: ClassName::method().

The graph does not make calls edges for instance calls. Instance calls include these three types:

  1. Calls through a service that the constructor receives: $this->service->method()
  2. Calls through a local variable that has a type
  3. Calls on a new object made in the same line: (new Service())->method()

The graph does not make these edges. This is true even when the code clearly shows the class of the callee. Modern Laravel code almost always uses constructor injection. So, for most service classes, the graph shows zero incoming calls. But the graph looks complete. The user cannot see that these edges are missing.

The solution

Two parts:

Part 1 — The extractor records the receiver. The receiver is the object before the arrow in $obj->method(). The PHP extractor now records it.

Part 2 — A new PHP resolver makes the edge. The resolver reads the receiver and its declared type. Then it makes a calls edge. The resolver is registered in the language-resolver registry. Because of this, it runs in full builds and in incremental builds.

The resolver makes an edge only when there is no doubt. It copies the structure and the guards of the Java member-call resolver. It obeys three conditions:

  1. The receiver has exactly one known concrete type.
  2. The corpus has exactly one definition of that type.
  3. That definition has exactly one method with that name.

If one condition fails, the resolver makes no edge. It does not fall back to a name-only match.

These calls now resolve:

  • Calls through constructor-promoted properties
  • Calls through typed properties
  • Calls through typed local variables
  • Calls through typed parameters
  • Calls through nullable types (?Foo counts as Foo)
  • Nullsafe calls (?->)

Each new edge has a confidence label. An edge from a declared type gets INFERRED (0.8). An edge from an inline new gets EXTRACTED (1.0) — but only when the written full class name agrees with the namespace that the file declares. If not, the edge gets INFERRED.

These calls do not resolve. The resolver refuses them:

  • Receivers with no type
  • Receivers with a union type or an intersection type
  • Receivers with an interface type, an enum type, or a trait type — the resolver never guesses the implementation. This holds when two names collide, and it holds in incremental builds.
  • Types with two or more definitions in the corpus
  • Methods that the class does not define — the resolver does not guess magic dispatch
  • Chained receivers and array receivers
  • Anonymous classes
  • self, static, and parent
  • Local variables that lose their known type. A variable loses its type when: the code assigns it again; the code assigns it a different new type; a global or static statement rebinds it; a closure parameter, an arrow-function parameter, a foreach target, or list destructuring shadows it.

The change also includes two hardening fixes:

  1. Raw calls that have a language tag cannot go through the member-call resolvers of other languages.
  2. The PHP and ObjC resolvers now read type definitions only from files of their own language. For ObjC, this repairs an old defect in mixed-language projects. Before, ObjC calls could bind to a class from a different language. Also before, a name collision with a different language removed correct ObjC edges. Now those edges come back.

Two behavior changes to know (the changelog also lists them):

  1. Some same-file calls change their label. If the receiver has a known concrete type, the edge was EXTRACTED from a name match. Now the edge is INFERRED from the resolver. The edge connects the same two nodes.
  2. The version goes up to 0.9.34. The AST cache uses the version as its namespace, so the new version makes old cache entries invalid. But this alone does not refresh an existing graph. To get the new edges in an existing graph, run graphify update .

Evidence

We ran the reproduction corpus from the issue. It has a Laravel-style controller and services. The table shows each call site before and after:

Call site Base 4e7e6b1 This branch
$this->leadHunter->search(...) (promoted ctor param) no edge calls INFERRED 0.8
(new \App\Services\MixedPaymentService())->resolve(...) no edge calls EXTRACTED 1.0
SucursalContext::id() (static control) calls edge unchanged
  • Tests: the base has 3933 passed / 36 skipped. This branch has 4024 passed / 36 skipped. That is 91 more tests. All 91 tests use the public extract() function. Each positive test also has a decoy: a different class with a method of the same name. Each test makes sure the decoy gets no edge.
  • We attacked the guards with approximately 20 hostile test fixtures. The attacks used: enums, traits, references, variable-variables, new $cls(), letter-case tricks, variable shadowing, heredocs, and match expressions. Three attacks found real defects. We fixed all three before this PR. Each fix has a regression test that failed before the fix.
  • We made 80 assertions about the AST shapes that the resolver reads. All 80 pass on all twelve tree-sitter-php releases that the version floor permits (0.23.0 – 0.24.1).
  • Full builds and incremental builds give the same result. They agree on each edge and each refusal. Watch-path tests hold this in place.

Known limits (the changelog lists them)

  • The resolver does not follow methods that come from traits or from parent classes. The Java resolver has the same limit.
  • Enum methods cannot be call targets. This only removes possible edges. It never adds wrong ones.
  • The resolver ignores docblock types.
  • One wrong-edge case remains: a use alias points to a class outside the corpus, and the corpus has one unrelated class with the same short name. Then the edge binds to the wrong class. The Java resolver has this same defect today. The planned repair is use-map awareness.
  • A union-typed receiver can still get a same-file, name-match edge. This behavior existed before this change. This change does not touch it.

🤖 Generated with Claude Code

filipechagas and others added 14 commits August 5, 2026 14:30
…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>
…ardening (#6)

* feat/php-member-calls-1682-t6:
  fix(extract): skip language-tagged raw calls in the Swift, Python and TypeScript member-call resolvers (#6)
`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.
…s incremental rebuilds (#11)

* feat/php-member-calls-1682-t6:
  fix(php): keep the interface refusal across incremental rebuilds (#11)

# Conflicts:
#	graphify/extract.py
#	graphify/extractors/engine.py
#	tests/test_php_member_calls.py
…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.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 PR adds PHP instance-method call resolution on typed receivers to the graphify extractor. When a PHP method is called through a receiver whose type can be determined (e.g. from a property, constructor-promoted param, typed parameter, nullsafe receiver, or new T() local), the edge now binds to that declared type rather than a same-name match, emitting INFERRED (0.8) edges in most cases and EXTRACTED (1.0) when the class is named inline and corroborated against the file's declared namespace. It deliberately emits no edge when the receiver type isn't provably one concrete in-corpus class (untyped, union/intersection, interface/enum/trait-typed, shadowed locals, etc.). The change also scopes the PHP and Objective-C member-call resolvers to their own language's definitions so cross-language name collisions no longer create wrong edges or suppress correct ones, and it threads new AST/marker fields (_php_non_class_types, nullsafe_member_call_expression, lang tags on raw calls) through extraction, watch/incremental rebuild, and the CLI context path. Supporting changes include a version bump that rolls the AST cache namespace, extensive new tests across PHP member calls, mixed-corpus, and watch scenarios, plus CHANGELOG and how-it-works documentation. Surface area touched: graphify/extract.py, graphify/cli.py, changelog/docs, and multiple test files covering PHP resolution, polyglot corpora, and incremental rebuilds.

No blocking issues surfaced. 3 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 2156 functions depend on the 1087 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: extract() — 371 callers, 40 callees
  • worse: _rebuild_code() — 92 callers, 51 callees
  • worse: _extract_generic() — 18 callers, 23 callees
  • worse: walk() — 1 callers, 53 callees
  • new: _full_then_incremental() — 9 callers, 3 callees
  • worse: walk_calls() — 1 callers, 13 callees
  • new: _php_method_receiver_types() — 1 callers, 6 callees

Verification — 2156 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: 2113 function(s) in the blast radius were not formally verified this run

· 2 grounded finding(s) anchored inline below; 5 more finding(s) on lines outside this diff (see the check run).

_PHP_CLOSURE_TYPES = frozenset({"anonymous_function", "arrow_function"})


def _php_method_receiver_types(

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 regression_php_method_receiver_types()

fans out to 6 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):

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 regression_full_then_incremental()

9 callers depend on it (afferent coupling).

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

@filipechagas

Copy link
Copy Markdown
Author

Status update from the submitting fork — three things reviewers may want to know:

1. Follow-up fixes have landed on the fork since this PR was opened. Independent review and release verification of this branch surfaced several issues; all are fixed, tested (red-first, full-suite gated), and merged on lawnstarter/graphify:

  • Union/intersection-typed receivers minted a same-file bare-name edge (pre-existing at this PR's base, but adjacent): lawnstarter#25
  • First-class callables $obj->method(...) now emit indirect_call instead of calls: lawnstarter#27
  • PHP use import metadata + a decisive-refusal PhpNameResolver closing the use-alias false-positive this PR's design accepted as a known risk: lawnstarter#29, lawnstarter#31, lawnstarter#32

Those build on this PR's code, so we're deliberately not adding them here mid-review; we'll submit them as follow-up PRs once this merges.

2. Two fixes that apply to upstream independently of this PR are being submitted separately (they touch pre-existing code): the untagged member-call resolvers consuming each other's raw calls (a TS receiver can mint a Python edge today), and use metadata capture plus a group-form use function/use const name-claiming bug in _resolve_php_type_references.

3. Version collision heads-up: this branch bumps to 0.9.34, which upstream has since released independently (07b9143). Happy to renumber at merge time however maintainers prefer — we've avoided force-pushing during review.

…e.py, CHANGELOG resolved per the reviewed c3cb70c resolution)

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 PR implements PHP typed-receiver resolution for member calls in the graphify extractor. It adds support for resolving calls like $this->prop->method(), nullsafe receivers ($obj?->method()), constructor-promoted params, new T() locals, and inline (new Service())->method() forms, binding them to the receiver's declared type (INFERRED 0.8, or EXTRACTED 1.0 for the corroborated inline form), while emitting no edge in ambiguous cases. It also scopes the PHP and Objective-C member-call resolvers to their own language's source suffixes, threads new node markers (_php_non_class_types/_php_interfaces) through the CLI dispatch context, and bumps the version to roll the AST cache namespace. The surface area spans graphify/cli.py (context marker propagation), graphify/extract.py (PHP config call types, resolver logic), graphify/watch.py, documentation (CHANGELOG.md, docs/how-it-works.md), and a substantial set of tests covering member-call resolution, refusal behavior, polyglot scoping, and watch/incremental-rebuild scenarios.

No blocking issues surfaced. 3 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 2159 functions depend on the 1088 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: extract() — 371 callers, 40 callees
  • worse: _rebuild_code() — 92 callers, 51 callees
  • worse: _extract_generic() — 18 callers, 23 callees
  • worse: walk() — 1 callers, 53 callees
  • new: _full_then_incremental() — 9 callers, 3 callees
  • worse: walk_calls() — 1 callers, 14 callees
  • new: _php_method_receiver_types() — 1 callers, 6 callees

Verification — 2159 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: 2116 function(s) in the blast radius were not formally verified this run

· 2 grounded finding(s) anchored inline below; 5 more finding(s) on lines outside this diff (see the check run).

_PHP_CLOSURE_TYPES = frozenset({"anonymous_function", "arrow_function"})


def _php_method_receiver_types(

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 regression_php_method_receiver_types()

fans out to 6 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):

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 regression_full_then_incremental()

9 callers depend on it (afferent coupling).

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PHP: member/instance method calls never resolve to calls edges — only static Class::method() works

1 participant