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

Angular's `private readonly dataService = inject(DataService)` or
`inject<DialogParams>(DIALOG_DATA)`, 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:
`inject` only when the file imports it from `@angular/core` (Vue's
`inject(key)` returns whatever was provided under the key), its type
argument first; `createInstance` only when the project declares one that
returns an `InstanceType` of its constructor argument. A local set from a
member of what the injector returns isn't read as the class.

A type the file imports under another name, like bitwarden's
`OrganizationApiServiceAbstraction as OrganizationApiService` or vscode's
`Engine as FileSearchEngine`, is now found through that import rather
than as a class with the local name.

A/B on main 383c2cd9: vscode +594 links, 351 retargets to the created or
imported class, 677 confidence-ups, 0 removed; bitwarden +1835, 35
retargets, 3 Angular Router guesses removed; ghostfolio +277; 8 other
JS/TS repos byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-19 11:12:49 +00:00
committed by GitHub
parent 93f85d10ce
commit b4e101387f
4 changed files with 285 additions and 17 deletions
+95 -17
View File
@@ -13105,6 +13105,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
TS_FIELD_DECL_MEMO.delete(context);
TS_CLASS_LINES.delete(context);
TS_PASS_THROUGH_WRAPPERS.delete(context);
TS_CREATE_INSTANCE_DECLARED.delete(context);
TS_NAMED_TYPES.delete(context);
TARGET_LANGUAGE.delete(context);
}
@@ -13134,6 +13135,15 @@ function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[]
case 'arkts':
return [
new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_$][\\w.$]*)`), // = new Logger()
// = inject(Logger) / = this.instantiationService.createInstance(Logger, …):
// a dependency-injection call that hands back an instance of the class
// it is given — unless a member of it is taken (`createInstance(
// TestServiceAccessor).filesConfigurationService`). A key that isn't a
// class (Vue's `inject(KEY)`) names no type with the method, so it
// links nothing.
// A type argument is the type returned: `inject<Params>(DIALOG_DATA)`.
new RegExp(`\\b${r}\\b\\s*=\\s*(?:inject|(?:this\\.)?(?:[\\w$#]+\\.)*createInstance)\\s*<\\s*([A-Z][\\w.$]*)[^;=(){}]*>\\s*\\((?![^;]*\\)\\s*(?:\\?\\.|\\.|\\[))`),
new RegExp(`\\b${r}\\b\\s*=\\s*(?:inject|(?:this\\.)?(?:[\\w$#]+\\.)*createInstance)\\s*\\(\\s*([A-Z][\\w.$]*)(?![^;]*\\)\\s*(?:\\?\\.|\\.|\\[))`),
// No keyword requirement, so this matches BOTH a local annotation
// (`const lg: Logger`) and a typed parameter (`function use(lg: Logger)`
// / `(lg: Logger) =>`) — the parameter case the old `const|let|var`
@@ -15669,8 +15679,17 @@ type TsFieldDecl = { valueType: boolean; type: string; array?: boolean };
* `this._store.add(new Emitter())`.
*/
type TsFieldWrapper = { holder?: string; method: string };
/** A declaration found on one line; a wrapped one names its wrapper and where the wrapper's `(` is. */
type TsFieldOnLine = TsFieldDecl & { via?: TsFieldWrapper; open?: number };
/**
* A dependency-injection call that hands back an instance of the class it
* is given: Angular's `inject(X)`, vscode's `instantiationService
* .createInstance(X, …)`.
*/
type TsInjector = 'inject' | 'createInstance';
/**
* A declaration found on one line; a wrapped one names its wrapper, an
* injected one its injector, and either where its call's `(` is.
*/
type TsFieldOnLine = TsFieldDecl & { via?: TsFieldWrapper; injector?: TsInjector; open?: number };
/** What follows a field's type when the field holds an array of it: `[]`, after any type arguments. */
const TS_ARRAY_SUFFIX = /^\s*(?:<[^;=(){}<>]*(?:<[^;=(){}<>]*>[^;=(){}<>]*)*>)?\s*\[\s*\]/;
/** A class's comment-stripped lines, and which of them hold each `[\w$#]` token. */
@@ -15723,18 +15742,25 @@ const TS_FIELD_PATTERNS_AT: readonly TsFieldPattern[] = [
{ re: /(?<![\w$#])[\w$#]+\b\s*[?!]?\s*:\s*(?:readonly\s+)?typeof\s+([A-Za-z_$][\w.$]*)/y, valueType: true },
{ re: /(?<![\w$#])[\w$#]+\b\s*[?!]?\s*:\s*(?:readonly\s+)?([A-Za-z_$][\w.$]*)/y, valueType: false },
{ re: /(?<![\w$#])[\w$#]+\b\s*=\s*new\s+([A-Za-z_$][\w.$]*)/y, valueType: false },
{ re: /(?<![\w$#])[\w$#]+\b\s*=\s*this\.(?:([\w$#]+)\.)?([\w$#]+)\(\s*new\s+([A-Za-z_$][\w.$]*)/y, valueType: false, wrapped: true },
{ re: /(?<![\w$#])[\w$#]+\b\s*=\s*this\.(?:([\w$#]+)\.)?([\w$#]+)\(\s*(?:new\s+|((?:this\.)?(?:[\w$#]+\.)*createInstance)\s*\(\s*)([A-Za-z_$][\w.$]*)/y, valueType: false, wrapped: true },
{ re: /(?<![\w$#])[\w$#]+\b\s*=\s*(inject|(?:this\.)?(?:[\w$#]+\.)*createInstance)\s*(?:<\s*([A-Z][\w.$]*)[^;=(){}]*>)?\s*\(\s*([A-Z][\w.$]*)/y, valueType: false, injected: true },
];
/** A declaration `pattern` matched (`m`) on `line`: its type, and its wrapper when it has one. */
/** A declaration `pattern` matched (`m`) on `line`: its type, and its wrapper or injector when it has one. */
function tsFieldMatch(pattern: TsFieldPattern, m: RegExpMatchArray, line: string): TsFieldOnLine | null {
const type = pattern.wrapped ? m[3] : m[1];
// An injector's type argument is the type it returns: `inject<Params>(DIALOG_DATA)`.
const type = pattern.wrapped ? m[4] : pattern.injected ? m[2] ?? m[3] : m[1];
if (!type) return null;
const decl: TsFieldOnLine = { valueType: pattern.valueType, type, array: TS_ARRAY_SUFFIX.test(line.slice(m.index! + m[0].length)) };
if (pattern.wrapped) {
decl.via = { holder: m[1], method: m[2]! };
decl.open = m.index! + m[0].indexOf('(');
if (m[3]) decl.injector = 'createInstance';
} else if (pattern.injected) {
decl.injector = m[1] === 'inject' ? 'inject' : 'createInstance';
}
// The first `(` after the field is the wrapper's, or the injector's: a
// type argument list (`inject<Foo>(`) holds none.
if (pattern.wrapped || pattern.injected) decl.open = m.index! + m[0].indexOf('(');
return decl;
}
@@ -15774,8 +15800,12 @@ function tsClassLinesWithToken(decl: TsClassDecl, token: string): readonly numbe
return decl.linesByToken.get(token) ?? [];
}
/** A field declaration pattern; a wrapped one captures its wrapper's holder and method before the type. */
type TsFieldPattern = { re: RegExp; valueType: boolean; wrapped?: true };
/**
* A field declaration pattern. A wrapped one captures its wrapper's holder
* and method, and an injector inside it, before the type; an injected one
* its injector before the type.
*/
type TsFieldPattern = { re: RegExp; valueType: boolean; wrapped?: true; injected?: true };
/** Compiled declaration patterns by field name — fields recur across classes. */
const TS_FIELD_PATTERNS = new Map<string, TsFieldPattern[]>();
const TS_FIELD_PATTERNS_CAP = 4096;
@@ -15809,10 +15839,17 @@ function tsFieldPatterns(field: string): TsFieldPattern[] {
// `_onDidChange = this._register(new Emitter())`, `x = this._store.add(new X())`:
// an X only when the wrapper returns its argument (tsFieldRead).
{
re: new RegExp(`${fieldStart}${fieldEsc}\\b\\s*=\\s*this\\.(?:([\\w$#]+)\\.)?([\\w$#]+)\\(\\s*new\\s+([A-Za-z_$][\\w.$]*)`),
re: new RegExp(`${fieldStart}${fieldEsc}\\b\\s*=\\s*this\\.(?:([\\w$#]+)\\.)?([\\w$#]+)\\(\\s*(?:new\\s+|((?:this\\.)?(?:[\\w$#]+\\.)*createInstance)\\s*\\(\\s*)([A-Za-z_$][\\w.$]*)`),
valueType: false,
wrapped: true,
},
// `http = inject(HttpClient)`, `filter = this._instaService.createInstance(DocumentFilter)`:
// an X when the injector is one that hands back an instance (tsFieldRead).
{
re: new RegExp(`${fieldStart}${fieldEsc}\\b\\s*=\\s*(inject|(?:this\\.)?(?:[\\w$#]+\\.)*createInstance)\\s*(?:<\\s*([A-Z][\\w.$]*)[^;=(){}]*>)?\\s*\\(\\s*([A-Z][\\w.$]*)`),
valueType: false,
injected: true,
},
];
if (TS_FIELD_PATTERNS.size >= TS_FIELD_PATTERNS_CAP) TS_FIELD_PATTERNS.delete(TS_FIELD_PATTERNS.keys().next().value!);
TS_FIELD_PATTERNS.set(field, patterns);
@@ -15879,11 +15916,40 @@ function tsFieldDeclaration(
* before.
*/
function tsFieldRead(onLine: TsFieldOnLine | null, cls: Node, lines: readonly string[], row: number, context: ResolutionContext): TsFieldDecl | null {
if (!onLine?.via) return onLine;
if (!tsCallEndsStatement(lines, row, onLine.open!) || !tsWrapperReturnsArgument(cls, onLine.via, context)) return null;
if (!onLine?.via && !onLine?.injector) return onLine;
if (!tsCallEndsStatement(lines, row, onLine.open!)) return null;
if (onLine.via && !tsWrapperReturnsArgument(cls, onLine.via, context)) return null;
if (onLine.injector && !tsInjectsInstance(cls, onLine.injector, context)) return null;
return { valueType: onLine.valueType, type: onLine.type, array: onLine.array };
}
/** Per-context memo: does the project declare a `createInstance` that returns an instance of its constructor argument? */
const TS_CREATE_INSTANCE_DECLARED = new WeakMap<ResolutionContext, boolean>();
/**
* Does `injector`, as class `cls`'s file calls it, hand back an instance of
* the class it is given? Angular's `inject(X)` when the file imports
* `inject` from `@angular/core` (Vue's `inject(key)` returns whatever was
* provided under the key); `createInstance(X, …)` when the project declares
* one that returns an `InstanceType` of its constructor argument, as
* vscode's `IInstantiationService` does.
*/
function tsInjectsInstance(cls: Node, injector: TsInjector, context: ResolutionContext): boolean {
if (injector === 'inject') {
return context.getImportMappings?.(cls.filePath, cls.language)?.some((i) => i.localName === 'inject' && i.source === '@angular/core') === true;
}
let declared = TS_CREATE_INSTANCE_DECLARED.get(context);
if (declared === undefined) {
declared = context.getNodesByName('createInstance').some((n) => {
if ((n.kind !== 'method' && n.kind !== 'function') || !ESM_FAMILY.has(n.language)) return false;
const lines = context.getFileLines?.(n.filePath) ?? context.readFile(n.filePath)?.split(/\r?\n/) ?? [];
return /\bInstanceType\s*</.test(lines.slice(n.startLine - 1, n.startLine + 2).join(' '));
});
TS_CREATE_INSTANCE_DECLARED.set(context, declared);
}
return declared;
}
/**
* Does the call whose `(` is at `open` on `lines[row]` end its statement, so
* that what it returns is what the statement assigns? Nothing but `;` (and
@@ -16023,18 +16089,24 @@ const TS_NAMED_TYPES = new WeakMap<ResolutionContext, Map<string, Node | 'outsid
* file declares. 'outside' when the file imports it from a package outside
* the project that no project file declares; null when it can't be found.
*/
function tsNamedType(written: string, from: Node, at: UnresolvedRef, context: ResolutionContext): Node | 'outside' | null {
function tsNamedType(
written: string,
from: Pick<Node, 'filePath' | 'language' | 'id'>,
at: UnresolvedRef,
context: ResolutionContext,
): Node | 'outside' | null {
let memo = TS_NAMED_TYPES.get(context);
if (!memo) {
memo = new Map();
TS_NAMED_TYPES.set(context, memo);
}
const key = from.filePath + '\0' + written;
const mapping = context.getImportMappings?.(from.filePath, from.language)?.find((i) => i.localName === written.split('.')[0]);
// A name the file declares is looked up past `from` itself.
const key = from.filePath + '\0' + written + (mapping ? '' : '\0' + from.id);
const hit = memo.get(key);
if (hit !== undefined) return hit;
const last = written.split('.').pop()!;
const ref: UnresolvedRef = { ...at, filePath: from.filePath, language: from.language };
const mapping = context.getImportMappings?.(from.filePath, from.language)?.find((i) => i.localName === written.split('.')[0]);
let found: Node | 'outside' | null = null;
if (mapping && context.isOutOfRepoImport?.(mapping.source, from.filePath, from.language) === true) {
const ambient = tsOutsideTypeDeclaration(written, last, ref, context);
@@ -16096,8 +16168,10 @@ function tsMethodUpChain(cls: Node, name: string, at: UnresolvedRef, context: Re
* (tsMethodUpChain); null when the whole chain is known and has none, as
* when the method is a package's: `Emitter extends lsp.Emitter` has no
* project `fire`, whatever other `Emitter` declares one. Undefined when
* `file` doesn't declare the type or a supertype can't be found, for the
* caller's own fallback.
* neither `file` nor what `at`'s file imports under the name declares the
* type, or a supertype can't be found, for the caller's own fallback. (An
* import may rename it: bitwarden's `MessagingService` is `export {
* MessageSender as MessagingService }`.)
*/
function tsKnownTypeCall(
typeName: string,
@@ -16108,7 +16182,11 @@ function tsKnownTypeCall(
context: ResolutionContext,
confidence: number,
): ResolvedRef | null | undefined {
const known = tsTypeDeclaredIn(file, typeName, context);
let known = tsTypeDeclaredIn(file, typeName, context);
if (!known) {
const named = tsNamedType(typeName, { filePath: at.filePath, language: at.language, id: '' }, at, context);
if (named && named !== 'outside') known = named;
}
if (!known) return undefined;
const up = nmTimedT('mc-tsknown', ref, () => tsMethodUpChain(known, methodName, at, context));
if (up.method) return { original: ref, targetNodeId: up.method.id, confidence, resolvedBy: 'instance-method' };