Skip to content

Static/scoped calls (Class::method) aren't tracked as callers in PHP and Rust #567

@NaeemHaque

Description

@NaeemHaque

What's wrong

If you call a method with the scoped/static syntax (Mailer::send($x) in PHP, Mailer::send(x) in Rust), the call edge gets stored but callers_of, get_impact_radius, and tests_for never see it. They report zero callers for a method that's obviously being called.

The data isn't lost, it's just filed under a key nothing looks up. The parser records the CALLS edge with the target Mailer::send, but every node in the graph is keyed by its dotted qualified name, e.g. src/Mailer.php::Mailer.send. Those two strings never compare equal, and nothing in the resolution layer bridges them, so the edge dangles.

The underlying reason: the resolution layer assumes a call target is one of two shapes, a fully-qualified node name (<file>::Class.method) or a bare name (method). The Class::method form is neither, so it falls through every path.

This is on 2.3.6. I'd call it high severity. It's a common call style in general, and on Rust it's the normal way to call associated functions and constructors (Foo::new(), Self::method()), so a big chunk of Rust call edges quietly go missing, which makes blast-radius and review scope under-report.

Which languages this actually affects

I built a small repo for each ::-using language and looked at the edges table directly. Only PHP and Rust actually hit this:

  • PHP Mailer::send($x) — stores Mailer::send, dangles against …::Mailer.send. Affected.
  • Rust Mailer::send(x) — same thing. Affected.
  • C++ Mailer::send(x) — no CALLS edge gets created at all, so there's nothing to dangle. That's a different, extraction-side problem (looks adjacent to C++ class method definitions not parsed as Function nodes (scoped Class::method() and Qt macros) #463).
  • Ruby — same as C++, no CALLS edge produced for the scoped call.
  • R pkg::fn(x) — does produce pkg::fn, but pkg is an external package with no node in the graph, so there's nothing to resolve it to. Intra-file calls already resolve fine.

So this report is scoped to PHP and Rust, where the edge exists but points at the wrong key. C++/Ruby/R are out of scope here for the reasons above.

Reproduction

Two PHP files:

src/Mailer.php

<?php
class Mailer {
    public static function send($to) {
        return true;
    }
}

src/SignupController.php

<?php
class SignupController {
    public function register($email) {
        return Mailer::send($email);  // scoped/static call — this is what breaks
    }
}

Build it and query:

code-review-graph build --repo .
query_graph(pattern="callers_of", target="Mailer.send")  ->  {"status": "ok", "results": []}
get_impact_radius(changed_files=["src/Mailer.php"])       ->  no method callers

You'd expect SignupController.register to show up as a caller of Mailer.send. Instead you get nothing.

The edge is in the DB, just keyed wrong:

sqlite> SELECT source_qualified, target_qualified FROM edges
        WHERE kind='CALLS' AND target_qualified LIKE '%send%';
src/SignupController.php::SignupController.register | Mailer::send   -- target is "Class::method"

sqlite> SELECT qualified_name FROM nodes WHERE name='send';
src/Mailer.php::Mailer.send                                          -- node key is "<file>::Class.method"

The same two-file pattern in Rust (struct Mailer; impl Mailer { fn send(..) }, called via Mailer::send(..)) reproduces identically.

If you poke at the store directly, it's clearly the resolution layer and not extraction:

store.resolve_bare_call_targets()                 -> 0   # batch resolver never touches it (it skips '::' targets)
store.search_edges_by_target_name("send")         -> 0   # the callers_of bare-name fallback misses it
store.search_edges_by_target_name("Mailer::send") -> 1   # only findable by the Class::method key, which nothing queries
store.get_edges_by_target("src/Mailer.php::Mailer.send") -> 1 CONTAINS edge only (no CALLS)

Where it goes wrong

Three spots all make the same assumption (line numbers from main @ 2.3.6, they'll drift):

  1. code_review_graph/parser.py:2189 (_resolve_call_targets) only qualifies bare targets:

    if edge.kind in ("CALLS", "REFERENCES") and "::" not in edge.target:

    Class::method has ::, so it's skipped.

  2. code_review_graph/graph.py:499 (resolve_bare_call_targets) explicitly excludes anything with :::

    "FROM edges WHERE kind = 'CALLS' AND target_qualified NOT LIKE '%::%'"
  3. code_review_graph/graph.py:370 (search_edges_by_target_name, the cross-file callers_of fallback) matches on the bare name:

    "SELECT * FROM edges WHERE target_qualified = ? AND kind = ?", (name, kind)

    The stored target is Mailer::send, the lookup is send, no match.

The target itself is produced in the PHP scoped-call handler at parser.py:6462, which returns f"{parts[0]}::{parts[-1]}". Worth noting: Rust's scoped_identifier (parser.py:6567) and R's namespace_operator (parser.py:6571) return the full scoped text, so a Rust target can carry more than one :: (e.g. crate::module::func).

Environment

  • code-review-graph 2.3.6
  • Reproduced on PHP and Rust
  • Checked and ruled out: C++ and Ruby (no CALLS edge produced, separate extraction issue) and R (:: is external package access, no in-graph target)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions