From 7605585c4e52266213358a5a657997070df4d880 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 18 Oct 2026 17:05:51 -0500 Subject: [PATCH 1/9] fix(ts): a call on a local annotated as one of a type's properties links to nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `groupIds: ExcalidrawElement["groupIds"]` was read as the type it indexes, so `groupIds.filter(…)` would reach `ExcalidrawElement.filter` had the class one, and on vscode `onCommandExecuted.fire(…)` reached an `Emitter.fire` through an `Emitter['event']`. An indexed type is one the annotation names without saying: like an outside type's, it links to nothing and is not matched by the method's name, which for a `Parameters[0]` local would only guess. The sync parking doesn't wait for the indexed type's module either. Co-Authored-By: Claude Opus 5.5 --- .../sync-typed-local-member-retry.test.ts | 5 ++- .../ts-array-annotated-receivers.test.ts | 38 +++++++++++++++++++ src/resolution/import-resolver.ts | 19 ++++++---- src/resolution/name-matcher.ts | 13 ++++--- 4 files changed, 60 insertions(+), 15 deletions(-) diff --git a/__tests__/sync-typed-local-member-retry.test.ts b/__tests__/sync-typed-local-member-retry.test.ts index a49a184d8..fdf973e9a 100644 --- a/__tests__/sync-typed-local-member-retry.test.ts +++ b/__tests__/sync-typed-local-member-retry.test.ts @@ -37,7 +37,8 @@ const CALLERS: Files = { 'src/use-default.ts': "import Store from './store';\nexport function e() {\n const s = new Store();\n return s.get();\n}\n", // A call on an element that shares the array's name: the element's class. 'src/use-each.ts': "import { LRUCache } from './lru-cache';\nexport function h(cs: LRUCache[]) {\n cs.forEach((cs) => cs.get('k'));\n}\n", - // One of the class's properties, which resolution reads as the class. + // One of the class's properties, which resolution links to nothing: no edit + // of the class's module links it, so it keeps its own tail. 'src/use-prop.ts': "import { LRUCache } from './lru-cache';\nexport function i(max: LRUCache['max']) {\n return max.toFixed(0);\n}\n", 'src/noise.ts': NOISE, }; @@ -219,7 +220,7 @@ describe('a failed member call on a typed local', () => { expect(tails('src/use-default.ts', 's.get')).toEqual(['module:store']); expect(tails('src/use-each.ts', 'cs.get')).toEqual(['module:lru-cache']); expect(tails('src/use-each.ts', 'cs.forEach')).toEqual(['forEach']); - expect(tails('src/use-prop.ts', 'max.toFixed')).toEqual(['module:lru-cache']); + expect(tails('src/use-prop.ts', 'max.toFixed')).toEqual(['toFixed']); expect(tails('src/other.ts', 'seen.get')).toEqual(['get']); expect(tails('src/other.ts', 'pkg.get')).toEqual(['get']); diff --git a/__tests__/ts-array-annotated-receivers.test.ts b/__tests__/ts-array-annotated-receivers.test.ts index 70982b968..93cccdfb1 100644 --- a/__tests__/ts-array-annotated-receivers.test.ts +++ b/__tests__/ts-array-annotated-receivers.test.ts @@ -9,6 +9,12 @@ * `OffsetRange[]` reached `OffsetRange.map`. A method arrays don't have can * only be called on an element that shares the array's name, as in * `d.forEach(d => d.dispose())`, and that one keeps the element's type. + * + * A local annotated as one of a type's properties (`groupIds: + * ExcalidrawElement["groupIds"]`) was read as that type too, so its calls + * reached the type's methods of the same name. Its type is one the + * annotation names without saying: like an outside type, it links to + * nothing, and the call isn't matched by its name either. */ import { describe, it, expect, afterEach } from 'vitest'; import * as fs from 'fs'; @@ -133,3 +139,35 @@ describe('a call on a TypeScript local annotated as an array of a class', () => expect(calls).toEqual(['item.find -> Item::find', 'repo.find -> Repository::find']); }, 60_000); }); + +describe("a call on a TypeScript local annotated as one of a type's properties", () => { + it('links to no method of that type', async () => { + // excalidraw's `groupIds: ExcalidrawElement["groupIds"]`, then `groupIds.filter(…)`. + const calls = await callsOf({ + ...CLASSES, + 'src/element.ts': 'export class Element {\n groupIds: string[] = [];\n filter() { return this; }\n}\n', + 'src/use.ts': + "import { Element } from './element';\n" + + "export function without(groupIds: Element['groupIds'], id: string) {\n" + + ' return groupIds.filter((g) => g !== id);\n' + + '}\n', + }); + expect(calls.filter((c) => c.startsWith('groupIds.'))).toEqual([]); + }, 60_000); + + it("is not matched to a method by the call's name either", async () => { + // vscode's `invocation: Parameters[0]`: + // left untyped, the receiver's name alone would pick `Logger.info`. + const calls = await callsOf({ + ...CLASSES, + 'src/logger.ts': 'export class Logger {\n info(message: string) { return message; }\n}\n', + 'src/use.ts': + "import { Logger } from './logger';\n" + + 'function make(logger: Logger) { return logger; }\n' + + 'export function log(logger: Parameters[0]) {\n' + + " return logger.info('x');\n" + + '}\n', + }); + expect(calls.filter((c) => c.startsWith('logger.'))).toEqual([]); + }, 60_000); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 9fa572206..dfc16155c 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -3272,20 +3272,23 @@ export function pythonModulePath(source: string, fromFile: string): string | nul * way resolution reads it for the call (inferLocalReceiverType, memoized per * file, scope and receiver, so a call the matcher just tried costs a lookup): * the binding the type is written through, `LRUCache` of `new LRUCache(…)` or `lib` of `new lib.LRUCache()`, of the element type of an array - * (`results: URI[]`), or of the type whose property it is (`Emitter['event']`). - * Undefined for a type the file doesn't import, in a file that imports - * nothing, and for a method arrays have called on an array (`results.push(…)`): - * resolution links that to no project class, so no edit of the type's module - * does. Such calls were most of what this parked on vscode, and retrying them - * nearly doubled what an edit of `uri.ts` retried. + * V>(…)` or `lib` of `new lib.LRUCache()`, or of the element type of an + * array (`results: URI[]`). Undefined for a type the file doesn't import, in + * a file that imports nothing, for a method arrays have called on an array + * (`results.push(…)`), and for a type's property (`ids: Element["ids"]`): + * resolution links neither to a project class, so no edit of the type's + * module does. Array calls were most of what this parked on vscode, and + * retrying them nearly doubled what an edit of `uri.ts` retried. */ function localTypeBinding(receiver: string, ref: UnresolvedRef, context: ResolutionContext, mappings: ImportMapping[]): ImportMapping | undefined { if (mappings.length === 0) return undefined; const decl: { raw?: string } = {}; const type = inferLocalReceiverType(receiver, ref, context, decl); if (!type || !decl.raw) return undefined; - if (arrayElementType(type) !== undefined && JS_ARRAY_METHODS.has(ref.referenceName.slice(receiver.length + 1))) return undefined; + if (type.endsWith(']')) { + const element = arrayElementType(type); + if (element === undefined || JS_ARRAY_METHODS.has(ref.referenceName.slice(receiver.length + 1))) return undefined; + } const head = decl.raw.split(/[.<[\s]/)[0]; return mappings.find((m) => m.localName === head); } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 32d9b4041..dcbb54e03 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -13069,7 +13069,7 @@ function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[] // A type followed by brackets is captured whole, so matchMethodCall // can tell an array of it (`items: Item[]`, `items: Item[]`), whose // own methods aren't an element's (arrayElementType), from one of its - // properties (`groupIds: Element["groupIds"]`). + // properties (`groupIds: Element["groupIds"]`), which isn't it. new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.$]*(?:(?:\\s*<[^;=(){}<>]*(?:<[^;=(){}<>]*>[^;=(){}<>]*)*>)?\\s*\\[[^\\]]*\\])?)`), // lg: Logger (annotation or typed param) ]; case 'python': @@ -14216,12 +14216,15 @@ export function matchMethodCall( // declares, where `distinctElements.find(…)` on vscode's `ExplorerItem[]` // reached `ExplorerItem.find`; any other is called on one of its elements // under the same name, as in `d.forEach(d => d.dispose())`, so it takes - // the element's type. A type's property (`Emitter['event']`, - // `Parameters[0]`) is still read as the type. + // the element's type. A type's property or element (`groupIds: + // ExcalidrawElement["groupIds"]`, `Parameters[0]`) is a type + // the annotation names without saying, like an outside type's: not the + // type it indexes, whose methods vscode's `Emitter['event']` reached, + // and not one to guess by the method's name. if (inferredType && ESM_FAMILY.has(ref.language) && inferredType.endsWith(']')) { const element = arrayElementType(inferredType); - if (element !== undefined && JS_ARRAY_METHODS.has(methodName!)) return null; - inferredType = element ?? inferredType.replace(/\s*\[[^\]]*\]$/, ''); + if (element === undefined || JS_ARRAY_METHODS.has(methodName!)) return null; + inferredType = element; } if (inferredType) { // A Go local bound from a type assertion calls a method of the type Go From 6194c232f7f4f8530e093a8c41c0e5aed89b125c Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 18 Oct 2026 17:23:39 -0500 Subject: [PATCH 2/9] fix(ts): a local annotated as one of a type's properties has that property's type `editor: IChatWidget['inputEditor']` now types `editor` as the `ICodeEditor` the property is declared with, read off the indexed class or interface's own lines as `this.field` calls already are, and an array property follows the array rules. One whose type isn't written in the project (`Parameters[0]`, a getter) names no type to call or to guess by the method's name. The sync parking is main's again: an indexed type's call waits for the indexed type's module, whose declaration resolution reads. Co-Authored-By: Claude Opus 5.5 --- .../sync-typed-local-member-retry.test.ts | 5 +- .../ts-array-annotated-receivers.test.ts | 50 +++++++++++++--- src/resolution/import-resolver.ts | 19 +++--- src/resolution/name-matcher.ts | 59 +++++++++++++++---- 4 files changed, 99 insertions(+), 34 deletions(-) diff --git a/__tests__/sync-typed-local-member-retry.test.ts b/__tests__/sync-typed-local-member-retry.test.ts index fdf973e9a..a49a184d8 100644 --- a/__tests__/sync-typed-local-member-retry.test.ts +++ b/__tests__/sync-typed-local-member-retry.test.ts @@ -37,8 +37,7 @@ const CALLERS: Files = { 'src/use-default.ts': "import Store from './store';\nexport function e() {\n const s = new Store();\n return s.get();\n}\n", // A call on an element that shares the array's name: the element's class. 'src/use-each.ts': "import { LRUCache } from './lru-cache';\nexport function h(cs: LRUCache[]) {\n cs.forEach((cs) => cs.get('k'));\n}\n", - // One of the class's properties, which resolution links to nothing: no edit - // of the class's module links it, so it keeps its own tail. + // One of the class's properties, which resolution reads as the class. 'src/use-prop.ts': "import { LRUCache } from './lru-cache';\nexport function i(max: LRUCache['max']) {\n return max.toFixed(0);\n}\n", 'src/noise.ts': NOISE, }; @@ -220,7 +219,7 @@ describe('a failed member call on a typed local', () => { expect(tails('src/use-default.ts', 's.get')).toEqual(['module:store']); expect(tails('src/use-each.ts', 'cs.get')).toEqual(['module:lru-cache']); expect(tails('src/use-each.ts', 'cs.forEach')).toEqual(['forEach']); - expect(tails('src/use-prop.ts', 'max.toFixed')).toEqual(['toFixed']); + expect(tails('src/use-prop.ts', 'max.toFixed')).toEqual(['module:lru-cache']); expect(tails('src/other.ts', 'seen.get')).toEqual(['get']); expect(tails('src/other.ts', 'pkg.get')).toEqual(['get']); diff --git a/__tests__/ts-array-annotated-receivers.test.ts b/__tests__/ts-array-annotated-receivers.test.ts index 93cccdfb1..fe6b4aadb 100644 --- a/__tests__/ts-array-annotated-receivers.test.ts +++ b/__tests__/ts-array-annotated-receivers.test.ts @@ -10,11 +10,12 @@ * only be called on an element that shares the array's name, as in * `d.forEach(d => d.dispose())`, and that one keeps the element's type. * - * A local annotated as one of a type's properties (`groupIds: - * ExcalidrawElement["groupIds"]`) was read as that type too, so its calls - * reached the type's methods of the same name. Its type is one the - * annotation names without saying: like an outside type, it links to - * nothing, and the call isn't matched by its name either. + * A local annotated as one of a type's properties (`editor: + * IChatWidget['inputEditor']`) was read as that type too, so its calls + * reached the type's methods of the same name, or none. It has the type the + * property is declared with, an array of it the same way; one whose type + * isn't written in the project (`Parameters[0]`) links to nothing, + * and the call isn't matched by its name either. */ import { describe, it, expect, afterEach } from 'vitest'; import * as fs from 'fs'; @@ -141,7 +142,42 @@ describe('a call on a TypeScript local annotated as an array of a class', () => }); describe("a call on a TypeScript local annotated as one of a type's properties", () => { - it('links to no method of that type', async () => { + it('links to the method of the type that property is declared with', async () => { + // vscode's `editor: IChatWidget['inputEditor']`, then `editor.executeEdits(…)`. + const calls = await callsOf({ + ...CLASSES, + 'src/widget.ts': + 'export class CodeEditor {\n executeEdits() { return true; }\n}\n' + + 'export interface Widget {\n readonly inputEditor: CodeEditor;\n}\n' + + // Another `executeEdits`, so the method's name alone picks neither. + 'export class DiffEditor {\n executeEdits() { return false; }\n}\n', + 'src/use.ts': + "import { Widget } from './widget';\n" + + "export function edit(ed: Widget['inputEditor']) {\n" + + ' return ed.executeEdits();\n' + + '}\n', + }); + expect(calls.filter((c) => c.startsWith('ed.'))).toEqual(['ed.executeEdits -> CodeEditor::executeEdits']); + }, 60_000); + + it('links to that type rather than to a method of the type it indexes', async () => { + // vscode's `visibility: ISessionChatPillVisibilityService['pullRequests']`, + // then `visibility.isVisible(…)`, which reached the service's own `isVisible`. + const calls = await callsOf({ + ...CLASSES, + 'src/service.ts': + 'export class Visibility {\n isVisible() { return true; }\n}\n' + + 'export class VisibilityService {\n readonly pullRequests: Visibility = new Visibility();\n isVisible() { return false; }\n}\n', + 'src/use.ts': + "import { VisibilityService } from './service';\n" + + "export function shown(visibility: VisibilityService['pullRequests']) {\n" + + ' return visibility.isVisible();\n' + + '}\n', + }); + expect(calls.filter((c) => c.startsWith('visibility.'))).toEqual(['visibility.isVisible -> Visibility::isVisible']); + }, 60_000); + + it("links an array property's own methods to nothing", async () => { // excalidraw's `groupIds: ExcalidrawElement["groupIds"]`, then `groupIds.filter(…)`. const calls = await callsOf({ ...CLASSES, @@ -155,7 +191,7 @@ describe("a call on a TypeScript local annotated as one of a type's properties", expect(calls.filter((c) => c.startsWith('groupIds.'))).toEqual([]); }, 60_000); - it("is not matched to a method by the call's name either", async () => { + it("is not matched to a method by the call's name when the property's type isn't written in the project", async () => { // vscode's `invocation: Parameters[0]`: // left untyped, the receiver's name alone would pick `Logger.info`. const calls = await callsOf({ diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index dfc16155c..9fa572206 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -3272,23 +3272,20 @@ export function pythonModulePath(source: string, fromFile: string): string | nul * way resolution reads it for the call (inferLocalReceiverType, memoized per * file, scope and receiver, so a call the matcher just tried costs a lookup): * the binding the type is written through, `LRUCache` of `new LRUCache(…)` or `lib` of `new lib.LRUCache()`, or of the element type of an - * array (`results: URI[]`). Undefined for a type the file doesn't import, in - * a file that imports nothing, for a method arrays have called on an array - * (`results.push(…)`), and for a type's property (`ids: Element["ids"]`): - * resolution links neither to a project class, so no edit of the type's - * module does. Array calls were most of what this parked on vscode, and - * retrying them nearly doubled what an edit of `uri.ts` retried. + * V>(…)` or `lib` of `new lib.LRUCache()`, of the element type of an array + * (`results: URI[]`), or of the type whose property it is (`Emitter['event']`). + * Undefined for a type the file doesn't import, in a file that imports + * nothing, and for a method arrays have called on an array (`results.push(…)`): + * resolution links that to no project class, so no edit of the type's module + * does. Such calls were most of what this parked on vscode, and retrying them + * nearly doubled what an edit of `uri.ts` retried. */ function localTypeBinding(receiver: string, ref: UnresolvedRef, context: ResolutionContext, mappings: ImportMapping[]): ImportMapping | undefined { if (mappings.length === 0) return undefined; const decl: { raw?: string } = {}; const type = inferLocalReceiverType(receiver, ref, context, decl); if (!type || !decl.raw) return undefined; - if (type.endsWith(']')) { - const element = arrayElementType(type); - if (element === undefined || JS_ARRAY_METHODS.has(ref.referenceName.slice(receiver.length + 1))) return undefined; - } + if (arrayElementType(type) !== undefined && JS_ARRAY_METHODS.has(ref.referenceName.slice(receiver.length + 1))) return undefined; const head = decl.raw.split(/[.<[\s]/)[0]; return mappings.find((m) => m.localName === head); } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index dcbb54e03..9c10609ce 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -14216,15 +14216,19 @@ export function matchMethodCall( // declares, where `distinctElements.find(…)` on vscode's `ExplorerItem[]` // reached `ExplorerItem.find`; any other is called on one of its elements // under the same name, as in `d.forEach(d => d.dispose())`, so it takes - // the element's type. A type's property or element (`groupIds: - // ExcalidrawElement["groupIds"]`, `Parameters[0]`) is a type - // the annotation names without saying, like an outside type's: not the - // type it indexes, whose methods vscode's `Emitter['event']` reached, - // and not one to guess by the method's name. + // the element's type. One of a type's properties (`editor: + // IChatWidget['inputEditor']`) has the type that property is declared + // with, an array of it the same way, where the type it indexes reached + // vscode's `ISessionChatPillVisibilityService.isVisible` through + // `['pullRequests']`. One whose type isn't written in the project + // (`Parameters[0]`, a getter) names no type to call, or to + // guess by the method's name. if (inferredType && ESM_FAMILY.has(ref.language) && inferredType.endsWith(']')) { const element = arrayElementType(inferredType); - if (element === undefined || JS_ARRAY_METHODS.has(methodName!)) return null; - inferredType = element; + const declared = element !== undefined ? { type: element, array: true } : tsIndexedPropertyType(inferredType, ref, context); + if (!declared || (declared.array && JS_ARRAY_METHODS.has(methodName!))) return null; + inferredType = normalizeInferredTypeName(declared.type); + if (!inferredType) return null; } if (inferredType) { // A Go local bound from a type assertion calls a method of the type Go @@ -15515,7 +15519,15 @@ function matchRustSelfFieldCall( * patterns each time, a third of all method-call matching on vscode. Derived * from file source: drops with clearNameMatcherMemos. */ -const TS_FIELD_DECL_MEMO = new WeakMap>(); +const TS_FIELD_DECL_MEMO = new WeakMap>(); +/** + * A TS field's declared type as tsFieldDeclaration reads it: the type it + * names (`Mailer` of `mailer: Mailer`), whether that is a value's + * (`typeof X`), and whether the field holds an array of it (`items: Item[]`). + */ +type TsFieldDecl = { valueType: boolean; type: string; array?: boolean }; +/** 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. */ interface TsClassDecl { lines: string[]; @@ -15569,7 +15581,7 @@ const TS_FIELD_PATTERNS_AT: readonly TsFieldPattern[] = [ ]; /** The first declaration of `field` on one line, as `line.match` of its patterns in order would find it. */ -function tsFieldOnLine(line: string, field: string): { valueType: boolean; type: string } | null { +function tsFieldOnLine(line: string, field: string): TsFieldDecl | null { let at: number[] | null = null; for (let i = line.indexOf(field); i !== -1; i = line.indexOf(field, i + 1)) { const end = i + field.length; @@ -15581,7 +15593,7 @@ function tsFieldOnLine(line: string, field: string): { valueType: boolean; type: for (const i of at) { re.lastIndex = i; const m = re.exec(line); - if (m && m[1]) return { valueType, type: m[1] }; + if (m && m[1]) return { valueType, type: m[1], array: TS_ARRAY_SUFFIX.test(line.slice(re.lastIndex)) }; } } return null; @@ -15644,7 +15656,7 @@ function tsFieldDeclaration( cls: Node, field: string, context: ResolutionContext -): { valueType: boolean; type: string } | null { +): TsFieldDecl | null { let memo = TS_FIELD_DECL_MEMO.get(context); if (!memo) { memo = new Map(); @@ -15653,7 +15665,7 @@ function tsFieldDeclaration( const key = cls.id + '\0' + field; const hit = memo.get(key); if (hit !== undefined) return hit; - let found: { valueType: boolean; type: string } | null = null; + let found: TsFieldDecl | null = null; const decl = tsClassDecl(cls, context); if (decl && TS_FIELD_TOKEN.test(field)) { // Every pattern needs the field as a whole `[\w$#]` run — the lookbehind @@ -15675,7 +15687,7 @@ function tsFieldDeclaration( for (const { re, valueType } of patterns) { const m = line.match(re); if (!m || !m[1]) continue; - found = { valueType, type: m[1] }; + found = { valueType, type: m[1], array: TS_ARRAY_SUFFIX.test(line.slice(m.index! + m[0].length)) }; break scan; } } @@ -15684,6 +15696,27 @@ function tsFieldDeclaration( return found; } +/** + * What a TS indexed type (`editor: IChatWidget['inputEditor']`) is: the type + * that property is declared with in the project class or interface it + * indexes, and whether the property holds an array of it. Null when the key + * isn't a string literal (`Parameters[0]`, `T[K]`), the indexed + * type isn't the project's, or the property's type isn't written there (a + * getter, `typeof`). + */ +function tsIndexedPropertyType(indexed: string, ref: UnresolvedRef, context: ResolutionContext): { type: string; array: boolean } | null { + const m = /^([A-Za-z_$][\w$]*)\s*\[\s*(['"])([\w$]+)\2\s*\]$/.exec(indexed); + if (!m) return null; + const owners = preferCallSiteFile(context.getNodesByName(m[1]!), ref.filePath).filter((n) => + (n.kind === 'class' || n.kind === 'interface' || n.kind === 'type_alias' || n.kind === 'component') && + sameLanguageFamily(n.language, ref.language)); + for (const owner of owners) { + const decl = tsFieldDeclaration(owner, m[3]!, context); + if (decl) return decl.valueType ? null : { type: decl.type, array: decl.array === true }; + } + return null; +} + /** * Resolve a TS/JS `this..()` call (#1496) through the field's * declared type, read off the ENCLOSING class's own declaration lines: From c38ce00be3fcc4e147eea78ae0d138afde457a8f Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 18 Oct 2026 17:25:24 -0500 Subject: [PATCH 3/9] docs(changelog): indexed-type annotations Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98020a127..526cb2db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph sync` and the file watcher now also link a call or read on a name a file imports, like `NODE_PATH_EXCLUDELIST.includes(path)` after `import { NODE_PATH_EXCLUDELIST } from './constants'`, `store.get(key)` through a default import or `api.get()` through a namespace import, when the module gets the export in a later edit, is added after the importing file was indexed, or comes back after being deleted, in JavaScript, TypeScript, Vue, Svelte and Astro. Before, such a call stayed unlinked until the importing file changed or the project was re-indexed, because sync looked it up only by its method's name, which the module seldom declares, and skipped a name as common as `includes`, `get` or `map` altogether. Re-index after upgrading to pick up links an earlier version missed. - `codegraph sync` and the file watcher now also link a call on a variable or parameter declared with a class a file imports, like `cache.get(key)` after `const cache = new LRUCache(10)` or in a function that takes `cache: LRUCache`, when the class's module gets the class in a later edit, is added after the file was indexed, or comes back after being deleted, in JavaScript and TypeScript. Before, such a call stayed unlinked until its own file changed or the project was re-indexed whenever its method's name, like `get`, was too common for sync to look up. Re-index after upgrading to pick up links an earlier version missed. - In TypeScript and JavaScript, a call of an array method, like `items.find(…)` or `items.push(…)`, on a variable or parameter annotated as an array of a class (`items: Item[]`) no longer links to the class's method of the same name. Before, the annotation was read as the class itself: in vscode, `distinctElements.find(…)` on an `ExplorerItem[]` linked to `ExplorerItem.find`, and `ranges.map(…)` on an `OffsetRange[]` to `OffsetRange.map`, so those methods showed callers they don't have. A call on an element that shares the array's name, as in `d.forEach(d => d.dispose())`, still links to the class. Re-index TypeScript and JavaScript projects after upgrading. +- In TypeScript and JavaScript, a call on a variable or parameter annotated as one of a type's properties, like `editor.executeEdits(…)` with `editor: IChatWidget['inputEditor']`, now links to the method of the type that property is declared with. Before, the annotation was read as the type it indexes, so in vscode `visibility.isVisible(…)` with `visibility: ISessionChatPillVisibilityService['pullRequests']` linked to the service's own `isVisible`, and a call it couldn't place there was matched by the method's name alone. A property declared as an array follows the same rules as an array annotation, and one whose type isn't written in the project, like `Parameters[0]`, links to nothing rather than to a guess. Re-index TypeScript and JavaScript projects after upgrading. - `codegraph sync` and the file watcher now do the same for a Python import under a name of its own, like `from .models import User as UserModel`, `from . import helpers as h` or `from app.pkg import sub as subpkg`: when the module or package is added after the importing file was indexed, comes back after being deleted, or gets the imported name in a later edit, the import and every use of the alias, such as a call, a base class or the class passed as a value, now link to it. Before, they stayed unlinked until the importing file changed or the project was re-indexed. Re-index Python projects after upgrading to pick up links an earlier version missed. - `codegraph sync` and the file watcher now also link a Python import of a name a package re-exports, like `from fastapi import FastAPI` through `fastapi/__init__.py`, when that `__init__.py` is added after the importing files were indexed or gains the re-export in a later edit. Before, calls through the name stayed unlinked until each importing file changed or the project was re-indexed. Large projects gain the most: sync also used to skip, in any language, a module or package that more than 500 references were waiting for, and now restoring Django's `django/urls/__init__.py` relinks every file that imports `path` from it. Re-index Python projects after upgrading to pick up links an earlier version missed. - `codegraph sync` and the file watcher now update the files that import through a module that re-exports what it imports, like a package's `__init__.py` or an `index.ts` barrel, when that module is deleted or added, and in Python also the files that reach it through another package re-exporting it. Before, deleting `shop/__init__.py` left every `from shop import Order` linked to the class it re-exported, and deleting `components/index.ts` left every `import { Button } from './components'` linked, until each importing file changed or the project was re-indexed. When such a module came back, the imports stayed linked the way they were while it was missing, and in a large Python project an import of a module whose name is as common as `models` or `test`, like `from django.test import TestCase`, stayed unlinked. Python, JavaScript, TypeScript, Vue, Svelte and Astro. From c1cf500d4e5d6827280775a39dfb410eb7b0bd4e Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 18 Oct 2026 17:39:51 -0500 Subject: [PATCH 4/9] fix(ts): an indexed property's type decides its call alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `visibility: ISessionChatPillVisibilityService['pullRequests']` is declared as `ISessionChatPillFilter & { isVisible(…) }`; its head names no `isVisible`, and the call then went on to a name guess that picked the service class's own `isVisible`. Like a `this.` call, a call on an indexed property links to its type's method or to nothing. Co-Authored-By: Claude Opus 5.5 --- __tests__/ts-array-annotated-receivers.test.ts | 18 ++++++++++++++++++ src/resolution/name-matcher.ts | 8 +++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/__tests__/ts-array-annotated-receivers.test.ts b/__tests__/ts-array-annotated-receivers.test.ts index fe6b4aadb..8357a1b17 100644 --- a/__tests__/ts-array-annotated-receivers.test.ts +++ b/__tests__/ts-array-annotated-receivers.test.ts @@ -177,6 +177,24 @@ describe("a call on a TypeScript local annotated as one of a type's properties", expect(calls.filter((c) => c.startsWith('visibility.'))).toEqual(['visibility.isVisible -> Visibility::isVisible']); }, 60_000); + it("links to nothing when the property's type lacks the method, not to a guess", async () => { + // The property is declared as an intersection, whose head names no + // `isVisible`: neither the indexed type's own method nor one found by + // the receiver's name, like `VisibilityService.isVisible`, is the call's. + const calls = await callsOf({ + ...CLASSES, + 'src/service.ts': + 'export interface Filter {\n showAll(): boolean;\n}\n' + + 'export class VisibilityService {\n readonly pullRequests!: Filter & { isVisible(): boolean };\n isVisible() { return false; }\n}\n', + 'src/use.ts': + "import { VisibilityService } from './service';\n" + + "export function shown(visibility: VisibilityService['pullRequests']) {\n" + + ' return visibility.isVisible();\n' + + '}\n', + }); + expect(calls.filter((c) => c.startsWith('visibility.'))).toEqual([]); + }, 60_000); + it("links an array property's own methods to nothing", async () => { // excalidraw's `groupIds: ExcalidrawElement["groupIds"]`, then `groupIds.filter(…)`. const calls = await callsOf({ diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 9c10609ce..6bd20e57f 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -14220,15 +14220,17 @@ export function matchMethodCall( // IChatWidget['inputEditor']`) has the type that property is declared // with, an array of it the same way, where the type it indexes reached // vscode's `ISessionChatPillVisibilityService.isVisible` through - // `['pullRequests']`. One whose type isn't written in the project - // (`Parameters[0]`, a getter) names no type to call, or to - // guess by the method's name. + // `['pullRequests']`. That type decides alone, as a `this.` + // call's does (matchTsThisFieldCall): the method is its, or nothing the + // method's name would guess, as for a property whose type isn't written + // in the project (`Parameters[0]`, a getter). if (inferredType && ESM_FAMILY.has(ref.language) && inferredType.endsWith(']')) { const element = arrayElementType(inferredType); const declared = element !== undefined ? { type: element, array: true } : tsIndexedPropertyType(inferredType, ref, context); if (!declared || (declared.array && JS_ARRAY_METHODS.has(methodName!))) return null; inferredType = normalizeInferredTypeName(declared.type); if (!inferredType) return null; + if (element === undefined) return resolveMethodOnType(inferredType, methodName!, ref, context, 0.9, 'instance-method'); } if (inferredType) { // A Go local bound from a type assertion calls a method of the type Go From 165efa63a3dc1823222d872afb5183422f90d44e Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 18 Oct 2026 17:55:19 -0500 Subject: [PATCH 5/9] docs(changelog): an indexed property's type decides alone Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 526cb2db3..a9b2a4cc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,7 +82,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph sync` and the file watcher now also link a call or read on a name a file imports, like `NODE_PATH_EXCLUDELIST.includes(path)` after `import { NODE_PATH_EXCLUDELIST } from './constants'`, `store.get(key)` through a default import or `api.get()` through a namespace import, when the module gets the export in a later edit, is added after the importing file was indexed, or comes back after being deleted, in JavaScript, TypeScript, Vue, Svelte and Astro. Before, such a call stayed unlinked until the importing file changed or the project was re-indexed, because sync looked it up only by its method's name, which the module seldom declares, and skipped a name as common as `includes`, `get` or `map` altogether. Re-index after upgrading to pick up links an earlier version missed. - `codegraph sync` and the file watcher now also link a call on a variable or parameter declared with a class a file imports, like `cache.get(key)` after `const cache = new LRUCache(10)` or in a function that takes `cache: LRUCache`, when the class's module gets the class in a later edit, is added after the file was indexed, or comes back after being deleted, in JavaScript and TypeScript. Before, such a call stayed unlinked until its own file changed or the project was re-indexed whenever its method's name, like `get`, was too common for sync to look up. Re-index after upgrading to pick up links an earlier version missed. - In TypeScript and JavaScript, a call of an array method, like `items.find(…)` or `items.push(…)`, on a variable or parameter annotated as an array of a class (`items: Item[]`) no longer links to the class's method of the same name. Before, the annotation was read as the class itself: in vscode, `distinctElements.find(…)` on an `ExplorerItem[]` linked to `ExplorerItem.find`, and `ranges.map(…)` on an `OffsetRange[]` to `OffsetRange.map`, so those methods showed callers they don't have. A call on an element that shares the array's name, as in `d.forEach(d => d.dispose())`, still links to the class. Re-index TypeScript and JavaScript projects after upgrading. -- In TypeScript and JavaScript, a call on a variable or parameter annotated as one of a type's properties, like `editor.executeEdits(…)` with `editor: IChatWidget['inputEditor']`, now links to the method of the type that property is declared with. Before, the annotation was read as the type it indexes, so in vscode `visibility.isVisible(…)` with `visibility: ISessionChatPillVisibilityService['pullRequests']` linked to the service's own `isVisible`, and a call it couldn't place there was matched by the method's name alone. A property declared as an array follows the same rules as an array annotation, and one whose type isn't written in the project, like `Parameters[0]`, links to nothing rather than to a guess. Re-index TypeScript and JavaScript projects after upgrading. +- In TypeScript and JavaScript, a call on a variable or parameter annotated as one of a type's properties, like `editor.executeEdits(…)` with `editor: IChatWidget['inputEditor']`, now links to the method of the type that property is declared with, or to nothing when that type has no such method. Before, the annotation was read as the type it indexes, so in vscode `visibility.isVisible(…)` with `visibility: ISessionChatPillVisibilityService['pullRequests']` linked to the service's own `isVisible`, and a call that type didn't have was matched by the method's name alone: `chatModel.onDidChange(…)` with `chatModel: IInlineChatSession['chatModel']` reached a folding model's `onDidChange`. A property declared as an array follows the same rules as an array annotation. Re-index TypeScript and JavaScript projects after upgrading. - `codegraph sync` and the file watcher now do the same for a Python import under a name of its own, like `from .models import User as UserModel`, `from . import helpers as h` or `from app.pkg import sub as subpkg`: when the module or package is added after the importing file was indexed, comes back after being deleted, or gets the imported name in a later edit, the import and every use of the alias, such as a call, a base class or the class passed as a value, now link to it. Before, they stayed unlinked until the importing file changed or the project was re-indexed. Re-index Python projects after upgrading to pick up links an earlier version missed. - `codegraph sync` and the file watcher now also link a Python import of a name a package re-exports, like `from fastapi import FastAPI` through `fastapi/__init__.py`, when that `__init__.py` is added after the importing files were indexed or gains the re-export in a later edit. Before, calls through the name stayed unlinked until each importing file changed or the project was re-indexed. Large projects gain the most: sync also used to skip, in any language, a module or package that more than 500 references were waiting for, and now restoring Django's `django/urls/__init__.py` relinks every file that imports `path` from it. Re-index Python projects after upgrading to pick up links an earlier version missed. - `codegraph sync` and the file watcher now update the files that import through a module that re-exports what it imports, like a package's `__init__.py` or an `index.ts` barrel, when that module is deleted or added, and in Python also the files that reach it through another package re-exporting it. Before, deleting `shop/__init__.py` left every `from shop import Order` linked to the class it re-exported, and deleting `components/index.ts` left every `import { Button } from './components'` linked, until each importing file changed or the project was re-indexed. When such a module came back, the imports stayed linked the way they were while it was missing, and in a large Python project an import of a module whose name is as common as `models` or `test`, like `from django.test import TestCase`, stayed unlinked. Python, JavaScript, TypeScript, Vue, Svelte and Astro. From c61ae58b302ae4b40b5b2e5bd9242fc3c647b949 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 18 Oct 2026 20:37:15 -0500 Subject: [PATCH 6/9] fix(ts): an indexed property's type is the class its file imports The indexed type is the one the call's file imports, and the property's type the class the declaring file imports or declares, passed to resolveMethodOnType like a typed local's import. vscode's `ComponentFixtureContext['disposableStore']` reached copilot's vendored `DisposableStore`. Co-Authored-By: Claude Opus 5.5 --- .../ts-array-annotated-receivers.test.ts | 58 ++++++++++++++++++- src/resolution/name-matcher.ts | 38 ++++++++---- 2 files changed, 83 insertions(+), 13 deletions(-) diff --git a/__tests__/ts-array-annotated-receivers.test.ts b/__tests__/ts-array-annotated-receivers.test.ts index 8357a1b17..ff278c1d4 100644 --- a/__tests__/ts-array-annotated-receivers.test.ts +++ b/__tests__/ts-array-annotated-receivers.test.ts @@ -34,7 +34,7 @@ afterEach(() => { }); /** Index `files` and return each call edge as ` -> `. */ -async function callsOf(files: Record): Promise { +async function callsOf(files: Record, withFiles = false): Promise { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ts-array-recv-')); roots.push(root); for (const [file, text] of Object.entries(files)) { @@ -46,7 +46,13 @@ async function callsOf(files: Record): Promise { 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) => `${e.metadata?.refName} -> ${byId.get(e.target)?.qualifiedName}`) + .map((e) => { + const source = byId.get(e.source); + const target = byId.get(e.target); + return withFiles + ? `${source?.filePath}: ${e.metadata?.refName} -> ${target?.filePath}::${target?.qualifiedName}` + : `${e.metadata?.refName} -> ${target?.qualifiedName}`; + }) .sort(); } @@ -209,6 +215,54 @@ describe("a call on a TypeScript local annotated as one of a type's properties", expect(calls.filter((c) => c.startsWith('groupIds.'))).toEqual([]); }, 60_000); + it("links to the class the property's file imports, when several share its name", async () => { + // vscode's `disposables: ComponentFixtureContext['disposableStore']` + // reached copilot's vendored `DisposableStore`, not the one the + // fixture context's file imports. + const editors = { + 'src/one/editor.ts': 'export class CodeEditor {\n executeEdits() { return 1; }\n}\n', + 'src/two/editor.ts': 'export class CodeEditor {\n executeEdits() { return 2; }\n}\n', + }; + const calls = await callsOf({ + ...CLASSES, + ...editors, + 'src/widget-one.ts': "import { CodeEditor } from './one/editor';\nexport interface WidgetOne {\n readonly inputEditor: CodeEditor;\n}\n", + 'src/widget-two.ts': "import { CodeEditor } from './two/editor';\nexport interface WidgetTwo {\n readonly inputEditor: CodeEditor;\n}\n", + 'src/use-one.ts': + "import { WidgetOne } from './widget-one';\n" + + "export function edit(ed: WidgetOne['inputEditor']) {\n return ed.executeEdits();\n}\n", + 'src/use-two.ts': + "import { WidgetTwo } from './widget-two';\n" + + "export function edit(ed: WidgetTwo['inputEditor']) {\n return ed.executeEdits();\n}\n", + }, true); + expect(calls.filter((c) => c.includes('ed.'))).toEqual([ + 'src/use-one.ts: ed.executeEdits -> src/one/editor.ts::CodeEditor::executeEdits', + 'src/use-two.ts: ed.executeEdits -> src/two/editor.ts::CodeEditor::executeEdits', + ]); + }, 60_000); + + it('reads the property off the indexed type the call\'s file imports, and a class its file declares', async () => { + // Two `Widget`s, each declaring its own `CodeEditor` beside it. + const widget = (n: number) => + `export class CodeEditor {\n executeEdits() { return ${n}; }\n}\n` + + 'export interface Widget {\n readonly inputEditor: CodeEditor;\n}\n'; + const calls = await callsOf({ + ...CLASSES, + 'src/a/widget.ts': widget(1), + 'src/b/widget.ts': widget(2), + 'src/use-a.ts': + "import { Widget } from './a/widget';\n" + + "export function edit(ed: Widget['inputEditor']) {\n return ed.executeEdits();\n}\n", + 'src/use-b.ts': + "import type { Widget } from './b/widget';\n" + + "export function edit(ed: Widget['inputEditor']) {\n return ed.executeEdits();\n}\n", + }, true); + expect(calls.filter((c) => c.includes('ed.'))).toEqual([ + 'src/use-a.ts: ed.executeEdits -> src/a/widget.ts::CodeEditor::executeEdits', + 'src/use-b.ts: ed.executeEdits -> src/b/widget.ts::CodeEditor::executeEdits', + ]); + }, 60_000); + it("is not matched to a method by the call's name when the property's type isn't written in the project", async () => { // vscode's `invocation: Parameters[0]`: // left untyped, the receiver's name alone would pick `Logger.info`. diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 7e834efa0..3fb1b8c14 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -14266,14 +14266,19 @@ export function matchMethodCall( // `['pullRequests']`. That type decides alone, as a `this.` // call's does (matchTsThisFieldCall): the method is its, or nothing the // method's name would guess, as for a property whose type isn't written - // in the project (`Parameters[0]`, a getter). + // in the project (`Parameters[0]`, a getter). Its class is the + // one the declaring file imports, as a typed local's is the one the + // call's file imports: vscode's `ComponentFixtureContext['disposableStore']` + // reached copilot's vendored `DisposableStore`. if (inferredType && ESM_FAMILY.has(ref.language) && inferredType.endsWith(']')) { const element = arrayElementType(inferredType); - const declared = element !== undefined ? { type: element, array: true } : tsIndexedPropertyType(inferredType, ref, context); + const declared = element !== undefined + ? { type: element, array: true, file: undefined } + : tsIndexedPropertyType(inferredType, ref, context); if (!declared || (declared.array && JS_ARRAY_METHODS.has(methodName!))) return null; inferredType = normalizeInferredTypeName(declared.type); if (!inferredType) return null; - if (element === undefined) return resolveMethodOnType(inferredType, methodName!, ref, context, 0.9, 'instance-method'); + if (element === undefined) return resolveMethodOnType(inferredType, methodName!, ref, context, 0.9, 'instance-method', declared.file); } if (inferredType) { // A Go local bound from a type assertion calls a method of the type Go @@ -15747,20 +15752,31 @@ function tsFieldDeclaration( /** * What a TS indexed type (`editor: IChatWidget['inputEditor']`) is: the type * that property is declared with in the project class or interface it - * indexes, and whether the property holds an array of it. Null when the key - * isn't a string literal (`Parameters[0]`, `T[K]`), the indexed - * type isn't the project's, or the property's type isn't written there (a - * getter, `typeof`). - */ -function tsIndexedPropertyType(indexed: string, ref: UnresolvedRef, context: ResolutionContext): { type: string; array: boolean } | null { + * indexes, whether the property holds an array of it, and the file that + * says which class of that name it is. The indexed type is the one the + * call's file imports, when it imports one; the property's type is the one + * the declaring file imports, or else declares (tsImportedTypeFile). Null + * when the key isn't a string literal (`Parameters[0]`, `T[K]`), + * the indexed type isn't the project's, or the property's type isn't + * written there (a getter, `typeof`). + */ +function tsIndexedPropertyType( + indexed: string, + ref: UnresolvedRef, + context: ResolutionContext +): { type: string; array: boolean; file: string } | null { const m = /^([A-Za-z_$][\w$]*)\s*\[\s*(['"])([\w$]+)\2\s*\]$/.exec(indexed); if (!m) return null; - const owners = preferCallSiteFile(context.getNodesByName(m[1]!), ref.filePath).filter((n) => + const imported = tsImportedTypeFile(m[1]!, ref, context); + const owners = preferCallSiteFile(context.getNodesByName(m[1]!), imported ?? ref.filePath).filter((n) => (n.kind === 'class' || n.kind === 'interface' || n.kind === 'type_alias' || n.kind === 'component') && sameLanguageFamily(n.language, ref.language)); for (const owner of owners) { const decl = tsFieldDeclaration(owner, m[3]!, context); - if (decl) return decl.valueType ? null : { type: decl.type, array: decl.array === true }; + if (!decl) continue; + if (decl.valueType) return null; + const at = owner.filePath === ref.filePath ? ref : { ...ref, filePath: owner.filePath, language: owner.language }; + return { type: decl.type, array: decl.array === true, file: tsImportedTypeFile(decl.type, at, context) ?? owner.filePath }; } return null; } From a2e70eedd2811ae0b2d19d8f4a058c7d99b9a97d Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 18 Oct 2026 22:03:31 -0500 Subject: [PATCH 7/9] fix(ts): an indexed type whose property isn't typed reads as before An indexed type whose property's type isn't written in the indexed type, a getter's or a `typeof`, or whose key isn't a name, is read as the type it indexes, as it was before. The nearest line declaring a name may be a member of another variable's type: vscode's `let mockPty: { onCommandExecuted: Emitter['event'] }` sits above calls on the local `onCommandExecuted`, an `Emitter`, whose `fire` they reach. Co-Authored-By: Claude Opus 5.5 --- .../ts-array-annotated-receivers.test.ts | 27 ++++++++++++ src/resolution/name-matcher.ts | 43 +++++++++++-------- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/__tests__/ts-array-annotated-receivers.test.ts b/__tests__/ts-array-annotated-receivers.test.ts index ff278c1d4..149f70e73 100644 --- a/__tests__/ts-array-annotated-receivers.test.ts +++ b/__tests__/ts-array-annotated-receivers.test.ts @@ -263,6 +263,33 @@ describe("a call on a TypeScript local annotated as one of a type's properties", ]); }, 60_000); + it("reads one whose property's type isn't written there, a getter's, as the type it indexes, as before", async () => { + // vscode's `let mockPty: { onCommandExecuted: Emitter['event'] }` + // sits nearer `onCommandExecuted.fire(…)` than the local's own `let + // onCommandExecuted: Emitter`; `event` is a getter of `Emitter`. + const calls = await callsOf({ + ...CLASSES, + 'src/emitter.ts': + 'export class Emitter {\n' + + ' get event() { return (listener: (e: T) => void) => listener; }\n' + + ' fire(value: T) { return value; }\n' + + '}\n', + 'src/use.test.ts': + "import { Emitter } from './emitter';\n" + + 'declare function suite(name: string, body: () => void): void;\n' + + "suite('pty', () => {\n" + + ' let onDone: Emitter;\n' + + ' let pty: {\n' + + " onDone: Emitter['event'];\n" + + ' };\n' + + " suite('fires', () => {\n" + + ' onDone.fire(1);\n' + + ' });\n' + + '});\n', + }); + expect(calls.filter((c) => c.startsWith('onDone.'))).toEqual(['onDone.fire -> Emitter::fire']); + }, 60_000); + it("is not matched to a method by the call's name when the property's type isn't written in the project", async () => { // vscode's `invocation: Parameters[0]`: // left untyped, the receiver's name alone would pick `Logger.info`. diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 3fb1b8c14..f12b0a51a 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -14265,20 +14265,29 @@ export function matchMethodCall( // vscode's `ISessionChatPillVisibilityService.isVisible` through // `['pullRequests']`. That type decides alone, as a `this.` // call's does (matchTsThisFieldCall): the method is its, or nothing the - // method's name would guess, as for a property whose type isn't written - // in the project (`Parameters[0]`, a getter). Its class is the - // one the declaring file imports, as a typed local's is the one the - // call's file imports: vscode's `ComponentFixtureContext['disposableStore']` - // reached copilot's vendored `DisposableStore`. + // method's name would guess. Its class is the one the declaring file + // imports, as a typed local's is the one the call's file imports: + // vscode's `ComponentFixtureContext['disposableStore']` reached + // copilot's vendored `DisposableStore`. A property whose type isn't + // written there (a getter like `Emitter`'s `event`, `typeof`), or a key + // that isn't a name (`Parameters[0]`), says nothing new, and + // is read as the type it indexes, as before: the nearest line declaring + // the name may be another variable's type's member, as vscode's `let + // mockPty: { onCommandExecuted: Emitter['event'] }` is above calls on + // the local `onCommandExecuted`, an `Emitter`. if (inferredType && ESM_FAMILY.has(ref.language) && inferredType.endsWith(']')) { const element = arrayElementType(inferredType); const declared = element !== undefined ? { type: element, array: true, file: undefined } : tsIndexedPropertyType(inferredType, ref, context); - if (!declared || (declared.array && JS_ARRAY_METHODS.has(methodName!))) return null; - inferredType = normalizeInferredTypeName(declared.type); - if (!inferredType) return null; - if (element === undefined) return resolveMethodOnType(inferredType, methodName!, ref, context, 0.9, 'instance-method', declared.file); + if (!declared) { + inferredType = inferredType.replace(/\s*\[[^\]]*\]$/, ''); + } else { + if (declared.array && JS_ARRAY_METHODS.has(methodName!)) return null; + inferredType = normalizeInferredTypeName(declared.type); + if (!inferredType) return null; + if (element === undefined) return resolveMethodOnType(inferredType, methodName!, ref, context, 0.9, 'instance-method', declared.file); + } } if (inferredType) { // A Go local bound from a type assertion calls a method of the type Go @@ -15755,18 +15764,18 @@ function tsFieldDeclaration( * indexes, whether the property holds an array of it, and the file that * says which class of that name it is. The indexed type is the one the * call's file imports, when it imports one; the property's type is the one - * the declaring file imports, or else declares (tsImportedTypeFile). Null - * when the key isn't a string literal (`Parameters[0]`, `T[K]`), - * the indexed type isn't the project's, or the property's type isn't - * written there (a getter, `typeof`). + * the declaring file imports, or else declares (tsImportedTypeFile). + * Undefined when the key isn't a string literal (`Parameters[0]`, + * `T[K]`), the indexed type isn't the project's, or the property's type + * isn't written there (a getter, `typeof`). */ function tsIndexedPropertyType( indexed: string, ref: UnresolvedRef, context: ResolutionContext -): { type: string; array: boolean; file: string } | null { +): { type: string; array: boolean; file: string } | undefined { const m = /^([A-Za-z_$][\w$]*)\s*\[\s*(['"])([\w$]+)\2\s*\]$/.exec(indexed); - if (!m) return null; + if (!m) return undefined; const imported = tsImportedTypeFile(m[1]!, ref, context); const owners = preferCallSiteFile(context.getNodesByName(m[1]!), imported ?? ref.filePath).filter((n) => (n.kind === 'class' || n.kind === 'interface' || n.kind === 'type_alias' || n.kind === 'component') && @@ -15774,11 +15783,11 @@ function tsIndexedPropertyType( for (const owner of owners) { const decl = tsFieldDeclaration(owner, m[3]!, context); if (!decl) continue; - if (decl.valueType) return null; + if (decl.valueType) return undefined; const at = owner.filePath === ref.filePath ? ref : { ...ref, filePath: owner.filePath, language: owner.language }; return { type: decl.type, array: decl.array === true, file: tsImportedTypeFile(decl.type, at, context) ?? owner.filePath }; } - return null; + return undefined; } /** From 46ac3fdad4652d7b14dee140bac7bd7ef74266b0 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 18 Oct 2026 22:05:49 -0500 Subject: [PATCH 8/9] docs(changelog): an indexed property's class follows imports; getters as before Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf1183937..23105eb67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,7 +82,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph sync` and the file watcher now also link a call or read on a name a file imports, like `NODE_PATH_EXCLUDELIST.includes(path)` after `import { NODE_PATH_EXCLUDELIST } from './constants'`, `store.get(key)` through a default import or `api.get()` through a namespace import, when the module gets the export in a later edit, is added after the importing file was indexed, or comes back after being deleted, in JavaScript, TypeScript, Vue, Svelte and Astro. Before, such a call stayed unlinked until the importing file changed or the project was re-indexed, because sync looked it up only by its method's name, which the module seldom declares, and skipped a name as common as `includes`, `get` or `map` altogether. Re-index after upgrading to pick up links an earlier version missed. - `codegraph sync` and the file watcher now also link a call on a variable or parameter declared with a class a file imports, like `cache.get(key)` after `const cache = new LRUCache(10)` or in a function that takes `cache: LRUCache`, when the class's module gets the class in a later edit, is added after the file was indexed, or comes back after being deleted, in JavaScript and TypeScript. Before, such a call stayed unlinked until its own file changed or the project was re-indexed whenever its method's name, like `get`, was too common for sync to look up. Re-index after upgrading to pick up links an earlier version missed. - In TypeScript and JavaScript, a call of an array method, like `items.find(…)` or `items.push(…)`, on a variable or parameter annotated as an array of a class (`items: Item[]`) no longer links to the class's method of the same name. Before, the annotation was read as the class itself: in vscode, `distinctElements.find(…)` on an `ExplorerItem[]` linked to `ExplorerItem.find`, and `ranges.map(…)` on an `OffsetRange[]` to `OffsetRange.map`, so those methods showed callers they don't have. A call on an element that shares the array's name, as in `d.forEach(d => d.dispose())`, still links to the class. Re-index TypeScript and JavaScript projects after upgrading. -- In TypeScript and JavaScript, a call on a variable or parameter annotated as one of a type's properties, like `editor.executeEdits(…)` with `editor: IChatWidget['inputEditor']`, now links to the method of the type that property is declared with, or to nothing when that type has no such method. Before, the annotation was read as the type it indexes, so in vscode `visibility.isVisible(…)` with `visibility: ISessionChatPillVisibilityService['pullRequests']` linked to the service's own `isVisible`, and a call that type didn't have was matched by the method's name alone: `chatModel.onDidChange(…)` with `chatModel: IInlineChatSession['chatModel']` reached a folding model's `onDidChange`. A property declared as an array follows the same rules as an array annotation. Re-index TypeScript and JavaScript projects after upgrading. +- In TypeScript and JavaScript, a call on a variable or parameter annotated as one of a type's properties, like `editor.executeEdits(…)` with `editor: IChatWidget['inputEditor']`, now links to the method of the type that property is declared with, or to nothing when that type has no such method. That type is the class the file declaring the property imports or declares, and the indexed type itself the one the call's file imports, when several classes share a name. Before, the annotation was read as the type it indexes, so in vscode `visibility.isVisible(…)` with `visibility: ISessionChatPillVisibilityService['pullRequests']` linked to the service's own `isVisible`, and a call that type didn't have was matched by the method's name alone: `chatModel.onDidChange(…)` with `chatModel: IInlineChatSession['chatModel']` reached a folding model's `onDidChange`. A property declared as an array follows the same rules as an array annotation, and one whose type isn't written there, like a getter, is read as the type it indexes, as before. Re-index TypeScript and JavaScript projects after upgrading. - `codegraph sync` and the file watcher now do the same for a Python import under a name of its own, like `from .models import User as UserModel`, `from . import helpers as h` or `from app.pkg import sub as subpkg`: when the module or package is added after the importing file was indexed, comes back after being deleted, or gets the imported name in a later edit, the import and every use of the alias, such as a call, a base class or the class passed as a value, now link to it. Before, they stayed unlinked until the importing file changed or the project was re-indexed. Re-index Python projects after upgrading to pick up links an earlier version missed. - `codegraph sync` and the file watcher now also link a Python import of a name a package re-exports, like `from fastapi import FastAPI` through `fastapi/__init__.py`, when that `__init__.py` is added after the importing files were indexed or gains the re-export in a later edit. Before, calls through the name stayed unlinked until each importing file changed or the project was re-indexed. Large projects gain the most: sync also used to skip, in any language, a module or package that more than 500 references were waiting for, and now restoring Django's `django/urls/__init__.py` relinks every file that imports `path` from it. Re-index Python projects after upgrading to pick up links an earlier version missed. - `codegraph sync` and the file watcher now update the files that import through a module that re-exports what it imports, like a package's `__init__.py` or an `index.ts` barrel, when that module is deleted or added, and in Python also the files that reach it through another package re-exporting it. Before, deleting `shop/__init__.py` left every `from shop import Order` linked to the class it re-exported, and deleting `components/index.ts` left every `import { Button } from './components'` linked, until each importing file changed or the project was re-indexed. When such a module came back, the imports stayed linked the way they were while it was missing, and in a large Python project an import of a module whose name is as common as `models` or `test`, like `from django.test import TestCase`, stayed unlinked. Python, JavaScript, TypeScript, Vue, Svelte and Astro. From 25d71f5bdde58467bf03fd0a0b378ef71b6ee9e2 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 18 Oct 2026 22:41:10 -0500 Subject: [PATCH 9/9] fix(ts): a property typed by the class's type parameter is unread The field reader takes `Emitter`'s `fire(event: T)` for its `event`, so `Emitter['event']` read as `T` and decided alone, and vscode's `onCommandExecuted.fire(...)` lost its link to `Emitter.fire`. A type parameter of the indexed class names no type, so the annotation reads as the type it indexes, as before. Co-Authored-By: Claude Opus 5.5 --- .../ts-array-annotated-receivers.test.ts | 5 ++-- src/resolution/name-matcher.ts | 29 ++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/__tests__/ts-array-annotated-receivers.test.ts b/__tests__/ts-array-annotated-receivers.test.ts index 149f70e73..cd3aabfdb 100644 --- a/__tests__/ts-array-annotated-receivers.test.ts +++ b/__tests__/ts-array-annotated-receivers.test.ts @@ -266,13 +266,14 @@ describe("a call on a TypeScript local annotated as one of a type's properties", it("reads one whose property's type isn't written there, a getter's, as the type it indexes, as before", async () => { // vscode's `let mockPty: { onCommandExecuted: Emitter['event'] }` // sits nearer `onCommandExecuted.fire(…)` than the local's own `let - // onCommandExecuted: Emitter`; `event` is a getter of `Emitter`. + // onCommandExecuted: Emitter`; `event` is a getter of `Emitter`, and + // the `event: T` of its `fire(event: T)` names no type. const calls = await callsOf({ ...CLASSES, 'src/emitter.ts': 'export class Emitter {\n' + ' get event() { return (listener: (e: T) => void) => listener; }\n' + - ' fire(value: T) { return value; }\n' + + ' fire(event: T) { return event; }\n' + '}\n', 'src/use.test.ts': "import { Emitter } from './emitter';\n" + diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index f12b0a51a..fa463210e 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -15783,13 +15783,40 @@ function tsIndexedPropertyType( for (const owner of owners) { const decl = tsFieldDeclaration(owner, m[3]!, context); if (!decl) continue; - if (decl.valueType) return undefined; + // A type parameter of the class names no type: `Emitter`'s + // `fire(event: T)`, which the reader takes for its `event`. + if (decl.valueType || tsClassTypeParameter(owner, decl.type, context)) return undefined; const at = owner.filePath === ref.filePath ? ref : { ...ref, filePath: owner.filePath, language: owner.language }; return { type: decl.type, array: decl.array === true, file: tsImportedTypeFile(decl.type, at, context) ?? owner.filePath }; } return undefined; } +/** Is `name` one of the type parameters the head of the TS class or interface `cls` declares (`class Emitter`)? */ +function tsClassTypeParameter(cls: Node, name: string, context: ResolutionContext): boolean { + const lines = context.getFileLines?.(cls.filePath) ?? context.readFile(cls.filePath)?.split(/\r?\n/) ?? []; + const head = lines.slice(cls.startLine - 1, cls.startLine + 2).join(' ').split('{')[0]!; + const open = new RegExp(`\\b${cls.name.replace(/\$/g, '\\$')}\\s*<`).exec(head); + if (!open) return false; + // The parameters between the `<` after the name and its own `>`, split + // at their top level: `T`, `K extends keyof T = never`. + let depth = 0; + let from = open.index + open[0].length; + for (let i = from; i < head.length; i++) { + const c = head[i]; + if (c === '<' || c === '(' || c === '[' || c === '{') depth++; + else if (c === ')' || c === ']' || c === '}') depth--; + else if (c === '>' && head[i - 1] !== '=') { + if (depth === 0) return head.slice(from, i).trim().replace(/^(?:in|out|const)\s+/, '').split(/[\s=]/)[0] === name; + depth--; + } else if (c === ',' && depth === 0) { + if (head.slice(from, i).trim().replace(/^(?:in|out|const)\s+/, '').split(/[\s=]/)[0] === name) return true; + from = i + 1; + } + } + return false; +} + /** * Resolve a TS/JS `this..()` call (#1496) through the field's * declared type, read off the ENCLOSING class's own declaration lines: