mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +08:00
* fix(resolution): resolve same-file calls through object-literal aliases (#1932) `api.getUser()` where `api` is `const api = { getUser }` (or `{ getUser: fetchUser }`) and the function is declared outside the literal resolved to nothing in the literal's own file: the #1573 containment lookup only finds members defined inside the literal, and the binding-following alias path existed only on the import side, so same-file callers were missing from callers / impact / blast radius. - Extract the member-binding read into `objectLiteralMemberBinding`, which splits the literal into its own top-level members on comment- and string-blanked source, so a nested object, a member body, a comment or a string never donates a binding, and `{ fn: 1 }` names nothing. The import path now uses it too. - In the same-file object-literal receiver strategy, fall back to that binding: a symbol of the file, else one of its imports, skipped when a parameter or nested declaration shadows the name where the literal is written. Resolution-only change; extraction output (and kernel parity) unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(resolution): a literal wrapped in Object.freeze still names its members (#1932) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(resolution): resolve object-literal function references safely (#1932) Same-file object-member calls only found functions defined inside the literal. Share lexical binding resolution across same-file, import, and alias-forwarding paths. Respect exact source columns, top-level membership, shadowing, and property overwrites while preserving wrappers and inline members. Add JavaScript and TypeScript regressions and verify kernel/WASM parity. Co-authored-by: danusha2345 <ewidusoc498@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: danusha2345 <ewidusoc498@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,14 +31,14 @@ describe('calls through an alias binding reach the aliased symbol', () => {
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
fs.writeFileSync(path.join(dir, name), content);
|
||||
}
|
||||
cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } });
|
||||
cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts', '**/*.js'], exclude: [] } });
|
||||
await cg.indexAll();
|
||||
};
|
||||
|
||||
const callersOf = (name: string): string[] => {
|
||||
const target = cg.getNodesByKind('function').find((n) => n.name === name);
|
||||
expect(target, `fixture symbol ${name} was not indexed`).toBeDefined();
|
||||
return cg.getCallers(target!.id).map((c) => c.node.name);
|
||||
return cg.getCallers(target!.id).filter(c => c.edge.kind === 'calls').map((c) => c.node.name);
|
||||
};
|
||||
|
||||
it('follows `export const alias = fn`', async () => {
|
||||
@@ -67,6 +67,88 @@ describe('calls through an alias binding reach the aliased symbol', () => {
|
||||
expect(callersOf('realImpl')).toContain('consumerFn');
|
||||
});
|
||||
|
||||
describe.each(['ts', 'js'])('object member boundaries (%s)', (ext) => {
|
||||
it.each([
|
||||
['sibling literals', 'const first = { wrong }; export const api = { run: right };', 'run', true],
|
||||
['unicode before literal', "const label = 'é🙂'; export const api = { run: right };", 'run', true],
|
||||
['absent sibling member', 'const first = { wrong }; export const api = { right };', 'wrong', false],
|
||||
['duplicate key', 'export const api = { run: wrong, run: right };', 'run', true],
|
||||
['non-callable overwrite', 'export const api = { wrong, wrong: 0 };', 'wrong', false],
|
||||
['nested member', 'export const api = { box: { wrong }, method() { return { wrong }; } };', 'wrong', false],
|
||||
['unknown spread', 'export const api = { wrong, ...unknown };', 'wrong', false],
|
||||
['explicit after spread', 'export const api = { ...unknown, run: right };', 'run', true],
|
||||
['quoted overwrite', "export const api = { wrong, 'wrong': 0 };", 'wrong', false],
|
||||
['computed overwrite', 'export const api = { wrong, [unknown]: 0 };', 'wrong', false],
|
||||
['frozen literal', 'export const api = Object.freeze({ run: right });', 'run', true],
|
||||
['parenthesized literal', 'export const api = ({ run: right });', 'run', true],
|
||||
['inline overwrite', 'export const api = { run() { return 0; }, run: right };', 'run', true],
|
||||
['nested inline', 'export const api = { box: { wrong() { return 0; } } };', 'wrong', false],
|
||||
])('%s', async (_name, declaration, member, resolves) => {
|
||||
await index({
|
||||
[`impl.${ext}`]: `function wrong() { return 1; }
|
||||
function right() { return 2; }
|
||||
${declaration}
|
||||
export function sameCaller() { return api.${member}(); }
|
||||
`,
|
||||
[`consumer.${ext}`]: `import { api } from './impl';
|
||||
export function crossCaller() { return api.${member}(); }
|
||||
`,
|
||||
});
|
||||
for (const caller of ['sameCaller', 'crossCaller']) {
|
||||
expect(callersOf('wrong')).not.toContain(caller);
|
||||
if (resolves) expect(callersOf('right')).toContain(caller);
|
||||
else expect(callersOf('right')).not.toContain(caller);
|
||||
}
|
||||
const inline = cg.getNodesByKind('function').filter(n => n.name === member && n.name !== 'right' && n.name !== 'wrong');
|
||||
for (const node of inline) {
|
||||
expect(cg.getCallers(node.id).map(c => c.node.name)).not.toContain('sameCaller');
|
||||
expect(cg.getCallers(node.id).map(c => c.node.name)).not.toContain('crossCaller');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('does not cross parameter or value shadows', async () => {
|
||||
await index({
|
||||
'impl.ts': `function target() { return 0; }
|
||||
export function parameter(target: () => number) {
|
||||
const api = { target };
|
||||
return api.target();
|
||||
}
|
||||
export function value() {
|
||||
const target = 0;
|
||||
const api = { target };
|
||||
return api.target();
|
||||
}
|
||||
export function later() {
|
||||
const api = { target };
|
||||
const target = 0;
|
||||
return api.target();
|
||||
}
|
||||
`,
|
||||
});
|
||||
const targets = cg.getNodesByKind('function').filter(n => n.name === 'target').sort((a, b) => a.startLine - b.startLine);
|
||||
expect(targets).toHaveLength(1);
|
||||
const outer = cg.getCallers(targets[0]!.id).filter(c => c.edge.kind === 'calls').map(c => c.node.name);
|
||||
for (const name of ['parameter', 'value', 'later']) expect(outer).not.toContain(name);
|
||||
});
|
||||
|
||||
it('resolves renamed imports at the literal and ignores unrelated nested bindings', async () => {
|
||||
await index({
|
||||
'target.ts': 'export function actual() { return 1; }',
|
||||
'impl.ts': `import { actual as renamed } from './target';
|
||||
function unrelated() { function renamed() { return 0; } return renamed(); }
|
||||
export const api = { run: renamed };
|
||||
export function sameCaller() { return api.run(); }
|
||||
`,
|
||||
'consumer.ts': `import { api as facade } from './impl';
|
||||
export function crossCaller() { return facade.run(); }
|
||||
`,
|
||||
});
|
||||
expect(callersOf('actual')).toEqual(expect.arrayContaining(['sameCaller', 'crossCaller']));
|
||||
expect(callersOf('renamed')).not.toContain('sameCaller');
|
||||
expect(callersOf('renamed')).not.toContain('crossCaller');
|
||||
});
|
||||
|
||||
it('follows a same-file alias binding', async () => {
|
||||
await index({
|
||||
'impl.ts':
|
||||
|
||||
@@ -3576,6 +3576,119 @@ export function remoteUse() { return obj.m(); }
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('Object-literal members that alias an outer function (#1932)', () => {
|
||||
// `export const api = { getUser }` / `{ getUser: getUser }` — the API-module
|
||||
// shape. The member's function is declared OUTSIDE the literal, so the
|
||||
// containment lookup of #1573 found nothing and `api.getUser()` in the
|
||||
// literal's own file resolved to no function at all.
|
||||
const callersOf = (cg: CodeGraph, name: string, filePath: string): string[] => {
|
||||
const target = cg
|
||||
.getNodesInFile(filePath)
|
||||
.find((n) => n.name === name && (n.kind === 'function' || n.kind === 'method'));
|
||||
expect(target).toBeDefined();
|
||||
return cg
|
||||
.getIncomingEdges(target!.id)
|
||||
.filter((e) => e.kind === 'calls')
|
||||
.map((e) => cg.getNode(e.source)!.name)
|
||||
.sort();
|
||||
};
|
||||
|
||||
it('resolves same-file and imported calls through shorthand and identifier-valued members', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1932-'));
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'a.ts'),
|
||||
`import { imported } from './d';
|
||||
const viaArrow = async () => 1;
|
||||
function viaDecl() { return 2; }
|
||||
const longForm = async () => 3;
|
||||
function renamed() { return 4; }
|
||||
|
||||
export const api = {
|
||||
inline() { return 0; },
|
||||
viaArrow,
|
||||
viaDecl,
|
||||
longForm: longForm,
|
||||
alias: renamed,
|
||||
imported,
|
||||
};
|
||||
|
||||
export function sameFileCaller() {
|
||||
return [api.inline(), api.viaArrow(), api.viaDecl(), api.longForm(), api.alias(), api.imported()];
|
||||
}
|
||||
`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'b.ts'),
|
||||
`import { api } from './a';
|
||||
export function crossFileCaller() {
|
||||
return [api.viaArrow(), api.viaDecl(), api.longForm(), api.alias()];
|
||||
}
|
||||
`
|
||||
);
|
||||
fs.writeFileSync(path.join(tmpDir, 'd.ts'), `export function imported() { return 5; }\n`);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'e.ts'),
|
||||
`function frozenFn() { return 6; }
|
||||
export const frozen = Object.freeze({ frozenFn });
|
||||
`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'f.ts'),
|
||||
`import { frozen } from './e';
|
||||
export function frozenCaller() { return frozen.frozenFn(); }
|
||||
`
|
||||
);
|
||||
try {
|
||||
const cg = CodeGraph.initSync(tmpDir);
|
||||
await cg.indexAll();
|
||||
// A literal handed straight to a wrapper still names its members.
|
||||
expect(callersOf(cg, 'frozenFn', 'e.ts')).toEqual(['frozenCaller']);
|
||||
|
||||
expect(callersOf(cg, 'inline', 'a.ts')).toEqual(['sameFileCaller']);
|
||||
for (const fn of ['viaArrow', 'viaDecl', 'longForm', 'renamed']) {
|
||||
expect(callersOf(cg, fn, 'a.ts')).toEqual(['crossFileCaller', 'sameFileCaller']);
|
||||
}
|
||||
expect(callersOf(cg, 'imported', 'd.ts')).toEqual(['sameFileCaller']);
|
||||
cg.close();
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it('never follows a property key, a nested object, or a shadowed binding', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1932-'));
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'c.ts'),
|
||||
`function keyOnly() { return 1; }
|
||||
function nested() { return 2; }
|
||||
function shadowed() { return 3; }
|
||||
|
||||
export const other = { keyOnly: 1, box: { nested } };
|
||||
|
||||
export function useOther() {
|
||||
return [other.keyOnly(), other.nested()];
|
||||
}
|
||||
|
||||
export function makeApi(shadowed: () => number) {
|
||||
const local = { shadowed };
|
||||
return local.shadowed();
|
||||
}
|
||||
`
|
||||
);
|
||||
try {
|
||||
const cg = CodeGraph.initSync(tmpDir);
|
||||
await cg.indexAll();
|
||||
|
||||
expect(callersOf(cg, 'keyOnly', 'c.ts')).toEqual([]);
|
||||
expect(callersOf(cg, 'nested', 'c.ts')).toEqual([]);
|
||||
expect(callersOf(cg, 'shadowed', 'c.ts')).toEqual([]);
|
||||
cg.close();
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('C++ namespace-qualified static method calls to out-of-line definitions (#1291)', () => {
|
||||
// The issue's exact shape: nested types + out-of-line static method
|
||||
// definition inside `namespace simulator { }` in the .cpp, called via the
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
import type { Node } from '../types';
|
||||
import type { ResolutionContext } from './types';
|
||||
import { resolveObjectLiteralBinding } from './name-matcher';
|
||||
|
||||
/** Kinds that can be a pure alias for another symbol. */
|
||||
const ALIAS_BINDING_KINDS = new Set<string>(['constant', 'variable', 'property']);
|
||||
@@ -79,6 +80,15 @@ export function resolveAliasBinding(
|
||||
): Node | null {
|
||||
if (!ALIAS_BINDING_KINDS.has(aliasNode.kind)) return null;
|
||||
|
||||
if (memberName && ['typescript', 'tsx', 'javascript', 'jsx', 'arkts'].includes(aliasNode.language)) {
|
||||
const resolved = resolveObjectLiteralBinding(aliasNode, memberName, {
|
||||
fromNodeId: aliasNode.id, referenceName: memberName, referenceKind: 'calls',
|
||||
filePath: aliasNode.filePath, language: aliasNode.language,
|
||||
line: aliasNode.startLine, column: aliasNode.startColumn,
|
||||
}, context);
|
||||
return resolved ? context.getNodeById?.(resolved.targetNodeId) ?? null : null;
|
||||
}
|
||||
|
||||
const targetName = aliasTargetName(aliasNode.signature, memberName);
|
||||
if (!targetName || targetName === aliasNode.name) return null;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { resolveWorkspaceImport } from './workspace-packages';
|
||||
import {
|
||||
resolveMethodOnType,
|
||||
resolveObjectLiteralMember,
|
||||
resolveObjectLiteralBinding,
|
||||
localReceiverTypePatterns,
|
||||
normalizeInferredTypeName,
|
||||
} from './name-matcher';
|
||||
@@ -1891,54 +1892,8 @@ function resolveObjectLiteralAlias(
|
||||
): ResolvedRef | null {
|
||||
if (container.kind !== 'constant' && container.kind !== 'variable') return null;
|
||||
if (!JS_FAMILY_FILE.test(container.filePath)) return null;
|
||||
if (!/^[A-Za-z_$][\w$]*$/.test(member)) return null;
|
||||
const lines = context.getFileLines?.(container.filePath) ?? context.readFile(container.filePath)?.split('\n');
|
||||
if (!lines) return null;
|
||||
const extent = lines.slice(container.startLine - 1, container.endLine).join('\n');
|
||||
const brace = extent.indexOf('{');
|
||||
if (brace < 0) return null;
|
||||
const body = extent.slice(brace);
|
||||
const keyed = new RegExp(`[{,\\s]${member}\\s*:\\s*([A-Za-z_$][\\w$]*)\\s*[,}]`);
|
||||
const shorthand = new RegExp(`[{,\\s]${member}\\s*[,}]`);
|
||||
const k = body.match(keyed);
|
||||
const binding = k ? k[1]! : shorthand.test(body) ? member : null;
|
||||
if (binding === null) return null;
|
||||
|
||||
const callable = (n: Node) => n.kind === 'function' || n.kind === 'method' || n.kind === 'class';
|
||||
const accepts =
|
||||
ref.referenceKind === 'calls'
|
||||
? callable
|
||||
: (n: Node) => callable(n) || n.kind === 'constant' || n.kind === 'variable' || n.kind === 'component';
|
||||
|
||||
// Declared in the object's own file, outside the literal.
|
||||
const local = context
|
||||
.getNodesInFile(container.filePath)
|
||||
.filter((n) => n.name === binding && n.id !== container.id && accepts(n))
|
||||
.sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0];
|
||||
if (local) return { original: ref, targetNodeId: local.id, confidence: 0.9, resolvedBy: 'import' };
|
||||
|
||||
// Imported into the object's file.
|
||||
for (const imp of context.getImportMappings(container.filePath, container.language)) {
|
||||
if (imp.localName !== binding || imp.isNamespace) continue;
|
||||
const resolvedPath = resolveImportPath(imp.source, container.filePath, container.language, context);
|
||||
if (!resolvedPath) continue;
|
||||
const target = findExportedSymbol(
|
||||
resolvedPath,
|
||||
{
|
||||
isDefault: imp.isDefault,
|
||||
isNamespace: false,
|
||||
exportedName: imp.isDefault ? 'default' : imp.exportedName,
|
||||
memberName: null,
|
||||
},
|
||||
container.language,
|
||||
context,
|
||||
new Set()
|
||||
);
|
||||
if (target && accepts(target)) {
|
||||
return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
const resolved = resolveObjectLiteralBinding(container, member, ref, context);
|
||||
return resolved ? { ...resolved, confidence: 0.9, resolvedBy: 'import' } : null;
|
||||
}
|
||||
|
||||
function resolveModuleImportToFile(
|
||||
|
||||
@@ -1020,7 +1020,9 @@ export function resolveObjectLiteralMember(
|
||||
const accepts = ref.referenceKind === 'calls' ? callable : valueMember;
|
||||
|
||||
const inside = inFile.filter((n) => n.id !== container.id && rangeWithin(n, container));
|
||||
let candidates = inside.filter((n) => n.name === member && accepts(n));
|
||||
const property = objectLiteralProperty(container, member, context);
|
||||
if (property === null || property?.binding) return null;
|
||||
let candidates = inside.filter((n) => n.name === member && accepts(n) && (!property || property.contains(n)));
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// Drop a candidate nested inside ANOTHER callable's body within the literal
|
||||
@@ -1049,6 +1051,147 @@ export function resolveObjectLiteralMember(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The binding an object-literal member names when it is a shorthand property
|
||||
* (`{ getUser }`) or a pair whose value is a bare identifier (`{ getUser:
|
||||
* fetchUser }`) — the usual way an API module assembles its namespace from
|
||||
* standalone functions (#1932). Only the literal's own members count: a member
|
||||
* of a nested object, a word inside a member's body, a comment or a string
|
||||
* never donates one. Null when the member is absent, or when its value is not
|
||||
* a bare identifier (`{ fn: 1 }` names nothing).
|
||||
*/
|
||||
export function objectLiteralMemberBinding(
|
||||
container: Node,
|
||||
member: string,
|
||||
context: ResolutionContext,
|
||||
): string | null {
|
||||
return objectLiteralProperty(container, member, context)?.binding ?? null;
|
||||
}
|
||||
|
||||
/** The last own property wins; an unknown spread/computed key invalidates earlier evidence. */
|
||||
function objectLiteralProperty(
|
||||
container: Node,
|
||||
member: string,
|
||||
context: ResolutionContext,
|
||||
): { binding: string | null; contains: (node: Node) => boolean } | null | undefined {
|
||||
const lines = context.getFileLines?.(container.filePath) ?? context.readFile(container.filePath)?.split('\n');
|
||||
if (!lines) return undefined;
|
||||
const extentLines = lines.slice(container.startLine - 1, container.endLine);
|
||||
if (!extentLines.length) return undefined;
|
||||
extentLines[extentLines.length - 1] = extentLines[extentLines.length - 1]!.slice(0, container.endColumn);
|
||||
extentLines[0] = extentLines[0]!.slice(container.startColumn);
|
||||
const extent = stripCommentsForRegex(extentLines.join('\n'), 'typescript');
|
||||
const code = blankStringContents(extent);
|
||||
// Start at THIS declarator, including its columns, never a sibling on the same line.
|
||||
const open = /^[^=]*=\s*(?:(?:Object\.(?:freeze|seal)\s*)?\(\s*)*\{/.exec(code);
|
||||
if (!open) return undefined;
|
||||
|
||||
const members: Array<{ start: number; end: number }> = [];
|
||||
let depth = 0;
|
||||
let start = open[0].length;
|
||||
for (let i = start; i < code.length; i++) {
|
||||
const ch = code[i];
|
||||
if (ch === '{' || ch === '(' || ch === '[') depth++;
|
||||
else if (ch === ')' || ch === ']') depth--;
|
||||
else if (ch === '}') {
|
||||
if (depth === 0) {
|
||||
members.push({ start, end: i });
|
||||
break;
|
||||
}
|
||||
depth--;
|
||||
} else if (ch === ',' && depth === 0) {
|
||||
members.push({ start, end: i });
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
let selected: { start: number; end: number; binding: string | null } | null = null;
|
||||
for (const part of members) {
|
||||
const text = extent.slice(part.start, part.end).trim();
|
||||
if (/^(?:\.\.\.|\[)/.test(text)) { selected = null; continue; }
|
||||
const key = /^(?:(?:async|get|set)\s+)?\*?\s*(?:([A-Za-z_$][\w$]*)|['"]([^'"\\]*)['"])(?=\s*(?:[:(<,=]|$))/.exec(text);
|
||||
if ((key?.[1] ?? key?.[2]) !== member) continue;
|
||||
const value = text.slice(key![0].length).trim();
|
||||
const binding = value === '' ? member : /^:\s*([A-Za-z_$][\w$]*)$/.exec(value)?.[1] ?? null;
|
||||
selected = { ...part, binding };
|
||||
}
|
||||
if (!selected) return null;
|
||||
const offset = (node: Node): number => {
|
||||
let result = node.startColumn - container.startColumn;
|
||||
for (let line = container.startLine; line < node.startLine; line++) result += lines[line - 1]!.length + 1;
|
||||
return result;
|
||||
};
|
||||
const property = selected;
|
||||
return { binding: property.binding, contains: (node) => offset(node) >= property.start && offset(node) < property.end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared lexical lookup for namespace-object aliases (#1932): `api.getUser()` where
|
||||
* `api` is `const api = { getUser }` (or `{ getUser: fetchUser }`) in the
|
||||
* object's own file. The member's function is declared OUTSIDE the literal, so
|
||||
* containment (`resolveObjectLiteralMember`) finds nothing. Follow the binding
|
||||
* the member names — a symbol of this file, else one of its imports — unless a
|
||||
* parameter or nearer declaration shadows that name where the literal is
|
||||
* written (the edge would then name the wrong function).
|
||||
*/
|
||||
export function resolveObjectLiteralBinding(
|
||||
container: Node,
|
||||
member: string,
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext,
|
||||
): ResolvedRef | null {
|
||||
const binding = objectLiteralMemberBinding(container, member, context);
|
||||
if (!binding) return null;
|
||||
const at: UnresolvedRef = { ...ref, filePath: container.filePath, language: container.language,
|
||||
fromNodeId: container.id, line: container.startLine, column: container.startColumn };
|
||||
const inFile = context.getNodesInFile(container.filePath);
|
||||
if (inFile.some((n) => (n.kind === 'function' || n.kind === 'method') &&
|
||||
rangeWithin(container, n) && n.signature &&
|
||||
hasParameterBinding(`${n.signature} {`, binding.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))) return null;
|
||||
|
||||
const lines = context.getFileLines?.(container.filePath) ?? context.readFile(container.filePath)?.split('\n');
|
||||
if (!lines) return null;
|
||||
const code = blankStringContents(stripCommentsForRegex(lines.join('\n'), 'typescript'));
|
||||
const offsets = [0];
|
||||
for (let i = 0; i < code.length; i++) if (code[i] === '\n') offsets.push(i + 1);
|
||||
const scopeAt = (node: Node): number[] => {
|
||||
const end = (offsets[node.startLine - 1] ?? code.length) + node.startColumn;
|
||||
const scope: number[] = [];
|
||||
for (let i = 0; i < end; i++) {
|
||||
if (code[i] === '{') scope.push(i);
|
||||
else if (code[i] === '}') scope.pop();
|
||||
}
|
||||
return scope;
|
||||
};
|
||||
const scope = scopeAt(container);
|
||||
|
||||
const callable = (n: Node) => n.kind === 'function' || n.kind === 'method' || n.kind === 'class';
|
||||
const accepts =
|
||||
ref.referenceKind === 'calls'
|
||||
? callable
|
||||
: (n: Node) => callable(n) || n.kind === 'constant' || n.kind === 'variable' || n.kind === 'component';
|
||||
|
||||
const locals = inFile
|
||||
.filter((n) => n.name === binding && n.id !== container.id &&
|
||||
['function', 'class', 'constant', 'variable', 'component'].includes(n.kind))
|
||||
.map((node) => ({ node, scope: scopeAt(node) }))
|
||||
.filter((entry) => entry.scope.every((position, i) => scope[i] === position))
|
||||
.sort((a, b) => b.scope.length - a.scope.length);
|
||||
// Select the lexical binding BEFORE checking callability: a nearer value
|
||||
// shadows an outer function even if that value cannot be called.
|
||||
const local = locals[0]?.node;
|
||||
if (local) return accepts(local)
|
||||
? { original: ref, targetNodeId: local.id, confidence: 0.85, resolvedBy: 'instance-method' }
|
||||
: null;
|
||||
|
||||
const imported = context.resolveImport?.({ ...at, referenceName: binding });
|
||||
const target = imported ? context.getNodeById?.(imported.targetNodeId) : null;
|
||||
if (target && accepts(target)) {
|
||||
return { original: ref, targetNodeId: target.id, confidence: 0.85, resolvedBy: 'instance-method' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Exported for the precedence unit tests (#1079): they assert the
|
||||
// preferredFqn → same-file → matches[0] ordering directly.
|
||||
export function resolveMethodOnType(
|
||||
@@ -2535,7 +2678,9 @@ export function matchMethodCall(
|
||||
(n) => (n.kind === 'constant' || n.kind === 'variable') && n.filePath === ref.filePath
|
||||
);
|
||||
for (const holder of holders) {
|
||||
const hit = resolveObjectLiteralMember(holder, methodName!, ref, context, 0.85, 'instance-method');
|
||||
const hit =
|
||||
resolveObjectLiteralMember(holder, methodName!, ref, context, 0.85, 'instance-method') ??
|
||||
resolveObjectLiteralBinding(holder, methodName!, ref, context);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user