diff --git a/CHANGELOG.md b/CHANGELOG.md index 19c04006a..cd4756156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -266,6 +266,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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. - In TypeScript React files, a type annotation no longer links to a component of the same name. When a file declares a type next to a component or value of the same name, like `type ModalRegistration` beside a `ModalRegistration` component, its annotations now link to the type while its calls still link to the component, and a type the file declares on its own, like `type Option`, or a generic's type parameter, like ``, no longer links to an `Option` or `Item` component elsewhere in the project. Before, `codegraph callers` and impact listed those annotations as uses of the component. Re-index TypeScript React projects after upgrading. - In React, a component declared through a wrapper with no render function of its own, like styled-components' `styled(ActionButton)` (with `.attrs(…)`, `.withConfig(…)` or type arguments too), emotion's and MUI's `styled(Box)(…)`, `memo(Button)` or `forwardRef(Render)`, now links to the component it wraps, so a render flow in `codegraph_explore`, a component's callers and its impact go on through the wrapper instead of stopping at it. The calls and values in a wrapper's styles, like `${(props) => theme.color(props)}` or a shared `css` block, now belong to the wrapper, as they do to any other constant, so a theme helper used only in styles has callers. A component that wraps its render function in `forwardRef(…)` or `memo(…)` now shows in the impact of the top-level constants it reads, as a function component does. A wrapper around a component that comes from a package, like react-router's `Link` or antd's `Typography.Text`, links to nothing, and the file that declares a wrapper no longer shows as referencing the component it wraps. Re-index React projects after upgrading. +- In TypeScript, a type written with a qualifier, like `React.ReactNode`, `Y.Doc`, `TChat.ChatMessage` or `EQueryType.CLICKHOUSE`, now links to the member of what the qualifier names: a type from a package, like React's, no longer links to a class or component of your own that shares its name, and a member of an enum or a namespace your project declares links to that member instead of to another enum's or a same-named component. Re-index TypeScript projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/ts-qualified-type-names.test.ts b/__tests__/ts-qualified-type-names.test.ts new file mode 100644 index 000000000..0e1aec652 --- /dev/null +++ b/__tests__/ts-qualified-type-names.test.ts @@ -0,0 +1,266 @@ +/** + * The last name of a TypeScript type written qualified — `React.ReactNode`, + * `TChat.ChatMessage`, `SectionKind.Legend` — is a member of what its qualifier + * names, never a project declaration that only shares the word. Both + * extractors record the last name alone, and name matching bound it by word: + * outline's `React.ReactNode` annotations reached its own editor `class + * ReactNode`, excalidraw's `TChat.ChatMessage` a `ChatMessage` component, + * signoz's `EQueryType.CLICKHOUSE` another enum's `CLICKHOUSE`, and bbb's + * `PluginSdk.Meeting` the app's own `Meeting` interface. + */ +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'; +import type { Node } from '../src/types'; + +let root: string; +let cg: CodeGraph; + +const FILES: Record = { + 'package.json': JSON.stringify({ name: 'app', private: true, dependencies: { react: '^18.0.0', yjs: '^13.0.0' } }), + 'tsconfig.json': JSON.stringify({ compilerOptions: { jsx: 'react-jsx', strict: true } }), + // Namesakes of the members the files below name through a qualifier. + 'src/editor/nodes/ReactNode.ts': `export default class ReactNode { + render(): string { + return "node"; + } +} +`, + 'src/editor/nodes/Doc.ts': `export class Doc { + title = ""; +} +`, + 'src/components/ChatMessage.tsx': `export function ChatMessage({ text }: { text: string }) { + return
  • {text}
  • ; +} +`, + 'src/explorer/views.ts': `export enum ExplorerViews { + CLICKHOUSE = "clickhouse", + LIST = "list", +} +`, + 'src/models/Group.ts': `export default class Group { + name = ""; +} +`, + // What the qualifiers name. + 'src/types/dashboard.ts': `export enum EQueryType { + CLICKHOUSE = "clickhouse", + PROM = "promql", +} +`, + 'src/chat/types.ts': `export namespace TChat { + export type ChatMessage = { + id: string; + text: string; + }; + + export type ChatHistory = { + messages: ChatMessage[]; + }; +} +`, + 'src/api/schema.ts': `export type UsersListReq = { + limit: number; +}; +`, + 'src/shared/types.ts': `export enum UnfurlResourceType { + Group = "group", + Document = "document", +} +`, + 'src/typings/express.d.ts': `declare global { + namespace Express { + interface User { + id: string; + } + } +} + +export {}; +`, + 'src/typings/vscode.d.ts': `declare module "vscode" { + export interface TextDocument { + uri: string; + } +} +`, + 'src/typings/passport.d.ts': `declare module "koa-passport" { + namespace passport { + interface PassportStatic { + initialize(): void; + } + } + + const passport: passport.PassportStatic; + export = passport; +} +`, + // The repository's own package by its name, through a map this resolver doesn't read. + 'src/selfref.ts': `import * as App from "app/api/schema"; + +export type Query = App.UsersListReq; +`, + // The files that write qualified types. + 'src/components/Panel.tsx': `import * as React from "react"; +import * as Y from "yjs"; +import type { TChat } from "../chat/types"; +import { EQueryType } from "../types/dashboard"; +import * as T from "../api/schema"; + +type Props = { + children: React.ReactNode; + doc: Y.Doc; + messages: TChat.ChatMessage[]; + kind: EQueryType.CLICKHOUSE; + query: T.UsersListReq; +}; + +export function Panel({ children }: Props) { + return
    {children}
    ; +} +`, + 'src/components/Legacy.tsx': `import React from "react"; + +type LegacyProps = { + title: React.ReactNode; +}; + +export function Legacy({ title }: LegacyProps) { + return

    {title}

    ; +} +`, + // React as a global (the UMD namespace @types/react declares), never imported. + 'src/components/Badge.tsx': `type BadgeProps = { + label: React.ReactNode; +}; + +export function Badge({ label }: BadgeProps) { + return {label}; +} +`, + 'src/unfurl.ts': `import Group from "./models/Group"; +import { UnfurlResourceType } from "./shared/types"; + +export type GroupUnfurl = { + type: UnfurlResourceType.Group; + group: Group; +}; +`, + 'src/kinds.ts': `enum Kind { + Line = "line", + Bar = "bar", +} + +namespace Shapes { + export interface Circle { + radius: number; + } +} + +export type LineKind = Kind.Line; +export type Round = Shapes.Circle; +`, + 'src/auth.ts': `export function currentUser(user: Express.User): string { + return user.id; +} +`, + 'src/extension.ts': `import * as vscode from "vscode"; + +export function uriOf(doc: vscode.TextDocument): string { + return doc.uri; +} +`, +}; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ts-qualified-')); + 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 }); +}, 120_000); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +function node(file: string, name: string, kind?: string): Node { + const found = cg.getNodesInFile(file).filter((n) => n.name === name && (!kind || n.kind === kind)); + expect(found, `${kind ?? 'node'} ${name} in ${file}`).toHaveLength(1); + return found[0]!; +} + +/** Where a node's `references` edges named `name` go: `kind:qualifiedName@file`. */ +function targets(from: Node, name: string): string[] { + return cg.getOutgoingEdges(from.id) + .filter((e) => e.kind === 'references' && e.metadata?.refName === name) + .map((e) => { + const target = cg.getNode(e.target)!; + return `${target.kind}:${target.qualifiedName}@${target.filePath}`; + }) + .sort(); +} + +describe('the last name of a qualified type', () => { + it('is nothing of the project for a member of a package', () => { + const props = node('src/components/Panel.tsx', 'Props', 'type_alias'); + expect(targets(props, 'ReactNode')).toEqual([]); + expect(targets(props, 'Doc')).toEqual([]); + const legacy = node('src/components/Legacy.tsx', 'LegacyProps', 'type_alias'); + expect(targets(legacy, 'ReactNode')).toEqual([]); + }); + + it('is nothing of the project for a member of a global namespace the project does not declare', () => { + const badge = node('src/components/Badge.tsx', 'BadgeProps', 'type_alias'); + expect(targets(badge, 'ReactNode')).toEqual([]); + }); + + it("is the imported namespace's member, not a component of its name", () => { + const props = node('src/components/Panel.tsx', 'Props', 'type_alias'); + expect(targets(props, 'ChatMessage')).toEqual(['type_alias:ChatMessage@src/chat/types.ts']); + }); + + it("is the imported enum's member, not another enum's", () => { + const props = node('src/components/Panel.tsx', 'Props', 'type_alias'); + expect(targets(props, 'CLICKHOUSE')).toEqual(['enum_member:EQueryType::CLICKHOUSE@src/types/dashboard.ts']); + }); + + it("is the export of the module a namespace import names", () => { + const props = node('src/components/Panel.tsx', 'Props', 'type_alias'); + expect(targets(props, 'UsersListReq')).toEqual(['type_alias:UsersListReq@src/api/schema.ts']); + }); + + it("is the qualifier's member even when the file imports the last name too", () => { + const unfurl = node('src/unfurl.ts', 'GroupUnfurl', 'type_alias'); + expect(targets(unfurl, 'Group')).toEqual([ + 'class:Group@src/models/Group.ts', + 'enum_member:UnfurlResourceType::Group@src/shared/types.ts', + ]); + }); + + it("is the member of an enum or a namespace the file declares", () => { + expect(targets(node('src/kinds.ts', 'LineKind', 'type_alias'), 'Line')).toEqual(['enum_member:Kind::Line@src/kinds.ts']); + expect(targets(node('src/kinds.ts', 'Round', 'type_alias'), 'Circle')).toEqual(['interface:Circle@src/kinds.ts']); + }); + + it('is the member of a global namespace the project declares', () => { + expect(targets(node('src/auth.ts', 'currentUser'), 'User')).toEqual(['interface:User@src/typings/express.d.ts']); + }); + + it('is the member of a package module the project declares', () => { + expect(targets(node('src/extension.ts', 'uriOf'), 'TextDocument')).toEqual(['interface:TextDocument@src/typings/vscode.d.ts']); + }); + + it("is the member of a namespace a declared module's body declares, for the module's own declarations", () => { + expect(targets(node('src/typings/passport.d.ts', 'passport'), 'PassportStatic')).toEqual(['interface:PassportStatic@src/typings/passport.d.ts']); + }); + + it("is left to the name strategies through the repository's own package, which no manifest declares", () => { + expect(targets(node('src/selfref.ts', 'Query', 'type_alias'), 'UsersListReq')).toEqual(['type_alias:UsersListReq@src/api/schema.ts']); + }); +}); diff --git a/src/resolution/ambient-modules.ts b/src/resolution/ambient-modules.ts index 73f1c4dee..17f5a052c 100644 --- a/src/resolution/ambient-modules.ts +++ b/src/resolution/ambient-modules.ts @@ -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 = []; + 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; /** isMergedAmbientDeclaration's answers, by node id. */ merged: Map; + /** Each read file's namespace blocks (see namespaceBlocks). */ + namespaces: Map; } const MEMO = new WeakMap(); @@ -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 { const DECLARATION_KINDS = new Set(['function', 'class', 'interface', 'type_alias', 'enum', 'constant', 'variable', 'component']); /** Whether `node` starts inside the block, by position. */ -export function inBlock(node: Pick, block: AmbientModuleBlock): boolean { +export function inBlock( + node: Pick, + block: Pick +): 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/import-resolver.ts b/src/resolution/import-resolver.ts index f26951168..9f968c44b 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -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 = new Set([ + 'class', 'interface', 'type_alias', 'enum', 'struct', 'function', 'component', 'constant', 'variable', +]); + +/** Kinds a type position names. */ +const TYPE_DECLARATION_KINDS: ReadonlySet = new Set([ + '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, 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 diff --git a/src/resolution/index.ts b/src/resolution/index.ts index c0f9e5c61..126c75f05 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -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 diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index dfe0b326f..7b1ebf019 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1540,7 +1540,7 @@ const TS_JS_FILE = /\.[cm]?[jt]sx?$/i; const TS_GLOBAL_SCOPES = new WeakMap>(); /** 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);