From 34b17f6b9d58d4994479b201be2c7b3fd989ec7b Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Mon, 19 Oct 2026 05:38:10 +0000 Subject: [PATCH 001/354] fix(ts): a local annotated as one of a type's properties has that property's type (#2640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A local or parameter annotated as one of a type's properties, like `editor: IChatWidget['inputEditor']`, was read as the type it indexes, so its calls reached that type's method of the same name or a guess by the method's name: on vscode, `visibility.isVisible(…)` through `ISessionChatPillVisibilityService['pullRequests']` reached the service's own `isVisible`, and `chatModel.onDidChange(…)` a folding model's. The property's declared type now decides alone, read off the indexed type the call's file imports, with its class the one the declaring file imports or declares, and an array property follows the array rules. A property whose type isn't written there (a getter's, a `typeof`, a type parameter of the class) or a key that isn't a name still reads as the type it indexes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../ts-array-annotated-receivers.test.ts | 178 +++++++++++++++++- src/resolution/name-matcher.ts | 112 +++++++++-- 3 files changed, 278 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e79cd4207..23105eb67f 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, 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. diff --git a/__tests__/ts-array-annotated-receivers.test.ts b/__tests__/ts-array-annotated-receivers.test.ts index 70982b9687..cd3aabfdb4 100644 --- a/__tests__/ts-array-annotated-receivers.test.ts +++ b/__tests__/ts-array-annotated-receivers.test.ts @@ -9,6 +9,13 @@ * `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 (`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'; @@ -27,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)) { @@ -39,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(); } @@ -133,3 +146,164 @@ 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 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 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({ + ...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("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("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`, 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(event: T) { return event; }\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`. + 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/name-matcher.ts b/src/resolution/name-matcher.ts index 5bb2175e87..fa463210e5 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -13112,7 +13112,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': @@ -14259,12 +14259,35 @@ 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. 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']`. That type decides alone, as a `this.` + // call's does (matchTsThisFieldCall): the method is its, or nothing the + // 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); - if (element !== undefined && JS_ARRAY_METHODS.has(methodName!)) return null; - inferredType = element ?? inferredType.replace(/\s*\[[^\]]*\]$/, ''); + const declared = element !== undefined + ? { type: element, array: true, file: undefined } + : tsIndexedPropertyType(inferredType, ref, context); + 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 @@ -15558,7 +15581,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[]; @@ -15612,7 +15643,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; @@ -15624,7 +15655,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; @@ -15687,7 +15718,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(); @@ -15696,7 +15727,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 @@ -15718,7 +15749,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; } } @@ -15727,6 +15758,65 @@ 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, 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). + * 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 } | undefined { + const m = /^([A-Za-z_$][\w$]*)\s*\[\s*(['"])([\w$]+)\2\s*\]$/.exec(indexed); + 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') && + sameLanguageFamily(n.language, ref.language)); + for (const owner of owners) { + const decl = tsFieldDeclaration(owner, m[3]!, context); + if (!decl) continue; + // 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: From b860b214e5d6affbd1f88ba896472bb47f8b5f2d Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Mon, 19 Oct 2026 05:38:48 +0000 Subject: [PATCH 002/354] fix(ts): a call on an imported value links to the class its file imports (#2644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A call on a value another file exports, like `store.add(…)` after `import { store } from './state'` with `export const store = new Store()` there, read the type off the value's declaration but broke a tie between same-named classes by the calling file, which declares neither, and then by index order: on vscode, `MenuRegistry`, `FileAccess`, `extUri` and `errorHandler` calls reached copilot's vendored copies or a test fixture. The value's file now says which class it is, the one it imports or declares, in Vue, Svelte and Astro components too. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/ts-imported-type-receivers.test.ts | 55 ++++++++++++++++++++ src/resolution/import-resolver.ts | 8 ++- src/resolution/name-matcher.ts | 17 ++++-- 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23105eb67f..a570451ca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -165,6 +165,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 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. +- In TypeScript and JavaScript, a call on a value another file exports, like `store.add(…)` after `import { store } from './state'` with `export const store = new Store()` there, now links to the method of the class that file imports or declares when other classes in the project share its name, in Vue, Svelte and Astro components too. Before, it linked to whichever of them was indexed first: in vscode, calls on `FileAccess`, `extUri`, `errorHandler` and `MenuRegistry` reached the copies the Copilot extension vendors or one of its test fixtures, and in folo the mobile app's `settingSyncQueue.init()` reached the desktop app's `SettingSyncQueue`. Re-index TypeScript and JavaScript projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/ts-imported-type-receivers.test.ts b/__tests__/ts-imported-type-receivers.test.ts index eabe582d22..0f2064a868 100644 --- a/__tests__/ts-imported-type-receivers.test.ts +++ b/__tests__/ts-imported-type-receivers.test.ts @@ -178,3 +178,58 @@ 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 call on a value another file exports, whose class that file imports', () => { + it("links to the class the value's file imports, whichever of the same-named classes it is", async () => { + // `import { store } from './state'; store.add(…)` with + // `export const store = new Store()` in `state.ts`. + const calls = await callsOf({ + ...STORES, + 'src/state-one.ts': "import { Store } from './one/store';\nexport const store = new Store();\n", + 'src/state-two.ts': "import { Store } from './two/store';\nexport const store: Store = new Store();\n", + 'src/use-one.ts': "import { store } from './state-one';\nexport function a() {\n return store.add(1);\n}\n", + 'src/use-two.ts': "import { store } from './state-two';\nexport function b() {\n return store.add(2);\n}\n", + }); + expect(calls.filter((c) => c.includes('store.add'))).toEqual([ + 'src/use-one.ts: store.add -> src/one/store.ts::Store::add', + 'src/use-two.ts: store.add -> src/two/store.ts::Store::add', + ]); + }, 60_000); + + it('links to the class the value\'s own file declares', async () => { + const own = (n: number) => + `export class Store {\n add(v: number) { return v * ${n}; }\n}\n` + + 'export const store = new Store();\n'; + const calls = await callsOf({ + ...STORES, + 'src/own-a.ts': own(3), + 'src/own-b.ts': own(4), + 'src/use-a.ts': "import { store } from './own-a';\nexport function a() {\n return store.add(1);\n}\n", + 'src/use-b.ts': "import { store } from './own-b';\nexport function b() {\n return store.add(2);\n}\n", + }); + expect(calls.filter((c) => c.includes('store.add'))).toEqual([ + 'src/use-a.ts: store.add -> src/own-a.ts::Store::add', + 'src/use-b.ts: store.add -> src/own-b.ts::Store::add', + ]); + }, 60_000); +}); + +describe('a call in a Vue component on a value another file exports', () => { + it("links to the class the value's file imports", async () => { + const vue = (state: string) => + '\n' + + `\n`; + const calls = await callsOf({ + ...STORES, + 'package.json': JSON.stringify({ name: 'app', dependencies: { vue: '^3.4.0' } }), + 'src/state-one.ts': "import { Store } from './one/store';\nexport const store = new Store();\n", + 'src/state-two.ts': "import { Store } from './two/store';\nexport const store = new Store();\n", + 'src/One.vue': vue('state-one'), + 'src/Two.vue': vue('state-two'), + }); + expect(calls.filter((c) => c.includes('store.add'))).toEqual([ + 'src/One.vue: store.add -> src/one/store.ts::Store::add', + 'src/Two.vue: store.add -> src/two/store.ts::Store::add', + ]); + }, 60_000); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 9fa572206f..3cd62cc382 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -24,6 +24,7 @@ import { goRefQualifier, inferLocalReceiverType, arrayElementType, + tsImportedTypeFile, } from './name-matcher'; import { JS_ARRAY_METHODS } from './js-builtins'; @@ -2959,7 +2960,12 @@ function resolveImportedInstanceMember( if (!m || !m[1]) continue; const typeName = normalizeInferredTypeName(m[1]); if (!typeName) continue; - const resolved = resolveMethodOnType(typeName, member, ref, context, 0.85, 'instance-method'); + // Several classes may share the name. The value's file says which one: + // the class it imports under that name, or else the one it declares. + const declaring = ESM_IMPORT_LANGUAGES.has(value.language) + ? tsImportedTypeFile(m[1], { ...ref, filePath: value.filePath, language: value.language }, context) ?? value.filePath + : undefined; + const resolved = resolveMethodOnType(typeName, member, ref, context, 0.85, 'instance-method', declaring); if (resolved) return resolved; } return null; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index fa463210e5..ff9b61a6fc 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1366,6 +1366,13 @@ function lexicalScopeOf(candidate: Node, context: ResolutionContext): { start: n /** Languages whose module boundary is `import`/`export` (or CommonJS). */ const ESM_FAMILY = new Set(['typescript', 'tsx', 'javascript', 'jsx', 'arkts']); +/** + * The ES-module family with single-file components' script blocks: a file + * declaring the class an import names (tsImportedTypeFile) is the same + * signal in each. + */ +const ES_IMPORT_LANGUAGES = new Set([...ESM_FAMILY, 'vue', 'svelte', 'astro']); + /** * A line-initial `import` statement — the marker that a JS/TS file is a MODULE * rather than a classic script. Line-anchored and followed by a name, brace, @@ -8960,7 +8967,9 @@ export function resolveMethodOnType( * For Go it is the project-relative directory of the package that * declares `typeName`, when that is known (resolveGoMethodInPackage). * For TS/JS it is the file declaring the class the caller's file - * imports under that name (tsImportedTypeFile). + * imports under that name (tsImportedTypeFile), or, for a type written + * in another file (an imported value's), the class that file imports or + * declares. */ preferredFqn?: string, /** Recursion guard for the supertype/conformance walk. */ @@ -9026,11 +9035,11 @@ export function resolveMethodOnType( return null; } - if (matches.length > 1 && preferredFqn && ESM_FAMILY.has(ref.language)) { + if (matches.length > 1 && preferredFqn && ES_IMPORT_LANGUAGES.has(ref.language)) { const chosen = matches.find((m) => m.filePath === preferredFqn); if (chosen) return { original: ref, targetNodeId: chosen.id, confidence, resolvedBy }; } - if (matches.length > 1 && preferredFqn && ref.language !== 'go' && !ESM_FAMILY.has(ref.language)) { + if (matches.length > 1 && preferredFqn && ref.language !== 'go' && !ES_IMPORT_LANGUAGES.has(ref.language)) { const ext = ref.language === 'kotlin' ? '.kt' : '.java'; const fqnPath = preferredFqn.replace(/\./g, '/') + ext; const chosen = matches.find((m) => { @@ -12775,7 +12784,7 @@ const TS_IMPORTED_TYPE_FILES = new WeakMap Date: Mon, 19 Oct 2026 05:39:23 +0000 Subject: [PATCH 003/354] fix(ts): a this. call finds a field a base class declares (#2645) A field the calling class doesn't declare was read off any same-named class elsewhere, never off the classes it extends. It is now looked up on the calling class's base classes, each the class its subclass's file imports or declares, and a same-named class elsewhere is read last, only for a type the call's file imports or declares. A type a file imports from a package outside the project now links to nothing in the project, unless a project file declares the package with `declare module`, Flow libdefs included: microsoft-authentication's `TelemetryReporter` from `@vscode/extension-telemetry` reached another extension's interface of that name. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/ts-this-field-bases.test.ts | 190 ++++++++++++++++++ src/resolution/name-matcher.ts | 277 ++++++++++++++++++++------ 3 files changed, 410 insertions(+), 58 deletions(-) create mode 100644 __tests__/ts-this-field-bases.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a570451ca8..3175d4185c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -164,6 +164,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. - In TypeScript and JavaScript, a call on a value another file exports, like `store.add(…)` after `import { store } from './state'` with `export const store = new Store()` there, now links to the method of the class that file imports or declares when other classes in the project share its name, in Vue, Svelte and Astro components too. Before, it linked to whichever of them was indexed first: in vscode, calls on `FileAccess`, `extUri`, `errorHandler` and `MenuRegistry` reached the copies the Copilot extension vendors or one of its test fixtures, and in folo the mobile app's `settingSyncQueue.init()` reached the desktop app's `SettingSyncQueue`. Re-index TypeScript and JavaScript projects after upgrading. diff --git a/__tests__/ts-this-field-bases.test.ts b/__tests__/ts-this-field-bases.test.ts new file mode 100644 index 0000000000..55130995f7 --- /dev/null +++ b/__tests__/ts-this-field-bases.test.ts @@ -0,0 +1,190 @@ +/** + * 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); + + 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); + + 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 ff9b61a6fc..38ca38cdfe 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -12976,6 +12976,8 @@ 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); + TS_AMBIENT_MODULE_FILES.delete(context); C_STATIC_MEMO.delete(context); CPP_TYPE_DECLS.delete(context); CPP_INCLUDE_LINES.delete(context); @@ -14309,7 +14311,7 @@ export function matchMethodCall( // 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) @@ -14319,6 +14321,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 = nmTimedT('mc-tsoutside', ref, () => tsOutsideTypeDeclaration(decl.raw ?? inferredType!, inferredType!, ref, context)); + if (outside === null) return null; + importedFqn = outside; + } const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType( inferredType, methodName!, @@ -15828,13 +15838,14 @@ function tsClassTypeParameter(cls: Node, name: string, context: ResolutionContex /** * 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, @@ -15856,62 +15867,212 @@ function matchTsThisFieldCall( const owners = preferCallSiteFile(context.getNodesByName(owner), ref.filePath).filter( (n) => (n.kind === 'class' || n.kind === 'component') && sameLanguageFamily(n.language, ref.language) ); - for (const cls of owners) { - 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; + // 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`. + 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; } - // `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 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. + 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 resolveMethodOnType(typeName, methodName, ref, context, 0.85, 'instance-method', imported); + return null; + }); + return namesake ? tsFieldCall(namesake, null, methodName, ref, context) : 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; +} + +/** 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` and react-native's Flow + * `flow-typed/npm/*.js` libdefs do; a `node:` import is its bare module's), + * 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 pkg = binding.source.replace(/^node:/, '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const declares = new RegExp(`\\bdeclare\\s+module\\s+(['"])(?:node:)?${pkg}\\1`); + for (const n of context.getNodesByName(typeName)) { + if (!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; + 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; + // 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 }; + let imported = tsImportedTypeFile(decl.type, at, context); + if (imported === undefined) { + const outside = nmTimedT('mc-tsoutside', ref, () => tsOutsideTypeDeclaration(decl.type, typeName, at, context)); + if (outside === null) return null; + imported = outside ?? (at === ref ? undefined : at.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 6071c9e4879949f92540ccd90fa581cd52010907 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Mon, 19 Oct 2026 07:44:00 +0000 Subject: [PATCH 004/354] fix(ts): a field set through a wrapper has the wrapped class's type (#2647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `private readonly _onDidChange = this._register(new Emitter())` and `this._store.add(new Emitter())` gave the field no type, so its `fire()` calls linked nowhere. The field reader now reads `new X(` handed to `this.(…)` or `this..(…)` when that method's own declaration returns its argument (`(o: T): T`, T its own type parameter) and the call ends the statement. The end-of-statement check trims what follows the wrapper's `)`: a CRLF checkout's lines keep their `\r`, which a `$` anchor never matched. The field walk and the wrapper lookup now go ten classes up a class's `extends` chain instead of four: vscode's editors inherit `_register` and `_store` from `Disposable` up to eight classes above them. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/ts-this-field-bases.test.ts | 23 +- .../ts-wrapped-field-initializers.test.ts | 207 +++++++++++++++++ src/resolution/name-matcher.ts | 219 ++++++++++++++++-- 4 files changed, 422 insertions(+), 28 deletions(-) create mode 100644 __tests__/ts-wrapped-field-initializers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3175d4185c..a1c48e42d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -167,6 +167,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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. - In TypeScript and JavaScript, a call on a value another file exports, like `store.add(…)` after `import { store } from './state'` with `export const store = new Store()` there, now links to the method of the class that file imports or declares when other classes in the project share its name, in Vue, Svelte and Astro components too. Before, it linked to whichever of them was indexed first: in vscode, calls on `FileAccess`, `extUri`, `errorHandler` and `MenuRegistry` reached the copies the Copilot extension vendors or one of its test fixtures, and in folo the mobile app's `settingSyncQueue.init()` reached the desktop app's `SettingSyncQueue`. Re-index TypeScript and JavaScript projects after upgrading. +- In TypeScript, a call through a field that a wrapper sets up, like `this._onDidChange.fire()` after `private readonly _onDidChange = this._register(new Emitter())` or `= this._store.add(new Emitter())`, now links to the method of the class handed to the wrapper, also in a class that inherits the field. Before, such a field had no type, so its calls linked nowhere, and that is one of the most common ways vscode declares its event emitters and disposables. A wrapper counts only when its own declaration hands back what it is given, like `_register(o: T): T`, so a field set from a wrapper that returns something else, or from a member of what it returns (`.event`), is never taken for the class. A call through an inherited field is also found now when the field is declared further up the class hierarchy than before. Re-index TypeScript projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/ts-this-field-bases.test.ts b/__tests__/ts-this-field-bases.test.ts index 55130995f7..b62b495238 100644 --- a/__tests__/ts-this-field-bases.test.ts +++ b/__tests__/ts-this-field-bases.test.ts @@ -97,6 +97,23 @@ describe('a this. call on a field a class it extends declares', () => { 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('reads it off a base many classes up, as vscode editors inherit `_store` from `Disposable`', async () => { + const files: Record = { + ...STORES, + 'src/base.ts': "import { Store } from './two/store';\nexport class Base {\n protected readonly store = new Store();\n}\n", + }; + let parent = ['Base', './base']; + for (const name of ['L1', 'L2', 'L3', 'L4', 'L5']) { + files[`src/${name.toLowerCase()}.ts`] = `import { ${parent[0]} } from '${parent[1]}';\nexport class ${name} extends ${parent[0]} {}\n`; + parent = [name, `./${name.toLowerCase()}`]; + } + const calls = await callsOf({ + ...files, + 'src/child.ts': "import { L5 } from './l5';\nexport class Child extends L5 {\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("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, @@ -109,9 +126,9 @@ describe('a this. call on a field a class it extends declares', () => { }, 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. + // The calling class's own declaration goes through a wrapper the reader + // can't vouch for (`wrap` declares no return type); the same-named + // class elsewhere declares the field. const calls = await callsOf({ ...STORES, 'src/app/mock.ts': diff --git a/__tests__/ts-wrapped-field-initializers.test.ts b/__tests__/ts-wrapped-field-initializers.test.ts new file mode 100644 index 0000000000..609021b54e --- /dev/null +++ b/__tests__/ts-wrapped-field-initializers.test.ts @@ -0,0 +1,207 @@ +/** + * A TypeScript `this.` call on a field initialized through a wrapper + * that hands back its argument — vscode's `this._register(new Emitter())` + * and `this._store.add(new Emitter())` — links through the wrapped class. + * + * The field reader knew `field: T`, `field: typeof X` and `field = new X()`, + * so vscode's most common declaration, `private readonly _onDidChange = + * this._register(new Emitter())`, gave the field no type and its + * `fire()` calls linked nowhere. A wrapper counts only when its own + * declaration returns its argument (`_register(o: T): + * T`) and its call ends the statement: `this.box(value)` may return a box of + * it, and `this._register(new DomEmitter(…)).event` a member of it. + */ +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-wrapped-fields-')); + 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(); +} + +/** + * vscode's lifecycle wrappers, the `Emitter` the app imports, and a decoy + * `Emitter` nearer the app (`src/app/legacy`) that a guess by method name or + * folder would pick. + */ +const LIFECYCLE = { + 'package.json': JSON.stringify({ name: 'app' }), + 'src/base/lifecycle.ts': + 'export interface IDisposable {\n dispose(): void;\n}\n' + + 'export class DisposableStore implements IDisposable {\n' + + ' private readonly items: IDisposable[] = [];\n' + + ' add(o: T): T {\n this.items.push(o);\n return o;\n }\n' + + ' dispose() {}\n' + + '}\n' + + 'export abstract class Disposable implements IDisposable {\n' + + ' protected readonly _store = new DisposableStore();\n' + + ' protected _register(o: T): T {\n return this._store.add(o);\n }\n' + + ' dispose() {}\n' + + '}\n', + 'src/base/event.ts': 'export class Emitter {\n fire(event?: T) {\n return event;\n }\n dispose() {}\n}\n', + 'src/app/legacy/event.ts': 'export class Emitter {\n fire() {\n return 0;\n }\n dispose() {}\n}\n', +}; + +const IMPORTS = "import { Disposable, DisposableStore } from '../base/lifecycle';\nimport { Emitter } from '../base/event';\n"; + +describe('a this. call on a field a wrapper initializes', () => { + it('reads `this._register(new X())` as an X when the inherited `_register` returns its argument', async () => { + const calls = await callsOf({ + ...LIFECYCLE, + 'src/app/model.ts': + IMPORTS + + 'export class Model extends Disposable {\n' + + ' private readonly _onDidChange = this._register(new Emitter());\n' + + ' private readonly _onDidMove = this._register(new Emitter(\n' + + ' ));\n' + + ' private readonly _onDidOpen;\n' + + ' constructor() {\n super();\n this._onDidOpen = this._register(new Emitter());\n }\n' + + " change() {\n this._onDidChange.fire('a');\n this._onDidMove.fire(1);\n this._onDidOpen.fire();\n }\n" + + '}\n', + }); + expect(calls.filter((c) => c.includes('.fire'))).toEqual([ + 'src/app/model.ts: this._onDidChange.fire -> src/base/event.ts::Emitter::fire', + 'src/app/model.ts: this._onDidMove.fire -> src/base/event.ts::Emitter::fire', + 'src/app/model.ts: this._onDidOpen.fire -> src/base/event.ts::Emitter::fire', + ]); + }, 60_000); + + it('reads `this..add(new X())` through the class the store field is declared as', async () => { + const calls = await callsOf({ + ...LIFECYCLE, + 'src/app/view.ts': + IMPORTS + + 'export class View extends Disposable {\n' + + ' private readonly _disposables = new DisposableStore();\n' + + ' private readonly _onDidShow = this._store.add(new Emitter());\n' + + ' private readonly _onDidHide = this._disposables.add(new Emitter());\n' + + ' show() {\n this._onDidShow.fire();\n }\n' + + ' hide() {\n this._onDidHide.fire();\n }\n' + + '}\n', + }); + expect(calls.filter((c) => c.includes('.fire'))).toEqual([ + 'src/app/view.ts: this._onDidHide.fire -> src/base/event.ts::Emitter::fire', + 'src/app/view.ts: this._onDidShow.fire -> src/base/event.ts::Emitter::fire', + ]); + }, 60_000); + + it('finds the wrapper and the store as many classes up as vscode editors inherit them from `Disposable`', async () => { + // `… extends EditorPane extends Composite extends Component extends Themable extends Disposable` + const chain = ['Themable', 'Component', 'Composite', 'Pane', 'EditorPane']; + const files: Record = { ...LIFECYCLE }; + chain.forEach((name, i) => { + const base = i === 0 ? ['Disposable', '../base/lifecycle'] : [chain[i - 1]!, `./${chain[i - 1]!.toLowerCase()}`]; + files[`src/app/${name.toLowerCase()}.ts`] = `import { ${base[0]} } from '${base[1]}';\nexport class ${name} extends ${base[0]} {}\n`; + }); + const calls = await callsOf({ + ...files, + 'src/app/editor.ts': + "import { EditorPane } from './editorpane';\nimport { Emitter } from '../base/event';\n" + + 'export class Editor extends EditorPane {\n' + + ' private readonly _onDidChange = this._register(new Emitter());\n' + + ' private readonly _onDidShow = this._store.add(new Emitter());\n' + + ' run() {\n this._onDidChange.fire();\n this._onDidShow.fire();\n }\n' + + '}\n', + }); + // `_onDidShow` needs `_store` found on `Disposable` too. + expect(calls.filter((c) => c.includes('.fire'))).toEqual([ + 'src/app/editor.ts: this._onDidChange.fire -> src/base/event.ts::Emitter::fire', + 'src/app/editor.ts: this._onDidShow.fire -> src/base/event.ts::Emitter::fire', + ]); + }, 60_000); + + it('reads them in files with CRLF line endings, as a Windows checkout has', async () => { + const files: Record = { + ...LIFECYCLE, + 'src/app/model.ts': + IMPORTS + + 'class DomEmitter {\n readonly event = (listener: () => void) => listener;\n dispose() {}\n}\n' + + 'export class Model extends Disposable {\n' + + ' private readonly _onDidChange = this._register(new Emitter());\n' + + ' private readonly _onDidShow = this._store.add(new Emitter());\n' + + ' private readonly _onDidHide = this._register(new Emitter())\n' + + ' private readonly onKeyDown = this._register(new DomEmitter()).event;\n' + + " change() {\n this._onDidChange.fire('a');\n this._onDidShow.fire();\n this._onDidHide.fire();\n this.onKeyDown.dispose();\n }\n" + + '}\n', + }; + const calls = await callsOf(Object.fromEntries(Object.entries(files).map(([file, text]) => [file, text.replace(/\n/g, '\r\n')]))); + expect(calls.filter((c) => c.includes('.fire') || c.includes('.dispose'))).toEqual([ + 'src/app/model.ts: this._onDidChange.fire -> src/base/event.ts::Emitter::fire', + 'src/app/model.ts: this._onDidHide.fire -> src/base/event.ts::Emitter::fire', + 'src/app/model.ts: this._onDidShow.fire -> src/base/event.ts::Emitter::fire', + ]); + }, 60_000); + + it("doesn't read a wrapper whose declaration doesn't return its argument", async () => { + const calls = await callsOf({ + ...LIFECYCLE, + 'src/app/box.ts': + IMPORTS + + 'class Box {\n constructor(readonly value: T) {}\n}\n' + + 'class Widget {\n fire() {}\n}\n' + + 'class FancyWidget extends Widget {\n fire() {}\n}\n' + + 'export class Holder extends Disposable {\n' + + ' private readonly _boxed = this.box(new Emitter());\n' + + ' private readonly _kept = this.keep(new Emitter());\n' + + ' private readonly _widget = this.replace(new FancyWidget());\n' + + ' private readonly items = new Set>();\n' + + ' private readonly _set = this.items.add(new Emitter());\n' + + ' private box(value: T): Box {\n return new Box(value);\n }\n' + + ' private keep(value: T) {\n return value;\n }\n' + + // Typed with a class, not a type parameter of its own: another Widget may come back. + ' private replace(widget: Widget): Widget {\n return widget instanceof FancyWidget ? new Widget() : widget;\n }\n' + + " run() {\n this._boxed.fire('a');\n this._kept.fire('b');\n this._widget.fire();\n this._set.fire('c');\n }\n" + + '}\n', + 'src/app/plain.ts': + "import { Emitter } from '../base/event';\n" + + 'export class Plain {\n' + + ' private readonly _onDidChange = this._register(new Emitter());\n' + + ' run() {\n this._onDidChange.fire();\n }\n' + + '}\n', + }); + expect(calls.filter((c) => c.includes('.fire'))).toEqual([]); + }, 60_000); + + it("doesn't read the wrapped class when a member of what the wrapper returns is taken", async () => { + const calls = await callsOf({ + ...LIFECYCLE, + 'src/app/input.ts': + IMPORTS + + 'class DomEmitter {\n readonly event = (listener: () => void) => listener;\n dispose() {}\n}\n' + + 'export class Input extends Disposable {\n' + + ' private readonly onKeyDown = this._register(new DomEmitter()).event;\n' + + ' private readonly onKeyUp = this._register(new DomEmitter())\n .event;\n' + + ' run() {\n this.onKeyDown.dispose();\n this.onKeyUp.dispose();\n }\n' + + '}\n', + }); + expect(calls.filter((c) => c.includes('.dispose'))).toEqual([]); + }, 60_000); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 38ca38cdfe..d5832ec65b 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -13085,6 +13085,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { JS_FN_CODE.delete(context); TS_FIELD_DECL_MEMO.delete(context); TS_CLASS_LINES.delete(context); + TS_PASS_THROUGH_WRAPPERS.delete(context); TARGET_LANGUAGE.delete(context); } @@ -15594,11 +15595,12 @@ function matchRustSelfFieldCall( /** * Per-context memo for matchTsThisFieldCall's declaration scan: `classId\0field` * → the first line of the class that declares the field (as `: typeof X`, - * `: X`, or `= new X`, tried in that order per line) with its captured type, - * or null. The scan depends only on the class and the field, but ran for every - * `this..()` call — re-splitting the file and compiling three - * patterns each time, a third of all method-call matching on vscode. Derived - * from file source: drops with clearNameMatcherMemos. + * `: X`, `= new X`, or `= this.(new X`, tried in that order per + * line) with its captured type, or null. The scan depends only on the class + * and the field, but ran for every `this..()` call — + * re-splitting the file and compiling three 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>(); /** @@ -15607,6 +15609,14 @@ const TS_FIELD_DECL_MEMO = new WeakMap(…)`, or + * `this..(…)` — vscode's `this._register(new Emitter())` and + * `this._store.add(new Emitter())`. + */ +type TsFieldWrapper = { holder?: string; method: string }; +/** A declaration found on one line; a wrapped one names its wrapper and where the wrapper's `(` is. */ +type TsFieldOnLine = TsFieldDecl & { via?: TsFieldWrapper; open?: number }; /** 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. */ @@ -15653,16 +15663,29 @@ const TS_TOKEN_CHAR = /[\w$#]/; * token occurrence of the field. There the run consumes exactly the field (a * shorter prefix cannot continue: every pattern needs `\s`, `?`, `!`, `:` or * `=` next), so each matches exactly where the field's own pattern would — - * without compiling three patterns for every field of every class. + * without compiling four patterns for every field of every class. */ const TS_FIELD_PATTERNS_AT: readonly TsFieldPattern[] = [ { re: /(?(); const TS_FIELD_PATTERNS_CAP = 4096; @@ -15727,6 +15752,13 @@ function tsFieldPatterns(field: string): TsFieldPattern[] { }, // `mailer = new Mailer()` / `this.mailer = new Mailer()` { re: new RegExp(`${fieldStart}${fieldEsc}\\b\\s*=\\s*new\\s+([A-Za-z_$][\\w.$]*)`), valueType: false }, + // `_onDidChange = this._register(new Emitter())`, `x = this._store.add(new X())`: + // an X only when the wrapper returns its argument (tsFieldRead). + { + re: new RegExp(`${fieldStart}${fieldEsc}\\b\\s*=\\s*this\\.(?:([\\w$#]+)\\.)?([\\w$#]+)\\(\\s*new\\s+([A-Za-z_$][\\w.$]*)`), + valueType: false, + wrapped: true, + }, ]; if (TS_FIELD_PATTERNS.size >= TS_FIELD_PATTERNS_CAP) TS_FIELD_PATTERNS.delete(TS_FIELD_PATTERNS.keys().next().value!); TS_FIELD_PATTERNS.set(field, patterns); @@ -15746,6 +15778,8 @@ function tsFieldDeclaration( const key = cls.id + '\0' + field; const hit = memo.get(key); if (hit !== undefined) return hit; + // Unread while it is being read: a wrapper's holder field can lead back here. + memo.set(key, null); let found: TsFieldDecl | null = null; const decl = tsClassDecl(cls, context); if (decl && TS_FIELD_TOKEN.test(field)) { @@ -15756,20 +15790,24 @@ function tsFieldDeclaration( const rows = ++decl.lookups > 1 ? tsClassLinesWithToken(decl, field) : null; const count = rows ? rows.length : decl.lines.length; for (let k = 0; k < count && !found; k++) { - const line = decl.lines[rows ? rows[k]! : k]!; + const row = rows ? rows[k]! : k; + const line = decl.lines[row]!; // Every pattern spells the field literally, so only a line that contains // it can match — and most of a class's lines never mention a given field. - if (line.includes(field)) found = tsFieldOnLine(line, field); + if (line.includes(field)) found = tsFieldRead(tsFieldOnLine(line, field), cls, decl.lines, row, context); } } else if (decl) { const patterns = tsFieldPatterns(field); - scan: for (const line of decl.lines) { + scan: for (let row = 0; row < decl.lines.length; row++) { + const line = decl.lines[row]!; if (!line.includes(field)) continue; - for (const { re, valueType } of patterns) { - const m = line.match(re); - if (!m || !m[1]) continue; - found = { valueType, type: m[1], array: TS_ARRAY_SUFFIX.test(line.slice(m.index! + m[0].length)) }; - break scan; + for (const pattern of patterns) { + const m = line.match(pattern.re); + const onLine = m && tsFieldMatch(pattern, m, line); + if (!onLine) continue; + found = tsFieldRead(onLine, cls, decl.lines, row, context); + if (found) break scan; + continue scan; } } } @@ -15777,6 +15815,136 @@ function tsFieldDeclaration( return found; } +/** + * The declaration `onLine` reads, on line `row` of class `cls`'s `lines`. A + * wrapped `new X(…)` is an X only when the wrapper's call ends the statement + * and the wrapper's declaration returns its argument: `this._register(new + * Emitter())` is an Emitter, but neither `this._register(new DomEmitter(el, + * 'keydown')).event` nor what a wrapper declared `box(value: T): Box` + * returns is. Otherwise the line declares nothing the reader can use, as + * before. + */ +function tsFieldRead(onLine: TsFieldOnLine | null, cls: Node, lines: readonly string[], row: number, context: ResolutionContext): TsFieldDecl | null { + if (!onLine?.via) return onLine; + if (!tsCallEndsStatement(lines, row, onLine.open!) || !tsWrapperReturnsArgument(cls, onLine.via, context)) return null; + return { valueType: onLine.valueType, type: onLine.type, array: onLine.array }; +} + +/** + * Does the call whose `(` is at `open` on `lines[row]` end its statement, so + * that what it returns is what the statement assigns? Nothing but `;` (and + * whatever follows it) may come after its `)`, nor start the next line — + * `.event`, `?.x` or `[0]` would take a member of what it returns. Strings + * are skipped; an unclosed one, or a call still open after 12 lines, isn't + * read. + */ +function tsCallEndsStatement(lines: readonly string[], row: number, open: number): boolean { + let depth = 0; + for (let r = row, from = open; r < lines.length && r < row + 12; r++, from = 0) { + const line = lines[r]!; + for (let i = from; i < line.length; i++) { + const c = line[i]!; + if (c === '"' || c === "'" || c === '`') { + let close = i + 1; + while (close < line.length && line[close] !== c) close += line[close] === '\\' ? 2 : 1; + if (close >= line.length) return false; + i = close; + } else if (c === '(') { + depth++; + } else if (c === ')' && --depth === 0) { + // Trimmed, not matched up to `$`: a CRLF checkout's lines keep their `\r`. + const rest = line.slice(i + 1).trim(); + if (rest) return rest.startsWith(';'); + const next = lines.slice(r + 1, r + 4).find((l) => l.trim()); + return !next || !/^\s*(?:[.?[(!,:|&+*\/%<>=-]|as\b|satisfies\b)/.test(next); + } + } + } + return false; +} + +/** Per-context memo: `\0\0` → tsWrapperReturnsArgument's answer. */ +const TS_PASS_THROUGH_WRAPPERS = new WeakMap>(); + +/** + * Does the wrapper a field initializer of class `cls` calls return its + * argument? `this.` is the method `cls` declares or inherits, and + * `this..` that method of the class the holder field is + * declared as (its file imports or declares it). Each declaration of it that + * the nearest declaring class has must be tsReturnsItsArgument. + */ +function tsWrapperReturnsArgument(cls: Node, via: TsFieldWrapper, context: ResolutionContext): boolean { + let memo = TS_PASS_THROUGH_WRAPPERS.get(context); + if (!memo) { + memo = new Map(); + TS_PASS_THROUGH_WRAPPERS.set(context, memo); + } + const key = `${cls.id}\0${via.holder ?? ''}\0${via.method}`; + const hit = memo.get(key); + if (hit !== undefined) return hit; + const at: UnresolvedRef = { + fromNodeId: cls.id, referenceName: via.method, referenceKind: 'calls', + line: cls.startLine, column: cls.startColumn, filePath: cls.filePath, language: cls.language, + }; + const returns = nmTimedT('mc-thisfield-wrapper', at, () => { + let owner: Node | null = cls; + if (via.holder) { + const held = tsFieldThroughBases(cls, via.holder, at, context, 0); + owner = held && !held.decl.valueType && !held.decl.array ? tsNamedClass(held.decl.type, held.cls, at, context) : null; + } + const methods = owner ? tsClassMethods(owner, via.method, at, context, 0) : []; + return methods.length > 0 && methods.every((m) => tsReturnsItsArgument(m, context)); + }); + memo.set(key, returns); + return returns; +} + +/** + * How many classes up its `extends` chain a TS class's fields and wrapper + * methods are looked for: vscode's editors declare emitters through + * `_register`, which `Disposable` declares up to eight classes above them + * (`… extends EditorPane extends Composite extends Component extends + * Themable extends Disposable`). + */ +const TS_BASE_WALK_DEPTH = 10; + +/** + * The TS methods named `name` that class `cls` declares, or else the nearest + * class it extends that declares one does (each base the class its + * subclass's file names, as tsFieldThroughBases walks them). + */ +function tsClassMethods(cls: Node, name: string, at: UnresolvedRef, context: ResolutionContext, depth: number): Node[] { + const own = context.getNodesInFile(cls.filePath).filter((n) => + n.kind === 'method' && n.name === name && n.startLine >= cls.startLine && n.endLine <= cls.endLine && + n.qualifiedName.slice(0, Math.max(0, n.qualifiedName.lastIndexOf('::'))).split(/::|\./).pop() === cls.name); + if (own.length > 0 || depth >= TS_BASE_WALK_DEPTH) return own; + for (const sup of declaredSupertypes(cls, context)) { + const base = tsNamedClass(sup, cls, at, context); + if (!base || base.id === cls.id) continue; + const found = tsClassMethods(base, name, at, context, depth + 1); + if (found.length > 0) return found; + } + return []; +} + +/** `(o: T): T` as the TS extractor writes a signature: one parameter, of the type returned. */ +const TS_PASS_THROUGH_SIGNATURE = /^\(\s*[\w$]+\s*:\s*([A-Za-z_$][\w$]*)\s*\)\s*:\s*([A-Za-z_$][\w$]*)$/; + +/** + * Does the TS method `fn` return its argument, as far as its declaration + * says: one parameter, typed by a type parameter of the method's own, which + * it returns — vscode's `_register(o: T): T` and + * `DisposableStore.add`? + */ +function tsReturnsItsArgument(fn: Node, context: ResolutionContext): boolean { + const sig = TS_PASS_THROUGH_SIGNATURE.exec(fn.signature?.trim() ?? ''); + if (!sig || sig[1] !== sig[2]) return false; + const lines = context.getFileLines?.(fn.filePath) ?? context.readFile(fn.filePath)?.split(/\r?\n/) ?? []; + const head = lines.slice(fn.startLine - 1, fn.startLine + 2).join(' '); + const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(? { for (const cls of owners) { if (cls.filePath === ref.filePath) continue; @@ -15913,7 +16082,7 @@ function tsFieldThroughBases( ): { decl: TsFieldRead; cls: Node } | null { const decl = tsFieldDeclaration(cls, field, context); if (decl) return { decl, cls }; - if (depth >= 4) return null; + if (depth >= TS_BASE_WALK_DEPTH) return null; for (const name of declaredSupertypes(cls, context)) { const base = tsNamedClass(name, cls, ref, context); if (!base || base.id === cls.id) continue; From d3a778d0b9498580ce39fa584bf2045ec54f03e9 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Mon, 19 Oct 2026 08:11:37 +0000 Subject: [PATCH 005/354] fix(ts): a class extends the class after its type parameters (#2648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(ts): a class extends the class after its type parameters A TS/JS class head was read up to its first `extends`, which for `class EditorPane extends Composite` is a type parameter's constraint: EditorPane read as extending `object` (so nothing), and `MultiWindowParts` as extending `IMultiWindowPart`. Fields, wrapper methods and static methods inherited through such a head were missed or found on the wrong class. The head is now read from its `class` keyword to its body, over several lines, with type parameter lists and type arguments left out. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/ts-generic-class-heads.test.ts | 117 +++++++++++++++++++++++ src/resolution/name-matcher.ts | 29 +++++- 3 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 __tests__/ts-generic-class-heads.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a1c48e42d6..1ce847c40d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -168,6 +168,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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. - In TypeScript and JavaScript, a call on a value another file exports, like `store.add(…)` after `import { store } from './state'` with `export const store = new Store()` there, now links to the method of the class that file imports or declares when other classes in the project share its name, in Vue, Svelte and Astro components too. Before, it linked to whichever of them was indexed first: in vscode, calls on `FileAccess`, `extUri`, `errorHandler` and `MenuRegistry` reached the copies the Copilot extension vendors or one of its test fixtures, and in folo the mobile app's `settingSyncQueue.init()` reached the desktop app's `SettingSyncQueue`. Re-index TypeScript and JavaScript projects after upgrading. - In TypeScript, a call through a field that a wrapper sets up, like `this._onDidChange.fire()` after `private readonly _onDidChange = this._register(new Emitter())` or `= this._store.add(new Emitter())`, now links to the method of the class handed to the wrapper, also in a class that inherits the field. Before, such a field had no type, so its calls linked nowhere, and that is one of the most common ways vscode declares its event emitters and disposables. A wrapper counts only when its own declaration hands back what it is given, like `_register(o: T): T`, so a field set from a wrapper that returns something else, or from a member of what it returns (`.event`), is never taken for the class. A call through an inherited field is also found now when the field is declared further up the class hierarchy than before. Re-index TypeScript projects after upgrading. +- In TypeScript and JavaScript, a class whose declaration constrains a type parameter, like `class EditorPane extends Composite`, is now read as extending the class after its type parameters. Before, the constraint's type was taken for its base class, so fields, wrapped event emitters and static methods inherited through such a class were missed or found on the wrong class. Class declarations that span several lines or sit below a decorator are read too. Re-index TypeScript and JavaScript projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/ts-generic-class-heads.test.ts b/__tests__/ts-generic-class-heads.test.ts new file mode 100644 index 0000000000..855026965b --- /dev/null +++ b/__tests__/ts-generic-class-heads.test.ts @@ -0,0 +1,117 @@ +/** + * A TypeScript class whose head opens a type parameter list with a + * constraint, like vscode's `class EditorPane extends Composite`, extends the class after its type + * parameters, not the constraint's type. + * + * The head was read up to its first `extends`, which there is the + * constraint: `EditorPane` read as extending `object` (so nothing), + * `MultiWindowParts` as extending + * `IMultiWindowPart`. Fields, wrapper methods and static methods a class + * inherits through such a head were then missed, or found on the wrong + * class. + */ +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-generic-heads-')); + 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(); +} + +/** vscode's lifecycle wrappers, the `Emitter` the app imports, and a decoy `Emitter` nearer the app. */ +const LIFECYCLE = { + 'package.json': JSON.stringify({ name: 'app' }), + 'src/base/lifecycle.ts': + 'export interface IDisposable {\n dispose(): void;\n}\n' + + 'export class DisposableStore implements IDisposable {\n add(o: T): T {\n return o;\n }\n dispose() {}\n}\n' + + 'export abstract class Disposable implements IDisposable {\n' + + ' protected readonly _store = new DisposableStore();\n' + + ' protected _register(o: T): T {\n return this._store.add(o);\n }\n' + + ' dispose() {}\n' + + '}\n', + 'src/base/event.ts': 'export class Emitter {\n fire(event?: T) {\n return event;\n }\n dispose() {}\n}\n', + 'src/app/legacy/event.ts': 'export class Emitter {\n fire() {\n return 0;\n }\n dispose() {}\n}\n', +}; + +describe('a TypeScript class whose head constrains a type parameter', () => { + it('extends the class after its type parameters, for a wrapper it inherits', async () => { + const calls = await callsOf({ + ...LIFECYCLE, + 'src/app/pane.ts': + "import { Disposable } from '../base/lifecycle';\n" + + 'export abstract class Pane extends Disposable {\n protected memento?: MementoType;\n}\n', + 'src/app/editor.ts': + "import { Pane } from './pane';\nimport { Emitter } from '../base/event';\n" + + 'export class Editor extends Pane<{ zoom: number }> {\n' + + ' private readonly _onDidChange = this._register(new Emitter());\n' + + ' run() {\n this._onDidChange.fire();\n }\n' + + '}\n', + }); + expect(calls.filter((c) => c.includes('.fire'))).toEqual(['src/app/editor.ts: this._onDidChange.fire -> src/base/event.ts::Emitter::fire']); + }, 60_000); + + it('extends the class after its type parameters, for a field it inherits, not the constraint', async () => { + const calls = await callsOf({ + 'package.json': JSON.stringify({ name: 'app' }), + 'src/store.ts': 'export class Store {\n add(v: number) {\n return v;\n }\n}\n', + // The constraint's class declares a `store` of another type with an `add`. + 'src/part.ts': 'export class Ledger {\n add(v: number) {\n return -v;\n }\n}\nexport class Part {\n protected readonly store = new Ledger();\n}\n', + 'src/root.ts': "import { Store } from './store';\nexport class Root {\n protected readonly store = new Store();\n}\n", + 'src/parts.ts': + "import { Part } from './part';\nimport { Root } from './root';\n" + + 'export class Parts extends Root {\n protected parts: T[] = [];\n}\n', + 'src/main.ts': + "import { Parts } from './parts';\nimport { Part } from './part';\n" + + 'export class MainParts extends Parts {\n run() {\n return this.store.add(1);\n }\n}\n', + }); + expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/main.ts: this.store.add -> src/store.ts::Store::add']); + }, 60_000); + + it('reads a head that runs over several lines, below a decorator, with an arrow type in a constraint', async () => { + const calls = await callsOf({ + ...LIFECYCLE, + 'src/app/decorate.ts': 'export function Decorate(options: object) {\n return (target: unknown) => target;\n}\n', + 'src/app/view.ts': + "import { Disposable } from '../base/lifecycle';\nimport { Decorate } from './decorate';\n" + + "@Decorate({ host: { class: 'view' } })\n" + + 'export class View<\n Render extends (value: string) => void,\n State extends object = object\n>\n extends Disposable {\n' + + ' protected render?: Render;\n}\n', + 'src/app/panel.ts': + "import { View } from './view';\nimport { Emitter } from '../base/event';\n" + + 'export class Panel extends View<(value: string) => void> {\n' + + ' private readonly _onDidShow = this._register(new Emitter());\n' + + ' show() {\n this._onDidShow.fire();\n }\n' + + '}\n', + }); + expect(calls.filter((c) => c.includes('.fire'))).toEqual(['src/app/panel.ts: this._onDidShow.fire -> src/base/event.ts::Emitter::fire']); + }, 60_000); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index d5832ec65b..6f8d5d9472 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -13467,12 +13467,37 @@ function readDeclaredSupertypes(cls: Node, context: ResolutionContext): string[] case 'pascal': return names(/=\s*class\s*\(([^)]*)\)/i.exec(head)?.[1]); case 'python': return names(/\bclass\s+\w+\s*\(([^)]*)\)/.exec(head)?.[1]?.replace(/\w+\s*=\s*[\w.]+/g, '')); case 'ruby': return names(/\bclass\s+[\w:]+\s*<\s*([\w:]+)/.exec(head)?.[1]); - case 'php': case 'typescript': case 'tsx': case 'javascript': case 'jsx': - return names(/\bextends\s+([\w.\\]+)/.exec(head)?.[1]); + case 'php': return names(/\bextends\s+([\w.\\]+)/.exec(head)?.[1]); + case 'typescript': case 'tsx': case 'javascript': case 'jsx': + return names(/\bextends\s+([\w.\\]+)/.exec(tsClassHead(lines, cls))?.[1]); default: return []; } } +/** + * A TS/JS class's head, from its `class` keyword to its body, with type + * parameter lists and type arguments left out: the first `extends` in + * `class EditorPane extends Composite` is a + * constraint, not the class it extends. Decorators above the keyword are + * skipped, and the head may run over several lines. + */ +function tsClassHead(lines: readonly string[], cls: Node): string { + const text = lines.slice(cls.startLine - 1, cls.startLine + 7).join(' '); + const named = new RegExp(`\\bclass\\s+${cls.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).exec(text); + let head = ''; + let angles = 0; + for (let i = named?.index ?? /\bclass\b/.exec(text)?.index ?? 0; i < text.length; i++) { + const c = text[i]!; + if (c === '<') angles++; + else if (c === '>' && angles > 0 && text[i - 1] !== '=') angles--; + else if (angles === 0) { + if (c === '{') break; + head += c; + } + } + return head; +} + /** A method named `name` on a supertype of the given classes, nearest first. */ function inheritedClassMethod(classes: Node[], name: string, context: ResolutionContext): Node | null { if (classes.length === 0) return null; From de3eb71df6c9ac7faec91321eea41feeec12893b Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Mon, 19 Oct 2026 08:36:56 +0000 Subject: [PATCH 006/354] fix(js,ts): an import written from a jsconfig or tsconfig baseUrl links the file it names (#2649) CRA and Vite admin templates (codedthemes' berry and mantis, uilibrary's matx) set `"baseUrl": "src"` or `"paths": { "*": ["./src/*"] }` in a jsconfig and write every cross-file import as `layout/MainLayout`. The import resolver took each one for an npm package: loadProjectAliases returned null for a config with a baseUrl and no paths, and isExternalImport classifies a bare specifier from the root config alone, before a nested app's config is read. The imports stayed unresolved, their names linked by bare-name matching (vite components rendering the remix app's namesakes), and React Router routes loaded through `lazy(() => import('views/x'))` bound to the local Loadable value. - path-aliases: a config with only `baseUrl` loads, marked `declaresBaseUrl`; aliasAndBaseUrlCandidates lists the most specific `paths` pattern's targets, then the specifier read from a declared baseUrl (paths alone never reads bare specifiers, as in TS 4.1+). - import-resolver: a specifier isExternalImport takes for a package is read through the importing file's own config (nearest, else the root's) and binds only to a file the index holds (`fileExists` also answers for folders and node_modules). A package the file's package.json or an enclosing one declares, or a Node built-in, never binds to a project module that way, only to a `.d.ts` the project keeps for it (#2437's `"*": ["./typings/*"]`). A root catch-all `"*"` no longer marks every specifier local in isExternalImport's default mode either. - The module ref of a one-segment import (`'config'`) links the file its specifier resolves to, as a path-shaped one already does (#2528). - isBoundToOutOfRepoImport keeps the `extends` edge of a class whose base is imported through baseUrl. - importBindingTail parks a binding under its module when the app's config maps the specifier to an indexed file, so a sync that edits the module relinks `import Layout from 'layout/MainLayout'` and `{ Foo as Bar }` (#2450's rule for relative imports). An undeclared name that maps to nothing (`express` under `"baseUrl": "."`) and a declared package keep their own tail, which root-"*" repos had lost. - The TypeScript and TSX extension lists probe `/index.jsx` last, as tsc does: a route resolves its lazy module as TypeScript. Validated against main 6071c9e4 on 21 repos (wasm path): every new import-resolved edge lands in the file its specifier names; failed imports naming a project file go 194/190/96 -> 0 on berry/mantis/matx; their routes bind their lazily loaded pages and layouts (matx 7 -> 21 routes); 37 + 45 cross-app JSX renders move to the app's own components. proshop, next-saas-starter, create-t3-turbo, excalidraw and six more are byte-identical; root-"*" repos change only parked tails plus module links. Same deltas with stubbed node_modules. Sync after late adds and edits equals a fresh index, except one pre-existing barrel-edit gap that relative imports share. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/tsconfig-baseurl-imports.test.ts | 447 +++++++++++++++++++++ __tests__/tsconfig-extends-aliases.test.ts | 64 ++- docs/design/framework-coverage.md | 16 +- src/resolution/import-resolver.ts | 147 +++++-- src/resolution/index.ts | 4 +- src/resolution/path-aliases.ts | 69 +++- src/resolution/types.ts | 22 +- 8 files changed, 722 insertions(+), 48 deletions(-) create mode 100644 __tests__/tsconfig-baseurl-imports.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ce847c40d..3e9637922f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -169,6 +169,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In TypeScript and JavaScript, a call on a value another file exports, like `store.add(…)` after `import { store } from './state'` with `export const store = new Store()` there, now links to the method of the class that file imports or declares when other classes in the project share its name, in Vue, Svelte and Astro components too. Before, it linked to whichever of them was indexed first: in vscode, calls on `FileAccess`, `extUri`, `errorHandler` and `MenuRegistry` reached the copies the Copilot extension vendors or one of its test fixtures, and in folo the mobile app's `settingSyncQueue.init()` reached the desktop app's `SettingSyncQueue`. Re-index TypeScript and JavaScript projects after upgrading. - In TypeScript, a call through a field that a wrapper sets up, like `this._onDidChange.fire()` after `private readonly _onDidChange = this._register(new Emitter())` or `= this._store.add(new Emitter())`, now links to the method of the class handed to the wrapper, also in a class that inherits the field. Before, such a field had no type, so its calls linked nowhere, and that is one of the most common ways vscode declares its event emitters and disposables. A wrapper counts only when its own declaration hands back what it is given, like `_register(o: T): T`, so a field set from a wrapper that returns something else, or from a member of what it returns (`.event`), is never taken for the class. A call through an inherited field is also found now when the field is declared further up the class hierarchy than before. Re-index TypeScript projects after upgrading. - In TypeScript and JavaScript, a class whose declaration constrains a type parameter, like `class EditorPane extends Composite`, is now read as extending the class after its type parameters. Before, the constraint's type was taken for its base class, so fields, wrapped event emitters and static methods inherited through such a class were missed or found on the wrong class. Class declarations that span several lines or sit below a decorator are read too. Re-index TypeScript and JavaScript projects after upgrading. +- In JavaScript and TypeScript, an import written from a jsconfig or tsconfig `baseUrl`, like `import MainLayout from 'layout/MainLayout'` under `"baseUrl": "src"`, or through a catch-all `"paths": { "*": ["./src/*"] }`, now links to the file it names, read with the config of the app the importing file belongs to, and so does an import of a single name like `'config'` or `'themes'`. Create React App and Vite admin templates such as Berry, Mantis and MatX write nearly every import this way. Before, each one was taken for an npm package, so it linked nothing, its names were matched by name alone, which could reach a same-named component in another app of the same repository, and a React Router route whose page loads through `lazy(() => import('views/dashboard/Default'))` pointed at that loader instead of the page and its layout. A package the app's `package.json` declares, or a Node built-in, never links to a project module of the same name, only to type declarations the project keeps for it, and only a file the index holds counts, never a folder or something in `node_modules`. `codegraph sync` and the file watcher link such an import too when the file it names is added later or gains what it imports. Re-index JavaScript and TypeScript projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/tsconfig-baseurl-imports.test.ts b/__tests__/tsconfig-baseurl-imports.test.ts new file mode 100644 index 0000000000..47e2ebad63 --- /dev/null +++ b/__tests__/tsconfig-baseurl-imports.test.ts @@ -0,0 +1,447 @@ +/** + * Bare specifiers that name a project file through the importing app's own + * tsconfig / jsconfig: a `baseUrl` with no `paths` (berry, matx) or a + * catch-all `"*": ["./src/*"]` (mantis). CRA and Vite admin templates write + * nearly every cross-file import this way (`import MainLayout from + * 'layout/MainLayout'`), and each one used to be taken for an npm package: + * the import stayed unresolved and its names linked by bare-name matching, + * often into another app of the same repository. + * + * Locked in: + * - the importing app's own config decides (two apps, two baseUrls) + * - only an indexed file binds; a specifier that names none stays unresolved + * - a package the app's package.json declares, or a Node built-in, never + * binds to a same-named project folder or file, also under a root `"*"` + * - a route's `lazy(() => import('views/x'))` renders `views/x/index.jsx`, + * and the layout around it links through the same kind of import + * - a class extending one imported this way keeps its `extends` edge + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { resolveImportPath } from '../src/resolution/import-resolver'; +import type { AliasMap } from '../src/resolution/path-aliases'; +import type { ResolutionContext } from '../src/resolution'; + +const ROOT = path.join(os.tmpdir(), 'cg-baseurl-stub'); + +function stubContext(files: string[], opts: { + nearest?: (fromFile: string) => AliasMap | null; + project?: AliasMap | null; + declared?: string[]; + noPackageCheck?: boolean; +}): ResolutionContext { + const set = new Set(files); + return { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: (p: string) => set.has(p), + readFile: () => null, + getProjectRoot: () => ROOT, + getAllFiles: () => files, + getImportMappings: () => [], + getNearestAliases: opts.nearest ?? (() => null), + getProjectAliases: () => opts.project ?? null, + ...(opts.noPackageCheck ? {} : { isDeclaredPackage: (source: string) => (opts.declared ?? []).includes(source.split('/')[0]!) }), + } as unknown as ResolutionContext; +} + +const baseUrlOnly = (dir: string): AliasMap => ({ baseUrl: path.join(ROOT, dir), declaresBaseUrl: true, patterns: [] }); + +describe('resolving a bare specifier through the app’s own config', () => { + it('reads a nested app’s baseUrl, down to an index.jsx (berry)', () => { + const files = ['vite/src/routes/MainRoutes.jsx', 'vite/src/layout/MainLayout/index.jsx', 'vite/src/views/dashboard/Default/index.jsx']; + const ctx = stubContext(files, { nearest: (f) => (f.startsWith('vite/') ? baseUrlOnly('vite/src') : null) }); + expect(resolveImportPath('layout/MainLayout', 'vite/src/routes/MainRoutes.jsx', 'jsx', ctx)).toBe('vite/src/layout/MainLayout/index.jsx'); + // A route table resolves a lazily loaded module as TypeScript, whatever it is written in. + expect(resolveImportPath('views/dashboard/Default', 'vite/src/routes/MainRoutes.jsx', 'typescript', ctx)).toBe('vite/src/views/dashboard/Default/index.jsx'); + }); + + it('reads a catch-all "*" from the config’s directory (mantis)', () => { + const star: AliasMap = { + baseUrl: path.join(ROOT, 'vite'), + declaresBaseUrl: false, + patterns: [{ prefix: '', suffix: '', hasWildcard: true, replacements: ['./src/*'] }], + }; + const ctx = stubContext(['vite/src/routes/LoginRoutes.jsx', 'vite/src/pages/auth/Register.jsx'], { nearest: () => star }); + expect(resolveImportPath('pages/auth/Register', 'vite/src/routes/LoginRoutes.jsx', 'jsx', ctx)).toBe('vite/src/pages/auth/Register.jsx'); + }); + + it('reads the root jsconfig when no nearer config has one (matx)', () => { + const ctx = stubContext(['src/app/routes.jsx', 'src/app/views/dashboard/Analytics.jsx'], { project: baseUrlOnly('src') }); + expect(resolveImportPath('app/views/dashboard/Analytics', 'src/app/routes.jsx', 'jsx', ctx)).toBe('src/app/views/dashboard/Analytics.jsx'); + }); + + it('uses the importing app’s baseUrl, not another app’s', () => { + const files = ['vite/src/views/Page.jsx', 'vite/src/utils/format.js', 'remix/app/routes/index.jsx', 'remix/app/utils/format.js']; + const ctx = stubContext(files, { + nearest: (f) => (f.startsWith('vite/') ? baseUrlOnly('vite/src') : f.startsWith('remix/') ? baseUrlOnly('remix/app') : null), + }); + expect(resolveImportPath('utils/format', 'vite/src/views/Page.jsx', 'jsx', ctx)).toBe('vite/src/utils/format.js'); + expect(resolveImportPath('utils/format', 'remix/app/routes/index.jsx', 'jsx', ctx)).toBe('remix/app/utils/format.js'); + }); + + it('never binds a declared package to a same-named project folder', () => { + const files = ['src/hooks/useSearch.js', 'src/lodash/index.js']; + expect(resolveImportPath('lodash', 'src/hooks/useSearch.js', 'javascript', stubContext(files, { project: baseUrlOnly('src'), declared: ['lodash'] }))).toBeNull(); + // Only the declaration keeps it out: the same folder is the project's when nothing declares `lodash`. + expect(resolveImportPath('lodash', 'src/hooks/useSearch.js', 'javascript', stubContext(files, { project: baseUrlOnly('src') }))).toBe('src/lodash/index.js'); + // A context that cannot tell packages apart resolves nothing this way. + expect(resolveImportPath('lodash', 'src/hooks/useSearch.js', 'javascript', stubContext(files, { project: baseUrlOnly('src'), noPackageCheck: true }))).toBeNull(); + }); + + it('lets a declared package reach a declaration file the project keeps for it, never a module', () => { + // cord-field's shape: `"*": ["./typings/*"]` holds types for untyped packages. + const typings: AliasMap = { + baseUrl: path.join(ROOT, 'src'), + declaresBaseUrl: true, + patterns: [{ prefix: '', suffix: '', hasWildcard: true, replacements: ['./typings/*'] }], + }; + const files = ['src/scenes/Page.tsx', 'src/typings/legacy-widgets.d.ts', 'src/axios/index.ts']; + const ctx = stubContext(files, { project: typings, declared: ['legacy-widgets', 'axios'] }); + expect(resolveImportPath('legacy-widgets', 'src/scenes/Page.tsx', 'tsx', ctx)).toBe('src/typings/legacy-widgets.d.ts'); + // `baseUrl` reads `src/axios/index.ts`, the app's own wrapper: the package stays the package. + expect(resolveImportPath('axios', 'src/scenes/Page.tsx', 'tsx', ctx)).toBeNull(); + }); + + it('leaves a specifier that names no project file unresolved', () => { + const ctx = stubContext(['src/App.jsx'], { project: baseUrlOnly('src') }); + expect(resolveImportPath('react', 'src/App.jsx', 'jsx', ctx)).toBeNull(); + expect(resolveImportPath('virtual:pwa-register', 'src/App.jsx', 'jsx', ctx)).toBeNull(); + }); + + it('binds only a file the index holds, not a folder or node_modules on disk', () => { + // `fileExists` falls back to the disk: true for `src/assets` (a folder) and + // for a package installed under a `"*"` target like `node_modules/*`. + const onDisk = new Set(['src/App.jsx', 'src/assets', 'node_modules/history/index.js']); + const indexed = new Set(['src/App.jsx']); + const star: AliasMap = { + baseUrl: ROOT, + declaresBaseUrl: true, + patterns: [{ prefix: '', suffix: '', hasWildcard: true, replacements: ['src/*', 'node_modules/*'] }], + }; + const ctx = { + ...stubContext([...indexed], { project: star }), + fileExists: (p: string) => onDisk.has(p), + isIndexedFile: (p: string) => indexed.has(p), + } as unknown as ResolutionContext; + expect(resolveImportPath('assets', 'src/App.jsx', 'jsx', ctx)).toBeNull(); + expect(resolveImportPath('history', 'src/App.jsx', 'jsx', ctx)).toBeNull(); + }); + + it('does not read bare specifiers from a config with paths but no baseUrl', () => { + const atOnly: AliasMap = { + baseUrl: ROOT, + declaresBaseUrl: false, + patterns: [{ prefix: '@/', suffix: '', hasWildcard: true, replacements: ['./src/*'] }], + }; + const ctx = stubContext(['src/App.jsx', 'components/Button.jsx', 'src/components/Button.jsx'], { project: atOnly }); + expect(resolveImportPath('components/Button', 'src/App.jsx', 'jsx', ctx)).toBeNull(); + expect(resolveImportPath('@/components/Button', 'src/App.jsx', 'jsx', ctx)).toBe('src/components/Button.jsx'); + }); +}); + +function writeTree(root: string, files: Record): void { + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } +} + +describe('two admin apps with their own baseUrl (berry shape)', () => { + let root = ''; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-baseurl-apps-')); + writeTree(root, { + 'package.json': JSON.stringify({ name: 'admin-templates', private: true }), + 'vite/package.json': JSON.stringify({ name: 'vite-app', dependencies: { react: '^18', 'react-router-dom': '^6', lodash: '^4' } }), + 'vite/jsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: 'src', jsx: 'react-jsx' } }), + 'vite/src/routes/index.jsx': `import { createBrowserRouter } from 'react-router-dom'; +import MainRoutes from './MainRoutes'; + +const router = createBrowserRouter([MainRoutes]); + +export default router; +`, + 'vite/src/routes/MainRoutes.jsx': `import { lazy } from 'react'; + +import MainLayout from 'layout/MainLayout'; +import Loadable from 'ui-component/Loadable'; + +const DashboardDefault = Loadable(lazy(() => import('views/dashboard/Default'))); + +const MainRoutes = { + path: '/', + element: , + children: [ + { + path: 'dashboard', + element: + } + ] +}; + +export default MainRoutes; +`, + 'vite/src/layout/MainLayout/index.jsx': `export default function MainLayout() { + return
; +} +`, + 'vite/src/ui-component/Loadable.jsx': `export default function Loadable(Component) { + return (props) => ; +} +`, + 'vite/src/views/dashboard/Default/index.jsx': `import { formatDate } from 'utils/format'; + +export default function Dashboard() { + return
{formatDate(new Date())}
; +} +`, + 'vite/src/utils/format.js': `export function formatDate(d) { + return String(d); +} +`, + // A one-segment specifier, and a binding named like another module. + 'vite/src/config.js': `export const DEFAULT_THEME_MODE = 'system'; +export const themes = ['light', 'dark']; +`, + 'vite/src/themes/index.jsx': `export default function ThemeCustomization({ children }) { + return children; +} +`, + 'vite/src/layout/Customization/index.jsx': `import { DEFAULT_THEME_MODE, themes } from 'config'; + +export default function Customization() { + return themes.includes(DEFAULT_THEME_MODE); +} +`, + // A folder named like a dependency, and a file named like a Node built-in. + 'vite/src/lodash/index.js': `export function debounce(fn) { + return fn; +} +`, + 'vite/src/events.js': `export class EventEmitter {} +`, + 'vite/src/hooks/useSearch.js': `import { debounce } from 'lodash'; +import { EventEmitter } from 'events'; + +export function useSearch() { + return debounce(() => new EventEmitter()); +} +`, + 'vite/src/components/BasePanel.js': `export default class BasePanel {} +`, + 'vite/src/components/Panel.js': `import BasePanel from 'components/BasePanel'; + +export class Panel extends BasePanel {} +`, + 'remix/package.json': JSON.stringify({ name: 'remix-app', dependencies: { react: '^18' } }), + 'remix/jsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: 'app' } }), + 'remix/app/utils/format.js': `export function formatDate(d) { + return d.toISOString(); +} +`, + 'remix/app/routes/index.jsx': `import { formatDate } from 'utils/format'; + +export default function Index() { + return formatDate(new Date()); +} +`, + }); + cg = await CodeGraph.init(root, { index: true }); + }); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true, maxRetries: 5 }); + }); + + /** The files `file`'s nodes import, by path. */ + const importedFiles = (file: string): string[] => + cg.getOutgoingEdgesFrom(cg.getNodesInFile(file).map((n) => n.id)) + .filter((e) => e.kind === 'imports') + .map((e) => cg.getNode(e.target)!.filePath); + + /** What `file`'s import of `ref` (a module specifier or a binding) links to, as `kind:name@path`. */ + const importTargets = (file: string, ref: string): string[] => + cg.getOutgoingEdgesFrom(cg.getNodesInFile(file).map((n) => n.id)) + .filter((e) => e.kind === 'imports' && (e.metadata as Record | undefined)?.refName === ref) + .map((e) => `${cg.getNode(e.target)!.kind}:${cg.getNode(e.target)!.name}@${cg.getNode(e.target)!.filePath}`); + + const routeLinks = (name: string, layout: boolean): string[] => { + const route = cg.getNodesByKind('route').find((r) => r.name === name); + if (!route) return []; + return cg.getOutgoingEdges(route.id) + .filter((e) => e.kind === 'references' && Boolean((e.metadata as Record | undefined)?.layout) === layout) + .map((e) => `${cg.getNode(e.target)!.name}@${cg.getNode(e.target)!.filePath}`); + }; + + it('links an import written from baseUrl to the file it names', () => { + const file = 'vite/src/routes/MainRoutes.jsx'; + expect(importTargets(file, 'layout/MainLayout')).toEqual(['file:index.jsx@vite/src/layout/MainLayout/index.jsx']); + expect(importTargets(file, 'ui-component/Loadable')).toEqual(['file:Loadable.jsx@vite/src/ui-component/Loadable.jsx']); + }); + + it('links a one-segment module, and reads a binding named like another module as the binding', () => { + const file = 'vite/src/layout/Customization/index.jsx'; + expect(importTargets(file, 'config')).toEqual(['file:config.js@vite/src/config.js']); + expect(importTargets(file, 'themes')).toEqual(['constant:themes@vite/src/config.js']); + expect(importedFiles(file)).not.toContain('vite/src/themes/index.jsx'); + }); + + it('calls the importing app’s own function, not the other app’s namesake', () => { + const callees = (fn: string, file: string) => { + const node = cg.getNodesInFile(file).find((n) => n.name === fn)!; + return cg.getCallees(node.id).map((c) => c.node.filePath); + }; + expect(callees('Dashboard', 'vite/src/views/dashboard/Default/index.jsx')).toContain('vite/src/utils/format.js'); + expect(callees('Dashboard', 'vite/src/views/dashboard/Default/index.jsx')).not.toContain('remix/app/utils/format.js'); + expect(callees('Index', 'remix/app/routes/index.jsx')).toContain('remix/app/utils/format.js'); + expect(callees('Index', 'remix/app/routes/index.jsx')).not.toContain('vite/src/utils/format.js'); + }); + + it('renders a lazily loaded page and the layout around it', () => { + expect(routeLinks('/dashboard', false)).toEqual(['Dashboard@vite/src/views/dashboard/Default/index.jsx']); + expect(routeLinks('/dashboard', true)).toEqual(['MainLayout@vite/src/layout/MainLayout/index.jsx']); + }); + + it('keeps a declared package and a Node built-in out of same-named project files', () => { + const imported = importedFiles('vite/src/hooks/useSearch.js'); + expect(imported).not.toContain('vite/src/lodash/index.js'); + expect(imported).not.toContain('vite/src/events.js'); + }); + + it('keeps the extends edge of a class whose base is imported from baseUrl', () => { + const panel = cg.getNodesInFile('vite/src/components/Panel.js').find((n) => n.name === 'Panel')!; + const supers = cg.getOutgoingEdges(panel.id).filter((e) => e.kind === 'extends').map((e) => cg.getNode(e.target)!.filePath); + expect(supers).toEqual(['vite/src/components/BasePanel.js']); + }); +}); + +describe('a root catch-all "*" beside a folder named like a dependency', () => { + let root = ''; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-baseurl-star-')); + writeTree(root, { + 'package.json': JSON.stringify({ name: 'star-app', dependencies: { axios: '^1', react: '^18' } }), + 'jsconfig.json': JSON.stringify({ compilerOptions: { paths: { '*': ['./src/*'] } } }), + // The app's own configured client, in a folder named after the package it wraps. + 'src/axios/index.js': `import axios from 'axios'; + +const instance = axios.create({ baseURL: '/api' }); + +export default instance; +`, + 'src/components/Button.jsx': `export default function Button() { + return