Repository navigation
Java: a call on an explicitly-typed parameter binds to a same-named method of an unrelated class #2382
Description
Summary
When two or more classes declare a method with the same simple name, a call made on a
variable whose type is explicitly declared is bound to the wrong class. The receiver's
declared type is known to the indexer — it emits a correct USAGE edge to it — but the
CALLS edge points somewhere else.
Two classes are enough to reproduce. No generics, no inheritance, no overloads, no wildcard
imports involved.
Environment
| Version | codebase-memory-mcp 0.11.0 (release v0.11.0, codebase-memory-mcp-windows-amd64.zip) |
| OS | Windows 11 (26100), amd64 |
| Install | release binary, run directly (no npm wrapper) |
| Language | Java |
Reproducer
The script below generates a throwaway project, indexes it and prints the result.
./repro.sh 2 # 2 classes declaring getValueA/getValueB
CBM=/path/to/binary ./repro.sh 2repro.sh
#!/usr/bin/env bash
# Reproducer: a Java call on an explicitly-typed parameter binds to a same-named
# method of an unrelated class. Two classes are enough.
#
# Usage: ./repro.sh [N] N = number of classes declaring getValueA/getValueB (default 2)
# CBM=/path/to/binary ./repro.sh 2
#
# Prints the CALLS edges from CallerDAO.doWork. Expected: two edges to TargetDTO.
set -euo pipefail
N="${1:-2}"
CBM="${CBM:-codebase-memory-mcp}"
DIR="$(mktemp -d)"
trap 'rm -rf "$DIR"' EXIT
# 1 target class + (N-1) decoys, each in its own package, all declaring the same two methods
mk_dto() { # $1 = package, $2 = class
mkdir -p "$DIR/src/com/example/$1"
cat > "$DIR/src/com/example/$1/$2.java" <<EOF
package com.example.$1;
public class $2 {
private String valueA;
private long valueB;
public String getValueA() { return valueA; }
public long getValueB() { return valueB; }
}
EOF
}
mk_dto target TargetDTO
for ((i = 1; i < N; i++)); do
mk_dto "$(printf 'decoy%03d' "$i")" "$(printf 'Decoy%03dDTO' "$i")"
done
# The only caller. Parameter is explicitly typed; only TargetDTO is imported.
mkdir -p "$DIR/src/com/example/dao"
cat > "$DIR/src/com/example/dao/CallerDAO.java" <<'EOF'
package com.example.dao;
import com.example.target.TargetDTO;
public class CallerDAO {
public void doWork(TargetDTO target) {
String a = target.getValueA();
long b = target.getValueB();
System.out.println(a + b);
}
}
EOF
( cd "$DIR" && git init -q && git add -A && \
git -c user.email=r@r -c user.name=r commit -qm repro )
echo "classes declaring getValueA/getValueB: $N"
OUT="$("$CBM" cli --quiet index_repository --repo-path "$DIR" 2>&1)"
PROJECT="$(printf '%s' "$OUT" | sed -n 's/.*"project":"\([^"]*\)".*/\1/p')"
echo "--- CALLS edges out of CallerDAO.doWork ---"
"$CBM" cli --quiet query_graph --project "$PROJECT" \
--query "MATCH (a)-[r:CALLS]->(b) WHERE a.qn =~ '.*CallerDAO.doWork' RETURN b.qn AS callee" 2>&1 \
| grep -E 'TargetDTO|Decoy|rows:' || true
echo "--- expected: TargetDTO.getValueA and TargetDTO.getValueB ---"The generated project is:
src/com/example/target/TargetDTO.java -> getValueA(), getValueB()
src/com/example/decoy001/Decoy001DTO.java -> getValueA(), getValueB()
src/com/example/dao/CallerDAO.java -> imports TargetDTO only
package com.example.dao;
import com.example.target.TargetDTO;
public class CallerDAO {
public void doWork(TargetDTO target) {
String a = target.getValueA();
long b = target.getValueB();
System.out.println(a + b);
}
}Decoy001DTO is never imported, never referenced, and lives in a different package.
Expected
CallerDAO.doWork --CALLS--> com.example.target.TargetDTO.getValueA
CallerDAO.doWork --CALLS--> com.example.target.TargetDTO.getValueB
Actual
CallerDAO.doWork --CALLS--> com.example.decoy001.Decoy001DTO.getValueA
CallerDAO.doWork --CALLS--> com.example.decoy001.Decoy001DTO.getValueB
Both edges point at the class that is not imported and not the parameter's type.
The binding appears to select the last same-named symbol in index order rather than the one
matching the receiver type. Increasing the number of decoys moves the target accordingly:
classes declaring getValueA |
CALLS edge resolves to |
|---|---|
| 2 | Decoy001DTO.getValueA |
| 47 | Decoy046DTO.getValueA |
| 48 | Decoy047DTO.getValueA |
Same result with all classes under one source root and with each class under its own source
root.
Notes
The declared type is available to the indexer. Alongside the wrong CALLS edges, the
graph contains a correct USAGE edge from the calling method to the parameter's declared
type. The information needed to bind correctly is present; it just is not used to filter
the candidates.
Deterministic. Three consecutive runs of ./repro.sh 2, each on a freshly created
project, produced the identical wrong binding (Decoy001DTO.getValueA) every time.
Not concurrency-related. No change in outcome with any of, each tested against a freshly
created project so no index could be reused:
CBM_INDEX_SINGLE_THREAD=1CBM_WORKERS=1CBM_WALK_DEFS_MAX=100000CBM_LSP_MAX_WALK_DEPTH=1000
Second failure mode on a large codebase
On a private ~3,500-file Java codebase (JEE, Java 7 source level) the same area produces a
different symptom: for method names declared by many classes, the CALLS edges are absent
altogether rather than misbound.
Observed on one method of one DAO, where the parameter type is explicit and the call sites
are plain accessor calls:
| method called | classes declaring that name | result |
|---|---|---|
accessor1 |
4 | correct |
accessor2 |
13 | bound to an unrelated class |
accessor3 |
21 | bound to an unrelated class |
accessor4 |
26 | bound to an unrelated class |
accessor5 |
75 | bound to an unrelated class |
accessor6 |
77 | bound to an unrelated class |
accessor7 |
199 | bound to an unrelated class |
accessor8 |
390 | no edge at all |
(method names replaced with placeholders; all eight are plain no-argument getters declared
on the parameter's type)
The classes it binds to are unrelated DTOs in different packages that happen to declare the
same accessor name. The declaring class does declare every one of these methods.
In a synthetic variant we could make the "no edge at all" behaviour appear above 48 classes
declaring the same name, with 47 still producing an edge, but we could not reduce that
second mode to a clean minimal case, so we are reporting it only as an observation.
Impact
This makes trace_path, impact and any blast-radius query unreliable on codebases with
repeated accessor names, which is the norm for large DTO-heavy Java applications. The
failure is silent: the returned edges look plausible, and nothing in the output distinguishes
a correctly bound call from a misbound one.
Possibly related
Scala: receiver.method(...) calls are bound to an arbitrary same-named symbol by short nameGo: method QNs ignore the receiver — every same-name method in a package collapses onto one node#2061 Swift: overloaded methods collapse into one node, producing fake callers in trace_path#1128 fix(registry): restrict same-module and unique-name suffix matches on receivers#1890 No gate on aggregate call-graph volume
This looks like the Java instance of the same same-name binding family.
Activity
tfactor2 commentedon Oct 16, 2026
The wrong edge in this reproducer has another cause: the target class sits in a directory named
target/, which discovery always skips (filed as #2661).TargetDTOis not in the graph, so the decoy is the only candidate. With the package renamed, main binds both calls toTargetDTO(suffix_match).The title's problem is still real:
On main (v0.12.0)
d.isShown()binds toActiveDialog.isShown. Cause: the Java cross-file resolver keys types by package-qualified name, but parameter, return and supertype types reach it as simple names, so an imported type never resolves and the call falls back to the name guess. A fix PR will reference this issue.DeusData commentedon Oct 17, 2026
Thank you @tfactor2, this is a really helpful narrowing: splitting the
target/discovery part out into #2661 and reducing the real problem to the two-classActiveDialog/InactiveDialogcase makes this much easier to pin down.Our reading of
mainpoints at the same mismatch you describe, and adds one more place it shows up. The import map that the pipeline hands to the Java cross-file resolver carries graph QNs (pxc_import_value_qninsrc/pipeline/pass_lsp_cross.c), and those entries are added before the file's own package-qualifiedimportstatements. Sojava_resolve_type_nameresolvesInactiveDialogto a spelling the package-keyed registry never matches, the LSP reportsno_method_match, and the call drops to the name guess. Kotlin already keeps the source spelling there.You mentioned a fix PR for this issue. Are you still planning to open it? If so, we will gladly review and measure it, the same way we are measuring #2667 now. If you would rather not take it on, just say so, and we will carry the fix ourselves and credit you for the diagnosis and the reproducer.