From a33040f786ceaa05131045cbd02f602ac040862e Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 20 Oct 2026 21:35:02 +0000 Subject: [PATCH] fix(js,ts): an import of a package the repository declares itself links to that declaration (#2718) A `declare module 'mammoth' { ... }` in a script .d.ts declares the module outright, and TypeScript takes it before any file the specifier resolves to. Nothing mapped a package name to a file in the repository, so an import of such a package linked nothing, or, where the out-of-repo guard did not fire (VS Code's extensions list no `vscode` dependency), a project namesake. A bodiless function in an ambient context produced no node either, so `convertToHtml` had nothing to link to. - ambient-modules.ts: a token scanner for `declare module` blocks, a per-context index of the project's declaration files, and the lookup the import resolver makes: named, default (`export default`, `export =`), export-clause and namespace-member imports, by position in the block. - The import resolver consults a script declaration before a file a path alias maps the specifier to (copilot's `paths` shim), and the import's module reference and namespace or default binding link the declaring file. - Extraction, both engines: a function_signature under `declare`, in `declare global`, at a declaration file's top level or directly in its `declare module` body is a function; never in a namespace, nor in an ordinary module file's `declare module`, an augmentation. - A module's function is reached from another file only through an import. - A type merged across the files declaring one module is no single declaration when a receiver is typed. - Sync re-opens the importers of a package a changed declaration file declares. Augmentations, wildcard patterns, shorthands and test-fixture declarations are left alone. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/ambient-module-imports.test.ts | 676 +++++++++++++++++++++++ codegraph-kernel/src/tsjs/mod.rs | 36 ++ src/extraction/languages/typescript.ts | 41 ++ src/extraction/tree-sitter-types.ts | 8 + src/extraction/tree-sitter.ts | 6 + src/index.ts | 10 +- src/resolution/ambient-modules.ts | 663 ++++++++++++++++++++++ src/resolution/import-resolver.ts | 41 +- src/resolution/index.ts | 7 + src/resolution/module-importers.ts | 51 ++ src/resolution/name-matcher.ts | 17 +- 12 files changed, 1543 insertions(+), 14 deletions(-) create mode 100644 __tests__/ambient-module-imports.test.ts create mode 100644 src/resolution/ambient-modules.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f9ba642b..46fac1efe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -246,6 +246,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Rust, a `for` loop's variable now has the type of an item of what the loop goes over, like `for item in &self.items` over a `Vec`, `for (key, value) in &map` over a `HashMap`, or `for sc in cmd.get_subcommands()` over the `impl Iterator` a method returns, and each name in a tuple pattern like `let (tx, rx) = mpsc::channel(8);` has the type of its part of the tuple, so calls on them link to that type's methods. Before, such calls were linked by name: tokio's `tx.send(…)` calls on channel senders went to an internal type's `send`. Re-index Rust projects after upgrading. - In C++, every name a declaration lists now has the declaration's type, like `strings_found` in google/breakpad's `ByteBuffer symbols_found, strings_found;` or `data_` in a class's `Table *index_, *data_;`, so calls on it link to that class's methods. Before, only the first name had its type read, and a call on another name linked to nothing or to the class of an earlier variable with the same name. - In C++, a call on a parameter, a local lambda or functor, a lambda's parameter or a range-`for` variable no longer links to a function or method elsewhere that only shares its name, like protocolbuffers/protobuf's code generators calling `format(…)` on their `Formatter& format` parameter, which linked to the conformance suite's `TestResult::format`, or facebook/rocksdb's tests calling a local `auto key = [](int i) {…};`. C++ finds the local first, so that is what the call calls. +- In TypeScript and JavaScript, an import of a package your project declares itself with `declare module '…'` in a `.d.ts` file, like a `typings/mammoth/index.d.ts` written for an untyped package or VS Code's own `vscode.d.ts`, now links to that declaration, so `codegraph callers`, impact and `codegraph affected` show the code that uses your typings instead of nothing or a project symbol that only shares a name. Named, default and namespace imports, `export =` and renaming export lists are read the way TypeScript reads them, a function such a file declares without a body (`export function convertToHtml(…): Promise;`) is now indexed so calls to it have something to link to, and only an import of its package reaches it. A `declare module` that augments a package's own types, a wildcard like `declare module '*.svg'`, and copies of typings kept in test fixtures are left alone. Re-index TypeScript projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/ambient-module-imports.test.ts b/__tests__/ambient-module-imports.test.ts new file mode 100644 index 000000000..26579bd3d --- /dev/null +++ b/__tests__/ambient-module-imports.test.ts @@ -0,0 +1,676 @@ +/** + * An import of a package the repository declares itself links to that + * declaration. + * + * // typings/mammoth/index.d.ts — a script: no top-level import or export + * declare module 'mammoth' { + * export interface MammothOptions { styleMap?: string } + * export function convertToHtml(input: MammothInput, options?: MammothOptions): Promise; + * } + * + * // src/editor.ts + * import { convertToHtml, MammothOptions } from 'mammoth'; + * + * TypeScript looks a specifier up among the ambient modules before it resolves + * it to a file, so `MammothOptions` here is cord-field's own declaration. The + * index never linked it: the import resolver follows paths and aliases only, + * and the out-of-repo guard declines every name a file binds from a package + * its package.json declares. `convertToHtml` had nothing to link to anyway: a + * bodiless function in an ambient context (`function_signature`) produced no + * node, though it is the whole declaration there is. + * + * Now the import resolver finds the block that declares the specifier and + * binds each name to the declaration in that block: named imports, an export + * clause's names, the default export (`export default class`, `export =`), + * a namespace import's members, and a namespace or default binding to the + * declaring file. Only a script declares a module; `declare module` in a + * module file augments the package's own, and a wildcard pattern or a + * shorthand `declare module 'x';` declares nothing to bind. Ambient function + * signatures are extracted by both the native kernel (when built) and the wasm + * extractor, which must agree. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel'; +import { scanAmbientModules } from '../src/resolution/ambient-modules'; +import type { ExtractionResult, Language } from '../src/types'; + +const KERNEL_PATH = path.join( + __dirname, + '..', + 'codegraph-kernel', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'codegraph-kernel.node' +); +const kernelAvailable = fs.existsSync(KERNEL_PATH) || process.env.CODEGRAPH_KERNEL_EXPECT === '1'; + +describe('scanAmbientModules', () => { + it('finds each block of a script, by position, and skips shorthands', () => { + const scan = scanAmbientModules( + [ + '/// ', + "declare module 'mammoth' {", + " import { ImgHTMLAttributes } from 'react';", + ' export interface Options { a: string }', + '}', + "declare module '*.svg' {", + ' const src: string;', + ' export default src;', + '}', + "declare module 'shorthand';", + 'declare module "@freiraum/msgreader" { export default class MsgReader {} }', + 'interface Thenable { then(): void }', + ].join('\n') + ); + expect(scan.script).toBe(true); + expect(scan.blocks.map((b) => [b.specifier, b.startLine, b.startColumn, b.endLine, b.endColumn, b.defaultName, b.pattern])).toEqual([ + ['mammoth', 2, 0, 5, 0, null, false], + // A pattern is kept, marked, to know its members; no import binds to it. + ['*.svg', 6, 0, 9, 0, 'src', true], + ['@freiraum/msgreader', 11, 0, 11, 73, 'MsgReader', false], + ]); + }); + + it('reads a file with a top-level import or export as a module, and every other form as a script', () => { + const block = "declare module 'x' { export const a: number; }\n"; + expect(scanAmbientModules(`import 'x';\n${block}`).script).toBe(false); + expect(scanAmbientModules(`${block}export {};\n`).script).toBe(false); + expect(scanAmbientModules(`export as namespace X;\n${block}`).script).toBe(false); + expect(scanAmbientModules(`import fs = require('fs');\n${block}`).script).toBe(false); + // A type that names a module, and an alias of a namespace, import nothing. + expect(scanAmbientModules(`type T = import('y').Z;\n${block}`).script).toBe(true); + expect(scanAmbientModules(`import Q = NS.Inner;\n${block}`).script).toBe(true); + // An import inside a block, a comment or a string is the block's or no one's. + expect(scanAmbientModules(`declare module 'y' { import { a } from 'x'; }\n// import x from 'z'\nconst s = "export";\n`).script).toBe(true); + }); + + it('reads what a block exports as its default, and the names an export clause gives', () => { + const scan = scanAmbientModules( + [ + "declare module 'a' { const main: Main; export = main; }", + "declare module 'b' { export default abstract class Base {} }", + "declare module 'c' { export default function convert(): void; }", + "declare module 'd' { const tool: Tool; export default tool; }", + "declare module 'e' { interface Hidden {} function helper(): void; export { Hidden as Shown, helper, helper as default }; }", + ].join('\n') + ); + expect(scan.blocks.map((b) => [b.specifier, b.defaultName, Object.fromEntries(b.exportAliases)])).toEqual([ + ['a', 'main', {}], + ['b', 'Base', {}], + ['c', 'convert', {}], + ['d', 'tool', {}], + ['e', 'helper', { Shown: 'Hidden', helper: 'helper' }], + ]); + }); + + it('is not fooled by braces in strings, comments and template literal types', () => { + const scan = scanAmbientModules( + [ + "declare module 'tricky' {", + " export type Open = '{';", + ' /* } a closing brace in a comment */', + ' // and one } more', + ' export type Path = `${string}/{id}/${number}`;', + ' export interface Last { x: `}` }', + '}', + "declare module 'after' { export const y: 1; }", + ].join('\r\n') + ); + expect(scan.blocks.map((b) => [b.specifier, b.startLine, b.endLine])).toEqual([ + ['tricky', 1, 7], + ['after', 8, 8], + ]); + }); +}); + +/** + * Bodiless functions in a module file: ambient ones, and an overload before its + * implementation. Its `declare module` body augments the package's own module, + * and a namespace's function is only reached through the namespace. + */ +const AMBIENT_SOURCE = `declare function acquireApi(): Api; +declare module 'mammoth' { + export function convertToHtml(input: Input): Promise; + export declare function extractRawText(input: Input): Promise; + function internal(): void; +} +declare global { + function structuredLog(message: string): void; +} +declare namespace Plugins { + function register(name: string): void; +} +export function overloaded(a: number): number; +export function overloaded(a: string): string; +export function overloaded(a: unknown): unknown { return a; } +`; + +/** A script declaration file: a module declared whole, and a global. */ +const DECLARATION_SCRIPT_SOURCE = `declare module 'mammoth' { + export function convertToHtml(input: Input): Promise; + export declare function extractRawText(input: Input): Promise; + function internal(): void; + namespace sub { + function hidden(): void; + } +} +declare function acquireVsCodeApi(): unknown; +`; + +/** A module-style declaration file, as tsc emits one, or written by hand. */ +const DECLARATION_FILE_SOURCE = `export declare function emitted(value: string): void; +export function handWritten(value: number): void; +export function handWritten(value: string): void; +export declare namespace clipboards { + export function write(text: string): Promise; +} +`; + +const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const; + +describe('a bodiless function in an ambient context is a function', () => { + let savedEnv: Record = {}; + + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['typescript', 'tsx']); + }); + + beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + resetKernelForTests(); + }); + + afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + resetKernelForTests(); + }); + + function extract(backend: 'kernel' | 'wasm', file: string, source: string, language: Language): ExtractionResult { + if (backend === 'wasm') { + process.env.CODEGRAPH_KERNEL = '0'; + return extractFromSource(file, source, language); + } + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + const result = tryKernelExtract(file, source, language); + expect(result, `kernel extraction of ${file}`).not.toBeNull(); + return result!; + } + + /** `name@line exported` for every function node. */ + const functions = (result: ExtractionResult): string[] => + result.nodes.filter((n) => n.kind === 'function').map((n) => `${n.name}@${n.startLine} ${n.isExported ? 'exported' : 'local'}`); + + const backends = kernelAvailable ? (['kernel', 'wasm'] as const) : (['wasm'] as const); + + for (const crlf of [false, true]) { + const eol = (s: string) => (crlf ? s.replace(/\n/g, '\r\n') : s); + const label = crlf ? ' (CRLF)' : ''; + + it.each(backends)(`in a module file, under declare and outside a namespace or an augmentation: %s${label}`, (backend) => { + expect(functions(extract(backend, 'src/env.ts', eol(AMBIENT_SOURCE), 'typescript'))).toEqual([ + 'acquireApi@1 local', + 'structuredLog@8 exported', + // The implementation is the function; the overloads before it are not. + 'overloaded@15 exported', + ]); + }); + + it.each(backends)(`in a declaration file's declare module body, and its globals: %s${label}`, (backend) => { + expect(functions(extract(backend, 'typings/mammoth.d.ts', eol(DECLARATION_SCRIPT_SOURCE), 'typescript'))).toEqual([ + 'convertToHtml@2 exported', + 'extractRawText@3 exported', + 'internal@4 exported', + 'acquireVsCodeApi@9 local', + ]); + }); + + it.each(backends)(`anywhere at a declaration file's top level: %s${label}`, (backend) => { + expect(functions(extract(backend, 'types/index.d.ts', eol(DECLARATION_FILE_SOURCE), 'typescript'))).toEqual([ + 'emitted@1 exported', + 'handWritten@2 exported', + 'handWritten@3 exported', + ]); + }); + + it.each(backends)(`in a tsx file too: %s${label}`, (backend) => { + const source = eol('declare function useIcon(name: string): JSX.Element;\nexport const App = () =>
{useIcon("x")}
;\n'); + expect(functions(extract(backend, 'src/main.tsx', source, 'tsx'))).toContain('useIcon@1 local'); + }); + } + + it.runIf(kernelAvailable)('the kernel and the wasm extractor agree on every node', () => { + for (const [file, source] of [ + ['src/env.ts', AMBIENT_SOURCE], + ['typings/mammoth.d.ts', DECLARATION_SCRIPT_SOURCE], + ['types/index.d.ts', DECLARATION_FILE_SOURCE], + ] as const) { + const canon = (r: ExtractionResult) => + r.nodes.map(({ updatedAt: _u, ...n }) => JSON.stringify(n, Object.keys(n).sort())).sort(); + expect(canon(extract('kernel', file, source, 'typescript'))).toEqual( + canon(extract('wasm', file, source, 'typescript')) + ); + } + }); +}); + +const roots: string[] = []; +afterAll(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); +}); + +type Files = Record; + +function write(root: string, files: Files): void { + 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); + } +} + +function tempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ambient-modules-')); + roots.push(root); + return root; +} + +/** ` :` for every edge leaving `file`'s nodes. */ +function edgesFrom(cg: CodeGraph, file: string): string[] { + const ids = cg.getNodesInFile(file).map((n) => n.id); + return cg + .getOutgoingEdgesFrom(ids) + .filter((e) => e.kind !== 'contains') + .map((e) => { + const target = cg.getNode(e.target); + return `${e.kind} ${target?.filePath}:${target?.qualifiedName}`; + }) + .sort(); +} + +const PACKAGE_JSON = JSON.stringify({ + name: 'editor', + dependencies: { + mammoth: '^1.6.0', + '@freiraum/msgreader': '^1.0.0', + 'stacktrace-gps': '^3.0.0', + 'listed-lib': '^1.0.0', + 'web-vitals': '^1.1.0', + 'styled-components': '^6.1.0', + 'shorthand-lib': '^1.0.0', + }, +}); + +/** The repository's declarations of the packages it uses, cord-field's and happa's shapes. */ +const DECLARATIONS: Files = { + 'typings/mammoth/index.d.ts': `declare module 'mammoth' { + export interface MammothOptions { styleMap?: string } + export interface MammothResult { value: string } + export type MammothInput = { path: string }; + export function convertToHtml(input: MammothInput, options?: MammothOptions): Promise; + export class Converter { run(): void; } +} +`, + 'typings/msgreader.d.ts': `declare module '@freiraum/msgreader' { + // eslint-disable-next-line import/no-default-export + export default class MsgReader { + constructor(buffer: ArrayBuffer); + getFileData(): OutlookMsgData; + } + export interface OutlookMsgData { body: string } +} +`, + 'typings/legacy.d.ts': `declare module 'stacktrace-gps' { + export interface IStackTraceGPS { pinpoint(frame: string): string } + const StackTrace: IStackTraceGPS; + export = StackTrace; +} +declare module 'listed-lib' { + interface Unlisted { a: string } + export interface Listed { b: string } + function helper(): void; + export { Unlisted as Renamed, helper }; +} +declare module '*.svg' { + const src: string; + export default src; +} +declare module 'shorthand-lib'; +`, + 'src/@types/web-vitals.d.ts': `declare module 'web-vitals' { + export interface Metric { name: string; value: number } + export declare function getCLS(onReport: (metric: Metric) => void, all?: boolean): void; + export declare function getCLS(onReport: (metric: Metric) => void): void; + export declare function getLCP(onReport: (metric: Metric) => void): void; +} +`, + // A module file: this augments the package's own module. + 'src/@types/styled-components.d.ts': `import 'styled-components'; +declare module 'styled-components' { + export interface DefaultTheme { color: string } +} +`, +}; + +/** Project symbols named like the packages' own: a match by name would take them. */ +const NAMESAKES: Files = { + 'src/legacy/options.ts': `export interface MammothOptions { decoy: true } +export function convertToHtml(): string { return ''; } +export class Converter { run(): void {} } +export interface DefaultTheme { decoy: true } +export function getLCP(): void {} +`, +}; + +const APP: Files = { + 'src/editor.ts': `import { convertToHtml, MammothOptions, Converter } from 'mammoth'; +import MsgReader, { OutlookMsgData } from '@freiraum/msgreader'; +import StackTraceGPS from 'stacktrace-gps'; +import { Renamed, Listed, Unlisted, helper } from 'listed-lib'; +import { getCLS, getLCP as lcp, Metric } from 'web-vitals'; +import * as vitals from 'web-vitals'; +import { DefaultTheme, css } from 'styled-components'; +import { anything } from 'shorthand-lib'; + +export class Reader extends MsgReader { + data(): OutlookMsgData { return this.getFileData(); } +} +export interface Row extends Listed, Renamed {} +export function render(options: MammothOptions, metric: Metric, theme: DefaultTheme): void { + convertToHtml({ path: 'doc.docx' }, options); + const converter = new Converter(); + converter.run(); + new MsgReader(new ArrayBuffer(1)).getFileData(); + StackTraceGPS.pinpoint('frame'); + helper(); + getCLS(() => {}); + lcp(() => {}); + vitals.getLCP(() => {}); + css\`color: \${theme.color}\`; + console.log(anything); + const unlisted: Unlisted = { a: '' }; +} +`, +}; + +describe('an import of a package the repository declares itself', () => { + it('links each name to its declaration in the block that declares the package', async () => { + const root = tempRoot(); + write(root, { 'package.json': PACKAGE_JSON, ...DECLARATIONS, ...NAMESAKES, ...APP }); + const cg = await CodeGraph.init(root, { index: true }); + try { + const edges = edgesFrom(cg, 'src/editor.ts'); + expect(edges).toEqual(expect.arrayContaining([ + // Named imports, a call, a type, `new`, and a method through the class. + 'imports typings/mammoth/index.d.ts:convertToHtml', + 'imports typings/mammoth/index.d.ts:MammothOptions', + 'imports typings/mammoth/index.d.ts:Converter', + 'calls typings/mammoth/index.d.ts:convertToHtml', + 'references typings/mammoth/index.d.ts:MammothOptions', + 'instantiates typings/mammoth/index.d.ts:Converter', + 'calls typings/mammoth/index.d.ts:Converter::run', + // A default import's binding is the declaring file; the class is its default export. + 'imports typings/msgreader.d.ts:typings/msgreader.d.ts', + 'imports typings/msgreader.d.ts:OutlookMsgData', + 'extends typings/msgreader.d.ts:MsgReader', + 'instantiates typings/msgreader.d.ts:MsgReader', + // `export =`: the module is that value, whose type has the method. + 'calls typings/legacy.d.ts:IStackTraceGPS::pinpoint', + // An export clause renames and exports. + 'imports typings/legacy.d.ts:Unlisted', + 'imports typings/legacy.d.ts:helper', + 'calls typings/legacy.d.ts:helper', + // Overloads, an aliased import, a namespace import's member. + 'calls src/@types/web-vitals.d.ts:getCLS', + 'calls src/@types/web-vitals.d.ts:getLCP', + 'imports src/@types/web-vitals.d.ts:src/@types/web-vitals.d.ts', + ])); + // Nothing the file imports from a package lands on a project namesake. + expect(edges.filter((e) => e.includes('src/legacy/options.ts'))).toEqual([]); + // An augmentation, a wildcard pattern and a shorthand declare nothing a + // name binds to, and a name the block doesn't export binds to nothing. + expect(edges.filter((e) => /DefaultTheme|:css|anything|:src$/.test(e))).toEqual([]); + expect(edges).not.toContain('references typings/legacy.d.ts:Unlisted'); + // The class's own declaration in the file. + expect(edges).toContain('calls typings/msgreader.d.ts:MsgReader::getFileData'); + } finally { + cg.close(); + } + }, 60_000); + + it('takes the declaration nearest the importing file when two apps declare the package', async () => { + const root = tempRoot(); + write(root, { + 'package.json': JSON.stringify({ name: 'mono', dependencies: { 'shared-lib': '^1.0.0' } }), + 'apps/a/typings/shared.d.ts': "declare module 'shared-lib' {\n export function make(): string;\n}\n", + 'apps/b/typings/shared.d.ts': "declare module 'shared-lib' {\n export function make(): number;\n}\n", + 'apps/a/src/use.ts': "import { make } from 'shared-lib';\nexport const a = make();\n", + 'apps/b/src/use.ts': "import { make } from 'shared-lib';\nexport const b = make();\n", + }); + const cg = await CodeGraph.init(root, { index: true }); + try { + expect(edgesFrom(cg, 'apps/a/src/use.ts')).toEqual([ + 'calls apps/a/typings/shared.d.ts:make', + // The import statement's module, as a relative import's links its file. + 'imports apps/a/typings/shared.d.ts:apps/a/typings/shared.d.ts', + 'imports apps/a/typings/shared.d.ts:make', + ]); + expect(edgesFrom(cg, 'apps/b/src/use.ts')).toEqual([ + 'calls apps/b/typings/shared.d.ts:make', + 'imports apps/b/typings/shared.d.ts:apps/b/typings/shared.d.ts', + 'imports apps/b/typings/shared.d.ts:make', + ]); + } finally { + cg.close(); + } + }, 60_000); + + it('links through a catch-all path alias that maps the package to its typings folder', async () => { + // cord-field: `"*": ["./typings/*"]` maps `mammoth` to the folder + // `typings/mammoth`, which holds the declaration but is no module. + const root = tempRoot(); + write(root, { + 'package.json': PACKAGE_JSON, + 'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '*': ['./typings/*'] } } }), + 'typings/mammoth/index.d.ts': DECLARATIONS['typings/mammoth/index.d.ts']!, + 'src/use.ts': "import { convertToHtml } from 'mammoth';\nexport const html = convertToHtml({ path: 'a' });\n", + }); + const cg = await CodeGraph.init(root, { index: true }); + try { + expect(edgesFrom(cg, 'src/use.ts')).toEqual([ + 'calls typings/mammoth/index.d.ts:convertToHtml', + 'imports typings/mammoth/index.d.ts:convertToHtml', + 'imports typings/mammoth/index.d.ts:typings/mammoth/index.d.ts', + ]); + } finally { + cg.close(); + } + }, 60_000); + it('binds a module function only through an import of that module, never by its bare name', async () => { + // excalidraw's `packages/excalidraw/global.d.ts` declares untyped packages' + // functions; `encode` there is png-chunks-encode's, and only an import of + // that package calls it. + const root = tempRoot(); + write(root, { + 'package.json': JSON.stringify({ name: 'draw', dependencies: { 'png-chunks-encode': '^1.0.0' } }), + 'src/global.d.ts': "declare module 'png-chunks-encode' {\n function encode(chunks: unknown[]): Uint8Array;\n export default encode;\n}\n", + 'src/export.ts': "import encodePng from 'png-chunks-encode';\nexport const png = (chunks: unknown[]) => encodePng(chunks);\n", + 'src/text.ts': "export function pack(text: string) { return encode(text); }\n", + }); + const cg = await CodeGraph.init(root, { index: true }); + try { + expect(edgesFrom(cg, 'src/export.ts')).toContain('calls src/global.d.ts:encode'); + expect(edgesFrom(cg, 'src/text.ts')).toEqual([]); + } finally { + cg.close(); + } + }, 60_000); + + it('never takes a test fixture’s copy of the declaration for the program’s', async () => { + // copilot keeps fixture copies of vscode's proposed API typings, which its + // tsconfig leaves out of the build. + const root = tempRoot(); + write(root, { + 'package.json': JSON.stringify({ name: 'mono', dependencies: { 'shared-lib': '^1.0.0' } }), + 'typings/shared.d.ts': "declare module 'shared-lib' {\n export function make(): string;\n}\n", + 'packages/app/test/fixtures/shared.d.ts': "declare module 'shared-lib' {\n export function make(): number;\n}\n", + 'packages/app/src/use.ts': "import { make } from 'shared-lib';\nexport const a = make();\n", + }); + const cg = await CodeGraph.init(root, { index: true }); + try { + expect(edgesFrom(cg, 'packages/app/src/use.ts')).toEqual([ + 'calls typings/shared.d.ts:make', + 'imports typings/shared.d.ts:make', + 'imports typings/shared.d.ts:typings/shared.d.ts', + ]); + } finally { + cg.close(); + } + }, 60_000); + + it('takes the declared module before a file a path alias maps the specifier to', async () => { + // copilot maps `vscode` to a runtime shim for its test runner; the type + // checker takes the ambient module first, so `TextLine` is the API's. + const root = tempRoot(); + write(root, { + 'package.json': JSON.stringify({ name: 'ext' }), + 'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { vscode: ['./src/shims/vscodeTypesShim.ts'] } } }), + 'src/shims/vscodeTypesShim.ts': 'export class Position { constructor(readonly line: number) {} }\n', + 'typings/vscode.d.ts': "declare module 'vscode' {\n export class Position { constructor(line: number); }\n export interface TextLine { text: string }\n}\n", + 'src/lines.ts': "import { TextLine, Position } from 'vscode';\nexport function first(line: TextLine): Position { return new Position(line.text.length); }\n", + }); + const cg = await CodeGraph.init(root, { index: true }); + try { + const edges = edgesFrom(cg, 'src/lines.ts'); + expect(edges).toEqual(expect.arrayContaining([ + 'imports typings/vscode.d.ts:TextLine', + 'references typings/vscode.d.ts:TextLine', + 'imports typings/vscode.d.ts:Position', + 'instantiates typings/vscode.d.ts:Position', + ])); + expect(edges.filter((e) => e.includes('vscodeTypesShim'))).toEqual([]); + } finally { + cg.close(); + } + }, 60_000); + + it('finds a member wherever the files declaring the module merge it', async () => { + // vscode's proposed API files add members to `vscode.d.ts`'s interfaces. + const root = tempRoot(); + write(root, { + 'package.json': JSON.stringify({ name: 'host' }), + 'typings/vscode.d.ts': "declare module 'vscode' {\n export interface InlineProvider {\n provide(): void;\n }\n}\n", + 'typings/vscode.proposed.inline.d.ts': "declare module 'vscode' {\n export interface InlineProvider {\n setCurrentModelId?(id: string): void;\n }\n}\n", + // copilot's shape: the type imported by name, held in a field. + 'src/stream.ts': "import type { InlineProvider } from 'vscode';\nexport class Wrapper {\n private readonly _wrapped: InlineProvider;\n constructor(provider: InlineProvider) { this._wrapped = provider; }\n run(): void { this._wrapped.setCurrentModelId?.('w'); }\n}\n", + 'src/host.ts': "import type * as vscode from 'vscode';\nexport class Host {\n constructor(private readonly _provider: vscode.InlineProvider) {}\n run(): void {\n this._provider.provide();\n this._provider.setCurrentModelId?.('m');\n }\n}\n", + }); + const cg = await CodeGraph.init(root, { index: true }); + try { + expect(edgesFrom(cg, 'src/host.ts')).toEqual(expect.arrayContaining([ + 'calls typings/vscode.d.ts:InlineProvider::provide', + 'calls typings/vscode.proposed.inline.d.ts:InlineProvider::setCurrentModelId', + ])); + expect(edgesFrom(cg, 'src/stream.ts')).toContain('calls typings/vscode.proposed.inline.d.ts:InlineProvider::setCurrentModelId'); + } finally { + cg.close(); + } + }, 60_000); +}); + +/** Every edge, by its ends' natural keys, with where it is written and how it resolved. */ +function edgesOf(cg: CodeGraph): string[] { + const keys = new Map( + cg.getFiles() + .flatMap((file) => cg.getNodesInFile(file.path)) + .map((n) => [n.id, `${n.kind} ${n.filePath}:${n.qualifiedName}:${n.startLine}`] as const) + ); + return cg.getOutgoingEdgesFrom([...keys.keys()]) + .map((e) => `${e.kind} ${keys.get(e.source)} -> ${keys.get(e.target) ?? e.target} @${e.line}:${e.column} ${JSON.stringify(e.metadata ?? {})}`) + .sort(); +} + +async function fullIndexOf(files: Files): Promise { + const root = tempRoot(); + write(root, files); + const cg = await CodeGraph.init(root, { index: true }); + try { + return edgesOf(cg); + } finally { + cg.close(); + } +} + +describe('sync links an import of a package once the repository declares it', () => { + /** Bindings whose names the declaration never carries: aliased, default, namespace. */ + const importers: Files = { + 'package.json': PACKAGE_JSON, + 'src/app.ts': `import { convertToHtml as toHtml, MammothOptions } from 'mammoth'; +import Reader from '@freiraum/msgreader'; +import * as vitals from 'web-vitals'; +export function run(options: MammothOptions): void { + toHtml({ path: 'a' }, options); + new Reader(new ArrayBuffer(1)); + vitals.getLCP(() => {}); +} +`, + }; + const declarations: Files = { + 'typings/mammoth/index.d.ts': DECLARATIONS['typings/mammoth/index.d.ts']!, + 'typings/msgreader.d.ts': DECLARATIONS['typings/msgreader.d.ts']!, + 'src/@types/web-vitals.d.ts': DECLARATIONS['src/@types/web-vitals.d.ts']!, + }; + + it('when the declaration file is added after its importers', async () => { + const root = tempRoot(); + write(root, importers); + const cg = await CodeGraph.init(root, { index: true }); + try { + expect(edgesFrom(cg, 'src/app.ts').filter((e) => e.includes('.d.ts'))).toEqual([]); + write(root, declarations); + const result = await cg.sync(); + expect(result.filesAdded).toBe(Object.keys(declarations).length); + expect(result.filesModified).toBe(0); + expect(edgesFrom(cg, 'src/app.ts')).toEqual(expect.arrayContaining([ + 'calls typings/mammoth/index.d.ts:convertToHtml', + 'imports typings/mammoth/index.d.ts:convertToHtml', + 'instantiates typings/msgreader.d.ts:MsgReader', + 'imports typings/msgreader.d.ts:typings/msgreader.d.ts', + 'calls src/@types/web-vitals.d.ts:getLCP', + 'imports src/@types/web-vitals.d.ts:src/@types/web-vitals.d.ts', + ])); + expect(edgesOf(cg)).toEqual(await fullIndexOf({ ...importers, ...declarations })); + } finally { + cg.close(); + } + }, 120_000); + + it('when an edit gives the declaration what its importers import', async () => { + const root = tempRoot(); + write(root, importers); + write(root, Object.fromEntries(Object.keys(declarations).map((file) => [file, '// declarations to come\n']))); + const cg = await CodeGraph.init(root, { index: true }); + try { + write(root, declarations); + const result = await cg.sync(); + expect(result.filesModified).toBe(Object.keys(declarations).length); + expect(edgesFrom(cg, 'src/app.ts')).toEqual(expect.arrayContaining([ + 'calls typings/mammoth/index.d.ts:convertToHtml', + 'instantiates typings/msgreader.d.ts:MsgReader', + 'imports src/@types/web-vitals.d.ts:src/@types/web-vitals.d.ts', + ])); + expect(edgesOf(cg)).toEqual(await fullIndexOf({ ...importers, ...declarations })); + } finally { + cg.close(); + } + }, 120_000); +}); diff --git a/codegraph-kernel/src/tsjs/mod.rs b/codegraph-kernel/src/tsjs/mod.rs index b00db3e43..2471dc0bb 100644 --- a/codegraph-kernel/src/tsjs/mod.rs +++ b/codegraph-kernel/src/tsjs/mod.rs @@ -681,6 +681,11 @@ impl<'t> Walker<'t> { // (the isInsideClassLike + methodTypes overlap is Python/Ruby-only) self.extract_function(node, None); skip_children = true; + } else if kind == "function_signature" && self.variant.is_ts() && is_ambient_function_signature(node, self.file_path) { + // A bodiless function that no implementation follows: a + // `declare function`, a function in a `declare module` body or a `.d.ts`. + self.extract_function(node, None); + skip_children = true; } else if is_class_type(self.variant, kind) { self.extract_class(node); skip_children = true; @@ -1162,6 +1167,37 @@ fn is_ambient_export(node: Node) -> bool { false } +/// isAmbientFunctionSignature (languages/typescript.ts): a bodiless +/// `function_signature` that is a function's declaration, not an overload of +/// an implementation that follows it — a `declare function`, one in a +/// `declare global` body, a declaration file's (`.d.ts`, `.d.mts`, `.d.cts`) +/// own, and one a declaration file's `declare module 'x'` body declares. +/// Never a namespace's (`declare namespace`, `module Foo {}`), nor one in an +/// ordinary module file's `declare module` body, an augmentation. +fn is_ambient_function_signature(node: Node, file_path: &str) -> bool { + if node.kind() != "function_signature" { + return false; + } + let lower = file_path.to_ascii_lowercase(); + let declaration_file = lower.ends_with(".d.ts") || lower.ends_with(".d.mts") || lower.ends_with(".d.cts"); + let mut ambient = false; + let mut cur = node.parent(); + while let Some(p) = cur { + match p.kind() { + "internal_module" => return false, + // `declare module 'x'`; `module Foo {}` is a namespace. + "module" => { + return declaration_file + && p.child_by_field_name("name").is_some_and(|n| n.kind() == "string"); + } + "ambient_declaration" => ambient = true, + _ => {} + } + cur = p.parent(); + } + ambient || declaration_file +} + /// hasExportDeclaration: a statement that exports by name or by assignment /// (`export {…}`, `export =`, `export default x`), not by declaring. fn has_export_declaration(block: Node) -> bool { diff --git a/src/extraction/languages/typescript.ts b/src/extraction/languages/typescript.ts index 7f280c5f4..eae4cc8df 100644 --- a/src/extraction/languages/typescript.ts +++ b/src/extraction/languages/typescript.ts @@ -94,9 +94,50 @@ function hasExportDeclaration(block: SyntaxNode): boolean { return false; } +/** A TypeScript declaration file: `.d.ts`, `.d.mts`, `.d.cts`. */ +const DECLARATION_FILE = /\.d\.[cm]?ts$/i; + +/** + * Whether a bodiless `function_signature` is a function's declaration rather + * than an overload of an implementation that follows it, and one a name or + * an import reaches. + * + * TypeScript writes a function without a body in two places. Before the + * implementation it overloads (`function f(a: number): void;`, then `function + * f(a) {…}`), and there the implementation is the function's node. In an + * ambient context nothing follows, and the signature is all the declaration + * there is: a `declare function`, a function in a `declare global` body, a + * declaration file's own functions, which are ambient with or without + * `declare`, and a function a declaration file's `declare module 'x'` body + * declares, which an import of `x` reaches (see resolution/ambient-modules). + * Without a node, `import { convertToHtml } from 'mammoth'` had nothing to + * bind to. Two are left out: a namespace's function is only ever reached + * through the namespace (`semver.compare`, `vscode.window.showMessage`), which + * the index doesn't model, so on its own it is only a bare name for unrelated + * calls to land on; and a `declare module` body in an ordinary module file + * augments the package's own module, whose functions the import means. + * Mirrored in the kernel (tsjs/mod.rs `is_ambient_function_signature`). + */ +export function isAmbientFunctionSignature(node: SyntaxNode, filePath: string): boolean { + if (node.type !== 'function_signature') return false; + const declarationFile = DECLARATION_FILE.test(filePath); + let ambient = false; + for (let current = node.parent; current; current = current.parent) { + if (current.type === 'internal_module') return false; + if (current.type === 'module') { + // `declare module 'x'`; `module Foo {}` is a namespace. + if (getChildByField(current, 'name')?.type !== 'string') return false; + return declarationFile; + } + if (current.type === 'ambient_declaration') ambient = true; + } + return ambient || declarationFile; +} + export const typescriptExtractor: LanguageExtractor = { preParse: blankFlowSyntax, functionTypes: ['function_declaration', 'generator_function_declaration', 'arrow_function', 'function_expression', 'generator_function'], + isAmbientFunction: isAmbientFunctionSignature, classTypes: ['class_declaration', 'abstract_class_declaration'], // `method_signature` is the interface/type-literal form of a method; without it // an interface's members never enter the graph, so a `.d.ts` platform API has diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index 691ac0f1f..6e5d40e51 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -101,6 +101,14 @@ export interface LanguageExtractor { /** Node types that represent functions */ functionTypes: string[]; + /** + * A bodiless function that is the function's whole declaration, not one + * overload of an implementation that follows it: TypeScript's + * `function_signature` in an ambient context (`declare function f(): void;`, + * a declaration file's own or its `declare module` body's), never in a + * namespace. Extracted as a function, like a `functionTypes` node. + */ + isAmbientFunction?: (node: SyntaxNode, filePath: string) => boolean; /** Node types that represent classes */ classTypes: string[]; /** Node types that represent methods */ diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index e3c7b0683..f2767a3d1 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1403,6 +1403,12 @@ export class TreeSitterExtractor { skipChildren = true; // extractFunction visits children via visitFunctionBody } } + // A bodiless function that no implementation follows: a TypeScript + // `declare function`, a `.d.ts` file's own or its `declare module` body's. + else if (nodeType === 'function_signature' && this.extractor.isAmbientFunction?.(node, this.filePath)) { + this.extractFunction(node); + skipChildren = true; + } // Check for class declarations else if (this.extractor.classTypes.includes(nodeType)) { // Some languages reuse class_declaration for structs/enums (e.g. Swift) diff --git a/src/index.ts b/src/index.ts index e17a36571..73b30f2a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,7 +54,7 @@ import { ResolutionResult, } from './resolution'; import { hasSynthesisPattern, isJsxChildCandidate } from './resolution/callback-synthesizer'; -import { createImportReader, reopenReferencesThroughAddedFiles, reopenReferencesThroughBarrels } from './resolution/module-importers'; +import { createImportReader, reopenReferencesThroughAddedFiles, reopenReferencesThroughBarrels, reopenReferencesThroughDeclaredPackages } from './resolution/module-importers'; import { GraphTraverser, GraphQueryManager } from './graph'; import { findNamedCopybooks, type NamedCopybook } from './graph/cobol-copybooks'; import { ContextBuilder, createContextBuilder } from './context'; @@ -1177,6 +1177,14 @@ export class CodeGraph { this.queries, importReader, [...seeds].map(([path, language]) => ({ path, language })), edgesBeforeResolution ); if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-barrel-modules: ${Date.now() - tBarrels}ms (${throughBarrels} refs re-opened)`); + + // A declaration file that declares a package itself (`declare + // module 'mammoth' { … }`): the files importing the package bound + // nothing through it while it was missing or lacked a name. + const declared = reopenReferencesThroughDeclaredPackages( + this.queries, importReader, this.projectRoot, result.changedFilePaths, edgesBeforeResolution + ); + if (process.env.CODEGRAPH_SYNTH_TIMINGS && declared > 0) console.error(`[phase-timing] sync-declared-packages: ${declared} refs re-opened`); } else { // No git info — use batched resolution to avoid OOM const unresolvedCount = this.queries.getUnresolvedReferencesCount(); diff --git a/src/resolution/ambient-modules.ts b/src/resolution/ambient-modules.ts new file mode 100644 index 000000000..bce1fcf9c --- /dev/null +++ b/src/resolution/ambient-modules.ts @@ -0,0 +1,663 @@ +/** + * The modules a repository declares itself, with `declare module 'name' { … }`. + * + * In a script declaration file (a `.d.ts` with no top-level `import` or + * `export`), the statement declares the module outright: cord-field's + * `typings/mammoth/index.d.ts` is the `mammoth` its code imports, whatever the + * package ships. TypeScript looks a specifier up among these ambient modules + * before it resolves the specifier to a file, so `import { MammothOptions } + * from 'mammoth'` means that declaration. Nothing else maps a package name to + * a file in the repository: the import resolver follows paths and aliases, and + * the out-of-repo guard declines every name a file binds from a package its + * package.json declares. + * + * Nothing records which block a member belongs to (#2418 exports a block's + * declarations without a container node), and one file can declare several + * modules, so each block is found in the file's text and its members by their + * position inside it. A wildcard pattern (`declare module '*.svg'`) applies + * only once a specifier fails to resolve to a file, and a shorthand `declare + * module 'x';` declares nothing to bind; no import is bound to either. + */ +import type { Node } from '../types'; +import type { ResolutionContext, UnresolvedRef } from './types'; +import { isTestFile } from '../search/query-utils'; + +/** One `declare module '' { … }` statement. */ +export interface AmbientModuleBlock { + /** The module's name as written: `mammoth`, `@freiraum/msgreader`. */ + specifier: string; + /** The `declare` keyword: 1-based line, 0-based column. */ + startLine: number; + startColumn: number; + /** The closing brace. */ + endLine: number; + endColumn: number; + /** The declaration the module's default export names: `export default X`, `export default class X`, `export = X`. */ + defaultName: string | null; + /** Names an export clause gives the body's declarations (`export { Impl as Name }`): exported name → local name. */ + exportAliases: Map; + /** A wildcard pattern (`'*.svg'`), which no specifier names exactly. */ + pattern: boolean; +} + +export interface AmbientModuleScan { + /** No top-level `import` or `export`: each block declares its module. In a module file, each block augments one. */ + script: boolean; + blocks: AmbientModuleBlock[]; +} + +/** A TypeScript declaration file: `.d.ts`, `.d.mts`, `.d.cts`. */ +const DECLARATION_FILE = /\.d\.[cm]?ts$/i; + +/** Whether `file` is a TypeScript declaration file, the kind that declares a module whole. */ +export function isDeclarationFile(file: string): boolean { + return DECLARATION_FILE.test(file); +} + +/** Words that start an `export default` declaration rather than name its value. */ +const DEFAULT_DECLARATION_WORDS = new Set(['class', 'function', 'interface', 'abstract', 'async', 'enum']); + +const enum Kind { + Word, + String, + Template, + Punct, +} + +/** + * Every `declare module '' { … }` block in `source`, and whether the + * file is a script. + * + * Reads tokens, never a tree: a declaration file holds no code, so strings, + * template literals and comments are all a brace can hide in. In a module + * file, a regex literal is read the way a tokenizer guesses one, by what + * precedes its slash. A block the scan cannot place is left out. + */ +export function scanAmbientModules(source: string): AmbientModuleScan { + const blocks: AmbientModuleBlock[] = []; + let script = true; + + // The statement being read at the top level. + let declare = 0; // 1: `declare`, 2: `declare module`, 3: `declare module ''` + let declareLine = 0; + let declareColumn = 0; + let declareName = ''; + // After a top-level `import`: the tokens seen so far, to tell `import x = N.y` + // (an alias of a namespace, legal in a script) from every other form. + let importTokens: Array<{ kind: Kind; text: string }> | null = null; + + // The block being read, and the export statement being read inside it. + let open: AmbientModuleBlock | null = null; + let exportState = 0; // 1: `export`, 2: `export default`, 3: `export default class`, 4: `export =`, 5: `export {` + let clause: string[] = []; + + const settleImport = (): void => { + const t = importTokens!; + importTokens = null; + // `import(…)` and `import.meta` are expressions; `import x = N.y` aliases a namespace. + if (t[0]?.kind === Kind.Punct && (t[0].text === '(' || t[0].text === '.')) return; + if (t[0]?.kind === Kind.Word && t[1]?.text === '=' && t[2]?.kind === Kind.Word && t[2].text !== 'require') return; + script = false; + }; + + const closeClause = (): void => { + // `export { a, b as c, d as default }`, read as name, name `as` name, separated by commas. + for (let i = 0; i < clause.length; ) { + const local = clause[i]!; + if (clause[i + 1] === 'as' && clause[i + 2] !== undefined) { + const exported = clause[i + 2]!; + if (exported === 'default') open!.defaultName ??= local; + else open!.exportAliases.set(exported, local); + i += 3; + } else { + open!.exportAliases.set(local, local); + i += 1; + } + } + clause = []; + }; + + const blockToken = (kind: Kind, text: string, depth: number): void => { + if (exportState === 5) { + if (kind === Kind.Punct && text === '}' && depth === 1) { + closeClause(); + exportState = 0; + } else if (kind === Kind.Word) { + clause.push(text); + } + return; + } + if (depth !== 1) return; + switch (exportState) { + case 0: + if (kind === Kind.Word && text === 'export') exportState = 1; + return; + case 1: + if (kind === Kind.Word && text === 'default') exportState = 2; + else if (kind === Kind.Punct && text === '=') exportState = 4; + else if (kind === Kind.Punct && text === '{') exportState = 5; + else exportState = 0; + return; + case 2: + if (kind === Kind.Word && DEFAULT_DECLARATION_WORDS.has(text)) exportState = 3; + else { + if (kind === Kind.Word) open!.defaultName ??= text; + exportState = 0; + } + return; + case 3: + // `export default abstract class X`, `export default async function X`, `function* X`. + if ((kind === Kind.Word && DEFAULT_DECLARATION_WORDS.has(text)) || (kind === Kind.Punct && text === '*')) return; + if (kind === Kind.Word) open!.defaultName ??= text; + exportState = 0; + return; + case 4: + if (kind === Kind.Word) open!.defaultName ??= text; + exportState = 0; + return; + } + }; + + const token = (kind: Kind, text: string, line: number, column: number, depth: number): void => { + if (importTokens) { + importTokens.push({ kind, text }); + // Decided by the first token unless it is a name, then by whether `=` follows, then by what does. + const t = importTokens; + if (t.length >= 3 || t[0]!.kind !== Kind.Word || (t.length === 2 && t[1]!.text !== '=')) settleImport(); + } + if (open) { + if (kind === Kind.Punct && text === '}' && depth === 0) { + open.endLine = line; + open.endColumn = column; + open.pattern = open.specifier.includes('*'); + blocks.push(open); + open = null; + exportState = 0; + clause = []; + return; + } + blockToken(kind, text, depth); + return; + } + if (depth !== 0) { + declare = 0; + return; + } + if (kind === Kind.Word) { + if (text === 'import') { + importTokens = []; + declare = 0; + } else if (text === 'export') { + script = false; + declare = 0; + } else if (text === 'declare') { + declare = 1; + declareLine = line; + declareColumn = column; + } else { + declare = declare === 1 && text === 'module' ? 2 : 0; + } + return; + } + if (kind === Kind.String && declare === 2) { + declare = 3; + declareName = text; + return; + } + if (kind === Kind.Punct && text === '{' && declare === 3 && declareName) { + open = { + specifier: declareName, + startLine: declareLine, + startColumn: declareColumn, + endLine: 0, + endColumn: 0, + defaultName: null, + exportAliases: new Map(), + pattern: false, + }; + } + declare = 0; + }; + + tokenize(source, token); + if (importTokens) settleImport(); + return { script, blocks }; +} + +/** + * Feed `source`'s tokens to `visit`: words, string literals (by their + * contents), template literals (as one token, their `${…}` expressions read as + * code) and punctuation, each with the brace depth outside it — both braces of + * a block carry the depth the block sits at. + */ +function tokenize( + source: string, + visit: (kind: Kind, text: string, line: number, column: number, depth: number) => void +): void { + const n = source.length; + let i = 0; + let line = 1; + let lineStart = 0; + let depth = 0; + // The brace depth at each open `${`: the `}` that brings depth back to it ends the expression. + const templates: number[] = []; + // Whether a `/` here would start a regex: after an operator, a keyword or nothing. + let regexAllowed = true; + + const newline = (at: number): void => { + line++; + lineStart = at + 1; + }; + + // Reads a template literal's text from `i` up to its closing backtick or a `${`. + const templateText = (): void => { + while (i < n) { + const c = source.charCodeAt(i); + if (c === 92 /* \ */) { + if (source.charCodeAt(i + 1) === 10) newline(i + 1); + i += 2; + continue; + } + if (c === 96 /* ` */) { + i++; + return; + } + if (c === 36 /* $ */ && source.charCodeAt(i + 1) === 123 /* { */) { + templates.push(depth); + i += 2; + return; + } + if (c === 10) newline(i); + i++; + } + }; + + while (i < n) { + const c = source.charCodeAt(i); + if (c === 10) { + newline(i); + i++; + continue; + } + if (c === 32 || c === 9 || c === 13 || c === 0xfeff || c === 0xa0) { + i++; + continue; + } + const column = i - lineStart; + if (c === 47 /* / */) { + const next = source.charCodeAt(i + 1); + if (next === 47) { + while (i < n && source.charCodeAt(i) !== 10) i++; + continue; + } + if (next === 42 /* * */) { + i += 2; + while (i < n && !(source.charCodeAt(i) === 42 && source.charCodeAt(i + 1) === 47)) { + if (source.charCodeAt(i) === 10) newline(i); + i++; + } + i += 2; + continue; + } + if (regexAllowed) { + // A regex literal ends at an unescaped `/` outside a character class, or at the line's end. + i++; + let inClass = false; + while (i < n) { + const r = source.charCodeAt(i); + if (r === 10) break; + if (r === 92) i++; + else if (r === 91 /* [ */) inClass = true; + else if (r === 93 /* ] */) inClass = false; + else if (r === 47 && !inClass) { + i++; + break; + } + i++; + } + while (i < n && isWordChar(source.charCodeAt(i))) i++; // flags + regexAllowed = false; + visit(Kind.Template, '', line, column, depth); + continue; + } + } + if (c === 39 /* ' */ || c === 34 /* " */) { + let j = i + 1; + let text = ''; + let from = j; + while (j < n) { + const s = source.charCodeAt(j); + if (s === c || s === 10) break; + if (s === 92) { + text += source.slice(from, j); + j += 2; + from = j; + continue; + } + j++; + } + text += source.slice(from, Math.min(j, n)); + i = j < n && source.charCodeAt(j) === c ? j + 1 : j; + regexAllowed = false; + visit(Kind.String, text, line, column, depth); + continue; + } + if (c === 96 /* ` */) { + i++; + templateText(); + regexAllowed = false; + visit(Kind.Template, '', line, column, depth); + continue; + } + if (c === 123 /* { */) { + visit(Kind.Punct, '{', line, column, depth); + depth++; + i++; + regexAllowed = true; + continue; + } + if (c === 125 /* } */) { + i++; + if (templates.length > 0 && templates[templates.length - 1] === depth) { + // The end of a template literal's `${…}`: the template's text resumes. + templates.pop(); + templateText(); + regexAllowed = false; + continue; + } + depth = Math.max(0, depth - 1); + visit(Kind.Punct, '}', line, column, depth); + regexAllowed = true; + continue; + } + if (isWordStart(c)) { + let j = i + 1; + while (j < n && isWordChar(source.charCodeAt(j))) j++; + const word = source.slice(i, j); + i = j; + regexAllowed = REGEX_AFTER_WORDS.has(word); + visit(Kind.Word, word, line, column, depth); + continue; + } + if (c >= 48 && c <= 57) { + while (i < n && (isWordChar(source.charCodeAt(i)) || source.charCodeAt(i) === 46)) i++; + regexAllowed = false; + continue; + } + i++; + regexAllowed = c !== 41 /* ) */ && c !== 93 /* ] */; + visit(Kind.Punct, String.fromCharCode(c), line, column, depth); + } +} + +/** Keywords after which a `/` starts a regex literal rather than a division. */ +const REGEX_AFTER_WORDS = new Set(['return', 'typeof', 'case', 'do', 'else', 'in', 'of', 'instanceof', 'new', 'delete', 'void', 'throw', 'yield', 'await']); + +function isWordStart(c: number): boolean { + return (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 95 || c === 36 || c > 127; +} + +function isWordChar(c: number): boolean { + return isWordStart(c) || (c >= 48 && c <= 57); +} + +/** A block of the repository's, in the file that holds it. */ +export interface AmbientModule extends AmbientModuleBlock { + file: string; + /** Declared in a script: the module itself. Declared in a module file: an augmentation of the package's own. */ + script: boolean; + /** Declared in a test or a fixture (see declarationsFor). */ + test: boolean; +} + +interface Memo { + /** Every declaration file's blocks, by file and by module name (patterns left out). Built on first use. */ + scanned: { byFile: Map; bySpecifier: Map } | null; + /** Per block (`file`, line, column): its declarations, by name. */ + members: Map>; + /** See ambientModuleFunctionIds. */ + functionIds: ReadonlySet | null; + /** declarationsFor's answers, by specifier and importing file. */ + declarations: Map; + /** isMergedAmbientDeclaration's answers, by node id. */ + merged: Map; +} + +const MEMO = new WeakMap(); + +/** Drop the per-context index (see ReferenceResolver.clearCaches): files and nodes may have changed. */ +export function clearAmbientModuleMemos(context: ResolutionContext): void { + MEMO.delete(context); +} + +function memoFor(context: ResolutionContext): Memo { + let memo = MEMO.get(context); + if (!memo) MEMO.set(context, (memo = { scanned: null, members: new Map(), functionIds: null, declarations: new Map(), merged: new Map() })); + return memo; +} + +/** + * 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 + * TypeScript file for the rare one written elsewhere would cost each + * resolution pass a read of the whole project. + */ +function scanned(context: ResolutionContext): NonNullable { + const memo = memoFor(context); + if (memo.scanned) return memo.scanned; + const byFile = new Map(); + const bySpecifier = new Map(); + for (const file of [...context.getAllFiles()].sort()) { + if (!DECLARATION_FILE.test(file)) continue; + if (context.fileContains && !context.fileContains(file, 'declare')) continue; + const source = context.readFile(file); + if (!source || !/\bdeclare\s+module\s*['"]/.test(source)) continue; + const scan = scanAmbientModules(source); + const test = isTestCode(file); + const modules = scan.blocks.map((block): AmbientModule => ({ ...block, file, script: scan.script, test })); + if (modules.length === 0) continue; + byFile.set(file, modules); + for (const mod of modules) { + if (mod.pattern) continue; + const list = bySpecifier.get(mod.specifier); + if (list) list.push(mod); + else bySpecifier.set(mod.specifier, [mod]); + } + } + memo.scanned = { byFile, bySpecifier }; + return memo.scanned; +} + +/** Kinds a statement in a module body declares. */ +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: Node, 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; +} + +/** Whether `node` is a declaration at a block's own level: not a class's or an interface's member. */ +function isBlockLevel(node: Node): boolean { + return DECLARATION_KINDS.has(node.kind) && node.qualifiedName === node.name; +} + +/** The declarations at the block's own level, by name, in position order. */ +function blockMembers(mod: AmbientModule, context: ResolutionContext): Map { + const memo = memoFor(context); + const key = `${mod.file}\0${mod.startLine}\0${mod.startColumn}`; + const cached = memo.members.get(key); + if (cached) return cached; + const members = new Map(); + const nodes = context + .getNodesInFile(mod.file) + .filter((n) => isBlockLevel(n) && inBlock(n, mod)) + .sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn); + for (const n of nodes) { + const list = members.get(n.name); + if (list) list.push(n); + else members.set(n.name, [n]); + } + memo.members.set(key, members); + return members; +} + +/** A path that is not the program's own: a test, a fixture, an example (see search/query-utils). */ +const isTestCode = (file: string): boolean => isTestFile(file); + +/** + * The script declarations of `specifier` an import in `fromFile` can mean, + * the files nearest it first. One a test or a fixture keeps counts only for a + * test's imports: copies of vscode's API typings under copilot's + * `test/fixtures` are left out of its build and shadow nothing. + */ +function declarationsFor(specifier: string, fromFile: string, context: ResolutionContext): AmbientModule[] { + const all = scanned(context).bySpecifier.get(specifier); + if (!all) return []; + const memo = memoFor(context); + const key = `${specifier}\0${fromFile}`; + const cached = memo.declarations.get(key); + if (cached) return cached; + const fromTest = isTestCode(fromFile); + const dir = fromFile.slice(0, fromFile.lastIndexOf('/') + 1); + const usable = all.filter((m) => m.script && (fromTest || !m.test)); + const from = dir.split('/'); + const shared = (file: string): number => { + const parts = file.split('/'); + let k = 0; + while (k < from.length - 1 && k < parts.length - 1 && from[k] === parts[k]) k++; + return k; + }; + const ordered = usable.length < 2 ? usable : usable + .map((m, index) => ({ m, index, near: shared(m.file) })) + .sort((a, b) => Number(a.m.test) - Number(b.m.test) || b.near - a.near || a.index - b.index) + .map((x) => x.m); + memo.declarations.set(key, ordered); + return ordered; +} + +/** The kinds a reference of `refKind` is most likely to mean, first. */ +function kindRank(refKind: UnresolvedRef['referenceKind'], kind: Node['kind']): number { + const callable = kind === 'function' || kind === 'class' || kind === 'constant' || kind === 'variable' || kind === 'component'; + const type = kind === 'interface' || kind === 'class' || kind === 'type_alias' || kind === 'enum'; + switch (refKind) { + case 'calls': + case 'function_ref': + return callable ? 0 : 1; + case 'instantiates': + return kind === 'class' ? 0 : callable ? 1 : 2; + case 'extends': + case 'implements': + return kind === 'class' || kind === 'interface' ? 0 : 1; + case 'references': + return type ? 0 : 1; + default: + return 0; + } +} + +/** + * The declaration an import of `want` from the package `specifier` means, + * through the repository's own `declare module 'specifier'`, or undefined. + * + * A named import is a declaration the body exports, or one an export clause + * names. A default import is what the body's `export default` or `export =` + * names. A namespace import's member is a declaration exported under the + * member's name. Same-named declarations (an interface and the value merged + * with it, a function's overloads) give the one the reference's kind means, + * then the first. + */ +export function findAmbientModuleExport( + specifier: string, + want: { isDefault: boolean; isNamespace: boolean; exportedName: string; memberName: string | null }, + ref: UnresolvedRef, + context: ResolutionContext +): Node | undefined { + if (want.isNamespace && (!want.memberName || want.memberName.includes('.'))) return undefined; + for (const mod of declarationsFor(specifier, ref.filePath, context)) { + const members = blockMembers(mod, context); + let found: Node[] | undefined; + if (want.isDefault) { + found = mod.defaultName ? members.get(mod.defaultName) : undefined; + } else { + const name = want.isNamespace ? want.memberName! : want.exportedName; + const local = mod.exportAliases.get(name); + // Under an export clause the clause decides; otherwise what the body exports. + found = local !== undefined ? members.get(local) : members.get(name)?.filter((n) => n.isExported); + } + if (found && found.length > 0) { + return [...found].sort((a, b) => kindRank(ref.referenceKind, a.kind) - kindRank(ref.referenceKind, b.kind))[0]; + } + } + return undefined; +} + +/** + * The file node of the declaration file that declares the module `specifier` + * (a script's `declare module`), nearest `fromFile`: what a namespace or + * default import of the package depends on. Undefined when none does. + */ +export function findAmbientModuleFile(specifier: string, fromFile: string, context: ResolutionContext): Node | undefined { + const [mod] = declarationsFor(specifier, fromFile, context); + if (!mod || mod.file === fromFile) return undefined; + return context.getNodesInFile(mod.file).find((n) => n.kind === 'file'); +} + +/** Whether the repository declares `specifier` itself, and its declaration exports `name` (or the default). */ +export function ambientModuleDeclares(specifier: string, name: string, isDefault: boolean, ref: UnresolvedRef, context: ResolutionContext): boolean { + return findAmbientModuleExport(specifier, { isDefault, isNamespace: false, exportedName: name, memberName: null }, ref, context) !== undefined; +} + +/** The `declare module` block of a declaration file whose own level declares `node`, if any. */ +function blockDeclaring(node: Node, context: ResolutionContext): AmbientModule | undefined { + if (!isBlockLevel(node) || !DECLARATION_FILE.test(node.filePath)) return undefined; + return scanned(context).byFile.get(node.filePath)?.find((mod) => inBlock(node, mod)); +} + +/** + * The ids of the functions declaration files' `declare module 'x'` bodies + * declare. Only an import of `x` reaches one: a module's functions are never + * global, so a bare `encode()` elsewhere is no call of the `encode` excalidraw + * declares for `png-chunks-encode`. A set, so asking about every reference a + * strategy other than the import resolver settles costs a lookup. + */ +export function ambientModuleFunctionIds(context: ResolutionContext): ReadonlySet { + const memo = memoFor(context); + if (memo.functionIds) return memo.functionIds; + const ids = new Set(); + for (const [file, modules] of scanned(context).byFile) { + for (const node of context.getNodesInFile(file)) { + if (node.kind === 'function' && isBlockLevel(node) && modules.some((mod) => inBlock(node, mod))) ids.add(node.id); + } + } + memo.functionIds = ids; + return ids; +} + +/** + * Whether `node` is one of several declarations of the same name that the + * blocks declaring one module merge: vscode declares `InlineCompletionItemProvider` + * in `vscode.d.ts`, and the proposed API files declaring `vscode` add members + * to it. No one of them holds every member, so none answers which method a + * value of the type calls. + */ +export function isMergedAmbientDeclaration(node: Node, context: ResolutionContext): boolean { + const memo = memoFor(context); + const cached = memo.merged.get(node.id); + if (cached !== undefined) return cached; + const mod = blockDeclaring(node, context); + let merged = false; + if (mod && !mod.pattern) { + let count = 0; + for (const other of scanned(context).bySpecifier.get(mod.specifier) ?? []) { + count += blockMembers(other, context).get(node.name)?.length ?? 0; + if (count > 1) { + merged = true; + break; + } + } + } + memo.merged.set(node.id, merged); + return merged; +} diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 96df63e24..09757d611 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -15,6 +15,7 @@ import { resolveWorkspaceImport } from './workspace-packages'; import { stripCommentsForRegex } from './strip-comments'; import { makeLineAt } from './synth-utils'; import { dartDirectiveFile } from './dart-libraries'; +import { ambientModuleDeclares, findAmbientModuleExport, findAmbientModuleFile } from './ambient-modules'; import { moduleTail } from '../db/reference-tail'; import { resolveMethodOnType, @@ -2552,6 +2553,12 @@ function isJsBareModuleRef(ref: UnresolvedRef, context: ResolutionContext): bool */ export function jsModuleFile(ref: UnresolvedRef, context: ResolutionContext): string | null { if (!isJsPathImportRef(ref) && !isJsBareModuleRef(ref, context)) return null; + // A package the repository declares itself is the file that declares it, + // whatever file its specifier resolves to (see ./ambient-modules). + if (isBareModuleSpecifier(ref.referenceName, ref.language)) { + const declaring = findAmbientModuleFile(ref.referenceName, ref.filePath, context); + if (declaring) return declaring.filePath; + } const file = resolveImportPath(ref.referenceName, ref.filePath, ref.language, context); return file && file !== ref.filePath ? file : null; } @@ -2859,19 +2866,20 @@ export function resolveViaImport( context ); - if (resolvedPath) { + // A package the repository declares itself, as cord-field's + // `typings/mammoth/index.d.ts` declares `declare module 'mammoth'`: + // TypeScript takes that declaration before any file the specifier + // resolves to (see ./ambient-modules). + const bare = isBareModuleSpecifier(imp.source, ref.language); + if (resolvedPath || bare) { const exportedName = imp.isDefault ? 'default' : imp.exportedName; const memberName = imp.isNamespace ? ref.referenceName.replace(imp.localName + '.', '') : null; + const want = { isDefault: imp.isDefault, isNamespace: imp.isNamespace, exportedName, memberName }; - const targetNode = findExportedSymbol( - resolvedPath, - { isDefault: imp.isDefault, isNamespace: imp.isNamespace, exportedName, memberName }, - ref.language, - context, - new Set() - ); + const targetNode = (bare ? findAmbientModuleExport(imp.source, want, ref, context) : undefined) ?? + (resolvedPath ? findExportedSymbol(resolvedPath, want, ref.language, context, new Set()) : undefined); if (targetNode) { // `Foo.bar()` / `Foo.CONST` — a NAMED (non-namespace) class import @@ -2960,6 +2968,11 @@ export function resolveViaImport( return null; } +/** Whether `source`, imported in a file of `language`, is an ES module specifier written as a name, not a path. */ +function isBareModuleSpecifier(source: string, language: Language): boolean { + return ESM_IMPORT_LANGUAGES.has(language) && !source.startsWith('.') && !source.startsWith('/'); +} + /** * Resolve a Python qualified reference whose receiver is an imported MODULE: * `certs.where()` after `from . import certs`, `mod.func()` after `import mod` @@ -3230,6 +3243,13 @@ function resolveModuleImportToFile( continue; } + // A package the repository declares itself in a script `.d.ts`: that file + // is the module, whatever file the specifier resolves to (see ./ambient-modules). + if (isBareModuleSpecifier(modulePath, ref.language)) { + const declaring = findAmbientModuleFile(modulePath, ref.filePath, context); + if (declaring) return { original: ref, targetNodeId: declaring.id, confidence: 0.9, resolvedBy: 'import' }; + } + const resolvedPath = resolveImportPath(modulePath, ref.filePath, ref.language, context); if (resolvedPath && resolvedPath !== ref.filePath) { const fileNode = context.getNodesInFile(resolvedPath).find((n) => n.kind === 'file'); @@ -3916,8 +3936,11 @@ export function isBoundToOutOfRepoImport( if (!ESM_IMPORT_LANGUAGES.has(ref.language)) return false; for (const imp of context.getImportMappings(ref.filePath, ref.language)) { if (imp.localName !== name) continue; + // A package the repository declares itself (`declare module 'x'`) has + // its declarations in the repository. return isExternalImport(imp.source, ref.language, context) && - resolveImportPath(imp.source, ref.filePath, ref.language, context) === null; + resolveImportPath(imp.source, ref.filePath, ref.language, context) === null && + !ambientModuleDeclares(imp.source, imp.exportedName, imp.isDefault, ref, context); } return false; } diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 80d3bae76..9e23575d2 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -33,6 +33,7 @@ import { gateDartLocal, clearDartLocalScopeMemos } from './dart-local-scope'; import { clearCppTypeAliasMemos } from './cpp-type-aliases'; import { clearCppIncluderMemos } from './cpp-includers'; import { clearCFeatureVariantMemos } from './c-feature-variants'; +import { clearAmbientModuleMemos, ambientModuleFunctionIds } from './ambient-modules'; import { matchShopifyThemeFile } from './shopify-themes'; import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isDartImportRef, isLuaRequireRef, isJsPathImportRef, isBoundToOutOfRepoImport, importBindingTail, clearImportResolverMemos, resolveImportPath, isExternalImport, ESM_IMPORT_LANGUAGES, jsModuleFile, pythonRootFingerprint, pythonReopenScope, pythonPackageImporters } from './import-resolver'; import { ResolverPool, minRefsForPool, shouldEngageAdaptively } from './resolver-pool'; @@ -578,6 +579,7 @@ export class ReferenceResolver { clearCppSupertypeMemos(this.context); clearCppIncluderMemos(this.context); clearCFeatureVariantMemos(this.context); + clearAmbientModuleMemos(this.context); } } @@ -1354,6 +1356,11 @@ export class ReferenceResolver { const module = this.nodeById(resolved.targetNodeId); if (module && module.kind !== 'file' && module.kind !== 'module') return null; } + // A function a declaration file's `declare module 'x'` declares is reached + // only through an import of `x`, never by its bare name from another file + // (see ./ambient-modules). + if (resolved && resolved.resolvedBy !== 'import' && ambientModuleFunctionIds(this.context).has(resolved.targetNodeId) && + this.nodeById(resolved.targetNodeId)?.filePath !== ref.filePath) return null; if (!resolved || ref.referenceKind !== 'calls') return resolved; const target = this.nodeById(resolved.targetNodeId); diff --git a/src/resolution/module-importers.ts b/src/resolution/module-importers.ts index 52c4adcbb..3d7252468 100644 --- a/src/resolution/module-importers.ts +++ b/src/resolution/module-importers.ts @@ -25,6 +25,7 @@ import type { ImportMapping, ReExport } from './types'; import { extractImportMappings, extractReExports, pythonModulePath, REEXPORT_MAX_DEPTH } from './import-resolver'; import { resurrectRefFromDroppedEdge } from './resurrect-ref'; import { defaultExportBinding, extractLocalExportAliases } from './alias-binding'; +import { isDeclarationFile, scanAmbientModules } from './ambient-modules'; import { moduleReferenceKeys } from '../db/reference-tail'; /** @@ -186,6 +187,56 @@ export function reopenReferencesThroughAddedFiles( reopenEdges(queries, bindings, (e) => e.edgeId <= resolvedUpToEdgeId) + queries.reopenFailedReferences(failed); } +/** + * The way in for the files importing a package that a declaration file this + * sync adds or edits declares itself: `declare module 'mammoth' { … }` in + * `typings/mammoth/index.d.ts` (see ./ambient-modules). They import the + * package by its name, which no import edge into the file records, so the + * pass over an added file's importers never finds them, and an aliased, + * default or namespace binding waits under a name the file never declares. + * What each importer reaches through those bindings goes back to pending for + * the sweep: the references that failed, and the edges bound before this sync + * resolved anything (up to `resolvedUpToEdgeId`), which a name match may have + * made while nothing declared the package. A removed declaration needs + * nothing of its own: the edges into its declarations are resurrected with + * them. Returns the number re-opened. + */ +export function reopenReferencesThroughDeclaredPackages( + queries: QueryBuilder, + reader: ImportReader, + rootDir: string, + changed: readonly string[], + resolvedUpToEdgeId: number, +): number { + const specifiers = new Set(); + for (const file of changed) { + if (!isDeclarationFile(file)) continue; + let content = ''; + try { + content = fs.readFileSync(path.join(rootDir, file), 'utf8'); + } catch { + continue; + } + if (!content.includes('declare')) continue; + for (const block of scanAmbientModules(content).blocks) if (!block.pattern) specifiers.add(block.specifier); + } + if (specifiers.size === 0) return 0; + const changedFiles = new Set(changed); + const importers: Importers = new Map(); + for (const specifier of specifiers) { + // An import statement's node is named by the specifier it imports. + for (const node of queries.getNodesByName(specifier)) { + if (node.kind !== 'import' || !BINDING_LANGUAGES.has(node.language) || changedFiles.has(node.filePath)) continue; + const entry = importers.get(node.filePath) ?? { language: node.language, sources: new Map | null>() }; + entry.sources.set(specifier, null); + importers.set(node.filePath, entry); + } + } + const bindings = bindingsOf(importers, reader); + const failed = [...bindings].flatMap(([file, { modules, names }]) => queries.getFailedReferencesThrough(file, modules, names)); + return reopenEdges(queries, bindings, (e) => e.edgeId <= resolvedUpToEdgeId) + queries.reopenFailedReferences(failed); +} + /** The languages a barrel a lazy route reads through is written in: the ES-module family. */ const BARREL_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx']); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index c00616299..217d717ae 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -21,6 +21,7 @@ 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 } 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'; @@ -16675,7 +16676,9 @@ export function tsImportedTypeFile(written: string, ref: UnresolvedRef, context: if (context.getImportMappings?.(ref.filePath, ref.language)?.some((i) => i.localName === binding)) { const imported = context.resolveImport?.({ ...ref, referenceName: name, referenceKind: 'references' }); const node = imported ? context.getNodeById?.(imported.targetNodeId) : undefined; - if (node && node.kind !== 'file' && node.filePath !== ref.filePath) file = node.filePath; + // A type the blocks declaring one module merge (vscode's proposed API files add + // members to `vscode.d.ts`'s interfaces) has no one file that holds every member. + if (node && node.kind !== 'file' && node.filePath !== ref.filePath && !isMergedAmbientDeclaration(node, context)) file = node.filePath; } memo.set(key, file); } @@ -17965,7 +17968,9 @@ function resolveAwaitedCallType( referenceName: returned, referenceKind: 'references' as const }; const typeImport = context.getImportMappings(declaring.filePath, declaring.language).some(m => m.localName === returned); const resolved = typeImport ? context.resolveImport?.(typeRef) : null; - const typeNode = resolved ? context.getNodeById?.(resolved.targetNodeId) : + const importedType = resolved ? context.getNodeById?.(resolved.targetNodeId) : null; + // A type the files declaring one module merge is no one declaration (see tsImportedTypeFile). + const typeNode = resolved ? (importedType && !isMergedAmbientDeclaration(importedType, context) ? importedType : null) : context.getNodesByName(returned).find(n => n.filePath === declaring.filePath && ESM_FAMILY.has(n.language) && (n.kind === 'class' || n.kind === 'interface')); if (!typeNode || (typeNode.kind !== 'class' && typeNode.kind !== 'interface')) return unknown; @@ -21831,7 +21836,11 @@ function tsNamedType( // vscode's `ILogService` is also a constant, its dependency-injection // decorator, which the import may name first: the type is the interface // of that name beside it. - found = !node ? null : TS_MEMBER_OWNER_KINDS.has(node.kind) ? node : tsTypeDeclaredIn(node.filePath, node.name, context) ?? null; + // A type the files declaring one module merge is no one declaration + // (see tsImportedTypeFile): copilot's `ChatResponseStream` methods are in + // vscode's proposed API files, not `vscode.d.ts`. + found = !node || isMergedAmbientDeclaration(node, context) ? null + : TS_MEMBER_OWNER_KINDS.has(node.kind) ? node : tsTypeDeclaredIn(node.filePath, node.name, context) ?? null; } else if (!written.includes('.')) { found = context.getNodesInFile(from.filePath).find((n) => n.id !== from.id && n.name === written && TS_MEMBER_OWNER_KINDS.has(n.kind)) ?? null; } @@ -22093,7 +22102,7 @@ function tsNamedClass(name: string, from: Node, ref: UnresolvedRef, context: Res if (context.getImportMappings?.(from.filePath, from.language)?.some((i) => i.localName === name)) { const imported = context.resolveImport?.({ ...ref, filePath: from.filePath, language: from.language, referenceName: name, referenceKind: 'references' }); const node = imported ? context.getNodeById?.(imported.targetNodeId) : undefined; - if (node && (node.kind === 'class' || node.kind === 'component')) found = node; + if (node && (node.kind === 'class' || node.kind === 'component') && !isMergedAmbientDeclaration(node, context)) found = node; } else { found = context.getNodesInFile(from.filePath).find((n) => n.kind === 'class' && n.name === name) ?? null; }