mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-23 06:50:54 +08:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
642c7bb3a5
commit
a33040f786
@@ -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<Item>`, `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<Result>;`) 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
|
||||
|
||||
|
||||
@@ -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<MammothResult>;
|
||||
* }
|
||||
*
|
||||
* // 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(
|
||||
[
|
||||
'/// <reference types="node" />',
|
||||
"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<T> { 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<Result>;
|
||||
export declare function extractRawText(input: Input): Promise<Result>;
|
||||
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<Result>;
|
||||
export declare function extractRawText(input: Input): Promise<Result>;
|
||||
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<void>;
|
||||
}
|
||||
`;
|
||||
|
||||
const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const;
|
||||
|
||||
describe('a bodiless function in an ambient context is a function', () => {
|
||||
let savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
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 = () => <main>{useIcon("x")}</main>;\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<string, string>;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** `<kind> <target file>:<target qualified name>` 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<MammothResult>;
|
||||
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<string[]> {
|
||||
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);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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)
|
||||
|
||||
+9
-1
@@ -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();
|
||||
|
||||
@@ -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 '<name>' { … }` 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<string, string>;
|
||||
/** 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 '<name>' { … }` 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 '<name>'`
|
||||
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<string, AmbientModule[]>; bySpecifier: Map<string, AmbientModule[]> } | null;
|
||||
/** Per block (`file`, line, column): its declarations, by name. */
|
||||
members: Map<string, Map<string, Node[]>>;
|
||||
/** See ambientModuleFunctionIds. */
|
||||
functionIds: ReadonlySet<string> | null;
|
||||
/** declarationsFor's answers, by specifier and importing file. */
|
||||
declarations: Map<string, AmbientModule[]>;
|
||||
/** isMergedAmbientDeclaration's answers, by node id. */
|
||||
merged: Map<string, boolean>;
|
||||
}
|
||||
|
||||
const MEMO = new WeakMap<ResolutionContext, Memo>();
|
||||
|
||||
/** 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<Memo['scanned']> {
|
||||
const memo = memoFor(context);
|
||||
if (memo.scanned) return memo.scanned;
|
||||
const byFile = new Map<string, AmbientModule[]>();
|
||||
const bySpecifier = new Map<string, AmbientModule[]>();
|
||||
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<Node['kind']>(['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<string, Node[]> {
|
||||
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<string, Node[]>();
|
||||
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<string> {
|
||||
const memo = memoFor(context);
|
||||
if (memo.functionIds) return memo.functionIds;
|
||||
const ids = new Set<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string>();
|
||||
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<string, Set<string> | 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<Language>(['typescript', 'tsx', 'javascript', 'jsx']);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user