Repository navigation
Rust: std-typed receivers hijack same-named project methods, fabricating CALLS edges (37.6% of edges stdlib-shaped on 0.10.8) — minimal repro #2053
Description
Activity
- addedparsing/qualityGraph extraction bugs, false positives, missing edgesGraph extraction bugs, false positives, missing edgesux/behaviorDisplay bugs, docs, adoption UXDisplay bugs, docs, adoption UX
on Sep 4, 2026 - addedbugSomething isn't workingSomething isn't workingpriority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.Needs near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.and removedux/behaviorDisplay bugs, docs, adoption UXDisplay bugs, docs, adoption UX
on Sep 5, 2026 Thank you so much, @Nicolas0315, for such a careful report. The minimal repro, the scale numbers and the spot checks against real files made this straightforward to pin down.
You were right about the mechanism. The Rust type resolver did work out that
rootis astd::path::Pathand correctly resolvedroot.join(..)toPath::join, but that symbol isn't a node in your graph, and the pipeline treated "resolved, but not in the graph" the same as "couldn't resolve", so it fell back to plain name matching and bound the call to yourEvidenceTier::join. #2339 makes the pipeline trust that answer: when the Rust resolver places a call on a std or known-crate symbol, the name-matching fallback no longer runs. The std model now also knows thatto_path_buf()/join()returnPathBufand thatPathBufderefs toPath, soroot.to_path_buf().join(..)is covered too. On ripgrep this removes 357 fabricated CALLS edges and adds none; every type-resolved edge is kept, and receivers whose type can't be inferred still use the fallback. Thanks again!Reacted by Nicolas Hidemaru Ogoshi / 大越ニコラス秀丸Reacted by Nicolas Hidemaru Ogoshi / 大越ニコラス秀丸Reacted by Nicolas Hidemaru Ogoshi / 大越ニコラス秀丸Thank you for the quick turnaround and the clear write-up of the mechanism — "resolved but not in the graph" being treated as "unresolved" explains everything I was seeing.
I built #2339 (
793e0b61) and its merge base (5958f546) side by side on macOS arm64 and re-ran the repro with separateCBM_CACHE_DIRs.Original repro — fixed.
EvidenceTier::joinnow has exactly one caller:caller → EvidenceTier::joinbase #2339 real_calleredge edge ✅ stdlib_receiver(root.join(..))fabricated none ✅ call_expr_receiver(root.to_path_buf().join(..))fabricated none ✅ Total edges went 24 → 22; the only two removed are the fabricated ones.
A few more receiver shapes. Legitimate project calls are all kept, and several more std receivers are fixed. Two typed receivers still fall through to name matching:
receiver base #2339 let t = EvidenceTier(1); t.join(..)edge edge ✅ (legit) t: &EvidenceTier→t.join(t)edge edge ✅ (legit) p: PathBuf→p.join(..)fabricated none ✅ let p = PathBuf::from(..); p.join(..)fabricated none ✅ v: &[&str]→v.join("/")fabricated none ✅ struct field self.root.join(..)withroot: PathBuffabricated fabricated v: Vec<String>→v.join(",")fabricated fabricated untyped closure param |p| p.join(..)fabricated fabricated (expected per your note) Minimal repro for the two remaining cases (same
EvidenceTieras in the original report):use std::path::PathBuf; pub struct EvidenceTier(pub u8); impl EvidenceTier { pub fn join(&self, other: &EvidenceTier) -> EvidenceTier { EvidenceTier(self.0.max(other.0)) } } pub struct Cfg { pub root: PathBuf } impl Cfg { /// Field receiver: type is declared on the struct. Must NOT hit EvidenceTier::join. pub fn field_receiver(&self) -> PathBuf { self.root.join("x") } } /// Vec<String> derefs to [String]; join is [T]::join. Must NOT hit EvidenceTier::join. pub fn vec_join(v: Vec<String>) -> String { v.join(",") }
MATCH (a)-[:CALLS]->(b) WHERE b.name = "join" RETURN a.name, b.qualified_nameOn #2339 this still returns
field_receiverandvec_join→...EvidenceTier.join. My guess is that field types onself.<field>aren't propagated into receiver resolution, and the std model is missing theVec<T>→[T]deref forjoin, but I haven't dug into the resolver to confirm.The
self.field.join(..)shape is very common in the real workspace from the report (config/context structs holding aroot: PathBuf), so I'd expect a noticeable share of the remainingjoinfan-in to come from it. Happy to open a separate issue for these two if you'd rather keep #2053 scoped to the original repro.Thanks again!
- added a commit that references this issue
on Sep 30, 2026 - added a commit that references this issue
on Sep 30, 2026 Thank you again for reporting this, @Nicolas0315! The fix is now on
mainin 78795ad (merged via #2339), and it will ship in the next release.Reacted by Nicolas Hidemaru Ogoshi / 大越ニコラス秀丸Reacted by Nicolas Hidemaru Ogoshi / 大越ニコラス秀丸Reacted by Nicolas Hidemaru Ogoshi / 大越ニコラス秀丸
Version
0.10.8 (also reproduces on 0.9.0)
Platform
macOS (arm64), Darwin 25.6.0
Summary
On Rust, a method call whose receiver is a std type loses the receiver at extraction and exact-matches a same-named project method, fabricating a
CALLSedge.PathBuf::join,Option::from,Vec::lenetc. are attributed to project symbols that the calling file never mentions.This is the Rust instance of the class already filed for Go (#1906, #1909, #1927) and cross-language (#1572). I did not find a Rust-specific issue open for it — #1694 (cfg-duplicated names) is closed and a different mechanism.
Minimal reproduction
Cargo.toml+ a singlesrc/lib.rs:Actual
Expected
EvidenceTier::joinhas exactly one caller (real_caller).stdlib_receiverandcall_expr_receivercallstd::path::Path::join/PathBuf::join, which is not a project symbol and should produce noCALLSedge to a project node.Scale on a real repo
A 376k-line Rust workspace (860
.rsfiles, indexed as 53,989 nodes):CALLSedges1 target
nameinnew, len, join, iter, from, get, contains, push, take, ok, all, min, max, clone, into, next, insert, unwrap, to_string, …(70 names). Not every one of these is fabricated — some project methods legitimately carry those names — but spot-checks came back 100% fabricated:scripts/reasoning-helper/src/path_safety.rs::accepts_child_path→EvidenceAxisTier::join.Real code is
root.join("3.8.0").grep -c EvidenceAxisTierin that file = 0.plugins/grok-bench-v2/src/checkpoint.rs::absolutize→EvidenceAxisTier::join.Real code is
fn absolutize(root: &Path, path: PathBuf) { root.join(path) }. Same file,EvidenceAxisTiercount = 0.Impact beyond the edges
get_architecturehotspots are ranked by this fabricated fan-in, so the "most important functions" list is wrong, not just noisy — on the repo above the top entries werejoin(426),matches(343),iter(257),len(234), i.e. std methods. That matches what #1572 reports for Louvain clusters.Because the answer is confident, well-formed, and wrong, an agent that trusts
trace_pathfor "who calls X" has no signal that it should fall back to grep. Suggestion: when the receiver type cannot be resolved, emit no edge (or a distinctly-typed low-confidence edge thattrace_pathfilters by default) rather than name-matching into the project namespace — and exclude unresolved-receiver edges from hotspot/cluster ranking.