Repository navigation
fix(php): consolidated PHP support — member-call resolution, use-import handling, node identity, cross-language isolation, query seeding (#1682, #2615) - #2617
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.
feat(php): resolve instance-method calls edges from typed receivers (#1)
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 `imports` edges carried no metadata: `_import_php` kept only
`raw.split("\\")[-1]`, so the FQN, the alias and the `function`/`const`
kind of every `use` statement were discarded at capture time. That is the
fact base #16's decisive-refusal resolver needs, and it already existed —
correctly parsed, group-use and aliases included — but only as a
function-local map inside `_resolve_php_type_references`.
Extract that parser (`_record_use_clause` plus the
`namespace_use_declaration` walk) into three shared helpers in
resolution.py and have both consumers call them. `_import_php` is
dispatched per `namespace_use_clause` and never sees the declaration, so
`_php_use_clause_context` recovers the group-use prefix and the
declaration-level `function`/`const` keyword from the parent node; the
edge then carries `target_fqn`, `alias` and `use_kind`, mirroring
`_import_csharp`.
Strictly metadata-only. The edge target stays keyed on the imported short
name, `_PHP_CONFIG.import_types` stays `namespace_use_clause` (so a trait
`use` inside a class body — a `use_declaration` — is still not an import),
and `_resolve_php_type_references` keeps its exact behavior: it passes
`apply_declaration_kind=False` because it has only ever honored a
clause-level `function`/`const` keyword, never the declaration-level one a
group `use function A\{f, g};` carries. Verified beyond the suite by
diffing full extract() output (metadata stripped) over a PHP corpus
covering every `use` form plus the existing fixtures — byte-identical
before and after.
Tests go through the public extract() seam: plain, aliased, group and
aliased-group use, leading-backslash normalization, and edge targets
unchanged. The `use function` / `use const` exclusion is asserted where it
is observable — a same-file `use Vendor\Sdk\Base;` control re-points its
supertype reference onto the FQN stub while the `use function` /
`use const` names must not, so they stay on the bare stub.
Suite: 4032 passed / 36 skipped (baseline 4024 / 36 plus 8 new).
fix(php): refuse the same-file bare-name edge for union/intersection receivers (#9)
The Swift, Python and TypeScript member-call resolvers skipped raw calls
carrying a `lang` tag, and each site claimed that guard kept another
language's data out. It did not: `lang` is stamped only on the cpp, csharp,
java, objc and php raw calls, and those three resolvers are themselves
untagged — so they consumed each other's raw calls. A TypeScript
`Lead.search({})` reached the Python resolver's capitalized-receiver class
arm and minted an EXTRACTED edge into a Python method with no TypeScript
`Lead` anywhere in the corpus. A negative tag check cannot close that by
construction.
Replace it with a positive source-file suffix filter (prior art:
`ruby_resolution._ruby_raw_calls`) and correct the comments at all three
sites. The tagged languages keep matching on `lang`, because C++ and ObjC
share `.h` and a suffix alone cannot tell their raw calls apart.
Also language-scope the Java and C# `type_def_nids` indexes, the last
unfixed copies of the shape #8 fixed for PHP/ObjC: an unscoped index let a
Python `class Lead` type the receiver of a Java `Lead lead; lead.search()`
at INFERRED, and let a foreign class merely sharing the name push the
single-definition guard to 2 and suppress the correct same-language edge.
The suffix tuples now feed both the resolver registration and the scoping,
so the two cannot drift.
Pre-existing defects, not regressions from the Graphify-Labs#1682 work.
Tests: 8 new mixed-corpus cases through the `extract()` seam, each built so
only one resolver can be the miner — the resolver that owns the raw call
refuses it on its own terms, so any surviving edge is a foreign resolver
reaching across. 7 of the 8 were red before this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings in #9 (union/intersection receiver refusal, PHP 8.2 DNF types). Conflict in CHANGELOG.md only: both sides edited the "Known open items" line of the unreleased 0.9.34 section — #9 removed its own clause, this branch removed the #15 clause. Resolved by keeping the two #9 Fix bullets and this branch's first-class-callable behavior-change bullet, with the open-items line now naming only #10. graphify/extractors/engine.py auto-merged: #9 changed how _php_receiver_type is derived (a PRESENT key mapped to None marks a multi-class annotation and now defers), while #15 adds the `php_fcc` capture and the indirect_call emission just below it. The interaction is the intended one — a union-typed receiver's first-class callable defers like its ordinary call does, and the cross-file resolver then refuses it for want of a receiver type, so `$multi->m(...)` emits no edge at all. Full suite: 4041 passed, 36 skipped (4034 on v8 + this branch's 7). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(php): emit indirect_call for first-class callables (#15)
Brings in the #9 (union/intersection + DNF receiver types) and #15 (first-class callables as `indirect_call`) fixes. CHANGELOG.md was the only conflict: both sides edited the "Known open items" bullet, each deleting the clause its own work fixed. Between them every clause is now gone — #9 and #15 on v8's side, #10 on this branch's — so the bullet is deleted outright rather than resolved. All three of v8's new bullets and this branch's #10 bullet are kept. graphify/extract.py auto-merged: v8's changes are confined to `_resolve_php_member_calls`, which this branch does not touch. Verified both sides landed byte-for-byte and that v8's engine.py receiver-table and first-class-callable work is gated on `tree_sitter_php`, so it cannot reach the Swift/Python/TypeScript raw-call filters or the Java/C# type indexes. Full suite: 4049 passed, 36 skipped (4041 on v8 + 8 from this branch). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(extract): scope member-call resolvers to the sources they own (#10)
…ture-19 * origin/v8: fix(extract): scope member-call resolvers to the sources they own (#10) fix(php): recognize PHP 8.2 DNF property and promoted-param types (#9) fix(php): refuse the same-file bare-name edge for union/intersection receivers (#9) fix(php): emit indirect_call for first-class callables (#15)
feat(php): capture use FQN/alias/kind metadata on imports edges (#19)
…ass names (#26) `_resolve_php_type_references` builds its per-file class-name map from `use` statements and correctly refuses `use function` / `use const` — but only when the keyword sits on the CLAUSE. tree-sitter-php (0.24.1) puts it on the DECLARATION for the group form, and the pass only ever iterated clause children, so `use function Vendor\Sdk\{Render};` registered `Render` as a claimed class name. Any `inherits` / `implements` / `mixes_in` / `imports` / `references` to that short name in the same file was then treated as an explicit import and re-pointed onto an FQN-labeled external stub — a stub naming a function or a constant, not a class. #19 already extracted the parser into `_php_use_declaration_facts`, which reads the declaration-level keyword and reports the right `use_kind`; the `imports`-edge capture consumes it that way today. This pass was the only caller passing `apply_declaration_kind=False`, purely to keep #19's metadata-only constraint byte-exact. Drop the argument and delete the flag, so one code path serves both consumers and cannot drift again. Strictly subtractive: it can only remove a class-name claim, which makes the affected reference fall back to the namespace-relative FQN or to the legacy unique-label rewire — the same place the unbraced spelling has always left it. Worth landing before #21's decisive-refusal work, where a wrongly claimed short name would become a wrongly decisive refusal. Pre-existing defect, not a regression from #19. Tests go through the public `extract()` seam with the plain form as the side-by-side control in the same test: group-vs-plain for `use function` and for `use const` (both spellings must land on the same bare stub, and no `Vendor\Sdk\*` class stub may be minted), every member of a multi-member group rejected, and an over-subtraction guard — a keyword-less group `use App\Cms\{Page};` still claims its member and resolves past a decoy `App\Models\Page`, which gets no edge. 3 of the 4 were red before this change; the guard was green both sides. Suite: 4061 passed / 36 skipped (baseline 4057 / 36 plus 4 new). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(php): stop group-form use function/const from claiming class names (#26)
`_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.
…raphify-Labs#2615) - CHANGELOG: replace the fork's per-release sections and sync bookkeeping with one 0.9.40 (unreleased) section that holds only the fork-authored changes, grouped and ordered as they landed on the fork. - pyproject: 0.9.41 -> 0.9.40 (one bump over upstream v0.9.39; rolls the version-namespaced AST cache that the extraction-side fixes require; uv.lock already reads 0.9.40). - docs/how-it-works.md: interface/enum/trait-typed receivers bind to the declaration's own method since #53; the refusal list was stale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Formal verification. 5 change(s) tested, no difference found (not proven).
Graphify review — findings
This pull request consolidates PHP support and member-call resolution work from a downstream fork, primarily focused on how PHP instance-method calls on typed receivers are resolved to their target methods. It also touches cross-language isolation so member-call and receiver-type resolvers across many languages (PHP, ObjC, Java, C#, C++, Swift, TypeScript, Python, etc.) only match definitions in their own source languages, and repairs import-edge resolution when files share a stem across several languages. The changes span the extractor engine, resolution logic, CLI/serve/watch paths, associated tests, and extensive CHANGELOG entries.
No blocking issues surfaced.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 3446 functions depend on the 1998 functions this change touches.
Health — this change adds coupling hotspots:
- worse:
extract()— 474 callers, 44 callees - worse:
_rebuild_code()— 99 callers, 51 callees - worse:
_extract_generic()— 18 callers, 27 callees - worse:
_query_graph_text()— 33 callers, 7 callees - worse:
_score_query()— 24 callers, 6 callees - worse:
resolve_seed()— 18 callers, 5 callees - worse:
_query_terms()— 22 callers, 3 callees - worse:
walk()— 1 callers, 56 callees - …and 15 more
Verification — 3446 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: 3403 function(s) in the blast radius were not formally verified this run
Formal verification
No difference found (not proven): No behavior difference found in resolve\_seed (not a proof).
The verifier ran both versions of resolve\_seed on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify dispatch\_command.
The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_import\_php.
The verifier did not have enough to check \_import\_php, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_resolve\_cpp\_member\_calls.
The verifier did not have enough to check \_resolve\_cpp\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_csharp\_member\_calls.
The verifier did not have enough to check \_resolve\_csharp\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_java\_member\_calls.
The verifier did not have enough to check \_resolve\_java\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_objc\_member\_calls.
The verifier did not have enough to check \_resolve\_objc\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_python\_member\_calls.
The verifier did not have enough to check \_resolve\_python\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_swift\_member\_calls.
The verifier did not have enough to check \_resolve\_swift\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_typescript\_member\_calls.
The verifier did not have enough to check \_resolve\_typescript\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify extract.
The verifier did not have enough to check extract, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `cache_root` is annotated `Path | None` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_extract\_generic.
The verifier did not have enough to check \_extract\_generic, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_resolve\_php\_type\_references.
The verifier did not have enough to check \_resolve\_php\_type\_references, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
No difference found (not proven): No behavior difference found in \_find\_node\_tiers (not a proof).
The verifier ran both versions of \_find\_node\_tiers on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_pick\_seeds (not a proof).
The verifier ran both versions of \_pick\_seeds on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_query\_graph\_text (not a proof).
The verifier ran both versions of \_query\_graph\_text on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify \_score\_query.
The verifier did not have enough to check \_score\_query, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 35 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)
No difference found (not proven): No behavior difference found in find\_node\_ambiguity (not a proof).
The verifier ran both versions of find\_node\_ambiguity on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify resolve\_bash\_source\_edges.
The verifier did not have enough to check resolve\_bash\_source\_edges, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `per_file` is annotated `Sequence` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_rebuild\_code.
The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set
· 15 grounded finding(s) anchored inline below; 8 more finding(s) on lines outside this diff (see the check run).
| return sourced[0] if len(sourced) == 1 else None | ||
|
|
||
|
|
||
| def resolve_seed(graph: nx.Graph, query: str) -> str | None: |
There was a problem hiding this comment.
resolve_seed()
18 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return entries | ||
|
|
||
|
|
||
| def _resolve_php_member_calls( |
There was a problem hiding this comment.
_resolve_php_member_calls()
fans out to 8 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.
| return table | ||
|
|
||
|
|
||
| def _php_ctor_assigned_field_types( |
There was a problem hiding this comment.
_php_ctor_assigned_field_types()
fans out to 8 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -4496,9 +5154,15 @@ def _php_class_const_scope(n) -> str | None: | |||
| def walk_calls( | |||
There was a problem hiding this comment.
walk_calls()
fans out to 15 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| raise AssertionError(f"no node count in header: {text.splitlines()[:1]}") | ||
|
|
||
|
|
||
| def test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded(): |
There was a problem hiding this comment.
test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded()
fans out to 12 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return "" | ||
|
|
||
|
|
||
| def test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed(): |
There was a problem hiding this comment.
test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed()
fans out to 7 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert "relaxed" in context, f"header does not report the relaxation: {context!r}" | ||
|
|
||
|
|
||
| def test_expanding_heuristic_filter_is_left_in_force(): |
There was a problem hiding this comment.
test_expanding_heuristic_filter_is_left_in_force()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| ) | ||
|
|
||
|
|
||
| def test_single_node_expansion_is_not_starvation(): |
There was a problem hiding this comment.
test_single_node_expansion_is_not_starvation()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| } | ||
|
|
||
|
|
||
| def test_incremental_rebuild_keeps_php_enum_and_trait_binding(tmp_path): |
There was a problem hiding this comment.
test_incremental_rebuild_keeps_php_enum_and_trait_binding()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
Resolves the PR conflicts against upstream v0.9.40 (upstream 26128ab). CHANGELOG.md was the only conflict. Upstream released and tagged 0.9.40 while this PR was open, and this PR's consolidated section claimed that same number, so the section is renumbered to 0.9.41 (with pyproject.toml and the AST-cache-roll bullet following it) and upstream's own 0.9.40 section is kept verbatim below it. Upstream's re-dated 0.9.39 heading is taken over ours. pyproject.toml did not conflict: both sides had independently moved to 0.9.40, which is exactly the collision above; it is set to 0.9.41 here. No code file conflicted. The merged graphify/ and tests/ trees are byte-identical to the fork's v8 sync merge, which runs 4566 passed / 42 skipped / 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Formal verification. 5 change(s) tested, no difference found (not proven).
Graphify review — findings
This pull request consolidates PHP-support and resolution work from the lawnstarter/graphify fork, centered on improving PHP instance-method call resolution so that calls on typed receivers bind to the method actually reached rather than a same-name match. It touches multiple language extractors and member-call resolvers to scope receiver-type and raw-call indexes to their own source languages, and repairs import edges that could vanish under same-stem filename collisions. The change spans the PHP/resolution extractors, member-call resolution logic across many languages (C++, Swift, TypeScript, Python, Java, C#, ObjC, etc.), watch/rebuild code, serve/CLI helpers, and a large set of accompanying tests, along with CHANGELOG entries describing the intended behavior. The stated intent leans heavily on a "refuse-don't-guess" policy for cases where a receiver's type can't be resolved to a single concrete in-corpus class.
No blocking issues surfaced.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 3479 functions depend on the 2006 functions this change touches.
Health — this change adds coupling hotspots:
- worse:
extract()— 483 callers, 46 callees - worse:
_rebuild_code()— 99 callers, 51 callees - worse:
_extract_generic()— 18 callers, 28 callees - worse:
_query_graph_text()— 33 callers, 7 callees - worse:
_score_query()— 24 callers, 6 callees - worse:
resolve_seed()— 18 callers, 5 callees - worse:
_query_terms()— 22 callers, 3 callees - worse:
walk()— 1 callers, 56 callees - …and 15 more
Verification — 3479 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: 3436 function(s) in the blast radius were not formally verified this run
Formal verification
No difference found (not proven): No behavior difference found in resolve\_seed (not a proof).
The verifier ran both versions of resolve\_seed on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify dispatch\_command.
The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_import\_php.
The verifier did not have enough to check \_import\_php, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_resolve\_cpp\_member\_calls.
The verifier did not have enough to check \_resolve\_cpp\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_csharp\_member\_calls.
The verifier did not have enough to check \_resolve\_csharp\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_java\_member\_calls.
The verifier did not have enough to check \_resolve\_java\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_objc\_member\_calls.
The verifier did not have enough to check \_resolve\_objc\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_python\_member\_calls.
The verifier did not have enough to check \_resolve\_python\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_swift\_member\_calls.
The verifier did not have enough to check \_resolve\_swift\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_typescript\_member\_calls.
The verifier did not have enough to check \_resolve\_typescript\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify extract.
The verifier did not have enough to check extract, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `cache_root` is annotated `Path | None` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_extract\_generic.
The verifier did not have enough to check \_extract\_generic, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_resolve\_php\_type\_references.
The verifier did not have enough to check \_resolve\_php\_type\_references, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
No difference found (not proven): No behavior difference found in \_find\_node\_tiers (not a proof).
The verifier ran both versions of \_find\_node\_tiers on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_pick\_seeds (not a proof).
The verifier ran both versions of \_pick\_seeds on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_query\_graph\_text (not a proof).
The verifier ran both versions of \_query\_graph\_text on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify \_score\_query.
The verifier did not have enough to check \_score\_query, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 35 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)
No difference found (not proven): No behavior difference found in find\_node\_ambiguity (not a proof).
The verifier ran both versions of find\_node\_ambiguity on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify resolve\_bash\_source\_edges.
The verifier did not have enough to check resolve\_bash\_source\_edges, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `per_file` is annotated `Sequence` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_rebuild\_code.
The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set
· 15 grounded finding(s) anchored inline below; 8 more finding(s) on lines outside this diff (see the check run).
| return sourced[0] if len(sourced) == 1 else None | ||
|
|
||
|
|
||
| def resolve_seed(graph: nx.Graph, query: str) -> str | None: |
There was a problem hiding this comment.
resolve_seed()
18 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return entries | ||
|
|
||
|
|
||
| def _resolve_php_member_calls( |
There was a problem hiding this comment.
_resolve_php_member_calls()
fans out to 8 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.
| return table | ||
|
|
||
|
|
||
| def _php_ctor_assigned_field_types( |
There was a problem hiding this comment.
_php_ctor_assigned_field_types()
fans out to 8 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -4516,9 +5174,15 @@ def _php_class_const_scope(n) -> str | None: | |||
| def walk_calls( | |||
There was a problem hiding this comment.
walk_calls()
fans out to 15 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| raise AssertionError(f"no node count in header: {text.splitlines()[:1]}") | ||
|
|
||
|
|
||
| def test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded(): |
There was a problem hiding this comment.
test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded()
fans out to 12 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return "" | ||
|
|
||
|
|
||
| def test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed(): |
There was a problem hiding this comment.
test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed()
fans out to 7 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert "relaxed" in context, f"header does not report the relaxation: {context!r}" | ||
|
|
||
|
|
||
| def test_expanding_heuristic_filter_is_left_in_force(): |
There was a problem hiding this comment.
test_expanding_heuristic_filter_is_left_in_force()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| ) | ||
|
|
||
|
|
||
| def test_single_node_expansion_is_not_starvation(): |
There was a problem hiding this comment.
test_single_node_expansion_is_not_starvation()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| } | ||
|
|
||
|
|
||
| def test_incremental_rebuild_keeps_php_enum_and_trait_binding(tmp_path): |
There was a problem hiding this comment.
test_incremental_rebuild_keeps_php_enum_and_trait_binding()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
Resolves the PR conflicts against upstream v0.9.42 (upstream 7fe58b0), refreshing the branch from the v0.9.40 state the last merge left it in. CHANGELOG.md: upstream released 0.9.41 on 2026-08-12 while this PR was open, and this PR's consolidated section claimed that same number - the same collision the last refresh resolved one version down. The section is renumbered to 0.9.43 (one patch above the higher of the two sides, per the sync convention), its AST-cache-roll bullet follows it, and upstream's 0.9.42 (unreleased), 0.9.41 (2026-08-12) and re-dated 0.9.40 (2026-08-11) sections are kept verbatim below it. pyproject.toml: 0.9.41 (ours) vs 0.9.42 (upstream) -> 0.9.43. uv.lock: regenerated for the bump. The committed lock had been stale since before the last refresh - byte-identical to the 0.9.40 base while pyproject.toml read 0.9.41 - so it did not previously appear in this PR's delta at all. `uv lock --check` passes. graphify/affected.py: two insertions at the same point in resolve_seed. The fork's sourced-stub preference (#49/#54) is kept first and upstream's repo-relative query_path (Graphify-Labs#2707) second; neither guards the other. graphify/extractors/resolution.py: upstream's prefixed-use-import alias fallback (Graphify-Labs#2661) is folded into the else-branch of the fork's target_fqn-metadata restructure (#48), firing only when that path declines and no stub label exists. Upstream Graphify-Labs#2661 also moved the `uses = uses_by_file.get(ref_file, {})` binding into the region the fork rewrote; the deletion at the moved-from site auto-merges silently, so the binding is kept explicitly above the repoint block - without it the fork's else-branch raises NameError on the first PHP repoint edge. Delta audit: `git diff --name-only upstream/v8` covers the same 31 files as the previous refresh plus uv.lock, with none dropped. Tests on the merged tree: 4672 passed / 45 skipped / 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Formal verification. 1 change(s) alter behavior, breaking input(s) attached.
Behavior changes: resolve\_seed changes behavior, here is the input that shows it.
The verifier found a concrete input on which resolve\_seed behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.
Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.
Evidence: On input \{"graph":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\(1, \{\}\), \(2, \{\}\), \(3, \{\}\)\]\), \_g\.add\_edges\_from\(\[\(1, 2, \{\}\), \(1, 3, \{\}\), \(2, 3, \{\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","query":"'\(\)'"\}, the old code produced None but the new code produces raises KeyError. Paste that input straight into a regression test.
Graphify review — findings
This PR consolidates PHP-support and cross-language resolution work merged from a downstream fork (lawnstarter/graphify), centered on how member calls and import edges are resolved. The main surface area is the PHP member-call resolver (typing receivers via properties, promoted constructor params, new expressions, etc.), language-scoping of receiver-type and raw-call indexes across all ten resolvers (PHP, ObjC, Java, C#, C++, Swift, TypeScript, Python), and import-edge target disambiguation for several languages (Python, Rust, Zig, Elixir, PowerShell, Pascal, Bash). Supporting changes touch the extractors/engine, resolution, CLI, watch, and serve modules, along with a new CHANGELOG entry (0.9.43) and a large set of accompanying tests covering the resolution and refusal behaviors described above. I'm only describing the stated intent and affected areas; I'm not evaluating whether the changes are correct or complete.
No blocking issues surfaced.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 3507 functions depend on the 2014 functions this change touches.
Health — this change adds coupling hotspots:
- worse:
extract()— 495 callers, 46 callees - worse:
_rebuild_code()— 100 callers, 51 callees - worse:
_extract_generic()— 18 callers, 28 callees - worse:
_query_graph_text()— 33 callers, 7 callees - worse:
_score_query()— 24 callers, 6 callees - worse:
resolve_seed()— 19 callers, 6 callees - worse:
_query_terms()— 22 callers, 3 callees - worse:
walk()— 1 callers, 56 callees - …and 15 more
Verification — 3507 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: 3464 function(s) in the blast radius were not formally verified this run
Formal verification
Behavior changes: resolve\_seed changes behavior, here is the input that shows it.
The verifier found a concrete input on which resolve\_seed behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.
Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.
Evidence: On input \{"graph":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\(1, \{\}\), \(2, \{\}\), \(3, \{\}\)\]\), \_g\.add\_edges\_from\(\[\(1, 2, \{\}\), \(1, 3, \{\}\), \(2, 3, \{\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","query":"'\(\)'"\}, the old code produced None but the new code produces raises KeyError. Paste that input straight into a regression test.
Could not verify: Could not verify dispatch\_command.
The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
Could not verify: Could not verify extract.
The verifier did not have enough to check extract, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `cache_root` is annotated `Path | None` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_import\_php.
The verifier did not have enough to check \_import\_php, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_resolve\_cpp\_member\_calls.
The verifier did not have enough to check \_resolve\_cpp\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_csharp\_member\_calls.
The verifier did not have enough to check \_resolve\_csharp\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_java\_member\_calls.
The verifier did not have enough to check \_resolve\_java\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_objc\_member\_calls.
The verifier did not have enough to check \_resolve\_objc\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_python\_member\_calls.
The verifier did not have enough to check \_resolve\_python\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_swift\_member\_calls.
The verifier did not have enough to check \_resolve\_swift\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_typescript\_member\_calls.
The verifier did not have enough to check \_resolve\_typescript\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_extract\_generic.
The verifier did not have enough to check \_extract\_generic, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_resolve\_php\_type\_references.
The verifier did not have enough to check \_resolve\_php\_type\_references, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
No difference found (not proven): No behavior difference found in find\_node\_ambiguity (not a proof).
The verifier ran both versions of find\_node\_ambiguity on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_find\_node\_tiers (not a proof).
The verifier ran both versions of \_find\_node\_tiers on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_pick\_seeds (not a proof).
The verifier ran both versions of \_pick\_seeds on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_query\_graph\_text (not a proof).
The verifier ran both versions of \_query\_graph\_text on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify \_score\_query.
The verifier did not have enough to check \_score\_query, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 45 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify resolve\_bash\_source\_edges.
The verifier did not have enough to check resolve\_bash\_source\_edges, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `per_file` is annotated `Sequence` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_rebuild\_code.
The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set
· 15 grounded finding(s) anchored inline below; 8 more finding(s) on lines outside this diff (see the check run).
| return sourced[0] if len(sourced) == 1 else None | ||
|
|
||
|
|
||
| def resolve_seed(graph: nx.Graph, query: str) -> str | None: |
There was a problem hiding this comment.
resolve_seed()
fans out to 6 callees (efferent coupling); 19 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return entries | ||
|
|
||
|
|
||
| def _resolve_php_member_calls( |
There was a problem hiding this comment.
_resolve_php_member_calls()
fans out to 8 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.
| return table | ||
|
|
||
|
|
||
| def _php_ctor_assigned_field_types( |
There was a problem hiding this comment.
_php_ctor_assigned_field_types()
fans out to 8 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -4533,9 +5191,15 @@ def _php_class_const_scope(n) -> str | None: | |||
| def walk_calls( | |||
There was a problem hiding this comment.
walk_calls()
fans out to 16 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| raise AssertionError(f"no node count in header: {text.splitlines()[:1]}") | ||
|
|
||
|
|
||
| def test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded(): |
There was a problem hiding this comment.
test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded()
fans out to 12 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return "" | ||
|
|
||
|
|
||
| def test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed(): |
There was a problem hiding this comment.
test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed()
fans out to 7 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert "relaxed" in context, f"header does not report the relaxation: {context!r}" | ||
|
|
||
|
|
||
| def test_expanding_heuristic_filter_is_left_in_force(): |
There was a problem hiding this comment.
test_expanding_heuristic_filter_is_left_in_force()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| ) | ||
|
|
||
|
|
||
| def test_single_node_expansion_is_not_starvation(): |
There was a problem hiding this comment.
test_single_node_expansion_is_not_starvation()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| } | ||
|
|
||
|
|
||
| def test_incremental_rebuild_keeps_php_enum_and_trait_binding(tmp_path): |
There was a problem hiding this comment.
test_incremental_rebuild_keeps_php_enum_and_trait_binding()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.
Formal verification. 4 change(s) tested, no difference found (not proven).
Graphify review — findings
Documents the 0.9.47 (unreleased) changelog entry consolidating PHP member-call resolution and cross-language isolation work from the lawnstarter/graphify fork; the diff shown is CHANGELOG.md only. Behavior described spans PHP typed-receiver call binding ($this->prop->method(), nullsafe/promoted/native-typed params, (new T())->method() as EXTRACTED vs INFERRED locals), per-language scoping of all ten member-call receiver-type indexes via a shared _is_owned_definition predicate, and import-edge repair via a target_file disambiguation hint across Python/Rust/Zig/Elixir/PowerShell/Pascal/Bash.
Worth a look
- multi_typed_params setdefault can resurrect a poisoned param as unresolved None —
graphify/extractors/engine.py· Escalate · high- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review Execution auto-disposal is off for this run; enable it (with sandbox isolation) to have Graphify try to confirm or refute this automatically.
- Nullsafe PHP member calls are not registered for member-call name extraction —
graphify/extract.py:1146· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review Execution auto-disposal is off for this run; enable it (with sandbox isolation) to have Graphify try to confirm or refute this automatically.
- Fully-qualified PHP
newexpressions are treated as untyped —graphify/extractors/engine.py· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review Execution auto-disposal is off for this run; enable it (with sandbox isolation) to have Graphify try to confirm or refute this automatically.
- Namespaced unqualified PHP types are treated as unclaimed and can fall back to global bare-name matches —
graphify/extractors/php.py· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review Execution auto-disposal is off for this run; enable it (with sandbox isolation) to have Graphify try to confirm or refute this automatically.
- Two-namespace file check only refuses for qualified written form, not use-imported bare names —
graphify/extractors/php.py· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review Execution auto-disposal is off for this run; enable it (with sandbox isolation) to have Graphify try to confirm or refute this automatically.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 3593 functions depend on the 2042 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 518 callers, 46 callees - new:
_rebuild_code()— 103 callers, 51 callees - new:
_extract_generic()— 18 callers, 29 callees - new:
extract_xaml()— 19 callers, 17 callees - new:
extract_objc()— 27 callers, 9 callees - new:
dispatch_command()— 2 callers, 117 callees - new:
_query_graph_text()— 33 callers, 7 callees - new:
extract_js()— 76 callers, 3 callees - …and 77 more — each is listed as a finding
Verification — 3593 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: 3550 function(s) in the blast radius were not formally verified this run
Formal verification
Could not verify: Could not verify resolve\_seed.
The verifier did not have enough to check resolve\_seed, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `root` is annotated `Path | None` — outside the synthesizable primitive/collection set
Could not verify: Could not verify dispatch\_command.
The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
Could not verify: Could not verify extract.
The verifier did not have enough to check extract, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `cache_root` is annotated `Path | None` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_import\_php.
The verifier did not have enough to check \_import\_php, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_resolve\_cpp\_member\_calls.
The verifier did not have enough to check \_resolve\_cpp\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_csharp\_member\_calls.
The verifier did not have enough to check \_resolve\_csharp\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_java\_member\_calls.
The verifier did not have enough to check \_resolve\_java\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_objc\_member\_calls.
The verifier did not have enough to check \_resolve\_objc\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_python\_member\_calls.
The verifier did not have enough to check \_resolve\_python\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_swift\_member\_calls.
The verifier did not have enough to check \_resolve\_swift\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_typescript\_member\_calls.
The verifier did not have enough to check \_resolve\_typescript\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_extract\_generic.
The verifier did not have enough to check \_extract\_generic, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_resolve\_php\_type\_references.
The verifier did not have enough to check \_resolve\_php\_type\_references, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
No difference found (not proven): No behavior difference found in find\_node\_ambiguity (not a proof).
The verifier ran both versions of find\_node\_ambiguity on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_find\_node\_tiers (not a proof).
The verifier ran both versions of \_find\_node\_tiers on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_pick\_seeds (not a proof).
The verifier ran both versions of \_pick\_seeds on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_query\_graph\_text (not a proof).
The verifier ran both versions of \_query\_graph\_text on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify \_score\_query.
The verifier did not have enough to check \_score\_query, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 45 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify resolve\_bash\_source\_edges.
The verifier did not have enough to check resolve\_bash\_source\_edges, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `per_file` is annotated `Sequence` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_rebuild\_code.
The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set
· 15 grounded finding(s) anchored inline below; 70 more finding(s) on lines outside this diff (see the check run).
| return sourced[0] if len(sourced) == 1 else None | ||
|
|
||
|
|
||
| def resolve_seed(graph: nx.Graph, query: str, root: Path | None = None) -> str | None: |
There was a problem hiding this comment.
resolve_seed()
fans out to 6 callees (efferent coupling); 19 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return entries | ||
|
|
||
|
|
||
| def _resolve_php_member_calls( |
There was a problem hiding this comment.
_resolve_php_member_calls()
fans out to 8 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.
| return table | ||
|
|
||
|
|
||
| def _php_ctor_assigned_field_types( |
There was a problem hiding this comment.
_php_ctor_assigned_field_types()
fans out to 8 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -4801,9 +5459,15 @@ def _php_class_const_scope(n) -> str | None: | |||
| def walk_calls( | |||
There was a problem hiding this comment.
walk_calls()
fans out to 17 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| raise AssertionError(f"no node count in header: {text.splitlines()[:1]}") | ||
|
|
||
|
|
||
| def test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded(): |
There was a problem hiding this comment.
test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded()
fans out to 12 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return "" | ||
|
|
||
|
|
||
| def test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed(): |
There was a problem hiding this comment.
test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed()
fans out to 7 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert "relaxed" in context, f"header does not report the relaxation: {context!r}" | ||
|
|
||
|
|
||
| def test_expanding_heuristic_filter_is_left_in_force(): |
There was a problem hiding this comment.
test_expanding_heuristic_filter_is_left_in_force()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| ) | ||
|
|
||
|
|
||
| def test_single_node_expansion_is_not_starvation(): |
There was a problem hiding this comment.
test_single_node_expansion_is_not_starvation()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| } | ||
|
|
||
|
|
||
| def test_incremental_rebuild_keeps_php_enum_and_trait_binding(tmp_path): |
There was a problem hiding this comment.
test_incremental_rebuild_keeps_php_enum_and_trait_binding()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.
Formal verification. 4 change(s) tested, no difference found (not proven).
Graphify review — findings
Consolidates the fork's PHP-support and resolution work behind a refuse-don't-guess policy for member-call resolution: PHP instance-method calls on a typed receiver ($this->prop->method(), nullsafe, promoted constructor params, $var = new T() locals, inline (new Service())->method()) now bind to the method actually reached, tagging inline forms EXTRACTED and other typed receivers INFERRED, while every receiver whose type isn't provably one concrete in-corpus class emits no edge — union/intersection types, interface/enum/trait types, ambiguous short names, magic dispatch, chained/array receivers, rebound locals, shadowed names, anonymous classes, and self/static/parent. Scopes all ten member-call receiver-type indexes and each resolver's raw-call consumption to their own source suffixes via a shared _is_owned_definition predicate, removing cross-language edges (some previously EXTRACTED) and restoring same-language edges that a foreign short-name collision had silently suppressed. Repairs vanishing imports edges under same-stem file collisions in Python/Rust/Zig/Elixir/PowerShell/Pascal/Bash by centrally stamping a transient target_file hint the disambiguator consumes and never persists, and fixes Bash's duplicate/dangling edge from resolve_bash_source_edges.
Worth a look
- TypeScript/JS resolver suffix filter omits .mjs/.cjs —
graphify/extract.py· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Nullsafe PHP member calls are not registered for member-call handling —
graphify/extract.py:1142· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- TypeScript/Python raw calls with .h-less source but tagged lang now consumed by suffix instead of lang —
graphify/extract.py:3234· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- PHP first-class callable detection only handles member calls —
graphify/extractors/engine.py:5876· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Nullsafe property receivers are not captured for PHP member resolution —
graphify/extractors/engine.py:5913· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 3760 functions depend on the 2072 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 540 callers, 47 callees - new:
_rebuild_code()— 103 callers, 50 callees - new:
_extract_generic()— 18 callers, 29 callees - new:
extract_xaml()— 19 callers, 17 callees - new:
_query_graph_text()— 35 callers, 8 callees - new:
dispatch_command()— 2 callers, 122 callees - new:
extract_objc()— 27 callers, 9 callees - new:
extract_js()— 80 callers, 3 callees - …and 79 more — each is listed as a finding
Verification — 3760 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: 3713 function(s) in the blast radius were not formally verified this run
Formal verification
Could not verify: Could not verify resolve\_seed.
The verifier did not have enough to check resolve\_seed, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `root` is annotated `Path | None` — outside the synthesizable primitive/collection set
Could not verify: Could not verify dispatch\_command.
The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
Could not verify: Could not verify extract.
The verifier did not have enough to check extract, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `cache_root` is annotated `Path | None` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_import\_php.
The verifier did not have enough to check \_import\_php, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_resolve\_cpp\_member\_calls.
The verifier did not have enough to check \_resolve\_cpp\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_csharp\_member\_calls.
The verifier did not have enough to check \_resolve\_csharp\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_java\_member\_calls.
The verifier did not have enough to check \_resolve\_java\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_objc\_member\_calls.
The verifier did not have enough to check \_resolve\_objc\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_python\_member\_calls.
The verifier did not have enough to check \_resolve\_python\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_swift\_member\_calls.
The verifier did not have enough to check \_resolve\_swift\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_typescript\_member\_calls.
The verifier did not have enough to check \_resolve\_typescript\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_extract\_generic.
The verifier did not have enough to check \_extract\_generic, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_resolve\_php\_type\_references.
The verifier did not have enough to check \_resolve\_php\_type\_references, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
No difference found (not proven): No behavior difference found in find\_node\_ambiguity (not a proof).
The verifier ran both versions of find\_node\_ambiguity on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_find\_node\_tiers (not a proof).
The verifier ran both versions of \_find\_node\_tiers on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_pick\_seeds (not a proof).
The verifier ran both versions of \_pick\_seeds on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_query\_graph\_text (not a proof).
The verifier ran both versions of \_query\_graph\_text on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify \_score\_query.
The verifier did not have enough to check \_score\_query, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 45 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify resolve\_bash\_source\_edges.
The verifier did not have enough to check resolve\_bash\_source\_edges, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `per_file` is annotated `Sequence` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_rebuild\_code.
The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set
· 15 grounded finding(s) anchored inline below; 72 more finding(s) on lines outside this diff (see the check run).
| return sourced[0] if len(sourced) == 1 else None | ||
|
|
||
|
|
||
| def resolve_seed(graph: nx.Graph, query: str, root: Path | None = None) -> str | None: |
There was a problem hiding this comment.
resolve_seed()
fans out to 6 callees (efferent coupling); 19 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return entries | ||
|
|
||
|
|
||
| def _resolve_php_member_calls( |
There was a problem hiding this comment.
_resolve_php_member_calls()
fans out to 8 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.
| return table | ||
|
|
||
|
|
||
| def _php_ctor_assigned_field_types( |
There was a problem hiding this comment.
_php_ctor_assigned_field_types()
fans out to 8 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -4941,9 +5599,15 @@ def _php_class_const_scope(n) -> str | None: | |||
| def walk_calls( | |||
There was a problem hiding this comment.
walk_calls()
fans out to 22 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| raise AssertionError(f"no node count in header: {text.splitlines()[:1]}") | ||
|
|
||
|
|
||
| def test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded(): |
There was a problem hiding this comment.
test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded()
fans out to 12 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return "" | ||
|
|
||
|
|
||
| def test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed(): |
There was a problem hiding this comment.
test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed()
fans out to 7 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert "relaxed" in context, f"header does not report the relaxation: {context!r}" | ||
|
|
||
|
|
||
| def test_expanding_heuristic_filter_is_left_in_force(): |
There was a problem hiding this comment.
test_expanding_heuristic_filter_is_left_in_force()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| ) | ||
|
|
||
|
|
||
| def test_single_node_expansion_is_not_starvation(): |
There was a problem hiding this comment.
test_single_node_expansion_is_not_starvation()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| } | ||
|
|
||
|
|
||
| def test_incremental_rebuild_keeps_php_enum_and_trait_binding(tmp_path): |
There was a problem hiding this comment.
test_incremental_rebuild_keeps_php_enum_and_trait_binding()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
|
Hey @safishamsi 👋 First of all, thank you for maintaining such a great product. We've been using the hell out of it at LS. What can we do to move these patches forward? |
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.
Formal verification. 4 change(s) tested, no difference found (not proven).
Graphify review — findings
Consolidates PHP member-call resolution from the lawnstarter/graphify fork: instance-method calls on a typed receiver now bind to the method actually reached via the property's or promoted-constructor-param's declared type (nullsafe and new T() locals included), tagging inline (new Service())->method() as EXTRACTED and other typed receivers as INFERRED, while refusing to emit any edge when the type isn't provably one concrete in-corpus class (untyped, union/intersection, interface/enum/trait, magic __call, chained/array/shadowed receivers, self/static/parent). Scopes all ten per-language member-call resolvers and their receiver-type indexes to their own source suffixes via a shared _is_owned_definition predicate, so polyglot corpora stop minting cross-language edges from same-short-name collisions and regain the same-language edges those collisions previously suppressed. Stamps a target_file hint on imports edges in Python, Rust, Zig, Elixir, PowerShell, Pascal and Bash so an import target no longer disappears when a same-stem file of another language forces id-disambiguation, and fixes Bash's duplicate/dangling edge by reading post-rename ids.
Worth a look
- New PHP extractor module has an unterminated expression —
graphify/extractors/php.py:254· Escalate · high- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Lone sourceless exact-label match no longer resolves, silently falls through —
graphify/affected.py:167· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Unique exact-label seeds without source_file no longer resolve —
graphify/affected.py:167· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Nullsafe PHP member calls are recognized as calls but not configured for name extraction —
graphify/extract.py:1158· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Python member-call resolver drops raw calls lacking source_file —
graphify/extract.py:3248· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 3817 functions depend on the 2081 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 550 callers, 47 callees - new:
_rebuild_code()— 103 callers, 50 callees - new:
_extract_generic()— 18 callers, 29 callees - new:
extract_xaml()— 19 callers, 17 callees - new:
_query_graph_text()— 35 callers, 8 callees - new:
extract_js()— 85 callers, 3 callees - new:
dispatch_command()— 2 callers, 122 callees - new:
extract_objc()— 27 callers, 9 callees - …and 79 more — each is listed as a finding
Verification — 3817 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: 3765 function(s) in the blast radius were not formally verified this run
Formal verification
Could not verify: Could not verify resolve\_seed.
The verifier did not have enough to check resolve\_seed, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `root` is annotated `Path | None` — outside the synthesizable primitive/collection set
Could not verify: Could not verify dispatch\_command.
The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
Could not verify: Could not verify extract.
The verifier did not have enough to check extract, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `cache_root` is annotated `Path | None` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_import\_php.
The verifier did not have enough to check \_import\_php, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_resolve\_cpp\_member\_calls.
The verifier did not have enough to check \_resolve\_cpp\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_csharp\_member\_calls.
The verifier did not have enough to check \_resolve\_csharp\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_java\_member\_calls.
The verifier did not have enough to check \_resolve\_java\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_objc\_member\_calls.
The verifier did not have enough to check \_resolve\_objc\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_python\_member\_calls.
The verifier did not have enough to check \_resolve\_python\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_swift\_member\_calls.
The verifier did not have enough to check \_resolve\_swift\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_typescript\_member\_calls.
The verifier did not have enough to check \_resolve\_typescript\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_extract\_generic.
The verifier did not have enough to check \_extract\_generic, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_resolve\_php\_type\_references.
The verifier did not have enough to check \_resolve\_php\_type\_references, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
No difference found (not proven): No behavior difference found in find\_node\_ambiguity (not a proof).
The verifier ran both versions of find\_node\_ambiguity on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_find\_node\_tiers (not a proof).
The verifier ran both versions of \_find\_node\_tiers on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_pick\_seeds (not a proof).
The verifier ran both versions of \_pick\_seeds on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_query\_graph\_text (not a proof).
The verifier ran both versions of \_query\_graph\_text on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify \_score\_query.
The verifier did not have enough to check \_score\_query, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 45 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify resolve\_bash\_source\_edges.
The verifier did not have enough to check resolve\_bash\_source\_edges, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `per_file` is annotated `Sequence` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_rebuild\_code.
The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set
· 15 grounded finding(s) anchored inline below; 72 more finding(s) on lines outside this diff (see the check run).
| return sourced[0] if len(sourced) == 1 else None | ||
|
|
||
|
|
||
| def resolve_seed(graph: nx.Graph, query: str, root: Path | None = None) -> str | None: |
There was a problem hiding this comment.
resolve_seed()
fans out to 6 callees (efferent coupling); 19 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return entries | ||
|
|
||
|
|
||
| def _resolve_php_member_calls( |
There was a problem hiding this comment.
_resolve_php_member_calls()
fans out to 8 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.
| return table | ||
|
|
||
|
|
||
| def _php_ctor_assigned_field_types( |
There was a problem hiding this comment.
_php_ctor_assigned_field_types()
fans out to 8 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -5039,9 +5697,15 @@ def _php_class_const_scope(n) -> str | None: | |||
| def walk_calls( | |||
There was a problem hiding this comment.
walk_calls()
fans out to 22 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| raise AssertionError(f"no node count in header: {text.splitlines()[:1]}") | ||
|
|
||
|
|
||
| def test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded(): |
There was a problem hiding this comment.
test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded()
fans out to 12 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return "" | ||
|
|
||
|
|
||
| def test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed(): |
There was a problem hiding this comment.
test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed()
fans out to 7 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert "relaxed" in context, f"header does not report the relaxation: {context!r}" | ||
|
|
||
|
|
||
| def test_expanding_heuristic_filter_is_left_in_force(): |
There was a problem hiding this comment.
test_expanding_heuristic_filter_is_left_in_force()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| ) | ||
|
|
||
|
|
||
| def test_single_node_expansion_is_not_starvation(): |
There was a problem hiding this comment.
test_single_node_expansion_is_not_starvation()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| } | ||
|
|
||
|
|
||
| def test_incremental_rebuild_keeps_php_enum_and_trait_binding(tmp_path): |
There was a problem hiding this comment.
test_incremental_rebuild_keeps_php_enum_and_trait_binding()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.
Formal verification. 4 change(s) tested, no difference found (not proven).
Graphify review — findings
Consolidates PHP member-call resolution from the fork so a typed receiver ($this->prop->method(), promoted-constructor params, nullsafe/typed/new T() locals) binds to the method its declared type actually reaches, tagging inline (new T())->method() EXTRACTED and every other typed receiver INFERRED, and refusing to emit any edge wherever the type isn't provably one concrete in-corpus class (untyped, union/intersection, interface/enum/trait, magic dispatch, chained/array receivers, rebound or shadowed locals, anonymous classes, self/static/parent). Scopes all ten member-call receiver-type indexes and each resolver's raw-call ownership to their own source suffixes via a shared _is_owned_definition predicate, dropping cross-language edges that were always wrong and recovering same-language edges a foreign short-name collision had suppressed, with .h deliberately shared between C++ and ObjC. Stamps a target_file hint on stem-named imports edges in Python, Rust, Zig, Elixir, PowerShell, Pascal and Bash so id-disambiguation salt lands on the right file instead of dropping the edge (and everything downstream) when a same-stem file collides.
Worth a look
- Unique exact label match can be overridden by a sourced bare-name rival —
graphify/affected.py:164· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Existing non-call edges suppress PHP member call edges —
graphify/extract.py· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- new_type_name matches only 'name'/'qualified_name' but PHP class ref may differ —
graphify/extractors/engine.py· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Nested member writes incorrectly poison the base PHP property binding —
graphify/extractors/engine.py· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- PHP first-class callables are only detected for member calls —
graphify/extractors/engine.py:6226· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 4104 functions depend on the 2191 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 613 callers, 48 callees - new:
_rebuild_code()— 120 callers, 51 callees - new:
_extract_generic()— 18 callers, 31 callees - new:
_query_graph_text()— 40 callers, 9 callees - new:
extract_js()— 85 callers, 4 callees - new:
extract_xaml()— 19 callers, 17 callees - new:
_resolve_js_module_path()— 34 callers, 9 callees - new:
dispatch_command()— 2 callers, 124 callees - …and 84 more — each is listed as a finding
Verification — 4104 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: 4050 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
279 of 279 test file(s) selected (100%) via static blast radius.
Escalated to a full run for safety — the selection is not trustworthy on its own (see below). CI should run the whole suite.
tests/test_affected_cli.py— impact, full-run-safetytests/test_affected_member_seed.py— impact, full-run-safetytests/test_agents_platform.py— impact, full-run-safetytests/test_analyze.py— full-run-safetytests/test_anthropic_custom_endpoint.py— full-run-safetytests/test_antigravity_install.py— full-run-safetytests/test_apm_fallback_version.py— full-run-safetytests/test_architecture_doc.py— full-run-safetytests/test_astro_extraction.py— impact, full-run-safetytests/test_astro_import_ids.py— impact, full-run-safetytests/test_atomic_canvas_export.py— full-run-safetytests/test_atomic_version_stamp.py— full-run-safetytests/test_atomic_writes.py— full-run-safetytests/test_backend_extras.py— full-run-safetytests/test_benchmark.py— impact, full-run-safetytests/test_benchmark_raw_graph.py— impact, full-run-safetytests/test_build.py— impact, full-run-safetytests/test_build_merge_hyperedges_and_prune.py— full-run-safetytests/test_build_merge_shrink_guard.py— full-run-safetytests/test_builtin_global_type_refs.py— impact, full-run-safetytests/test_cache.py— full-run-safetytests/test_callflow_html.py— full-run-safetytests/test_cargo_introspect.py— full-run-safetytests/test_carried_hyperedge_remap.py— full-run-safetytests/test_case_sensitive_resolution.py— impact, full-run-safetytests/test_charmap_encoding.py— full-run-safetytests/test_chunking.py— full-run-safetytests/test_cjs_module_extension.py— impact, full-run-safetytests/test_claude_cli_backend.py— full-run-safetytests/test_claude_md.py— full-run-safetytests/test_cli_broken_pipe.py— full-run-safetytests/test_cli_export.py— full-run-safetytests/test_cli_help.py— full-run-safetytests/test_cluster.py— full-run-safetytests/test_codebuddy.py— impact, full-run-safetytests/test_community_hub_labels.py— full-run-safetytests/test_community_labels_skill.py— full-run-safetytests/test_confidence.py— full-run-safetytests/test_corrupt_graph_json.py— impact, full-run-safetytests/test_cpp_nested_and_cli.py— impact, full-run-safetytests/test_cpp_objc_cross_file_calls.py— impact, full-run-safetytests/test_cpp_preprocess.py— full-run-safetytests/test_cross_extension_reexport_self_cycle.py— impact, full-run-safetytests/test_cross_language_call_resolution.py— impact, full-run-safetytests/test_cross_repo_member_calls.py— impact, full-run-safetytests/test_cross_repo_shared_types.py— full-run-safetytests/test_csharp_call_site_generic_args.py— impact, full-run-safetytests/test_csharp_enum_members.py— impact, full-run-safetytests/test_csharp_field_generic_args.py— impact, full-run-safetytests/test_csharp_generic_callsites.py— impact, full-run-safety- … and 229 more
non-code file(s) changed (
CHANGELOG.md,docs/how-it-works.md,pyproject.toml,uv.lock) → running the full suite for safety (a code graph can't see config/fixture/data deps)
changed code file(s) with no mapped test (
CHANGELOG.md,docs/how-it-works.md,pyproject.toml) — a coverage gap or a missing link — running the full suite rather than only the selected tests
Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.
Formal verification
Could not verify: Could not verify resolve\_seed.
The verifier did not have enough to check resolve\_seed, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `root` is annotated `Path | None` — outside the synthesizable primitive/collection set
Could not verify: Could not verify dispatch\_command.
The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
Could not verify: Could not verify extract.
The verifier did not have enough to check extract, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `cache_root` is annotated `Path | None` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_import\_php.
The verifier did not have enough to check \_import\_php, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_resolve\_cpp\_member\_calls.
The verifier did not have enough to check \_resolve\_cpp\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_csharp\_member\_calls.
The verifier did not have enough to check \_resolve\_csharp\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_java\_member\_calls.
The verifier did not have enough to check \_resolve\_java\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_objc\_member\_calls.
The verifier did not have enough to check \_resolve\_objc\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_python\_member\_calls.
The verifier did not have enough to check \_resolve\_python\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_swift\_member\_calls.
The verifier did not have enough to check \_resolve\_swift\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_resolve\_typescript\_member\_calls.
The verifier did not have enough to check \_resolve\_typescript\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_extract\_generic.
The verifier did not have enough to check \_extract\_generic, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_resolve\_php\_type\_references.
The verifier did not have enough to check \_resolve\_php\_type\_references, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
No difference found (not proven): No behavior difference found in find\_node\_ambiguity (not a proof).
The verifier ran both versions of find\_node\_ambiguity on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_find\_node\_tiers (not a proof).
The verifier ran both versions of \_find\_node\_tiers on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_pick\_seeds (not a proof).
The verifier ran both versions of \_pick\_seeds on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_query\_graph\_text (not a proof).
The verifier ran both versions of \_query\_graph\_text on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify \_score\_query.
The verifier did not have enough to check \_score\_query, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 45 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify resolve\_bash\_source\_edges.
The verifier did not have enough to check resolve\_bash\_source\_edges, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `per_file` is annotated `Sequence` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_rebuild\_code.
The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set
· 15 grounded finding(s) anchored inline below; 77 more finding(s) on lines outside this diff (see the check run).
| return sourced[0] if len(sourced) == 1 else None | ||
|
|
||
|
|
||
| def resolve_seed(graph: nx.Graph, query: str, root: Path | None = None) -> str | None: |
There was a problem hiding this comment.
resolve_seed()
fans out to 6 callees (efferent coupling); 19 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return entries | ||
|
|
||
|
|
||
| def _resolve_php_member_calls( |
There was a problem hiding this comment.
_resolve_php_member_calls()
fans out to 8 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.
| return table | ||
|
|
||
|
|
||
| def _php_ctor_assigned_field_types( |
There was a problem hiding this comment.
_php_ctor_assigned_field_types()
fans out to 8 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -5264,9 +5922,15 @@ def _php_class_const_scope(n) -> str | None: | |||
| def walk_calls( | |||
There was a problem hiding this comment.
walk_calls()
fans out to 23 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| raise AssertionError(f"no node count in header: {text.splitlines()[:1]}") | ||
|
|
||
|
|
||
| def test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded(): |
There was a problem hiding this comment.
test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded()
fans out to 12 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return "" | ||
|
|
||
|
|
||
| def test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed(): |
There was a problem hiding this comment.
test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed()
fans out to 7 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert "relaxed" in context, f"header does not report the relaxation: {context!r}" | ||
|
|
||
|
|
||
| def test_expanding_heuristic_filter_is_left_in_force(): |
There was a problem hiding this comment.
test_expanding_heuristic_filter_is_left_in_force()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| ) | ||
|
|
||
|
|
||
| def test_single_node_expansion_is_not_starvation(): |
There was a problem hiding this comment.
test_single_node_expansion_is_not_starvation()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| } | ||
|
|
||
|
|
||
| def test_incremental_rebuild_keeps_php_enum_and_trait_binding(tmp_path): |
There was a problem hiding this comment.
test_incremental_rebuild_keeps_php_enum_and_trait_binding()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* upstream/v8: (184 commits) release: 0.9.80 — include Graphify-Labs#4195, Graphify-Labs#4198 feat(python): resolve obj.method() through an annotated/constructor-bound local (Graphify-Labs#4198) perf(extract): resolve and parse each path once in the id remap and call tie-break release: 0.9.80 fix(deps): raise tree-sitter runtime floor to >=0.25 for ABI 15 grammars (Graphify-Labs#4148) fix(build): give --no-cluster graph.json the same endpoint rules as build_from_json fix(watch): keep extract()'s run-only keys out of --no-cluster graph.json fix(watch): drop an external import stub once no edge references it fix(detect): drop a previous checkout's absolute manifest keys (Graphify-Labs#4175) fix(python): keep TYPE_CHECKING-only imports out of import cycles (Graphify-Labs#3159) fix(python): resolve self.<attr>.<method>() calls through the attribute's constructor type (Graphify-Labs#2860) fix(cache): treat an AST hit whose import target is gone as a miss fix(extract): preserve cross-drive syntax warnings fix(extract): let the Windows console script use the extraction pool fix(resolution): keep re_exports from both files whose ids collide fix(markdown): resolve links to file names that contain a space fix(java): capture methods and calls inside anonymous class bodies test: skip optional-extra tests when the extra is not installed (Graphify-Labs#4190) fix(serve): show the graph's build commit in graph_stats perf(serve): build the MCP traversal view and shortest-path graphs once per graph ... # Conflicts: # CHANGELOG.md # graphify/extract.py # graphify/extractors/engine.py # graphify/serve.py # pyproject.toml # uv.lock
Fixes #2615. Fixes #1682.
What this PR is
This PR contains all PHP-support and resolution work from the
lawnstarter/graphifyfork, in one review unit. It replaces our seven open PRs: #2492, #2502, #2503, #2505, #2506, #2516, #2536. We close those seven with pointers to this PR.One PR is easier to review than seven stacked PRs. Four of the seven had strict merge-order constraints (2492 → 2502 → 2505 → 2506). This PR has none: it is based on the current
v8head (50556ba, v0.9.39) and is 0 commits behind.The full defect report, with all corpus measurements, is #2615.
Contents, in four groups
The CHANGELOG on this branch lists every change under one
## 0.9.40 (unreleased)section, in the same four groups.Group A — PHP member-call resolution, cross-language isolation, import-edge repair
(previously PRs #2492, #2502, #2503, #2505, #2506)
$this->prop->method()(including promoted constructor params), typed locals and params, nullsafe?->, and(new Service())->method(). Declared types bind at INFERRED 0.8. Inlinenewbinds at EXTRACTED 1.0 when the written namespace corroborates the class. This is the PHP: member/instance method calls never resolve to calls edges — only static Class::method() works #1682 fix.self/static/parent.indirect_call, notcalls. The syntax creates aClosure; it does not transfer control flow.importsedges keep the written FQN, alias, and kind (use_kind/alias/target_fqnmetadata). Group-formuse function A\{f};no longer claimsfas a class name.use Vendor\Sdk\Client;claim is honored. A claim that names no in-corpus class refuses (subtractive,PhpNameResolver). A claim that matches a declared FQN binds to it, including through renaming aliases (additive). The declared-FQN facts persist ingraph.json(_php_class_fqns), so the bindings survivegraphify update._is_owned_definitionpredicate keys off the per-resolver suffix tuple, so registration and scoping cannot drift.importsedges no longer die beside a same-stem foreign file (Python, Rust, Zig, Elixir, PowerShell, Pascal, Bash).Group B — natural-language query seeding
(previously PR #2516; follow-ups to #2507)
_CONTEXT_HINTSand_CONTEXT_FILTER_ALIASESinstead of a hardcoded list, and is a strict superset of it (62 words vs 29). The five upstream query: relational-intent verbs ("calls"/"uses") survive stopwording, prefix-match unrelated identifiers, and the #1445 per-term guarantee seats the junk match as a seed #2507 tests pass unchanged.--contextfilter is always honored.Group C — PHP node identity and lookup
(previously PR #2536, plus two sibling fixes that PR deferred because they needed #2492/#2502; those dependencies are inside this PR)
interface,trait, andenumdeclarations mint canonical nodes, exactly asclassdoes.target_fqnedge metadata, so an import of a name only written asFoo::classno longer dangles.explain, andaffectedapplies the same rule, so the two commands agree.parent::/self::/static::calls refuse instead of naming their scope as the callee.Group D — resolution gaps closed on top of the above
(never proposed upstream before; they depended on groups A and C)
name(...)) never binds to a method or a class-like node cross-file. Only functions are plausible targets at a function-call site. The case-insensitive fallback is retried with the same refusal.interface,enum, ortraitbinds to that declaration's own method. Implementations are never guessed. The declared-FQN pre-scan now covers all four declaration kinds, which also closes a full-vs-incremental parity hole.graphify queryandshortest_pathprefer a sourced node over a sourceless stub on exact score ties, asexplainandaffectedalready do. Tie-break only; no live score changes.Measured evidence
All numbers are from real builds of a 46.4k-node (later 48.5k-node) PHP 8.3 / Laravel production corpus. Details and attribution per fix are in #2615 and in the CHANGELOG entries.
LeadHunterServiceinboundcallsedges (#1682 repro)callson one test method from Laravel'sevent()helpercallson one model accessor fromparent::callsimportsedgescallsedge delta with the final fixesuseclaims (lawnstarter#16)Behavior changes
callstoindirect_call. Consumers that filter onrelation == "calls"will not see these edges.affectedoutput is unchanged.graphify update .lands this consistently. A hook-driven incremental rebuild against a pre-fix graph drops (never repoints) stale-id edges until the next full update.caller/callersqueries that strand on a class node now relax to an unfiltered traversal, with the header noteContext: call (heuristic; relaxed — no matches beyond seeds).Composition with upstream 0.9.36–0.9.39
The fork tracked upstream continuously. Two syncs (0.9.36/0.9.37 and 0.9.38/0.9.39) are merged into this branch, and the compositions were verified:
extractors/engine.py: the member-call defer condition is_python_defer or _java_defer or _php_defer or (…). The Python and PHP disjuncts are mutually exclusive by construction: each reads state assigned only inside its ownconfig.ts_modulebranch.extract.py, ObjC receiver index: this branch keeps the fork's_is_owned_definitiongate (strictly stronger than the source-file truthiness test upstream narrowed) AND adds upstream'snot _is_protocol_declaration. Upstream's three new ObjC suites pass on this composition.serve.py: upstream's hardcoded relational-verb list is replaced by the derived superset (group B above). This is the one place the branch deliberately restructures upstream code; it absorbs all of upstream's vocabulary and its five tests pass unchanged.importstatement real TypeScript requires.Tests
openaiextra installed; without it, 4test_ollama_retry_cap.pytests fail on import, an environment condition fix(query): skip covered-term seed guarantee; relax starving heuristic context filters (#2507) #2516's evidence already documented). One labeling test (test_label_communities_batches_when_over_batch_size) is known-flaky — it also flakes at the clean upstream baseline, per fix(extract): isolate member-call resolvers and import edges from cross-language interference #2503's evidence — and passes in isolation and in this run.tests/test_watch.py. Every positive resolution test carries a same-named decoy asserted to get no edge. Positive scenarios have mirrored full-build and incremental-rebuild assertions.Migration
graphify update .once to shed the fabricated edges and gain the new ones.Known gaps, all deliberate refusals
parent::/self::/static::calls resolve to nothing. Binding them needs a base-class-aware pass throughinherits; a decided spec exists (lawnstarter/graphify#58). We can propose it as a follow-up after this PR.used trait, or inherited from a cross-file parent class, gets no edge. Docblock types are not read.event(...)call in the same file as a class that declares anevent()method still binds in-file (documented residual; it never reaches the cross-file pass).Shape of the branch
66 commits of fork history (including the two upstream-sync merges) plus one packaging commit (consolidated CHANGELOG section, version 0.9.40, one docs correction). A squash merge gives one clean change if you prefer that. For per-fix review, each superseded PR body remains available, and each commit carries provenance to its fork PR.