fix(resolution): resolve object-literal function references safely (#1932) (#2031)

* 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:
Colby Mchenry
2026-09-27 13:39:18 +00:00
committed by GitHub
parent 86708bf519
commit 17d09bf506
5 changed files with 357 additions and 52 deletions
+84 -2
View File
@@ -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':
+113
View File
@@ -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
+10
View File
@@ -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;
+3 -48
View File
@@ -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(
+147 -2
View File
@@ -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;