Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result>;`) 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<User>, 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

Expand Down
75 changes: 75 additions & 0 deletions __tests__/ambient-module-imports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> { fire(data: T): void; }
export interface FileSystem { readFile(path: string): Promise<Uint8Array> }
export interface Clipboard { writeText(value: string): Promise<void> }
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<T>()` is recorded by its last name, as a type is.
'src/emitter.ts': "import * as vscode from 'vscode';\nexport const changed = new vscode.EventEmitter<string>();\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.
Expand Down
56 changes: 42 additions & 14 deletions src/resolution/ambient-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,8 +460,10 @@ interface Memo {
scanned: { byFile: Map<string, AmbientModule[]>; bySpecifier: Map<string, AmbientModule[]> } | null;
/** Per block (`file`, line, column): its declarations, by name. */
members: Map<string, Map<string, Node[]>>;
/** See ambientModuleFunctionIds. */
functionIds: ReadonlySet<string> | null;
/** See ambientModuleDeclarations. */
declarationIds: ReadonlyMap<string, string> | null;
/** The blocks of TypeScript files other than declaration files, by file (see isInModuleBlock). */
otherBlocks: Map<string, readonly AmbientModuleBlock[]>;
/** declarationsFor's answers, by specifier and importing file. */
declarations: Map<string, AmbientModule[]>;
/** isMergedAmbientDeclaration's answers, by node id. */
Expand All @@ -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;
}

Expand Down Expand Up @@ -517,7 +519,7 @@ function scanned(context: ResolutionContext): NonNullable<Memo['scanned']> {
const DECLARATION_KINDS = new Set<Node['kind']>(['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<Node, 'startLine' | 'startColumn'>, 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;
Expand Down Expand Up @@ -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<string> {
export function ambientModuleDeclarations(context: ResolutionContext): ReadonlyMap<string, string> {
const memo = memoFor(context);
if (memo.functionIds) return memo.functionIds;
const ids = new Set<string>();
if (memo.declarationIds) return memo.declarationIds;
const ids = new Map<string, string>();
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`
Expand Down
46 changes: 45 additions & 1 deletion src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<T>()`). A call keeps what it is called through.
*/
const LAST_NAME_KINDS: ReadonlySet<string> = 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 '<specifier>'`
* 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`
Expand Down
18 changes: 11 additions & 7 deletions src/resolution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down