Repository navigation
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
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:6Outbox::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
- TypeScript/JavaScript: local-variable receiver-type inference doesn't cover typed function parameters (unlike Java/C#/Kotlin/Swift/Scala) #1125 (closed) — TS/JS receiver-type inference missing typed parameters.
The pattern it added is present in 1.5.0 and matches these field declarations;
it does not help here, which is what suggests the problem is precedence rather
than a missing pattern. - C++: method calls through singletons, factories, and chained getters resolve to the wrong class (or not at all) #645 (closed) — the C++ analogue: receiver type lost, same-named methods
resolve to the wrong class. Same failure character, different language and a
different tie-break (index order there, call-site file here). - Tracking: chained factory/singleton call resolution across statically-typed languages #750 — tracking issue for chained factory/singleton resolution.
- C++ method calls to same-named classes in different files all resolve to the first definition #1079 — introduced the same-file preference.
- callers/callees/impact silently answer for a DIFFERENT symbol when the requested name does not exist (top fuzzy hit substituted, no note) — CLI + MCP, verified end-to-end #1473 — also produces wrong
callersoutput, but at query time via fuzzy
substitution. Distinct: the edges here are already wrong in the database, as
the rawedgesquery above shows.
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
inth3shadows commentedon Aug 6, 2026
Reproduced your fixture exactly on
1.5.0(linux-x64), same two edges: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.
preferCallSiteFileis 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:
So by the time
name-matcher.tsruns, the ref forthis.mailer.send(msg)is the bare stringsend. The receiver-inference branch is keyed on areceiver.methodshape (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.preferCallSiteFileis 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:
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:For
this.mailer.send()the receiver node isthis.mailer— amember_expression, not anidentifier— so the first guard fails. Theelse ifthat handles non-identifier receivers is gated to cpp/c/kotlin/swift/rust/go/scala, which excludes TS/JS.calleeNametherefore stays the baremethodName.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 atname-matcher.ts:1650-1672that 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 —ThrottlerModuleis a plainidentifier, so it passes the guard above and the receiver survives: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 differentthis.<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 againstmain@d6d1728.fix(go): resolve a package-qualified accessor chain on the factory's …
colbymchenry commentedon Sep 8, 2026
Verified and fixed on Linux (x86_64, Node 22.19.0).
Root cause:
this.mailer.send()was extracted as the bare method namesend, 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 keepsthis.<field>; resolution uses the field declared type.Linux fail → pass (issue fixtures):
Outbox::send → Outbox::sendOutbox::send → Mailer::sendOutbox::send → Outbox::sendOutbox::send → Mailer::sendRelay::forward → Mailer::sendstayed correct; zero self-edges after the fix.#1566 coexistence: Map / nested
holder.values.getcases from #1790 remain green (10/10 resolution cases on both backends).Closing as completed; #1691 superseded by #1792.
colbymchenry commentedon Sep 8, 2026
Fixed in #1792 (lands #1691). Linux verified fail→pass; #1566 coexistence green.
fix(resolution): resolve this.<field>.<method>() on the fields declar…
fix(resolution): resolve this.#field.method() on the field's type (co…
fix(resolution): require receiver evidence for built-in method calls (#…
perf(resolution): remove the indexing regressions since v1.6.0 (vscod…
perf(resolution): answer an absent call name without inferring the re…
perf(resolution): answer an absent call name without inferring the re…