mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 11:24:49 +08:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f66d064279
commit
accf600e47
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
@@ -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<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;
|
||||
@@ -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`
|
||||
|
||||
@@ -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<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`
|
||||
|
||||
+11
-7
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user