fix(ts): a type a module doesn't import is never another module's (#2736)

A TypeScript module reaches another module's type only through an import,
so a type it names without importing it is its own or a global one: a
script's top-level declaration or one in a declare global block. #2726
held a supertype to that. A type annotation, a type argument and a type
alias's value went to the name matcher like any other name, which handed
them a project type of that name: vscode's Omit<...> annotations reached
quickInput.ts's export type Omit instead of TypeScript's own, and type
parameters and mapped keys, like excalidraw's <Point extends ...> and
frontend's <T>, reached other modules' types and enum members.

Both name-match strategies now leave out another module's non-global type
for a TS or JS module's type reference to a name the file doesn't import,
ahead of the ranking, so a declare global type still wins over a nearer
module's namesake. Left as they were: the last name of a qualified type,
vscode.TextDocument, written after a dot; a name inside a declare module
'x' block, which is module x's own; and a name an import X = ns.X alias
binds, which the file's import list doesn't hold.

Globals come from declaration files and declare global blocks. A script
.ts's top level is a sample's or a fixture's, global to its own program
only (vscode's colorize fixtures declare type T), and a test's or
fixture's globals reach its tests only, as its declare module blocks do.
That holds for supertypes too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-21 08:34:49 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0c28480576
commit 458ad51921
5 changed files with 251 additions and 32 deletions
+18 -3
View File
@@ -235,6 +235,10 @@ export interface GlobalScopeScan {
* `abstract`, `enum`, `function`, `const`, …).
*/
globalWords: Set<string>;
/** The file's `declare module '<name>' { … }` blocks, whose names are the declared module's. */
moduleBlocks: readonly AmbientModuleBlock[];
/** Names an `import X = …` declaration binds: `import AutofillIpcServer = autofill.AutofillIpcServer`. */
importAliases: Set<string>;
}
/**
@@ -244,11 +248,22 @@ export interface GlobalScopeScan {
* not in it.
*/
export function scanGlobalScope(source: string): GlobalScopeScan {
const { script } = scanAmbientModules(source);
const { script, blocks: moduleBlocks } = scanAmbientModules(source);
const globalWords = new Set<string>();
const importAliases = new Set<string>();
let declare = 0; // 1: `declare`, 2: `declare global`
let inGlobal = false;
let alias = 0; // 1: `import` (or `import type`), 2: `import X`
let aliasName = '';
tokenize(source, (kind, text, line, column, depth) => {
if (kind === Kind.Word && text === 'import') alias = 1;
else if (alias === 1 && kind === Kind.Word && text !== 'type') {
alias = 2;
aliasName = text;
} else if (alias === 2 && kind === Kind.Punct && text === '=') {
importAliases.add(aliasName);
alias = 0;
} else if (!(alias === 1 && kind === Kind.Word)) alias = 0;
if (inGlobal) {
if (kind === Kind.Punct && text === '}' && depth === 0) inGlobal = false;
else if (kind === Kind.Word && depth === 1) globalWords.add(`${line}:${column}`);
@@ -266,7 +281,7 @@ export function scanGlobalScope(source: string): GlobalScopeScan {
if (kind === Kind.Punct && text === '{' && declare === 2) inGlobal = true;
declare = 0;
});
return { script, globalWords };
return { script, globalWords, moduleBlocks, importAliases };
}
/**
@@ -519,7 +534,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: Pick<Node, 'startLine' | 'startColumn'>, block: AmbientModuleBlock): boolean {
export 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;
+63 -27
View File
@@ -20,8 +20,8 @@ import { clearCppRawStringMemos, cppLiteralsBlanked, cppNonCodeLines } from './c
import { cppClassWritten, type CppNameSite } from './cpp-supertypes';
import { cppIncludedFile, cppIncludedHeadersBeside, cppIncluders } from './cpp-includers';
import { isCFeatureVariant, isOffByDefault } from './c-feature-variants';
import { isTestPath } from '../search/query-utils';
import { isMergedAmbientDeclaration, scanGlobalScope, type GlobalScopeScan } from './ambient-modules';
import { isTestFile, isTestPath } from '../search/query-utils';
import { inBlock, isDeclarationFile, isMergedAmbientDeclaration, scanGlobalScope, type GlobalScopeScan } from './ambient-modules';
import { isMinifiedContent } from '../extraction/generated-detection';
import { getCargoWorkspaceCrateMap } from './frameworks/cargo-workspace';
import { readRustScopes, rustBindingAt, rustInnerBlockAt, rustBoundsOf, rustFnAt, rustImplAt, splitRustTopLevel, type RustBinding, type RustPatternSite, type RustScopes } from './rust-scopes';
@@ -1547,35 +1547,71 @@ function tsGlobalScope(file: string, context: ResolutionContext): GlobalScopeSca
if (scope === undefined) {
const source = context.readFile(file);
scope = source === null ? null : scanGlobalScope(source);
if (scope?.script && HAS_CJS_EXPORT.test(source!)) scope = { script: false, globalWords: new Set() };
if (scope?.script && HAS_CJS_EXPORT.test(source!)) scope = { ...scope, script: false, globalWords: new Set() };
memo.set(file, scope);
}
return scope;
}
/**
* Whether a supertype written as a bare name the file doesn't import, in an
* ES module, may be `n`: a declaration of the file's own, or a global one —
* at a script's top level (a file with no import or export, a script `.d.ts`)
* or directly inside `declare global`. A module's own declaration, exported
* or not, is reached only through an import, which the import resolver has
* followed already, and a namespace's member is no global. So an interface's
* `extends Omit<…>` is TypeScript's `Omit`, never vscode's `export type Omit`
* in quickInput.ts, and `extends Function` never terminal-suggest's
* `Fig.Function`.
* Whether a type written as a bare name the file doesn't import, in an ES
* module, may be `n`: a declaration of the file's own, or a global one — at a
* script's top level (a file with no import or export, a script `.d.ts`) or
* directly inside `declare global`. A module's own declaration, exported or
* not, is reached only through an import, which the import resolver has
* followed already, and a namespace's member is no global. So `extends
* Omit<…>` and `options: Omit<IPickOptions, 'canPickMany'>` are TypeScript's
* `Omit`, never vscode's `export type Omit` in quickInput.ts, and `extends
* Function` never terminal-suggest's `Fig.Function`.
*
* The globals project code relies on come from declaration files and
* `declare global` blocks. A script `.ts` is a sample or a fixture whose top
* level is global only to its own program — vscode's colorize fixtures
* declare `type T` and copilot's eslint fixtures `interface Node` — and a
* test's or fixture's globals are its tests' only, as its `declare module`
* blocks are (see ambient-modules.ts).
*/
function isTsSupertypeInScope(n: Node, ref: UnresolvedRef, context: ResolutionContext): boolean {
function isTsTypeInScope(n: Node, ref: UnresolvedRef, context: ResolutionContext): boolean {
if (n.filePath === ref.filePath || !TS_JS_FILE.test(n.filePath)) return true;
const scope = tsGlobalScope(n.filePath, context);
return !scope || scope.globalWords.has(`${n.startLine}:${n.startColumn}`);
if (!scope) return true;
if ((scope.script && !isDeclarationFile(n.filePath)) || (isTestFile(n.filePath) && !isTestFile(ref.filePath))) return false;
return scope.globalWords.has(`${n.startLine}:${n.startColumn}`);
}
/** Whether `ref` is a supertype written as a bare name its ES module doesn't import (isTsSupertypeInScope). */
function isTsBareSupertype(ref: UnresolvedRef, context: ResolutionContext): boolean {
return isInheritanceRef(ref) && JS_FAMILY.has(ref.language) && TS_JS_FILE.test(ref.filePath) &&
/^[A-Za-z_$][\w$]*$/.test(ref.referenceName) &&
!context.getImportMappings(ref.filePath, ref.language).some((m) => m.localName === ref.referenceName) &&
tsGlobalScope(ref.filePath, context)?.script === false;
/**
* Whether `ref` names a type by a bare name its ES module doesn't import
* (isTsTypeInScope): a supertype, or a type annotation's or type argument's
* name, which TS and JS files record as `references` and nothing else does.
* The last name of a qualified type, `vscode.TextDocument`, is no bare name,
* and neither is a name inside a `declare module 'x'` block, which is module
* `x`'s own: legend-state's augmentation of `'@legendapp/state/react'` writes
* that module's `FCReactive` without importing it. An `import X = ns.X`
* alias imports its name.
*/
function isTsBareTypeName(ref: UnresolvedRef, context: ResolutionContext): boolean {
const typeRef = ref.referenceKind === 'references';
if (!(typeRef || isInheritanceRef(ref)) || !JS_FAMILY.has(ref.language) || !TS_JS_FILE.test(ref.filePath) ||
!/^[A-Za-z_$][\w$]*$/.test(ref.referenceName) ||
context.getImportMappings(ref.filePath, ref.language).some((m) => m.localName === ref.referenceName) ||
(typeRef && isWrittenQualified(ref, context))) return false;
const scope = tsGlobalScope(ref.filePath, context);
const at = { startLine: ref.line, startColumn: ref.column };
return scope?.script === false && !scope.importAliases.has(ref.referenceName) &&
!scope.moduleBlocks.some((block) => inBlock(at, block));
}
/**
* Whether a name recorded alone was written after a `.`, as the last name of
* `ns.Type` or `import('x').Type` is. A column that doesn't find the name on
* its line counts as written so, which leaves the reference as it was.
*/
function isWrittenQualified(ref: UnresolvedRef, context: ResolutionContext): boolean {
const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split(/\r?\n/)[ref.line - 1] ?? '';
if (!line.startsWith(ref.referenceName, ref.column)) return true;
let i = ref.column - 1;
while (i >= 0 && (line[i] === ' ' || line[i] === '\t')) i--;
return line[i] === '.';
}
/**
@@ -11585,8 +11621,8 @@ export function matchByExactName(
const importRef = ref.referenceKind === 'imports';
const inheritanceRef = isInheritanceRef(ref);
// Read once, and only for a candidate in another file: it scans the file.
let bareSupertype: boolean | undefined;
const tsBareSupertype = (): boolean => (bareSupertype ??= isTsBareSupertype(ref, context));
let bareTypeName: boolean | undefined;
const tsBareTypeName = (): boolean => (bareTypeName ??= isTsBareTypeName(ref, context));
const sameName = context.getNodesByName(ref.referenceName);
// `NAME(...)` where NAME is a function-like macro somewhere in the project is
// an expansion or a call to a same-named function — never the macro itself
@@ -11687,10 +11723,10 @@ export function matchByExactName(
// single-match shortcut. Restricting the pool BEFORE ranking lets the
// legitimate supertype win instead of merely dropping the false edge.
(!inheritanceRef || isSupertypeTarget(n)) &&
// And a supertype a TS/JS module names without importing it is its own or
// a global, never another module's — ahead of the ranking, so a global
// `interface Window` in a `declare global` still wins.
!(inheritanceRef && n.filePath !== ref.filePath && tsBareSupertype() && !isTsSupertypeInScope(n, ref, context)) &&
// And a supertype or type a TS/JS module names without importing it is
// its own or a global, never another module's — ahead of the ranking, so
// a global `interface Window` in a `declare global` still wins.
!(n.filePath !== ref.filePath && tsBareTypeName() && !isTsTypeInScope(n, ref, context)) &&
// Likewise for `imports`: a member that only exists inside a type is not
// importable, so it is not a candidate. Without this a `path`/`id`/`url`
// import resolved to some interface's same-named property.
@@ -23890,7 +23926,7 @@ export function matchFuzzy(
(TYPE_MEMBER_KINDS.has(finalCandidates[0]!.kind) ||
(finalCandidates[0]!.filePath !== ref.filePath && isLocallyBoundJsName(ref.referenceName, ref.filePath, context)))) &&
!(JS_FAMILY.has(ref.language) && isOutOfRepoBinding(ref.referenceName, ref, context)) &&
!(finalCandidates[0]!.filePath !== ref.filePath && isTsBareSupertype(ref, context) && !isTsSupertypeInScope(finalCandidates[0]!, ref, context)) &&
!(finalCandidates[0]!.filePath !== ref.filePath && isTsBareTypeName(ref, context) && !isTsTypeInScope(finalCandidates[0]!, ref, context)) &&
!(finalCandidates[0]!.kind === 'method' && isBareGoCall(ref, context)) &&
// A bare PHP call is a function call (case-insensitive, so fuzzy may find
// one) — never the class `View` for `view(…)`, never a method.