Skip to content

Call-expression receivers drop the receiver and fabricate a calls edge to any same-named top-level symbol — Python AND JavaScript, live in 1.6.0 #1683

Closed

Description

@inth3shadows

Summary

A member call whose receiver is a call expression loses the receiver at extraction time, degrades to a bare method-name ref, and then exact-matches any top-level project symbol with that name — fabricating a call edge from an unrelated function.

Reproduced in Python and JavaScript, so this is the shared extractor, not a per-language heuristic.

Not the same as #1230/#1317 (literal receivers, fixed — control C below passes) or #645 (C++ chained getters binding to the wrong class; here the receiver is dropped entirely and the edge lands on a top-level function).

Environment

@colbymchenry/codegraph 1.5.0 and 1.6.0 (npm latest), Linux, fresh codegraph init per run.

Repro — Python

# pkg/ledger.py
def append(row): return row
def path():      return "/x"
# pkg/uses.py
from . import ledger

def plain_identifier(out, k):
    out.append(k)                   # C: plain ident      -> no edge   (correct)

def chained_receiver(d, k, v):
    d.setdefault(k, []).append(v)   # D: call-expr recv   -> calls ledger.append  WRONG

Repro — JavaScript

// src/lib.js
export function push(row) { return row; }
export function path() { return "/x"; }
// src/use.js
import * as lib from './lib.js';

export function realNamespaceCall(row) {
  lib.push(row);              // A -> calls lib.push  "import"    (correct)
}
export function plainIdent(out, k) {
  out.push(k);                // C -> no edge                     (correct)
}
export function chained(m, k, v) {
  m.getOrInit(k, []).push(v); // D -> calls lib.push  "exact-match"  WRONG
}

Observed — both languages, identical

shape receiver edge
plain identifier (out.append(k)) ident none — correct
literal (", ".join(v)) literal none — correct (#1317)
namespace/module member ident resolves via import — correct
call expression (d.setdefault(k,[]).append(v)) call fabricated exact-match edge to the top-level append/push

Only the receiver shape differs between the correct and incorrect rows.

Impact

Any project with a top-level export named like a common collection method (append, push, get, update, run, close) accumulates fabricated inbound edges proportional to how often that method is used on chained locals elsewhere. callers/impact then return a populated, plausible list that is wrong — worse than an empty one, which is visibly wrong.

Suspected mechanism

In the extractor, the qualified receiver.method shape is preserved only when the receiver is a plain identifier; a call-chain receiver degrades to a bare method ref. That bare ref passes the codebase-wide name check (a real top-level symbol of that name exists) and is exact-matched as the sole candidate.

Verification

Both versions, both languages, fresh index each time; cross-checked via the codegraph CLI and direct SQL against .codegraph/codegraph.db, which agree.

Activity

  1. inth3shadows commented on Sep 4, 2026

    @inth3shadows
    ContributorAuthor

    Adding the fix. I have this working locally against a patched build; the full upstream suite (2902 tests) passed with it applied.

    The receiver is dropped in the extractor's method-call branch: only a plain-identifier receiver keeps the qualified receiver.method shape, everything else falls through to a bare methodName. Keeping the receiver's source text as a qualifier is enough — the ref can then only resolve through import/module-member resolution, and an unresolvable qualifier becomes a silent miss instead of a wrong edge.

    // src/extraction/tree-sitter.ts — method-call receiver handling
    } else if (receiver) {
      // Any other receiver shape — attribute chain (`self.data`), subscript
      // (`d[k]`), call chain (`rows.setdefault(k, []).append(x)`) — previously
      // fell through to a bare methodName. A bare name matching a common
      // list/dict/str method (`append`, `get`, `update`, ...) can exact-match
      // an unrelated top-level project function sharing that name, fabricating
      // a call edge (same class as #1230/#1276). Keep the receiver's source
      // text as a qualifier so the ref can only resolve through import/
      // module-member resolution, never the bare-name fallback; an
      // unresolvable qualifier is a silent miss, never a wrong edge.
      const receiverText = getNodeText(receiver, this.source).replace(/\s+/g, ' ').trim();
      calleeName = receiverText ? `${receiverText}.${methodName}` : methodName;
    }

    One important caveat about my own version of this patch: I originally wrote it gated as this.language === 'python' && receiver, because I had only found the Python case. The JavaScript reproduction in this issue shows that gate is wrong — the same fabrication happens in JS, so the branch should be language-agnostic as written above. If you take the Python-gated form, this issue stays open for JS/TS.

    Worth checking before merging: the qualifier is raw source text, so rows.setdefault(k, []).append becomes a long referenceName. That is harmless for correctness (it simply will not match anything, which is the intent) but it does land in unresolved_refs, so it is worth confirming it does not bloat that table on a large repo. Normalising whitespace, as above, keeps it stable across formatting.

    I am not opening a PR — my checkout of this repo is kept read-only for bug-hunting, and the language-gating decision above is yours to make. Happy for anyone to take the patch as-is.

  2. inth3shadows commented on Sep 4, 2026

    @inth3shadows
    ContributorAuthor

    Third language: Go. This is no longer a Python/JS pair — and the Go case is the sharpest demonstration, because the correct target exists in the same file and a control line resolves to it.

    // store/store.go
    package store
    
    func Get(k string) string { return k }          // package-level func, line 3
    
    type Bag struct{ m map[string]string }
    func (b *Bag) Get(k string) string { return b.m[k] }   // method, line 7
    // main.go
    package main
    import "x/store"
    
    type Reg struct{ inner map[string]*store.Bag }
    func (r *Reg) lookup(k string) *store.Bag { return r.inner[k] }
    
    func chained(r *Reg, k, v string) string {
    	return r.lookup(k).Get(v)     // call-expression receiver
    }
    func direct(b *store.Bag, k string) string {
    	return b.Get(k)               // plain identifier receiver
    }
    func pkgCall(k string) string {
    	return store.Get(k)           // package-qualified
    }

    Observed (1.6.0, fresh index)

    line call resolves to resolvedBy
    10 r.lookup(k).Get(v) store.Get @store.go:3 exact-match WRONG
    10 r.lookup(k) lookup @main.go:7 instance-method correct
    14 b.Get(k) Bag.Get @store.go:7 instance-method correct
    18 store.Get(k) store.Get @store.go:3 import correct

    lookup returns *store.Bag, so line 10 should reach Bag.Get at :7 — exactly what line 14 reaches from the same type. Instead the receiver is dropped and the bare Get exact-matches the package-level function at :3.

    Line 14 proves the resolver can type this receiver and find the right method; line 10 proves it never gets the chance. Only the receiver shape differs.

    Consequence for the fix

    The patch I posted above is in the right place, but this settles the language-gating question: it must not be gated to Python. Confirmed fabricating in Python, JavaScript and Go, with the same exact-match signature in all three — it is the shared extractor path, so the branch should be unconditional on receiver.

    Go also shows the failure is not limited to "a name that looks like a collection method" — Get here collides with an ordinary package-level function. Any project where a method name is also a package-level function name is exposed.

    Verified on 1.6.0, fresh codegraph init, read back via direct SQL.

  3. Nicolas0315 commented on Sep 4, 2026

    @Nicolas0315

    Confirming this on Rust as well, on v1.6.0 (darwin-arm64), which supports the read that it's the shared extractor rather than a per-language heuristic.

    Minimal repro — Cargo.toml plus one src/lib.rs:

    use std::path::{Path, PathBuf};
    
    /// A project method named `join`. Only `real_caller` below calls it.
    pub struct EvidenceTier(pub u8);
    impl EvidenceTier {
        pub fn join(&self, other: &EvidenceTier) -> EvidenceTier {
            EvidenceTier(self.0.max(other.0))
        }
    }
    
    /// The ONLY legitimate caller of EvidenceTier::join.
    pub fn real_caller() -> EvidenceTier {
        EvidenceTier(1).join(&EvidenceTier(2))
    }
    
    /// Calls std PathBuf::join — plain receiver.
    pub fn stdlib_receiver(root: &Path) -> PathBuf {
        root.join("subdir")
    }
    
    /// Call-expression receiver — the shape this issue describes.
    pub fn call_expr_receiver(root: &Path) -> PathBuf {
        root.to_path_buf().join("nested")
    }
    $ codegraph init && codegraph sync
    ● 11 nodes, 21 edges
    
    $ codegraph callers join
    
    Callers of "join" (3):
    
    function    real_caller
      src/lib.rs:12
    
    function    stdlib_receiver          <- fabricated
      src/lib.rs:17
    
    function    call_expr_receiver       <- fabricated
      src/lib.rs:22
    
    $ codegraph callees stdlib_receiver
    
    Callees of "stdlib_receiver" (1):
    
    method      join
      src/lib.rs:6                       <- resolves to EvidenceTier::join
    

    Ground truth is one caller. Two of three are fabricated.

    Worth noting for scoping: stdlib_receiver is a plain receiver (root.join(...) where root: &Path), not a call-expression receiver. So on Rust the trigger is broader than the title suggests — an unresolved receiver of any kind falls through to bare-name matching, and a std method on a std type is enough. call_expr_receiver reproduces the exact shape in the report on top of that.

    The downstream cost is the part I'd flag: the same mechanism ranks codegraph's importance signals. On a 376k-line Rust workspace the top fan-in symbols came out as join, matches, iter, len — all std methods collapsed onto same-named project symbols — so it isn't only edge noise, it reorders which functions the tool says matter.

  4. colbymchenry commented on Sep 8, 2026

    @colbymchenry
    Owner

    Verified on Linux against current main, then fixed in #1748.

    Repro (pre-fix): call-expression receivers fabricated exact-match edges — Python chained_receiver → ledger.append, JS chained → lib.push. Plain-ident and literal receivers stayed correct; real namespace lib.push still resolved via import.

    Fix: rebased #1692 onto main (post-#1746): keep <inner>().<method> at extraction (TS + kernel) and refuse name-match guessing for that shape. Issue fixtures and __tests__/call-receiver-no-fabrication.test.ts now pass (kernel path and CODEGRAPH_KERNEL=0).

    Closing as fixed by #1748.

  5. added a commit that references this issue on Sep 8, 2026
    bb1d309
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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions