From 986de95c35c135f34f1fe16888421066bc5af1e9 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sun, 18 Oct 2026 16:28:23 +0000 Subject: [PATCH 01/33] fix(vue): an import through `@/` or `~/` in a Vue project links the module's file (#2633) In a project with a `vue` dependency or a `.vue` file, a script's module import through `@/` or `~/` went through the Vue resolver's own mapping to `src/`, which linked the first node of the file it found: a constant the module declares, or a component. A tsconfig alias that starts like a Nuxt virtual module (`#app/where`) linked the importing file itself. Without Vue the import resolver links the module's file node, following tsconfig paths. So the walks a sync makes from a module or barrel that appears or goes away to the files importing it (#2512, #2560) never found these importers. Which node the edge hit also depended on the order a file's nodes were stored, so a sync and a fresh index could disagree. The Vue resolver now leaves a module import to the import resolver whenever that finds the file, and only adds what a Vue app's bundler finds from a plain script: a component named without `.vue`, or a folder's `index.vue` (`import Layout from '@/layout'`), linked to the file. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/vue-module-imports.test.ts | 178 +++++++++++++++++++++++++++ src/resolution/frameworks/vue.ts | 76 ++++++------ 3 files changed, 216 insertions(+), 39 deletions(-) create mode 100644 __tests__/vue-module-imports.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d50f1de81b..d921ed2ba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, a file that re-exports a module, like an `index.ts` with `export * from './button'` or `export { Button } from './button'`, now counts as importing that module, the way an `import` statement does. `codegraph affected` and a module's dependents now include the barrels that re-export it; before, a barrel made only of `export *` lines showed no dependency at all. Re-index JavaScript and TypeScript projects after upgrading. - `codegraph sync` and the file watcher now also update the files that import through an `index.ts` re-exporting another barrel, like `export * from './dropdown-menu'`, when that inner barrel is deleted or added. Before, their imports stayed linked through the deleted barrel, or stayed linked the way they were while it was missing, until each importing file changed or the project was re-indexed. - An import in a Vue, Svelte or Astro component now links the component to the file it imports, as an import in a script does, and so does an import of a scoped package that a `tsconfig` path or a workspace package maps to a project file, like `import { debounce } from '@excalidraw/common'`. Before, only the names such an import binds were linked, so a barrel or a module imported for its side effects showed no dependency from those files in `codegraph affected`, and `codegraph sync` could not update them when a barrel they import through was deleted or added. `codegraph sync` and the file watcher also link such an import again, and one written as `.` or `..`, when the file it names comes back. Re-index JavaScript, TypeScript, Vue, Svelte and Astro projects after upgrading. +- In a project that uses Vue, an import written through an `@/` or `~/` alias, like `import { Button } from '@/components'`, now links the file it imports, as it does in any other project, so `codegraph sync` and the file watcher update the files that import a module when that module, or a barrel they import through, is added or deleted. Before, Vue support linked such an import to the first declaration or the component in that file, which sync does not follow, and an import through a `tsconfig` alias that starts like one of Nuxt's built-in modules, like `#app/where`, linked the importing file to itself. Re-index Vue projects after upgrading. - `codegraph sync` and the file watcher now move an import written without an extension, like `import { sizeOf } from './button'`, to a file added beside the one it found before when the new file comes first, such as `button.ts` next to `button.vue`. Before, the import and the names it binds stayed linked to `button.vue` until the importing file changed or the project was re-indexed. JavaScript, TypeScript, Vue, Svelte and Astro. - In C, C++ and Objective-C, a struct or class member declared as a pointer, reference, array or function, like `SharedState* shared;`, `jv elements[];` or `virtual Status Put(…) = 0;`, no longer makes the struct look like it inherits from the member's type. Before, `codegraph_explore` and impact listed those types as base classes, every struct holding a pointer to a type as one of its subclasses, and a method of that struct as an override of the type's method with the same name. Real base classes like `class Derived : public Base`, Objective-C superclasses and Go's embedded fields are unchanged. Re-index C, C++ and Objective-C projects after upgrading. - In C++, a call on a variable, parameter or member declared through a `typedef` or `using` alias now links to the method of the type the alias names, with the alias looked up the way C++ does it: in the calling function, its class and the classes that class inherits from, then the namespaces around it. In google/leveldb, `table_.Insert(buf)` on a `Table table_;` declared next to `typedef SkipList Table;` now reaches `SkipList::Insert` instead of the unrelated `HandleTable::Insert`, and `Table::Iterator iter(&table_);` reaches `SkipList::Iterator`'s constructor. A class that only shares the alias's name is no longer taken for it, and when the aliased type has no such method, as with an alias of `std::vector` or of a template parameter, no method is guessed from the receiver's name. An alias of a type written from the global scope names that global type, also inside a namespace of the same name: protocolbuffers/protobuf's `using Arena = ::upb::Arena;` in `hpb::internal::backend::upb`, so hpb's `arena_.Fuse(…)` on a `backend::Arena` now reaches `upb::Arena::Fuse` instead of the calling method itself. Re-index C++ projects after upgrading. diff --git a/__tests__/vue-module-imports.test.ts b/__tests__/vue-module-imports.test.ts new file mode 100644 index 0000000000..431ff5346a --- /dev/null +++ b/__tests__/vue-module-imports.test.ts @@ -0,0 +1,178 @@ +/** + * In a Vue project, an import's module path links the module's file. + * + * Every script's `imports` reference named by a path (`@/flags/registry`, + * `~/utils/helper`) went through the Vue resolver first, which mapped `@/` and + * `~/` to `src/` by hand and linked the FIRST node of the file it found: a + * constant the module declares, a component, or for a `#app/…` path alias the + * importing file itself (the Nuxt virtual-module rule). Without Vue the import + * resolver links the file node, following tsconfig paths. So in any project + * with a `vue` dependency or a `.vue` file, no importer's edge reached the + * module's file, and what follows those edges came up empty: sync's walk from + * a module that appears to the files importing it (#2512), and a barrel's + * importers (#2560). + * + * Now the import resolver links what it finds, and the Vue resolver only adds + * what a Vue app's bundler finds from a plain script: a component named + * without `.vue`, or a folder's `index.vue` (`import Layout from '@/layout'`). + */ +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'; + +type Files = Record; + +const PROJECT: Files = { + 'package.json': JSON.stringify({ name: 'app', dependencies: { vue: '^3' } }), + 'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['src/*'], '~/*': ['src/*'], '#app/*': ['client/*'] } } }), + 'src/flags.ts': "import { REGISTERED_FLAGS } from '@/flags/registry';\nexport function flag(n: string) { return REGISTERED_FLAGS.get(n); }\n", + 'src/flags/registry.ts': 'export const REGISTERED_FLAGS = new Map();\n', + 'src/router.js': "import Layout from '@/layout';\nimport Foo from '@/components/Foo.vue';\nimport api from '@/api';\nexport const routes = [Layout, Foo, api];\n", + 'src/layout/index.vue': '\n\n', + 'src/components/Foo.vue': '\n', + 'src/api/index.js': 'export default { get() { return 1; } };\n', + 'src/tilde.ts': "import { helper } from '~/utils/helper';\nexport const h = helper();\n", + 'src/utils/helper.ts': 'export const flagged = 1;\nexport function helper() { return flagged; }\n', + 'src/components/Panel.vue': "\n\n", + 'src/table.ts': "export const COLUMNS = [{ name: 'a' }];\n", + // A tsconfig alias that starts like one of Nuxt's virtual modules. + 'src/elsewhere.ts': "import { where } from '#app/where';\nexport const w = where;\n", + 'client/where.ts': "export const where = 'client';\n", + // A Nuxt virtual module is no file: it keeps linking the importing file. + 'src/virtual.ts': "import { useHead } from '#imports';\nexport const head = useHead;\n", +}; + +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 = []; +}); + +function tempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vue-imports-')); + roots.push(root); + return root; +} + +function write(root: string, files: Files): void { + 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); + } +} + +async function index(root: string): Promise { + const graph = await CodeGraph.init(root, { index: true }); + graphs.push(graph); + return graph; +} + +/** Each `imports` edge whose reference names a module by path: ` -> `. */ +function moduleImportsOf(graph: CodeGraph): string[] { + const ids = graph.getFiles().flatMap((file) => graph.getNodesInFile(file.path)).map((n) => n.id); + const links: string[] = []; + for (const edge of graph.getOutgoingEdgesFrom(ids, ['imports'])) { + const ref = edge.metadata?.refName; + if (typeof ref !== 'string' || (!ref.includes('/') && !ref.startsWith('#'))) continue; + const source = graph.getNode(edge.source)!; + const target = graph.getNode(edge.target)!; + links.push(`${source.filePath} ${ref} -> ${target.kind} ${target.filePath}`); + } + return links.sort(); +} + +/** Every edge, by its ends' natural keys, with where it is written and how it resolved. */ +function edgesOf(graph: CodeGraph): string[] { + const keys = new Map( + graph.getFiles() + .flatMap((file) => graph.getNodesInFile(file.path)) + .map((n) => [n.id, `${n.kind} ${n.filePath}:${n.qualifiedName}:${n.startLine}`] as const) + ); + return graph.getOutgoingEdgesFrom([...keys.keys()]) + .map((e) => `${e.kind} ${keys.get(e.source)} -> ${keys.get(e.target) ?? e.target} @${e.line}:${e.column} ${JSON.stringify(e.metadata ?? {})}`) + .sort(); +} + +describe('a module import in a Vue project', () => { + it('links the module\'s file, as without Vue', async () => { + const root = tempRoot(); + write(root, PROJECT); + const graph = await index(root); + expect(moduleImportsOf(graph)).toEqual([ + 'src/components/Panel.vue @/table -> file src/table.ts', + 'src/elsewhere.ts #app/where -> file client/where.ts', + 'src/flags.ts @/flags/registry -> file src/flags/registry.ts', + 'src/router.js @/api -> file src/api/index.js', + 'src/router.js @/components/Foo.vue -> file src/components/Foo.vue', + 'src/router.js @/layout -> file src/layout/index.vue', + 'src/tilde.ts ~/utils/helper -> file src/utils/helper.ts', + 'src/virtual.ts #imports -> file src/virtual.ts', + ]); + }, 60_000); + + it('links the module the way the import resolver does, wherever it can', async () => { + const root = tempRoot(); + write(root, PROJECT); + const graph = await index(root); + const ids = graph.getFiles().flatMap((file) => graph.getNodesInFile(file.path)).map((n) => n.id); + const how = new Map(graph.getOutgoingEdgesFrom(ids, ['imports']) + .map((e) => [String(e.metadata?.refName), String(e.metadata?.resolvedBy)] as const)); + expect(how.get('@/flags/registry')).toBe('import'); + expect(how.get('@/table')).toBe('import'); + expect(how.get('#app/where')).toBe('import'); + // A script's `@/layout` is a folder's `index.vue` only to a Vue bundler. + expect(how.get('@/layout')).toBe('framework'); + }, 60_000); + +}); + +describe('a sync in a Vue project follows an import to its barrel', () => { + // `src/components/index.ts` passes `Button` on from `./Button`. Its first + // node is the constant it declares, which the import of `@/components` + // used to link; sync finds a barrel's importers through the edge into the + // barrel's file (#2512). + const BASE: Files = { + 'package.json': JSON.stringify({ name: 'app', dependencies: { vue: '^3' } }), + 'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['src/*'] } } }), + 'src/components/Button.ts': 'export function Button() { return 1; }\n', + 'src/views/page.ts': "import { Button } from '@/components';\nexport function page() { return Button(); }\n", + }; + const BARREL: Files = { 'src/components/index.ts': "export const VERSION = 1;\nexport * from './Button';\n" }; + + /** The final tree, indexed from scratch in a folder of its own. */ + async function fullIndexOf(files: Files): Promise { + const root = tempRoot(); + write(root, files); + return edgesOf(await index(root)); + } + + it('when the barrel is added after its importers', async () => { + // Meanwhile `Button` links its one namesake by name; through the barrel + // once it is there. + const root = tempRoot(); + write(root, BASE); + const graph = await index(root); + + write(root, BARREL); + expect((await graph.sync()).filesAdded).toBe(1); + const fresh = await fullIndexOf({ ...BASE, ...BARREL }); + expect(fresh.filter((e) => e.startsWith('calls ') && e.includes('"refName":"Button"') && e.includes('"resolvedBy":"import"'))).toHaveLength(1); + expect(edgesOf(graph)).toEqual(fresh); + }, 120_000); + + it('when the barrel is deleted', async () => { + const root = tempRoot(); + write(root, { ...BASE, ...BARREL }); + const graph = await index(root); + + fs.rmSync(path.join(root, 'src/components/index.ts')); + expect((await graph.sync()).filesRemoved).toBe(1); + expect(edgesOf(graph)).toEqual(await fullIndexOf(BASE)); + }, 120_000); +}); diff --git a/src/resolution/frameworks/vue.ts b/src/resolution/frameworks/vue.ts index 044221afc6..b8a5a3c2ee 100644 --- a/src/resolution/frameworks/vue.ts +++ b/src/resolution/frameworks/vue.ts @@ -7,6 +7,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { isJsPathImportRef, resolveImportPath } from '../import-resolver'; import { dependsOn } from './package-deps'; import { pageComponentRef, resolvePageComponent } from './page-component'; @@ -82,6 +83,9 @@ const NUXT_VIRTUAL_MODULES = [ '#head', ]; +/** The alias prefixes a Vue app's imports name `src/` by, as Vue CLI and Nuxt set them up. */ +const VUE_ALIAS_PREFIXES = ['@/', '~/']; + export const vueResolver: FrameworkResolver = { name: 'vue', @@ -133,7 +137,34 @@ export const vueResolver: FrameworkResolver = { }; } - // Pattern 3: Nuxt virtual module imports (#imports, #components, etc.) + // A module an import names by path is the file the path names: the import + // resolver's to find, through tsconfig paths, workspace packages and the + // `@/` and `~/` conventions, and to link. Answering here took that from + // it: this linked the first node of the file (a constant it declares, a + // component), and `#app/…` the importing file itself, so no importer's + // edge reached the module's file node for sync to follow. + if (isJsPathImportRef(ref)) { + if (moduleFileNode(resolveImportPath(ref.referenceName, ref.filePath, ref.language, context), ref, context)) return null; + // What only a Vue app's bundler finds from a plain script: a component + // named without `.vue`, or a folder's `index.vue` (`import Layout from + // '@/layout'` is `src/layout/index.vue`; the path alone names the + // folder). An SFC's own imports already look for those. Linked to the + // file, as the import resolver links one. + if (ref.language !== 'vue' && VUE_ALIAS_PREFIXES.some((prefix) => ref.referenceName.startsWith(prefix))) { + const fileNode = moduleFileNode(resolveImportPath(ref.referenceName, ref.filePath, 'vue', context), ref, context); + if (fileNode) { + return { + original: ref, + targetNodeId: fileNode.id, + confidence: 0.9, + resolvedBy: 'framework', + }; + } + } + } + + // Pattern 3: Nuxt virtual module imports (#imports, #components, etc.), + // which no file of the project is. if (ref.referenceKind === 'imports' && ref.referenceName.startsWith('#')) { if (NUXT_VIRTUAL_MODULES.some((prefix) => ref.referenceName.startsWith(prefix))) { return { @@ -145,44 +176,6 @@ export const vueResolver: FrameworkResolver = { } } - // Pattern 4: @ alias imports (@/components/Foo -> src/components/Foo) - if (ref.referenceKind === 'imports' && ref.referenceName.startsWith('@/')) { - const aliasPath = ref.referenceName.replace('@/', 'src/'); - for (const ext of ['', '.ts', '.js', '.vue', '/index.ts', '/index.js', '/index.vue']) { - const fullPath = aliasPath + ext; - if (context.fileExists(fullPath)) { - const nodes = context.getNodesInFile(fullPath); - if (nodes.length > 0) { - return { - original: ref, - targetNodeId: nodes[0]!.id, - confidence: 0.9, - resolvedBy: 'framework', - }; - } - } - } - } - - // Pattern 5: ~ alias imports (~/components/Foo -> src/components/Foo, Nuxt convention) - if (ref.referenceKind === 'imports' && ref.referenceName.startsWith('~/')) { - const aliasPath = ref.referenceName.replace('~/', 'src/'); - for (const ext of ['', '.ts', '.js', '.vue', '/index.ts', '/index.js', '/index.vue']) { - const fullPath = aliasPath + ext; - if (context.fileExists(fullPath)) { - const nodes = context.getNodesInFile(fullPath); - if (nodes.length > 0) { - return { - original: ref, - targetNodeId: nodes[0]!.id, - confidence: 0.9, - resolvedBy: 'framework', - }; - } - } - } - } - // Pattern 6: Component references (PascalCase) — resolve to .vue files if (isPascalCase(ref.referenceName) && ref.referenceKind === 'calls') { const result = resolveComponent(ref.referenceName, ref.filePath, context); @@ -303,6 +296,11 @@ export const nuxtResolver: FrameworkResolver = { }, }; +/** The file node of `file`, a path an import resolved to — none for a folder, an unindexed file or the importing file itself. */ +function moduleFileNode(file: string | null, ref: UnresolvedRef, context: ResolutionContext): Node | undefined { + return file && file !== ref.filePath ? context.getNodesInFile(file).find((n) => n.kind === 'file') : undefined; +} + /** Does the reference's own file declare a function (or a const holding one) by that name? */ function declaresOwnFunction(ref: UnresolvedRef, context: ResolutionContext): boolean { return context From 53ad0d48b3dc2e50ab95e4ecb3041d614a950e1a Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sun, 18 Oct 2026 20:06:03 +0000 Subject: [PATCH 02/33] fix(sync): a member call on a local typed by an imported class links when the class's module changes (#2637) `cache.get(k)` after `const cache = new LRUCache(10)` (or an annotated local, a typed parameter, a default or namespace import of the class) is linked by a fresh index through the local's declaration. When the class's module was missing or empty, the failed call was parked under its member's name, `get`, which more failed refs share than the retry's ceiling, so a sync that added, restored or filled in the module never retried it. Such a call now waits under the key of the module the type's binding imports, as a member of the binding itself does since the previous change (2630), and keeps that change's member and receiver triggers. Only calls are parked this way: in TypeScript a typed local's member is only ever a call, and a method of `this` passed as a value is looked up in the enclosing class. A local declared as an array of the class (`results: URI[]`) keeps its member's tail. Resolution reads the element type there too, but `results.push` is the array's method, which no edit of the class's module links. On vscode those calls were 60% of what the parking added and nearly doubled what an edit of `uri.ts` retried. Inference now reports the line it read the declaration from. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../sync-typed-local-member-retry.test.ts | 227 ++++++++++++++++++ src/resolution/import-resolver.ts | 48 +++- src/resolution/name-matcher.ts | 28 ++- 4 files changed, 294 insertions(+), 10 deletions(-) create mode 100644 __tests__/sync-typed-local-member-retry.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d921ed2ba3..b35d11dc4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph_explore` now leaves out of its answers every file that search already treats as a test or an example: a C++ `FooTest.cpp` or `foo_test.cc`, a Google `foo_unittest.cc`, anything in an LLVM-style `unittests/` folder or an `e2e/` or `testing/` folder, and a project's `examples/`, `samples/` and `benchmarks/`. Before, explore went by a shorter list of its own, so a unit test or an example that called the code you asked about could take a place in the answer meant for that code: in protocolbuffers/protobuf, a question about `DescriptorPool` came back with three of its unit tests. The same files are no longer taken for a project's main routes file either, such as an end-to-end suite's mock server. No re-index is needed. - `codegraph sync` and the file watcher now link a file to a module it imports under a name of its own when the module is added after the file was indexed, comes back after being deleted, or gets the export in a later edit: a default import like `import tagsController from './tag/tag.controller'`, a namespace import like `import * as api from './api'`, or an aliased one like `import { Component as GroupsPageWrapper } from './groups_page'`, in JavaScript, TypeScript, Vue, Svelte and Astro. Before, the import and every use of the name, such as a call, `new`, a base class, a JSX or template tag or an Express `app.use` mount, stayed unlinked until the importing file changed or the project was re-indexed, so the module looked unused and `codegraph affected` missed the files that use it. Re-index after upgrading to pick up links an earlier version missed. - `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. - `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__/sync-typed-local-member-retry.test.ts b/__tests__/sync-typed-local-member-retry.test.ts new file mode 100644 index 0000000000..2323c0b97d --- /dev/null +++ b/__tests__/sync-typed-local-member-retry.test.ts @@ -0,0 +1,227 @@ +/** + * A sync links a member call on a local typed by an imported class once the + * class's module appears. + * + * `const cache = new LRUCache(2)` after `import { LRUCache } + * from './lru-cache'`, then `cache.get(key)`: a fresh index reads the local's + * declaration and links the call to `LRUCache.get` (folo's lru-cache tests, + * milo-ui's `astCache.get`). The same holds for an annotated local, a typed + * parameter and a class imported by default or through a namespace. While the + * module was missing or empty the call failed, and it was parked under its + * member's name, `get`, which more failed calls share than the retry's + * ceiling: a sync that brought the class back never retried it. + * + * Such a call is now parked under the key of the module the type's binding + * imports, as #2630 parks a member of the binding itself, and keeps its + * member's name as a key under that name's ceiling. + */ +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'; +import { DatabaseConnection, getDatabasePath } from '../src/db'; + +type Files = Record; + +/** 501 failed `get` calls on a receiver nothing declares: `get` is past the retry's ceiling. */ +const NOISE = `export function noise() {\n${Array.from({ length: 501 }, (_, i) => ` extApi.get(${i});`).join('\n')}\n}\n`; + +/** The files that call members of locals typed by a class they import. */ +const CALLERS: Files = { + 'package.json': JSON.stringify({ name: 'app' }), + 'src/use-new.ts': "import { LRUCache } from './lru-cache';\nexport function a() {\n const cache = new LRUCache(10);\n cache.set('k', 1);\n return cache.get('k');\n}\n", + 'src/use-annotated.ts': "import { LRUCache } from './lru-cache';\nexport function b(make: () => LRUCache) {\n const cache: LRUCache = make();\n return cache.get('k');\n}\n", + 'src/use-param.ts': "import { LRUCache } from './lru-cache';\nexport function c(cache: LRUCache) {\n return cache.get('k');\n}\n", + 'src/use-namespace.ts': "import * as lib from './lru-cache';\nexport function g() {\n const cache = new lib.LRUCache(3);\n return cache.get('k');\n}\n", + 'src/use-default.ts': "import Store from './store';\nexport function e() {\n const s = new Store();\n return s.get();\n}\n", + 'src/noise.ts': NOISE, +}; + +const MODULES: Files = { + 'src/lru-cache.ts': 'export class LRUCache {\n constructor(private max: number) {}\n get(key: K): V | undefined { return undefined; }\n set(key: K, value: V) { return this; }\n}\n', + 'src/store.ts': 'export default class Store {\n get() { return 1; }\n}\n', +}; + +/** What a full index links: `: -> ::`. */ +const LINKS = [ + 'cache.get: calls src/use-annotated.ts -> method src/lru-cache.ts::LRUCache::get', + 'cache.get: calls src/use-namespace.ts -> method src/lru-cache.ts::LRUCache::get', + 'cache.get: calls src/use-new.ts -> method src/lru-cache.ts::LRUCache::get', + 'cache.get: calls src/use-param.ts -> method src/lru-cache.ts::LRUCache::get', + 'cache.set: calls src/use-new.ts -> method src/lru-cache.ts::LRUCache::set', + 's.get: calls src/use-default.ts -> method src/store.ts::Store::get', +]; + +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 = []; +}); + +function tempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sync-typed-local-')); + roots.push(root); + return root; +} + +function write(root: string, files: Files): void { + 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); + } +} + +async function index(root: string): Promise { + const graph = await CodeGraph.init(root, { index: true }); + graphs.push(graph); + return graph; +} + +/** Every edge, by its ends' natural keys, with where it is written and how it resolved. */ +function edgesOf(graph: CodeGraph): string[] { + const keys = new Map( + graph.getFiles() + .flatMap((file) => graph.getNodesInFile(file.path)) + .map((n) => [n.id, `${n.kind} ${n.filePath}:${n.qualifiedName}:${n.startLine}`] as const) + ); + return graph.getOutgoingEdgesFrom([...keys.keys()]) + .map((e) => `${e.kind} ${keys.get(e.source)} -> ${keys.get(e.target) ?? e.target} @${e.line}:${e.column} ${JSON.stringify(e.metadata ?? {})}`) + .sort(); +} + +/** The calls on a lowercase local's member (`cache.get`): `: -> ::`. */ +function localMemberLinksOf(graph: CodeGraph): string[] { + const ids = graph.getFiles().flatMap((file) => graph.getNodesInFile(file.path)).map((n) => n.id); + const links: string[] = []; + for (const edge of graph.getOutgoingEdgesFrom(ids, ['calls'])) { + const ref = edge.metadata?.refName; + if (typeof ref !== 'string' || !/^[a-z]\w*\.\w+$/.test(ref) || ref.startsWith('extApi.')) continue; + const source = graph.getNode(edge.source)!; + const target = graph.getNode(edge.target)!; + links.push(`${ref}: ${edge.kind} ${source.filePath} -> ${target.kind} ${target.filePath}::${target.qualifiedName}`); + } + return [...new Set(links)].sort(); +} + +/** The final tree, indexed from scratch in a folder of its own: what the sync has to match. */ +async function fullIndexOf(files: Files): Promise { + const root = tempRoot(); + write(root, files); + return edgesOf(await index(root)); +} + +describe('sync links a member call on a local typed by an imported class', () => { + it('when the class\'s module is added after the callers', async () => { + const root = tempRoot(); + write(root, CALLERS); + const graph = await index(root); + expect(localMemberLinksOf(graph)).toEqual([]); + + write(root, MODULES); + const result = await graph.sync(); + // The callers did not change: only the retry of their parked refs links them. + expect(result.filesAdded).toBe(Object.keys(MODULES).length); + expect(result.filesModified).toBe(0); + expect(localMemberLinksOf(graph)).toEqual(LINKS); + expect(edgesOf(graph)).toEqual(await fullIndexOf({ ...CALLERS, ...MODULES })); + expect(graph.getPendingReferenceCount()).toBe(0); + }, 120_000); + + it('when the class\'s module comes back after it was deleted', async () => { + const root = tempRoot(); + write(root, { ...CALLERS, ...MODULES }); + const graph = await index(root); + expect(localMemberLinksOf(graph)).toEqual(LINKS); + + for (const file of Object.keys(MODULES)) fs.rmSync(path.join(root, file)); + expect((await graph.sync()).filesRemoved).toBe(Object.keys(MODULES).length); + expect(localMemberLinksOf(graph)).toEqual([]); + + write(root, MODULES); + expect((await graph.sync()).filesAdded).toBe(Object.keys(MODULES).length); + expect(localMemberLinksOf(graph)).toEqual(LINKS); + expect(edgesOf(graph)).toEqual(await fullIndexOf({ ...CALLERS, ...MODULES })); + }, 120_000); + + it('when an edit gives the module the class', async () => { + const root = tempRoot(); + write(root, CALLERS); + write(root, Object.fromEntries(Object.keys(MODULES).map((file) => [file, 'export const placeholder = 1;\n']))); + const graph = await index(root); + + write(root, MODULES); + const result = await graph.sync(); + expect(result.filesModified).toBe(Object.keys(MODULES).length); + expect(result.filesAdded).toBe(0); + expect(localMemberLinksOf(graph)).toEqual(LINKS); + expect(edgesOf(graph)).toEqual(await fullIndexOf({ ...CALLERS, ...MODULES })); + }, 120_000); +}); + +describe('a failed member call on a typed local', () => { + it('waits for the module of the class its declaration imports, and every other one keeps its own tail', async () => { + const root = tempRoot(); + write(root, { + ...CALLERS, + // A local of a built-in type, of a package's, of a type the file + // declares itself and one a factory returns: none waits for a module of + // the project. Nor does a local or a parameter declared as an array of + // an imported class, whose calls are the array's, or a method of `this` + // passed as a value, which resolution looks up in the enclosing class, + // whatever `this` is declared as. + 'src/other.ts': + "import { createCache, LRUCache } from './factory';\n" + + "import QuickLRU from 'quick-lru';\n" + + 'class Local { get() { return 1; } }\n' + + 'export function run() {\n' + + ' const seen = new Map();\n' + + " seen.get('k');\n" + + ' const pkg = new QuickLRU();\n' + + " pkg.get('k');\n" + + ' const mine: Local = new Local();\n' + + ' mine.peek();\n' + + ' const made = createCache();\n' + + " made.get('k');\n" + + ' const caches: LRUCache[] = [];\n' + + ' caches.push(made);\n' + + '}\n' + + 'export function each(all: LRUCache[]) {\n' + + ' return all.map((c) => c);\n' + + '}\n' + + 'export function bound(this: LRUCache) {\n' + + " return ['k'].map(this.peek);\n" + + '}\n', + }); + (await CodeGraph.init(root, { index: true })).close(); + + const db = DatabaseConnection.open(getDatabasePath(root)); + try { + const rows = db.getDb() + .prepare("SELECT reference_name AS name, file_path AS file, name_tail AS tail FROM unresolved_refs WHERE status = 'failed' AND reference_kind IN ('calls', 'function_ref')") + .all() as Array<{ name: string; file: string; tail: string }>; + const tails = (file: string, name: string) => [...new Set(rows.filter((r) => r.file === file && r.name === name).map((r) => r.tail))]; + + expect(tails('src/use-new.ts', 'cache.get')).toEqual(['module:lru-cache']); + expect(tails('src/use-new.ts', 'cache.set')).toEqual(['module:lru-cache']); + expect(tails('src/use-annotated.ts', 'cache.get')).toEqual(['module:lru-cache']); + expect(tails('src/use-param.ts', 'cache.get')).toEqual(['module:lru-cache']); + expect(tails('src/use-namespace.ts', 'cache.get')).toEqual(['module:lru-cache']); + expect(tails('src/use-default.ts', 's.get')).toEqual(['module:store']); + + expect(tails('src/other.ts', 'seen.get')).toEqual(['get']); + expect(tails('src/other.ts', 'pkg.get')).toEqual(['get']); + expect(tails('src/other.ts', 'mine.peek')).toEqual(['peek']); + expect(tails('src/other.ts', 'made.get')).toEqual(['get']); + expect(tails('src/other.ts', 'caches.push')).toEqual(['push']); + expect(tails('src/other.ts', 'all.map')).toEqual(['map']); + expect(tails('src/other.ts', 'this.peek')).toEqual(['peek']); + } finally { + db.close(); + } + }, 60_000); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 24a2732908..a5e2a1074a 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -22,6 +22,7 @@ import { localReceiverTypePatterns, normalizeInferredTypeName, goRefQualifier, + inferLocalReceiverType, } from './name-matcher'; /** @@ -3131,7 +3132,13 @@ export function isBoundToOutOfRepoImport( * member hangs off is what another member returns or holds, seldom a type * the module declares. Parked under the module as well, they tripled the * refs an edit of a hot module retries on vscode, and none of them resolved. - * Resolution reaches either through the module, so a sync's retry has to find + * - a direct member call on a local the file declares with a type it + * imports (`const cache = new LRUCache(10)`, `cache: LRUCache`, a + * typed parameter, then `cache.get(k)`), but not an array of it: + * resolution reads that declaration and calls the member of the class, + * which the type's binding imports, so the call waits for that module as a + * member of the binding itself does. + * Resolution reaches each through the module, so a sync's retry has to find * it by the module: under the module's {@link moduleTail} ('module:tag'), the * key a sync looks up for every file it adds or changes, so the file that * appears or gains the export finds it. @@ -3148,7 +3155,9 @@ export function importBindingTail(ref: UnresolvedRef, context: ResolutionContext const receiver = ref.referenceKind === 'imports' ? undefined : MEMBER_RECEIVER.exec(ref.referenceName)?.[1]; const name = receiver ?? ref.referenceName; if (!receiver && !IDENTIFIER.test(name)) return undefined; - const binding = context.getImportMappings(ref.filePath, ref.language).find((m) => m.localName === name); + const mappings = context.getImportMappings(ref.filePath, ref.language); + const binding = mappings.find((m) => m.localName === name) ?? + (receiver && ref.referenceKind === 'calls' ? localTypeBinding(receiver, ref, context, mappings) : undefined); if (!binding || (!receiver && !binding.isDefault && !binding.isNamespace && binding.exportedName === binding.localName)) return undefined; if (isExternalImport(binding.source, ref.language, context) && // A monorepo app's own tsconfig alias (`#/views/…`): isExternalImport reads the root's only. @@ -3256,6 +3265,41 @@ export function pythonModulePath(source: string, fromFile: string): string | nul return modulePath === '..' || modulePath.startsWith('../') ? null : modulePath; } +/** + * The import binding of the type a call's receiver is declared with, read the + * way resolution reads it for the call (inferLocalReceiverType, memoized per + * file, scope and receiver, so a call the matcher just tried costs a lookup): + * the binding the type is written through, `LRUCache` of `new LRUCache(…)` or `lib` of `new lib.LRUCache()`. Undefined for a type the file + * doesn't import, in a file that imports nothing, and for a local declared as + * an array of the type (`results: URI[]`). Resolution reads the element type + * there too, but the call is the array's (`results.push(…)`), which no edit + * of the type's module links: such calls were most of what this parked on + * vscode, and retrying them nearly doubled what an edit of `uri.ts` retried. + */ +function localTypeBinding(receiver: string, ref: UnresolvedRef, context: ResolutionContext, mappings: ImportMapping[]): ImportMapping | undefined { + if (mappings.length === 0) return undefined; + const decl: { raw?: string; line?: number } = {}; + if (!inferLocalReceiverType(receiver, ref, context, decl) || !decl.raw) return undefined; + const head = decl.raw.split('.')[0]; + const binding = mappings.find((m) => m.localName === head); + if (!binding || decl.line === undefined) return binding; + const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/); + const declaration = lines?.[decl.line]; + return declaration !== undefined && declaresArrayOf(declaration, receiver, decl.raw) ? undefined : binding; +} + +/** A `name: Type` annotation, the type's arguments, and the `[` of an array of it: `items: Item[]`. */ +const ANNOTATION = /([A-Za-z_$][\w$]*)\s*:\s*([A-Za-z_$][\w.$]*)\s*(?:<[^;=(){}<>]*(?:<[^;=(){}<>]*>[^;=(){}<>]*)*>)?\s*(\[)?/g; + +/** Whether `line` annotates `name` as an array of `type`: `items: Item[]`, `items: Item[] = []`. */ +function declaresArrayOf(line: string, name: string, type: string): boolean { + for (const m of line.matchAll(ANNOTATION)) { + if (m[1] === name && m[2] === type) return m[3] !== undefined; + } + return false; +} + const IDENTIFIER = /^[A-Za-z_$][\w$]*$/; /** The receiver of a direct member reference: `store` of `store.get`, none of `store.get().then` or `a.b.c`. */ const MEMBER_RECEIVER = /^([A-Za-z_$][\w$]*)\.[A-Za-z_$][\w$]*$/; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index d3cf05b889..55162fdd95 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -13584,7 +13584,7 @@ function enclosingScopeStartLine(ref: UnresolvedRef, context: ResolutionContext) * without patterns or when no declaration is found. Bounded to the enclosing * scope so a same-named variable in another function can't leak in. */ -function inferLocalReceiverType( +export function inferLocalReceiverType( receiverName: string, ref: UnresolvedRef, context: ResolutionContext, @@ -13713,7 +13713,10 @@ function inferLocalReceiverType( const type = matchLine(i); if (type) { states.set(key, { hi: callIdx, ansIdx: i, ansType: type, ansRaw: lastRaw }); - if (decl) decl.raw = lastRaw; + if (decl) { + decl.raw = lastRaw; + decl.line = i; + } return type; } } @@ -13732,13 +13735,19 @@ function inferLocalReceiverType( } state.hi = callIdx; if (state.ansIdx < startIdx) return null; - if (decl) decl.raw = state.ansRaw; + if (decl) { + decl.raw = state.ansRaw; + decl.line = state.ansIdx; + } return state.ansType; } for (let i = callIdx; i >= startIdx; i--) { const type = matchLine(i); if (type) { - if (decl) decl.raw = lastRaw; + if (decl) { + decl.raw = lastRaw; + decl.line = i; + } return type; } } @@ -14956,13 +14965,16 @@ interface GoProjectType { /** * What inferLocalReceiverType tells its caller besides the type: the type as - * the declaration spells it (`raw`), and for a Go local bound from a type - * assertion, the type goWrittenType finds for it (`asserted`; null for one - * with no method the project declares). `asserted` is unset when the local - * is declared some other way, or goWrittenType can't tell its type. + * the declaration spells it (`raw`), the 0-based index of the line it was + * read from (`line`, set with `raw` by the line scan), and for a Go local + * bound from a type assertion, the type goWrittenType finds for it + * (`asserted`; null for one with no method the project declares). `asserted` + * is unset when the local is declared some other way, or goWrittenType can't + * tell its type. */ interface GoReceiverDecl { raw?: string; + line?: number; asserted?: GoProjectType | null; } From 8ed0202071b804b715fc1d0cbd8985aff347341b Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sun, 18 Oct 2026 21:54:50 +0000 Subject: [PATCH 03/33] fix(resolution): workspace globs reach nested members, and a negated glob leaves members out (#2638) pnpm's `packages/**/*` (folo) and npm's `libs/**/*` (bitwarden) list member packages at any depth, `packages/plugin-*` and `apps/*/web` name them by part of a folder name or by a folder below the wildcard, and a negated glob (`!**/example`, `!examples/with-nx`) leaves some out. The workspace loader listed only the folders directly under the text before the first `*` and took a negation for a folder name, so folo's `@follow/utils`, two levels under `packages/`, was no member and every import of it counted as an outside package's. Globs now match folder by folder: `*`, `?` and `{a,b}` within a name, `**` across folders, breadth-first so a member nearer the root keeps a name a deeper copy also declares. A wildcard never enters a dot-folder, `node_modules` or `bower_components`, and a `**` skips `test` and `tests` folders as pnpm's default ignores do; a folder the glob names literally is always entered. What a negated glob matches is no member. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/workspace-nested-globs.test.ts | 169 +++++++++++++++++++++++ src/resolution/workspace-packages.ts | 137 +++++++++++++++--- 3 files changed, 291 insertions(+), 16 deletions(-) create mode 100644 __tests__/workspace-nested-globs.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b35d11dc4a..eb4add53d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, a file that re-exports a module, like an `index.ts` with `export * from './button'` or `export { Button } from './button'`, now counts as importing that module, the way an `import` statement does. `codegraph affected` and a module's dependents now include the barrels that re-export it; before, a barrel made only of `export *` lines showed no dependency at all. Re-index JavaScript and TypeScript projects after upgrading. - `codegraph sync` and the file watcher now also update the files that import through an `index.ts` re-exporting another barrel, like `export * from './dropdown-menu'`, when that inner barrel is deleted or added. Before, their imports stayed linked through the deleted barrel, or stayed linked the way they were while it was missing, until each importing file changed or the project was re-indexed. - An import in a Vue, Svelte or Astro component now links the component to the file it imports, as an import in a script does, and so does an import of a scoped package that a `tsconfig` path or a workspace package maps to a project file, like `import { debounce } from '@excalidraw/common'`. Before, only the names such an import binds were linked, so a barrel or a module imported for its side effects showed no dependency from those files in `codegraph affected`, and `codegraph sync` could not update them when a barrel they import through was deleted or added. `codegraph sync` and the file watcher also link such an import again, and one written as `.` or `..`, when the file it names comes back. Re-index JavaScript, TypeScript, Vue, Svelte and Astro projects after upgrading. +- In a pnpm, npm, Yarn or Bun workspace whose globs reach packages more than one folder down, like pnpm's `packages/**/*` or npm's `libs/**/*`, those packages now count as part of the project, so an import of one is no longer taken for an outside package's, and `codegraph sync` and the file watcher relink the calls through it when its files change. A workspace glob can now also name packages by part of a folder name (`packages/plugin-*`) or by a folder below the wildcard (`apps/*/web`), and a negated glob like `!**/example` leaves packages out, as the package managers do. Before, only the folders directly under a glob's first wildcard were read, and a negated glob was ignored. Re-index after upgrading. - In a project that uses Vue, an import written through an `@/` or `~/` alias, like `import { Button } from '@/components'`, now links the file it imports, as it does in any other project, so `codegraph sync` and the file watcher update the files that import a module when that module, or a barrel they import through, is added or deleted. Before, Vue support linked such an import to the first declaration or the component in that file, which sync does not follow, and an import through a `tsconfig` alias that starts like one of Nuxt's built-in modules, like `#app/where`, linked the importing file to itself. Re-index Vue projects after upgrading. - `codegraph sync` and the file watcher now move an import written without an extension, like `import { sizeOf } from './button'`, to a file added beside the one it found before when the new file comes first, such as `button.ts` next to `button.vue`. Before, the import and the names it binds stayed linked to `button.vue` until the importing file changed or the project was re-indexed. JavaScript, TypeScript, Vue, Svelte and Astro. - In C, C++ and Objective-C, a struct or class member declared as a pointer, reference, array or function, like `SharedState* shared;`, `jv elements[];` or `virtual Status Put(…) = 0;`, no longer makes the struct look like it inherits from the member's type. Before, `codegraph_explore` and impact listed those types as base classes, every struct holding a pointer to a type as one of its subclasses, and a method of that struct as an override of the type's method with the same name. Real base classes like `class Derived : public Base`, Objective-C superclasses and Go's embedded fields are unchanged. Re-index C, C++ and Objective-C projects after upgrading. diff --git a/__tests__/workspace-nested-globs.test.ts b/__tests__/workspace-nested-globs.test.ts new file mode 100644 index 0000000000..b08ad412b2 --- /dev/null +++ b/__tests__/workspace-nested-globs.test.ts @@ -0,0 +1,169 @@ +/** + * Workspace globs reach nested member packages. + * + * pnpm's `packages/**\/*` (folo) and npm's `libs/**\/*` (bitwarden) list + * members at any depth, `packages/plugin-*` and `apps/*\/web` name them by + * part of a folder name or by a folder below the wildcard, and a negated glob + * (`!**\/example`, `!examples/with-nx`) leaves some out. The workspace loader + * listed only the folders directly under the text before the first `*`, and + * took a negation for a folder name: folo's `@follow/utils`, two levels under + * `packages/`, was no member, so every import of it was taken for an outside + * package's, and an excluded example was a member all the same. + */ +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'; +import { loadWorkspacePackages } from '../src/resolution/workspace-packages'; + +type Files = Record; + +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 = []; +}); + +function workspace(files: Files): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ws-globs-')); + 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); + } + return root; +} + +const pkg = (name: string) => JSON.stringify({ name }); +const pnpm = (...globs: string[]) => `packages:\n${globs.map((g) => ` - '${g}'`).join('\n')}\n`; + +/** The loader's member map, as sorted `name -> dir` lines. */ +function members(root: string): string[] { + const ws = loadWorkspacePackages(root); + return [...(ws?.byName ?? new Map())].map(([name, dir]) => `${name} -> ${dir}`).sort(); +} + +describe('workspace globs', () => { + it("reach pnpm's nested members through `packages/**/*`", () => { + const root = workspace({ + 'package.json': pkg('root'), + 'pnpm-workspace.yaml': pnpm('apps/*', 'packages/**/*'), + 'apps/web/package.json': pkg('@acme/web'), + 'packages/cli/package.json': pkg('@acme/cli'), + 'packages/internal/utils/package.json': pkg('@acme/utils'), + 'packages/internal/ui/package.json': pkg('@acme/ui'), + 'packages/internal/AGENTS.md': '# not a package\n', + }); + expect(members(root)).toEqual([ + '@acme/cli -> packages/cli', + '@acme/ui -> packages/internal/ui', + '@acme/utils -> packages/internal/utils', + '@acme/web -> apps/web', + ]); + }); + + it("reach npm's nested members through `libs/**/*`, and `**` alone counts the folder itself", () => { + const root = workspace({ + 'package.json': JSON.stringify({ name: 'root', workspaces: ['libs/**/*', 'tools/**'] }), + 'libs/common/package.json': pkg('@bw/common'), + 'libs/tools/generator/core/package.json': pkg('@bw/generator-core'), + 'tools/package.json': pkg('@bw/tools'), + 'tools/lint/package.json': pkg('@bw/lint'), + }); + expect(members(root)).toEqual([ + '@bw/common -> libs/common', + '@bw/generator-core -> libs/tools/generator/core', + '@bw/lint -> tools/lint', + '@bw/tools -> tools', + ]); + }); + + it('match part of a folder name, a choice of names, and folders below the wildcard', () => { + const root = workspace({ + 'package.json': JSON.stringify({ name: 'root', workspaces: ['packages/plugin-*', '{apps,sites}/*/web'] }), + 'packages/plugin-a/package.json': pkg('plugin-a'), + 'packages/core/package.json': pkg('core'), + 'apps/shop/web/package.json': pkg('shop-web'), + 'apps/shop/package.json': pkg('shop'), + 'sites/blog/web/package.json': pkg('blog-web'), + }); + expect(members(root)).toEqual([ + 'blog-web -> sites/blog/web', + 'plugin-a -> packages/plugin-a', + 'shop-web -> apps/shop/web', + ]); + }); + + it('leave out what a negated glob matches, wherever it is listed', () => { + const root = workspace({ + 'package.json': pkg('root'), + // Quoted both ways and bare, as folo writes them. + 'pnpm-workspace.yaml': "packages:\n - '!packages/legacy'\n - packages/*\n - \"apps/**\"\n - \"!**/example\"\n - '!examples/with-*'\n - examples/*\n", + 'packages/core/package.json': pkg('core'), + 'packages/legacy/package.json': pkg('legacy'), + 'apps/mobile/package.json': pkg('mobile'), + 'apps/mobile/native/example/package.json': pkg('mobile-example'), + 'examples/basic/package.json': pkg('example-basic'), + 'examples/with-nx/package.json': pkg('example-nx'), + }); + expect(members(root)).toEqual([ + 'core -> packages/core', + 'example-basic -> examples/basic', + 'mobile -> apps/mobile', + ]); + }); + + it("don't take installed packages, hidden folders or a `**` walk's test fixtures for members, unless a glob names them", () => { + const root = workspace({ + 'package.json': JSON.stringify({ name: 'root', workspaces: ['packages/**', 'e2e/tests/*'] }), + 'packages/core/package.json': pkg('core'), + 'packages/core/test/fixtures/react/package.json': pkg('react'), + 'packages/core/node_modules/lodash/package.json': pkg('lodash'), + 'packages/.cache/stale/package.json': pkg('stale'), + 'e2e/tests/smoke/package.json': pkg('smoke'), + }); + expect(members(root)).toEqual([ + 'core -> packages/core', + 'smoke -> e2e/tests/smoke', + ]); + }); + + it('keep the member nearest the root when a deeper copy declares its name', () => { + const root = workspace({ + 'package.json': pkg('root'), + 'pnpm-workspace.yaml': pnpm('packages/**'), + 'packages/a-build/core/package.json': pkg('@acme/core'), + 'packages/core/package.json': pkg('@acme/core'), + }); + // `packages/a-build/core` sorts first, but `packages/core` is a level up. + expect(members(root)).toEqual(['@acme/core -> packages/core']); + }); +}); + +describe('a call through an import of a nested workspace member', () => { + it('links to the member package', async () => { + const root = workspace({ + 'package.json': pkg('root'), + 'pnpm-workspace.yaml': pnpm('apps/*', 'packages/**/*'), + 'apps/web/package.json': pkg('@acme/web'), + // Imported under another name, so only the import can link the call. + 'apps/web/src/main.ts': "import { fmt as format } from '@acme/utils';\nexport function main() {\n return format('x');\n}\n", + 'packages/internal/utils/package.json': pkg('@acme/utils'), + 'packages/internal/utils/index.ts': 'export function fmt(s: string) {\n return s.trim();\n}\n', + }); + const graph = await CodeGraph.init(root, { index: true }); + graphs.push(graph); + const fmt = graph.getNodesInFile('packages/internal/utils/index.ts').find((n) => n.name === 'fmt'); + expect(fmt).toBeDefined(); + // The call, and the file's import of the name. + expect(graph.getCallers(fmt!.id).map((c) => `${c.edge.kind} ${c.node.filePath}::${c.node.name}`).sort()).toEqual([ + 'calls apps/web/src/main.ts::main', + 'imports apps/web/src/main.ts::main.ts', + ]); + }, 60_000); +}); diff --git a/src/resolution/workspace-packages.ts b/src/resolution/workspace-packages.ts index 386c3e45e6..0d1e2de8ea 100644 --- a/src/resolution/workspace-packages.ts +++ b/src/resolution/workspace-packages.ts @@ -17,7 +17,9 @@ * Scope deliberately small for v1 (mirrors path-aliases.ts): * - reads `workspaces` (array OR `{ packages: [...] }`) from package.json, * plus a minimal `pnpm-workspace.yaml` `packages:` list - * - expands one level of `*` / `**` globs (`packages/*`, `apps/*`) + * - expands `*`, `?` and `{a,b}` within a folder name and `**` across + * folders (`packages/*`, pnpm's nested `packages/**\/*`), and leaves out + * what a negated glob (`!**\/example`) matches * - subpath resolution is directory-based (`@scope/ui/sub` → `/sub`); * it does NOT yet honour a member's `exports` map or `main` field * - returns null when the project declares no workspaces, so single- @@ -56,8 +58,13 @@ export function loadWorkspacePackages(projectRoot: string): WorkspacePackages | const byName = new Map(); const patterns = readWorkspaceGlobs(projectRoot); + // What a negated glob matches (`!examples/with-nx`, pnpm's `!**/example`) is + // no member, wherever the glob is listed. + const excluded = patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1)); for (const pattern of patterns) { + if (pattern.startsWith('!')) continue; for (const dir of expandWorkspaceGlob(projectRoot, pattern)) { + if (excluded.some((x) => workspaceGlobMatches(x, dir))) continue; const pkgName = readPackageName(path.join(projectRoot, dir)); // First declaration wins — workspace patterns are tried in order. if (pkgName && !byName.has(pkgName)) byName.set(pkgName, dir); @@ -291,28 +298,126 @@ function parsePnpmPackages(yaml: string): string[] { return out; } -/** Expand one level of a `packages/*` / `apps/**` glob to member dirs. */ +/** Folders no wildcard matches: installed packages. */ +const GLOB_SKIP_DIRS = new Set(['node_modules', 'bower_components']); +/** + * Folders a `**` doesn't descend into either, as pnpm's default ignores + * don't: their fixture packages are no members, and their names (`react`, + * `lodash`) would make imports of the real package the project's. + */ +const GLOBSTAR_SKIP_DIRS = new Set(['test', 'tests']); +/** How far a `**` reaches below the glob's literal prefix, and how many folders one glob reads. */ +const GLOBSTAR_MAX_DEPTH = 8; +const GLOB_DIR_BUDGET = 10_000; + +/** + * Expand a workspace glob to the member directories it matches, nearest + * first: `packages/*`, `packages/plugin-*`, `apps/*\/web`, `{apps,libs}/*`, + * and the nested members of pnpm's `packages/**\/*` or npm's `libs/**\/*` + * (folo's `@follow/utils` is `packages/internal/utils`, two levels down). + * A wildcard never matches a dot-folder or one of GLOB_SKIP_DIRS, and a `**` + * none of GLOBSTAR_SKIP_DIRS; a folder the glob names literally is always + * entered. Breadth-first with sorted names, so a member nearer the root wins + * a package name a deeper copy also declares. + */ function expandWorkspaceGlob(projectRoot: string, pattern: string): string[] { const norm = pattern.replace(/\\/g, '/').replace(/\/+$/, ''); - const star = norm.indexOf('*'); - if (star === -1) return [norm]; // exact directory + const glob = compileGlob(norm); + const first = glob.findIndex((seg) => seg.wildcard); + if (first === -1) return [norm]; // exact directory - // Everything before the wildcard segment is the base to enumerate. - const base = norm.slice(0, star).replace(/\/+$/, ''); - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(path.join(projectRoot, base), { withFileTypes: true }); - } catch { - return []; - } - const out: string[] = []; - for (const e of entries) { - if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules') continue; - out.push(base ? `${base}/${e.name}` : e.name); + const base = glob.slice(0, first).map((seg) => seg.text).join('/'); + const start = globClosure(glob, new Set([first])); + // `tools/**` is `tools` too, as npm and pnpm match `tools/**/package.json`. + const out: string[] = start.has(glob.length) ? [base || '.'] : []; + let level = [{ rel: base, states: start }]; + let read = 0; + for (let depth = 0; level.length > 0 && depth <= GLOBSTAR_MAX_DEPTH; depth++) { + const next: typeof level = []; + for (const { rel, states } of level) { + if (++read > GLOB_DIR_BUDGET) { + logDebug('workspace glob stopped at its folder budget', { pattern, read }); + return out; + } + let names: string[]; + try { + names = fs.readdirSync(path.join(projectRoot, rel), { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort(); + } catch { + continue; + } + for (const name of names) { + const advanced = globStep(glob, states, name, true); + if (advanced.size === 0) continue; + const child = rel ? `${rel}/${name}` : name; + if (advanced.has(glob.length)) out.push(child); + if ([...advanced].some((state) => state < glob.length)) next.push({ rel: child, states: advanced }); + } + } + level = next; } return out; } +/** Whether a negated workspace glob's pattern (`**\/example`, `examples/with-*`) matches a member directory. */ +function workspaceGlobMatches(pattern: string, dir: string): boolean { + const glob = compileGlob(pattern.replace(/\\/g, '/').replace(/\/+$/, '')); + let states = globClosure(glob, new Set([0])); + for (const name of dir.split('/').filter((s) => s !== '' && s !== '.')) { + states = globStep(glob, states, name, false); + if (states.size === 0) return false; + } + return states.has(glob.length); +} + +interface GlobSegment { + text: string; + /** `**`: any number of folders, none included. */ + globstar: boolean; + wildcard: boolean; + re: RegExp | null; +} + +/** A glob's folder segments: `*`, `?` and `{a,b}` within one, `**` across them. */ +function compileGlob(pattern: string): GlobSegment[] { + return pattern.split('/').filter((s) => s !== '' && s !== '.').map((text) => { + if (text === '**') return { text, globstar: true, wildcard: true, re: null }; + if (!/[*?{]/.test(text)) return { text, globstar: false, wildcard: false, re: null }; + const source = text + .replace(/[.+^$()|[\]\\]/g, '\\$&') + .replace(/\*+/g, '[^/]*') + .replace(/\?/g, '[^/]') + .replace(/\{([^{}]*)\}/g, (_, alts: string) => `(?:${alts.split(',').join('|')})`); + return { text, globstar: false, wildcard: true, re: new RegExp(`^${source}$`) }; + }); +} + +/** The states a folder named `name` leads to from `states`; `walking` skips what pnpm's walk skips. */ +function globStep(glob: GlobSegment[], states: Set, name: string, walking: boolean): Set { + const next = new Set(); + const hidden = walking && (name.startsWith('.') || GLOB_SKIP_DIRS.has(name)); + for (const i of states) { + const seg = glob[i]; + if (!seg) continue; + if (seg.globstar) { + if (!hidden && !(walking && GLOBSTAR_SKIP_DIRS.has(name))) next.add(i); + } else if (seg.re ? !hidden && seg.re.test(name) : seg.text === name) { + next.add(i + 1); + } + } + return globClosure(glob, next); +} + +/** `states` with every `**` that can match no folder at all stepped over. */ +function globClosure(glob: GlobSegment[], states: Set): Set { + for (const i of [...states]) { + for (let j = i; glob[j]?.globstar; j++) states.add(j + 1); + } + return states; +} + /** Read the `name` field from a member directory's package.json. */ function readPackageName(dirAbs: string): string | null { try { From fb213d0bc539270565a2ce2ad356fd0bfce80ec6 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sun, 18 Oct 2026 21:55:15 +0000 Subject: [PATCH 04/33] fix(ts): an array method on a local annotated as an array of a class links to nothing, not the class's method (#2639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `items: Item[]` was read as an `Item`: the annotation's capture stopped at `[`, so `items.find(…)` linked to `Item.find` whenever the class declares a method of that name. On vscode that was 16 wrong edges, like `distinctElements.find(…)` on an `ExplorerItem[]` reaching `ExplorerItem.find` and `ranges.map(…)` on an `OffsetRange[]` reaching `OffsetRange.map`. The annotation pattern now captures a bracket suffix with the type. On an array of a type, a method arrays have links to nothing and isn't matched by its name either. Any other method can't be called on the array, so it is called on an element that shares its name, as in `d.forEach(d => d.dispose())`, and keeps the element's type as before. An indexed type (`Element["groupIds"]`) is still read as the type it indexes. The sync parking that came with the previous change now reads arrays from the type inference gives instead of re-reading the declaration line: an array method on an array keeps its member's tail, and an element's or an indexed type's call waits for that type's module. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../sync-typed-local-member-retry.test.ts | 14 +- .../ts-array-annotated-receivers.test.ts | 135 ++++++++++++++++++ src/resolution/import-resolver.ts | 40 ++---- src/resolution/js-builtins.ts | 15 ++ src/resolution/name-matcher.ts | 51 ++++--- 6 files changed, 207 insertions(+), 49 deletions(-) create mode 100644 __tests__/ts-array-annotated-receivers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index eb4add53d8..98020a1270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph sync` and the file watcher now link a file to a module it imports under a name of its own when the module is added after the file was indexed, comes back after being deleted, or gets the export in a later edit: a default import like `import tagsController from './tag/tag.controller'`, a namespace import like `import * as api from './api'`, or an aliased one like `import { Component as GroupsPageWrapper } from './groups_page'`, in JavaScript, TypeScript, Vue, Svelte and Astro. Before, the import and every use of the name, such as a call, `new`, a base class, a JSX or template tag or an Express `app.use` mount, stayed unlinked until the importing file changed or the project was re-indexed, so the module looked unused and `codegraph affected` missed the files that use it. Re-index after upgrading to pick up links an earlier version missed. - `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. - `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__/sync-typed-local-member-retry.test.ts b/__tests__/sync-typed-local-member-retry.test.ts index 2323c0b97d..a49a184d80 100644 --- a/__tests__/sync-typed-local-member-retry.test.ts +++ b/__tests__/sync-typed-local-member-retry.test.ts @@ -35,6 +35,10 @@ const CALLERS: Files = { 'src/use-param.ts': "import { LRUCache } from './lru-cache';\nexport function c(cache: LRUCache) {\n return cache.get('k');\n}\n", 'src/use-namespace.ts': "import * as lib from './lru-cache';\nexport function g() {\n const cache = new lib.LRUCache(3);\n return cache.get('k');\n}\n", 'src/use-default.ts': "import Store from './store';\nexport function e() {\n const s = new Store();\n return s.get();\n}\n", + // A call on an element that shares the array's name: the element's class. + 'src/use-each.ts': "import { LRUCache } from './lru-cache';\nexport function h(cs: LRUCache[]) {\n cs.forEach((cs) => cs.get('k'));\n}\n", + // One of the class's properties, which resolution reads as the class. + 'src/use-prop.ts': "import { LRUCache } from './lru-cache';\nexport function i(max: LRUCache['max']) {\n return max.toFixed(0);\n}\n", 'src/noise.ts': NOISE, }; @@ -50,6 +54,7 @@ const LINKS = [ 'cache.get: calls src/use-new.ts -> method src/lru-cache.ts::LRUCache::get', 'cache.get: calls src/use-param.ts -> method src/lru-cache.ts::LRUCache::get', 'cache.set: calls src/use-new.ts -> method src/lru-cache.ts::LRUCache::set', + 'cs.get: calls src/use-each.ts -> method src/lru-cache.ts::LRUCache::get', 's.get: calls src/use-default.ts -> method src/store.ts::Store::get', ]; @@ -171,9 +176,9 @@ describe('a failed member call on a typed local', () => { // A local of a built-in type, of a package's, of a type the file // declares itself and one a factory returns: none waits for a module of // the project. Nor does a local or a parameter declared as an array of - // an imported class, whose calls are the array's, or a method of `this` - // passed as a value, which resolution looks up in the enclosing class, - // whatever `this` is declared as. + // an imported class, whose array methods are the array's, or a method of + // `this` passed as a value, which resolution looks up in the enclosing + // class, whatever `this` is declared as. 'src/other.ts': "import { createCache, LRUCache } from './factory';\n" + "import QuickLRU from 'quick-lru';\n" + @@ -212,6 +217,9 @@ describe('a failed member call on a typed local', () => { expect(tails('src/use-param.ts', 'cache.get')).toEqual(['module:lru-cache']); expect(tails('src/use-namespace.ts', 'cache.get')).toEqual(['module:lru-cache']); expect(tails('src/use-default.ts', 's.get')).toEqual(['module:store']); + expect(tails('src/use-each.ts', 'cs.get')).toEqual(['module:lru-cache']); + expect(tails('src/use-each.ts', 'cs.forEach')).toEqual(['forEach']); + expect(tails('src/use-prop.ts', 'max.toFixed')).toEqual(['module:lru-cache']); expect(tails('src/other.ts', 'seen.get')).toEqual(['get']); expect(tails('src/other.ts', 'pkg.get')).toEqual(['get']); diff --git a/__tests__/ts-array-annotated-receivers.test.ts b/__tests__/ts-array-annotated-receivers.test.ts new file mode 100644 index 0000000000..70982b9687 --- /dev/null +++ b/__tests__/ts-array-annotated-receivers.test.ts @@ -0,0 +1,135 @@ +/** + * A call on a TypeScript local or parameter annotated as an array of a class + * is the array's method, not the class's. + * + * `items: Item[]` was read as an `Item`, because the annotation's capture + * stopped at `[`, so `items.find(…)` linked to `Item.find` whenever the class + * declares a method of that name: on vscode, `distinctElements.find(…)` on + * an `ExplorerItem[]` reached `ExplorerItem.find`, and `ranges.map(…)` on an + * `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. + */ +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-array-recv-')); + 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) => `${e.metadata?.refName} -> ${byId.get(e.target)?.qualifiedName}`) + .sort(); +} + +const CLASSES = { + 'package.json': JSON.stringify({ name: 'app' }), + 'src/item.ts': + 'export class Item {\n' + + ' find(key: string) { return key; }\n' + + ' push(value: number) { return value; }\n' + + ' flush() { return 0; }\n' + + '}\n' + + 'export class Repository {\n' + + ' save(value: T) { return value; }\n' + + ' find(id: string) { return id; }\n' + + '}\n', + // The one project method named `findLast`, on an unrelated class. + 'src/stack.ts': 'export class Stack {\n findLast() { return 1; }\n}\n', +}; + +describe('a call on a TypeScript local annotated as an array of a class', () => { + it('links to no method of the element class', async () => { + const calls = await callsOf({ + ...CLASSES, + 'src/use.ts': + "import { Item } from './item';\n" + + 'export function first(items: Item[]) {\n' + + " return items.find((i) => i !== undefined);\n" + + '}\n' + + 'export function fill() {\n' + + ' const list: Item[] = [];\n' + + ' list.push(new Item());\n' + + ' return list;\n' + + '}\n' + + 'export function spaced(all: Item [], grid: Item[][]) {\n' + + " all.find((i) => i !== undefined);\n" + + ' return grid.push([]);\n' + + '}\n', + }); + expect(calls.filter((c) => /^(items|list|all|grid)\./.test(c))).toEqual([]); + }, 60_000); + + it("is not matched to another class's method by the call's name instead", async () => { + const calls = await callsOf({ + ...CLASSES, + 'src/use.ts': + "import { Item } from './item';\n" + + 'export function last(all: Item[]) {\n' + + ' return all.findLast((i) => i !== undefined);\n' + + '}\n', + }); + expect(calls.filter((c) => c.startsWith('all.'))).toEqual([]); + }, 60_000); + + it('does the same for an array of a generic class', async () => { + const calls = await callsOf({ + ...CLASSES, + 'src/use.ts': + "import { Repository } from './item';\n" + + 'export function saveAll(repos: Repository[], value: string) {\n' + + ' repos.forEach((r) => r.save(value));\n' + + " return repos.find((r) => r !== undefined);\n" + + '}\n', + }); + expect(calls.filter((c) => c.startsWith('repos.'))).toEqual([]); + }, 60_000); + + it("still links a method arrays don't have to the element's class: the call is on an element of the same name", async () => { + // vscode's `d.forEach(d => d.dispose())` on an `IDisposable[]`, and + // `extensions.map(extensions => extensions.syncInstalledExtensionsWithGallery(…))`. + const calls = await callsOf({ + ...CLASSES, + 'src/use.ts': + "import { Item } from './item';\n" + + 'export function flushAll(d: Item[]) {\n' + + ' d.forEach((d) => d.flush());\n' + + '}\n', + }); + expect(calls.filter((c) => c.startsWith('d.'))).toEqual(['d.flush -> Item::flush']); + }, 60_000); + + it('still links a call on a local annotated as the class, generic or not', async () => { + const calls = await callsOf({ + ...CLASSES, + 'src/use.ts': + "import { Item, Repository } from './item';\n" + + 'export function one(item: Item, repo: Repository) {\n' + + " repo.find('x');\n" + + " return item.find('k');\n" + + '}\n', + }); + expect(calls).toEqual(['item.find -> Item::find', 'repo.find -> Repository::find']); + }, 60_000); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index a5e2a1074a..9fa572206f 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -23,7 +23,9 @@ import { normalizeInferredTypeName, goRefQualifier, inferLocalReceiverType, + arrayElementType, } from './name-matcher'; +import { JS_ARRAY_METHODS } from './js-builtins'; /** * Extension resolution order by language @@ -3270,34 +3272,22 @@ export function pythonModulePath(source: string, fromFile: string): string | nul * way resolution reads it for the call (inferLocalReceiverType, memoized per * file, scope and receiver, so a call the matcher just tried costs a lookup): * the binding the type is written through, `LRUCache` of `new LRUCache(…)` or `lib` of `new lib.LRUCache()`. Undefined for a type the file - * doesn't import, in a file that imports nothing, and for a local declared as - * an array of the type (`results: URI[]`). Resolution reads the element type - * there too, but the call is the array's (`results.push(…)`), which no edit - * of the type's module links: such calls were most of what this parked on - * vscode, and retrying them nearly doubled what an edit of `uri.ts` retried. + * V>(…)` or `lib` of `new lib.LRUCache()`, of the element type of an array + * (`results: URI[]`), or of the type whose property it is (`Emitter['event']`). + * Undefined for a type the file doesn't import, in a file that imports + * nothing, and for a method arrays have called on an array (`results.push(…)`): + * resolution links that to no project class, so no edit of the type's module + * does. Such calls were most of what this parked on vscode, and retrying them + * nearly doubled what an edit of `uri.ts` retried. */ function localTypeBinding(receiver: string, ref: UnresolvedRef, context: ResolutionContext, mappings: ImportMapping[]): ImportMapping | undefined { if (mappings.length === 0) return undefined; - const decl: { raw?: string; line?: number } = {}; - if (!inferLocalReceiverType(receiver, ref, context, decl) || !decl.raw) return undefined; - const head = decl.raw.split('.')[0]; - const binding = mappings.find((m) => m.localName === head); - if (!binding || decl.line === undefined) return binding; - const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/); - const declaration = lines?.[decl.line]; - return declaration !== undefined && declaresArrayOf(declaration, receiver, decl.raw) ? undefined : binding; -} - -/** A `name: Type` annotation, the type's arguments, and the `[` of an array of it: `items: Item[]`. */ -const ANNOTATION = /([A-Za-z_$][\w$]*)\s*:\s*([A-Za-z_$][\w.$]*)\s*(?:<[^;=(){}<>]*(?:<[^;=(){}<>]*>[^;=(){}<>]*)*>)?\s*(\[)?/g; - -/** Whether `line` annotates `name` as an array of `type`: `items: Item[]`, `items: Item[] = []`. */ -function declaresArrayOf(line: string, name: string, type: string): boolean { - for (const m of line.matchAll(ANNOTATION)) { - if (m[1] === name && m[2] === type) return m[3] !== undefined; - } - return false; + const decl: { raw?: string } = {}; + const type = inferLocalReceiverType(receiver, ref, context, decl); + if (!type || !decl.raw) return undefined; + if (arrayElementType(type) !== undefined && JS_ARRAY_METHODS.has(ref.referenceName.slice(receiver.length + 1))) return undefined; + const head = decl.raw.split(/[.<[\s]/)[0]; + return mappings.find((m) => m.localName === head); } const IDENTIFIER = /^[A-Za-z_$][\w$]*$/; diff --git a/src/resolution/js-builtins.ts b/src/resolution/js-builtins.ts index 3d877759e1..435dbd609f 100644 --- a/src/resolution/js-builtins.ts +++ b/src/resolution/js-builtins.ts @@ -33,6 +33,21 @@ export const JS_BUILTIN_METHODS = new Set([ 'text', 'json', 'arrayBuffer', 'blob', 'formData', ]); +/** + * The methods an array has: `Array.prototype`'s and those every object + * inherits. On a local annotated as an array (`items: Item[]`) a call of one + * is the array's, never a method of the element's class. + */ +export const JS_ARRAY_METHODS = new Set([ + 'at', 'concat', 'copyWithin', 'entries', 'every', 'fill', 'filter', 'find', + 'findIndex', 'findLast', 'findLastIndex', 'flat', 'flatMap', 'forEach', + 'includes', 'indexOf', 'join', 'keys', 'lastIndexOf', 'map', 'pop', 'push', + 'reduce', 'reduceRight', 'reverse', 'shift', 'slice', 'some', 'sort', 'splice', + 'toReversed', 'toSorted', 'toSpliced', 'unshift', 'values', 'with', + 'toString', 'toLocaleString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf', + 'propertyIsEnumerable', +]); + /** * TypeScript primitive type names. Distinct from JS_BUILT_INS on purpose: those * are runtime globals a receiver can be constructed from, these only ever come diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 55162fdd95..32d9b40417 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -9,7 +9,7 @@ import * as path from 'path'; import { Language, Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, isSupertypeTarget, CPP_DEFINE_SIGNATURE, isInheritanceRef, isImportableKind } from './types'; import { blankStringContents, stripCommentsForRegex } from './strip-comments'; -import { JS_BUILT_INS, JS_BUILTIN_METHODS, TS_PRIMITIVE_TYPES } from './js-builtins'; +import { JS_ARRAY_METHODS, JS_BUILT_INS, JS_BUILTIN_METHODS, TS_PRIMITIVE_TYPES } from './js-builtins'; import { SWIFT_TYPE_PATH_CALL, resolveSwiftTypePathCall } from './swift-type-visibility'; import { dartImportPrefixes, dartLibrarySees, dartPrefixSees, inSameDartLibrary } from './dart-libraries'; import { isDartLocallyBound } from './dart-local-scope'; @@ -12847,6 +12847,11 @@ export function normalizeInferredTypeName(raw: string): string | null { return seg; } +/** The element type of an array type a TS annotation gave (`Item[]`, `Item []` → `Item`), or undefined. */ +export function arrayElementType(type: string): string | undefined { + return /^(.+?)\s*\[\s*\]$/.exec(type)?.[1]; +} + /** * A Java / C# type's optional type arguments (one level of nesting) and array * ranks, as a regex source: ``, `>`, `[]`, `[,]`. @@ -13061,7 +13066,11 @@ function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[] // at `<` so a generic-typed param (`repo: Repository`) still yields // `Repository`. resolveMethodOnType validates the type actually declares // the method, so the looser match produces no edge on a mis-inference. - new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.$]*)`), // lg: Logger (annotation or typed param) + // 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"]`). + new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.$]*(?:(?:\\s*<[^;=(){}<>]*(?:<[^;=(){}<>]*>[^;=(){}<>]*)*>)?\\s*\\[[^\\]]*\\])?)`), // lg: Logger (annotation or typed param) ]; case 'python': return [ @@ -13713,10 +13722,7 @@ export function inferLocalReceiverType( const type = matchLine(i); if (type) { states.set(key, { hi: callIdx, ansIdx: i, ansType: type, ansRaw: lastRaw }); - if (decl) { - decl.raw = lastRaw; - decl.line = i; - } + if (decl) decl.raw = lastRaw; return type; } } @@ -13735,19 +13741,13 @@ export function inferLocalReceiverType( } state.hi = callIdx; if (state.ansIdx < startIdx) return null; - if (decl) { - decl.raw = state.ansRaw; - decl.line = state.ansIdx; - } + if (decl) decl.raw = state.ansRaw; return state.ansType; } for (let i = callIdx; i >= startIdx; i--) { const type = matchLine(i); if (type) { - if (decl) { - decl.raw = lastRaw; - decl.line = i; - } + if (decl) decl.raw = lastRaw; return type; } } @@ -14211,6 +14211,18 @@ export function matchMethodCall( if (!awaited.name || TS_PRIMITIVE_TYPES.has(awaited.name)) return null; inferredType = awaited.name; } + // A TS annotation with brackets after the type. An array of it (`items: + // Item[]`): a method arrays have is the array's, which no project class + // 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. + if (inferredType && ESM_FAMILY.has(ref.language) && inferredType.endsWith(']')) { + const element = arrayElementType(inferredType); + if (element !== undefined && JS_ARRAY_METHODS.has(methodName!)) return null; + inferredType = element ?? inferredType.replace(/\s*\[[^\]]*\]$/, ''); + } if (inferredType) { // A Go local bound from a type assertion calls a method of the type Go // finds for it, or nothing the project declares: `flusher.Flush()` after @@ -14965,16 +14977,13 @@ interface GoProjectType { /** * What inferLocalReceiverType tells its caller besides the type: the type as - * the declaration spells it (`raw`), the 0-based index of the line it was - * read from (`line`, set with `raw` by the line scan), and for a Go local - * bound from a type assertion, the type goWrittenType finds for it - * (`asserted`; null for one with no method the project declares). `asserted` - * is unset when the local is declared some other way, or goWrittenType can't - * tell its type. + * the declaration spells it (`raw`), and for a Go local bound from a type + * assertion, the type goWrittenType finds for it (`asserted`; null for one + * with no method the project declares). `asserted` is unset when the local + * is declared some other way, or goWrittenType can't tell its type. */ interface GoReceiverDecl { raw?: string; - line?: number; asserted?: GoProjectType | null; } From 10e316d9a7d99a5f9cb075256fd403c05b63eccc Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Mon, 19 Oct 2026 01:32:46 +0000 Subject: [PATCH 05/33] fix(ts): a typed call links to the method of the class the file imports (#2641) When several classes share a name, a TypeScript or JavaScript call on a typed value broke the tie only by the call site's own file, then took the first declaration indexed, and a `this.` call by folder nearness. On vscode, about 25,000 calls on classes vscode imports from its own `vs/base` and `vs/platform` reached the copies the Copilot extension vendors, 907 `Emitter` calls a copilot test fixture's copy, and 952 calls whose first match was a test fixture's class linked to nothing. The file declaring the class the call's file imports under the written name, read through re-exports, `import type`, namespace imports, default imports and `require`, is now passed to resolveMethodOnType, which picks that file's method among several, through the supertype walk too. `this.` calls check the import of the call's own file before folder nearness. Java, Kotlin and every other language are unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/ts-imported-type-receivers.test.ts | 180 +++++++++++++++++++ src/resolution/name-matcher.ts | 79 ++++++-- 3 files changed, 247 insertions(+), 13 deletions(-) create mode 100644 __tests__/ts-imported-type-receivers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 98020a1270..2e79cd4207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,6 +163,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's ` items={…} />` or excalidraw's ` …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading. - In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading. - In React, a JSX tag imported from one of your own modules now links to the component that module exports under that name, read the way a lazily loaded page's module already is. That includes going through an `index` file or barrel that passes the component on, like outline's `Sidebar/index.ts` with `import Sidebar from './App'; export default Sidebar` or its `Avatar/index.ts` with `const Avatar = AvatarWithHoverCard`, and past a wrapper such as `export default observer(AppSidebar)`, `React.memo(StaticCanvas, areEqual)` or mastodon's `const MemoizedBlurhash = memo(Blurhash)`. Before, a tag was matched by its name and the import only broke ties, so outline's `` linked to an unrelated styled `Sidebar` in a sibling file, a default import under another name linked to whatever other component its module exported, and an import through a barrel could land on another app's component of the same name. A tag whose module exports an unnamed component, like `export default () => …`, now links to nothing instead of to a namesake elsewhere. A lazily loaded React Router page exported as such a value or wrapper links to the component it holds too, the one with the markup. `codegraph sync` and the file watcher redraw these links when only the file that passes the component on changes. Re-index React projects after upgrading. +- In TypeScript and JavaScript, a call on a variable, parameter or field whose class the file imports, like `store.add(…)` with `store: DisposableStore`, now links to the method of the class that import names when other classes in the project share its name, such as a vendored copy, a test mock or another app's class. Before, it linked to whichever of them was indexed first, or to the one in the nearest folder: in vscode, calls on classes imported from vscode's own `vs/base` and `vs/platform` reached the copies the Copilot extension vendors or its test fixtures, in bitwarden the desktop app's `initService.init()` reached the browser extension's `InitService`, and in react-native `Keyboard` and `WebSocket` reached the Jest mock of `NativeEventEmitter`. A call whose first match was a test file's class, which then linked to nothing, now reaches the imported class too. The import is followed through re-exports, `import type`, namespace imports like `new async.Delayer()` and `require(…)`. Re-index TypeScript and JavaScript projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/ts-imported-type-receivers.test.ts b/__tests__/ts-imported-type-receivers.test.ts new file mode 100644 index 0000000000..eabe582d22 --- /dev/null +++ b/__tests__/ts-imported-type-receivers.test.ts @@ -0,0 +1,180 @@ +/** + * A call on a TypeScript/JavaScript value whose type the file imports links + * to the method of the class the import names, when several classes share + * the name. + * + * Typed calls broke a tie between same-named classes only by the call site's + * own file, then took the first declaration indexed: on vscode, calls in + * `src/vs/` on a `DisposableStore` imported from `vs/base/common/lifecycle` + * reached copilot's vendored copy under `extensions/copilot/src/util/vs/`, + * and on an `Emitter` a copilot test fixture's. `this.` calls broke + * it by folder nearness, which took react-native's Jest mock of + * `NativeEventEmitter` over the class beside it. Java and Kotlin already + * followed the import (#314). + */ +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-imported-recv-')); + 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 Base {\n reset() { return 1; }\n}\n' + + 'export class Store extends Base {\n add(v: number) { return v; }\n}\n', + 'src/two/store.ts': + 'export class Base {\n reset() { return 2; }\n}\n' + + 'export class Store extends Base {\n add(v: number) { return -v; }\n}\n', + 'src/two/index.ts': "export { Store } from './store';\n", +}; + +describe('a call on a TypeScript value whose type the file imports', () => { + it("links to the imported class's method, whichever of the same-named classes it is", async () => { + const calls = await callsOf({ + ...STORES, + 'src/use-one.ts': + "import { Store } from './one/store';\n" + + 'export function a(s: Store) {\n return s.add(1);\n}\n', + 'src/use-two.ts': + "import { Store } from './two/store';\n" + + 'export function b(s: Store) {\n return s.add(2);\n}\n', + }); + expect(calls.filter((c) => c.includes('s.add'))).toEqual([ + 'src/use-one.ts: s.add -> src/one/store.ts::Store::add', + 'src/use-two.ts: s.add -> src/two/store.ts::Store::add', + ]); + }, 60_000); + + it('follows the import through a re-export, a type-only import and a namespace import', async () => { + const calls = await callsOf({ + ...STORES, + 'src/use-barrel.ts': + "import { Store } from './two';\n" + + 'export function b(s: Store) {\n return s.add(2);\n}\n', + 'src/use-type.ts': + "import type { Store } from './two/store';\n" + + 'export function t(s: Store) {\n return s.add(3);\n}\n', + 'src/use-ns.ts': + "import * as two from './two/store';\n" + + 'export function n() {\n const s = new two.Store();\n return s.add(4);\n}\n', + }); + expect(calls.filter((c) => c.includes('s.add'))).toEqual([ + 'src/use-barrel.ts: s.add -> src/two/store.ts::Store::add', + 'src/use-ns.ts: s.add -> src/two/store.ts::Store::add', + 'src/use-type.ts: s.add -> src/two/store.ts::Store::add', + ]); + }, 60_000); + + it("links an inherited method to the base class declared beside the imported class", async () => { + const calls = await callsOf({ + ...STORES, + 'src/use-two.ts': + "import { Store } from './two/store';\n" + + 'export function b(s: Store) {\n return s.reset();\n}\n', + }); + expect(calls.filter((c) => c.includes('s.reset'))).toEqual(['src/use-two.ts: s.reset -> src/two/store.ts::Base::reset']); + }, 60_000); + + it("links a `this.` call to the method of the class its file imports", async () => { + // Both stores are as near the caller's directory, so nearness alone + // picks the first path, `src/one`. + const calls = await callsOf({ + ...STORES, + 'src/user.ts': + "import { Store } from './two/store';\n" + + 'export class User {\n' + + ' constructor(private readonly store: Store) {}\n' + + ' run() {\n return this.store.add(3);\n }\n' + + '}\n', + }); + expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/user.ts: this.store.add -> src/two/store.ts::Store::add']); + }, 60_000); + + it("follows the call's own file's import when the field is read off a same-named class elsewhere", async () => { + // vscode's copilot `MockPromptsService` declares its emitter as + // `this._register(new Emitter())`, which isn't read, so its field + // is read off the workbench's `MockPromptsService`, which imports the + // other `Emitter`. + 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' + + ' run() {\n return this.store.add(2);\n }\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', + 'src/other/mock.ts: this.store.add -> src/one/store.ts::Store::add', + ]); + }, 60_000); + + it('does the same in JavaScript, for `new` of an imported or required class', async () => { + const calls = await callsOf({ + 'package.json': JSON.stringify({ name: 'app' }), + 'lib/one/store.js': 'export class Store {\n add(v) { return v; }\n}\n', + 'lib/two/store.js': 'export class Store {\n add(v) { return -v; }\n}\n', + 'lib/use-new.js': + "import { Store } from './two/store';\n" + + 'export function n() {\n const s = new Store();\n return s.add(5);\n}\n', + 'lib/use-require.js': + "const { Store } = require('./two/store');\n" + + 'function r() {\n const s = new Store();\n return s.add(6);\n}\n' + + 'module.exports = { r };\n', + }); + expect(calls.filter((c) => c.includes('s.add'))).toEqual([ + 'lib/use-new.js: s.add -> lib/two/store.js::Store::add', + 'lib/use-require.js: s.add -> lib/two/store.js::Store::add', + ]); + }, 60_000); + + it("still prefers a class the call's own file declares", async () => { + const calls = await callsOf({ + ...STORES, + 'src/own.ts': + 'class Store {\n add(v: number) { return v * 2; }\n}\n' + + 'export function o(s: Store) {\n return s.add(7);\n}\n', + }); + expect(calls.filter((c) => c.includes('s.add'))).toEqual(['src/own.ts: s.add -> src/own.ts::Store::add']); + }, 60_000); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 32d9b40417..5bb2175e87 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -8959,6 +8959,8 @@ export function resolveMethodOnType( * signal Java imports carry but the call site doesn't (#314). * 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). */ preferredFqn?: string, /** Recursion guard for the supertype/conformance walk. */ @@ -9024,7 +9026,11 @@ export function resolveMethodOnType( return null; } - if (matches.length > 1 && preferredFqn && ref.language !== 'go') { + if (matches.length > 1 && preferredFqn && ESM_FAMILY.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)) { const ext = ref.language === 'kotlin' ? '.kt' : '.java'; const fqnPath = preferredFqn.replace(/\./g, '/') + ext; const chosen = matches.find((m) => { @@ -9047,9 +9053,9 @@ export function resolveMethodOnType( // the definition in the CALL SITE's own file. Without this, every ambiguous // call collapses onto the first-indexed definition, so a call in `b/svc.cpp` // wrongly points at `a/svc.cpp` (#1079). This runs AFTER the `preferredFqn` - // block, so Java/Kotlin import disambiguation — whose target is intentionally - // in ANOTHER file (#314) — is unaffected: that block returns early whenever - // an import FQN pins the class. + // blocks, so Java/Kotlin and TS/JS import disambiguation — whose target is + // intentionally in ANOTHER file (#314) — is unaffected: those blocks return + // early whenever an import pins the class. if (ref.referenceKind === 'function_ref' && matches.length !== 1) return null; const ordered = preferCallSiteFile(cppVisibleFirst(matches, ref, context), ref.filePath); return { @@ -12756,6 +12762,42 @@ function importedFqnOf( return imports.find((i) => i.localName === typeName)?.source; } +/** Per-context memo: `\0` → tsImportedTypeFile's answer. */ +const TS_IMPORTED_TYPE_FILES = new WeakMap>(); + +/** + * The file declaring the type a TS/JS file imports under the name a + * declaration in it writes (`store: DisposableStore`, `new async.Delayer()`), + * or undefined when the file doesn't import that name. Several classes may + * share it, a vendored copy, a test fixture or another app's, and the import + * is what says which one the file means: on vscode, calls on a + * `DisposableStore` imported from `vs/base/common/lifecycle` reached + * copilot's vendored copy of that file, the first declaration indexed, and + * a call whose first declaration was a test fixture's linked to nothing. + */ +function tsImportedTypeFile(written: string, ref: UnresolvedRef, context: ResolutionContext): string | undefined { + const name = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*/.exec(written.trim())?.[0]; + if (!name) return undefined; + let memo = TS_IMPORTED_TYPE_FILES.get(context); + if (!memo) { + memo = new Map(); + TS_IMPORTED_TYPE_FILES.set(context, memo); + } + const key = ref.filePath + '\0' + name; + let file = memo.get(key); + if (file === undefined) { + file = null; + const binding = name.split('.')[0]!; + if (context.getImportMappings?.(ref.filePath, ref.language)?.some((i) => i.localName === binding)) { + const imported = context.resolveImport?.({ ...ref, referenceName: name, referenceKind: 'references' }); + const node = imported ? context.getNodeById?.(imported.targetNodeId) : undefined; + if (node && node.kind !== 'file' && node.filePath !== ref.filePath) file = node.filePath; + } + memo.set(key, file); + } + return file ?? undefined; +} + /** * Java/Kotlin: infer a receiver's declared type by walking field declarations * in the class enclosing the call site. The field's `signature` is already in @@ -12924,6 +12966,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { PYTHON_IMPORTED_FILES.delete(context); AWAITED_TYPE_MEMO.delete(context); AWAITED_FILES.delete(context); + TS_IMPORTED_TYPE_FILES.delete(context); C_STATIC_MEMO.delete(context); CPP_TYPE_DECLS.delete(context); CPP_INCLUDE_LINES.delete(context); @@ -14231,7 +14274,8 @@ export function matchMethodCall( return decl.asserted && resolveMethodOnType(decl.asserted.name, methodName!, ref, context, 0.9, 'instance-method', decl.asserted.dir); } // Java/Kotlin: when two classes share the simple name, the file's import - // pins WHICH one (#314); Go: the package that declares the type (#2323). + // 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 = ref.language === 'java' || ref.language === 'kotlin' @@ -14240,7 +14284,9 @@ export function matchMethodCall( .find((i) => i.localName === inferredType)?.source : ref.language === 'go' ? goDeclaredTypePackage(decl.raw, ref.filePath, context) - : undefined; + : ESM_FAMILY.has(ref.language) && !awaited + ? nmTimedT('mc-tsimport', ref, () => tsImportedTypeFile(decl.raw ?? inferredType!, ref, context)) + : undefined; const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType( inferredType, methodName!, @@ -15730,12 +15776,17 @@ function matchTsThisFieldCall( // `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 bare-name - // path this replaces broke that 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. + // 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( @@ -15745,6 +15796,8 @@ function matchTsThisFieldCall( (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); @@ -15755,7 +15808,7 @@ function matchTsThisFieldCall( 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'); + return resolveMethodOnType(typeName, methodName, ref, context, 0.85, 'instance-method', imported); } return null; } From 34b17f6b9d58d4994479b201be2c7b3fd989ec7b Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Mon, 19 Oct 2026 05:38:10 +0000 Subject: [PATCH 06/33] 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 07/33] 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 08/33] 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 09/33] 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 10/33] 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 11/33] 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