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

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.

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