Skip to content

TypeScript: built-in Map.get/set/has calls resolve to unrelated project methods #1566

Closed

Description

@netbrah

Summary

TypeScript calls to the built-in Map.get, Map.set, and Map.has methods
resolve to unrelated project methods when the project contains exactly one
same-language method with each name.

Both common receiver shapes fail:

  • a simple local receiver such as values.get(...) is stored as
    values.get, then resolves to the sole project get method with confidence
    0.7 / instance-method; and
  • a nested receiver such as holder.values.get(...) is extracted as bare
    get, then resolves to the project method with confidence 0.9 /
    exact-match.

The simple local receiver is the distinct gap demonstrated here. The nested
receiver belongs to the extraction family already reported in #1496 and is
included as a regression boundary rather than as a second new defect.

The resulting callers, callees, impact, and raw graph all report calls that do
not exist. An unresolved external/built-in call would be safer than a confident
wrong project edge.

Minimal reproduction

One dependency-free TypeScript file:

export class LRUCache {
  private store = new Map<string, string>();

  get(key: string): string | undefined {
    return this.store.get(key);
  }

  set(key: string, value: string): void {
    this.store.set(key, value);
  }

  has(key: string): boolean {
    return this.store.has(key);
  }
}

export function useLocalMap(): boolean {
  const values = new Map<string, string>();
  values.set("answer", "42");
  values.get("answer");
  return values.has("answer");
}

export function useNestedMap(
  holder: { values: Map<string, string> },
): string | undefined {
  return holder.values.get("answer");
}
$ codegraph init

$ codegraph callees useLocalMap --json
{
  "symbol": "useLocalMap",
  "callees": [
    { "name": "set", "kind": "method", "filePath": "repro.ts", "startLine": 8 },
    { "name": "get", "kind": "method", "filePath": "repro.ts", "startLine": 4 },
    { "name": "has", "kind": "method", "filePath": "repro.ts", "startLine": 12 }
  ]
}

$ codegraph callees useNestedMap --json
{
  "symbol": "useNestedMap",
  "callees": [
    { "name": "get", "kind": "method", "filePath": "repro.ts", "startLine": 4 }
  ]
}

The stored edge metadata shows two independent fallbacks:

source          target          confidence  resolvedBy       refName
useLocalMap     LRUCache::set   0.7         instance-method  values.set
useLocalMap     LRUCache::get   0.7         instance-method  values.get
useLocalMap     LRUCache::has   0.7         instance-method  values.has
useNestedMap    LRUCache::get   0.9         exact-match      get

Control

Remove only the LRUCache class, leaving both Map-using functions unchanged,
then initialize a fresh index:

$ codegraph callees useLocalMap --json
{ "symbol": "useLocalMap", "callees": [] }

$ codegraph callees useNestedMap --json
{ "symbol": "useNestedMap", "callees": [] }

The control graph contains zero calls edges. The four call sites that remain
unchanged between the two fixtures acquire false edges only when same-named
project methods become available.

Source-level cause

There are two paths to the same bad outcome.

In src/extraction/tree-sitter.ts, TS/JS method-call extraction preserves a
receiver only when it is a simple identifier. A nested member_expression
such as holder.values or this.store falls through as the bare method name.
That bare name then reaches exact-name resolution, where a sole same-language
candidate wins at confidence 0.9.

For the distinct preserved-simple-receiver case, matchMethodCall in
src/resolution/name-matcher.ts gathers every project method named get,
set, or has. When exactly one same-language method exists, it selects that
method at confidence 0.7 even though receiver inference has not established
that the built-in Map receiver has the project class's type.

A guard that only recognizes an already-inferred receiver type of Map would
not fix the nested case. For simple local receivers, once inference identifies
an external/built-in type rather than a project class, failure to find a project
method should remain unresolved instead of falling back. The recall tradeoff
for a genuinely untyped receiver is separate. For multi-segment TS/JS
receivers, the safe choices are to infer the nested property's type or keep the
call unresolved; preserving more receiver text and then applying the same
unique-method guess would retain the false edge.

Suggested regression

Add a TypeScript fixture beside the local receiver-inference tests with:

  1. one project class defining get, set, and has;
  2. a local Map using all three methods; and
  3. a nested holder.values.get() call.

Assert that none of those built-in calls produces a project calls edge. Keep
the existing positive control where a local variable constructed as a project
class resolves to that class's method.

Relationship to existing issues

Environment

Reproduced from fresh indexes with the released CodeGraph 1.5.0 binary on
macOS 26.6 arm64.

Activity

  1. kogriv commented on Aug 28, 2026

    @kogriv

    Same defect on Python, and it has a downstream consequence that connects this issue to #888 / #889 / #1012.

    Python instance

    Two files, no imports between them, one method named get in the project:

    # cache.py
    class LRUCache:
        def __init__(self):
            self._store = {}
    
        def get(self, key):
            return self._store.get(key)
    
    # settings.py
    DEFAULTS = {"answer": "42"}
    
    
    def read_setting(name):
        return DEFAULTS.get(name, None)
    $ codegraph init . -y
    $ codegraph callers get --limit 500 --json
    {
      "symbol": "get",
      "callers": [
        { "name": "read_setting", "kind": "function", "filePath": "settings.py", "startLine": 4 }
      ]
    }

    DEFAULTS.get(...) is dict.get. read_setting never touches LRUCache. Same shape as the Map.get case above, so this looks like one resolver behaviour rather than two language-specific ones.

    On a real 207-file Python package the affected band is the usual suspects — call-edge targets by name: log 1654, info 691, warning 222, get 164, calculate 114. I checked the six calls edges into one get method by hand: all six are dict.get at the call site.

    Why this reaches further than callers/callees/impact

    GraphQueryManager.getFileDependencies deliberately walks the resolved call/reference edges rather than imports (its comment explains why, and the reasoning is sound). findCircularDependencies is built on it. So a false call edge becomes a false dependency, and a false dependency in the opposite direction of a real import becomes a reported cycle.

    Two files, fifteen lines, a one-way import:

    # settings.py
    DEFAULTS = {"answer": "42"}
    
    
    def read_setting(name):
        return DEFAULTS.get(name, None)
    
    # cache.py
    from settings import read_setting
    
    
    class LRUCache:
        def __init__(self):
            self._store = {}
    
        def get(self, key):
            return self._store.get(key)
    
        def warm(self):
            self._store["answer"] = read_setting("answer")
    settings.py deps: [ 'cache.py' ]      <- from DEFAULTS.get() resolving to LRUCache.get
    cache.py    deps: [ 'settings.py' ]   <- the real import
    findCircularDependencies: [["cache.py","settings.py"]]

    settings.py imports nothing. On the 207-file package above, findCircularDependencies() returns 136 cycles; an import-graph cycle check on the same tree returns 1 (a genuine one — two modules in the same subpackage importing each other).

    That is the part I thought was worth adding here rather than in a new issue: #888 / #889 / #1012 ask for circular-dependency detection to be exposed on the CLI and MCP. Shipping it on top of the current edges would surface this defect to every user of that feature, where today it only affects consumers who reach for the API directly. Fixing #1566 first makes that feature land clean.

    Not claimed

    • No opinion on the fix. Your framing above — an unresolved built-in call being safer than a confident wrong project edge — already covers the Python case.
    • The 136 were not classified one by one; six edges into one method were, plus the two-file repro.
    • findDeadCode() shares the same input, but I did not measure its precision.

    Found while benchmarking CodeGraph against a Python code-graph tool of my own on a fixed, hash-pinned scope; the full write-up, including the axes where CodeGraph came out ahead, is public in that project's tool card. Environment: CodeGraph 1.6.0 (npm), Linux x64, Node v24.12.0, DO_NOT_TRACK=1.

  2. inth3shadows commented on Sep 4, 2026

    @inth3shadows
    Contributor

    Reproduced on 1.6.0 (npm latest). Adding a minimal fixture and one distinguishing detail: this is not the same root cause as #1683, so a fix for that one will not close this.

    // src/store.ts
    export class Store {
      get(k: string) { return k; }
      set(k: string, v: string) { return v; }
      has(k: string) { return true; }
    }
    // src/use.ts
    export function useMap() {
      const m = new Map<string, string>();
      m.set("a", "b");
      m.get("a");
      m.has("a");
      return m;
    }

    Observed

    line call edge resolvedBy
    3 m.set(...) -> Store.set (src/store.ts) instance-method
    4 m.get(...) -> Store.get (src/store.ts) instance-method
    5 m.has(...) -> Store.has (src/store.ts) instance-method

    All three fabricated. Store is never imported, constructed or referenced in use.ts; the only receiver is a Map created two lines above.

    Why this is a distinct defect

    The receiver here is a plain identifier with a local, explicit type (const m = new Map<string, string>()), and resolution reports instance-method — it went down the receiver-typing path and picked a project class whose method names happen to match. Contrast #1683, where the receiver is a call expression, is dropped at extraction, and resolves exact-match by bare name.

    So there are two independent paths that both land a call on an unrelated project symbol:

    The fix direction that would close this one specifically: when local type inference yields a built-in/lib type, treat the call as external and stop, rather than falling through to name-matching against project classes — the same stance isBuiltInOrExternal already takes for console./Math./JSON., but keyed on the inferred receiver type instead of the callee name.

    Environment

    1.6.0 (and 1.5.0), Linux, fresh codegraph init, verified via the CLI and direct SQL against .codegraph/codegraph.db.

  3. inth3shadows commented on Sep 4, 2026

    @inth3shadows
    Contributor

    Following up my earlier comment with the fix direction turned into actual code.

    Where it goes wrong

    Every instance-method resolution funnels through resolveMethodOnType in src/resolution/name-matcher.ts (call sites at :893, :921, :962, :994, :1016). That function validates the method exists on the named type, which is exactly why a wrong inference normally yields no edge — but it never asks whether the inferred type is a project type at all. Map is not, so Map.get is looked up against project classes and Store.get matches on name.

    The existing built-in guard cannot catch this. BUILTIN_TYPES (src/extraction/tree-sitter.ts:5809) is scalars only:

    string number boolean void null undefined never any unknown object symbol bigint
    str bool i8..i128 usize f32 f64 char int long short byte float double
    int8..uint64 float32 float64 complex64 complex128 rune error
    Int Long Short Byte Float Double Boolean Char Unit String Any AnyRef AnyVal Nothing Null
    

    No Map, Set, WeakMap, WeakSet, Array, Promise, Date, RegExp, Error, Proxy. It also lives in the extractor, so the resolver never consults it.

    Patch

    Guard at the shared choke point, so all five instance-method call sites are covered by one change:

    // src/resolution/name-matcher.ts
    
    // JS/TS built-in object types. A method call on one of these is external, no
    // matter that a project class happens to declare the same member name —
    // `new Map()` then `m.get(k)` is `Map.prototype.get`, never `Store.get`.
    // Scalars are already handled by the extractor's BUILTIN_TYPES; these are the
    // *object* types, which that set does not contain and which the resolver
    // never consulted.
    const BUILTIN_OBJECT_TYPES = new Set([
      'Map', 'Set', 'WeakMap', 'WeakSet', 'Array', 'Promise', 'Date', 'RegExp',
      'Error', 'Proxy', 'Reflect', 'ArrayBuffer', 'DataView', 'Intl', 'JSON', 'Math',
    ]);
    
    function resolveMethodOnType(
      typeName: string, method: string, ref: UnresolvedRef,
      context: ResolutionContext, confidence: number, via: string, fqn?: string,
    ): ResolvedRef | null {
      // Bail before the project-class lookup, not after: falling through to name
      // matching is what fabricates the edge.
      const bare = typeName.replace(/<.*$/, '').trim();   // strip generics: Map<string, T>
      if (BUILTIN_OBJECT_TYPES.has(bare)) return null;
      // ... existing lookup
    }

    Three notes for whoever takes it:

    1. Strip generics before the check. The inferred type here is Map<string, string>, so a bare has() on the raw string misses.
    2. A project class genuinely named Map would now be skipped. If that matters, gate the check on the name not being in knownNames — though I would argue shadowing a built-in container name is rare enough, and silent-miss is the safer failure, that the simple form is the better default.
    3. This is a different defect from 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 and needs its own fix — that one is bare-name matching after the receiver is lost; this one is receiver typing succeeding and then not excluding a built-in.

    I have not built or tested this patch, unlike the ones I posted on #1681 and #1683 — I traced the call sites and the BUILTIN_TYPES gap but did not run it. Treat it as a reviewed proposal, not a verified fix.

  4. colbymchenry commented on Sep 8, 2026

    @colbymchenry
    Owner

    Implemented in #1790, commit d818b74de411bcadc10a7b022961582e377ec9bd.

    Receiver inference now stops guessing when a known JS/TS built-in has no matching project method. Untyped nested identifier chains are left unresolved by both extractors, and local project-class receivers still resolve normally.

    Verified on Linux x86_64 with Node 22.19.0 after rebuilding TypeScript/assets and the native kernel. Fresh CLI re-indexes and direct SQLite checks produced the same results in the default native engine and with CODEGRAPH_KERNEL=0:

    Fixture Main 72c1ff13 PR branch
    useLocalMap False LRUCache get/set/has edges, confidence 0.7 No project calls
    useNestedMap False LRUCache get edge, confidence 0.9 No project calls
    Local new LRUCache() control Correct get/set/has edges All three retained, confidence 0.9
    Remove LRUCache No false edges No false edges

    Native verification: 920 tests passed across nine related suites, including kernel/wasm parity. Wasm verification: 889 tests passed across seven related suites. The nine new negative regression cases failed before the fix.

    PR #1691 was evaluated and not used: the separate this.<field>.method() family remains tracked by #1496/#1691. The fix is currently on the open PR branch, not merged or released; re-index after upgrading once it is released.

  5. kogriv commented on Oct 15, 2026

    @kogriv

    Re-measured on 1.6.2, same 207-file Python package and the same hash-pinned staging as my comment above, with 1.6.0 run first through the same harness as a control (it reproduced the original figures exactly).

    1.6.0 1.6.2
    findCircularDependencies() 136 25
    of them real import cycles 13 16 (none of the 13 lost)
    calls edges from dict.get sites into the project's MemoryCache.get 6 0
    the two-file repro above [["cache.py","settings.py"]] []

    So the fix holds on Python too, including the subscript-receiver shape (MAPPING[key].get(...)), which none of the PR titles named.

    A correction I owe you. My comment above said an import-graph check on that tree returns "1 (a genuine one)". That was the number of eager cycles. Counting every intra-package import, function-local ones included, the tree has 45. Precision of the 136 is unchanged by this (about 10%), but "136 where there is 1" overstated the gap. The one eager cycle is still the only one that breaks at import time.

    Nothing asked; just closing the loop. Method, raw results and the pre-registration are public in the tool card of the project mentioned above.

    CodeGraph 1.6.0 and 1.6.2 (npm), Linux x64, Node v24.12.0, DO_NOT_TRACK=1.

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