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