Skip to content

[bug] PHP: framework/core method calls ($storage->load()) become false-positive CALLS edges to same-named project methods — same class as the (fixed) Perl #476 #606

Open

Description

@gkastanis

Version: codebase-memory-mcp v0.8.1 (Linux amd64, release binary)
Project: real-world Drupal 11 / PHP module, 2,869 nodes / 7,069 edges
Public fixture: the repo is public at https://git.drupalcode.org/project/ai_eval (clone + index_repository, full mode)

Summary

On PHP, the generic short-name call resolver wires framework / core method calls to unrelated project methods that merely share a method name, ignoring the receiver's type. The real callee (a Drupal core method like EntityStorageInterface::load()) is not a node in the graph — it's an external dependency — so the resolver picks the only same-named definition it can see (a project method) and fabricates a CALLS edge.

This is the same root cause already fixed for Perl in #476/#477 ("the textual resolver … falls back to a generic short-name matcher with no language or call-kind awareness … wires framework method calls … to unrelated project subs that merely share a name"), and the same name-vs-receiver defect reported for the recursion detector in #599. The fix in #466 does not cover this case: #466 ranks among same-named definition nodes and reports ties, but here there is no tie — the correct target is out-of-graph entirely, so #466 has nothing to prefer.

Repro

MATCH (caller)-[:CALLS]->(m)
WHERE m.qualified_name ENDS WITH 'DatasetLoaderInterface.load'
RETURN caller.qualified_name

Three representative false positives (verified against source — none of these call the dataset loader):

Edge (caller → callee) Actual call in body Why it's wrong
AiLogTraceSource::traceById → DatasetLoaderInterface::load $this->logStorage()->load($id) (src/Service/AiLogTraceSource.php:90) receiver is the ai_log entity storage (Drupal core EntityStorageInterface::load), not the dataset loader
LabelStore::loadForTrace → DatasetLoaderInterface::load $storage->load(reset($ids)) where $storage = getStorage('ai_eval_review_label') (src/Service/LabelStore.php, loadForTrace()) receiver is the review-label entity storage, not the dataset loader
build_viewer.load_log → DatasetSourceInterface::load a Python function in skills/view-results/scripts/build_viewer.py cross-language edge: a Python function cannot call a PHP interface method

Inconsistency worth noting: the resolver gets EvalRunner::doStartRun/runTarget → DatasetLoaderInterface::load correct (those genuinely call $this->datasetLoader->load()) while getting AiLogTraceSource/LabelStore wrong — so identical-looking ->load() call sites resolve to the right or wrong target non-deterministically from the consumer's point of view.

Minimal example

// Project interface — the only `load()` definition node in the graph
interface DatasetLoaderInterface { public function load(string $ref): array; }

final class TraceSource {
  public function traceById(int $id): ?array {
    // receiver is a Drupal core EntityStorageInterface — NOT in the graph.
    // Today this emits a phantom CALLS edge to DatasetLoaderInterface::load.
    return $this->logStorage()->load($id);
  }
}

Expected

  • A CALLS edge should be emitted only when the call resolves to the same method on a compatible receiver type. When the receiver type is unknown/external, prefer no edge (or a low-confidence/unresolved marker) over a fabricated edge to a same-named project method.
  • $storage->load() / $this->logStorage()->load() (core/framework methods not in the graph) must not bind to a project load() purely on short-name match — exactly the call-kind awareness that Perl call graph polluted by false-positive CALLS edges (builtins, framework method calls, config strings) #476 added for Perl, applied to PHP.
  • Cross-language edges (Python caller → PHP callee) should never be produced.

Impact

trace_path (inbound) and any query_graph over CALLS return phantom callers for the affected methods, so caller-set / impact-analysis answers are wrong for the most common Drupal/PHP idiom (->load(), and by extension ->save(), ->get(), ->create(), ->delete() — all core storage/entity methods). PHP call-graph coverage on the tracker is currently thin (only Blade #258 and a vendor-exclude #234, both closed), so this is offered as a clean, public PHP/Drupal fixture for the same fix #476 shipped for Perl.

Related

Activity

  1. gkastanis commented on Jun 24, 2026

    @gkastanis
    Author

    Update — minimal repros sharpen the mechanism (this is narrower than my original wording).

    My initial framing implied "PHP isn't type-resolved." That's wrong, and I want to correct it so the fix targets the right place. Tested on v0.8.1 (Linux amd64) with three minimal single-purpose repos:

    1. Explicit property type, both candidates in-graph → resolves correctly.

    interface StorageInterface { public function load(int $id): ?object; }   // decoy A
    interface LoaderInterface  { public function load(string $ref): array; } // decoy B
    class Consumer {
      public function __construct(private StorageInterface $storage) {}
      public function run() { return $this->storage->load(1); }
    }

    run → StorageInterface::load ✅ (picks the right one via the type hint)

    2. Method-return-typed receiver, type in-graph → also resolves correctly.

    class Consumer {
      private function storage(): StorageInterface { /* ... */ }
      public function run() { $this->storage()->load(1); }
    }

    run → StorageInterface::load ✅

    3. Receiver type EXTERNAL / not indexed → phantom edge.

    use Drupal\Core\Entity\EntityStorageInterface;   // not in the graph
    class Consumer {
      public function __construct(private EntityStorageInterface $storage) {}
      public function run() { $this->storage->load(1); }   // real target is core
    }

    run → LoaderInterface::load ❌ — the only project load in the repo. The correct target (EntityStorageInterface::load) isn't a node, so resolution falls back to bare-name and binds to an arbitrary same-named project method.

    So the defect is specifically: when a receiver's type resolves but is not a node in the graph, the resolver drops to the bare-name fallback instead of declining. This is exactly the Drupal $this->entityTypeManager->getStorage(...)->load() idiom — core types are never indexed (they're under vendor/ / web/core, which the tool correctly gitignores), so every ->load()/->save()/->get() into core is a phantom-edge candidate.

    Confirmed that indexing the whole site does not help: a full Drupal site index contains only the custom code (0 matches for EntityStorageInterface, EntityTypeManagerInterface, ContentEntityBase after indexing one), because core/contrib/vendor are gitignored.

    Suggested fix: when the receiver type is resolved but has no corresponding node in the graph, emit no CALLS edge (or an unresolved marker) rather than the bare-name/suffix fallback. The in-graph cases above already work, so this is a narrow change to the out-of-graph branch. Happy to share the three repro repos.

  2. kumayizxcvb commented on Jun 24, 2026

    @kumayizxcvb

    Tree-sitter 的解析速度确实无敌,但单纯靠语法树做语义分析经常会被动态语言的“魔法”搞晕。你们这个 Hybrid LSP 的思路很对胃口,不过把 LSP Server 这种内存大户和易阻塞进程塞进单静态二进制里,是怎么解决进程间通信和超时容错的?之前用 Go 封装过类似方案,经常因为某个语言服务器崩溃导致整个索引挂起,C 这边处理这种并发模型感觉挑战更大。


    🤖 若这条评论有帮助,欢迎点赞交流!

  3. DeusData commented on Jun 24, 2026

    @DeusData
    Owner

    Tree-sitter 的解析速度确实无敌,但单纯靠语法树做语义分析经常会被动态语言的“魔法”搞晕。你们这个 Hybrid LSP 的思路很对胃口,不过把 LSP Server 这种内存大户和易阻塞进程塞进单静态二进制里,是怎么解决进程间通信和超时容错的?之前用 Go 封装过类似方案,经常因为某个语言服务器崩溃导致整个索引挂起,C 这边处理这种并发模型感觉挑战更大。

    We basically built a light version of the LSP Servers, where we attempt to only use the type resolution logic, not everything else. Bundled with tree-sitter this leads to the "hybrid". It's still a bit experimental but I am working on tuning it. Maybe have a look, feedback is always appreciated :)

  4. DeusData commented on Jun 24, 2026

    @DeusData
    Owner

    @halindrome could this be an issue for you? It's related to your pearl work and could be a good fit :)

  5. added
    bugSomething isn't working
    parsing/qualityGraph extraction bugs, false positives, missing edges
    on Jun 29, 2026
  6. DeusData commented on Jun 29, 2026

    @DeusData
    Owner

    Thanks. I’ve triaged this under #592. This is a receiver/type-aware call-resolution issue; the fixed Perl class is a useful precedent, but PHP/Drupal needs its own fixture proving framework/core methods do not resolve to same-named project methods when the receiver type is external.

  7. added
    priority/normalStandard review queue; useful PR with ordinary maintainer urgency.
    on Jun 30, 2026
  8. rudi193-cmd commented on Jul 5, 2026

    @rudi193-cmd
    Contributor

    Same resolver family — Python store wrappers (willow-2.0)

    Different language, same bare-name / receiver-blind pattern as your Drupal $storage->load() report.

    On v0.8.1 / willow-2.0, core.soil.get is flagged unguarded_recursion + self_recursive because the body calls _get_store().get(...) — not a self-call. Same on soil.put → _get_store().put(...).

    This is the measurement-side cousin of false-positive CALLS edges: the resolver matches name without receiver. We filed #876 (recursion flags) and document F-007/F-008 in our tool-eval addendum.

    A fix that disambiguates receiver type for framework/store calls would likely touch the same subsystem as #476/#599/#725.

  9. added this to the 0.9.1-rc milestone on Jul 8, 2026
  10. added
    priority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.
    and removed
    priority/normalStandard review queue; useful PR with ordinary maintainer urgency.
    on Sep 5, 2026
  11. wazum commented on Sep 30, 2026

    @wazum

    @DeusData I can reproduce this on v0.11.0 with a real PHP project of about 200 files. Many weak CALLS edges link framework calls to project methods that only share the name.

    I have a small fix ready. When the PHP LSP knows the receiver's class but that class is not indexed, it already emits php_method_typed_unindexed. That row is below the confidence floor, so the pipeline skips it, and the name fallback links the call anyway. My change stops the weak fallback in that case, unless the project has a class with the same short name. pass_calls.c and pass_parallel.c get the same check.

    There are tests for the sequential and the parallel path:

    use Vendor\Storage\File;   // not indexed
    
    public function vendorCall(File $file): int
    {
        return $file->getUid();   // no edge to App\Item::getUid any more
    }

    Calls on a receiver typed as a project class still resolve, and so do $this-> calls. On the real project the fix removes 55 wrong edges and keeps all typed edges. It does not cover untyped receivers. Those need a separate change.

    Can I open a PR for this?

  12. DeusData commented on Oct 17, 2026

    @DeusData
    Owner

    Thank you, @wazum, for digging into this and for offering a fix, and sorry it took us a while to come back to you. Yes, please open the PR!

    Some context so you can scope it. Since your comment, #2520 (merged on 12 October) added typed, per-file import evidence for PHP. A receiver typed as a class imported from a vendor namespace now gets a php_external_receiver row at 0.95, and the name fallback no longer binds that call to a same-named project method.

    Your change covers the next case, which is still open on main: the PHP LSP knows the receiver's class (php_method_typed_unindexed), but the import evidence cannot prove the class is a vendor one. That row is emitted at 0.55, below CBM_LSP_CONFIDENCE_FLOOR, so the pipeline ignores it and the weak name fallback links the call anyway. Exactly the gap you found.

    What would help us merge it quickly:

    • the same check in src/pipeline/pass_calls.c and src/pipeline/pass_parallel.c (the sequential resolver runs for repos with 50 files or fewer, the parallel one above that), gated on PHP files, with a test for each path;
    • your File $file->getUid() case, plus a control showing that a receiver typed as a project class still resolves;
    • a Signed-off-by on every commit (git commit -s, DCO);
    • if you have them, the before/after edge counts from your 200-file project, like the 55 removed edges you mentioned.

    Untyped receivers (for example $storage->load() where nothing types $storage) are a separate change we are working on, so please leave those out. Thanks again!

  13. DeusData commented on Oct 20, 2026

    @DeusData
    Owner

    Typed vendor receivers are now handled on main by 9a2d1d6 (PR #2710, thanks to @wazum): a typed receiver whose class is only known from vendor code no longer falls back to a name-guess CALLS edge, in both resolvers. Untyped receivers such as $storage->load() are still open here.

  14. DeusData commented on Oct 25, 2026

    @DeusData
    Owner

    Thank you for your patience. Sorry for the delay. The October20 update records typed vendor receiver handling as landed; untyped receivers remain the follow-up here. I’ll follow up soon.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingparsing/qualityGraph extraction bugs, false positives, missing edgespriority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions