Skip to content

PHP usages recall: references-driven enrichment + dispatch resolution#231

Merged
zzet merged 8 commits into
mainfrom
feat/php-usages-recall-enrichment
Jul 3, 2026
Merged

PHP usages recall: references-driven enrichment + dispatch resolution#231
zzet merged 8 commits into
mainfrom
feat/php-usages-recall-enrichment

Conversation

@zzet

@zzet zzet commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Lifts PHP find_usages recall from near-zero on OOP-heavy libraries (monolog-shaped: handle/format/write dispatch across dozens of handlers) while preserving the existing precision guard. The dispatch surface is maximally ambiguous by design, so the graph's edges were right but there were almost none of them; this closes the recall gap on two independent fronts — the LSP enrichment path and the in-engine resolver — plus the supporting node/model fixes.

LSP enrichment

  • Pin intelephense's index cache (9535a874). ServerAlt can now carry InitializationOptions (static or a resolve-time InitOptionsFunc(repoRoot)), threaded through NewProviderFromSpec to the initialize request. When intelephense stands in for an absent phpactor, its storagePath/globalStoragePath are rooted under the gortex cache home per repo (honoring XDG) instead of intelephense's default global location.
  • Add call edges via references when call hierarchy is absent (86727bb4) — the load-bearing fix. intelephense advertises textDocument/references but no call hierarchy, so enrichment previously only confirmed existing edges (edges_added stuck at 0). A references-driven add pass now queries references per declaration, attributes each in-repo site to its enclosing caller, and mints an lsp_resolved EdgeCalls at the call site. Provider-generic (any references-capable, call-hierarchy-absent server benefits), dispatch-anchors-first ordering under the existing deadline budget, with a references_add_pass marker on the enrichment report.

In-engine resolver

  • Bind ambiguous PHP dispatch calls via the class hierarchy (df171475). parent::/self:: calls walk the enclosing class's extends chain to the nearest declaring type (precise single bind — parent::__construct now resolves through a multi-level chain); plain member calls whose same-name candidates form an override family related through a common interface / abstract base / used trait fan out to every implementation. Edges land at ast_inferred (visible in default find_usages), and the relatedness gate keeps precision high by leaving unrelated same-name methods ambiguous. The PHP extractor now records implements / interface-extends (scope_interfaces) so the ancestor closure spans implements + extends + trait use.
  • Mint phpdoc @method virtuals and mark __call classes (f7e3e9d9). Docblock @method tags (magic accessors, mixins) get a KindMethod node so their ids resolve and calls bind; classes declaring __call/__callStatic are stamped has_magic_call.

Cross-cutting model fixes

  • Preserve call-site multiplicity (072b3a22). A synthesized producer that mints one edge per (from,to) records extra sites in Edge.Meta[call_sites]; find_usages expands them into one row per site, deduped against any real per-line edge. Meta-only (no Edge struct / wire change); survives a warm restart. AST extractors already key per call site, so their multiplicity is untouched.
  • Distinguish tier-filtered from no-usages (f1f671c8). A min_tier that emptied the result was indistinguishable from dead code (and even attached a likely_unused caveat). FilterByMinTier now records a tier_filtered caveat {class, edges_below_min_tier, max_available_tier} that takes precedence over the zero-edge classification; index_health gains a per-language lsp_resolved edge rollup.
  • Bodyless interface/trait method nodes (50328900, 7f65ffb1) — regression tests pinning that the C# and Rust extractors mint <file>::<Iface|Trait>.<name> KindMethod nodes for bodyless signatures (parity with PHP's extractMethod).

Verification

Per commit: go build ./cmd/gortex/ + go test -race on the touched packages, golangci-lint, and the cmd/gortex golden. Rebased onto current main; post-rebase the full repo builds and 6099 -race tests across the eight touched packages pass, lint clean.

zzet added 8 commits July 3, 2026 13:30
Carry InitializationOptions on a winning AlternativeCommand — either a
static blob or a resolve-time InitOptionsFunc(repoRoot) — through
NewProviderFromSpec to the initialize request. When intelephense stands
in for an absent phpactor, this roots its storagePath/globalStoragePath
under the gortex cache home per repo (honoring XDG overrides) instead of
writing to intelephense's default global location outside the engine's
isolation.
An LSP server that advertises textDocument/references but no call
hierarchy (e.g. intelephense) previously only confirmed existing edges
during enrichment — the per-file sweep's call-hierarchy hops never ran,
so the dispatch call sites the server can enumerate were never added
(edges_added stayed 0). Add a references-driven add pass: for each
function/method declaration, query references, attribute every in-repo
site to its enclosing caller, and mint an lsp_resolved EdgeCalls at the
call-site line (deduping/promoting against existing edges). Targets are
ordered dispatch-anchors-first (interface/trait members, abstract
methods, then by fan-in) under the targeted deadline budget. A
references_add_pass marker rides the enrichment report so index_health
distinguishes this add mode from the call-hierarchy mode.
…ierarchy

Ambiguous PHP member/scoped calls that static resolution left unresolved
(handle/format/write across dozens of handlers) now bind through the
class hierarchy, mirroring the language server's fan-out-to-
implementations semantics:

- parent::/self:: calls walk the enclosing class's extends chain to the
  nearest declaring type (a precise single bind), so parent::__construct
  resolves through a multi-level chain, not only the direct parent.
- plain member calls whose same-name candidates form an override family
  related through a common interface, abstract base, or used trait fan
  out to every implementation.

Edges land at the ast_inferred tier (visible in default find_usages)
rather than the speculative tier the Java analogue uses, because the PHP
dispatch surface is the primary recall source for the language; the
relatedness gate keeps precision high by leaving unrelated same-name
methods ambiguous. The PHP extractor now records a class's implemented
interfaces and an interface's parent interfaces (scope_interfaces meta)
so the ancestor closure spans implements + extends + trait use. Runs
after the cross-package guard so its edges are never reverted.
A class/trait docblock's @method tags (magic accessors, mixins) had no
method_declaration node, so their ids resolved not_found and member
calls to them were invisible. Parse the full raw docblock and mint a
KindMethod node per @method tag (virtual=phpdoc_method, positioned on
the class declaration line so the LSP hover path still works), unless a
concrete method of the same name already exists. Classes declaring
__call/__callStatic are stamped has_magic_call so the read path knows
unresolved members on them are an intentional dynamic surface, not a
gap. The virtual nodes are ordinary KindMethod nodes, so the resolver
binds calls to them like any other method.
…ites]

A synthesized producer that can only mint one edge per (from,to) — the
references-add pass, which sees N reference sites for one declaration —
previously collapsed those N sites to a single usage row. Keep one edge
(primary site in FilePath/Line) and record the extra sites in
Meta[call_sites] (sorted, deduped file:line); find_usages expands them
into one row per site, deduping against any real per-line edge so a site
is never double-counted. Both the GCX and plain-JSON find_usages paths
render from the same expanded edge slice. Meta-only, so no Edge struct
or wire-contract change; the sites round-trip through the store
(PersistEdge encodes Meta) so they survive a warm restart. AST
extractors already key edges per call site, so their multiplicity is
untouched.
A min_tier that filtered every edge returned a bare empty result
indistinguishable from genuine dead code — and it even attached a
likely_unused / extraction_gap caveat, actively misleading a safety
gate. FilterByMinTier now records a tier_filtered caveat {class,
edges_below_min_tier, max_available_tier} whenever it empties the
visible set while lower-tier edges remain, and that caveat takes
precedence over the zero-edge classification. It rides plain-JSON via
the embedded SubGraph and is emitted into GCX meta. index_health also
gains a per-language lsp_resolved edge rollup (from the enrichment
statuses, no graph pass) so an agent can see up front when
min_tier=lsp_resolved is unusable on a given language.
The C# extractor mints a <file>::<Iface>.<name> KindMethod node for a
bodyless interface-method signature (marked iface_member), so its id
resolves and dispatch calls bind to it. Add a regression test pinning
that behavior — the parity shape PHP's extractMethod establishes.
The Rust extractor mints a <file>::<Trait>.<name> KindMethod node for a
bodyless trait-method signature (marked trait_decl) — the node
rust-analyzer binds every dispatch call site to. Add a regression test
pinning that behavior, parity with C#'s bodyless interface methods and
PHP's extractMethod.
@zzet zzet merged commit b3c4c4a into main Jul 3, 2026
10 checks passed
@zzet zzet deleted the feat/php-usages-recall-enrichment branch July 3, 2026 12:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant