From accf600e4717b7cadb42fc692da87b5a6d64f838 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 21 Oct 2026 05:01:49 +0000 Subject: [PATCH] fix(ts): a declaration in a declare module block is reached through its module (#2728) A declaration file's declare module 'x' { ... } declares the members of x, which no other file names by a bare name: an import of x reaches them. The functions of such a block have been held to that since #2718, but its other declarations, the types, classes, constants and enums, still took bare-name matches from any file. On vscode, Node's import { readFileSync } from 'fs' linked to the fs that vscode.d.ts's workspace namespace declares, electron's clipboard and shell to env's, a ProcessEnv to the proposed API's, and an LSP TextDocument re-exported through a project barrel to the API's own. Every block-level declaration now takes a match from another file only when the reference is written in a block declaring the same module (the proposed API files merge into vscode.d.ts), is a type position in a file that imports the module (vscode.TextDocument under a namespace import, which the extractor records by its last name), or names what the file imports from a project file that imports the module and re-exports it (copilot's vscodeTypes.ts). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/ambient-module-imports.test.ts | 75 ++++++++++++++++++++++++ src/resolution/ambient-modules.ts | 56 +++++++++++++----- src/resolution/import-resolver.ts | 46 ++++++++++++++- src/resolution/index.ts | 18 +++--- 5 files changed, 174 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c8dd8f2..f6661ba6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -255,6 +255,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a call on a parameter, a local lambda or functor, a lambda's parameter or a range-`for` variable no longer links to a function or method elsewhere that only shares its name, like protocolbuffers/protobuf's code generators calling `format(…)` on their `Formatter& format` parameter, which linked to the conformance suite's `TestResult::format`, or facebook/rocksdb's tests calling a local `auto key = [](int i) {…};`. C++ finds the local first, so that is what the call calls. - In TypeScript and JavaScript, an import of a package your project declares itself with `declare module '…'` in a `.d.ts` file, like a `typings/mammoth/index.d.ts` written for an untyped package or VS Code's own `vscode.d.ts`, now links to that declaration, so `codegraph callers`, impact and `codegraph affected` show the code that uses your typings instead of nothing or a project symbol that only shares a name. Named, default and namespace imports, `export =` and renaming export lists are read the way TypeScript reads them, a function such a file declares without a body (`export function convertToHtml(…): Promise;`) is now indexed so calls to it have something to link to, and only an import of its package reaches it. A `declare module` that augments a package's own types, a wildcard like `declare module '*.svg'`, and copies of typings kept in test fixtures are left alone. Re-index TypeScript projects after upgrading. - In TypeScript, an interface that extends other interfaces, like `interface UserRepo extends Repo, Named`, is now linked to each of them: the type hierarchy shows them above the interface and above every class that implements it, a change to an interface reaches the interfaces built on it and their implementations in impact results, and a call on a value typed by such an interface, like `hass.formatEntityName(…)` on a `HomeAssistant` that extends seven interfaces, links to the method the interface inherits instead of a same-named method elsewhere. Before, only a class's `extends` and `implements` were read, so every interface stood alone. A type from a package, like `ButtonProps` from `@mui/material`, or one of TypeScript's own, like `Omit<…>`, still links to nothing rather than to a type of the same name elsewhere in your project. Re-index TypeScript projects after upgrading. +- In TypeScript and JavaScript, a type, class, constant or other declaration that a `.d.ts` file declares inside `declare module '…'` now links only from files that use that module: files that import it, the module's other declarations, and files that import it through a file of yours that re-exports it. Before, any file using the same name linked to it: Node's `import { readFileSync } from 'fs'` linked to the `fs` that VS Code's `vscode.d.ts` declares, and a `ProcessEnv`, or an LSP `TextDocument` imported through a barrel, to the `vscode` module's types of those names. Re-index TypeScript projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/ambient-module-imports.test.ts b/__tests__/ambient-module-imports.test.ts index 26579bd3..abc12576 100644 --- a/__tests__/ambient-module-imports.test.ts +++ b/__tests__/ambient-module-imports.test.ts @@ -515,6 +515,81 @@ describe('an import of a package the repository declares itself', () => { } }, 60_000); + it('reaches any declaration of the module only from a file that reaches the module', async () => { + // vscode's own shapes. A namespace import's `vscode.TextDocument`, a name + // from a file re-exporting the module (copilot's `vscodeTypes.ts`), and the + // proposed API files' own declarations of `vscode` reach `vscode.d.ts`. + // Node's `fs`, electron's `clipboard`, a `ProcessEnv` the file never + // imports, and an LSP `TextDocument` through a barrel don't, though the + // module declares the names. + const root = tempRoot(); + write(root, { + 'package.json': JSON.stringify({ name: 'ext' }), + 'typings/vscode.d.ts': `declare module 'vscode' { + export interface TextDocument { uri: string } + export class Disposable { dispose(): void; } + export class EventEmitter { fire(data: T): void; } + export interface FileSystem { readFile(path: string): Promise } + export interface Clipboard { writeText(value: string): Promise } + export type ProcessEnv = { [key: string]: string }; + export namespace workspace { + export const fs: FileSystem; + } + export namespace env { + export const clipboard: Clipboard; + } +} +`, + 'typings/vscode.proposed.watch.d.ts': `declare module 'vscode' { + export interface Watcher extends Disposable { + document: TextDocument; + } +} +`, + 'src/vscodeTypes.ts': "import * as vscode from 'vscode';\nexport import TextDocument = vscode.TextDocument;\n", + 'src/ext.ts': "import * as vscode from 'vscode';\nexport function title(doc: vscode.TextDocument): string { return doc.uri; }\n", + 'src/viaShim.ts': "import { TextDocument } from './vscodeTypes';\nexport function size(doc: TextDocument): number { return doc.uri.length; }\n", + 'src/node.ts': "import { readFileSync } from 'fs';\nexport function read(): string { return readFileSync('x', 'utf8'); }\n", + 'src/main.ts': "import { clipboard } from 'electron';\nexport function copy(): void { clipboard.writeText('x'); }\n", + 'src/env.ts': "export function home(env: ProcessEnv): string { return env.HOME ?? ''; }\n", + 'src/languageModes.ts': "export { TextDocument } from 'vscode-languageserver-textdocument';\n", + 'src/html.ts': "import { TextDocument } from './languageModes';\nexport function lineCount(doc: TextDocument): number { return 0; }\n", + // `new vscode.EventEmitter()` is recorded by its last name, as a type is. + 'src/emitter.ts': "import * as vscode from 'vscode';\nexport const changed = new vscode.EventEmitter();\n", + // A file that imports the module, but binds the bare name elsewhere. + 'src/mixed.ts': "import * as vscode from 'vscode';\nimport { TextDocument } from 'vscode-languageserver-textdocument';\nexport function lines(doc: TextDocument): number { return vscode ? 0 : 1; }\n", + // typescript-language-features' protocol.d.ts: a module augmenting a + // package's namespace, which its `export =` hands out. + 'typings/protocol.d.ts': `import type ts from 'typescript-lib'; +export = ts.server.protocol; +declare module 'typescript-lib' { + namespace server.protocol { + interface Response { readonly _serverType?: string } + } +} +`, + 'src/cached.ts': "import type * as Proto from '../typings/protocol';\nexport function ok(response: Proto.Response): boolean { return !!response; }\n", + }); + const cg = await CodeGraph.init(root, { index: true }); + try { + expect(edgesFrom(cg, 'src/ext.ts')).toContain('references typings/vscode.d.ts:TextDocument'); + expect(edgesFrom(cg, 'src/viaShim.ts')).toContain('references typings/vscode.d.ts:TextDocument'); + expect(edgesFrom(cg, 'src/emitter.ts')).toContain('instantiates typings/vscode.d.ts:EventEmitter'); + expect(edgesFrom(cg, 'src/cached.ts')).toContain('references typings/protocol.d.ts:Response'); + expect(edgesFrom(cg, 'src/mixed.ts').filter((e) => e.includes('typings/vscode.d.ts:TextDocument'))).toEqual([]); + expect(edgesFrom(cg, 'typings/vscode.proposed.watch.d.ts')).toEqual(expect.arrayContaining([ + 'extends typings/vscode.d.ts:Disposable', + 'references typings/vscode.d.ts:TextDocument', + ])); + for (const file of ['src/node.ts', 'src/env.ts', 'src/languageModes.ts', 'src/html.ts']) { + expect(edgesFrom(cg, file).filter((e) => e.includes('typings/vscode.d.ts')), file).toEqual([]); + } + expect(edgesFrom(cg, 'src/main.ts')).not.toContain('imports typings/vscode.d.ts:clipboard'); + } finally { + cg.close(); + } + }, 60_000); + it('never takes a test fixture’s copy of the declaration for the program’s', async () => { // copilot keeps fixture copies of vscode's proposed API typings, which its // tsconfig leaves out of the build. diff --git a/src/resolution/ambient-modules.ts b/src/resolution/ambient-modules.ts index b45985e0..495185bb 100644 --- a/src/resolution/ambient-modules.ts +++ b/src/resolution/ambient-modules.ts @@ -460,8 +460,10 @@ interface Memo { scanned: { byFile: Map; bySpecifier: Map } | null; /** Per block (`file`, line, column): its declarations, by name. */ members: Map>; - /** See ambientModuleFunctionIds. */ - functionIds: ReadonlySet | null; + /** See ambientModuleDeclarations. */ + declarationIds: ReadonlyMap | null; + /** The blocks of TypeScript files other than declaration files, by file (see isInModuleBlock). */ + otherBlocks: Map; /** declarationsFor's answers, by specifier and importing file. */ declarations: Map; /** isMergedAmbientDeclaration's answers, by node id. */ @@ -477,7 +479,7 @@ export function clearAmbientModuleMemos(context: ResolutionContext): void { function memoFor(context: ResolutionContext): Memo { let memo = MEMO.get(context); - if (!memo) MEMO.set(context, (memo = { scanned: null, members: new Map(), functionIds: null, declarations: new Map(), merged: new Map() })); + if (!memo) MEMO.set(context, (memo = { scanned: null, members: new Map(), declarationIds: null, otherBlocks: new Map(), declarations: new Map(), merged: new Map() })); return memo; } @@ -517,7 +519,7 @@ function scanned(context: ResolutionContext): NonNullable { const DECLARATION_KINDS = new Set(['function', 'class', 'interface', 'type_alias', 'enum', 'constant', 'variable', 'component']); /** Whether `node` starts inside the block, by position. */ -function inBlock(node: Node, block: AmbientModuleBlock): boolean { +function inBlock(node: Pick, block: AmbientModuleBlock): boolean { const afterStart = node.startLine > block.startLine || (node.startLine === block.startLine && node.startColumn >= block.startColumn); const beforeEnd = node.startLine < block.endLine || (node.startLine === block.endLine && node.startColumn <= block.endColumn); return afterStart && beforeEnd; @@ -661,25 +663,51 @@ function blockDeclaring(node: Node, context: ResolutionContext): AmbientModule | } /** - * The ids of the functions declaration files' `declare module 'x'` bodies - * declare. Only an import of `x` reaches one: a module's functions are never - * global, so a bare `encode()` elsewhere is no call of the `encode` excalidraw - * declares for `png-chunks-encode`. A set, so asking about every reference a - * strategy other than the import resolver settles costs a lookup. + * The declarations at the own level of declaration files' `declare module 'x'` + * bodies, by id, each with the `x` its block declares. A module's members are + * never global: a bare `encode()` elsewhere is no call of the `encode` + * excalidraw declares for `png-chunks-encode`, Node's `import * as fs from + * 'fs'` no import of the `fs` vscode's `workspace` namespace declares, and an + * ESLint `Property` no use of emmet's `declare module 'EmmetFlatNode'`. A + * map, so asking about every reference a strategy other than the import + * resolver settles costs a lookup (see reachesAmbientModule). */ -export function ambientModuleFunctionIds(context: ResolutionContext): ReadonlySet { +export function ambientModuleDeclarations(context: ResolutionContext): ReadonlyMap { const memo = memoFor(context); - if (memo.functionIds) return memo.functionIds; - const ids = new Set(); + if (memo.declarationIds) return memo.declarationIds; + const ids = new Map(); for (const [file, modules] of scanned(context).byFile) { for (const node of context.getNodesInFile(file)) { - if (node.kind === 'function' && isBlockLevel(node) && modules.some((mod) => inBlock(node, mod))) ids.add(node.id); + if (!isBlockLevel(node)) continue; + const mod = modules.find((m) => inBlock(node, m)); + if (mod) ids.set(node.id, mod.specifier); } } - memo.functionIds = ids; + memo.declarationIds = ids; return ids; } +/** + * Whether line:column of `file` lies inside a block declaring `specifier`: + * one of the module's own declarations, which merge, as vscode's proposed API + * files add to `vscode.d.ts`'s `declare module 'vscode'`. A declaration + * file's blocks are the scanned ones; another TypeScript file's are read the + * first time one is asked about. + */ +export function isInModuleBlock(file: string, line: number, column: number, specifier: string, context: ResolutionContext): boolean { + let blocks: readonly AmbientModuleBlock[] | undefined = scanned(context).byFile.get(file); + if (!blocks && !DECLARATION_FILE.test(file)) { + const memo = memoFor(context); + blocks = memo.otherBlocks.get(file); + if (!blocks) { + const source = context.readFile(file); + blocks = source && /\bdeclare\s+module\s*['"]/.test(source) ? scanAmbientModules(source).blocks : []; + memo.otherBlocks.set(file, blocks); + } + } + return (blocks ?? []).some((block) => block.specifier === specifier && inBlock({ startLine: line, startColumn: column }, block)); +} + /** * Whether `node` is one of several declarations of the same name that the * blocks declaring one module merge: vscode declares `InlineCompletionItemProvider` diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 09757d61..f2695116 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -15,7 +15,7 @@ import { resolveWorkspaceImport } from './workspace-packages'; import { stripCommentsForRegex } from './strip-comments'; import { makeLineAt } from './synth-utils'; import { dartDirectiveFile } from './dart-libraries'; -import { ambientModuleDeclares, findAmbientModuleExport, findAmbientModuleFile } from './ambient-modules'; +import { ambientModuleDeclares, findAmbientModuleExport, findAmbientModuleFile, isInModuleBlock } from './ambient-modules'; import { moduleTail } from '../db/reference-tail'; import { resolveMethodOnType, @@ -2973,6 +2973,50 @@ function isBareModuleSpecifier(source: string, language: Language): boolean { return ESM_IMPORT_LANGUAGES.has(language) && !source.startsWith('.') && !source.startsWith('/'); } +/** + * Reference kinds the extractor records by a qualified name's last part: a + * type position (`doc: vscode.TextDocument`) and a construction (`new + * vscode.EventEmitter()`). A call keeps what it is called through. + */ +const LAST_NAME_KINDS: ReadonlySet = new Set(['references', 'extends', 'implements', 'type_of', 'returns', 'instantiates']); + +/** + * Whether `ref`, in another file than the declaration, may name by its bare + * name a declaration of the repository's own `declare module ''` + * in `declaringFile` (see ambientModuleDeclarations), the import resolver + * having bound nothing: + * - written inside a block declaring the same module, whose declarations + * merge (vscode's proposed API files add to `vscode.d.ts`); + * - the last name of a qualified one, the file importing the module or the + * declaring file: `vscode.TextDocument` under `import * as vscode from + * 'vscode'`, typescript-language-features' `Proto.Response` under `import + * type * as Proto from './protocol/protocol'`, whose `export =` hands out the + * namespace the file's block augments; + * - a name the file imports from the module, from the declaring file, or from + * a project file that imports the module and re-exports it (copilot's + * `vscodeTypes.ts`: `export import X = vscode.X`). + * A name bound to another package (an LSP `TextDocument`), to Node's own + * modules (`fs`), or to a project file that doesn't import the module, or a + * bare name a file reaching no such module writes, is not one of the module's. + */ +export function reachesAmbientModule(ref: UnresolvedRef, specifier: string, declaringFile: string, context: ResolutionContext): boolean { + if (isInModuleBlock(ref.filePath, ref.line, ref.column ?? 0, specifier, context)) return true; + const mappings = context.getImportMappings(ref.filePath, ref.language); + const relative = (source: string): boolean => source.startsWith('.') || source.startsWith('/'); + const leadsTo = (source: string, through: (file: string) => boolean): boolean => { + if (source === specifier) return true; + if (!relative(source)) return false; + const file = resolveImportPath(source, ref.filePath, ref.language, context); + return file !== null && (file === declaringFile || through(file)); + }; + const head = ref.referenceName.split('.')[0]!; + const binding = mappings.find((m) => m.localName === head); + if (binding) { + return leadsTo(binding.source, (file) => context.getImportMappings(file, ref.language).some((m) => m.source === specifier)); + } + return LAST_NAME_KINDS.has(ref.referenceKind) && mappings.some((m) => leadsTo(m.source, () => false)); +} + /** * Resolve a Python qualified reference whose receiver is an imported MODULE: * `certs.where()` after `from . import certs`, `mod.func()` after `import mod` diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 9e23575d..31c8f803 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -33,9 +33,9 @@ import { gateDartLocal, clearDartLocalScopeMemos } from './dart-local-scope'; import { clearCppTypeAliasMemos } from './cpp-type-aliases'; import { clearCppIncluderMemos } from './cpp-includers'; import { clearCFeatureVariantMemos } from './c-feature-variants'; -import { clearAmbientModuleMemos, ambientModuleFunctionIds } from './ambient-modules'; +import { clearAmbientModuleMemos, ambientModuleDeclarations } from './ambient-modules'; import { matchShopifyThemeFile } from './shopify-themes'; -import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isDartImportRef, isLuaRequireRef, isJsPathImportRef, isBoundToOutOfRepoImport, importBindingTail, clearImportResolverMemos, resolveImportPath, isExternalImport, ESM_IMPORT_LANGUAGES, jsModuleFile, pythonRootFingerprint, pythonReopenScope, pythonPackageImporters } from './import-resolver'; +import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isDartImportRef, isLuaRequireRef, isJsPathImportRef, isBoundToOutOfRepoImport, reachesAmbientModule, importBindingTail, clearImportResolverMemos, resolveImportPath, isExternalImport, ESM_IMPORT_LANGUAGES, jsModuleFile, pythonRootFingerprint, pythonReopenScope, pythonPackageImporters } from './import-resolver'; import { ResolverPool, minRefsForPool, shouldEngageAdaptively } from './resolver-pool'; import { resolveAliasBinding } from './alias-binding'; import { detectFrameworks, getResolvingFrameworks } from './frameworks'; @@ -1356,11 +1356,15 @@ export class ReferenceResolver { const module = this.nodeById(resolved.targetNodeId); if (module && module.kind !== 'file' && module.kind !== 'module') return null; } - // A function a declaration file's `declare module 'x'` declares is reached - // only through an import of `x`, never by its bare name from another file - // (see ./ambient-modules). - if (resolved && resolved.resolvedBy !== 'import' && ambientModuleFunctionIds(this.context).has(resolved.targetNodeId) && - this.nodeById(resolved.targetNodeId)?.filePath !== ref.filePath) return null; + // A declaration of a declaration file's `declare module 'x'` is reached + // through an import of `x`, never by its bare name from a file that + // reaches no `x` (see reachesAmbientModule). + if (resolved && resolved.resolvedBy !== 'import') { + const specifier = ambientModuleDeclarations(this.context).get(resolved.targetNodeId); + const declaringFile = specifier === undefined ? undefined : this.nodeById(resolved.targetNodeId)?.filePath; + if (declaringFile !== undefined && declaringFile !== ref.filePath && + !reachesAmbientModule(ref, specifier!, declaringFile, this.context)) return null; + } if (!resolved || ref.referenceKind !== 'calls') return resolved; const target = this.nodeById(resolved.targetNodeId);