fix(ts): an interface links the interfaces it extends (#2726)

tree-sitter-typescript holds an interface's supertypes in an
extends_type_clause, which neither extractInheritance (wasm) nor the
kernel's extract_inheritance read: a class's extends and implements
became edges, an interface's extends nothing. So an interface had no
place in the type hierarchy, a change to it reached none of the
interfaces built on it, and a call through a composite interface whose
head the receiver typing could not read fell to name matching
(home-assistant/frontend's hass.formatEntityName linked to a namesake
in an unrelated file).

Both engines now emit one extends reference per type of the clause: a
name, a qualified name as written (shapes.Shape), or a generic type by
its name field (Repo for Repo<User>). They resolve as a class's do, so
a package's type stays unresolved, and a namespace import or a module
the repository declares resolves.

A supertype a TS/JS module names without importing it is now its own
declaration or a global one, at a script's top level or in declare
global, never another module's or a namespace's: vscode's interfaces
extending Omit<...> linked to quickInput.ts's export type Omit, and
extends Function to terminal-suggest's Fig.Function.

A call on a TS/JS type whose declaration is known now takes the method
from that declaration's own supertypes. The walk by name, which unions
the supertypes of every same-named type and looks the method up by name
again, follows only classes' supertypes, as before interfaces had any.
Otherwise vscode's receiver.dispose() on an ILocalOtlpHttpReceiver
extends IDisposable reached copilot's own IDisposable, one of eleven,
and stream.on() on a NodeJS.ReadableStream the project's
ReadableStreamEvents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-21 03:44:24 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 226ac930de
commit e97012eb31
6 changed files with 659 additions and 2 deletions
+1
View File
@@ -253,6 +253,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- 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.
- In TypeScript, an interface that extends other interfaces, like `interface UserRepo extends Repo<User>, Named`, is now linked to each of them: the type hierarchy shows them above the interface and above every class that implements it, a change to an interface reaches the interfaces built on it and their implementations in impact results, and a call on a value typed by such an interface, like `hass.formatEntityName(…)` on a `HomeAssistant` that extends seven interfaces, links to the method the interface inherits instead of a same-named method elsewhere. Before, only a class's `extends` and `implements` were read, so every interface stood alone. A type from a package, like `ButtonProps` from `@mui/material`, or one of TypeScript's own, like `Omit<…>`, still links to nothing rather than to a type of the same name elsewhere in your project. Re-index TypeScript projects after upgrading.
## [1.6.2] - 2026-10-03
+458
View File
@@ -0,0 +1,458 @@
/**
* A TypeScript interface is linked to the interfaces it extends.
*
* interface Repo<T> { find(id: string): T }
* interface UserRepo extends Repo<User>, Named {}
*
* The grammar holds an interface's supertypes in an `extends_type_clause`,
* which neither the wasm extractor nor the native kernel read: a class's
* `extends` and `implements` became edges, an interface's `extends` nothing.
* So an interface had no place in the type hierarchy — a class implementing
* `UserRepo` showed nothing above it — and an interface that is only ever
* extended had no dependents. Now each supertype is an `extends` reference,
* resolved like a class's: a plain name, a qualified one (`shapes.Shape`), or
* a parameterized one by its name alone (`Repo`, never `Repo<User>`). It goes
* through the file's imports first, so a package's type (`ButtonProps` from
* '@mui/material') never reaches a project namesake.
*/
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 { buildTypeHierarchy } from '../src/graph/type-hierarchy';
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';
const SOURCE = `interface A extends B, ns.C, Repo<T>, ns.D<string> {}
interface E<T extends object> extends F<T> {}
export default interface P extends Q {}
declare module 'pkg' {
interface N extends O {}
}
declare global {
interface Window extends Gadget {}
}
class G extends H implements L, ns.M {}
interface Commented extends B /* not a type */, C {}
`;
const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const;
describe("an interface's extends clause is read", () => {
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!;
}
/** `<from> <kind> <name>@<line>:<column>` for every supertype reference. */
const supertypeRefs = (result: ExtractionResult): string[] => {
const names = new Map(result.nodes.map((n) => [n.id, n.name]));
return result.unresolvedReferences
.filter((r) => r.referenceKind === 'extends' || r.referenceKind === 'implements')
.map((r) => `${names.get(r.fromNodeId)} ${r.referenceKind} ${r.referenceName}@${r.line}:${r.column}`);
};
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)(`each supertype is a reference, a parameterized one by its name: %s${label}`, (backend) => {
expect(supertypeRefs(extract(backend, 'src/types.ts', eol(SOURCE), 'typescript'))).toEqual([
'A extends B@1:20',
'A extends ns.C@1:23',
'A extends Repo@1:29',
'A extends ns.D@1:38',
// A type parameter's constraint is not a supertype.
'E extends F@2:38',
'P extends Q@3:35',
'N extends O@5:22',
'Window extends Gadget@8:27',
// A class's clauses are read as before.
'G extends H@10:16',
'G implements L@10:29',
'G implements ns.M@10:32',
// A comment in the clause is not a supertype.
'Commented extends B@11:28',
'Commented extends C@11:48',
]);
});
it.each(backends)(`in a tsx file too: %s${label}`, (backend) => {
const source = eol('interface Props extends Base<string> {}\nexport const App = (p: Props) => <main />;\n');
expect(supertypeRefs(extract(backend, 'src/App.tsx', source, 'tsx'))).toEqual(['Props extends Base@1:24']);
});
}
it.runIf(kernelAvailable)('the kernel and the wasm extractor agree on every node and reference', () => {
const canon = (r: ExtractionResult) => [
...r.nodes.map(({ updatedAt: _u, ...n }) => JSON.stringify(n, Object.keys(n).sort())),
...r.unresolvedReferences.map((ref) => JSON.stringify(ref, Object.keys(ref).sort())),
].sort();
expect(canon(extract('kernel', 'src/types.ts', SOURCE, 'typescript'))).toEqual(
canon(extract('wasm', 'src/types.ts', SOURCE, 'typescript'))
);
});
});
describe('an interface links the interfaces it extends', () => {
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-interface-extends-'));
const files: Record<string, string> = {
'package.json': JSON.stringify({ name: 'app', dependencies: { '@mui/material': '^5.0.0', lib: '^1.0.0' } }),
'src/shapes.ts': `export interface Shape {
area(): number;
}
export interface Named {
name: string;
}
`,
'src/repo.ts': `export interface Repo<T> {
find(id: string): T;
}
`,
// Namesakes of the package's ButtonProps and of the global Error.
'src/legacy/button.ts': `export interface ButtonProps {
legacy: true;
}
export class Error {}
`,
// The repository declares the package 'lib' itself (#2718).
'typings/lib.d.ts': `declare module 'lib' {
export interface Options { verbose: boolean }
}
`,
// A module's own `Omit`, a namespace's `Function`, and two globals: a
// script's top level and a module's `declare global`.
'src/legacy/quick.ts': `export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
`,
'typings/fig.d.ts': `declare namespace Fig {
type Function<T = void, R = void> = (param: T) => R;
}
`,
'typings/globals.d.ts': `interface GlobalShape {
id: string;
}
`,
'src/globals.ts': `export const appName = 'app';
declare global {
interface Window {
appName: string;
}
}
`,
'src/model.ts': `import { Shape, Named } from './shapes';
import * as shapes from './shapes';
import { Repo } from './repo';
import { ButtonProps } from '@mui/material';
import { Options } from 'lib';
interface Base {
a(): void;
}
interface Other {
b(): void;
}
export interface Sub extends Base {}
export interface Both extends Base, Other {}
export interface Circle extends Shape, Named {}
export interface Square extends shapes.Shape {}
export interface User { id: string }
export interface UserRepo extends Repo<User> {}
export interface MyButtonProps extends ButtonProps {}
export interface MyOptions extends Options {}
export interface MyError extends Error {}
export interface Picked extends Omit<Base, 'a'> {}
export interface Callable extends Function {}
export interface Located extends GlobalShape {}
export interface WindowWithApp extends Window {}
class BaseImpl {}
export class Impl extends BaseImpl implements Sub {
a(): 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);
}
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
const nodeOf = (file: string, name: string) => {
const node = cg.getNodesInFile(file).find((n) => n.name === name && n.kind !== 'file' && n.kind !== 'import');
expect(node, `${file} ${name}`).toBeDefined();
return node!;
};
/** `<kind> <file>:<qualified name>` of the supertype edges `name` in model.ts declares. */
const supertypes = (name: string): string[] =>
cg.getOutgoingEdges(nodeOf('src/model.ts', name).id)
.filter((e) => e.kind === 'extends' || e.kind === 'implements')
.map((e) => {
const target = cg.getNode(e.target)!;
return `${e.kind} ${target.filePath}:${target.qualifiedName}`;
})
.sort();
it('a same-file supertype, and several', () => {
expect(supertypes('Sub')).toEqual(['extends src/model.ts:Base']);
expect(supertypes('Both')).toEqual(['extends src/model.ts:Base', 'extends src/model.ts:Other']);
});
it('an imported supertype, through a named import and a namespace import', () => {
expect(supertypes('Circle')).toEqual(['extends src/shapes.ts:Named', 'extends src/shapes.ts:Shape']);
expect(supertypes('Square')).toEqual(['extends src/shapes.ts:Shape']);
});
it('a parameterized supertype, by its name', () => {
expect(supertypes('UserRepo')).toEqual(['extends src/repo.ts:Repo']);
});
it("the repository's own declaration of a package's type", () => {
expect(supertypes('MyOptions')).toEqual(['extends typings/lib.d.ts:Options']);
});
it("a package's type or a global never reaches a project namesake", () => {
expect(supertypes('MyButtonProps')).toEqual([]);
expect(supertypes('MyError')).toEqual([]);
});
it("a name the module doesn't import is its own or a global, never another module's or a namespace's", () => {
expect(supertypes('Picked')).toEqual([]);
expect(supertypes('Callable')).toEqual([]);
expect(supertypes('Located')).toEqual(['extends typings/globals.d.ts:GlobalShape']);
expect(supertypes('WindowWithApp')).toEqual(['extends src/globals.ts:Window']);
});
it("a class's extends and implements are unchanged", () => {
expect(supertypes('Impl')).toEqual(['extends src/model.ts:BaseImpl', 'implements src/model.ts:Sub']);
});
it('the type hierarchy runs through the interface: above a class implementing it, and below its supertype', () => {
const above = buildTypeHierarchy(cg, nodeOf('src/model.ts', 'Impl'))!;
expect(above.ancestors.map((a) => `${a.depth} ${a.relation} ${a.node.name}`)).toEqual([
'1 extends BaseImpl',
'1 implements Sub',
'2 extends Base',
]);
const below = buildTypeHierarchy(cg, nodeOf('src/model.ts', 'Base'))!;
expect(below.descendants.map((d) => `${d.depth} ${d.relation} ${d.node.name}`).sort()).toEqual([
'1 extends Both',
'1 extends Sub',
'2 implements Impl',
]);
});
it("a change to an interface reaches the interfaces built on it, and their implementers", () => {
const impact = cg.getImpactRadius(nodeOf('src/model.ts', 'Base').id);
const reached = [...impact.nodes.values()].filter((n) => n.kind === 'interface' || n.kind === 'class').map((n) => n.name);
expect(reached.sort()).toEqual(['Base', 'Both', 'Impl', 'Sub']);
});
});
/**
* A call on a value typed by an interface that only inherits the method
* reaches the declaration in the interface's own supertype: home-assistant's
* `hass.formatEntityName(…)` on a `HomeAssistant`, which extends seven
* interfaces over a head too long to read from its source text, and vscode's
* `receiver.dispose()` on an `ILocalOtlpHttpReceiver extends IDisposable`
* while another app in the repository declares an `IDisposable` of its own.
*/
describe('a call through an interface reaches the method its supertype declares', () => {
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-interface-extends-calls-'));
const files: Record<string, string> = {
'src/types.ts': `export interface HomeAssistantRegistries { entities: string[] }
export interface HomeAssistantInternationalization { language: string }
export interface HomeAssistantApi {
callService(domain: string): void;
}
export interface HomeAssistantFormatters {
formatEntityName(entity: string): string;
}
export interface HomeAssistantConnection { connected: boolean }
export interface HomeAssistantUI { themes: string[] }
export interface HomeAssistantConfig { config: string }
export interface HomeAssistant
extends
HomeAssistantRegistries,
HomeAssistantInternationalization,
HomeAssistantApi,
HomeAssistantFormatters,
HomeAssistantConnection,
HomeAssistantUI,
HomeAssistantConfig {
user: string;
}
`,
// A namesake method in an unrelated file.
'src/resources/completions.ts': `export interface HassArgHoverContext {
formatEntityName(entity: string): string;
}
`,
'src/data/describe.ts': `import type { HomeAssistant } from '../types';
export function describe(hass: HomeAssistant): string {
hass.callService('light');
return hass.formatEntityName('light.kitchen');
}
`,
// Another app's IDisposable, ahead of the project's own in the index.
'extensions/copilot/src/util/lifecycle.ts': `export interface IDisposable {
dispose(): void;
}
`,
'src/base/lifecycle.ts': `export interface IDisposable {
dispose(): void;
}
`,
'src/otel/receiver.ts': `import { IDisposable } from '../base/lifecycle';
export interface ILocalReceiver extends IDisposable {
readonly port: number;
}
export async function startReceiver(port: number): Promise<ILocalReceiver> {
return { port, dispose() {} };
}
`,
'src/otel/run.ts': `import { startReceiver } from './receiver';
export async function run(): Promise<void> {
const receiver = await startReceiver(0);
receiver.dispose();
}
`,
// A project interface named like Node's `NodeJS.ReadableStream`.
'src/base/stream.ts': `export interface ReadableStreamEvents<T> {
on(event: 'data', listener: (data: T) => void): void;
}
export interface ReadableStream<T> extends ReadableStreamEvents<T> {
pause(): void;
}
`,
'src/shim/runner.ts': `export function listen(stream: NodeJS.ReadableStream, listener: (chunk: string) => void): void {
stream.on('data', listener);
}
`,
// Two supertypes that both have the method, one through its own
// supertype, again over a head too long to read.
'src/widgets.ts': `export interface IBase {
render(): void;
}
export interface IMiddle extends IBase {}
export interface IRenderable {
render(): void;
}
export interface ISized { width: number }
export interface IPositioned { x: number }
export interface IStyled { color: string }
export interface INamed { name: string }
export interface IVisible { visible: boolean }
export interface IWidget
extends
IMiddle,
ISized,
IPositioned,
IStyled,
INamed,
IVisible,
IRenderable {}
export function draw(widget: IWidget): void {
widget.render();
}
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
/** `<file>:<qualified name>` of every call `fn` in `file` makes. */
const callees = (file: string, fn: string): string[] => {
const node = cg.getNodesInFile(file).find((n) => n.name === fn && n.kind === 'function')!;
expect(node, `${file} ${fn}`).toBeDefined();
return cg.getOutgoingEdges(node.id).filter((e) => e.kind === 'calls').map((e) => {
const target = cg.getNode(e.target)!;
return `${target.filePath}:${target.qualifiedName}`;
}).sort();
};
it("through a composite interface, the member of the supertype that declares it, not a namesake's", () => {
expect(callees('src/data/describe.ts', 'describe')).toEqual([
'src/types.ts:HomeAssistantApi::callService',
'src/types.ts:HomeAssistantFormatters::formatEntityName',
]);
});
it('the supertype the interface extends, not a same-named one elsewhere', () => {
expect(callees('src/otel/run.ts', 'run')).toEqual([
'src/base/lifecycle.ts:IDisposable::dispose',
'src/otel/receiver.ts:startReceiver',
]);
});
it("a type from outside the project reaches none of a project namesake's supertypes", () => {
expect(callees('src/shim/runner.ts', 'listen')).toEqual([]);
});
it('each supertype, and then its own supertypes, in the order they are written', () => {
expect(callees('src/widgets.ts', 'draw')).toEqual(['src/widgets.ts:IBase::render']);
});
});
+16
View File
@@ -1694,6 +1694,22 @@ impl<'t> Walker<'t> {
}
}
}
// TS `interface Repo<T> extends Base, ns.Shape, Store<T>` — each
// supertype is a `type` of the clause; a `generic_type` is named
// by its `name` field (`Store`, never `Store<T>`).
"extends_type_clause" => {
let mut cursor = child.walk();
let targets: Vec<Node<'t>> = child.children_by_field_name("type", &mut cursor).collect();
for target in targets {
let named = if target.kind() == "generic_type" {
target.child_by_field_name("name").unwrap_or(target)
} else {
target
};
let name = self.text(named).to_string();
self.push_ref(class_row, &name, extends_kind, target);
}
}
// JS `class Foo extends Bar` — class_heritage holds a bare
// identifier without an extends_clause wrapper.
"identifier" | "type_identifier" if node.kind() == "class_heritage" => {
+18
View File
@@ -7289,6 +7289,24 @@ export class TreeSitterExtractor {
}
}
// TypeScript: `interface Repo<T> extends Base, ns.Shape, Store<T>` — each
// supertype is a `type` of the extends_type_clause. A parameterized one
// (`generic_type`) is named by its `name` field: the index holds `Store`,
// never `Store<T>`.
if (child.type === 'extends_type_clause') {
for (const target of child.childrenForFieldName('type')) {
if (!target) continue;
const named = target.type === 'generic_type' ? getChildByField(target, 'name') ?? target : target;
this.unresolvedReferences.push({
fromNodeId: classId,
referenceName: getNodeText(named, this.source),
referenceKind: 'extends',
line: target.startPosition.row + 1,
column: target.startPosition.column,
});
}
}
// C++ base classes: `class Derived : public Base, private Other` →
// base_class_clause holds access specifiers + base type(s). Emit an extends
// ref per base type (skip the public/private/protected keywords). A
+45
View File
@@ -224,6 +224,51 @@ export function scanAmbientModules(source: string): AmbientModuleScan {
return { script, blocks };
}
/** Where a file declares into the global scope. */
export interface GlobalScopeScan {
/** No top-level `import` or `export`: the file's top level is the global scope. */
script: boolean;
/**
* `line:column` of every word at the global scope: at a script's top level,
* and directly inside `declare global { … }`. A declaration starts at one of
* these exactly when it is a global (its keyword: `interface`, `type`, `class`,
* `abstract`, `enum`, `function`, `const`, …).
*/
globalWords: Set<string>;
}
/**
* The global scope's share of `source`: its top level if the file is a
* script, and the bodies of its `declare global` blocks. A namespace's or a
* `declare module` block's members, and a module's own declarations, are
* not in it.
*/
export function scanGlobalScope(source: string): GlobalScopeScan {
const { script } = scanAmbientModules(source);
const globalWords = new Set<string>();
let declare = 0; // 1: `declare`, 2: `declare global`
let inGlobal = false;
tokenize(source, (kind, text, line, column, depth) => {
if (inGlobal) {
if (kind === Kind.Punct && text === '}' && depth === 0) inGlobal = false;
else if (kind === Kind.Word && depth === 1) globalWords.add(`${line}:${column}`);
return;
}
if (depth !== 0) {
declare = 0;
return;
}
if (kind === Kind.Word) {
if (script) globalWords.add(`${line}:${column}`);
declare = text === 'declare' ? 1 : declare === 1 && text === 'global' ? 2 : 0;
return;
}
if (kind === Kind.Punct && text === '{' && declare === 2) inGlobal = true;
declare = 0;
});
return { script, globalWords };
}
/**
* Feed `source`'s tokens to `visit`: words, string literals (by their
* contents), template literals (as one token, their `${…}` expressions read as
+121 -2
View File
@@ -21,7 +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 { isMergedAmbientDeclaration, scanGlobalScope, type GlobalScopeScan } from './ambient-modules';
import { isMinifiedContent } from '../extraction/generated-detection';
import { getCargoWorkspaceCrateMap } from './frameworks/cargo-workspace';
import { readRustScopes, rustBindingAt, rustInnerBlockAt, rustBoundsOf, rustFnAt, rustImplAt, splitRustTopLevel, type RustBinding, type RustPatternSite, type RustScopes } from './rust-scopes';
@@ -1533,6 +1533,51 @@ function isSealedModule(filePath: string, context: ResolutionContext): boolean {
return sealed;
}
/** A TypeScript or JavaScript source file, read as such (an SFC's markup is not). */
const TS_JS_FILE = /\.[cm]?[jt]sx?$/i;
/** Per-context memo: file → where it declares into the global scope (null when unreadable). */
const TS_GLOBAL_SCOPES = new WeakMap<ResolutionContext, Map<string, GlobalScopeScan | null>>();
/** scanGlobalScope of `file`, a CommonJS file's top level counting as its own. */
function tsGlobalScope(file: string, context: ResolutionContext): GlobalScopeScan | null {
let memo = TS_GLOBAL_SCOPES.get(context);
if (!memo) TS_GLOBAL_SCOPES.set(context, (memo = new Map()));
let scope = memo.get(file);
if (scope === undefined) {
const source = context.readFile(file);
scope = source === null ? null : scanGlobalScope(source);
if (scope?.script && HAS_CJS_EXPORT.test(source!)) scope = { script: false, globalWords: new Set() };
memo.set(file, scope);
}
return scope;
}
/**
* Whether a supertype written as a bare name the file doesn't import, in an
* ES module, may be `n`: a declaration of the file's own, or a global one —
* at a script's top level (a file with no import or export, a script `.d.ts`)
* or directly inside `declare global`. A module's own declaration, exported
* or not, is reached only through an import, which the import resolver has
* followed already, and a namespace's member is no global. So an interface's
* `extends Omit<…>` is TypeScript's `Omit`, never vscode's `export type Omit`
* in quickInput.ts, and `extends Function` never terminal-suggest's
* `Fig.Function`.
*/
function isTsSupertypeInScope(n: Node, ref: UnresolvedRef, context: ResolutionContext): boolean {
if (n.filePath === ref.filePath || !TS_JS_FILE.test(n.filePath)) return true;
const scope = tsGlobalScope(n.filePath, context);
return !scope || scope.globalWords.has(`${n.startLine}:${n.startColumn}`);
}
/** Whether `ref` is a supertype written as a bare name its ES module doesn't import (isTsSupertypeInScope). */
function isTsBareSupertype(ref: UnresolvedRef, context: ResolutionContext): boolean {
return isInheritanceRef(ref) && JS_FAMILY.has(ref.language) && TS_JS_FILE.test(ref.filePath) &&
/^[A-Za-z_$][\w$]*$/.test(ref.referenceName) &&
!context.getImportMappings(ref.filePath, ref.language).some((m) => m.localName === ref.referenceName) &&
tsGlobalScope(ref.filePath, context)?.script === false;
}
/**
* Whether `candidate` can be named by a reference in `ref`'s file at all.
* Both name-based strategies validate their chosen candidate. Removing an
@@ -11356,6 +11401,9 @@ export function matchByExactName(
const valueRef = ref.referenceKind === 'references' || ref.referenceKind === 'function_ref';
const importRef = ref.referenceKind === 'imports';
const inheritanceRef = isInheritanceRef(ref);
// Read once, and only for a candidate in another file: it scans the file.
let bareSupertype: boolean | undefined;
const tsBareSupertype = (): boolean => (bareSupertype ??= isTsBareSupertype(ref, context));
const sameName = context.getNodesByName(ref.referenceName);
// `NAME(...)` where NAME is a function-like macro somewhere in the project is
// an expansion or a call to a same-named function — never the macro itself
@@ -11456,6 +11504,10 @@ export function matchByExactName(
// single-match shortcut. Restricting the pool BEFORE ranking lets the
// legitimate supertype win instead of merely dropping the false edge.
(!inheritanceRef || isSupertypeTarget(n)) &&
// And a supertype a TS/JS module names without importing it is its own or
// a global, never another module's — ahead of the ranking, so a global
// `interface Window` in a `declare global` still wins.
!(inheritanceRef && n.filePath !== ref.filePath && tsBareSupertype() && !isTsSupertypeInScope(n, ref, context)) &&
// Likewise for `imports`: a member that only exists inside a type is not
// importable, so it is not a candidate. Without this a `path`/`id`/`url`
// import resolved to some interface's same-named property.
@@ -12477,6 +12529,62 @@ export function matchCollapsedObjectCall(ref: UnresolvedRef, context: Resolution
return resolveObjectPathMember(host[2]!.replace(/[\s?]/g, ''), ref.referenceName, ref, context, host[1]!) ?? undefined;
}
/**
* The method `methodName` a TS/JS class or interface declared in `file` gets
* from its supertypes, found through that declaration's own extends and
* implements edges: the member a supertype's own declaration has, each
* supertype and then its own supertypes in the order they are written, as
* the walk by name goes. That walk (getSupertypes) unions the supertypes of
* every type sharing the name and looks the method up by its name again, so a
* supertype declared in many files — vscode's `IDisposable`, in eleven — gave
* whichever one came first, another app's included. Undefined when `file`
* declares no such type, or none of its supertypes has the member.
*/
function esInheritedMethod(typeName: string, methodName: string, file: string, ref: UnresolvedRef, context: ResolutionContext): Node | undefined {
const supertypeNodes = context.getSupertypeNodes;
if (!supertypeNodes) return undefined;
const decls = context.getNodesByName(typeName).filter((n) => n.filePath === file && (n.kind === 'class' || n.kind === 'interface'));
const seen = new Set(decls.map((n) => n.id));
const inherited = (decl: Node, depth: number): Node | undefined => {
for (const base of supertypeNodes(decl.id)) {
if (seen.has(base.id)) continue;
seen.add(base.id);
const members = context.getMethodMatches?.(base.name, methodName, ref.language) ??
context.getNodesByName(methodName).filter((m) => m.kind === 'method' && m.qualifiedName.endsWith(`${base.name}::${methodName}`));
const own = members.find((m) => m.filePath === base.filePath && m.startLine >= base.startLine && m.endLine <= base.endLine);
if (own) return own;
const above = depth < 3 ? inherited(base, depth + 1) : undefined;
if (above) return above;
}
return undefined;
};
for (const decl of decls) {
const found = inherited(decl, 0);
if (found) return found;
}
return undefined;
}
/**
* The supertypes, by name, of the TS/JS classes named `typeName`: the walk by
* name without an interface's supertypes, which only esInheritedMethod reads,
* from the interface's own declaration. Through any interface of the name, a
* type from a package or a global namespace (vscode's `NodeJS.ReadableStream`)
* reached the supertypes of the project's own `ReadableStream`, and a type
* whose import doesn't resolve those of whichever app declares one.
*/
function esClassSupertypes(typeName: string, language: Language, context: ResolutionContext): string[] {
if (!context.getSupertypeNodes) return context.getSupertypes?.(typeName, language) ?? [];
const names = new Set<string>();
for (const n of context.getNodesByName(typeName)) {
if (n.language !== language || n.kind !== 'class') continue;
for (const s of context.getSupertypeNodes(n.id)) {
if (s.name && s.name !== typeName) names.add(s.name);
}
}
return [...names];
}
// Exported for the precedence unit tests (#1079): they assert the
// preferredFqn → same-file → matches[0] ordering directly.
export function resolveMethodOnType(
@@ -12544,7 +12652,16 @@ export function resolveMethodOnType(
// a supertype), so a wrong inference produces no edge.
if (depth < 4 && context.getSupertypes) {
const viaSupers = nmTimedT('rmot-supers', ref, (): ResolvedRef | null => {
for (const supertype of context.getSupertypes!(typeName, ref.language)) {
// A TS/JS type whose declaration is known — the file it is imported
// from, or else the call's own — takes the member its own supertypes
// declare before any type of the same name elsewhere is asked.
const es = ES_IMPORT_LANGUAGES.has(ref.language);
if (depth === 0 && es) {
const inherited = esInheritedMethod(typeName, methodName, preferredFqn ?? ref.filePath, ref, context);
if (inherited) return { original: ref, targetNodeId: inherited.id, confidence, resolvedBy };
}
const supertypes = es ? esClassSupertypes(typeName, ref.language, context) : context.getSupertypes!(typeName, ref.language);
for (const supertype of supertypes) {
const via = resolveMethodOnType(
supertype, methodName, ref, context, confidence, resolvedBy, preferredFqn, depth + 1,
);
@@ -17086,6 +17203,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
TS_IMPORTED_TYPE_FILES.delete(context);
TS_NAMED_CLASSES.delete(context);
TS_AMBIENT_MODULE_FILES.delete(context);
TS_GLOBAL_SCOPES.delete(context);
C_STATIC_MEMO.delete(context);
CPP_TYPE_DECLS.delete(context);
CPP_INCLUDE_LINES.delete(context);
@@ -23372,6 +23490,7 @@ export function matchFuzzy(
(TYPE_MEMBER_KINDS.has(finalCandidates[0]!.kind) ||
(finalCandidates[0]!.filePath !== ref.filePath && isLocallyBoundJsName(ref.referenceName, ref.filePath, context)))) &&
!(JS_FAMILY.has(ref.language) && isOutOfRepoBinding(ref.referenceName, ref, context)) &&
!(finalCandidates[0]!.filePath !== ref.filePath && isTsBareSupertype(ref, context) && !isTsSupertypeInScope(finalCandidates[0]!, ref, context)) &&
!(finalCandidates[0]!.kind === 'method' && isBareGoCall(ref, context)) &&
// A bare PHP call is a function call (case-insensitive, so fuzzy may find
// one) — never the class `View` for `view(…)`, never a method.