diff --git a/__tests__/alias-binding-resolution.test.ts b/__tests__/alias-binding-resolution.test.ts index b7105ce42..e099f3a79 100644 --- a/__tests__/alias-binding-resolution.test.ts +++ b/__tests__/alias-binding-resolution.test.ts @@ -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': diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index ba055293f..13cea5e16 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.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 diff --git a/src/resolution/alias-binding.ts b/src/resolution/alias-binding.ts index 408f9ba73..4b2ec2380 100644 --- a/src/resolution/alias-binding.ts +++ b/src/resolution/alias-binding.ts @@ -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(['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; diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index cf1740f9a..8c3038dca 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -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( diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 95b2a552d..e9a08fed4 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -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;