Skip to content

Java: a call on an explicitly-typed parameter binds to a same-named method of an unrelated class #2382

Open

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 2
repro.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=1
  • CBM_WORKERS=1
  • CBM_WALK_DEFS_MAX=100000
  • CBM_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 name
  • Go: 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

  1. tfactor2 commented on Oct 16, 2026

    @tfactor2

    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). TargetDTO is not in the graph, so the decoy is the only candidate. With the package renamed, main binds both calls to TargetDTO (suffix_match).

    The title's problem is still real:

    // com.example.ui: ActiveDialog and InactiveDialog, each with `boolean isShown()`
    import com.example.ui.ActiveDialog;
    import com.example.ui.InactiveDialog;
    
    public class Caller {
        public boolean param2(InactiveDialog d) { return d.isShown(); }
    }

    On main (v0.12.0) d.isShown() binds to ActiveDialog.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.

  2. DeusData commented on Oct 17, 2026

    @DeusData
    Owner

    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-class ActiveDialog / InactiveDialog case makes this much easier to pin down.

    Our reading of main points 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_qn in src/pipeline/pass_lsp_cross.c), and those entries are added before the file's own package-qualified import statements. So java_resolve_type_name resolves InactiveDialog to a spelling the package-keyed registry never matches, the LSP reports no_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.

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

    parsing/qualityGraph extraction bugs, false positives, missing edgeswindowsWindows-specific issues

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions