Skip to content

fix(ts): a receiver a dependency-injection call sets up has the class it is given - #2661

Merged
colbymchenry merged 4 commits into
mainfrom
claude/ts-injected-fields
Oct 19, 2026
Merged

colbymchenry merged 4 commits into
mainfrom
claude/ts-injected-fields

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

What was wrong

A TypeScript field or local set from a dependency-injection call had no type, so calls through it linked nowhere, or to whichever class had a method of that name:

// Angular (bitwarden, ghostfolio)
private readonly dataService = inject(DataService);
protected params = inject<DialogParams>(DIALOG_DATA);
// vscode
private readonly widget = this._register(this.instantiationService.createInstance(Widget, …));
const service = instantiationService.createInstance(CustomizationMarketplaceWorkbenchService, …);

#2647 read this._register(new X()) fields and #2656 made a known class's call walk its supertypes, but neither covered an injector. A census on main counted 410 createInstance fields and 764 locals in vscode, 2,351 inject(X) fields in bitwarden and 587 in ghostfolio.

A related gap: a type the file imports under another name was looked up by its local name. bitwarden writes import { OrganizationApiServiceAbstraction as OrganizationApiService } and export { MessageSender as MessagingService }, and vscode writes import { Engine as FileSearchEngine }. Each call through such a value reached a class that happened to have the local name.

What changed (src/resolution/name-matcher.ts)

  • Field reader: two new field patterns.
    • x = inject(X) and x = inject<T>(TOKEN). The type argument wins when there is one.
    • x = this.….createInstance(X, …), alone or inside a pass-through wrapper (this._register(…), this._store.add(…)).
  • Validation: an injector counts only when it really hands back an instance of what it is given.
    • inject must be imported from @angular/core. Vue's inject(key) returns whatever was provided under the key.
    • createInstance must be declared somewhere in the project with InstanceType< in its signature, as vscode's IInstantiationService declares it.
    • The existing wrapper check still applies.
  • Locals: the TS local patterns read const x = inject(X), inject<T>(…) and ….createInstance(X, …).
    • A local set from a member of what the injector returns (createInstance(Accessor).widget) isn't read as the class.
    • As before, a local's call counts only when the class has the method.
  • Renaming imports: tsKnownTypeCall now finds a type the file imports under another name through that import (tsNamedType), when the name isn't declared under itself in the imported file.

Tests

  • New __tests__/ts-injected-receivers.test.ts (5 tests):
    • Angular inject(X) fields and locals, including inject(X, { optional: true });
    • inject<T>(TOKEN);
    • Vue's inject is not read;
    • vscode createInstance fields, wrapped fields and locals, and a member of the created instance;
    • a createInstance the project doesn't declare with InstanceType is not read.
  • One more test in __tests__/ts-known-class-methods.test.ts covers the renaming import.
  • All of these are red on main.
  • Full suite on this branch, rebased onto main 93f85d1, with the native kernel: 7,431 passed, 0 failed, 80 skipped.

A/B: fresh index, main 383c2cd vs this branch (since then, main has only gained #2660, which changes Go imports)

  • vscode: nodes and files are identical.
    • 594 call sites gain a link.
    • 351 sites retarget to the class the injector is given or the renaming import names. 207 of these were 0.65 name guesses. 26 had reached another app's class; 5 still do, all in Copilot's simulation fixtures, which are copies of vscode files that import vscode's classes. The largest groups:
      • FileSearchEngine::search → Engine::search (32);
      • IJSONEditingService::write → JSONEditingService::write (20);
      • AskAgentProvider → PlanAgentProvider::provideCustomAgents (19);
      • IClaudeAgentSdkService::query → the created marketplace services' query (28).
    • 677 sites keep their target with a higher confidence: a name guess becomes the known class.
    • None are removed.
  • bitwarden:
    • 1,835 call sites gain a link, mostly calls on inject(X) fields.
    • 35 retarget: a browser-app I18nService::t becomes the imported TranslationService::t, ElectronMainMessagingService::send becomes MessageSender::send through the renaming re-export, concrete services become the abstractions their files import under the concrete name, and inject(BrowserRouterService) reaches the browser router.
    • 69 keep their target with a higher confidence.
    • 3 are removed: router.navigate on Angular's inject(Router), which had been guessed as a project class's navigate.
  • ghostfolio: 277 call sites gain a link, all on inject(X) fields.
  • 8 other JS/TS repos (folo, vben, milo-ui, infrahub, medusa, react-native, refine-examples, excalidraw): byte-identical.

🤖 Generated with Claude Code

colbymchenry and others added 4 commits October 19, 2026 06:02
Angular's `private readonly dataService = inject(DataService)` and
vscode's `this._register(this.instantiationService.createInstance(Widget,
…))` or `const widget = this.instantiationService.createInstance(Widget)`
gave the field or local no type, so calls through them linked nowhere or
by name alone. The field reader and the TS local patterns now read them:
for a field, `inject` only when the file imports it from `@angular/core`
(Vue's `inject(key)` returns whatever was provided under the key), and
`createInstance` only when the project declares one that returns an
`InstanceType` of its constructor argument; a local's call is still
validated against the class's methods.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… instance

vscode's service = instantiationService.createInstance(TestServiceAccessor).filesConfigurationService was read as a TestServiceAccessor, hiding the local's own annotation above it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt leads to the class

bitwarden's inject<AdvancedUriOptionDialogParams>(DIALOG_DATA) has the type argument's type, and its MessagingService (export { MessageSender as MessagingService }) is the class the import renames.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry
colbymchenry merged commit b4e1013 into main Oct 19, 2026
@colbymchenry
colbymchenry deleted the claude/ts-injected-fields branch October 19, 2026 11:12
colbymchenry added a commit that referenced this pull request Oct 22, 2026
…parent (#2818)

A TS/JS call written super.run() is recorded under its bare name, as
issue #2497 found. Inside an override of run every strategy found the
calling method itself, and the self-edge gate declined it, so the call
was unlinked; elsewhere it went to whatever method had the name, often
the class's own or a sibling class's in the same file, and, for a
parent outside the project like HTMLElement, Set or Promise, to a
project namesake.

A call written on super where it starts is now resolved through the
class's resolved extends edges, which the resolver persists before it
resolves calls: the nearest ancestor's method of that name, a static
one for a call in a static member, or nothing when no ancestor in the
project has it. The call may sit in a method, a class field, or an
arrow function inside either. One the index can't place in a class (a
class expression's method, an object literal's) keeps the old path, as
does one after a class keyword in the member, outside comments and
strings. The self-edge gate now reads a TS/JS call at its own column,
so a this.run() on the line of a super.run() keeps its edge.

The issue's first part, a method call on the receiver of an aliased
constructor, already resolves on main since PR #2661 and is unchanged.

Validation: the new test fails 3 of 4 on main 68fcf2f and passes 4 of
4 on the fix; the fourth pins the class-expression guard, and a
mutation of each guard fails a test. A/B against main on 22 repos (20
TS/JS, flask and gin): 660 call sites change, all super calls, checked
against each class's extends chain and sampled by hand. 643 gain an
edge to an ancestor's method (304 in three.js, like super.copy(source)
reaching Object3D.copy; 210 in webpack, 85 in lit); 2 move off the
class's own method to the parent's; 4 keep their target, now by the
parent; 11 lose a guess, where the parent is a builtin or a library's
class, like webpack's SortableSet extending Set, or a mixin's
parameter. Nodes and all other edges are
unchanged; 14 repos' dumps, the controls among them, are byte-identical.
A sync that moves a parent, swaps a parent or adds an override to an
intermediate class matches a fresh index. Index time, median of 3
interleaved runs on three.js, webpack, jest and insomnia: +1.7% total,
noise; 7 interleaved rounds on three.js alone: -1.5%.

Fixes #2497

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant