mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-29 14:49:36 +08:00
fix(resolution): resolve awaited TypeScript receivers safely (#1885)
Fixes #1840. Reuses and extends #1855 with lexical/import ownership and cache invalidation controls. Co-authored-by: Max Hsu <maxmilian@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Max Hsu
Claude Opus 5
parent
8f8081968e
commit
d0996a28d8
@@ -155,6 +155,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- Direct React Native bridge calls retain their native implementations and cross-platform relationships.
|
||||
- Dart extension-type getters remain searchable when using the WebAssembly parser.
|
||||
|
||||
- Calling a built-in method on an awaited value no longer records a call into an unrelated class that happens to declare a method of the same name, and a variable bound to an awaited call now resolves methods on the type that call returns. Thanks @maxmilian. (#1840)
|
||||
- Spring mappings now include every declared path combination and resolve constants declared in the same file, while unresolved paths no longer appear as false root routes. (#1461)
|
||||
- `codegraph callers`, `codegraph callees` and `codegraph impact` now resolve qualified names, group results and JSON edges by definition, and accept `--file` to narrow ambiguous names; thanks @ferrine. (#1512, #1656)
|
||||
- `codegraph callers`, `codegraph callees` and `codegraph impact` (CLI and MCP) now report missing names with did-you-mean suggestions instead of another symbol's results, and exact matches with no callers stay empty; thanks @uvmplus. (#1473, #1481)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
let root: string;
|
||||
let cg: CodeGraph | undefined;
|
||||
beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-awaited-')); });
|
||||
afterEach(() => { cg?.close(); cg = undefined; fs.rmSync(root, { recursive: true, force: true }); });
|
||||
async function index(files: Record<string, string>) {
|
||||
for (const [name, text] of Object.entries(files)) fs.writeFileSync(path.join(root, name), text);
|
||||
cg = await CodeGraph.init(root, { index: true });
|
||||
}
|
||||
function calls(name: string, file = 'caller.ts') {
|
||||
const node = cg!.getNodesByKind('function').find(n => n.name === name && n.filePath === file);
|
||||
expect(node).toBeDefined();
|
||||
return cg!.getCallees(node!.id).filter(({ edge }) => edge.kind === 'calls')
|
||||
.map(({ node, edge }) => ({ target: `${node.filePath}:${node.qualifiedName}`, line: edge.line }));
|
||||
}
|
||||
const engine = 'export class Engine { run() {} }\nexport async function makeEngine(): Promise<Engine> { return new Engine(); }';
|
||||
|
||||
it('follows an imported factory alias rather than an unrelated namesake (#1840)', async () => {
|
||||
await index({
|
||||
'engine.ts': engine,
|
||||
'decoy.ts': 'export async function load(): Promise<string> { return ""; }',
|
||||
'caller.ts': 'import { makeEngine as load } from "./engine";\nexport async function drive() { const handle = await load(); handle.run(); }',
|
||||
});
|
||||
expect(calls('drive').map(c => c.target).sort()).toEqual(['engine.ts:Engine::run', 'engine.ts:makeEngine']);
|
||||
});
|
||||
|
||||
it('resolves return-type aliases in the factory module, not a caller-local decoy (#1840)', async () => {
|
||||
await index({
|
||||
'engine.ts': engine,
|
||||
'factory.ts': 'import { Engine as Service } from "./engine";\nexport async function load(): Promise<Service> { return new Service(); }',
|
||||
'caller.ts': 'import { load } from "./factory";\nclass Service { run() {} }\nexport async function drive() { const handle = await load(); handle.run(); }',
|
||||
});
|
||||
expect(calls('drive').map(c => c.target).sort()).toEqual(['engine.ts:Engine::run', 'factory.ts:load']);
|
||||
});
|
||||
|
||||
it('does not infer from a factory hidden by a parameter (#1840)', async () => {
|
||||
await index({
|
||||
'engine.ts': engine,
|
||||
'caller.ts': 'import { makeEngine } from "./engine";\nexport async function drive(makeEngine: () => Promise<string>) { const handle = await makeEngine(); handle.run(); }',
|
||||
});
|
||||
expect(calls('drive').map(c => c.target)).not.toContain('engine.ts:Engine::run');
|
||||
});
|
||||
|
||||
it('distinguishes same-named awaited receivers in sibling blocks (#1840)', async () => {
|
||||
await index({ 'caller.ts': `class PaneManager { split() {} }
|
||||
async function text(): Promise<string> { return ""; }
|
||||
async function pane(): Promise<PaneManager> { return new PaneManager(); }
|
||||
export async function drive() {
|
||||
{ const value = await text(); value.split(); }
|
||||
{ const value = await pane(); value.split(); }
|
||||
}` });
|
||||
expect(calls('drive').filter(c => c.target.endsWith('PaneManager::split'))).toEqual([
|
||||
{ target: 'caller.ts:PaneManager::split', line: 6 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps captured awaited bindings but rejects shadowing parameters (#1840)', async () => {
|
||||
await index({ 'caller.ts': `${engine}
|
||||
export async function outer() {
|
||||
const handle = await makeEngine();
|
||||
function captured() { handle.run(); }
|
||||
function shadow(handle: any) { handle.run(); }
|
||||
return { captured, shadow };
|
||||
}` });
|
||||
expect(calls('captured').map(c => c.target)).toContain('caller.ts:Engine::run');
|
||||
expect(calls('shadow').map(c => c.target)).not.toContain('caller.ts:Engine::run');
|
||||
});
|
||||
|
||||
it('invalidates an awaited return type after edits in the callee file (#1840)', async () => {
|
||||
const caller = 'import { load } from "./factory";\nexport async function drive() { const value = await load(); value.split(); }';
|
||||
const primitive = 'export async function load(): Promise<string> { return ""; }';
|
||||
const project = 'export class Pane { split() {} }\nexport async function load(): Promise<Pane> { return new Pane(); }';
|
||||
await index({ 'caller.ts': caller, 'factory.ts': primitive, 'decoy.ts': 'export class Other { split() {} }' });
|
||||
expect(calls('drive').map(c => c.target)).toEqual(['factory.ts:load']);
|
||||
fs.writeFileSync(path.join(root, 'factory.ts'), project);
|
||||
await cg!.sync();
|
||||
expect(calls('drive').map(c => c.target).sort()).toEqual(['factory.ts:Pane::split', 'factory.ts:load']);
|
||||
fs.writeFileSync(path.join(root, 'factory.ts'), primitive);
|
||||
await cg!.sync();
|
||||
expect(calls('drive').map(c => c.target)).toEqual(['factory.ts:load']);
|
||||
});
|
||||
|
||||
it('reads a multiline factory annotation and preserves ordinary typed receivers (#1840)', async () => {
|
||||
await index({ 'caller.ts': `class Engine { run() {} }
|
||||
class Decoy { run() {} }
|
||||
async function load(
|
||||
input: string
|
||||
): Promise<Engine> { return new Engine(); }
|
||||
export async function drive() { const value = await load(''); value.run(); }
|
||||
export function ordinary() { const engine = new Engine(); engine.run(); }` });
|
||||
expect(calls('drive').map(c => c.target).sort()).toEqual(['caller.ts:Engine::run', 'caller.ts:load']);
|
||||
expect(calls('ordinary').map(c => c.target)).toContain('caller.ts:Engine::run');
|
||||
});
|
||||
|
||||
it('supports newline-terminated awaited declarations without treating a chained result as the factory type (#1840)', async () => {
|
||||
await index({ 'caller.ts': `${engine}
|
||||
export async function drive() {
|
||||
const value = await makeEngine()
|
||||
value.run()
|
||||
}
|
||||
export async function chained() {
|
||||
const value = await makeEngine().toString();
|
||||
value.run();
|
||||
}` });
|
||||
expect(calls('drive').map(c => c.target)).toContain('caller.ts:Engine::run');
|
||||
expect(calls('chained').map(c => c.target)).not.toContain('caller.ts:Engine::run');
|
||||
});
|
||||
|
||||
it('does not borrow a local factory annotation through a nearer variable binding (#1840)', async () => {
|
||||
await index({ 'caller.ts': `${engine}
|
||||
export async function drive(other: () => Promise<string>) {
|
||||
const makeEngine = other;
|
||||
const value = await makeEngine();
|
||||
value.run();
|
||||
}` });
|
||||
expect(calls('drive').map(c => c.target)).not.toContain('caller.ts:Engine::run');
|
||||
});
|
||||
|
||||
it('preserves a real awaited member-factory call outside the bare-callee inference path (#1840)', async () => {
|
||||
await index({ 'caller.ts': `class Engine { run() {} }
|
||||
class Factory { async create(): Promise<Engine> { return new Engine(); } }
|
||||
export async function drive() {
|
||||
const handle = await new Factory().create();
|
||||
handle.run();
|
||||
}` });
|
||||
expect(calls('drive').map(c => c.target)).toContain('caller.ts:Engine::run');
|
||||
});
|
||||
|
||||
it('invalidates negative and positive file eligibility when caller edits add and remove await (#1840)', async () => {
|
||||
const plain = 'import { load } from "./factory";\nfunction opaque() { return null; }\nexport async function drive() { const value = opaque(); value.split(); }';
|
||||
const awaited = 'import { load } from "./factory";\nexport async function drive() { const value = await load(); value.split(); }';
|
||||
await index({
|
||||
'caller.ts': plain,
|
||||
'factory.ts': 'export class Pane { split() {} }\nexport class Other { split() {} }\nexport async function load(): Promise<Pane> { return new Pane(); }',
|
||||
});
|
||||
expect(calls('drive').filter(c => c.target.endsWith('Pane::split'))).toEqual([]);
|
||||
fs.writeFileSync(path.join(root, 'caller.ts'), awaited);
|
||||
await cg!.sync();
|
||||
expect(calls('drive').map(c => c.target)).toContain('factory.ts:Pane::split');
|
||||
fs.writeFileSync(path.join(root, 'caller.ts'), plain);
|
||||
await cg!.sync();
|
||||
expect(calls('drive').filter(c => c.target.endsWith('Pane::split'))).toEqual([]);
|
||||
});
|
||||
@@ -2430,6 +2430,59 @@ export function useProjectCache() {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a built-in string method off an unrelated project method (#1840)', async () => {
|
||||
fs.writeFileSync(path.join(tempDir, 'strings.ts'), `
|
||||
export async function listPaths(): Promise<string> { return "a\0b"; }
|
||||
export async function snapshot(): Promise<string[]> {
|
||||
const listed = await listPaths();
|
||||
return listed.split('\0');
|
||||
}
|
||||
`);
|
||||
fs.writeFileSync(path.join(tempDir, 'pane.ts'), `
|
||||
export class PaneManager {
|
||||
split(): string { return "new pane"; }
|
||||
}
|
||||
`);
|
||||
cg = await CodeGraph.init(tempDir, { index: true });
|
||||
cg.resolveReferences();
|
||||
|
||||
const caller = cg.getNodesByName('snapshot').find((n) => n.kind === 'function');
|
||||
expect(caller).toBeDefined();
|
||||
expect(
|
||||
cg.getCallees(caller!.id)
|
||||
.filter(({ edge }) => edge.kind === 'calls')
|
||||
.map(({ node }) => node.qualifiedName)
|
||||
.sort(),
|
||||
).toEqual(['listPaths']);
|
||||
});
|
||||
|
||||
it('types an awaited receiver from the callee\'s declared return (#1840)', async () => {
|
||||
fs.writeFileSync(path.join(tempDir, 'engine.ts'), `
|
||||
export class Engine {
|
||||
run(): string { return "ran"; }
|
||||
}
|
||||
export class Decoy {
|
||||
run(): string { return "decoy"; }
|
||||
}
|
||||
export async function makeEngine(): Promise<Engine> { return new Engine(); }
|
||||
export async function drive(): Promise<string> {
|
||||
const handle = await makeEngine();
|
||||
return handle.run();
|
||||
}
|
||||
`);
|
||||
cg = await CodeGraph.init(tempDir, { index: true });
|
||||
cg.resolveReferences();
|
||||
|
||||
const caller = cg.getNodesByName('drive').find((n) => n.kind === 'function');
|
||||
expect(caller).toBeDefined();
|
||||
expect(
|
||||
cg.getCallees(caller!.id)
|
||||
.filter(({ edge }) => edge.kind === 'calls')
|
||||
.map(({ node }) => node.qualifiedName)
|
||||
.sort(),
|
||||
).toEqual(['Engine::run', 'makeEngine']);
|
||||
});
|
||||
|
||||
it('keeps a validated project class that shadows Map (#1566)', async () => {
|
||||
fs.writeFileSync(path.join(tempDir, 'shadow.ts'), `
|
||||
export class Map { get() { return 1; } }
|
||||
|
||||
@@ -6,3 +6,15 @@ export const JS_BUILT_INS = new Set([
|
||||
'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval',
|
||||
'fetch', 'require', 'module', 'exports', '__dirname', '__filename',
|
||||
]);
|
||||
|
||||
/**
|
||||
* TypeScript primitive type names. Distinct from JS_BUILT_INS on purpose: those
|
||||
* are runtime globals a receiver can be constructed from, these only ever come
|
||||
* from a type annotation. A receiver typed `string` calls a built-in string
|
||||
* method — never a project method — so the resolver declines rather than
|
||||
* guessing a same-named one (#1840).
|
||||
*/
|
||||
export const TS_PRIMITIVE_TYPES = new Set([
|
||||
'string', 'number', 'boolean', 'bigint', 'symbol',
|
||||
'void', 'undefined', 'null', 'never', 'unknown', 'any', 'object',
|
||||
]);
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as path from 'path';
|
||||
import { Language, Node } from '../types';
|
||||
import { UnresolvedRef, ResolvedRef, ResolutionContext, SUPERTYPE_TARGET_KINDS, isInheritanceRef, isImportableKind } from './types';
|
||||
import { blankStringContents, stripCommentsForRegex } from './strip-comments';
|
||||
import { JS_BUILT_INS } from './js-builtins';
|
||||
import { JS_BUILT_INS, TS_PRIMITIVE_TYPES } from './js-builtins';
|
||||
|
||||
/**
|
||||
* Ceiling on how many same-named definitions a FUZZY name-match strategy will
|
||||
@@ -1659,6 +1659,19 @@ const PATTERN_MEMO_CAP = 8192;
|
||||
type InferScanState = { hi: number; ansIdx: number; ansType: string | null };
|
||||
const INFER_SCAN_STATES = new WeakMap<ResolutionContext, Map<string, InferScanState>>();
|
||||
|
||||
/** Awaited inference caches are scoped to the resolver's stable-source window.
|
||||
* Negative file eligibility avoids scanning ordinary receiver misses; call-site
|
||||
* keys distinguish shadowed bindings and sibling blocks. Both caches are bounded
|
||||
* and are invalidated with file/import caches on sync. */
|
||||
type AwaitedType = { name: string | null; filePath: string };
|
||||
type AwaitedFile = {
|
||||
code: string; ready: boolean; offsets: number[]; names: Set<string>;
|
||||
scopes: { start: number; end: number; parent: number }[];
|
||||
declarations: Map<string, { index: number; length: number }[]>;
|
||||
};
|
||||
const AWAITED_TYPE_MEMO = new WeakMap<ResolutionContext, Map<string, AwaitedType | null>>();
|
||||
const AWAITED_FILES = new WeakMap<ResolutionContext, Map<string, AwaitedFile | null>>();
|
||||
|
||||
function getInferScanStates(context: ResolutionContext): Map<string, InferScanState> {
|
||||
let m = INFER_SCAN_STATES.get(context);
|
||||
if (!m) {
|
||||
@@ -1671,6 +1684,8 @@ function getInferScanStates(context: ResolutionContext): Map<string, InferScanSt
|
||||
/** Drop the per-context scan states (see ReferenceResolver.clearCaches). */
|
||||
export function clearNameMatcherMemos(context: ResolutionContext): void {
|
||||
INFER_SCAN_STATES.delete(context);
|
||||
AWAITED_TYPE_MEMO.delete(context);
|
||||
AWAITED_FILES.delete(context);
|
||||
C_STATIC_MEMO.delete(context);
|
||||
RUST_TRAIT_IMPL_MEMO.delete(context);
|
||||
SEALED_MODULES.delete(context);
|
||||
@@ -2021,6 +2036,164 @@ function inferLocalReceiverType(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Infer only a visible awaited binding and its actual local/imported callee.
|
||||
* The signature already carries the return annotation in both extractors, so
|
||||
* multiline declarations and neighboring declarations cannot donate a type.
|
||||
* `null` means no awaited evidence; a null NAME means an awaited receiver whose
|
||||
* type is unknown, which must not fall back to an unrelated method name. */
|
||||
function inferEsmAwaitedCallType(
|
||||
receiverName: string,
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext,
|
||||
): AwaitedType | null {
|
||||
if (!/^[A-Za-z_$][\w$]*$/.test(receiverName)) return null;
|
||||
let files = AWAITED_FILES.get(context);
|
||||
if (!files) { files = new Map(); AWAITED_FILES.set(context, files); }
|
||||
let file = files.get(ref.filePath);
|
||||
if (file === undefined) {
|
||||
const source = context.readFile(ref.filePath) ?? '';
|
||||
file = null;
|
||||
// Raw eligibility is cheap; sanitize and index scopes only when a ref
|
||||
// actually uses one of these names. Comments cannot donate a binding:
|
||||
// the names are checked again after sanitizing on the first real lookup.
|
||||
const names = new Set([...source.matchAll(/\b(?:const|let|var)\s+([\w$]+)\s*=\s*await\s+[\w$]+\s*\(/g)].map(m => m[1]!));
|
||||
if (names.size) file = { code: source, ready: false, names, offsets: [], scopes: [], declarations: new Map() };
|
||||
if (files.size >= 256) files.delete(files.keys().next().value!);
|
||||
files.set(ref.filePath, file);
|
||||
}
|
||||
if (!file?.names.has(receiverName)) return null;
|
||||
if (!file.ready) {
|
||||
const code = blankStringContents(stripCommentsForRegex(file.code, 'typescript'));
|
||||
const names = new Set([...code.matchAll(/\b(?:const|let|var)\s+([\w$]+)\s*=\s*await\s+[\w$]+\s*\(/g)].map(m => m[1]!));
|
||||
const offsets = [0];
|
||||
const scopes = [{ start: -1, end: code.length, parent: -1 }];
|
||||
const stack = [0];
|
||||
for (let i = 0; i < code.length; i++) {
|
||||
if (code[i] === '\n') offsets.push(i + 1);
|
||||
if (code[i] === '{') {
|
||||
scopes.push({ start: i, end: code.length, parent: stack[stack.length - 1]! });
|
||||
stack.push(scopes.length - 1);
|
||||
} else if (code[i] === '}' && stack.length > 1) scopes[stack.pop()!]!.end = i;
|
||||
}
|
||||
const declarations = new Map<string, { index: number; length: number }[]>();
|
||||
for (const m of code.matchAll(/\b(?:const|let|var)\s+([\w$]+)\s*=\s*/g)) {
|
||||
if (!names.has(m[1]!)) continue;
|
||||
const entries = declarations.get(m[1]!) ?? [];
|
||||
entries.push({ index: m.index!, length: m[0].length });
|
||||
declarations.set(m[1]!, entries);
|
||||
}
|
||||
Object.assign(file, { code, ready: true, names, offsets, scopes, declarations });
|
||||
if (!names.has(receiverName)) return null;
|
||||
}
|
||||
let memo = AWAITED_TYPE_MEMO.get(context);
|
||||
if (!memo) { memo = new Map(); AWAITED_TYPE_MEMO.set(context, memo); }
|
||||
const key = `${ref.filePath}|${ref.line}|${ref.column}|${receiverName}`;
|
||||
if (memo.has(key)) return memo.get(key)!;
|
||||
const result = resolveAwaitedCallType(receiverName, file, ref, context);
|
||||
if (memo.size >= PATTERN_MEMO_CAP) memo.delete(memo.keys().next().value!);
|
||||
memo.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveAwaitedCallType(
|
||||
receiverName: string,
|
||||
file: AwaitedFile,
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext,
|
||||
): AwaitedType | null {
|
||||
const unknown: AwaitedType = { name: null, filePath: ref.filePath };
|
||||
const end = (file.offsets[ref.line - 1] ?? file.code.length) + ref.column;
|
||||
const code = file.code.slice(0, end);
|
||||
const escaped = receiverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
// Locate scopes in the precomputed brace tree. Rescanning the entire file
|
||||
// for every candidate binding made large test files quadratic in refs.
|
||||
const scopeAt = (offset: number): number => {
|
||||
let lo = 0, hi = file.scopes.length;
|
||||
while (lo + 1 < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (file.scopes[mid]!.start < offset) lo = mid; else hi = mid;
|
||||
}
|
||||
while (lo > 0 && file.scopes[lo]!.end < offset) lo = file.scopes[lo]!.parent;
|
||||
return lo;
|
||||
};
|
||||
const visibleAt = (declaration: number, use: number): boolean => {
|
||||
const ancestor = scopeAt(declaration);
|
||||
for (let scope = scopeAt(use); scope >= 0; scope = file.scopes[scope]!.parent) if (scope === ancestor) return true;
|
||||
return false;
|
||||
};
|
||||
const binding = [...(file.declarations.get(receiverName) ?? [])].reverse()
|
||||
.find(m => m.index < end && visibleAt(m.index, end));
|
||||
if (!binding) return null;
|
||||
const init = code.slice(binding.index + binding.length);
|
||||
if (!/^await\b/.test(init)) return null;
|
||||
// Only a bare call result, not a following member/index/conditional expression.
|
||||
const call = /^await\s+([A-Za-z_$][\w$]*)\s*\(/.exec(init);
|
||||
if (!call) return null;
|
||||
let depth = 1, callEnd = call[0].length;
|
||||
for (; callEnd < init.length && depth; callEnd++) {
|
||||
if (init[callEnd] === '(') depth++;
|
||||
else if (init[callEnd] === ')') depth--;
|
||||
}
|
||||
if (depth) return unknown;
|
||||
const tail = init.slice(callEnd);
|
||||
// A following property/index/call is not the callee's annotated value.
|
||||
if (!/^[ \t]*(?:;|\r?\n(?![ \t]*[.(\[?]))/.test(tail)) return unknown;
|
||||
const rest = tail;
|
||||
if (new RegExp(`\\b(?:const|let|var|function|class)\\s+(?:${escaped}\\b|\\{[^}]*\\b${escaped}\\b)`).test(rest) ||
|
||||
new RegExp(`\\b${escaped}\\s*=(?!=)`).test(rest) || hasParameterBinding(rest, escaped)) return unknown;
|
||||
|
||||
const bindingLine = file.code.slice(0, binding.index!).split('\n').length;
|
||||
const bindingRef = { ...ref, line: bindingLine, column: binding.index! - file.offsets[bindingLine - 1]! };
|
||||
const callee = call[1]!;
|
||||
const calleeEscaped = callee.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
if (context.getNodesInFile(ref.filePath).some(n =>
|
||||
(n.kind === 'function' || n.kind === 'method') && n.startLine <= bindingLine && n.endLine >= bindingLine &&
|
||||
n.signature && hasParameterBinding(`${n.signature} {`, calleeEscaped))) return unknown;
|
||||
|
||||
const imported = context.getImportMappings(ref.filePath, ref.language).some(m => m.localName === callee);
|
||||
let declaring: Node | undefined;
|
||||
if (imported) {
|
||||
if (importShadowedAt(callee, bindingRef, context)) return unknown;
|
||||
const resolved = context.resolveImport?.({ ...bindingRef, referenceName: callee, referenceKind: 'calls' });
|
||||
declaring = resolved ? context.getNodeById?.(resolved.targetNodeId) ?? undefined : undefined;
|
||||
} else {
|
||||
const local = context.getNodesByName(callee).filter(n => n.kind === 'function' &&
|
||||
n.filePath === ref.filePath && ESM_FAMILY.has(n.language) && isLexicallyReachable(n, bindingRef, context));
|
||||
if (local.length === 1) declaring = local[0];
|
||||
}
|
||||
if (!declaring || declaring.kind !== 'function' || !declaring.signature) return unknown;
|
||||
if (!imported) {
|
||||
const beforeBinding = code.slice(0, binding.index!);
|
||||
const shadows = new RegExp(`\\b(?:const|let|var)\\s+${calleeEscaped}\\b`, 'g');
|
||||
for (const shadow of beforeBinding.matchAll(shadows)) {
|
||||
if (!visibleAt(shadow.index!, binding.index)) continue;
|
||||
// A typed arrow function may itself be the declared local factory.
|
||||
const line = file.code.slice(0, shadow.index!).split('\n').length;
|
||||
if (line !== declaring.startLine || shadow.index! - file.offsets[line - 1]! > declaring.startColumn) return unknown;
|
||||
}
|
||||
}
|
||||
const signature = declaring.signature;
|
||||
const annotation = signature.slice(signature.lastIndexOf(')') + 1).match(/^\s*:\s*([\s\S]+)$/)?.[1]?.trim();
|
||||
if (!annotation) return unknown;
|
||||
// Do not turn unions, arrays, object/function types, or conditional types into
|
||||
// a project class. Await recursively unwraps promises, but this narrow path
|
||||
// accepts a single named Promise<T> layer only.
|
||||
const returned = annotation.match(/^Promise\s*<\s*([\w$]+)\s*>$/)?.[1] ?? annotation;
|
||||
if (!/^[A-Za-z_$][\w$]*$/.test(returned)) return unknown;
|
||||
if (TS_PRIMITIVE_TYPES.has(returned)) return { name: returned, filePath: declaring.filePath };
|
||||
|
||||
const typeRef = { ...bindingRef, fromNodeId: declaring.id, filePath: declaring.filePath,
|
||||
language: declaring.language, line: declaring.startLine, column: declaring.startColumn,
|
||||
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) :
|
||||
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;
|
||||
return { name: typeNode.name, filePath: typeNode.filePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Patterns that recover a PHP class property's declared type for a
|
||||
* `$this->prop` receiver. Deliberately NOT localReceiverTypePatterns: only
|
||||
@@ -2188,10 +2361,16 @@ export function matchMethodCall(
|
||||
// shared source-based inferrer. resolveMethodOnType validates the method
|
||||
// exists on the inferred type, so a mis-inference produces no edge.
|
||||
if (inferableReceiver) {
|
||||
const inferredType = nmTimedT('mc-infer', ref, () =>
|
||||
let inferredType = nmTimedT('mc-infer', ref, () =>
|
||||
ref.language === 'cpp'
|
||||
? inferCppReceiverType(objectOrClass!, ref, context)
|
||||
: inferLocalReceiverType(objectOrClass!, ref, context));
|
||||
const awaited = !inferredType && ESM_FAMILY.has(ref.language)
|
||||
? inferEsmAwaitedCallType(objectOrClass!, ref, context) : null;
|
||||
if (awaited) {
|
||||
if (!awaited.name || TS_PRIMITIVE_TYPES.has(awaited.name)) return null;
|
||||
inferredType = awaited.name;
|
||||
}
|
||||
if (inferredType) {
|
||||
// Java/Kotlin: when two classes share the simple name, the file's import
|
||||
// pins WHICH one (#314). Other languages disambiguate by call-site file.
|
||||
@@ -2204,20 +2383,32 @@ export function matchMethodCall(
|
||||
const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType(
|
||||
inferredType,
|
||||
methodName!,
|
||||
ref,
|
||||
awaited ? { ...ref, filePath: awaited.filePath } : ref,
|
||||
context,
|
||||
0.9,
|
||||
'instance-method',
|
||||
importedFqn,
|
||||
));
|
||||
if (typedMatch) {
|
||||
if (awaited) {
|
||||
const target = context.getNodeById?.(typedMatch.targetNodeId);
|
||||
if (!target || (target.qualifiedName.startsWith(`${inferredType}::`) && target.filePath !== awaited.filePath)) return null;
|
||||
return { ...typedMatch, original: ref };
|
||||
}
|
||||
return typedMatch;
|
||||
}
|
||||
if (awaited) return null;
|
||||
// A known JS/TS builtin receiver is external when it has no project
|
||||
// method (#1566). Inference already strips generics (`Map<K, V>` →
|
||||
// `Map`); do not let Strategy 3 guess an unrelated `get`/`set`/`has`.
|
||||
// Keep the validated match above for a project type shadowing a builtin.
|
||||
if (ESM_FAMILY.has(ref.language) && JS_BUILT_INS.has(inferredType)) {
|
||||
// A primitive receiver joins the builtins here: `listed.split()` on a
|
||||
// `string` is the built-in method, and Strategy 3 would otherwise hand
|
||||
// it whichever project class happens to declare a lone `split` (#1840).
|
||||
if (
|
||||
ESM_FAMILY.has(ref.language) &&
|
||||
(JS_BUILT_INS.has(inferredType) || TS_PRIMITIVE_TYPES.has(inferredType))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user