Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,6 @@ fn emit_pts_alias_edges<'a>(
) {
for alias in resolve_via_points_to(alias_ctx.lookup_name, alias_ctx.pts) {
let alias_imported_from = alias_ctx.imported_names.get(alias).copied();
let alias_imported_original_name = alias_ctx.imported_original_names.get(alias).copied();
let alias_call = CallInfo {
name: alias.to_string(),
line: alias_ctx.call_line,
Expand All @@ -428,7 +427,7 @@ fn emit_pts_alias_edges<'a>(
};
let mut alias_targets = resolve_call_targets(
ctx, &alias_call, alias_ctx.rel_path, alias_imported_from, alias_ctx.type_map, alias_ctx.caller_name,
alias_imported_original_name,
alias_ctx.imported_original_names,
);
sort_targets_by_confidence(&mut alias_targets, alias_ctx.rel_path, alias_imported_from);
for t in &alias_targets {
Expand Down Expand Up @@ -780,10 +779,9 @@ fn process_file<'a>(
let (caller_id, caller_name) = find_enclosing_caller(&fc.defs_with_ids, call.line, fc.file_node_id);
let is_dynamic = if call.dynamic.unwrap_or(false) { 1u32 } else { 0u32 };
let imported_from = fc.imported_names.get(call.name.as_str()).copied();
let imported_original_name = fc.imported_original_names.get(call.name.as_str()).copied();

let mut targets = resolve_call_targets(
ctx, call, fc.rel_path, imported_from, &fc.type_map, caller_name, imported_original_name,
ctx, call, fc.rel_path, imported_from, &fc.type_map, caller_name, &fc.imported_original_names,
);
// #1771/#1784: value-ref references (object-literal property values,
// Lua builtin reassignment, `instanceof ClassName`) resolve against
Expand Down Expand Up @@ -947,7 +945,7 @@ fn resolve_call_targets<'a>(
imported_from: Option<&str>,
type_map: &HashMap<&str, (&str, f64)>,
caller_name: &str,
imported_original_name: Option<&str>,
imported_original_names: &HashMap<&str, &str>,
) -> Vec<&'a NodeInfo> {
// Flagged dynamic calls use synthetic names like "<dynamic:eval>". Short-circuit
// so they never accidentally match a real symbol via name lookup.
Expand All @@ -958,7 +956,7 @@ fn resolve_call_targets<'a>(
// When the call site uses a renamed import binding (`import { X as Y }`),
// the imported file's actual symbol is declared under the *original* name
// (X) — look that up instead of the local alias the call site wrote (#1730).
let target_name = imported_original_name.unwrap_or(call.name.as_str());
let target_name = imported_original_names.get(call.name.as_str()).copied().unwrap_or(call.name.as_str());

// 1. Import-aware resolution
if let Some(imp_file) = imported_from {
Expand Down Expand Up @@ -1035,6 +1033,13 @@ fn resolve_call_targets<'a>(
// Use typeMap-resolved type or inline-new-extracted type, whichever is available.
let resolved_type = type_lookup.map(|&(t, _)| t).or(inline_new_type.as_deref());
if let Some(type_name) = resolved_type {
// The resolved type name can itself be a renamed import binding
// (e.g. `import { Foo as Bar } from './x'; const y = new Bar();
// y.method()` seeds typeMap['y'] = 'Bar') — de-alias before
// building the qualified lookup key, since the symbol table
// stores definitions under the declared name (`Foo.method`),
// not the local alias (#1825).
let type_name = imported_original_names.get(type_name).copied().unwrap_or(type_name);
let qualified = format!("{}.{}", type_name, call.name);
let typed: Vec<&NodeInfo> = ctx.nodes_by_name
.get(qualified.as_str())
Expand Down Expand Up @@ -1066,7 +1071,16 @@ fn resolve_call_targets<'a>(
// Guard: skip when inline_new_type is Some — mirrors TS `!typeName` which is false when the
// inline-new regex extracted a type (e.g. `(new Foo).bar()` → typeName='Foo' → skip).
if type_lookup.is_none() && inline_new_type.is_none() {
let qualified = format!("{}.{}", effective_receiver, call.name);
// The receiver itself can be a renamed import binding (`import {
// NamespaceObj as NsAlias } from './helpers.js'; NsAlias.doThing()`)
// — de-alias before building the qualified lookup key, since the
// symbol table stores the object literal under its declared name
// (`NamespaceObj.doThing`), not the importing file's local alias (#1825).
let dealiased_receiver = imported_original_names
.get(effective_receiver)
.copied()
.unwrap_or(effective_receiver);
let qualified = format!("{}.{}", dealiased_receiver, call.name);
let direct: Vec<&NodeInfo> = ctx.nodes_by_name
.get(qualified.as_str())
.map(|v| v.iter()
Expand Down
17 changes: 15 additions & 2 deletions src/domain/graph/builder/call-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,14 +227,19 @@ export function findCaller(
* - resolveByReceiver — receiver is a concrete object/class reference
* - resolveByGlobal — bare call, or this/self/super receiver
*
* The original logic is unchanged; only the physical location moved.
* `importedOriginalNames` is forwarded to `resolveByReceiver` so a receiver
* that is itself a renamed import binding (`import { X as Y }; Y.method()`)
* resolves against the declared name `X` rather than the local alias `Y`
* (#1825). `resolveByGlobal` has no receiver-qualifier lookups, so it does
* not need it.
*/
export function resolveByMethodOrGlobal(
lookup: CallNodeLookup,
call: { name: string; receiver?: string | null },
relPath: string,
typeMap: Map<string, unknown>,
callerName?: string | null,
importedOriginalNames?: ReadonlyMap<string, string>,
): ReadonlyArray<{ id: number; file: string }> {
if (
call.receiver &&
Expand All @@ -248,6 +253,7 @@ export function resolveByMethodOrGlobal(
relPath,
typeMap,
callerName,
importedOriginalNames,
);
}
if (
Expand Down Expand Up @@ -296,7 +302,14 @@ export function resolveCallTargets(
if (!targets || targets.length === 0) {
targets = lookup.byNameAndFile(call.name, relPath);
if (targets.length === 0) {
targets = resolveByMethodOrGlobal(lookup, call, relPath, typeMap, callerName);
targets = resolveByMethodOrGlobal(
lookup,
call,
relPath,
typeMap,
callerName,
importedOriginalNames,
);
}
}

Expand Down
26 changes: 23 additions & 3 deletions src/domain/graph/resolver/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,13 +208,25 @@ function resolveViaCompositePtsKey(
* 5. resolveViaPrototypeAlias — prototype alias via typeMap.
* 6. resolveViaDirectQualifiedMethod — direct qualified method lookup.
* 7. resolveViaCompositePtsKey — composite pts key → callback target function.
*
* `importedOriginalNames` maps a local renamed-import binding to the name it
* is actually declared under in its source file (`import { X as Y }` → `Y` →
* `X`, #1730). Steps 4-6 build a qualified `Qualifier.methodName` string to
* look up in the symbol table, where `Qualifier` is either the receiver text
* itself (step 6) or a type name resolved from the typeMap (steps 4-5) — in
* both cases the qualifier can itself be a renamed import binding (e.g.
* `import { NamespaceObj as NsAlias } from './helpers.js'; NsAlias.doThing()`).
* The symbol table stores definitions under their *declared* name
* (`NamespaceObj.doThing`), not the importing file's local alias, so the
* qualifier is de-aliased before building the lookup key (#1825).
*/
export function resolveByReceiver(
lookup: StrategyLookup,
call: { name: string; receiver: string },
relPath: string,
typeMap: Map<string, unknown>,
callerName?: string | null,
importedOriginalNames?: ReadonlyMap<string, string>,
): ReadonlyArray<{ id: number; file: string }> {
// Strip "this." so `this.repo.method()` resolves via typeMap["repo"]
// (or the "this.repo" key seeded directly by the TSC property-declaration enricher).
Expand All @@ -225,13 +237,21 @@ export function resolveByReceiver(
const typeName = resolveReceiverTypeName(typeMap, call.receiver, effectiveReceiver, callerName);

if (typeName) {
const typed = resolveViaTypedMethod(lookup, typeName, call, relPath);
const dealiasedTypeName = importedOriginalNames?.get(typeName) ?? typeName;
const typed = resolveViaTypedMethod(lookup, dealiasedTypeName, call, relPath);
if (typed.length > 0) return typed;

const viaPrototype = resolveViaPrototypeAlias(lookup, typeMap, typeName, call, relPath);
const viaPrototype = resolveViaPrototypeAlias(
lookup,
typeMap,
dealiasedTypeName,
call,
relPath,
);
if (viaPrototype.length > 0) return viaPrototype;
} else {
const direct = resolveViaDirectQualifiedMethod(lookup, effectiveReceiver, call, relPath);
const dealiasedReceiver = importedOriginalNames?.get(effectiveReceiver) ?? effectiveReceiver;
const direct = resolveViaDirectQualifiedMethod(lookup, dealiasedReceiver, call, relPath);
if (direct.length > 0) return direct;
}

Expand Down
139 changes: 139 additions & 0 deletions tests/integration/issue-1825-renamed-import-receiver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* Regression test for #1825: a renamed import used as a call *receiver*
* (`import { X as Y } from '...'; Y.method()`) must resolve — the qualified-
* name fallback in `resolveByReceiver` (src/domain/graph/resolver/strategy.ts)
* previously built the lookup key from the local alias (`Y.method`) instead
* of the declared name (`X.method`), so the call was silently dropped.
*
* Setup: two files.
* - helpers.js: `export const NamespaceObj = { doThing() {...} }` and
* `export class Greeter { greet() {...} }`.
* - consumer.js: imports both renamed (`NamespaceObj as NsAlias`,
* `Greeter as GreeterAlias`) and calls:
* - `NsAlias.doThing()` — direct-qualified-method path (no typeMap
* entry for the receiver; #1825's exact reported repro).
* - `new GreeterAlias().greet()` — typed-method path (typeMap records
* the constructor's local alias as the receiver's type; the same
* de-aliasing gap applies to `resolveViaTypedMethod`).
*
* Before the fix, neither call produced a `calls` edge on either engine.
*
* Verified on both engines — this is resolver logic mirrored in
* `crates/codegraph-core/`, so WASM and native must agree.
*/

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildGraph } from '../../src/domain/graph/builder.js';

const FILE_HELPERS = `
export const NamespaceObj = {
doThing() {
return 1;
},
};

export class Greeter {
greet() {
return 'hi';
}
}
`;

const FILE_CONSUMER = `
import { NamespaceObj as NsAlias, Greeter as GreeterAlias } from './helpers.js';

export function useReceiver() {
return NsAlias.doThing();
}

export function useConstructedReceiver() {
const g = new GreeterAlias();
return g.greet();
}
`;

let tmpWasm: string;
let tmpNative: string;

beforeAll(async () => {
tmpWasm = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1825-wasm-'));
fs.writeFileSync(path.join(tmpWasm, 'helpers.js'), FILE_HELPERS);
fs.writeFileSync(path.join(tmpWasm, 'consumer.js'), FILE_CONSUMER);

tmpNative = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1825-native-'));
fs.writeFileSync(path.join(tmpNative, 'helpers.js'), FILE_HELPERS);
fs.writeFileSync(path.join(tmpNative, 'consumer.js'), FILE_CONSUMER);

await Promise.all([
buildGraph(tmpWasm, { incremental: false, skipRegistry: true, engine: 'wasm' }),
buildGraph(tmpNative, { incremental: false, skipRegistry: true, engine: 'native' }),
]);
});

afterAll(() => {
fs.rmSync(tmpWasm, { recursive: true, force: true });
fs.rmSync(tmpNative, { recursive: true, force: true });
});
Comment on lines +77 to +80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Cleanup Uses Uninitialized Paths

If setup throws before both temp directory variables are assigned, this cleanup calls fs.rmSync with undefined. The original setup error is then followed by a teardown TypeError, and any directory created before the failure can be left behind.

Suggested change
afterAll(() => {
fs.rmSync(tmpWasm, { recursive: true, force: true });
fs.rmSync(tmpNative, { recursive: true, force: true });
});
afterAll(() => {
if (tmpWasm) fs.rmSync(tmpWasm, { recursive: true, force: true });
if (tmpNative) fs.rmSync(tmpNative, { recursive: true, force: true });
});

Fix in Claude Code


function getCallEdges(dbPath: string) {
const db = new Database(dbPath, { readonly: true });
try {
return db
.prepare(
`SELECT n1.name AS src, n1.file AS src_file, n2.name AS tgt, n2.file AS tgt_file
FROM edges e
JOIN nodes n1 ON e.source_id = n1.id
JOIN nodes n2 ON e.target_id = n2.id
WHERE e.kind = 'calls'
ORDER BY n1.name, n2.name`,
)
.all() as Array<{ src: string; src_file: string; tgt: string; tgt_file: string }>;
} finally {
db.close();
}
}

describe('call-edge resolution through a renamed import used as a receiver (#1825)', () => {
it('WASM: useReceiver -> NamespaceObj.doThing calls edge exists (direct-qualified path)', () => {
const edges = getCallEdges(path.join(tmpWasm, '.codegraph', 'graph.db'));
const edge = edges.find((e) => e.src === 'useReceiver' && e.tgt === 'NamespaceObj.doThing');
expect(edge).toBeDefined();
expect(edge?.tgt_file).toBe('helpers.js');
});

it('Native: useReceiver -> NamespaceObj.doThing calls edge exists (direct-qualified path)', () => {
const edges = getCallEdges(path.join(tmpNative, '.codegraph', 'graph.db'));
const edge = edges.find((e) => e.src === 'useReceiver' && e.tgt === 'NamespaceObj.doThing');
expect(edge).toBeDefined();
expect(edge?.tgt_file).toBe('helpers.js');
});

it('WASM: useConstructedReceiver -> Greeter.greet calls edge exists (typed-method path)', () => {
const edges = getCallEdges(path.join(tmpWasm, '.codegraph', 'graph.db'));
const edge = edges.find((e) => e.src === 'useConstructedReceiver' && e.tgt === 'Greeter.greet');
expect(edge).toBeDefined();
expect(edge?.tgt_file).toBe('helpers.js');
});

it('Native: useConstructedReceiver -> Greeter.greet calls edge exists (typed-method path)', () => {
const edges = getCallEdges(path.join(tmpNative, '.codegraph', 'graph.db'));
const edge = edges.find((e) => e.src === 'useConstructedReceiver' && e.tgt === 'Greeter.greet');
expect(edge).toBeDefined();
expect(edge?.tgt_file).toBe('helpers.js');
});

it('no spurious edge is created against the local alias names', () => {
for (const dbPath of [
path.join(tmpWasm, '.codegraph', 'graph.db'),
path.join(tmpNative, '.codegraph', 'graph.db'),
]) {
const edges = getCallEdges(dbPath);
expect(edges.find((e) => e.tgt === 'NsAlias.doThing')).toBeUndefined();
expect(edges.find((e) => e.tgt === 'GreeterAlias.greet')).toBeUndefined();
}
});
});
Loading