fix(resolution): resolve calls to object-literal namespace members (#1573) (#1597)

Fixes #1573. Thanks @IAliceBobI — the report had the root cause exactly right, and the fix sits one layer up from the suggested spot (resolution rather than the container-kind set), for the reason below.

## What was wrong

Methods of an exported object-literal constant — `export const api = { call() {…}, get: () => {…} }` used as a module's API surface — never received a call edge from `api.call()`, same-file or through an import. The members are extracted as plain functions with **bare** qualified names (`call`, not `api::call`) sitting inside the constant's source extent, so:

- the `Container::member` lookup the class-shaped kinds use (#825) bails on kind `constant`, and even with `constant` added to that set there is no `api::call` to find;
- the declared-type inference for imported singleton instances (#1292) finds no type in a literal and falls back to the constant edge;
- the same-file strategies only consider classes and `method` kinds, so the call resolved to nothing at all.

Net effect: `callers` / impact reported zero for methods called from everywhere, with no boundary warning because nothing about `obj.method()` looks dynamic.

## What this does

Adds one helper that resolves a member **by containment** — a node named `member` whose source range lies inside the value's range, in the value's own file — and uses it from both halves:

- **Import path**: when the imported value is a constant/variable, the literal member is tried right after the `Container::member` lookup and before the #1292 instance inference, so the cross-file edge lands on the method instead of the constant.
- **Same-file path**: a same-file constant/variable receiver (TS/JS family only) is checked before the class-name strategies.

Precision rules, all tested: calls accept callable kinds only; a declaration nested inside another member's body is not a member; nothing outside the value's range can donate a match — a same-named top-level function, or a method returned by a factory the value merely holds — so those cases keep today's behavior rather than guessing. Class statics (`C.s()`) and non-literal values are untouched.

Extraction and qualified names are deliberately left alone: changing how literal members are named would have to be mirrored in the native kernel byte-for-byte, and the resolver-side lookup is contained and language-gated.

## Tests

- The issue's repro end-to-end: `sameFileCallers` and `crossFileCaller` are both callers of `m`; a decoy `m` in a third file gets none; the `C.s()` static control resolves exactly as before; `crossFileCaller` no longer has a `calls` edge to the constant.
- Arrow-property and method members both resolve; a `function call()` nested inside `get`'s body is never taken for `api.call()`.
- A value holding a factory's result (`const obj = makeObj()`) with a same-named top-level `m` in the file: no false attribution, existing behavior kept.
- The two positive tests fail on `main`; the control passes both ways, as a guard should.
- Full suite: 189 files, 3181 passed / 9 skipped.

With the built CLI on the issue's `a.ts`/`b.ts`: `codegraph callers m` → 2 callers (`sameFileCallers`, `crossFileCaller`); `callers s` unchanged; edges `sameFileCallers -> m` (0.85) and `crossFileCaller -> m` (import, 0.9), none to `obj`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01LxZj6W6Y1SHXwvpT3uwJpK
This commit is contained in:
Colby Mchenry
2026-08-26 10:38:13 -05:00
committed by GitHub
parent 7963672689
commit 278a8edc35
4 changed files with 249 additions and 0 deletions
+15
View File
@@ -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
+108
View File
@@ -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<string>(['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(
@@ -1771,6 +1858,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