diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b2d07fb3..186820ca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Files skipped because they are too large or repeatedly fail to parse are now recorded with the reason, so unchanged rejected files are no longer rediscovered and retried on every status check and sync — and a later successful parse of such a file replaces the record with its real symbols. Thanks @netbrah for the exceptional failure analysis behind this batch, and @danusha2345 for the fixes. (#1557) - C/C++ function-pointer analysis now bounds its compiled-pattern caches, so very large repositories can no longer exhaust the JavaScript engine's regular-expression code space during indexing. (#1559) - JSX rendering analysis now runs only on JavaScript-family files, so JSX-looking strings in C/C++ (or any other language) no longer create impossible call edges — in pure-C projects and in mixed-language monorepos alike. (#1560) +- Calls to the methods of an exported object-literal constant — `export const api = { call() { … } }` used as a module's namespace, a common way to organize a TypeScript API surface — now resolve to the method, both in the defining file and through imports. Previously such a call linked to nothing (or to the constant itself), so `codegraph callers` and impact analysis reported zero callers for methods that are called from everywhere. Re-index after upgrading to pick up the edges. Thanks @IAliceBobI for the precise report and root-cause. (#1573) ## [1.5.0] - 2026-07-21 diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 637b4a9d0..73b9e4d89 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -2913,6 +2913,131 @@ export function callFromImportedFile(): void { }, 30000); }); + describe('Object-literal namespace members (#1573)', () => { + // `export const api = { call() {…}, get: () => {…} }` used as the module's + // API surface: the members are plain functions with bare names inside the + // constant's extent, so `api.call()` resolved to nothing in the defining + // file and to the CONSTANT through an import — zero callers everywhere. + const setup = (files: Record) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1573-')); + for (const [name, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(tmpDir, name)), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, name), content); + } + return tmpDir; + }; + const callersOf = async (cg: CodeGraph, name: string, kind: string, filePath?: string) => { + const target = (await cg.searchNodes(name, { limit: 20 })).find( + (r) => r.node.kind === kind && r.node.name === name && (!filePath || r.node.filePath === filePath) + ); + expect(target).toBeDefined(); + return (await cg.getCallers(target!.node.id)).map((c) => c.node.name).sort(); + }; + + it('resolves same-file and imported calls to the literal member, never to the constant (#1573)', async () => { + const tmpDir = setup({ + 'a.ts': `export const obj = { m() { return 1; } }; +export class C { static s() { return 2; } } +export function sameFileCallers() { return obj.m() + C.s(); } +`, + 'b.ts': `import { obj, C } from "./a"; +export function crossFileCaller() { return obj.m() + C.s(); } +`, + // A same-named top-level function elsewhere must never be chosen. + 'decoy.ts': `export function m() { return 'decoy'; } +`, + }); + try { + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + expect(await callersOf(cg, 'm', 'function', 'a.ts')).toEqual(['crossFileCaller', 'sameFileCallers']); + expect(await callersOf(cg, 'm', 'function', 'decoy.ts')).toEqual([]); + // The class static next to it resolves exactly as before (#825). + expect(await callersOf(cg, 's', 'method')).toEqual(['crossFileCaller', 'sameFileCallers']); + + // The import edge no longer lands on the constant itself. + const obj = (await cg.searchNodes('obj', { limit: 5 })).find((r) => r.node.kind === 'constant'); + expect(obj).toBeDefined(); + const caller = (await cg.searchNodes('crossFileCaller', { limit: 5 })).find((r) => r.node.kind === 'function'); + const toConstant = cg + .getOutgoingEdges(caller!.node.id) + .filter((e) => e.kind === 'calls' && e.target === obj!.node.id); + expect(toConstant).toHaveLength(0); + cg.close(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); + + it('covers method and arrow-property members, and skips a declaration nested in a member body', async () => { + const tmpDir = setup({ + 'src/api.ts': `export const api = { + call: () => { return 1; }, + get() { + function call() { return 'nested in get, not a member'; } + return call(); + }, +}; +`, + 'src/use.ts': `import { api } from './api'; +export function useCall() { return api.call(); } +export function useGet() { return api.get(); } +`, + }); + try { + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const calls = (await cg.searchNodes('call', { limit: 20 })) + .map((r) => r.node) + .filter((n) => n.name === 'call' && n.filePath === 'src/api.ts' && (n.kind === 'function' || n.kind === 'method')); + // The member is the arrow on line 2; the nested declaration sits + // inside `get`'s body on line 4 and must never be taken for it. + const member = calls.find((n) => n.startLine === 2); + const nested = calls.find((n) => n.startLine === 4); + expect(member).toBeDefined(); + expect(nested).toBeDefined(); + expect((await cg.getCallers(member!.id)).map((c) => c.node.name)).toContain('useCall'); + expect((await cg.getCallers(nested!.id)).map((c) => c.node.name)).not.toContain('useCall'); + expect(await callersOf(cg, 'get', 'function')).toEqual(['useGet']); + cg.close(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); + + it('leaves a non-literal value receiver on its existing path', async () => { + const tmpDir = setup({ + 'src/mk.ts': `export function m() { return 'top-level, unrelated to obj'; } +export const obj = makeObj(); +export function makeObj(): { m(): number } { return { m: () => 1 } as { m(): number }; } +export function localUse() { return obj.m(); } +`, + 'src/use.ts': `import { obj } from './mk'; +export function remoteUse() { return obj.m(); } +`, + }); + try { + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + // `obj` holds a call result, not a literal: the same-named top-level + // `m` lies outside its declaration, so containment finds nothing and + // both calls keep today's behavior (unresolved in the defining file; + // the constant edge through the import) rather than guessing. + expect(await callersOf(cg, 'm', 'function')).toEqual([]); + const obj = (await cg.searchNodes('obj', { limit: 5 })).find((r) => r.node.kind === 'constant'); + const remote = (await cg.searchNodes('remoteUse', { limit: 5 })).find((r) => r.node.kind === 'function'); + expect( + cg.getOutgoingEdges(remote!.node.id).some((e) => e.kind === 'calls' && e.target === obj!.node.id) + ).toBe(true); + 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/import-resolver.ts b/src/resolution/import-resolver.ts index df15579d5..60c7b3008 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -12,6 +12,7 @@ import { applyAliases } from './path-aliases'; import { resolveWorkspaceImport } from './workspace-packages'; import { resolveMethodOnType, + resolveObjectLiteralMember, localReceiverTypePatterns, normalizeInferredTypeName, } from './name-matcher'; @@ -1545,6 +1546,20 @@ export function resolveViaImport( resolvedBy: 'import', }; } + // An imported object literal used as a namespace (#1573): + // `api.call()` after `import { api } from './api'` where `api` is + // `export const api = { call() {…} }`. Its members have bare + // qualified names inside the constant's extent, so the + // `Container::member` lookup above can't see them and the edge + // landed on the constant — every cross-file caller of the method + // went missing. Resolve the member by containment instead. + if (targetNode.kind === 'constant' || targetNode.kind === 'variable') { + const member = ref.referenceName.slice(imp.localName.length + 1).split('.')[0]; + if (member) { + const literalMember = resolveObjectLiteralMember(targetNode, member, ref, context, 0.9, 'import'); + if (literalMember) return literalMember; + } + } // An imported VALUE (singleton constant / shared instance) called // through a member: `reproStore.notifyJoinGuildStatus()` after // `import { reproStore } from './store'`. findExportedSymbol diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 651051466..f7ca2be7a 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -543,6 +543,93 @@ export function preferCallSiteFile(nodes: Node[], callSiteFile: string): Node[] return same.length ? [...same, ...other] : nodes; } +/** + * Languages whose object literals declare callable members — `export const + * api = { call() {…}, get: () => {…} }` used as a namespace (#1573). + */ +const OBJECT_LITERAL_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx', 'arkts']); + +/** True when `inner`'s source range lies within `outer`'s (lines, then columns on a shared line). */ +function rangeWithin(inner: Node, outer: Node): boolean { + const innerEnd = inner.endLine ?? inner.startLine; + const outerEnd = outer.endLine ?? outer.startLine; + if (inner.startLine < outer.startLine || innerEnd > outerEnd) return false; + if (inner.startLine === outer.startLine && inner.startColumn < outer.startColumn) return false; + if (innerEnd === outerEnd && inner.endColumn > outer.endColumn) return false; + return true; +} + +function sameRange(a: Node, b: Node): boolean { + return ( + a.startLine === b.startLine && + a.startColumn === b.startColumn && + (a.endLine ?? a.startLine) === (b.endLine ?? b.startLine) && + a.endColumn === b.endColumn + ); +} + +/** + * Resolve `container.member` where `container` is a VALUE holding an object + * literal — `export const api = { call() {…}, get: () => {…} }` used as the + * module's namespace (#1573). The members are extracted as plain functions + * with BARE qualified names inside the constant's source extent (there is no + * `api::call`), so neither the `Container::member` lookup the class-shaped + * kinds use (#825) nor the declared-type inference for singleton instances + * (#1292) can reach them, and every such call resolved to nothing — or, via + * an import, to the constant itself. This looks the member up by CONTAINMENT: + * a node named `member` whose range lies inside the container's, in the + * container's own file. A helper declared inside a member's body is not a + * member and is skipped; nothing else in the file can donate a match. Calls + * take callable kinds only; other references accept value members too. + */ +export function resolveObjectLiteralMember( + container: Node, + member: string, + ref: UnresolvedRef, + context: ResolutionContext, + confidence: number, + resolvedBy: ResolvedRef['resolvedBy'], +): ResolvedRef | null { + if (container.kind !== 'constant' && container.kind !== 'variable') return null; + if (!OBJECT_LITERAL_LANGUAGES.has(container.language)) return null; + if (!sameLanguageFamily(container.language, ref.language)) return null; + + const inFile = context.getNodesInFile(container.filePath); + const callable = (n: Node) => n.kind === 'function' || n.kind === 'method'; + const valueMember = (n: Node) => + callable(n) || n.kind === 'property' || n.kind === 'variable' || n.kind === 'constant'; + 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)); + if (candidates.length === 0) return null; + + // Drop a candidate nested inside ANOTHER callable's body within the literal + // (`{ run() { const call = () => {}; } }` — `call` is `run`'s local, not a + // member). Strict containment: an identically-ranged sibling node for the + // same member (a property node over an arrow function) is not a body. + const bodies = inside.filter(callable); + candidates = candidates.filter( + (c) => !bodies.some((b) => b.id !== c.id && !sameRange(b, c) && rangeWithin(c, b)) + ); + if (candidates.length === 0) return null; + + // Several survivors (a property AND a function for one arrow member, say): + // a callable first, then the earliest in source order. + candidates.sort((a, b) => { + const ca = callable(a) ? 0 : 1; + const cb = callable(b) ? 0 : 1; + if (ca !== cb) return ca - cb; + return a.startLine - b.startLine || a.startColumn - b.startColumn; + }); + return { + original: ref, + targetNodeId: candidates[0]!.id, + confidence, + resolvedBy, + }; +} + // Exported for the precedence unit tests (#1079): they assert the // preferredFqn → same-file → matches[0] ordering directly. export function resolveMethodOnType( @@ -1759,6 +1846,27 @@ export function matchMethodCall( } } + // Object-literal namespace receiver (#1573): `api.call()` where `api` is a + // same-file `const api = { call() {…}, get: () => {…} }`. Its members are + // plain functions with bare names inside the constant's extent — no + // `Container::member` qualified name — so none of the class-shaped + // strategies below can see them (Strategy 3 only considers `method` + // kinds) and the call resolved to nothing at all. Same file only: a + // cross-file use reaches the same helper through the import path. + if (dotMatch && !objectOrClass!.includes('.') && OBJECT_LITERAL_LANGUAGES.has(ref.language)) { + const literalMatch = nmTimedT('mc-literal', ref, (): ResolvedRef | null => { + const holders = preferCallSiteFile(context.getNodesByName(objectOrClass!), ref.filePath).filter( + (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'); + if (hit) return hit; + } + return null; + }); + if (literalMatch) return literalMatch; + } + // Strategy 1: Direct class name match (existing logic). When the receiver // names a class that exists in several files (`Logger.log()` / `Logger::log()` // with a `Logger` in both `a/` and `b/`), try the class in the call site's