Skip to content

TypeScript: a call through this.<field> resolves to the ENCLOSING method when the two share a name — silent self-edge, 0% recall on that shape #1496

Closed

Description

@assafkamil

Summary

this.someField.doThing() resolves to the calling method instead of the
field's method, whenever the calling method happens to be named doThing too.
The result is a stored calls self-edge — A::doThing → A::doThing — so
callers, callees, impact, and trace are wrong, silently and plausibly.

The identical call resolves correctly if the calling method has any other
name. Name collision is the only variable; no framework, no decorators, no DI
involved.

Version 1.5.0 (npm, darwin-arm64 bundle). Language: TypeScript.

Reproduction

Three files, no dependencies.

// src/mailer.ts
export class Mailer {
  send(msg: string): string { return msg; }
}
// src/outbox.ts — calling method SHARES the callee's name
import { Mailer } from './mailer';
export class Outbox {
  private mailer = new Mailer();
  send(msg: string): string { return this.mailer.send(msg); }
}
// src/relay.ts — identical call, calling method has a DIFFERENT name
import { Mailer } from './mailer';
export class Relay {
  private mailer = new Mailer();
  forward(msg: string): string { return this.mailer.send(msg); }
}
$ codegraph init && codegraph callers Mailer.send
Callers of "Mailer.send" (1):

method      forward
  src/relay.ts:6

Outbox::send is missing. The stored edges show where it went:

$ sqlite3 .codegraph/codegraph.db "SELECT n1.qualified_name, n2.qualified_name
    FROM edges e JOIN nodes n1 ON n1.id=e.source JOIN nodes n2 ON n2.id=e.target
    WHERE e.kind='calls';"

Outbox::send    ->  Outbox::send      ❌ self-edge
Relay::forward  ->  Mailer::send      ✅

Expected

Both should resolve to Mailer::send. Outbox::send does not call itself.

The field style does not matter

Same fixture, varying only how mailer is declared. Only the name collision
changes the outcome:

field declaration calling method result
private mailer = new Mailer() forward ✅ Mailer::send
private mailer: Mailer (assigned in ctor) forward ✅ Mailer::send
constructor(private readonly mailer: Mailer) forward ✅ Mailer::send
constructor(private readonly mailer: Mailer) send ❌ self-edge
private mailer = new Mailer() send ❌ self-edge

Scale on a real codebase

A TypeScript monorepo (NestJS server + Electron tooling), 2,106 files,
26,583 nodes, 77,741 edges. All this.<field>.<method>() call sites where the
field has a declared type, in apps/server and tools/strauss:

enclosing method name sites sampled resolved correctly
same as callee 60 0 (0%)
different 60 28 (47%)

732 such sites total, 181 of them colliding. Repo-wide there are 335
calls self-edges
; six sampled at random were all this bug, none real
recursion:

AdminBootstrapController::inviteInitialOwner  → this.adminService.inviteInitialOwner(
AdminBootstrapController::resendOwnerInvite   → this.adminService.resendOwnerInvite()
AdminBootstrapController::getBootstrapStatus  → this.adminService.getBootstrapStatus()
AuthProxyController::signIn                   → this.authProxyService.signIn({
AppModule::forRoot                            → ThrottlerModule.forRoot([
AppModule::forRoot                            → ConfigModule.forRoot({

The last two show it is not limited to this. receivers: ThrottlerModule.forRoot(
is a static call on an imported class, and it also collapsed onto the enclosing
AppModule::forRoot.

The concentration is not accidental. Delegation — a method forwarding to a
same-named method on a collaborator — is a normal pattern, and it is the exact
shape that fails. In frameworks that encourage it (NestJS controllers calling
same-named service methods) essentially every delegation edge is wrong.

Hypothesis (not verified — reading the shipped bundle only)

preferCallSiteFile in src/resolution/name-matcher.ts ranks same-file
candidates ahead of all others (#1079), and the precedence comment nearby
describes the order as preferredFqn → same-file → matches[0]. For
this.<field>.<method>() the receiver does not appear in the reference
(referenceName is the bare method name), so receiver-type inference has
nothing to key on, and resolution falls through to name matching — where the
call site's own file always wins.

That would explain both columns above: with a collision the same-file candidate
shadows the correct cross-file one; without a collision there is no same-file
candidate, so the single remaining match happens to be right.

If that is the mechanism, the fix is presumably to attempt receiver-type
inference for member-expression receivers before the same-file preference
applies, rather than to weaken preferCallSiteFile — the #1079 behaviour is
clearly right for genuinely ambiguous bare names.

Related

Why this one is costly

A missing edge is a visible gap. This produces a confident wrong edge that
reads as a legitimate self-call, so anything built on callers / impact —
blast-radius counts, review tooling, agent context — inherits the error with no
signal that it happened.

Activity

  1. inth3shadows commented on Aug 6, 2026

    @inth3shadows
    Contributor

    Reproduced your fixture exactly on 1.5.0 (linux-x64), same two edges:

    Outbox::send    ->  Outbox::send
    Relay::forward  ->  Mailer::send
    

    Your hypothesis is right about what happens — the receiver never reaches the resolver — but the location is one layer earlier than you inferred, and that changes the fix. preferCallSiteFile is innocent here.

    The receiver is dropped at extraction, not mis-ranked at resolution

    Forcing the calls to stay unresolved so the stored reference name is readable:

    send(msg: string): string {
      this.mailer.zzzNoSuchMethod(msg);      // this.<field> receiver
      const m = new Mailer();
      m.zzzAlsoNoSuch(msg);                  // plain local receiver
      return msg;
    }
    $ sqlite3 .codegraph/codegraph.db "SELECT reference_name FROM unresolved_refs;"
    zzzNoSuchMethod        <- receiver gone
    m.zzzAlsoNoSuch        <- receiver kept

    So by the time name-matcher.ts runs, the ref for this.mailer.send(msg) is the bare string send. The receiver-inference branch is keyed on a receiver.method shape (dotMatch, name-matcher.ts:1633), a bare name doesn't match it, and the whole inference path is skipped — then name matching runs and the same-file candidate wins. preferCallSiteFile is the last step in the chain, not the cause.

    That matters for your proposed fix: "attempt receiver-type inference for member-expression receivers before the same-file preference applies" can't work as written, because there is no receiver in the reference to infer from. It has to be repaired in the extractor.

    Control that isolates it: the collision is not the trigger

    Same name collision, only the receiver shape changed:

    export class Outbox {
      send(msg: string): string { const m = new Mailer(); return m.send(msg); }
    }
    Outbox::send -> Mailer::send        ✅ correct, despite `send` calling `send`
    

    Receiver inference beats the same-file preference just fine when the receiver survives. So the variable in your table isn't the name collision — it's that a this.<field> receiver produces a bare ref, and a bare ref has nothing left to disambiguate with. The collision only decides whether the fallback lands on a wrong node or accidentally the right one, which is exactly why your "different name" column sits at 47% rather than 100%.

    The line

    src/extraction/tree-sitter.ts:4406-4412:

    const SKIP_RECEIVERS = new Set(['self', 'this', 'cls', 'super']);
    if (receiver && (receiver.type === 'identifier' || receiver.type === 'simple_identifier' || receiver.type === 'field_identifier')) {
      const receiverName = getNodeText(receiver, this.source);
      if (!SKIP_RECEIVERS.has(receiverName)) {
        calleeName = `${receiverName}.${methodName}`;
      } else {
        calleeName = methodName;
      }
    } else if ( /* cpp | c | kotlin | swift | rust | go | scala */ && receiver.type === 'call_expression') {

    For this.mailer.send() the receiver node is this.mailer — a member_expression, not an identifier — so the first guard fails. The else if that handles non-identifier receivers is gated to cpp/c/kotlin/swift/rust/go/scala, which excludes TS/JS. calleeName therefore stays the bare methodName.

    Worth noting the repo already has the shape this needs, in another language: PHP preserves the property receiver, reaching the resolver as this->prop.method, and gets a dedicated inference-only branch at name-matcher.ts:1650-1672 that returns no edge rather than a guessed one when the type can't be recovered. TS/JS has no equivalent.

    One correction on your samples

    I could not reproduce the static-call half. ThrottlerModule.forRoot([]) resolves correctly — ThrottlerModule is a plain identifier, so it passes the guard above and the receiver survives:

    static forRoot(): string {
      ThrottlerModule.forRoot([]);   // -> AppModule::forRoot -> ThrottlerModule::forRoot  ✅
      this.helper.forRoot();         // -> AppModule::forRoot -> AppModule::forRoot        ❌
    }
    AppModule::forRoot -> ThrottlerModule::forRoot
    AppModule::forRoot -> AppModule::forRoot
    

    Both edges hang off the same enclosing method, so a self-edge attributed to the ThrottlerModule.forRoot( line in your repo-wide scan was most likely produced by a different this.<field> call in that same method. Worth re-checking those two rows — if the static form really does fail on your codebase it's a second cause, and it'd be useful to know that separately.

    Everything else in your report reproduces as written. The 0%/47% split, the field-style table, and the delegation-concentration argument all follow from the single extraction gap above.


    Reproduced on released 1.5.0; source read against main @ d6d1728.

  2. colbymchenry commented on Sep 8, 2026

    @colbymchenry
    Owner

    Verified and fixed on Linux (x86_64, Node 22.19.0).

    Root cause: this.mailer.send() was extracted as the bare method name send, so exact-name matching preferred the same-file enclosing method and stored a silent self-edge whenever the wrapper shared the callee name.

    Fix: Landed upstream #1691 (danusha2345) onto current main as Forge PR #1792 @ bc47acc7. Extraction keeps this.<field>; resolution uses the field declared type.

    Linux fail → pass (issue fixtures):

    Backend Before After
    wasm Outbox::send → Outbox::send Outbox::send → Mailer::send
    native kernel Outbox::send → Outbox::send Outbox::send → Mailer::send

    Relay::forward → Mailer::send stayed correct; zero self-edges after the fix.

    #1566 coexistence: Map / nested holder.values.get cases from #1790 remain green (10/10 resolution cases on both backends).

    Closing as completed; #1691 superseded by #1792.

  3. colbymchenry commented on Sep 8, 2026

    @colbymchenry
    Owner

    Fixed in #1792 (lands #1691). Linux verified fail→pass; #1566 coexistence green.

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