diff --git a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs index 53f05601d..f191b9f66 100644 --- a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs +++ b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs @@ -1277,17 +1277,22 @@ fn load_file_node_id_map(conn: &Connection) -> HashMap { /// Resolve a file's imports to the list of `ImportedName` entries the edge /// builder consumes. Walks barrel chains to the ultimate definition file so /// the edge builder's name-lookup can find the right target (#976 P1). +/// +/// For renamed specifiers (`import { X as Y }`), `ImportedName.imported` +/// carries the original name (X) so `resolve_call_targets` can look it up in +/// the target file instead of the local alias (Y), which only exists in the +/// importing file (#1730). fn collect_imported_names_for_file( abs_str: &str, symbols: &FileSymbols, import_ctx: &crate::domain::graph::builder::stages::import_edges::ImportEdgeContext, ) -> Vec { use crate::domain::graph::builder::stages::build_edges::ImportedName; + use crate::domain::graph::builder::stages::import_edges::import_name_pairs; let mut imported_names: Vec = Vec::new(); for imp in &symbols.imports { let resolved_path = import_ctx.get_resolved(abs_str, &imp.source); - for name in &imp.names { - let clean_name = name.strip_prefix("* as ").unwrap_or(name).to_string(); + for (local, original) in import_name_pairs(imp) { // CJS require bindings are included in imported_names so the receiver-edge // resolver treats them as import artifacts (not locally-defined symbols). // We use an empty target_file so the import-aware call-target lookup @@ -1295,20 +1300,25 @@ fn collect_imported_names_for_file( // through to the same-file shadow node — matching WASM call-resolution // behaviour where CJS bindings are not in importedNamesMap (#1678). if imp.cjs_require.unwrap_or(false) { - imported_names.push(ImportedName { name: clean_name, file: String::new() }); + imported_names.push(ImportedName { + name: local, + file: String::new(), + imported: None, + }); continue; } let mut target_file = resolved_path.clone(); if import_ctx.is_barrel_file(&resolved_path) { let mut visited = HashSet::new(); if let Some(actual) = - import_ctx.resolve_barrel_export(&resolved_path, &clean_name, &mut visited) + import_ctx.resolve_barrel_export(&resolved_path, &original, &mut visited) { target_file = actual; } } imported_names.push(ImportedName { - name: clean_name, + imported: if original != local { Some(original) } else { None }, + name: local, file: target_file, }); } @@ -1411,8 +1421,14 @@ fn inject_return_types_for_file( let imported_names = collect_imported_names_for_file(abs_str, symbols, import_ctx); // Later entries overwrite earlier ones on duplicate names — same as the // HashMap collect in build_call_edges. - let imported_map: HashMap = - imported_names.into_iter().map(|e| (e.name, e.file)).collect(); + let mut imported_map: HashMap = HashMap::new(); + let mut imported_original_map: HashMap = HashMap::new(); + for e in imported_names { + if let Some(original) = e.imported { + imported_original_map.insert(e.name.clone(), original); + } + imported_map.insert(e.name, e.file); + } let mut injections: Vec = Vec::new(); let mut injected: HashSet = HashSet::new(); @@ -1429,7 +1445,13 @@ fn inject_return_types_for_file( global_return_types.get(&format!("{receiver}.{}", ca.callee_name)) } None => imported_map.get(&ca.callee_name).and_then(|from| { - return_type_index.get(from).and_then(|m| m.get(&ca.callee_name)) + // The return-type index for the imported file is keyed by the + // function's own declared name — use the original (pre-rename) + // name when the callee is a renamed import binding (#1730). + let callee_original_name = imported_original_map + .get(&ca.callee_name) + .unwrap_or(&ca.callee_name); + return_type_index.get(from).and_then(|m| m.get(callee_original_name)) }), }; 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 d86c42872..8d14f7e4d 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 @@ -46,6 +46,12 @@ pub struct CallInfo { pub struct ImportedName { pub name: String, pub file: String, + /// For renamed specifiers (`import { X as Y }`): the original name + /// exported by `file` (X), when it differs from `name` (the local + /// binding Y). `resolve_call_targets` looks this up in `file` instead of + /// `name` — the renamed local alias only exists in the importing file, + /// not in `file` itself (#1730). + pub imported: Option, } #[napi(object)] @@ -384,6 +390,7 @@ struct PtsAliasCtx<'a> { is_dynamic: u32, rel_path: &'a str, imported_names: &'a HashMap<&'a str, &'a str>, + imported_original_names: &'a HashMap<&'a str, &'a str>, type_map: &'a HashMap<&'a str, (&'a str, f64)>, } @@ -399,6 +406,7 @@ 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, @@ -409,6 +417,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, ); sort_targets_by_confidence(&mut alias_targets, alias_ctx.rel_path, alias_imported_from); for t in &alias_targets { @@ -458,6 +467,11 @@ struct FileContext<'a> { rel_path: &'a str, file_node_id: u32, imported_names: HashMap<&'a str, &'a str>, + /// Local import alias -> original exported name, for renamed specifiers + /// (`import { X as Y }`) only — entries where local === original are + /// omitted. Consulted by `resolve_call_targets` so a call to the local + /// alias resolves against the correct exported symbol (#1730). + imported_original_names: HashMap<&'a str, &'a str>, type_map: HashMap<&'a str, (&'a str, f64)>, defs_with_ids: Vec>, pts_map: Option>>, @@ -567,6 +581,10 @@ fn build_file_context<'a>( .imported_names.iter() .map(|im| (im.name.as_str(), im.file.as_str())) .collect(); + let imported_original_names: HashMap<&str, &str> = file_input + .imported_names.iter() + .filter_map(|im| im.imported.as_deref().map(|orig| (im.name.as_str(), orig))) + .collect(); let type_map = build_type_map(file_input); let file_nodes: Vec<&NodeInfo> = all_nodes.iter().filter(|n| n.file == rel_path).collect(); let defs_with_ids: Vec = file_input.definitions.iter().map(|d| { @@ -590,6 +608,7 @@ fn build_file_context<'a>( rel_path, file_node_id: file_input.file_node_id, imported_names, + imported_original_names, type_map, defs_with_ids, pts_map, @@ -655,6 +674,7 @@ fn emit_no_receiver_pts_edges<'a>( is_dynamic, rel_path: fc.rel_path, imported_names: &fc.imported_names, + imported_original_names: &fc.imported_original_names, type_map: &fc.type_map, }, seen_edges, @@ -697,6 +717,7 @@ fn emit_receiver_pts_edges<'a>( is_dynamic, rel_path: fc.rel_path, imported_names: &fc.imported_names, + imported_original_names: &fc.imported_original_names, type_map: &fc.type_map, }, seen_edges, @@ -734,8 +755,11 @@ 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); + let mut targets = resolve_call_targets( + ctx, call, fc.rel_path, imported_from, &fc.type_map, caller_name, imported_original_name, + ); sort_targets_by_confidence(&mut targets, fc.rel_path, imported_from); emit_call_edges(&targets, caller_id, is_dynamic, fc.rel_path, imported_from, &mut seen_edges, &mut pts_edge_map, edges); @@ -882,6 +906,7 @@ fn resolve_call_targets<'a>( imported_from: Option<&str>, type_map: &HashMap<&str, (&str, f64)>, caller_name: &str, + imported_original_name: Option<&str>, ) -> Vec<&'a NodeInfo> { // Flagged dynamic calls use synthetic names like "". Short-circuit // so they never accidentally match a real symbol via name lookup. @@ -889,10 +914,15 @@ fn resolve_call_targets<'a>( return vec![]; } + // 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()); + // 1. Import-aware resolution if let Some(imp_file) = imported_from { let targets = ctx.nodes_by_name_and_file - .get(&(call.name.as_str(), imp_file)) + .get(&(target_name, imp_file)) .cloned().unwrap_or_default(); if !targets.is_empty() { return targets; } } @@ -2081,7 +2111,7 @@ mod call_edge_tests { ); // Mark `Calculator` as an imported name so the resolver treats the // same-file kind="function" node as an import artifact and falls through. - file.imported_names = vec![ImportedName { name: "Calculator".to_string(), file: "utils.js".to_string() }]; + file.imported_names = vec![ImportedName { name: "Calculator".to_string(), file: "utils.js".to_string(), imported: None }]; let edges = build_call_edges(vec![file], all_nodes, vec![]); @@ -2464,7 +2494,7 @@ mod call_edge_tests { vec![], vec![], ); - file.imported_names = vec![ImportedName { name: "e4".to_string(), file: "other.js".to_string() }]; + file.imported_names = vec![ImportedName { name: "e4".to_string(), file: "other.js".to_string(), imported: None }]; file.param_bindings = Some(vec![ParamBinding { callee: "f3".to_string(), arg_index: 0, diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs index fd09d4a2f..c8f0487e8 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs @@ -115,6 +115,29 @@ impl BarrelContext for ImportEdgeContext { } } +/// Pairs each locally-bound name from an import statement with its original +/// (pre-rename) exported name — identical to the local name unless the +/// specifier renames a binding (`import { X as Y }`). Barrel tracing and +/// target-file symbol lookups must search using the *original* name — the +/// renamed local alias only exists in the importing file, not in the file +/// being imported from (#1730). Mirrors `importNamePairs` in build-edges.ts. +pub(crate) fn import_name_pairs(imp: &crate::types::Import) -> Vec<(String, String)> { + let mut original_name_for: HashMap<&str, &str> = HashMap::new(); + if let Some(renamed) = &imp.renamed_imports { + for r in renamed { + original_name_for.insert(&r.local, &r.imported); + } + } + imp.names + .iter() + .map(|name| { + let local = name.strip_prefix("* as ").unwrap_or(name); + let original = original_name_for.get(local).copied().unwrap_or(local); + (local.to_string(), original.to_string()) + }) + .collect() +} + /// Build the reexport map from parsed file symbols. pub fn build_reexport_map(ctx: &ImportEdgeContext) -> HashMap> { let mut reexport_map = HashMap::new(); @@ -249,18 +272,17 @@ fn collect_type_only_lookup_pairs(ctx: &ImportEdgeContext) -> HashSet<(String, S continue; } let resolved_path = ctx.get_resolved(abs_str, &imp.source); - for name in &imp.names { - let clean_name = name.strip_prefix("* as ").unwrap_or(name); + for (_local, original) in import_name_pairs(imp) { let mut target_file = resolved_path.clone(); if ctx.is_barrel_file(&resolved_path) { let mut visited = HashSet::new(); if let Some(actual) = - ctx.resolve_barrel_export(&resolved_path, clean_name, &mut visited) + ctx.resolve_barrel_export(&resolved_path, &original, &mut visited) { target_file = actual; } } - pairs.insert((clean_name.to_string(), target_file)); + pairs.insert((original, target_file)); } } } @@ -303,18 +325,16 @@ fn emit_type_only_symbol_rows( if !imp.type_only.unwrap_or(false) { return; } - for name in &imp.names { - let clean_name = name.strip_prefix("* as ").unwrap_or(name); + for (_local, original) in import_name_pairs(imp) { let mut target_file = resolved_path.to_string(); if ctx.is_barrel_file(resolved_path) { let mut visited = HashSet::new(); - if let Some(actual) = - ctx.resolve_barrel_export(resolved_path, clean_name, &mut visited) + if let Some(actual) = ctx.resolve_barrel_export(resolved_path, &original, &mut visited) { target_file = actual; } } - if let Some(&sym_id) = symbol_node_ids.get(&(clean_name.to_string(), target_file)) { + if let Some(&sym_id) = symbol_node_ids.get(&(original, target_file)) { edges.push(EdgeRow { source_id: file_node_id, target_id: sym_id, @@ -347,14 +367,13 @@ fn emit_barrel_through_rows( _ => "imports", }; let mut resolved_sources: HashSet = HashSet::new(); - for name in &imp.names { - let clean_name = name.strip_prefix("* as ").unwrap_or(name); + for (_local, original) in import_name_pairs(imp) { let mut visited = HashSet::new(); - let actual_source = - match ctx.resolve_barrel_export(resolved_path, clean_name, &mut visited) { - Some(s) => s, - None => continue, - }; + let actual_source = match ctx.resolve_barrel_export(resolved_path, &original, &mut visited) + { + Some(s) => s, + None => continue, + }; if actual_source == resolved_path || !resolved_sources.insert(actual_source.clone()) { continue; } diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 5c343e820..daf2ff9d3 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -1672,11 +1672,15 @@ fn handle_import_stmt(node: &Node, source: &[u8], symbols: &mut FileSymbols) { if let Some(source_node) = source_node { let mod_path = node_text(&source_node, source) .replace(&['\'', '"'][..], ""); - let names = extract_import_names(node, source); + let mut renamed_imports = Vec::new(); + let names = extract_import_names_with_renames(node, source, &mut renamed_imports); let mut imp = Import::new(mod_path, names, start_line(node)); if is_type_only { imp.type_only = Some(true); } + if !renamed_imports.is_empty() { + imp.renamed_imports = Some(renamed_imports); + } symbols.imports.push(imp); } } @@ -2972,20 +2976,80 @@ fn extract_rest_identifier(rest_node: &Node, source: &[u8], names: &mut Vec Vec { let mut names = Vec::new(); - scan_import_names(node, source, &mut names); + let mut renamed = Vec::new(); + scan_import_names(node, source, &mut names, &mut renamed); + names +} + +/// Extract import names and collect `{ local, imported }` pairs for +/// `import_specifier` nodes that rename a binding (`import { X as Y }`). +/// Mirrors `extractImportNames`'s `renamedOut` parameter in +/// src/extractors/javascript.ts (#1730). +fn extract_import_names_with_renames( + node: &Node, + source: &[u8], + renamed_out: &mut Vec, +) -> Vec { + let mut names = Vec::new(); + scan_import_names(node, source, &mut names, renamed_out); names } -fn scan_import_names(node: &Node, source: &[u8], names: &mut Vec) { - scan_import_names_depth(node, source, names, 0); +fn scan_import_names( + node: &Node, + source: &[u8], + names: &mut Vec, + renamed_out: &mut Vec, +) { + scan_import_names_depth(node, source, names, renamed_out, 0); } -fn scan_import_names_depth(node: &Node, source: &[u8], names: &mut Vec, depth: usize) { +/// Grammar note (see tree-sitter-javascript): for `import_specifier`, the +/// `name` field is *always* present — it holds the name as declared by the +/// source module. `alias` is only present for `X as Y` and holds the *local* +/// binding actually referenced by call sites in this file. Preferring `name` +/// unconditionally (as this function used to, and as the `export_specifier` +/// branch below still deliberately does — see its comment) silently drops the +/// local alias for every renamed import: call sites use `Y`, not `X` (#1730). +fn scan_import_names_depth( + node: &Node, + source: &[u8], + names: &mut Vec, + renamed_out: &mut Vec, + depth: usize, +) { if depth >= MAX_WALK_DEPTH { return; } match node.kind() { - "import_specifier" | "export_specifier" => { + "import_specifier" => { + let source_name_node = node.child_by_field_name("name"); + let alias_node = node.child_by_field_name("alias"); + let local_node = alias_node.or(source_name_node); + if let Some(local_node) = local_node { + names.push(node_text(&local_node, source).to_string()); + if let (Some(alias), Some(source_name)) = (alias_node, source_name_node) { + let alias_text = node_text(&alias, source); + let source_text = node_text(&source_name, source); + if alias_text != source_text { + renamed_out.push(RenamedImport { + local: alias_text.to_string(), + imported: source_text.to_string(), + }); + } + } + } else { + names.push(node_text(node, source).to_string()); + } + } + "export_specifier" => { + // export_specifier's `name` is the local declaration being (re-)exported; + // `alias` is the external name it's exposed as. Barrel/reexport tracing + // keys off the *original* declaration name, so this branch is + // deliberately left picking `name` first — do not unify with the + // import_specifier branch above. Rename-aware barrel tracing for + // `export { X as Y } from …` is a distinct, separate gap (#1730 + // investigation). let name_node = node .child_by_field_name("name") .or_else(|| node.child_by_field_name("alias")); @@ -3009,7 +3073,7 @@ fn scan_import_names_depth(node: &Node, source: &[u8], names: &mut Vec, } for i in 0..node.child_count() { if let Some(child) = node.child(i) { - scan_import_names_depth(&child, source, names, depth + 1); + scan_import_names_depth(&child, source, names, renamed_out, depth + 1); } } } @@ -3659,6 +3723,30 @@ mod tests { assert_eq!(s.imports[0].names, vec!["readFile"]); } + /// Regression test for #1730: `import { X as Y }` must record the *local* + /// binding (Y) in `names` — that's what call sites reference — plus the + /// `{ local: Y, imported: X }` pair in `renamed_imports` so call-edge + /// resolution can recover the original exported name X. + #[test] + fn renamed_import_records_local_name_and_rename_pair() { + let s = parse_js("import { collectFiles as collectFilesUtil } from './helpers';"); + assert_eq!(s.imports.len(), 1); + assert_eq!(s.imports[0].names, vec!["collectFilesUtil"]); + let renamed = s.imports[0] + .renamed_imports + .as_ref() + .expect("renamed_imports should be populated for a renamed specifier"); + assert_eq!(renamed.len(), 1); + assert_eq!(renamed[0].local, "collectFilesUtil"); + assert_eq!(renamed[0].imported, "collectFiles"); + } + + #[test] + fn non_renamed_import_has_no_renamed_imports() { + let s = parse_js("import { readFile } from 'fs';"); + assert!(s.imports[0].renamed_imports.is_none()); + } + #[test] fn finds_calls() { let s = parse_js("function f() { console.log('hi'); foo(); }"); diff --git a/crates/codegraph-core/src/types.rs b/crates/codegraph-core/src/types.rs index a1c3ccdc2..e41c50034 100644 --- a/crates/codegraph-core/src/types.rs +++ b/crates/codegraph-core/src/types.rs @@ -114,6 +114,16 @@ pub struct Call { pub key_expr: Option, } +/// `import { X as Y }`: the local binding name (Y) paired with the original +/// name exported by the source module (X). Mirrors TS `Import.renamedImports`. +/// See `Import.renamed_imports` for why this is needed (#1730). +#[napi(object)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RenamedImport { + pub local: String, + pub imported: String, +} + #[napi(object)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Import { @@ -125,6 +135,15 @@ pub struct Import { pub reexport: Option, #[napi(js_name = "wildcardReexport")] pub wildcard_reexport: Option, + /// For `import { X as Y }` specifiers: the local binding name (Y) mapped to + /// the original name exported by the source module (X). `names` always + /// carries the local (post-rename) binding — this field lets call-edge + /// resolution recover the *original* symbol name to look up in the + /// imported file when a call site uses the local alias (#1730). Only + /// populated for specifiers that actually rename a binding. Mirrors TS + /// `Import.renamedImports`. + #[napi(js_name = "renamedImports")] + pub renamed_imports: Option>, // Language-specific flags #[napi(js_name = "pythonImport")] pub python_import: Option, @@ -168,6 +187,7 @@ impl Import { type_only: None, reexport: None, wildcard_reexport: None, + renamed_imports: None, python_import: None, go_import: None, rust_use: None, diff --git a/src/domain/graph/builder/call-resolver.ts b/src/domain/graph/builder/call-resolver.ts index b39a158a7..68a5a2589 100644 --- a/src/domain/graph/builder/call-resolver.ts +++ b/src/domain/graph/builder/call-resolver.ts @@ -217,6 +217,7 @@ export function resolveCallTargets( importedNames: Map, typeMap: Map, callerName?: string | null, + importedOriginalNames?: ReadonlyMap, ): { targets: Array<{ id: number; file: string }>; importedFrom: string | undefined } { // Flagged dynamic calls use synthetic names like ''. Short-circuit // so they never accidentally match a real symbol via lookup.byName. @@ -225,14 +226,18 @@ export function resolveCallTargets( } const importedFrom = importedNames.get(call.name); + // 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). + const targetName = importedOriginalNames?.get(call.name) ?? call.name; let targets: ReadonlyArray<{ id: number; file: string }> | undefined; if (importedFrom) { - targets = lookup.byNameAndFile(call.name, importedFrom); + targets = lookup.byNameAndFile(targetName, importedFrom); if (targets.length === 0 && lookup.isBarrel(importedFrom)) { - const actualSource = lookup.resolveBarrel(importedFrom, call.name); + const actualSource = lookup.resolveBarrel(importedFrom, targetName); if (actualSource) { - targets = lookup.byNameAndFile(call.name, actualSource); + targets = lookup.byNameAndFile(targetName, actualSource); } } } diff --git a/src/domain/graph/builder/import-utils.ts b/src/domain/graph/builder/import-utils.ts new file mode 100644 index 000000000..a56c7517a --- /dev/null +++ b/src/domain/graph/builder/import-utils.ts @@ -0,0 +1,22 @@ +import type { Import } from '../../../types.js'; + +/** + * Pairs each locally-bound name from an import statement with its original + * (pre-rename) exported name — identical to the local name unless the + * specifier renames a binding (`import { X as Y }`). Barrel tracing and + * target-file symbol lookups must search using the *original* name — the + * renamed local alias only exists in the importing file, not in the file + * being imported from (#1730). + * + * Shared by the full-build (`stages/build-edges.ts`) and incremental + * (`incremental.ts`) pipelines so the rename-stripping logic can't drift + * between them. + */ +export function importNamePairs(imp: Import): Array<{ local: string; original: string }> { + const originalNameFor = new Map(); + for (const r of imp.renamedImports ?? []) originalNameFor.set(r.local, r.imported); + return imp.names.map((name) => { + const local = name.replace(/^\*\s+as\s+/, ''); + return { local, original: originalNameFor.get(local) ?? local }; + }); +} diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index 863ef00f7..29c17ee10 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -29,6 +29,7 @@ import { resolveSameClassQualifiedMethod, } from './call-resolver.js'; import { BUILTIN_RECEIVERS, readFileSafe } from './helpers.js'; +import { importNamePairs } from './import-utils.js'; // ── Local types ───────────────────────────────────────────────────────── @@ -209,8 +210,22 @@ function rebuildReverseDepEdges( aliases, skipBarrel ? null : db, ); - const importedNames = buildImportedNamesMap(symbols, rootDir, depRelPath, aliases, db); - edgesAdded += buildCallEdges(db, stmts, depRelPath, symbols, fileNodeRow, importedNames); + const { importedNames, importedOriginalNames } = buildImportedNamesMap( + symbols, + rootDir, + depRelPath, + aliases, + db, + ); + edgesAdded += buildCallEdges( + db, + stmts, + depRelPath, + symbols, + fileNodeRow, + importedNames, + importedOriginalNames, + ); edgesAdded += buildClassHierarchyEdges(stmts, depRelPath, symbols); return edgesAdded; } @@ -318,9 +333,8 @@ function resolveBarrelImportEdges( let edgesAdded = 0; if (!isBarrelFile(db, resolvedPath)) return edgesAdded; const resolvedSources = new Set(); - for (const name of imp.names) { - const cleanName = name.replace(/^\*\s+as\s+/, ''); - const actualSource = resolveBarrelTarget(db, resolvedPath, cleanName); + for (const { original } of importNamePairs(imp)) { + const actualSource = resolveBarrelTarget(db, resolvedPath, original); if (actualSource && actualSource !== resolvedPath && !resolvedSources.has(actualSource)) { resolvedSources.add(actualSource); const actualRow = stmts.getNodeId.get(actualSource, 'file', actualSource, 0); @@ -343,14 +357,13 @@ function emitTypeOnlySymbolEdges( fileNodeId: number, ): number { let edgesAdded = 0; - for (const name of imp.names) { - const cleanName = name.replace(/^\*\s+as\s+/, ''); + for (const { original } of importNamePairs(imp)) { let targetFile = resolvedPath; if (db && isBarrelFile(db, resolvedPath)) { - const actual = resolveBarrelTarget(db, resolvedPath, cleanName); + const actual = resolveBarrelTarget(db, resolvedPath, original); if (actual) targetFile = actual; } - const candidates = stmts.findNodeInFile.all(cleanName, targetFile) as Array<{ + const candidates = stmts.findNodeInFile.all(original, targetFile) as Array<{ id: number; file: string; }>; @@ -413,14 +426,23 @@ function buildImportEdges( return edgesAdded; } +/** + * Mirrors the full-build `buildImportedNamesMap` in build-edges.ts: maps each + * locally-bound import name to its defining file (`importedNames`), plus, for + * renamed specifiers (`import { X as Y }`), the *original* exported name + * (`importedOriginalNames`, keyed by local name Y). Barrel tracing and the + * downstream target-file symbol lookup must use the original name — the + * renamed local alias only exists in the importing file (#1730). + */ function buildImportedNamesMap( symbols: ExtractorOutput, rootDir: string, relPath: string, aliases: PathAliases, db: BetterSqlite3Database, -): Map { +): { importedNames: Map; importedOriginalNames: Map } { const importedNames = new Map(); + const importedOriginalNames = new Map(); for (const imp of symbols.imports) { const resolvedPath = resolveImportPath( path.join(rootDir, relPath), @@ -428,21 +450,21 @@ function buildImportedNamesMap( rootDir, aliases, ); - for (const name of imp.names) { - const cleanName = name.replace(/^\*\s+as\s+/, ''); + for (const { local, original } of importNamePairs(imp)) { // Mirror full-build's `buildImportedNamesMap`: follow barrel re-exports so // `importedNames` maps to the *defining* file, not the barrel. This ensures // `computeConfidence` gets `importedFrom === targetFile` and returns 1.0 // instead of the cross-directory fallback (0.3). let targetFile = resolvedPath; if (isBarrelFile(db, resolvedPath)) { - const actual = resolveBarrelTarget(db, resolvedPath, cleanName); + const actual = resolveBarrelTarget(db, resolvedPath, original); if (actual) targetFile = actual; } - importedNames.set(cleanName, targetFile); + importedNames.set(local, targetFile); + if (original !== local) importedOriginalNames.set(local, original); } } - return importedNames; + return { importedNames, importedOriginalNames }; } // ── Class hierarchy edges ─────────────────────────────────────────────── @@ -690,6 +712,7 @@ function buildCallEdges( symbols: ExtractorOutput, fileNodeRow: { id: number }, importedNames: Map, + importedOriginalNames?: ReadonlyMap, ): number { const typeMap = buildIncrementalTypeMap(symbols); const seenCallEdges = new Set(); @@ -707,6 +730,7 @@ function buildCallEdges( importedNames, typeMap, caller.callerName, + importedOriginalNames, ); const targets = applyThisReceiverFallbacks( @@ -773,8 +797,22 @@ function rebuildEdgesForTargetFile( let edgesAdded = buildContainmentEdges(db, stmts, relPath, symbols); edgesAdded += rebuildDirContainment(db, stmts, relPath); edgesAdded += buildImportEdges(stmts, relPath, symbols, rootDir, fileNodeRow.id, aliases, db); - const importedNames = buildImportedNamesMap(symbols, rootDir, relPath, aliases, db); - edgesAdded += buildCallEdges(db, stmts, relPath, symbols, fileNodeRow, importedNames); + const { importedNames, importedOriginalNames } = buildImportedNamesMap( + symbols, + rootDir, + relPath, + aliases, + db, + ); + edgesAdded += buildCallEdges( + db, + stmts, + relPath, + symbols, + fileNodeRow, + importedNames, + importedOriginalNames, + ); edgesAdded += buildClassHierarchyEdges(stmts, relPath, symbols); return edgesAdded; } diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 011ce246b..96f03b9ca 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -56,6 +56,7 @@ import { CHA_TYPED_DISPATCH_CONFIDENCE, runChaPostPass, } from '../helpers.js'; +import { importNamePairs } from '../import-utils.js'; import { getResolved, isBarrelFile, resolveBarrelExportCached } from './resolve-imports.js'; // ── Local types ────────────────────────────────────────────────────────── @@ -88,7 +89,7 @@ interface NativeFileEntry { params?: string[]; }>; calls: Call[]; - importedNames: Array<{ name: string; file: string }>; + importedNames: Array<{ name: string; file: string; imported?: string }>; classes: ClassRelation[]; typeMap: Array<{ name: string; typeName: string; confidence: number }>; /** Phase 8.3: function-reference bindings for pts analysis. */ @@ -160,14 +161,13 @@ function emitTypeOnlySymbolEdges( allEdgeRows: EdgeRowTuple[], ): void { if (!ctx.nodesByNameAndFile) return; - for (const name of imp.names) { - const cleanName = name.replace(/^\*\s+as\s+/, ''); + for (const { original } of importNamePairs(imp)) { let targetFile = resolvedPath; if (isBarrelFile(ctx, resolvedPath)) { - const actual = resolveBarrelExportCached(ctx, resolvedPath, cleanName); + const actual = resolveBarrelExportCached(ctx, resolvedPath, original); if (actual) targetFile = actual; } - const candidates = ctx.nodesByNameAndFile.get(`${cleanName}|${targetFile}`); + const candidates = ctx.nodesByNameAndFile.get(`${original}|${targetFile}`); if (candidates && candidates.length > 0) { allEdgeRows.push([fileNodeId, candidates[0]!.id, 'imports-type', 1.0, 0, null, null]); } @@ -233,9 +233,8 @@ function buildBarrelEdges( edgeRows: EdgeRowTuple[], ): void { const resolvedSources = new Set(); - for (const name of imp.names) { - const cleanName = name.replace(/^\*\s+as\s+/, ''); - const actualSource = resolveBarrelExportCached(ctx, resolvedPath, cleanName); + for (const { original } of importNamePairs(imp)) { + const actualSource = resolveBarrelExportCached(ctx, resolvedPath, original); if (actualSource && actualSource !== resolvedPath && !resolvedSources.has(actualSource)) { resolvedSources.add(actualSource); const actualRow = getNodeIdStmt.get(actualSource, 'file', actualSource, 0); @@ -474,7 +473,12 @@ function propagateReturnTypesAcrossFiles( // file rather than the barrel. This means returnTypeIndex.get(importedFrom) // now finds entries it previously missed, improving cross-file return-type // propagation through re-export chains (Phase 8.2 improvement). - const importedNamesMap = buildImportedNamesMap(ctx, relPath, symbols, rootDir); + const { importedNames: importedNamesMap, importedOriginalNames } = buildImportedNamesMap( + ctx, + relPath, + symbols, + rootDir, + ); for (const ca of symbols.callAssignments) { if (symbols.typeMap.has(ca.varName)) continue; // already resolved locally @@ -484,7 +488,11 @@ function propagateReturnTypesAcrossFiles( returnEntry = globalReturnTypeMap.get(`${ca.receiverTypeName}.${ca.calleeName}`); } else { const importedFrom = importedNamesMap.get(ca.calleeName); - if (importedFrom) returnEntry = returnTypeIndex.get(importedFrom)?.get(ca.calleeName); + // The return-type index for the imported file is keyed by the + // function's own declared name — use the original (pre-rename) name + // when the call-assignment's callee is a renamed import binding (#1730). + const calleeOriginalName = importedOriginalNames.get(ca.calleeName) ?? ca.calleeName; + if (importedFrom) returnEntry = returnTypeIndex.get(importedFrom)?.get(calleeOriginalName); } if (returnEntry) { @@ -639,7 +647,12 @@ function buildDefinePropertyPostPass( const fileNodeRow = getNodeIdStmt.get(relPath, 'file', relPath, 0); if (!fileNodeRow) continue; - const importedNames = buildImportedNamesMap(ctx, relPath, symbols, rootDir); + const { importedNames, importedOriginalNames } = buildImportedNamesMap( + ctx, + relPath, + symbols, + rootDir, + ); const typeMap: Map = symbols.typeMap || new Map(); const definePropertyReceivers = symbols.definePropertyReceivers!; @@ -660,6 +673,7 @@ function buildDefinePropertyPostPass( importedNames, typeMap as Map, caller.callerName, + importedOriginalNames, ); if (directTargets.length > 0) continue; @@ -789,21 +803,28 @@ function buildImportedNamesForNative( relPath: string, symbols: ExtractorOutput, rootDir: string, -): Array<{ name: string; file: string }> { - const importedNames: Array<{ name: string; file: string }> = []; +): Array<{ name: string; file: string; imported?: string }> { + const importedNames: Array<{ name: string; file: string; imported?: string }> = []; // Process dynamic imports first (lower priority), then static imports // (higher priority). Rust HashMap::collect keeps the last entry per key, // so static imports win when both contribute the same name. const addImports = (imp: (typeof symbols.imports)[number]) => { const resolvedPath = getResolved(ctx, path.join(rootDir, relPath), imp.source); - for (const name of imp.names) { - const cleanName = name.replace(/^\*\s+as\s+/, ''); + for (const { local, original } of importNamePairs(imp)) { let targetFile = resolvedPath; if (isBarrelFile(ctx, resolvedPath)) { - const actual = resolveBarrelExportCached(ctx, resolvedPath, cleanName); + const actual = resolveBarrelExportCached(ctx, resolvedPath, original); if (actual) targetFile = actual; } - importedNames.push({ name: cleanName, file: targetFile }); + // `imported` carries the original (pre-rename) exported name so the + // native resolver can look it up in `targetFile` instead of the local + // alias, which only exists in this file (#1730). Omitted when unrenamed. + const entry: { name: string; file: string; imported?: string } = { + name: local, + file: targetFile, + }; + if (original !== local) entry.imported = original; + importedNames.push(entry); } }; for (const imp of symbols.imports) { @@ -831,7 +852,12 @@ function buildCallEdgesJS( const fileNodeRow = getNodeIdStmt.get(relPath, 'file', relPath, 0); if (!fileNodeRow) continue; - const importedNames = buildImportedNamesMap(ctx, relPath, symbols, rootDir); + const { importedNames, importedOriginalNames } = buildImportedNamesMap( + ctx, + relPath, + symbols, + rootDir, + ); const typeMap: Map = new Map( symbols.typeMap instanceof Map ? symbols.typeMap : [], ); @@ -887,48 +913,58 @@ function buildCallEdgesJS( ptsMap, chaCtx, importArtifactNames, + importedOriginalNames, ); buildClassHierarchyEdges(ctx, relPath, symbols, allEdgeRows); } } +/** + * Maps each locally-bound import name in `relPath` to the file it comes from + * (`importedNames`), plus, for renamed specifiers (`import { X as Y }`), the + * *original* exported name (`importedOriginalNames`, keyed by local name Y). + * + * Barrel tracing and downstream target-file symbol lookups must search using + * the original name — the renamed local alias only exists in the importing + * file, not in the file being imported from (#1730). + */ function buildImportedNamesMap( ctx: PipelineContext, relPath: string, symbols: ExtractorOutput, rootDir: string, -): Map { +): { importedNames: Map; importedOriginalNames: Map } { const importedNames = new Map(); - // Process dynamic imports first (lower priority), then static imports - // (higher priority). Static imports represent direct bindings while dynamic - // imports often use aliased destructuring (`{ foo: bar } = await import(…)`). - // When both contribute the same name, the static binding is authoritative. - // + const importedOriginalNames = new Map(); // Phase 8.4: trace through barrel files so that symbol names map to their // actual definition file, not the re-exporting barrel. Mirrors the tracing // already done in buildImportedNamesForNative (the native path). - const traceBarrel = (resolvedPath: string, cleanName: string): string => { + const traceBarrel = (resolvedPath: string, originalName: string): string => { if (!isBarrelFile(ctx, resolvedPath)) return resolvedPath; - const actual = resolveBarrelExportCached(ctx, resolvedPath, cleanName); + const actual = resolveBarrelExportCached(ctx, resolvedPath, originalName); return actual ?? resolvedPath; }; + const addImportNames = (imp: (typeof symbols.imports)[number], resolvedPath: string) => { + for (const { local, original } of importNamePairs(imp)) { + importedNames.set(local, traceBarrel(resolvedPath, original)); + if (original !== local) importedOriginalNames.set(local, original); + } + }; + // Process dynamic imports first (lower priority), then static imports + // (higher priority). Static imports represent direct bindings while dynamic + // imports often use aliased destructuring (`{ foo: bar } = await import(…)`). + // When both contribute the same name, the static binding is authoritative. for (const imp of symbols.imports) { if (!imp.dynamicImport) continue; const resolvedPath = getResolved(ctx, path.join(rootDir, relPath), imp.source); - for (const name of imp.names) { - const cleanName = name.replace(/^\*\s+as\s+/, ''); - importedNames.set(cleanName, traceBarrel(resolvedPath, cleanName)); - } + addImportNames(imp, resolvedPath); } for (const imp of symbols.imports) { if (imp.dynamicImport) continue; const resolvedPath = getResolved(ctx, path.join(rootDir, relPath), imp.source); - for (const name of imp.names) { - const cleanName = name.replace(/^\*\s+as\s+/, ''); - importedNames.set(cleanName, traceBarrel(resolvedPath, cleanName)); - } + addImportNames(imp, resolvedPath); } - return importedNames; + return { importedNames, importedOriginalNames }; } /** @@ -1226,6 +1262,7 @@ function resolveFallbackTargets( lookup: CallNodeLookup, typeMap: Map, definePropertyReceivers: Map | undefined, + importedOriginalNames?: ReadonlyMap, ): { targets: ReadonlyArray<{ id: number; file: string; kind?: string }>; importedFrom: string | null | undefined; @@ -1245,6 +1282,7 @@ function resolveFallbackTargets( importedNames, typeMap as Map, caller.callerName, + importedOriginalNames, ); // Fallback strategies, applied in order until one yields a match. Each @@ -1403,6 +1441,7 @@ function emitPtsNoReceiverEdges( seenCallEdges: Set, ptsEdgeRows: Map, allEdgeRows: EdgeRowTuple[], + importedOriginalNames?: ReadonlyMap, ): void { const scopedPtsKey = caller.callerName != null ? `${caller.callerName}::${call.name}` : null; // Module-level calls (callerName === null) use the '' sentinel emitted by @@ -1445,6 +1484,8 @@ function emitPtsNoReceiverEdges( relPath, importedNames, typeMap as Map, + undefined, + importedOriginalNames, ); const sortedAliasTargets = aliasTargets.length > 1 @@ -1488,6 +1529,7 @@ function emitPtsReceiverEdges( seenCallEdges: Set, ptsEdgeRows: Map, allEdgeRows: EdgeRowTuple[], + importedOriginalNames?: ReadonlyMap, ): void { const receiverKey = `${call.receiver}.${call.name}`; if (!ptsMap.has(receiverKey)) return; @@ -1499,6 +1541,8 @@ function emitPtsReceiverEdges( relPath, importedNames, typeMap as Map, + undefined, + importedOriginalNames, ); const sortedAliasTargets = aliasTargets.length > 1 @@ -1621,6 +1665,7 @@ function buildFileCallEdges( ptsMap?: PointsToMap | null, chaCtx?: ChaContext, importArtifactNames?: ReadonlyMap, + importedOriginalNames?: ReadonlyMap, ): void { // Tracks edges that were inserted by the pts fallback (edgeKey → allEdgeRows index). // Kept separate from seenCallEdges so that a subsequent direct-call edge for the same @@ -1656,6 +1701,7 @@ function buildFileCallEdges( lookup, typeMap, symbols.definePropertyReceivers, + importedOriginalNames, ); // Step 2: Emit direct-call edges (upgrades any pending pts edge in-place). @@ -1687,6 +1733,7 @@ function buildFileCallEdges( seenCallEdges, ptsEdgeRows, allEdgeRows, + importedOriginalNames, ); } @@ -1712,6 +1759,7 @@ function buildFileCallEdges( seenCallEdges, ptsEdgeRows, allEdgeRows, + importedOriginalNames, ); } diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 7c4b1ea9e..65326e366 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -311,12 +311,14 @@ function handleImportCapture(c: Record, imports: Import[ const impNode = c.imp_node!; const isTypeOnly = impNode.text.startsWith('import type'); const modPath = c.imp_source!.text.replace(/['"]/g, ''); - const names = extractImportNames(impNode); + const renamedImports: Array<{ local: string; imported: string }> = []; + const names = extractImportNames(impNode, renamedImports); imports.push({ source: modPath, names, line: nodeStartLine(impNode), typeOnly: isTypeOnly, + ...(renamedImports.length > 0 ? { renamedImports } : {}), }); } @@ -1478,12 +1480,14 @@ function handleImportStmt(node: TreeSitterNode, ctx: ExtractorOutput): void { const source = node.childForFieldName('source') || findChild(node, 'string'); if (source) { const modPath = source.text.replace(/['"]/g, ''); - const names = extractImportNames(node); + const renamedImports: Array<{ local: string; imported: string }> = []; + const names = extractImportNames(node, renamedImports); ctx.imports.push({ source: modPath, names, line: nodeStartLine(node), typeOnly: isTypeOnly, + ...(renamedImports.length > 0 ? { renamedImports } : {}), }); } } @@ -3843,10 +3847,45 @@ function findParentClass(node: TreeSitterNode): string | null { return findParentNode(node, JS_CLASS_TYPES); } -function extractImportNames(node: TreeSitterNode): string[] { +/** + * Extract the local binding names introduced by an import/export statement. + * + * `renamedOut`, when passed, collects `{ local, imported }` pairs for + * `import_specifier` nodes that rename a binding (`import { X as Y }`). + * + * Grammar note (see tree-sitter-javascript): for `import_specifier`, the + * `name` field is *always* present — it holds the name as declared by the + * source module. `alias` is only present for `X as Y` and holds the *local* + * binding actually referenced by call sites in this file. Preferring `name` + * unconditionally (as this function used to, and as the `export_specifier` + * branch below still deliberately does — see its comment) silently drops the + * local alias for every renamed import: call sites use `Y`, not `X` (#1730). + */ +function extractImportNames( + node: TreeSitterNode, + renamedOut?: Array<{ local: string; imported: string }>, +): string[] { const names: string[] = []; function scan(n: TreeSitterNode): void { - if (n.type === 'import_specifier' || n.type === 'export_specifier') { + if (n.type === 'import_specifier') { + const sourceNameNode = n.childForFieldName('name'); + const aliasNode = n.childForFieldName('alias'); + const localNode = aliasNode || sourceNameNode; + if (localNode) { + names.push(localNode.text); + if (aliasNode && sourceNameNode && aliasNode.text !== sourceNameNode.text) { + renamedOut?.push({ local: aliasNode.text, imported: sourceNameNode.text }); + } + } else { + names.push(n.text); + } + } else if (n.type === 'export_specifier') { + // export_specifier's `name` is the local declaration being (re-)exported; + // `alias` is the external name it's exposed as. Barrel/reexport tracing + // (resolveBarrelExport) keys off the *original* declaration name, so this + // branch is deliberately left picking `name` first — do not unify with + // the import_specifier branch above. Rename-aware barrel tracing for + // `export { X as Y } from …` is a distinct, separate gap (#1730 investigation). const nameNode = n.childForFieldName('name') || n.childForFieldName('alias'); if (nameNode) names.push(nameNode.text); else names.push(n.text); diff --git a/src/types.ts b/src/types.ts index 81fafbe03..96f7d2d46 100644 --- a/src/types.ts +++ b/src/types.ts @@ -510,6 +510,17 @@ export interface Import { reexport?: boolean; wildcardReexport?: boolean; dynamicImport?: boolean; + /** + * For `import { X as Y }` specifiers: the local binding name (Y) mapped to + * the original name exported by the source module (X). `names` always + * carries the local (post-rename) binding — this field lets call-edge + * resolution recover the *original* symbol name to look up in the imported + * file when a call site uses the local alias (#1730). Only populated for + * specifiers that actually rename a binding; entries where local === source + * name are omitted. Not populated for `export { X as Y } from …` reexports + * — barrel/reexport tracing is a distinct mechanism (see resolveBarrelExport). + */ + renamedImports?: Array<{ local: string; imported: string }>; // Language-specific flags (mutually exclusive at runtime) pythonImport?: boolean; goImport?: boolean; diff --git a/tests/integration/issue-1730-renamed-import-consumer.test.ts b/tests/integration/issue-1730-renamed-import-consumer.test.ts new file mode 100644 index 000000000..4d43843c0 --- /dev/null +++ b/tests/integration/issue-1730-renamed-import-consumer.test.ts @@ -0,0 +1,125 @@ +/** + * Regression test for #1730: consumer resolution must follow a call site + * through a renamed import specifier (`import { X as Y } from '...'`) back + * to the original exported symbol. + * + * Setup: two files. + * - helpers.js: `export function collectFiles() {...}` + * - consumer.js: `import { collectFiles as collectFilesUtil } from './helpers.js';` + * calls `collectFilesUtil()` from an exported function. + * + * Before the fix, the extractor recorded the *original* exported name + * (`collectFiles`) instead of the local alias actually used at the call site + * (`collectFilesUtil`) — so `importedNames` never had a key matching the call + * site text, and no `calls` edge was ever created. `codegraph exports + * helpers.js` reported zero consumers for a genuinely-used export. + * + * Verified on both engines — this is resolver/extractor logic mirrored in + * `crates/codegraph-core/`, so WASM and native must agree (#1730 root cause + * was duplicated in both). + */ + +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'; +import { exportsData } from '../../src/domain/queries.js'; + +const FILE_HELPERS = ` +export function collectFiles() { + return ['a.js', 'b.js']; +} +`; + +const FILE_CONSUMER = ` +import { collectFiles as collectFilesUtil } from './helpers.js'; + +export function useIt() { + return collectFilesUtil(); +} +`; + +let tmpWasm: string; +let tmpNative: string; + +beforeAll(async () => { + tmpWasm = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1730-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-1730-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 specifier (#1730)', () => { + it('WASM: useIt -> collectFiles calls edge exists (resolved through the rename)', () => { + const edges = getCallEdges(path.join(tmpWasm, '.codegraph', 'graph.db')); + const edge = edges.find((e) => e.src === 'useIt' && e.tgt === 'collectFiles'); + expect(edge).toBeDefined(); + expect(edge?.tgt_file).toBe('helpers.js'); + }); + + it('Native: useIt -> collectFiles calls edge exists (resolved through the rename)', () => { + const edges = getCallEdges(path.join(tmpNative, '.codegraph', 'graph.db')); + const edge = edges.find((e) => e.src === 'useIt' && e.tgt === 'collectFiles'); + expect(edge).toBeDefined(); + expect(edge?.tgt_file).toBe('helpers.js'); + }); + + it('no spurious edge is created against a nonexistent "collectFilesUtil" symbol', () => { + 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 === 'collectFilesUtil')).toBeUndefined(); + } + }); + + it('WASM: codegraph exports credits collectFiles with the renamed-import consumer', () => { + const data = exportsData('helpers.js', path.join(tmpWasm, '.codegraph', 'graph.db')); + const collectFiles = data.results.find((r: { name: string }) => r.name === 'collectFiles'); + expect(collectFiles).toBeDefined(); + expect(collectFiles.consumerCount).toBeGreaterThanOrEqual(1); + expect(collectFiles.consumers.map((c: { name: string }) => c.name)).toContain('useIt'); + }); + + it('Native: codegraph exports credits collectFiles with the renamed-import consumer', () => { + const data = exportsData('helpers.js', path.join(tmpNative, '.codegraph', 'graph.db')); + const collectFiles = data.results.find((r: { name: string }) => r.name === 'collectFiles'); + expect(collectFiles).toBeDefined(); + expect(collectFiles.consumerCount).toBeGreaterThanOrEqual(1); + expect(collectFiles.consumers.map((c: { name: string }) => c.name)).toContain('useIt'); + }); +}); diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index c825df2a9..5f9994fbe 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -130,6 +130,52 @@ describe('JavaScript parser', () => { expect(symbols.imports[0].names).toContain('bar'); }); + // Regression coverage for #1730: `import { X as Y }` must record the local + // binding (Y) — what call sites actually reference — in `names`, plus the + // `{ local, imported }` rename pair so call-edge resolution can recover the + // original exported symbol (X) when a call site uses the local alias. + describe('renamed import specifiers (#1730)', () => { + it('records the local alias, not the source name, in imports[].names', () => { + const symbols = parseJS(`import { collectFiles as collectFilesUtil } from './helpers';`); + expect(symbols.imports).toHaveLength(1); + expect(symbols.imports[0].names).toEqual(['collectFilesUtil']); + }); + + it('records the local -> original rename pair in renamedImports', () => { + const symbols = parseJS(`import { collectFiles as collectFilesUtil } from './helpers';`); + expect(symbols.imports[0].renamedImports).toEqual([ + { local: 'collectFilesUtil', imported: 'collectFiles' }, + ]); + }); + + it('does not set renamedImports for non-renamed specifiers', () => { + const symbols = parseJS(`import { foo, bar } from './baz';`); + expect(symbols.imports[0].renamedImports).toBeUndefined(); + }); + + it('handles a mix of renamed and non-renamed specifiers in one statement', () => { + const symbols = parseJS( + `import { foo, collectFiles as collectFilesUtil, bar } from './mixed';`, + ); + expect(symbols.imports[0].names).toEqual(['foo', 'collectFilesUtil', 'bar']); + expect(symbols.imports[0].renamedImports).toEqual([ + { local: 'collectFilesUtil', imported: 'collectFiles' }, + ]); + }); + + it('does not apply rename tracking to export_specifier (reexport) statements', () => { + // export_specifier semantics differ (name = local declaration being + // re-exported, alias = external name) — barrel/reexport tracing keys off + // the original declaration name, so this is intentionally left as-is. + // See resolveBarrelExport / issues filed from the #1730 investigation. + const symbols = parseJS(`export { collectFiles as friendlyName } from './helpers';`); + expect(symbols.imports).toHaveLength(1); + expect(symbols.imports[0].reexport).toBe(true); + expect(symbols.imports[0].names).toEqual(['collectFiles']); + expect(symbols.imports[0].renamedImports).toBeUndefined(); + }); + }); + it('extracts call expressions', () => { const symbols = parseJS(`import { foo } from './bar'; foo(); baz();`); expect(symbols.calls).toContainEqual(expect.objectContaining({ name: 'foo' }));