From 458ad5192155cfac52ce1cc4f51b8dd52e2f4ea3 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 21 Oct 2026 08:34:49 +0000 Subject: [PATCH] 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 and frontend's , 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 --- CHANGELOG.md | 1 + __tests__/extraction.test.ts | 6 +- __tests__/ts-type-reference-scope.test.ts | 165 ++++++++++++++++++++++ src/resolution/ambient-modules.ts | 21 ++- src/resolution/name-matcher.ts | 90 ++++++++---- 5 files changed, 251 insertions(+), 32 deletions(-) create mode 100644 __tests__/ts-type-reference-scope.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7986b8d95..7df86900b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -263,6 +263,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a call written through a namespace, like protocolbuffers/protobuf's `absl::StripPrefix(…)` or fmtlib/fmt's `fmt::ptr(p)`, no longer links to a class's method that only shares the function's name, like protobuf's `MultiCasePrefixStripper::StripPrefix` or a test scanner's `ptr()`: a namespace holds functions and types, not methods. A call through a namespace a macro opens now links to what that namespace declares, so fmt's `fmt::ptr(p)` reaches fmt's own `ptr` function and `fmt::day(…)` its `day` type, while a call into a library outside your project, like abseil, links to nothing. A call through a lowercase class, or an alias or typedef of one, links as before. Re-index C++ projects after upgrading. - In TypeScript and JavaScript, a call to a function's own parameter, like `isAvailable(f)` on a parameter typed `(f: string) => boolean` or `compare(a, b)` on a comparator parameter, no longer links to a function of the same name in another file, or to the file's own import of that name. Before, a parameter whose type or default value contained parentheses, as a function type does, wasn't recognized as one. A parameter or local stands in for an import of its name only where it is in scope, so a Promise executor's `resolve(…)` no longer links to an imported `path.resolve`, while a `resolve(file)` after the executor still does. Re-index TypeScript and JavaScript projects after upgrading. - In TypeScript and JavaScript, a name ending in `$`, like `syncState$`, is no longer read as a declaration of `syncState`, so a call to an imported `syncState(…)` in the same function keeps its link, and a file's own `function $()` or `const $ = …` now counts as declaring `$`. +- In TypeScript, a type a file uses without importing it, like `Omit`, `Record`, the DOM's `Event`, or a generic's own `T`, no longer links to a type of that name another module of your project declares, like a local `export type Omit` or an enum member `T`: it links to the file's own type or a global one, declared in a script `.d.ts` or a `declare global` block. A type you import, write qualified (`vscode.TextDocument`), bring in with `import X = ns.X`, or name inside a `declare module` block links as before. Re-index TypeScript projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 498b5abc9..ed460dd4c 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -7011,13 +7011,15 @@ describe('Cross-language type/import gate (RN name collisions)', () => { // TS name-match — so a TS ref to `TestRunner` crossed web→jvm. The ref here // is intentionally NOT imported: a clean relative import would mask the bug // by resolving via the import map before the framework strategy can win. + // So the TS type is a global, declared in a script `.d.ts`: the one kind of + // type a module names without importing it. fs.writeFileSync( path.join(tempDir, 'package.json'), JSON.stringify({ dependencies: { 'react-native': '*' } }) ); fs.writeFileSync( - path.join(tempDir, 'useTests.ts'), - `export type TestRunner = { run: () => void };\n` + path.join(tempDir, 'useTests.d.ts'), + `type TestRunner = { run: () => void };\n` ); fs.writeFileSync( path.join(tempDir, 'basic.tsx'), diff --git a/__tests__/ts-type-reference-scope.test.ts b/__tests__/ts-type-reference-scope.test.ts new file mode 100644 index 000000000..33656e526 --- /dev/null +++ b/__tests__/ts-type-reference-scope.test.ts @@ -0,0 +1,165 @@ +/** + * A type an ES module names without importing it is the module's own or a + * global one — never another module's type of that name. + * + * // quickInput.ts + * export type Omit = Pick>; + * // picker.ts + * export function pick(options: Omit) {} + * + * picker.ts's `Omit` is TypeScript's own, yet a type annotation's name went + * to the name matcher like any other, which handed it the one project type + * of that name: 336 of vscode's annotations reached quickInput.ts's `export + * type Omit`. #2726 held a supertype to this rule; a type annotation, a type + * argument and a type alias's value now follow it too. A type the file + * declares, one a script's top level or a `declare global` block declares, + * and the last name of a qualified type (`qi.IPickOptions`) link as before. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ts-type-scope-')); + const files: Record = { + 'src/quickInput.ts': `export type Omit = Pick>; +export interface IPickOptions { canPickMany?: boolean; title?: string } +`, + 'src/events.ts': 'export class Event { constructor(readonly type: string) {} }\n', + 'src/picker.ts': `import { IPickOptions } from './quickInput'; +type Local = { id: string }; +export function pick(options: Omit): Local { + return { id: options.title ?? '' }; +} +export function onClick(handler: (e: Event) => void): void { + void handler; +} +export type Picked = Omit; +`, + // An augmentation's names are the augmented module's own, imported or not. + 'src/react/reactInterfaces.ts': 'export type FCReactive = P & P2;\nexport interface IReactive { base?: string }\n', + 'src/types/reactive-native.d.ts': `import type { IReactive } from '../react/reactInterfaces'; +declare module '../react/reactInterfaces' { + interface IReactive { + View: FCReactive; + } +} +`, + // An `import X = ns.X` alias imports its name, as bitwarden's desktop code does napi's classes. + 'src/napi.d.ts': 'export declare namespace autofill {\n export class IpcServer { listen(): void }\n}\n', + 'src/alias.ts': `import { autofill } from './napi'; +import IpcServer = autofill.IpcServer; +export function serve(server: IpcServer): void { + void server; +} +`, + // Only the namespace is imported: `IPickOptions` is recorded alone, written qualified. + 'src/qualified.ts': `import * as qi from './quickInput'; +export function qualified(options: qi.IPickOptions): void { + void options; +} +`, + // A script declaration file (no import or export): its declarations are global. + 'src/typings/globals.d.ts': 'interface GlobalThing { id: string }\n', + // A module's `declare global` block, and a module-scoped namesake nearer the user. + 'src/env.ts': `export {}; +declare global { + interface AppConfig { debug: boolean } +} +`, + 'src/app/config.ts': 'export interface AppConfig { port: number }\n', + 'src/app/use.ts': `export function useGlobal(thing: GlobalThing): string { + return thing.id; +} +export function useConfig(config: AppConfig): boolean { + return config.debug; +} +export function useScripts(thing: LegacyThing, fixture: FixtureGlobal): number { + return thing.id + fixture.x; +} +export const firstOf = (items: T[]): T | undefined => items[0]; +export function useShared(shape: SharedShape): string { + return shape.id; +} +`, + // A global both the project and a nearer fixture declare: the project's wins, ahead of the ranking. + 'types/shared.d.ts': 'interface SharedShape { id: string }\n', + 'src/app/__tests__/shared-fixture.d.ts': 'interface SharedShape { id: string; fixture: true }\n', + // A script .ts is no declaration file: its top level is global to its own program only. + 'src/legacy/globals.ts': 'interface LegacyThing { id: number }\n', + // A fixture's globals are its tests' — a declaration file's, and a colorize-style script's `type T`. + 'test/fixtures/fixture.d.ts': 'interface FixtureGlobal { x: number }\n', + 'test/fixtures/colorize.ts': 'const n = null;\ntype T = null | undefined;\n', + 'test/use-fixture.test.ts': `export function check(fixture: FixtureGlobal): number { + return fixture.x; +} +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +/** `:` of every type the named symbol in `file` references. */ +const referencesFrom = (file: string, name: string): string[] => { + const node = cg.getNodesInFile(file).find((n) => n.name === name && n.kind !== 'file')!; + expect(node, `${file} ${name}`).toBeDefined(); + return cg.getOutgoingEdges(node.id).filter((e) => e.kind === 'references').map((e) => { + const target = cg.getNode(e.target)!; + return `${target.filePath}:${target.qualifiedName}`; + }); +}; + +describe('a type a TS module names without importing it', () => { + it('is no other module’s type of that name: TypeScript’s Omit, the DOM’s Event', () => { + const picked = referencesFrom('src/picker.ts', 'pick'); + expect(picked).not.toContain('src/quickInput.ts:Omit'); + expect(referencesFrom('src/picker.ts', 'onClick')).not.toContain('src/events.ts:Event'); + expect(referencesFrom('src/picker.ts', 'Picked')).not.toContain('src/quickInput.ts:Omit'); + }); + + it('is the module’s own type, and an imported one still links through its import', () => { + const picked = referencesFrom('src/picker.ts', 'pick'); + expect(picked).toContain('src/picker.ts:Local'); + expect(picked).toContain('src/quickInput.ts:IPickOptions'); + expect(referencesFrom('src/picker.ts', 'Picked')).toContain('src/quickInput.ts:IPickOptions'); + }); + + it('is a global: a script’s top level or a declare global block, ahead of a nearer module’s namesake', () => { + expect(referencesFrom('src/app/use.ts', 'useGlobal')).toContain('src/typings/globals.d.ts:GlobalThing'); + const config = referencesFrom('src/app/use.ts', 'useConfig'); + expect(config).toContain('src/env.ts:AppConfig'); + expect(config).not.toContain('src/app/config.ts:AppConfig'); + }); + + it('leaves the last name of a qualified type to link as before', () => { + expect(referencesFrom('src/qualified.ts', 'qualified')).toContain('src/quickInput.ts:IPickOptions'); + }); + + it('leaves a name inside a declare module block to the module it declares', () => { + expect(referencesFrom('src/types/reactive-native.d.ts', 'View')).toContain('src/react/reactInterfaces.ts:FCReactive'); + }); + + it('is no script .ts’s top-level type, nor a fixture’s outside the tests', () => { + expect(referencesFrom('src/app/use.ts', 'useScripts')).toEqual([]); + expect(referencesFrom('src/app/use.ts', 'firstOf')).not.toContain('test/fixtures/colorize.ts:T'); + expect(referencesFrom('test/use-fixture.test.ts', 'check')).toContain('test/fixtures/fixture.d.ts:FixtureGlobal'); + expect(referencesFrom('src/app/use.ts', 'useShared')).toEqual(['types/shared.d.ts:SharedShape']); + }); + + it('counts an import X = ns.X alias as an import of its name', () => { + expect(referencesFrom('src/alias.ts', 'serve').some((t) => t.startsWith('src/napi.d.ts:') && t.endsWith('IpcServer'))).toBe(true); + }); +}); diff --git a/src/resolution/ambient-modules.ts b/src/resolution/ambient-modules.ts index 495185bbb..73f1c4dee 100644 --- a/src/resolution/ambient-modules.ts +++ b/src/resolution/ambient-modules.ts @@ -235,6 +235,10 @@ export interface GlobalScopeScan { * `abstract`, `enum`, `function`, `const`, …). */ globalWords: Set; + /** The file's `declare module '' { … }` blocks, whose names are the declared module's. */ + moduleBlocks: readonly AmbientModuleBlock[]; + /** Names an `import X = …` declaration binds: `import AutofillIpcServer = autofill.AutofillIpcServer`. */ + importAliases: Set; } /** @@ -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(); + const importAliases = new Set(); 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 { 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: Pick, block: AmbientModuleBlock): boolean { +export 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; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 11c69d3f7..dfe0b326f 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -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` 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.