diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs index 460023bc..1dc36549 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs @@ -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, @@ -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 { @@ -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 @@ -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 "". Short-circuit // so they never accidentally match a real symbol via name lookup. @@ -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 { @@ -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()) @@ -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() diff --git a/src/domain/graph/builder/call-resolver.ts b/src/domain/graph/builder/call-resolver.ts index 587c4eee..674b20f6 100644 --- a/src/domain/graph/builder/call-resolver.ts +++ b/src/domain/graph/builder/call-resolver.ts @@ -227,7 +227,11 @@ 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, @@ -235,6 +239,7 @@ export function resolveByMethodOrGlobal( relPath: string, typeMap: Map, callerName?: string | null, + importedOriginalNames?: ReadonlyMap, ): ReadonlyArray<{ id: number; file: string }> { if ( call.receiver && @@ -248,6 +253,7 @@ export function resolveByMethodOrGlobal( relPath, typeMap, callerName, + importedOriginalNames, ); } if ( @@ -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, + ); } } diff --git a/src/domain/graph/resolver/strategy.ts b/src/domain/graph/resolver/strategy.ts index 32179171..7ff13a7b 100644 --- a/src/domain/graph/resolver/strategy.ts +++ b/src/domain/graph/resolver/strategy.ts @@ -208,6 +208,17 @@ 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, @@ -215,6 +226,7 @@ export function resolveByReceiver( relPath: string, typeMap: Map, callerName?: string | null, + importedOriginalNames?: ReadonlyMap, ): 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). @@ -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; } diff --git a/tests/integration/issue-1825-renamed-import-receiver.test.ts b/tests/integration/issue-1825-renamed-import-receiver.test.ts new file mode 100644 index 00000000..27456ada --- /dev/null +++ b/tests/integration/issue-1825-renamed-import-receiver.test.ts @@ -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 }); +}); + +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(); + } + }); +});