mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 22:02:49 +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:
@@ -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