From 13da84cdfe69e5ea1340b39a35122cc18defd2f1 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 18 Oct 2026 21:14:42 -0500 Subject: [PATCH 1/9] fix(ts): a this. call finds a field a base class declares A field the calling class doesn't declare was read off any same-named class elsewhere, and never off the classes it extends. The field is now looked up on the calling class and then on its base classes, each the class its subclass's file imports (aliases and re-exports followed) or declares, with the field's type read through the declaring file's imports. A same-named class elsewhere is read last, and only when the call's file imports or declares the type it gives the field. Co-Authored-By: Claude Opus 5.5 --- __tests__/ts-imported-type-receivers.test.ts | 49 +++++ src/resolution/name-matcher.ts | 195 ++++++++++++++----- 2 files changed, 194 insertions(+), 50 deletions(-) diff --git a/__tests__/ts-imported-type-receivers.test.ts b/__tests__/ts-imported-type-receivers.test.ts index eabe582d2..1e7d950d6 100644 --- a/__tests__/ts-imported-type-receivers.test.ts +++ b/__tests__/ts-imported-type-receivers.test.ts @@ -178,3 +178,52 @@ describe('a call on a TypeScript value whose type the file imports', () => { expect(calls.filter((c) => c.includes('s.add'))).toEqual(['src/own.ts: s.add -> src/own.ts::Store::add']); }, 60_000); }); + +describe('a this. call on a field a class it extends declares', () => { + it('reads the field off the base its file imports, and its type off the import there', async () => { + const calls = await callsOf({ + ...STORES, + 'src/base.ts': "import { Store } from './two/store';\nexport class Base {\n protected readonly store = new Store();\n}\n", + 'src/child.ts': + "import { Base } from './base';\n" + + 'export class Child extends Base {\n run() {\n return this.store.add(1);\n }\n}\n', + }); + expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/child.ts: this.store.add -> src/two/store.ts::Store::add']); + }, 60_000); + + it("reads the field's type off a class the base's own file declares", async () => { + const calls = await callsOf({ + ...STORES, + 'src/base.ts': + 'export class Store {\n add(v: number) { return v * 10; }\n}\n' + + 'export class Base {\n protected readonly store = new Store();\n}\n', + 'src/child.ts': + "import { Base } from './base';\n" + + 'export class Child extends Base {\n run() {\n return this.store.add(1);\n }\n}\n', + }); + expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/child.ts: this.store.add -> src/base.ts::Store::add']); + }, 60_000); + + it('follows an aliased import to a base of the same name', async () => { + // vscode's sessions `EditorPart extends` the workbench's `EditorPart`. + const calls = await callsOf({ + ...STORES, + 'src/workbench/part.ts': "import { Store } from '../two/store';\nexport class Part {\n protected readonly store: Store = new Store();\n}\n", + 'src/sessions/part.ts': + "import { Part as BasePart } from '../workbench/part';\n" + + 'export class Part extends BasePart {\n run() {\n return this.store.add(2);\n }\n}\n', + }); + expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/sessions/part.ts: this.store.add -> src/two/store.ts::Store::add']); + }, 60_000); + + it("doesn't read the field off an unrelated class of the same name whose type the call's file doesn't name", async () => { + const calls = await callsOf({ + ...STORES, + 'src/app-a/service.ts': "import { Store } from '../one/store';\nexport class Service {\n private readonly store = new Store();\n}\n", + 'src/app-b/service.ts': + 'const makeStore = () => ({ add: (v: number) => v });\n' + + 'export class Service {\n private readonly store = makeStore();\n run() {\n return this.store.add(3);\n }\n}\n', + }); + expect(calls.filter((c) => c.includes('this.store.add'))).toEqual([]); + }, 60_000); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 5bb2175e8..baf9bd209 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -12967,6 +12967,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { AWAITED_TYPE_MEMO.delete(context); AWAITED_FILES.delete(context); TS_IMPORTED_TYPE_FILES.delete(context); + TS_NAMED_CLASSES.delete(context); C_STATIC_MEMO.delete(context); CPP_TYPE_DECLS.delete(context); CPP_INCLUDE_LINES.delete(context); @@ -15757,62 +15758,156 @@ function matchTsThisFieldCall( const owners = preferCallSiteFile(context.getNodesByName(owner), ref.filePath).filter( (n) => (n.kind === 'class' || n.kind === 'component') && sameLanguageFamily(n.language, ref.language) ); + // The calling class declares the field, or a class it extends does: each + // base is the one its subclass's file names (tsFieldThroughBases), so + // vscode's browser `FileDialogService` finds `AbstractFileDialogService`'s + // `openerService`. for (const cls of owners) { + if (cls.filePath !== ref.filePath) continue; + const found = tsFieldThroughBases(cls, field, ref, context, 0); + if (found) return tsFieldCall(found.decl, found.cls, methodName, ref, context); + } + // Else a same-named class elsewhere may declare it, read for a calling + // class whose own declaration isn't (`this._register(new Emitter())`), + // but only with a type the call's file imports or declares: another app's + // class of that name says nothing about this one's field. + for (const cls of owners) { + if (cls.filePath === ref.filePath) continue; const decl = tsFieldDeclaration(cls, field, context); - if (!decl) continue; - if (decl.valueType) { - // The value's declaration may live in another file (it is imported); - // the call site's file is preferred when several share the name. - const holderName = decl.type.split('.').pop()!; - const holders = preferCallSiteFile(context.getNodesByName(holderName), ref.filePath).filter( - (n) => (n.kind === 'constant' || n.kind === 'variable') && sameLanguageFamily(n.language, ref.language) - ); - for (const holder of holders) { - const hit = resolveObjectLiteralMember(holder, methodName, ref, context, 0.85, 'instance-method'); - if (hit) return hit; - } - return null; - } - // `ns.Mailer` → `Mailer`; a primitive or builtin names no project type. - const typeName = decl.type.split('.').pop()!; - if (!/^[A-Z]/.test(typeName)) return null; - // Two apps in one repo may each declare a `UserService`. The call's file - // imports the one its field holds, so that one's method is the call's. - // Only the call's file: a field read off a same-named class elsewhere - // follows that file's imports, not this one's (vscode's two - // `MethodContextProvider`s import two `Symbols`). Without an import, the - // bare-name path this replaces broke the tie by directory proximity, so - // keep the same signal: among the type's declarations of the method, - // prefer the one closest to the call site's directory (its own app), - // never index order. resolveMethodOnType still answers the - // single-declaration and supertype cases. - const imported = tsImportedTypeFile(decl.type, ref, context); - const declared = context - .getNodesByName(methodName) - .filter( - (n) => - n.kind === 'method' && - sameLanguageFamily(n.language, ref.language) && - (n.qualifiedName === `${typeName}::${methodName}` || n.qualifiedName.endsWith(`::${typeName}::${methodName}`)) - ); - if (declared.length > 1) { - const own = imported !== undefined ? declared.find((n) => n.filePath === imported) : undefined; - if (own) return { original: ref, targetNodeId: own.id, confidence: 0.85, resolvedBy: 'instance-method' }; - const callDirs = ref.filePath.split('/').slice(0, -1); - const shared = (fp: string) => { - const dirs = fp.split('/').slice(0, -1); - let i = 0; - while (i < dirs.length && i < callDirs.length && dirs[i] === callDirs[i]) i++; - return i; - }; - const nearest = [...declared].sort((a, b) => shared(b.filePath) - shared(a.filePath) || a.filePath.localeCompare(b.filePath))[0]!; - return { original: ref, targetNodeId: nearest.id, confidence: 0.85, resolvedBy: 'instance-method' }; - } - return resolveMethodOnType(typeName, methodName, ref, context, 0.85, 'instance-method', imported); + if (!decl || !tsTypeVisibleIn(decl.type, ref, context)) continue; + return tsFieldCall(decl, null, methodName, ref, context); + } + return null; +} + +/** A TS field's declared type, as tsFieldDeclaration reads it. */ +type TsFieldRead = NonNullable>; + +/** + * `field`'s declaration on `cls` or on the classes it extends, nearest + * first, with the class it is written in: each base is the class its + * subclass's file names (tsNamedClass), and the field's type is the class + * that class's file imports or declares. + */ +function tsFieldThroughBases( + cls: Node, + field: string, + ref: UnresolvedRef, + context: ResolutionContext, + depth: number, +): { decl: TsFieldRead; cls: Node } | null { + const decl = tsFieldDeclaration(cls, field, context); + if (decl) return { decl, cls }; + if (depth >= 4) return null; + for (const name of declaredSupertypes(cls, context)) { + const base = tsNamedClass(name, cls, ref, context); + if (!base || base.id === cls.id) continue; + const found = tsFieldThroughBases(base, field, ref, context, depth + 1); + if (found) return found; } return null; } +/** Per-context memo: `\0` → tsNamedClass's answer. */ +const TS_NAMED_CLASSES = new WeakMap>(); + +/** + * The class `name` names in `from`'s file: the one that file imports under + * it, followed through re-exports and aliases (`import { EditorPart as + * BaseEditorPart }`), or else a class the file declares. + */ +function tsNamedClass(name: string, from: Node, ref: UnresolvedRef, context: ResolutionContext): Node | null { + let memo = TS_NAMED_CLASSES.get(context); + if (!memo) { + memo = new Map(); + TS_NAMED_CLASSES.set(context, memo); + } + const key = from.filePath + '\0' + name; + const hit = memo.get(key); + if (hit !== undefined) return hit; + let found: Node | null = null; + if (context.getImportMappings?.(from.filePath, from.language)?.some((i) => i.localName === name)) { + const imported = context.resolveImport?.({ ...ref, filePath: from.filePath, language: from.language, referenceName: name, referenceKind: 'references' }); + const node = imported ? context.getNodeById?.(imported.targetNodeId) : undefined; + if (node && (node.kind === 'class' || node.kind === 'component')) found = node; + } else { + found = context.getNodesInFile(from.filePath).find((n) => n.kind === 'class' && n.name === name) ?? null; + } + memo.set(key, found); + return found; +} + +/** Does the call's file import a type named `type` from the project, or declare one? */ +function tsTypeVisibleIn(type: string, ref: UnresolvedRef, context: ResolutionContext): boolean { + if (tsImportedTypeFile(type, ref, context) !== undefined) return true; + const head = type.split('.')[0]!; + return context.getNodesInFile(ref.filePath).some((n) => + n.name === head && (n.kind === 'class' || n.kind === 'interface' || n.kind === 'type_alias' || n.kind === 'constant' || n.kind === 'variable')); +} + +/** + * Resolve `this..()` through a declaration of the field on + * `declaring`, the calling class or a base, or on a same-named class + * elsewhere (null), whose type the call's file names. + */ +function tsFieldCall( + decl: TsFieldRead, + declaring: Node | null, + methodName: string, + ref: UnresolvedRef, + context: ResolutionContext, +): ResolvedRef | null { + if (decl.valueType) { + // The value's declaration may live in another file (it is imported); + // the call site's file is preferred when several share the name. + const holderName = decl.type.split('.').pop()!; + const holders = preferCallSiteFile(context.getNodesByName(holderName), ref.filePath).filter( + (n) => (n.kind === 'constant' || n.kind === 'variable') && sameLanguageFamily(n.language, ref.language) + ); + for (const holder of holders) { + const hit = resolveObjectLiteralMember(holder, methodName, ref, context, 0.85, 'instance-method'); + if (hit) return hit; + } + return null; + } + // `ns.Mailer` → `Mailer`; a primitive or builtin names no project type. + const typeName = decl.type.split('.').pop()!; + if (!/^[A-Z]/.test(typeName)) return null; + // Two apps in one repo may each declare a `UserService`. The file the + // field is written in imports the one it holds, or declares it, so that + // one's method is the call's. Without an import, the bare-name path this + // replaces broke the tie by directory proximity, so keep the same signal: + // among the type's declarations of the method, prefer the one closest to + // the call site's directory (its own app), never index order. + // resolveMethodOnType still answers the single-declaration and supertype + // cases. + const imported = !declaring || declaring.filePath === ref.filePath + ? tsImportedTypeFile(decl.type, ref, context) + : tsImportedTypeFile(decl.type, { ...ref, filePath: declaring.filePath, language: declaring.language }, context) ?? declaring.filePath; + const declared = context + .getNodesByName(methodName) + .filter( + (n) => + n.kind === 'method' && + sameLanguageFamily(n.language, ref.language) && + (n.qualifiedName === `${typeName}::${methodName}` || n.qualifiedName.endsWith(`::${typeName}::${methodName}`)) + ); + if (declared.length > 1) { + const own = imported !== undefined ? declared.find((n) => n.filePath === imported) : undefined; + if (own) return { original: ref, targetNodeId: own.id, confidence: 0.85, resolvedBy: 'instance-method' }; + const callDirs = ref.filePath.split('/').slice(0, -1); + const shared = (fp: string) => { + const dirs = fp.split('/').slice(0, -1); + let i = 0; + while (i < dirs.length && i < callDirs.length && dirs[i] === callDirs[i]) i++; + return i; + }; + const nearest = [...declared].sort((a, b) => shared(b.filePath) - shared(a.filePath) || a.filePath.localeCompare(b.filePath))[0]!; + return { original: ref, targetNodeId: nearest.id, confidence: 0.85, resolvedBy: 'instance-method' }; + } + return resolveMethodOnType(typeName, methodName, ref, context, 0.85, 'instance-method', imported); +} + /** * The one fallback a TS/JS/Python call-receiver chain keeps (#1683): a STORE * ACCESSOR. Zustand's `get()` inside the store factory and From 0368f847d61e3bc19c0877c80f60f49a5b11c116 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 18 Oct 2026 21:18:28 -0500 Subject: [PATCH 2/9] test(ts): the base-file type case can't pass by folder order Co-Authored-By: Claude Opus 5.5 --- __tests__/ts-imported-type-receivers.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/__tests__/ts-imported-type-receivers.test.ts b/__tests__/ts-imported-type-receivers.test.ts index 1e7d950d6..3aec9ec80 100644 --- a/__tests__/ts-imported-type-receivers.test.ts +++ b/__tests__/ts-imported-type-receivers.test.ts @@ -194,14 +194,16 @@ describe('a this. call on a field a class it extends declares', () => { it("reads the field's type off a class the base's own file declares", async () => { const calls = await callsOf({ ...STORES, - 'src/base.ts': + // A folder that sorts after `one` and `two`, so folder nearness, which + // ties, would pick another `Store`. + 'src/zeta/base.ts': 'export class Store {\n add(v: number) { return v * 10; }\n}\n' + 'export class Base {\n protected readonly store = new Store();\n}\n', 'src/child.ts': - "import { Base } from './base';\n" + + "import { Base } from './zeta/base';\n" + 'export class Child extends Base {\n run() {\n return this.store.add(1);\n }\n}\n', }); - expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/child.ts: this.store.add -> src/base.ts::Store::add']); + expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/child.ts: this.store.add -> src/zeta/base.ts::Store::add']); }, 60_000); it('follows an aliased import to a base of the same name', async () => { From 5d4dcddbff98798be203fe49f059c2586f417b20 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 18 Oct 2026 21:40:44 -0500 Subject: [PATCH 3/9] fix(ts): a type from a package outside the project has no project method A TypeScript or JavaScript call on a field or local whose type its file imports from an outside package linked to whichever project class shared the type's name: microsoft-authentication's `TelemetryReporter` from `@vscode/extension-telemetry` reached another extension's interface. Such a call now links to nothing in the project. Co-Authored-By: Claude Opus 5.5 --- __tests__/ts-imported-type-receivers.test.ts | 24 +++++++++++++++++ src/resolution/name-matcher.ts | 28 ++++++++++++++------ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/__tests__/ts-imported-type-receivers.test.ts b/__tests__/ts-imported-type-receivers.test.ts index 3aec9ec80..5d858c54f 100644 --- a/__tests__/ts-imported-type-receivers.test.ts +++ b/__tests__/ts-imported-type-receivers.test.ts @@ -229,3 +229,27 @@ describe('a this. call on a field a class it extends declares', () => { expect(calls.filter((c) => c.includes('this.store.add'))).toEqual([]); }, 60_000); }); + +describe('a call on a value whose type a package outside the project gives', () => { + it("links to no project class that shares the type's name", async () => { + // microsoft-authentication's `_telemetryReporter: TelemetryReporter`, + // imported from `@vscode/extension-telemetry`, reached another + // extension's `TelemetryReporter` interface. + const calls = await callsOf({ + 'package.json': JSON.stringify({ name: 'app', dependencies: { 'ext-telemetry': '^1.0.0' } }), + 'src/other.ts': 'export interface TelemetryReporter {\n sendTelemetryEvent(name: string): void;\n}\n', + 'src/reporter.ts': + "import TelemetryReporter from 'ext-telemetry';\n" + + 'export class Reporter {\n' + + ' protected reporter: TelemetryReporter;\n' + + ' constructor() {\n this.reporter = new TelemetryReporter();\n }\n' + + " send() {\n this.reporter.sendTelemetryEvent('a');\n }\n" + + '}\n' + + "export class Sub extends Reporter {\n more() {\n this.reporter.sendTelemetryEvent('b');\n }\n}\n", + 'src/local.ts': + "import TelemetryReporter from 'ext-telemetry';\n" + + "export function f() {\n const r = new TelemetryReporter();\n return r.sendTelemetryEvent('c');\n}\n", + }); + expect(calls.filter((c) => c.includes('sendTelemetryEvent'))).toEqual([]); + }, 60_000); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index baf9bd209..d2a1c13d5 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -14274,6 +14274,10 @@ export function matchMethodCall( if (decl.asserted !== undefined) { return decl.asserted && resolveMethodOnType(decl.asserted.name, methodName!, ref, context, 0.9, 'instance-method', decl.asserted.dir); } + // A TS/JS type the file imports from a package outside the project has + // no project method, whatever project class shares its name. + if (ESM_FAMILY.has(ref.language) && !awaited && + isOutOfRepoBinding(/^[A-Za-z_$][\w$]*/.exec((decl.raw ?? inferredType).trim())?.[0] ?? '', ref, context)) return null; // Java/Kotlin: when two classes share the simple name, the file's import // pins WHICH one (#314); Go: the package that declares the type (#2323); // TS/JS: the file declaring the class the file imports under that name. @@ -15873,17 +15877,25 @@ function tsFieldCall( // `ns.Mailer` → `Mailer`; a primitive or builtin names no project type. const typeName = decl.type.split('.').pop()!; if (!/^[A-Z]/.test(typeName)) return null; - // Two apps in one repo may each declare a `UserService`. The file the - // field is written in imports the one it holds, or declares it, so that - // one's method is the call's. Without an import, the bare-name path this - // replaces broke the tie by directory proximity, so keep the same signal: - // among the type's declarations of the method, prefer the one closest to - // the call site's directory (its own app), never index order. + // The file the field is written in names its type. One it imports from a + // package outside the project has no project method: microsoft- + // authentication's `TelemetryReporter` from `@vscode/extension-telemetry` + // reached another extension's interface of that name. + const at = !declaring || declaring.filePath === ref.filePath + ? ref + : { ...ref, filePath: declaring.filePath, language: declaring.language }; + if (isOutOfRepoBinding(decl.type.split('.')[0]!, at, context)) return null; + // Two apps in one repo may each declare a `UserService`. That file + // imports the one the field holds, or declares it, so that one's method + // is the call's. Without an import, the bare-name path this replaces + // broke the tie by directory proximity, so keep the same signal: among + // the type's declarations of the method, prefer the one closest to the + // call site's directory (its own app), never index order. // resolveMethodOnType still answers the single-declaration and supertype // cases. - const imported = !declaring || declaring.filePath === ref.filePath + const imported = at === ref ? tsImportedTypeFile(decl.type, ref, context) - : tsImportedTypeFile(decl.type, { ...ref, filePath: declaring.filePath, language: declaring.language }, context) ?? declaring.filePath; + : tsImportedTypeFile(decl.type, at, context) ?? at.filePath; const declared = context .getNodesByName(methodName) .filter( From 394e8f6e2a7af8ddcf3a48c345e1db2b81d1408c Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 18 Oct 2026 22:08:31 -0500 Subject: [PATCH 4/9] test(ts): base-class field and outside-package tests in their own file Co-Authored-By: Claude Opus 5.5 --- __tests__/ts-imported-type-receivers.test.ts | 75 --------- __tests__/ts-this-field-bases.test.ts | 151 +++++++++++++++++++ 2 files changed, 151 insertions(+), 75 deletions(-) create mode 100644 __tests__/ts-this-field-bases.test.ts diff --git a/__tests__/ts-imported-type-receivers.test.ts b/__tests__/ts-imported-type-receivers.test.ts index 5d858c54f..eabe582d2 100644 --- a/__tests__/ts-imported-type-receivers.test.ts +++ b/__tests__/ts-imported-type-receivers.test.ts @@ -178,78 +178,3 @@ describe('a call on a TypeScript value whose type the file imports', () => { expect(calls.filter((c) => c.includes('s.add'))).toEqual(['src/own.ts: s.add -> src/own.ts::Store::add']); }, 60_000); }); - -describe('a this. call on a field a class it extends declares', () => { - it('reads the field off the base its file imports, and its type off the import there', async () => { - const calls = await callsOf({ - ...STORES, - 'src/base.ts': "import { Store } from './two/store';\nexport class Base {\n protected readonly store = new Store();\n}\n", - 'src/child.ts': - "import { Base } from './base';\n" + - 'export class Child extends Base {\n run() {\n return this.store.add(1);\n }\n}\n', - }); - expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/child.ts: this.store.add -> src/two/store.ts::Store::add']); - }, 60_000); - - it("reads the field's type off a class the base's own file declares", async () => { - const calls = await callsOf({ - ...STORES, - // A folder that sorts after `one` and `two`, so folder nearness, which - // ties, would pick another `Store`. - 'src/zeta/base.ts': - 'export class Store {\n add(v: number) { return v * 10; }\n}\n' + - 'export class Base {\n protected readonly store = new Store();\n}\n', - 'src/child.ts': - "import { Base } from './zeta/base';\n" + - 'export class Child extends Base {\n run() {\n return this.store.add(1);\n }\n}\n', - }); - expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/child.ts: this.store.add -> src/zeta/base.ts::Store::add']); - }, 60_000); - - it('follows an aliased import to a base of the same name', async () => { - // vscode's sessions `EditorPart extends` the workbench's `EditorPart`. - const calls = await callsOf({ - ...STORES, - 'src/workbench/part.ts': "import { Store } from '../two/store';\nexport class Part {\n protected readonly store: Store = new Store();\n}\n", - 'src/sessions/part.ts': - "import { Part as BasePart } from '../workbench/part';\n" + - 'export class Part extends BasePart {\n run() {\n return this.store.add(2);\n }\n}\n', - }); - expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/sessions/part.ts: this.store.add -> src/two/store.ts::Store::add']); - }, 60_000); - - it("doesn't read the field off an unrelated class of the same name whose type the call's file doesn't name", async () => { - const calls = await callsOf({ - ...STORES, - 'src/app-a/service.ts': "import { Store } from '../one/store';\nexport class Service {\n private readonly store = new Store();\n}\n", - 'src/app-b/service.ts': - 'const makeStore = () => ({ add: (v: number) => v });\n' + - 'export class Service {\n private readonly store = makeStore();\n run() {\n return this.store.add(3);\n }\n}\n', - }); - expect(calls.filter((c) => c.includes('this.store.add'))).toEqual([]); - }, 60_000); -}); - -describe('a call on a value whose type a package outside the project gives', () => { - it("links to no project class that shares the type's name", async () => { - // microsoft-authentication's `_telemetryReporter: TelemetryReporter`, - // imported from `@vscode/extension-telemetry`, reached another - // extension's `TelemetryReporter` interface. - const calls = await callsOf({ - 'package.json': JSON.stringify({ name: 'app', dependencies: { 'ext-telemetry': '^1.0.0' } }), - 'src/other.ts': 'export interface TelemetryReporter {\n sendTelemetryEvent(name: string): void;\n}\n', - 'src/reporter.ts': - "import TelemetryReporter from 'ext-telemetry';\n" + - 'export class Reporter {\n' + - ' protected reporter: TelemetryReporter;\n' + - ' constructor() {\n this.reporter = new TelemetryReporter();\n }\n' + - " send() {\n this.reporter.sendTelemetryEvent('a');\n }\n" + - '}\n' + - "export class Sub extends Reporter {\n more() {\n this.reporter.sendTelemetryEvent('b');\n }\n}\n", - 'src/local.ts': - "import TelemetryReporter from 'ext-telemetry';\n" + - "export function f() {\n const r = new TelemetryReporter();\n return r.sendTelemetryEvent('c');\n}\n", - }); - expect(calls.filter((c) => c.includes('sendTelemetryEvent'))).toEqual([]); - }, 60_000); -}); diff --git a/__tests__/ts-this-field-bases.test.ts b/__tests__/ts-this-field-bases.test.ts new file mode 100644 index 000000000..ed027ab3f --- /dev/null +++ b/__tests__/ts-this-field-bases.test.ts @@ -0,0 +1,151 @@ +/** + * A TypeScript/JavaScript `this.` call on a field a base class + * declares links through that base, the class its subclass's file names. + * + * A field the calling class doesn't declare was read off any same-named + * class elsewhere, and never off the classes it extends: on vscode, the + * browser `FileDialogService`'s `this.openerService` was typed by the + * electron `FileDialogService`, and a field another app's same-named class + * declares could type it however that app did. Now the calling class's + * bases come first, and a same-named class elsewhere only when the call's + * file imports or declares the type it gives the field. + * + * A type the declaring file imports from a package outside the project has + * no project method, which typed locals follow too: microsoft- + * authentication's `TelemetryReporter` from `@vscode/extension-telemetry` + * reached another extension's interface of that name. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; + +let roots: string[] = []; +let graphs: CodeGraph[] = []; + +afterEach(() => { + for (const graph of graphs) graph.close(); + graphs = []; + for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); + roots = []; +}); + +/** Index `files` and return each call edge as `: -> ::`. */ +async function callsOf(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ts-field-bases-')); + roots.push(root); + for (const [file, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); + } + const graph = await CodeGraph.init(root, { index: true }); + graphs.push(graph); + const nodes = graph.getFiles().flatMap((f) => graph.getNodesInFile(f.path)); + const byId = new Map(nodes.map((n) => [n.id, n])); + return graph.getOutgoingEdgesFrom(nodes.map((n) => n.id), ['calls']) + .map((e) => { + const target = byId.get(e.target); + return `${byId.get(e.source)?.filePath}: ${e.metadata?.refName} -> ${target?.filePath}::${target?.qualifiedName}`; + }) + .sort(); +} + +/** Two classes named `Store`, each with the same methods. */ +const STORES = { + 'package.json': JSON.stringify({ name: 'app' }), + 'src/one/store.ts': 'export class Store {\n add(v: number) { return v; }\n}\n', + 'src/two/store.ts': 'export class Store {\n add(v: number) { return -v; }\n}\n', +}; + +describe('a this. call on a field a class it extends declares', () => { + it('reads the field off the base its file imports, and its type off the import there', async () => { + const calls = await callsOf({ + ...STORES, + 'src/base.ts': "import { Store } from './two/store';\nexport class Base {\n protected readonly store = new Store();\n}\n", + 'src/child.ts': + "import { Base } from './base';\n" + + 'export class Child extends Base {\n run() {\n return this.store.add(1);\n }\n}\n', + }); + expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/child.ts: this.store.add -> src/two/store.ts::Store::add']); + }, 60_000); + + it("reads the field's type off a class the base's own file declares", async () => { + const calls = await callsOf({ + ...STORES, + // A folder that sorts after `one` and `two`, so folder nearness, which + // ties, would pick another `Store`. + 'src/zeta/base.ts': + 'export class Store {\n add(v: number) { return v * 10; }\n}\n' + + 'export class Base {\n protected readonly store = new Store();\n}\n', + 'src/child.ts': + "import { Base } from './zeta/base';\n" + + 'export class Child extends Base {\n run() {\n return this.store.add(1);\n }\n}\n', + }); + expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/child.ts: this.store.add -> src/zeta/base.ts::Store::add']); + }, 60_000); + + it('follows an aliased import to a base of the same name', async () => { + // vscode's sessions `EditorPart extends` the workbench's `EditorPart`. + const calls = await callsOf({ + ...STORES, + 'src/workbench/part.ts': "import { Store } from '../two/store';\nexport class Part {\n protected readonly store: Store = new Store();\n}\n", + 'src/sessions/part.ts': + "import { Part as BasePart } from '../workbench/part';\n" + + 'export class Part extends BasePart {\n run() {\n return this.store.add(2);\n }\n}\n', + }); + expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/sessions/part.ts: this.store.add -> src/two/store.ts::Store::add']); + }, 60_000); + + it("doesn't read the field off an unrelated class of the same name whose type the call's file doesn't name", async () => { + const calls = await callsOf({ + ...STORES, + 'src/app-a/service.ts': "import { Store } from '../one/store';\nexport class Service {\n private readonly store = new Store();\n}\n", + 'src/app-b/service.ts': + 'const makeStore = () => ({ add: (v: number) => v });\n' + + 'export class Service {\n private readonly store = makeStore();\n run() {\n return this.store.add(3);\n }\n}\n', + }); + expect(calls.filter((c) => c.includes('this.store.add'))).toEqual([]); + }, 60_000); + + it('still reads it off a same-named class elsewhere whose type the call\'s file imports', async () => { + // vscode's copilot `MockPromptsService` declares its emitter as + // `this._register(new Emitter())`, which isn't read; the + // workbench's `MockPromptsService` declares the same field. + const calls = await callsOf({ + ...STORES, + 'src/app/mock.ts': + "import { Store } from '../two/store';\n" + + 'const wrap = (value: T) => value;\n' + + 'export class Mock {\n' + + ' private readonly store = wrap(new Store());\n' + + ' run() {\n return this.store.add(1);\n }\n' + + '}\n', + 'src/other/mock.ts': + "import { Store } from '../one/store';\n" + + 'export class Mock {\n private readonly store = new Store();\n}\n', + }); + expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/app/mock.ts: this.store.add -> src/two/store.ts::Store::add']); + }, 60_000); +}); + +describe('a call on a value whose type a package outside the project gives', () => { + it("links to no project class that shares the type's name", async () => { + const calls = await callsOf({ + 'package.json': JSON.stringify({ name: 'app', dependencies: { 'ext-telemetry': '^1.0.0' } }), + 'src/other.ts': 'export interface TelemetryReporter {\n sendTelemetryEvent(name: string): void;\n}\n', + 'src/reporter.ts': + "import TelemetryReporter from 'ext-telemetry';\n" + + 'export class Reporter {\n' + + ' protected reporter: TelemetryReporter;\n' + + ' constructor() {\n this.reporter = new TelemetryReporter();\n }\n' + + " send() {\n this.reporter.sendTelemetryEvent('a');\n }\n" + + '}\n' + + "export class Sub extends Reporter {\n more() {\n this.reporter.sendTelemetryEvent('b');\n }\n}\n", + 'src/local.ts': + "import TelemetryReporter from 'ext-telemetry';\n" + + "export function f() {\n const r = new TelemetryReporter();\n return r.sendTelemetryEvent('c');\n}\n", + }); + expect(calls.filter((c) => c.includes('sendTelemetryEvent'))).toEqual([]); + }, 60_000); +}); From cac72a71217438ef5ff11c59cb5439407fa7a2a1 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 18 Oct 2026 22:15:44 -0500 Subject: [PATCH 5/9] fix(ts): a package a project file declares keeps its links A type imported from a package outside the project that a project file declares, as `declare module 'pkg' { ... }` in vscode's `src/typings/copilot-api.d.ts`, still links to that declaration, which is now also the class the call prefers when several share the name. Co-Authored-By: Claude Opus 5.5 --- __tests__/ts-this-field-bases.test.ts | 18 ++++++ src/resolution/name-matcher.ts | 86 ++++++++++++++++++++------- 2 files changed, 83 insertions(+), 21 deletions(-) diff --git a/__tests__/ts-this-field-bases.test.ts b/__tests__/ts-this-field-bases.test.ts index ed027ab3f..805c19d1d 100644 --- a/__tests__/ts-this-field-bases.test.ts +++ b/__tests__/ts-this-field-bases.test.ts @@ -148,4 +148,22 @@ describe('a call on a value whose type a package outside the project gives', () }); expect(calls.filter((c) => c.includes('sendTelemetryEvent'))).toEqual([]); }, 60_000); + + it('still links to the declaration a project file gives the package', async () => { + // vscode's `src/typings/copilot-api.d.ts` declares `@vscode/copilot-api`. + const calls = await callsOf({ + 'package.json': JSON.stringify({ name: 'app', dependencies: { 'ext-api': '^1.0.0' } }), + 'src/typings/ext-api.d.ts': "declare module 'ext-api' {\n export class ApiClient {\n update(): void;\n }\n}\n", + 'src/other.ts': 'export class ApiClient {\n update() { return 1; }\n}\n', + 'src/use.ts': + "import { ApiClient } from 'ext-api';\n" + + 'export class Service {\n private readonly client: ApiClient = new ApiClient();\n' + + ' run() {\n return this.client.update();\n }\n}\n' + + 'export function f() {\n const api = new ApiClient();\n return api.update();\n}\n', + }); + expect(calls.filter((c) => c.includes('.update'))).toEqual([ + 'src/use.ts: api.update -> src/typings/ext-api.d.ts::ApiClient::update', + 'src/use.ts: this.client.update -> src/typings/ext-api.d.ts::ApiClient::update', + ]); + }, 60_000); }); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index d2a1c13d5..cc44e2cf8 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -12968,6 +12968,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { AWAITED_FILES.delete(context); TS_IMPORTED_TYPE_FILES.delete(context); TS_NAMED_CLASSES.delete(context); + TS_AMBIENT_MODULE_FILES.delete(context); C_STATIC_MEMO.delete(context); CPP_TYPE_DECLS.delete(context); CPP_INCLUDE_LINES.delete(context); @@ -14274,15 +14275,11 @@ export function matchMethodCall( if (decl.asserted !== undefined) { return decl.asserted && resolveMethodOnType(decl.asserted.name, methodName!, ref, context, 0.9, 'instance-method', decl.asserted.dir); } - // A TS/JS type the file imports from a package outside the project has - // no project method, whatever project class shares its name. - if (ESM_FAMILY.has(ref.language) && !awaited && - isOutOfRepoBinding(/^[A-Za-z_$][\w$]*/.exec((decl.raw ?? inferredType).trim())?.[0] ?? '', ref, context)) return null; // Java/Kotlin: when two classes share the simple name, the file's import // pins WHICH one (#314); Go: the package that declares the type (#2323); // TS/JS: the file declaring the class the file imports under that name. // Other languages disambiguate by call-site file. - const importedFqn = + let importedFqn = ref.language === 'java' || ref.language === 'kotlin' ? context .getImportMappings(ref.filePath, ref.language) @@ -14292,6 +14289,14 @@ export function matchMethodCall( : ESM_FAMILY.has(ref.language) && !awaited ? nmTimedT('mc-tsimport', ref, () => tsImportedTypeFile(decl.raw ?? inferredType!, ref, context)) : undefined; + // A TS/JS type the file imports from a package outside the project has + // no project method, whatever project class shares its name, unless a + // project file declares the package (tsOutsideTypeDeclaration). + if (ESM_FAMILY.has(ref.language) && !awaited && importedFqn === undefined) { + const outside = tsOutsideTypeDeclaration(decl.raw ?? inferredType, inferredType, ref, context); + if (outside === null) return null; + importedFqn = outside; + } const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType( inferredType, methodName!, @@ -15841,6 +15846,43 @@ function tsNamedClass(name: string, from: Node, ref: UnresolvedRef, context: Res return found; } +/** Per-context memo: `\0` → tsOutsideTypeDeclaration's ambient file. */ +const TS_AMBIENT_MODULE_FILES = new WeakMap>(); + +/** + * For a TS/JS type the file `at` names imports under the head of `written` + * from a package outside the project: the project file that declares that + * package with a `typeName` in it (`declare module 'pkg' { … }`, as + * vscode's `src/typings/copilot-api.d.ts` does), or null when none does. + * Undefined when the type isn't imported from outside the project. + */ +function tsOutsideTypeDeclaration(written: string, typeName: string, at: UnresolvedRef, context: ResolutionContext): string | null | undefined { + const head = /^[A-Za-z_$][\w$]*/.exec(written.trim())?.[0]; + if (!head) return undefined; + const binding = context.getImportMappings?.(at.filePath, at.language)?.find((m) => m.localName === head); + if (!binding || context.isOutOfRepoImport?.(binding.source, at.filePath, at.language) !== true) return undefined; + let memo = TS_AMBIENT_MODULE_FILES.get(context); + if (!memo) { + memo = new Map(); + TS_AMBIENT_MODULE_FILES.set(context, memo); + } + const key = binding.source + '\0' + typeName; + let file = memo.get(key); + if (file === undefined) { + file = null; + const declares = new RegExp(`\\bdeclare\\s+module\\s+(['"])${binding.source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\1`); + for (const n of context.getNodesByName(typeName)) { + if (!/\.d\.[cm]?ts$/.test(n.filePath) || !sameLanguageFamily(n.language, at.language)) continue; + if (declares.test(context.readFile(n.filePath) ?? '')) { + file = n.filePath; + break; + } + } + memo.set(key, file); + } + return file; +} + /** Does the call's file import a type named `type` from the project, or declare one? */ function tsTypeVisibleIn(type: string, ref: UnresolvedRef, context: ResolutionContext): boolean { if (tsImportedTypeFile(type, ref, context) !== undefined) return true; @@ -15877,25 +15919,27 @@ function tsFieldCall( // `ns.Mailer` → `Mailer`; a primitive or builtin names no project type. const typeName = decl.type.split('.').pop()!; if (!/^[A-Z]/.test(typeName)) return null; - // The file the field is written in names its type. One it imports from a - // package outside the project has no project method: microsoft- - // authentication's `TelemetryReporter` from `@vscode/extension-telemetry` - // reached another extension's interface of that name. + // The file the field is written in names its type. Two apps in one repo + // may each declare a `UserService`: that file imports the one the field + // holds, or declares it, so that one's method is the call's. One it + // imports from a package outside the project, which no project file + // declares (as `src/typings/*.d.ts` may), has no project method: + // microsoft-authentication's `TelemetryReporter` from + // `@vscode/extension-telemetry` reached another extension's interface of + // that name. Without an import, the bare-name path this replaces broke + // the tie by directory proximity, so keep the same signal: among the + // type's declarations of the method, prefer the one closest to the call + // site's directory (its own app), never index order. resolveMethodOnType + // still answers the single-declaration and supertype cases. const at = !declaring || declaring.filePath === ref.filePath ? ref : { ...ref, filePath: declaring.filePath, language: declaring.language }; - if (isOutOfRepoBinding(decl.type.split('.')[0]!, at, context)) return null; - // Two apps in one repo may each declare a `UserService`. That file - // imports the one the field holds, or declares it, so that one's method - // is the call's. Without an import, the bare-name path this replaces - // broke the tie by directory proximity, so keep the same signal: among - // the type's declarations of the method, prefer the one closest to the - // call site's directory (its own app), never index order. - // resolveMethodOnType still answers the single-declaration and supertype - // cases. - const imported = at === ref - ? tsImportedTypeFile(decl.type, ref, context) - : tsImportedTypeFile(decl.type, at, context) ?? at.filePath; + let imported = tsImportedTypeFile(decl.type, at, context); + if (imported === undefined) { + const outside = tsOutsideTypeDeclaration(decl.type, typeName, at, context); + if (outside === null) return null; + imported = outside ?? (at === ref ? undefined : at.filePath); + } const declared = context .getNodesByName(methodName) .filter( From a9ee7e0a348a8d0c4f88801b6350eea6304601cb Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 18 Oct 2026 22:18:32 -0500 Subject: [PATCH 6/9] docs(ts): matchTsThisFieldCall reads base classes too Co-Authored-By: Claude Opus 5.5 --- src/resolution/name-matcher.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index cc44e2cf8..1815e1865 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -15739,13 +15739,14 @@ function tsFieldDeclaration( /** * Resolve a TS/JS `this..()` call (#1496) through the field's - * declared type, read off the ENCLOSING class's own declaration lines: - * a field or constructor-parameter property (`private mailer: Mailer`, - * `mailer?: Mailer`, `readonly mailer: Mailer`) or an initializer - * (`mailer = new Mailer()`, `this.mailer = new Mailer()`). The method is then - * VALIDATED on that type by resolveMethodOnType. Null — never a bare-name - * fallback — when the field is not declared there or its type is external, - * a builtin (`this.items.push()`) or not spelled out. + * declared type, read off the ENCLOSING class's own declaration lines, or + * a class it extends (tsFieldThroughBases): a field or constructor-parameter + * property (`private mailer: Mailer`, `mailer?: Mailer`, `readonly mailer: + * Mailer`) or an initializer (`mailer = new Mailer()`, `this.mailer = new + * Mailer()`). The method is then VALIDATED on that type by + * resolveMethodOnType. Null — never a bare-name fallback — when the field is + * not declared there or its type is external, a builtin + * (`this.items.push()`) or not spelled out. */ function matchTsThisFieldCall( field: string, From 1756ab0893c58d26c186319ec37b0d1f57cfd795 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 18 Oct 2026 22:33:05 -0500 Subject: [PATCH 7/9] docs(changelog): inherited this. calls and outside-package types Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e79cd420..b5c54f8a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,6 +163,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's ` items={…} />` or excalidraw's ` …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading. - In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading. - In React, a JSX tag imported from one of your own modules now links to the component that module exports under that name, read the way a lazily loaded page's module already is. That includes going through an `index` file or barrel that passes the component on, like outline's `Sidebar/index.ts` with `import Sidebar from './App'; export default Sidebar` or its `Avatar/index.ts` with `const Avatar = AvatarWithHoverCard`, and past a wrapper such as `export default observer(AppSidebar)`, `React.memo(StaticCanvas, areEqual)` or mastodon's `const MemoizedBlurhash = memo(Blurhash)`. Before, a tag was matched by its name and the import only broke ties, so outline's `` linked to an unrelated styled `Sidebar` in a sibling file, a default import under another name linked to whatever other component its module exported, and an import through a barrel could land on another app's component of the same name. A tag whose module exports an unnamed component, like `export default () => …`, now links to nothing instead of to a namesake elsewhere. A lazily loaded React Router page exported as such a value or wrapper links to the component it holds too, the one with the markup. `codegraph sync` and the file watcher redraw these links when only the file that passes the component on changes. Re-index React projects after upgrading. +- In TypeScript and JavaScript, a call through a field the calling class inherits, like `this._store.add(…)` in a class whose base class declares `_store`, now links to the method of the field's class, with the base class found the way the subclass's file imports it, under its own name, an alias or a re-export. Before, such a call linked only when some other class with the calling class's name happened to declare the field, which could be another app's class: that last resort now needs a type the calling file imports or declares. A call on a field or variable whose type comes from a package outside your project, like `TelemetryReporter` from `@vscode/extension-telemetry`, no longer links to a project class that only shares the type's name, unless a file in your project declares that package (`declare module '…'`). Re-index TypeScript and JavaScript projects after upgrading. - In TypeScript and JavaScript, a call on a variable, parameter or field whose class the file imports, like `store.add(…)` with `store: DisposableStore`, now links to the method of the class that import names when other classes in the project share its name, such as a vendored copy, a test mock or another app's class. Before, it linked to whichever of them was indexed first, or to the one in the nearest folder: in vscode, calls on classes imported from vscode's own `vs/base` and `vs/platform` reached the copies the Copilot extension vendors or its test fixtures, in bitwarden the desktop app's `initService.init()` reached the browser extension's `InitService`, and in react-native `Keyboard` and `WebSocket` reached the Jest mock of `NativeEventEmitter`. A call whose first match was a test file's class, which then linked to nothing, now reaches the imported class too. The import is followed through re-exports, `import type`, namespace imports like `new async.Delayer()` and `require(…)`. Re-index TypeScript and JavaScript projects after upgrading. ## [1.6.2] - 2026-10-03 From 8345b6acc25f2e24c95878d43292c5715b112d64 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 18 Oct 2026 22:43:34 -0500 Subject: [PATCH 8/9] perf(ts): profile the base-class field walk and outside-type lookups Co-Authored-By: Claude Opus 5.5 --- src/resolution/name-matcher.ts | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 1815e1865..ca94d8232 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -14293,7 +14293,7 @@ export function matchMethodCall( // no project method, whatever project class shares its name, unless a // project file declares the package (tsOutsideTypeDeclaration). if (ESM_FAMILY.has(ref.language) && !awaited && importedFqn === undefined) { - const outside = tsOutsideTypeDeclaration(decl.raw ?? inferredType, inferredType, ref, context); + const outside = nmTimedT('mc-tsoutside', ref, () => tsOutsideTypeDeclaration(decl.raw ?? inferredType!, inferredType!, ref, context)); if (outside === null) return null; importedFqn = outside; } @@ -15772,22 +15772,28 @@ function matchTsThisFieldCall( // base is the one its subclass's file names (tsFieldThroughBases), so // vscode's browser `FileDialogService` finds `AbstractFileDialogService`'s // `openerService`. - for (const cls of owners) { - if (cls.filePath !== ref.filePath) continue; - const found = tsFieldThroughBases(cls, field, ref, context, 0); - if (found) return tsFieldCall(found.decl, found.cls, methodName, ref, context); - } + const found = nmTimedT('mc-thisfield-bases', ref, () => { + for (const cls of owners) { + if (cls.filePath !== ref.filePath) continue; + const hit = tsFieldThroughBases(cls, field, ref, context, 0); + if (hit) return hit; + } + return null; + }); + if (found) return tsFieldCall(found.decl, found.cls, methodName, ref, context); // Else a same-named class elsewhere may declare it, read for a calling // class whose own declaration isn't (`this._register(new Emitter())`), // but only with a type the call's file imports or declares: another app's // class of that name says nothing about this one's field. - for (const cls of owners) { - if (cls.filePath === ref.filePath) continue; - const decl = tsFieldDeclaration(cls, field, context); - if (!decl || !tsTypeVisibleIn(decl.type, ref, context)) continue; - return tsFieldCall(decl, null, methodName, ref, context); - } - return null; + const namesake = nmTimedT('mc-thisfield-namesakes', ref, () => { + for (const cls of owners) { + if (cls.filePath === ref.filePath) continue; + const decl = tsFieldDeclaration(cls, field, context); + if (decl && tsTypeVisibleIn(decl.type, ref, context)) return decl; + } + return null; + }); + return namesake ? tsFieldCall(namesake, null, methodName, ref, context) : null; } /** A TS field's declared type, as tsFieldDeclaration reads it. */ @@ -15937,7 +15943,7 @@ function tsFieldCall( : { ...ref, filePath: declaring.filePath, language: declaring.language }; let imported = tsImportedTypeFile(decl.type, at, context); if (imported === undefined) { - const outside = tsOutsideTypeDeclaration(decl.type, typeName, at, context); + const outside = nmTimedT('mc-tsoutside', ref, () => tsOutsideTypeDeclaration(decl.type, typeName, at, context)); if (outside === null) return null; imported = outside ?? (at === ref ? undefined : at.filePath); } From 24ab0b50ae2e28b4cf9533296943017ac62c0541 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 18 Oct 2026 22:53:37 -0500 Subject: [PATCH 9/9] fix(ts): Flow libdefs and node: imports count as declaring a package A package a project file declares is now found in any JS or TS file, as react-native's Flow `flow-typed/npm/*.js` libdefs declare theirs, and a `node:` import matches the bare module a libdef declares, as `flow-typed/environment/node.js` does for Node's modules. Co-Authored-By: Claude Opus 5.5 --- __tests__/ts-this-field-bases.test.ts | 21 +++++++++++++++++++++ src/resolution/name-matcher.ts | 11 +++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/__tests__/ts-this-field-bases.test.ts b/__tests__/ts-this-field-bases.test.ts index 805c19d1d..55130995f 100644 --- a/__tests__/ts-this-field-bases.test.ts +++ b/__tests__/ts-this-field-bases.test.ts @@ -166,4 +166,25 @@ describe('a call on a value whose type a package outside the project gives', () 'src/use.ts: this.client.update -> src/typings/ext-api.d.ts::ApiClient::update', ]); }, 60_000); + + it('does the same for a Flow libdef, and for a `node:` import of the module a libdef declares', async () => { + // react-native's `flow-typed/npm/tinybench_v4.1.x.js` declares + // `tinybench`, and `flow-typed/environment/node.js` Node's modules. + const calls = await callsOf({ + 'package.json': JSON.stringify({ name: 'app', dependencies: { 'ext-bench': '^1.0.0' } }), + 'flow-typed/npm/ext-bench.js': "/**\n * @flow strict\n */\ndeclare module 'ext-bench' {\n declare export class Bench {\n run(): void;\n }\n}\n", + 'flow-typed/environment/node.d.ts': "declare module 'timers' {\n export class Timeout {\n refresh(): Timeout;\n }\n}\n", + 'src/other.js': 'export class Bench {\n run() { return 1; }\n}\nexport class Timeout {\n refresh() { return this; }\n}\n', + 'src/use.js': + "import { Bench } from 'ext-bench';\n" + + 'export function b() {\n const bench = new Bench();\n return bench.run();\n}\n', + 'src/timer.ts': + "import { Timeout } from 'node:timers';\n" + + 'export function t(timeout: Timeout) {\n return timeout.refresh();\n}\n', + }); + expect(calls.filter((c) => c.includes('bench.run') || c.includes('timeout.refresh'))).toEqual([ + 'src/timer.ts: timeout.refresh -> flow-typed/environment/node.d.ts::Timeout::refresh', + 'src/use.js: bench.run -> flow-typed/npm/ext-bench.js::Bench::run', + ]); + }, 60_000); }); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index ca94d8232..7fbdb941c 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -15860,8 +15860,10 @@ const TS_AMBIENT_MODULE_FILES = new WeakMap