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
+125
View File
@@ -3075,6 +3075,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<string, string>) => {
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