Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
43cd7a2
feat(php): resolve $this->prop->method() to the property's declared t…
filipechagas Aug 5, 2026
ae95261
feat(php): resolve (new Service())->method() with FQN corroboration (#3)
filipechagas Aug 5, 2026
a707b42
feat(php): resolve typed locals and typed params with scope poisoning…
filipechagas Aug 5, 2026
6d33de1
fix(extract): skip language-tagged raw calls in the Swift, Python and…
filipechagas Aug 5, 2026
4f17dd8
feat(php): refuse interface-typed receivers, even under short-name co…
filipechagas Aug 5, 2026
f567a36
Merge branch 'feat/php-member-calls-1682-t6': mixed-corpus lang-tag h…
filipechagas Aug 5, 2026
2e68ec6
fix(extract): language-scope the PHP and ObjC receiver type indexes (#8)
filipechagas Aug 5, 2026
5de8f76
feat(php): refuse enum- and trait-typed receivers alongside interface…
filipechagas Aug 5, 2026
0cb78af
fix(php): poison receiver types rebound by `global` and `static` stat…
filipechagas Aug 5, 2026
8d051c6
fix(php): keep the interface refusal across incremental rebuilds (#11)
filipechagas Aug 5, 2026
49b665e
fix(php): corroborate an inline-`new` FQN against the declared namesp…
filipechagas Aug 5, 2026
c2c3e89
Merge branch 'feat/php-member-calls-1682-t6': interface refusal acros…
filipechagas Aug 5, 2026
85bb8df
fix(php): carry enum and trait names across incremental rebuilds too …
filipechagas Aug 5, 2026
db7c8f8
chore: bump to 0.9.34; changelog and docs for #1682 (#2/#3/#4/#5/#6/#…
filipechagas Aug 5, 2026
a149cb5
feat(php): capture use FQN/alias/kind metadata on imports edges
filipechagas Aug 6, 2026
c1e0634
fix(php): stop group-form use function/const from claiming class names
filipechagas Aug 6, 2026
c3cb70c
Merge remote-tracking branch 'origin/upstream-feat/php-use-metadata' …
filipechagas Aug 6, 2026
804dc81
fix(php): emit indirect_call for first-class callables (#15)
filipechagas Aug 5, 2026
1ac0c74
fix(php): refuse the same-file bare-name edge for union/intersection …
filipechagas Aug 5, 2026
90d0425
fix(php): recognize PHP 8.2 DNF property and promoted-param types (#9)
filipechagas Aug 5, 2026
bd2ec67
feat(extract): keep written qualified PHP receiver types (#20)
filipechagas Aug 5, 2026
e1b1650
fix(php): refuse a member call whose receiver type the file claims (#21)
filipechagas Aug 5, 2026
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,25 @@ Full release notes with details on each version: [GitHub Releases](https://githu

## 0.9.34 (unreleased)

- Feature: PHP instance-method calls on a typed receiver now resolve to the method they really reach, instead of a bare same-name match (#1682). `$this->prop->method()` binds to the type declared on the property — including a constructor-promoted param — and the same typing covers nullsafe receivers (`$obj?->method()`), natively typed parameters, and `$var = new T()` locals. `(new Service())->method()` resolves too and is the one form tagged EXTRACTED (1.0), since the class is named right there in the source; the promotion applies only when the written namespace corroborates the class node found for it, compared against the namespace the defining file DECLARES rather than against its PSR-4 path (a file at `app/Services/Client.php` may well declare `namespace App\Vendor;`). Every other typed receiver is INFERRED (0.8). The motivating case is a Laravel corpus: `$this->leadHunter->search(...)` used to reach nothing, and now reaches `App\Services\LeadHunterService::search`.
- Refuse-don't-guess is the policy wherever the receiver's type is not provably one concrete in-corpus class, so all of these deliberately emit NO edge: untyped, union-typed (`A|B`) and intersection-typed (`A&B`) receivers; receivers typed by an `interface`, `enum` or `trait` — none of which mints a definition node, so binding one would pick a same-short-named stranger (the `App\Contracts\Notifier` vs `App\Support\Notifier` collision, or `App\Enums\Status` beside an Eloquent `App\Models\Status`), and that refusal now survives an incremental rebuild by persisting those names on the declaring file's node; a short type name that does not match exactly one class in the corpus; a method the receiver's own class does not declare, so `__call` magic dispatch fabricates nothing; chained (`$this->factory()->method()`) and array-element (`$bag['k']->method()`) receivers; a local rebound to anything but a matching `new`, or rebound to other storage by `global`/`static`; a name shadowed by a closure or arrow-function parameter, a `foreach` target, or list destructuring; anonymous classes (`new class { ... }`); and `self`/`static`/`parent` in type position, which need inheritance context the raw-call facts do not carry.
- Behavior change: a same-file call through a typed receiver moves from EXTRACTED to INFERRED (0.8). Those calls used to be minted by the in-file bare-name matcher, which cannot tell the property's declared type apart from any other class in the file; they are now routed through the receiver-typed resolver, which is right more often but no longer claims to be certain. Untyped receivers keep their existing in-file behavior, so this is a confidence change on typed receivers only, not a drop in edge count. One asymmetry is visible in the output and worth knowing about: a fully qualified `(new \App\Services\Client())->method()` is EXTRACTED, while the same name written as a local (`$c = new \App\Services\Client(); $c->method();`) stays INFERRED — the inline form is corroborated against the declared namespace, the local form is typed through the method-scoped table and is not.
- Fix: the PHP and Objective-C member-call resolvers no longer match a receiver's type against class definitions written in ANY language; each index is scoped to its own source suffixes. This cut both ways in a polyglot corpus, so it is two fixes: a Python `class Lead` could be bound as a PHP or ObjC receiver's type and mint a cross-language edge, and a foreign class merely SHARING a short name pushed the single-definition guard to 2 and silently suppressed the correct same-language edge. Polyglot corpora therefore also GAIN PHP and ObjC edges that a name collision previously deleted. The ObjC half is a pre-existing defect of the same shape that rides along with the PHP work; the same exposure in the Java, C#, C++, Swift and TypeScript resolvers is untouched and left as a follow-up.
- Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk was named here — a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfying the single-definition guard and minting a wrong INFERRED edge — and is closed by the `use`-map fix below. Java still has the identical exposure.
- Behavior change: a PHP 8.1 first-class callable (`$obj->method(...)`, plus the nullsafe `$obj?->method(...)` and `$this->method(...)` forms) now emits `indirect_call` instead of `calls` (`lawnstarter/graphify#15`). The syntax creates a `Closure` — it names the method without invoking it — so the edge moves to the relation this repo already uses for a callback passed by name, and `calls` keeps meaning "control flow transfers here". Target resolution is untouched: the same receiver typing, the same refusals, the same confidence (INFERRED 0.8 through a typed receiver, EXTRACTED 1.0 for `$this`), only the relation differs. Ordinary invocations are unaffected, including the spread form `$obj->method(...$args)`, which is a real call; when a caller both invokes and references the same method, the `calls` edge wins the pair. Detection is keyed on the argument list being exactly the `...` placeholder (`variadic_placeholder`) on the pinned grammar, tree-sitter-php 0.24.1. Consumers that query `relation == "calls"` will no longer see these edges; blast-radius/`affected` output is unchanged, since it already includes `indirect_call`.
- Fix: a union- or intersection-typed PHP receiver no longer mints a bare-name `calls` edge when the candidate methods live in the SAME file as the call (`lawnstarter/graphify#9`). The refusal above already held across files, but the legacy in-file matcher derived its decision from whether a type had been STAMPED, which made "annotation refused" indistinguishable from "no annotation" — so `private Alpha|Beta $svc; $this->svc->run();` bound to whichever `run()` the file's label index saw last, by file order, at EXTRACTED confidence. The receiver table now tells the two apart, and a refused multi-class annotation defers to the receiver-typed resolver, which emits nothing for an unstamped receiver. **Deletion scope**, stated deliberately because deferring removes edges that exist today: the ONLY edges removed are same-file bare-name edges whose receiver is declared as a union (`A|B`) or an intersection (`A&B`) — including `A|null`, which is semantically `?A` but is a union node, and so loses its same-file edge rather than resolving as one concrete type. Everything else the concrete-type policy also refuses is deliberately left on the in-file arm, because none of it declares MULTIPLE candidate classes: `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.
- Fix: a PHP 8.2 disjunctive-normal-form property type (`private (A&B)|C $x;`) is no longer skipped outright (`lawnstarter/graphify#9`). DNF parses as its own AST node, which the property and promoted-param scanners did not name among the type shapes they accept, so such a property was invisible twice over: it minted the same-file bare-name `calls` edge the fix above removes (a DNF type is a union at top level, so it has no single receiver class either), and its classes got no `references` edge at all. It now refuses like a union, and references A, B and C like one.
- Fix: a group-form `use function A\{f, g};` or `use const A\{K};` no longer claims `f`, `g` or `K` as an imported CLASS name (`lawnstarter/graphify#26`). tree-sitter-php puts the `function`/`const` keyword on the *declaration* for the braced form but on the *clause* for the unbraced one, and the PHP type-reference pass only ever inspected the clause — so a group-imported function or constant whose short name was also used in a class position in the SAME file re-pointed that `inherits`/`implements`/`mixes_in`/`imports`/`references` edge onto an external stub labeled with an FQN that names a function or a constant, not a class. Both spellings now agree: the reference falls back to the namespace-relative FQN or to the legacy unique-label rewire, exactly as the unbraced form always did. Strictly subtractive — it can only REMOVE a class-name claim, never add one — and it needs the same short name used both ways in one file, which is why it is rare in practice. Pre-existing; not a regression from the `use`-metadata capture.
- Fix: a PHP member call through a receiver whose type name the calling file CLAIMED no longer falls back to a same-short-named stranger elsewhere in the corpus (`lawnstarter/graphify#16`). A file that writes `use Vendor\Sdk\Client;` has already said which `Client` it means, but the resolver never read `use` statements — so `private Client $c; $this->c->send();` bound the lone unrelated `App\Local\Client`, which satisfied the single-definition guard, and minted an `INFERRED 0.8` edge into a class the file never imported. A new `PhpNameResolver` (the PHP twin of the C# one) resolves the claimed name against the corpus and is consulted in FRONT of the corpus-wide short-name index, so a claim that lands on no in-corpus class refuses instead of guessing. Annotations written out qualified are read the same way, including the namespace-relative form: inside `namespace App\Http`, `private Local\Client $c;` means `App\Http\Local\Client` and not `App\Local\Client`. **Deletion scope**, stated deliberately because this change only ever removes edges: the ONLY edges removed are `calls`/`indirect_call` edges through a receiver whose short type name the calling file claims — through `use`, `use ... as`, a group `use`, or a qualified annotation — where that claim does not name a class in the corpus. Everything else is untouched. An unclaimed short name still resolves through exactly the same fallback as before; `use function` / `use const` claim no class name in either spelling; and a claim is only ever contradicted by hard evidence — the namespace the defining file DECLARES, or that file's PSR-4 path when it was not dispatched this run, where a path SHORTER than the written name is read as a stripped composer prefix rather than as a contradiction, so incremental rebuilds agree with full ones instead of quietly deleting more. Binding an alias to the right one of several same-short-named classes is a recall ADDITION and deliberately not part of this change.
- Known open items tracked against this work, unfixed in this release: the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`).
- The package version bump rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`), so a file dispatched for extraction after upgrading is re-parsed instead of being served a cached entry whose `raw_calls` predate the receiver fields. The bump does not by itself force a re-extraction: `graphify extract` on a corpus with an unchanged stat index reports every file cached and never consults the AST cache, so it replays the pre-upgrade graph. To pick up the new PHP edges on an existing graph, run `graphify update .`, or delete `graphify-out/manifest.json` — either re-dispatches the corpus, and the cache namespace then does its job.
- Fix: C# receiver typing no longer drops a true call when a same-named variable is declared untypeably elsewhere in the method (#2472, thanks @JensD-git). Receiver types are now tracked per lexical declaration scope and resolved by the call's position, so a typed `static` local-function parameter keeps resolving even when an `out var` reuses the name in the enclosing body. This fixes a regression from 0.9.32 (#2346). Cross-method independence (#2299) and field-conflict poisoning are unchanged; an `out var` receiver itself remains untyped.
- Fix: `graphify path` (and the MCP `shortest_path` tool) now respect edge direction by default instead of running on an undirected view, so a returned path no longer traverses edges backwards (#2487, thanks @luliaz0601). Direction is recovered from the stored `_src`/`_tgt` markers. Pass `--undirected` (CLI) or `undirected=true` (MCP) to search ignoring direction; when no directed path exists the command says so instead of silently returning a reversed one.
- Fix: semantic extraction no longer aborts at merge with a `TypeError` when a hyperedge carries dict-shaped members (#2486, thanks @adminwat). Members are normalized to ids (or dropped with a warning) so a malformed hyperedge can no longer destroy a completed extraction.
- Fix: `graphify merge-graphs` no longer drops hyperedges (#2484, thanks @sortakool, and @oleksii-tumanov for the approach in #1691). Hyperedge member ids and ids are now relabeled with the per-repo prefix, both inputs' hyperedges are unioned instead of one clobbering the other, and they are written to both the top-level and nested slots.
- Fix: `build_from_json` now reads hyperedges from both the top-level and nested `graph` slots, so label and re-cluster runs no longer silently empty a graph's hyperedge set (#2485, thanks @sortakool); a full validation wipeout is now reported loudly.
- Fix: the skill flow now passes the curated community labels to `to_json`, so `graph.json` ships with `community_name` on nodes instead of dropping it (#2490, thanks @PapiScholz).
- PHP `imports` edges now carry `use_kind` (`class`/`function`/`const`), `alias` and `target_fqn` metadata, mirroring the C# `using` capture. The `use`-statement parser is now shared between the capture path and the PHP type-reference pass, so group use `use A\{B, C as X};`, aliases and leading-backslash absolutes are spelled out once. Edge targets are unchanged — they stay keyed on the imported short name — so this is additive for existing consumers; note that the type-reference pass re-points `imports` edges without rewriting metadata, making `metadata.target_fqn` the reliable read rather than the target node's label.

## 0.9.33 (2026-08-05)

Expand Down
6 changes: 6 additions & 0 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ EXTRACTED edges always have confidence 1.0. INFERRED edges use a discrete rubric
- **0.65** — weak (naming similarity only)
- **0.55** — speculative

That rubric describes edges Claude inferred. The per-language member-call resolvers are a separate, deterministic source of INFERRED edges: they read the receiver's declared type out of the AST and bind at a fixed **0.8**, reserving EXTRACTED (1.0) for a receiver whose type is named in the source at the call site.

**PHP member calls refuse rather than guess.** `$this->prop->method()`, `$obj?->method()`, a typed parameter and a `$var = new T()` local all bind to the receiver's declared type as INFERRED 0.8; `(new Service())->method()` is EXTRACTED 1.0, but only when the namespace written at the call site corroborates the class that was found. When the type is not provably one concrete in-corpus class, no edge is emitted at all — untyped, union- and intersection-typed receivers, receivers typed by an `interface`, `enum` or `trait`, a short name that matches two classes, a method the receiver's class does not declare, chained and array-element receivers, a local rebound or shadowed anywhere in the method, anonymous classes, and `self`/`static`/`parent`. A Laravel corpus has many identically named `search()`/`log()`/`handle()` methods, so an absent edge is worth more than a guessed one.

A PHP 8.1 first-class callable — `$obj->method(...)`, including the nullsafe and `$this` forms — resolves by exactly those rules but is emitted as **`indirect_call`**, not `calls`: the syntax creates a `Closure` and names the method without invoking it, the same shape the resolver already labels `indirect_call` for a callback passed by name. `calls` therefore keeps meaning "control flow transfers here". The spread form `$obj->method(...$args)` is a real invocation and stays `calls`.

---

## Token benchmark
Expand Down
9 changes: 8 additions & 1 deletion graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3214,7 +3214,14 @@ def _ctx_identity(source_file) -> str | None:
"file_type": _node.get("file_type"),
"type": _node.get("type"),
}
for _marker in ("_callable", "_callable_class"):
# `_php_non_class_types` (#11, #12) rides the same
# marker channel as the callability flags: without it an
# unchanged PHP file declaring an interface, enum or
# trait stops refusing such a receiver and a stranger
# class gets the edge. `_php_interfaces` is the pre-#12
# spelling, still carried for older graphs.
for _marker in ("_callable", "_callable_class",
"_php_non_class_types", "_php_interfaces"):
if _node.get(_marker):
_ctx_node[_marker] = _node[_marker]
_ctx_nodes.append(_ctx_node)
Expand Down
Loading