fix(ts): a qualified type's last name is a member of what its qualifier names, not any declaration of that name (#2739)

Both extractors record the last name of a qualified type alone, so
`React.ReactNode` reached name matching as `ReactNode` and bound by word:
outline's 197 `React.ReactNode` annotations linked to its own editor
`class ReactNode`, excalidraw's `TChat.ChatMessage` to a `ChatMessage`
component, signoz's `EQueryType.CLICKHOUSE` to another enum's member, and
outline's `UnfurlResourceType.Group` to the `Group` model the file imports.
PR #2736 left that name to name matching on purpose.

A TypeScript `references` ref written after a qualifier now resolves
through the qualifier, ahead of the framework, import and name strategies:
- an import: the module's export (`import * as T`, through barrels and the
  repository's own `declare module` blocks), or the member of the enum,
  class or namespace a named or default import binds;
- a declaration of the file's own, in the innermost namespace around the
  reference that declares it, else at the top level: an enum's or a
  class's `::` member, or a member of a namespace block;
- otherwise a namespace the project declares global (`Express.User` in a
  `declare global` block).
A package from outside the repository, which no manifest of the importing
file's declares as its own and no `declare module` block declares, and a
global the project doesn't declare, link nothing. An import this resolver
can't follow but no manifest declares as a dependency (zod's own tests'
`import * as z from "zod/v3"`, through the package's `exports`), an
`import X = ns.Y` alias, and a name written after an expression
(`import('./x').T`) keep the old strategies.

The index holds no node for a TypeScript namespace, and its members are
indexed by their bare names, so ambient-modules gains scanNamespaceBlocks:
every `namespace A.B { … }` block with its full name and whether it is
global, read from the same tokens as the module blocks.

Validated on 18 TypeScript repos (13 React apps plus zod, vben, mealie,
prometheus's web UI and fastapi-template), main 2c4a16d0 against this fix,
every changed site checked with the TypeScript checker in a program over
each file's tsconfig and the repository's declaration files, without
node_modules:
- 300 qualified types move from a wrong declaration to the member
  TypeScript binds (zod 224, outline 39, excalidraw 23, signoz 10,
  mastodon 3, prometheus 1);
- 297 lose a wrong link and link nothing, where TypeScript binds nothing
  of the project, a package's member (outline 254, signoz 32, bbb 8,
  mastodon 3);
- 43 unresolved ones now link the member (outline 28, zod 9, signoz 6);
- 1966 keep their target, now resolved through the qualifier;
- next-saas, proshop, t3-turbo, minimal-kit, matx, berry, mantis,
  bulletproof, fastapi-template and mealie are byte-identical;
- index time is unchanged (125 s before, 120 s after, all 18 repos).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-21 09:48:45 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 2c4a16d091
commit ada1445458
6 changed files with 630 additions and 5 deletions
+126 -2
View File
@@ -284,6 +284,113 @@ export function scanGlobalScope(source: string): GlobalScopeScan {
return { script, globalWords, moduleBlocks, importAliases };
}
/** One `namespace A.B { … }` statement, or one in the old spelling, `module A { … }`. */
export interface NamespaceBlock {
/** The namespace's full name, the namespaces around it first: `A.B` for `namespace B` in `namespace A`, as for `namespace A.B`. */
path: string;
/** The `namespace` keyword: 1-based line, 0-based column. */
startLine: number;
startColumn: number;
/** The closing brace. */
endLine: number;
endColumn: number;
/**
* A global namespace: directly inside `declare global`, at a declaration
* script's top level, or in another global one. A script `.ts`'s top level
* is global to its own program only (see isTsTypeInScope in name-matcher).
*/
global: boolean;
}
/**
* Every namespace block in `source`, a nested one under its full name. Read
* from tokens, as the module blocks are. One in a `declare module 'x'` body is
* module `x`'s, never global, and named as if at the file's top level, where
* the module's own declarations name it (`const passport:
* passport.PassportStatic`). One in a class's or a function's body is no
* namespace a qualified name reaches, and is left out.
*/
export function scanNamespaceBlocks(source: string, declarationFile: boolean): NamespaceBlock[] {
const topLevelGlobal = declarationFile && scanAmbientModules(source).script;
const blocks: NamespaceBlock[] = [];
// What each open brace opened, innermost last.
const open: Array<NamespaceBlock | 'global' | 'module' | 'other'> = [];
let names: string[] | null = null; // the names after `namespace`, while its header is read
let expectName = false;
let keyword = { line: 0, column: 0 };
let declare = 0; // 1: `declare`, 2: `declare global`
let ambientModule = false; // just read `module 'x'`
let afterDot = false;
tokenize(source, (kind, text, line, column) => {
if (kind === Kind.Punct && text === '{') {
const header = names && names.length > 0 && !expectName ? names : null;
const outer = open[open.length - 1];
if (declare === 2) {
open.push('global');
} else if (ambientModule) {
open.push('module');
} else if (header && outer !== 'other') {
const parent = typeof outer === 'object' ? outer : null;
const block: NamespaceBlock = {
path: (parent ? `${parent.path}.` : '') + header.join('.'),
startLine: keyword.line,
startColumn: keyword.column,
endLine: line,
endColumn: column,
global: parent ? parent.global : outer === 'global' || (outer === undefined && topLevelGlobal),
};
blocks.push(block);
open.push(block);
} else {
open.push('other');
}
names = null;
declare = 0;
ambientModule = false;
afterDot = false;
return;
}
if (kind === Kind.Punct && text === '}') {
const closed = open.pop();
if (typeof closed === 'object') {
closed.endLine = line;
closed.endColumn = column;
}
names = null;
declare = 0;
ambientModule = false;
afterDot = false;
return;
}
ambientModule = names !== null && expectName && names.length === 0 && kind === Kind.String;
if (names) {
if (expectName && kind === Kind.Word) {
names.push(text);
expectName = false;
return;
}
if (!expectName && kind === Kind.Punct && text === '.') {
expectName = true;
return;
}
names = null;
}
if (kind === Kind.Word) {
if ((text === 'namespace' || text === 'module') && !afterDot) {
names = [];
expectName = true;
keyword = { line, column };
}
declare = text === 'declare' ? 1 : declare === 1 && text === 'global' ? 2 : 0;
afterDot = false;
return;
}
declare = 0;
afterDot = kind === Kind.Punct && text === '.';
});
return blocks;
}
/**
* Feed `source`'s tokens to `visit`: words, string literals (by their
* contents), template literals (as one token, their `${…}` expressions read as
@@ -483,6 +590,8 @@ interface Memo {
declarations: Map<string, AmbientModule[]>;
/** isMergedAmbientDeclaration's answers, by node id. */
merged: Map<string, boolean>;
/** Each read file's namespace blocks (see namespaceBlocks). */
namespaces: Map<string, readonly NamespaceBlock[]>;
}
const MEMO = new WeakMap<ResolutionContext, Memo>();
@@ -494,10 +603,22 @@ 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(), declarationIds: null, otherBlocks: new Map(), 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(), namespaces: new Map() }));
return memo;
}
/** The namespace blocks of `file` (scanNamespaceBlocks), read once a resolution pass. */
export function namespaceBlocks(file: string, context: ResolutionContext): readonly NamespaceBlock[] {
const memo = memoFor(context);
let blocks = memo.namespaces.get(file);
if (!blocks) {
const source = context.readFile(file);
blocks = source && /\b(?:namespace|module)\s+[A-Za-z_$]/.test(source) ? scanNamespaceBlocks(source, DECLARATION_FILE.test(file)) : [];
memo.namespaces.set(file, blocks);
}
return blocks;
}
/**
* The `declare module` blocks of the project's declaration files. Only those
* are read: a module is declared whole in a `.d.ts`, and reading every
@@ -534,7 +655,10 @@ 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. */
export function inBlock(node: Pick<Node, 'startLine' | 'startColumn'>, block: AmbientModuleBlock): boolean {
export function inBlock(
node: Pick<Node, 'startLine' | 'startColumn'>,
block: Pick<AmbientModuleBlock, 'startLine' | 'startColumn' | 'endLine' | 'endColumn'>
): 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;
+229 -1
View File
@@ -15,7 +15,8 @@ import { resolveWorkspaceImport } from './workspace-packages';
import { stripCommentsForRegex } from './strip-comments';
import { makeLineAt } from './synth-utils';
import { dartDirectiveFile } from './dart-libraries';
import { ambientModuleDeclares, findAmbientModuleExport, findAmbientModuleFile, isInModuleBlock } from './ambient-modules';
import { ambientModuleDeclares, findAmbientModuleExport, findAmbientModuleFile, inBlock, isInModuleBlock, namespaceBlocks, type NamespaceBlock } from './ambient-modules';
import { isTestFile } from '../search/query-utils';
import { moduleTail } from '../db/reference-tail';
import {
resolveMethodOnType,
@@ -30,6 +31,7 @@ import {
reExportSource,
pythonFunctionModuleAlias,
isRustAssociatedItem,
tsGlobalScope,
} from './name-matcher';
import { JS_ARRAY_METHODS } from './js-builtins';
@@ -2630,6 +2632,232 @@ export function resolvePhpQualifiedClassRef(
return { original: ref, targetNodeId: methods[0]!.id, confidence: 0.95, resolvedBy: 'import' };
}
/** TypeScript and JavaScript files: where the extractors record type names. */
const TS_JS_FILE = /\.[cm]?[jt]sx?$/i;
/**
* The last name of a TypeScript type written qualified — `React.ReactNode`,
* `TChat.ChatMessage`, `EQueryType.CLICKHOUSE` — which both extractors record
* alone. TypeScript reads it as a member of what its qualifier names: the
* module an import names (`import * as T`, or a package the repository
* declares itself, `vscode.TextDocument`), a namespace, enum or class an
* import names or the file declares, or a namespace the project declares
* global (`Express.User`). Matched by the last name alone, outline's
* `React.ReactNode` reached its own editor `class ReactNode`, excalidraw's
* `TChat.ChatMessage` a `ChatMessage` component, signoz's
* `EQueryType.CLICKHOUSE` another enum's member, and outline's
* `UnfurlResourceType.Group` the `Group` model the file imports. undefined
* when the name was not written after a qualifier this reads
* (`import('./x').T`, a line break), names an `import X = ns.Y` alias, or
* names a module whose member it cannot follow; null when the qualifier is a
* package's or a global the project doesn't declare, or has no such member —
* so name fallbacks must not guess.
*/
export function resolveTsQualifiedTypeRef(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined {
if (ref.referenceKind !== 'references' || !ESM_IMPORT_LANGUAGES.has(ref.language) || !TS_JS_FILE.test(ref.filePath)) return undefined;
const qualifier = typeQualifier(ref, context);
if (!qualifier) return undefined;
const head = qualifier[0]!;
const path = [...qualifier.slice(1), ref.referenceName];
const imp = context.getImportMappings(ref.filePath, ref.language).find((m) => m.localName === head);
let target: Node | null | undefined;
if (imp) {
target = importedTypeMember(imp, path, ref, context);
} else {
if (tsGlobalScope(ref.filePath, context)?.importAliases.has(head)) return undefined;
target = ownTypeMember(head, path, ref, context);
if (target === undefined) target = globalTypeMember(qualifier.join('.'), ref, context) ?? null;
}
if (target === undefined) return undefined;
return target ? { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: imp ? 'import' : 'qualified-name' } : null;
}
/**
* The names a type's last name was written after, first to last: `['React']`
* for `React.ReactNode`, `['a', 'b']` for `a.b.C`. Null for a name written
* alone, after an expression (`import('./x').T`), or not where the reference
* puts it.
*/
function typeQualifier(ref: UnresolvedRef, context: ResolutionContext): string[] | null {
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 null;
const names: string[] = [];
let i = ref.column - 1;
for (;;) {
while (i >= 0 && (line[i] === ' ' || line[i] === '\t')) i--;
if (line[i] !== '.') break;
i--;
while (i >= 0 && (line[i] === ' ' || line[i] === '\t')) i--;
const end = i + 1;
while (i >= 0 && /[\w$]/.test(line[i]!)) i--;
const name = line.slice(i + 1, end);
if (!/^[A-Za-z_$][\w$]*$/.test(name)) return null;
names.unshift(name);
}
return names.length > 0 ? names : null;
}
/**
* A type path through an import of the file's: the module's export the path
* starts with (`T.X`, through barrels and the repository's `declare module`
* blocks), or the member of the enum, class or namespace the import names.
* null for a package the repository doesn't declare, or a container without
* the member; undefined when the module's export can't be followed.
*/
function importedTypeMember(imp: ImportMapping, path: string[], ref: UnresolvedRef, context: ResolutionContext): Node | null | undefined {
const file = resolveImportPath(imp.source, ref.filePath, ref.language, context);
const bare = isBareModuleSpecifier(imp.source, ref.language);
let found: Node | undefined;
let rest: string[];
if (imp.isNamespace) {
const want = { isDefault: false, isNamespace: true, exportedName: path[0]!, memberName: path[0]! };
found = (bare ? findAmbientModuleExport(imp.source, want, ref, context) : undefined) ??
(file ? findExportedSymbol(file, want, ref.language, context, new Set()) : undefined);
if (found && path.length === 1) return found;
rest = path.slice(1);
} else {
const want = { isDefault: imp.isDefault, isNamespace: false, exportedName: imp.isDefault ? 'default' : imp.exportedName, memberName: null };
found = (bare ? findAmbientModuleExport(imp.source, want, ref, context) : undefined) ??
(file ? findExportedSymbol(file, want, ref.language, context, new Set()) : undefined);
rest = path;
}
if (found) {
const member = STATIC_MEMBER_CONTAINERS.has(found.kind) ? containerMember(found, rest, context) : undefined;
return member ?? typeMemberIn(found.filePath, [], [found.name, ...rest], context) ?? null;
}
// A namespace the module declares, for which the index holds no node.
const namespace = imp.isNamespace ? path[0]! : imp.isDefault ? null : imp.exportedName;
const declaring = file && namespace ? namespaceDeclaringFile(file, namespace, ref.language, context) : undefined;
if (declaring) return typeMemberIn(declaring.file, [], [declaring.name, ...rest], context) ?? null;
// A package from outside the repository, which it doesn't declare itself:
// its type is no project node. An import this resolver can't follow but no
// manifest declares, like zod's own tests' `import * as z from "zod/v3"`
// through the package's `exports`, keeps the ordinary strategies.
const outside = !file && !findAmbientModuleFile(imp.source, ref.filePath, context) &&
context.isOutOfRepoImport?.(imp.source, ref.filePath, ref.language) === true;
return outside ? null : undefined;
}
/**
* A type path through what the file declares itself as `head`: in the
* innermost namespace around the reference that declares it, else at the
* file's top level. null when the file declares `head` but nothing at the
* path; undefined when it declares no `head`.
*/
function ownTypeMember(head: string, path: string[], ref: UnresolvedRef, context: ResolutionContext): Node | null | undefined {
const at = { startLine: ref.line, startColumn: ref.column };
const scopes = namespaceBlocks(ref.filePath, context)
.filter((b) => inBlock(at, b))
.map((b) => b.path.split('.'))
.sort((a, b) => b.length - a.length);
for (const scope of [...scopes, []]) {
if (!hasNamespace(ref.filePath, [...scope, head].join('.'), context) && declarationsIn(ref.filePath, scope, head, context).length === 0) continue;
return typeMemberIn(ref.filePath, scope, [head, ...path], context) ?? null;
}
return undefined;
}
/**
* The member of a namespace the project declares global (`Express.User`, an
* interface in `declare global { namespace Express { … } }`): a declaration of
* the reference's name directly in such a block. A test's or a fixture's
* globals reach its tests only.
*/
function globalTypeMember(namespace: string, ref: UnresolvedRef, context: ResolutionContext): Node | undefined {
const fromTest = isTestFile(ref.filePath);
return context.getNodesByName(ref.referenceName)
.filter((n) => {
if (!TS_JS_FILE.test(n.filePath) || n.qualifiedName !== n.name || !MEMBER_DECLARATION_KINDS.has(n.kind)) return false;
if (!fromTest && isTestFile(n.filePath)) return false;
const block = innermostBlock(n, namespaceBlocks(n.filePath, context));
return block?.global === true && block.path === namespace;
})
.sort(typesFirst)[0];
}
/**
* What `path` names in `file`, from the namespace `scope` (the file's top
* level when empty): a namespace takes the next name as a member of its own,
* and an enum, a class or an interface takes the rest as its `::` members.
*/
function typeMemberIn(file: string, scope: string[], path: string[], context: ResolutionContext): Node | undefined {
const [name, ...rest] = path;
if (name === undefined) return undefined;
if (rest.length > 0 && hasNamespace(file, [...scope, name].join('.'), context)) {
return typeMemberIn(file, [...scope, name], rest, context);
}
const declared = declarationsIn(file, scope, name, context);
if (rest.length === 0) return declared[0];
const container = declared.find((n) => STATIC_MEMBER_CONTAINERS.has(n.kind));
return container ? containerMember(container, rest, context) : undefined;
}
/** An enum's, a class's or an interface's member at `path` (`Kind::Line`), in the container's own file. */
function containerMember(container: Node, path: string[], context: ResolutionContext): Node | undefined {
if (path.length === 0) return undefined;
return context.getNodesByQualifiedName(`${container.qualifiedName}::${path.join('::')}`)
.filter((n) => n.filePath === container.filePath)
.sort(typesFirst)[0];
}
/** Kinds a declaration at a file's or a namespace's own level has; a class's member is `Class::member`. */
const MEMBER_DECLARATION_KINDS: ReadonlySet<Node['kind']> = new Set<Node['kind']>([
'class', 'interface', 'type_alias', 'enum', 'struct', 'function', 'component', 'constant', 'variable',
]);
/** Kinds a type position names. */
const TYPE_DECLARATION_KINDS: ReadonlySet<Node['kind']> = new Set<Node['kind']>([
'class', 'interface', 'type_alias', 'enum', 'enum_member', 'struct',
]);
/** Types before values: an interface before the constant merged with it. */
function typesFirst(a: Node, b: Node): number {
return Number(!TYPE_DECLARATION_KINDS.has(a.kind)) - Number(!TYPE_DECLARATION_KINDS.has(b.kind));
}
/** The declarations named `name` directly in the namespace `scope` of `file` (its top level when empty), types first. */
function declarationsIn(file: string, scope: string[], name: string, context: ResolutionContext): Node[] {
const blocks = namespaceBlocks(file, context);
const want = scope.join('.');
return (context.getNodesInFileNamed?.(file, name) ?? context.getNodesInFile(file).filter((n) => n.name === name))
.filter((n) => n.qualifiedName === name && MEMBER_DECLARATION_KINDS.has(n.kind) && (innermostBlock(n, blocks)?.path ?? '') === want)
.sort(typesFirst);
}
/** Whether `file` declares the namespace `path`, itself or as the prefix of a longer one (`namespace A.B` declares `A`). */
function hasNamespace(file: string, path: string, context: ResolutionContext): boolean {
return namespaceBlocks(file, context).some((b) => b.path === path || b.path.startsWith(`${path}.`));
}
/** The innermost namespace block a node starts in. */
function innermostBlock(node: Pick<Node, 'startLine' | 'startColumn'>, blocks: readonly NamespaceBlock[]): NamespaceBlock | undefined {
let inner: NamespaceBlock | undefined;
for (const block of blocks) {
if (!inBlock(node, block)) continue;
if (!inner || block.startLine > inner.startLine || (block.startLine === inner.startLine && block.startColumn > inner.startColumn)) inner = block;
}
return inner;
}
/** The file that declares the namespace a module exports as `name`, through its re-exports, and the name it declares it under. */
function namespaceDeclaringFile(
file: string,
name: string,
language: Language,
context: ResolutionContext,
depth = 0,
): { file: string; name: string } | undefined {
if (depth > REEXPORT_MAX_DEPTH) return undefined;
if (namespaceBlocks(file, context).some((b) => b.path === name || b.path.startsWith(`${name}.`))) return { file, name };
for (const rex of context.getReExports?.(file, language) ?? []) {
if (rex.kind !== 'wildcard' && !(rex.kind === 'named' && rex.exportedName === name)) continue;
const next = resolveImportPath(rex.source, file, language, context);
const found = next ? namespaceDeclaringFile(next, rex.kind === 'named' ? rex.originalName : name, language, context, depth + 1) : undefined;
if (found) return found;
}
return undefined;
}
export function resolveViaImport(
ref: UnresolvedRef,
context: ResolutionContext
+7 -1
View File
@@ -35,7 +35,7 @@ import { clearCppIncluderMemos } from './cpp-includers';
import { clearCFeatureVariantMemos } from './c-feature-variants';
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, reachesAmbientModule, importBindingTail, clearImportResolverMemos, resolveImportPath, isExternalImport, ESM_IMPORT_LANGUAGES, jsModuleFile, pythonRootFingerprint, pythonReopenScope, pythonPackageImporters } from './import-resolver';
import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveTsQualifiedTypeRef, 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';
@@ -1496,6 +1496,12 @@ export class ReferenceResolver {
return this.gateLanguage(matchVbPathCall(ref, this.context, (name) => isStdMethodName('vbnet', name)), ref);
}
// The last name of a TypeScript type written qualified (`React.ReactNode`,
// `TChat.ChatMessage`) is a member of what its qualifier names, which no
// framework, import or name strategy may guess past by the name alone.
const tsQualified = resolveTsQualifiedTypeRef(ref, this.context);
if (tsQualified !== undefined) return this.gateLanguage(tsQualified, ref);
// Function-as-value refs (#756) get a dedicated, strictly-gated path:
// import-based resolution first (an imported callback resolves through its
// import, the most precise cross-file signal), then matchFunctionRef
+1 -1
View File
@@ -1540,7 +1540,7 @@ const TS_JS_FILE = /\.[cm]?[jt]sx?$/i;
const TS_GLOBAL_SCOPES = new WeakMap<ResolutionContext, Map<string, GlobalScopeScan | null>>();
/** scanGlobalScope of `file`, a CommonJS file's top level counting as its own. */
function tsGlobalScope(file: string, context: ResolutionContext): GlobalScopeScan | null {
export function tsGlobalScope(file: string, context: ResolutionContext): GlobalScopeScan | null {
let memo = TS_GLOBAL_SCOPES.get(context);
if (!memo) TS_GLOBAL_SCOPES.set(context, (memo = new Map()));
let scope = memo.get(file);