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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user