diff --git a/crates/codegraph-core/src/domain/graph/builder/barrel_resolution.rs b/crates/codegraph-core/src/domain/graph/builder/barrel_resolution.rs index 777c416c1..aa531e38b 100644 --- a/crates/codegraph-core/src/domain/graph/builder/barrel_resolution.rs +++ b/crates/codegraph-core/src/domain/graph/builder/barrel_resolution.rs @@ -7,11 +7,19 @@ use std::collections::HashSet; +use crate::types::RenamedImport; + /// Minimal view of a single reexport entry, borrowed from the caller's data. pub struct ReexportRef<'a> { pub source: &'a str, pub names: &'a [String], pub wildcard_reexport: bool, + /// `{ local, imported }` pairs for `export { X as Y } from …` specifiers + /// within this entry: `local` is the external name (Y) a consumer of the + /// barrel imports, `imported` is the name (X) actually declared in + /// `source`. Lets `resolve_barrel_export` translate a consumer's + /// requested name back to X before matching it against `names` (#1823). + pub renames: &'a [RenamedImport], } /// Trait that abstracts over the different context types in `edge_builder` and @@ -26,6 +34,31 @@ pub trait BarrelContext { fn has_definition(&self, file_path: &str, symbol: &str) -> bool; } +/// Result of successfully resolving a symbol through a barrel chain. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BarrelResolution { + /// The file that actually defines the symbol. + pub file: String, + /// The name the symbol is declared under in `file`. Identical to the + /// requested symbol name unless one of the barrel hops in the chain + /// renamed it (`export { X as Y } from …`), in which case this is the + /// *original* declared name (X) to search for in `file` (#1823). + pub name: String, +} + +/// Translate a consumer-requested name through a single reexport entry's +/// rename table, if it renamed that name (`export { X as Y } from …` records +/// `{ local: Y, imported: X }`). Returns `symbol_name` unchanged when the +/// entry doesn't rename it — covers both "not renamed at all" and "requested +/// name isn't one of this entry's external aliases" (#1823). +fn translate_through_rename(re: &ReexportRef, symbol_name: &str) -> String { + re.renames + .iter() + .find(|r| r.local == symbol_name) + .map(|r| r.imported.clone()) + .unwrap_or_else(|| symbol_name.to_string()) +} + /// Recursively resolve a symbol through barrel reexport chains. /// /// Mirrors `resolveBarrelExport()` in `resolve-imports.ts`. @@ -36,7 +69,7 @@ pub fn resolve_barrel_export( barrel_path: &str, symbol_name: &str, visited: &mut HashSet, -) -> Option { +) -> Option { if visited.contains(barrel_path) { return None; } @@ -45,27 +78,34 @@ pub fn resolve_barrel_export( let reexports = ctx.reexports_for(barrel_path)?; for re in &reexports { + // Translate the requested external name (Y) back to the name + // actually declared in `re.source` (X) before matching + // `re.names`/checking the target's definitions — `re.names` always + // carries the original declaration name, never the barrel's + // external alias (#1823). + let lookup_name = translate_through_rename(re, symbol_name); + // Named reexports (non-wildcard) if !re.names.is_empty() && !re.wildcard_reexport { - if re.names.iter().any(|n| n == symbol_name) { - if ctx.has_definition(re.source, symbol_name) { - return Some(re.source.to_string()); + if re.names.iter().any(|n| n == &lookup_name) { + if ctx.has_definition(re.source, &lookup_name) { + return Some(BarrelResolution { file: re.source.to_string(), name: lookup_name }); } - let deeper = resolve_barrel_export(ctx, re.source, symbol_name, visited); + let deeper = resolve_barrel_export(ctx, re.source, &lookup_name, visited); if deeper.is_some() { return deeper; } // Fallback: return source even if no definition found - return Some(re.source.to_string()); + return Some(BarrelResolution { file: re.source.to_string(), name: lookup_name }); } continue; } // Wildcard or empty-names reexports - if ctx.has_definition(re.source, symbol_name) { - return Some(re.source.to_string()); + if ctx.has_definition(re.source, &lookup_name) { + return Some(BarrelResolution { file: re.source.to_string(), name: lookup_name }); } - let deeper = resolve_barrel_export(ctx, re.source, symbol_name, visited); + let deeper = resolve_barrel_export(ctx, re.source, &lookup_name, visited); if deeper.is_some() { return deeper; } @@ -80,7 +120,7 @@ mod tests { use std::collections::HashMap; struct TestContext { - reexports: HashMap, bool)>>, + reexports: HashMap, bool, Vec)>>, definitions: HashMap>, } @@ -89,10 +129,11 @@ mod tests { self.reexports.get(barrel_path).map(|entries| { entries .iter() - .map(|(source, names, wildcard)| ReexportRef { + .map(|(source, names, wildcard, renames)| ReexportRef { source: source.as_str(), names: names.as_slice(), wildcard_reexport: *wildcard, + renames: renames.as_slice(), }) .collect() }) @@ -110,7 +151,7 @@ mod tests { let mut reexports = HashMap::new(); reexports.insert( "src/index.ts".to_string(), - vec![("src/utils.ts".to_string(), vec!["foo".to_string()], false)], + vec![("src/utils.ts".to_string(), vec!["foo".to_string()], false, vec![])], ); let mut definitions = HashMap::new(); definitions.insert( @@ -121,7 +162,10 @@ mod tests { let ctx = TestContext { reexports, definitions }; let mut visited = HashSet::new(); let result = resolve_barrel_export(&ctx, "src/index.ts", "foo", &mut visited); - assert_eq!(result.as_deref(), Some("src/utils.ts")); + assert_eq!( + result, + Some(BarrelResolution { file: "src/utils.ts".to_string(), name: "foo".to_string() }) + ); } #[test] @@ -129,7 +173,7 @@ mod tests { let mut reexports = HashMap::new(); reexports.insert( "src/index.ts".to_string(), - vec![("src/utils.ts".to_string(), vec![], true)], + vec![("src/utils.ts".to_string(), vec![], true, vec![])], ); let mut definitions = HashMap::new(); definitions.insert( @@ -140,7 +184,10 @@ mod tests { let ctx = TestContext { reexports, definitions }; let mut visited = HashSet::new(); let result = resolve_barrel_export(&ctx, "src/index.ts", "bar", &mut visited); - assert_eq!(result.as_deref(), Some("src/utils.ts")); + assert_eq!( + result, + Some(BarrelResolution { file: "src/utils.ts".to_string(), name: "bar".to_string() }) + ); } #[test] @@ -148,11 +195,11 @@ mod tests { let mut reexports = HashMap::new(); reexports.insert( "src/index.ts".to_string(), - vec![("src/mid.ts".to_string(), vec![], true)], + vec![("src/mid.ts".to_string(), vec![], true, vec![])], ); reexports.insert( "src/mid.ts".to_string(), - vec![("src/deep.ts".to_string(), vec!["baz".to_string()], false)], + vec![("src/deep.ts".to_string(), vec!["baz".to_string()], false, vec![])], ); let mut definitions = HashMap::new(); definitions.insert( @@ -163,7 +210,10 @@ mod tests { let ctx = TestContext { reexports, definitions }; let mut visited = HashSet::new(); let result = resolve_barrel_export(&ctx, "src/index.ts", "baz", &mut visited); - assert_eq!(result.as_deref(), Some("src/deep.ts")); + assert_eq!( + result, + Some(BarrelResolution { file: "src/deep.ts".to_string(), name: "baz".to_string() }) + ); } #[test] @@ -171,11 +221,11 @@ mod tests { let mut reexports = HashMap::new(); reexports.insert( "src/a.ts".to_string(), - vec![("src/b.ts".to_string(), vec![], true)], + vec![("src/b.ts".to_string(), vec![], true, vec![])], ); reexports.insert( "src/b.ts".to_string(), - vec![("src/a.ts".to_string(), vec![], true)], + vec![("src/a.ts".to_string(), vec![], true, vec![])], ); let ctx = TestContext { @@ -186,4 +236,41 @@ mod tests { let result = resolve_barrel_export(&ctx, "src/a.ts", "missing", &mut visited); assert_eq!(result, None); } + + /// `export { realName as friendlyName } from './underlying'` — a consumer + /// requesting `friendlyName` must resolve to `underlying.ts`'s `realName` + /// definition, with the resolution's `name` reporting the *declared* name + /// (#1823). + #[test] + fn resolves_renamed_reexport() { + let mut reexports = HashMap::new(); + reexports.insert( + "src/barrel.ts".to_string(), + vec![( + "src/underlying.ts".to_string(), + vec!["realName".to_string()], + false, + vec![RenamedImport { + local: "friendlyName".to_string(), + imported: "realName".to_string(), + }], + )], + ); + let mut definitions = HashMap::new(); + definitions.insert( + "src/underlying.ts".to_string(), + HashSet::from(["realName".to_string()]), + ); + + let ctx = TestContext { reexports, definitions }; + let mut visited = HashSet::new(); + let result = resolve_barrel_export(&ctx, "src/barrel.ts", "friendlyName", &mut visited); + assert_eq!( + result, + Some(BarrelResolution { + file: "src/underlying.ts".to_string(), + name: "realName".to_string(), + }) + ); + } } diff --git a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs index 8c7fa58e7..83dd1b75e 100644 --- a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs +++ b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs @@ -1189,7 +1189,7 @@ fn compute_edge_relevant_files( if let Some(ultimate) = import_ctx.resolve_barrel_export(&resolved, clean_name, &mut visited) { - relevant_files.insert(ultimate); + relevant_files.insert(ultimate.file); } } } @@ -1341,16 +1341,18 @@ fn collect_imported_names_for_file( continue; } let mut target_file = resolved_path.clone(); + let mut target_name = original; if import_ctx.is_barrel_file(&resolved_path) { let mut visited = HashSet::new(); - if let Some(actual) = - import_ctx.resolve_barrel_export(&resolved_path, &original, &mut visited) + if let Some(resolved) = + import_ctx.resolve_barrel_export(&resolved_path, &target_name, &mut visited) { - target_file = actual; + target_file = resolved.file; + target_name = resolved.name; } } imported_names.push(ImportedName { - imported: if original != local { Some(original) } else { None }, + imported: if target_name != local { Some(target_name) } else { None }, name: local, file: target_file, }); 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 1e2c923f6..567f5dec2 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 @@ -6,7 +6,7 @@ use crate::domain::graph::builder::barrel_resolution::{self, BarrelContext, Reex use crate::domain::graph::resolve; use crate::types::{ ArrayCallbackBinding, ArrayElemBinding, FnRefBinding, ForOfBinding, ObjectPropBinding, - ObjectRestParamBinding, ParamBinding, SpreadArgBinding, ThisCallBinding, + ObjectRestParamBinding, ParamBinding, RenamedImport, SpreadArgBinding, ThisCallBinding, }; /// Kind sets for hierarchy edge resolution -- mirrors the JS constants in @@ -1503,6 +1503,10 @@ pub struct ReexportEntryInput { pub names: Vec, #[napi(js_name = "wildcardReexport")] pub wildcard_reexport: bool, + /// `{ local, imported }` pairs for `export { X as Y } from …` specifiers + /// within this entry — see `barrel_resolution::ReexportRef::renames` (#1823). + #[napi(ts_type = "RenamedImport[] | undefined")] + pub renames: Option>, } #[napi(object)] @@ -1545,7 +1549,11 @@ struct ImportEdgeContext<'a> { file_defs: HashMap<&'a str, HashSet<&'a str>>, /// Symbol node lookup: (name, file) → node ID. /// Used to create symbol-level `imports-type` edges for type-only imports. - symbol_node_map: HashMap<(&'a str, &'a str), u32>, + /// + /// Owned keys (rather than `&'a str`) because a barrel-rename lookup key + /// (#1823) is a freshly-resolved name that doesn't borrow from `'a` input + /// data. + symbol_node_map: HashMap<(String, String), u32>, } impl<'a> ImportEdgeContext<'a> { @@ -1583,7 +1591,7 @@ impl<'a> ImportEdgeContext<'a> { let mut symbol_node_map = HashMap::with_capacity(symbol_nodes.len()); for entry in symbol_nodes { - symbol_node_map.insert((entry.name.as_str(), entry.file.as_str()), entry.node_id); + symbol_node_map.insert((entry.name.clone(), entry.file.clone()), entry.node_id); } Self { resolved, reexport_map, file_node_map, barrel_set, file_defs, symbol_node_map } @@ -1599,6 +1607,7 @@ impl<'a> BarrelContext for ImportEdgeContext<'a> { source: re.source.as_str(), names: &re.names, wildcard_reexport: re.wildcard_reexport, + renames: re.renames.as_deref().unwrap_or(&[]), }) .collect() }) @@ -1716,6 +1725,13 @@ fn is_wildcard_reexport(imp: &ImportInfo) -> bool { /// externally, so this naturally resolves `export { X as Z }` against `X`'s /// own node — the emitted edge (and downstream `reexportedSymbols` entry) /// is reported under the symbol's own declared name, not the barrel alias. +/// +/// When `resolved_path` is itself a barrel that renamed the requested name +/// further down its own reexport chain (`export { X as Y } from …`), +/// `resolve_barrel_export` reports the name actually declared in the +/// resolved file — which may differ from `clean_name` — so the lookup below +/// must use that reported name, not `clean_name`, against the barrel target +/// (#1823). fn emit_named_symbol_edges( edges: &mut Vec, file_input: &ImportEdgeFileInput, @@ -1742,9 +1758,12 @@ fn emit_named_symbol_edges( None }; let sym_id = barrel_target - .as_deref() - .and_then(|f| ctx.symbol_node_map.get(&(clean_name, f))) - .or_else(|| ctx.symbol_node_map.get(&(clean_name, resolved_path))); + .as_ref() + .and_then(|resolved| ctx.symbol_node_map.get(&(resolved.name.clone(), resolved.file.clone()))) + .or_else(|| { + ctx.symbol_node_map + .get(&(clean_name.to_string(), resolved_path.to_string())) + }); if let Some(&id) = sym_id { edges.push(ComputedEdge { source_id: file_input.file_node_id, @@ -1789,7 +1808,7 @@ fn emit_barrel_through_edges( &mut visited, ); let actual_source = match actual { - Some(s) => s, + Some(resolved) => resolved.file, None => continue, }; if actual_source == resolved_path || resolved_sources.contains(&actual_source) { @@ -2198,6 +2217,7 @@ mod import_edge_tests { source: "src/utils.ts".to_string(), names: vec!["foo".to_string()], wildcard_reexport: false, + renames: None, }], }]; let node_ids = vec![ @@ -2236,6 +2256,7 @@ mod import_edge_tests { source: "src/mid.ts".to_string(), names: vec![], wildcard_reexport: true, + renames: None, }], }, FileReexports { @@ -2244,6 +2265,7 @@ mod import_edge_tests { source: "src/deep.ts".to_string(), names: vec!["deep".to_string()], wildcard_reexport: false, + renames: None, }], }, ]; @@ -2277,6 +2299,7 @@ mod import_edge_tests { source: "src/b.ts".to_string(), names: vec![], wildcard_reexport: true, + renames: None, }], }, FileReexports { @@ -2285,6 +2308,7 @@ mod import_edge_tests { source: "src/a.ts".to_string(), names: vec![], wildcard_reexport: true, + renames: None, }], }, ]; @@ -2316,6 +2340,7 @@ mod import_edge_tests { source: "src/helpers.ts".to_string(), names: vec![], wildcard_reexport: true, + renames: None, }], }]; let node_ids = vec![ @@ -2348,6 +2373,7 @@ mod import_edge_tests { source: "src/real.ts".to_string(), names: vec!["a".to_string(), "b".to_string()], wildcard_reexport: false, + renames: None, }], }]; let node_ids = vec![ @@ -2361,6 +2387,47 @@ mod import_edge_tests { // 1 direct import + 1 barrel-through (deduped, not 2) assert_eq!(edges.len(), 2); } + + /// `export { realName as friendlyName } from './underlying'` — a consumer + /// importing the barrel's external name `friendlyName` must produce a + /// barrel-through edge to `underlying.ts`, the file that actually + /// declares `realName` (#1823). + #[test] + fn renamed_barrel_reexport_resolution() { + let files = vec![ + make_file("src/app.ts", 1, vec![ + make_import("./barrel", vec!["friendlyName"], false, false, false), + ], vec![]), + make_file("src/barrel.ts", 10, vec![], vec![]), + make_file("src/underlying.ts", 20, vec![], vec!["realName"]), + ]; + let resolved = vec![make_resolved("/root/src/app.ts", "./barrel", "src/barrel.ts")]; + let reexports = vec![FileReexports { + file: "src/barrel.ts".to_string(), + reexports: vec![ReexportEntryInput { + source: "src/underlying.ts".to_string(), + names: vec!["realName".to_string()], + wildcard_reexport: false, + renames: Some(vec![RenamedImport { + local: "friendlyName".to_string(), + imported: "realName".to_string(), + }]), + }], + }]; + let node_ids = vec![ + make_node_entry("src/app.ts", 1), + make_node_entry("src/barrel.ts", 10), + make_node_entry("src/underlying.ts", 20), + ]; + let barrels = vec!["src/barrel.ts".to_string()]; + + let edges = build_import_edges(files, resolved, reexports, node_ids, barrels, "/root".to_string(), None); + assert_eq!(edges.len(), 2); + // Barrel-through edge resolves through the rename to underlying.ts. + assert_eq!(edges[1].target_id, 20); + assert_eq!(edges[1].confidence, 0.9); + assert_eq!(edges[1].kind, "imports"); + } } #[cfg(test)] 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 7681df385..eccf8801e 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 @@ -6,7 +6,7 @@ use crate::domain::graph::builder::barrel_resolution::{self, BarrelContext, ReexportRef}; use crate::domain::graph::resolve; -use crate::types::{FileSymbols, PathAliases}; +use crate::types::{FileSymbols, PathAliases, RenamedImport}; use rusqlite::Connection; use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; @@ -17,6 +17,9 @@ pub struct ReexportEntry { pub source: String, pub names: Vec, pub wildcard_reexport: bool, + /// `{ local, imported }` pairs for `export { X as Y } from …` specifiers + /// within this entry — see `barrel_resolution::ReexportRef::renames` (#1823). + pub renames: Vec, } /// Context for import edge building — holds resolved imports, reexport map, and file symbols. @@ -89,7 +92,7 @@ impl ImportEdgeContext { barrel_path: &str, symbol_name: &str, visited: &mut HashSet, - ) -> Option { + ) -> Option { barrel_resolution::resolve_barrel_export(self, barrel_path, symbol_name, visited) } } @@ -103,6 +106,7 @@ impl BarrelContext for ImportEdgeContext { source: re.source.as_str(), names: &re.names, wildcard_reexport: re.wildcard_reexport, + renames: &re.renames, }) .collect() }) @@ -180,6 +184,7 @@ pub fn build_reexport_map(ctx: &ImportEdgeContext) -> HashMap HashSet<(String, Stri continue; } let mut target_file = resolved_path.clone(); + let mut target_name = original; if ctx.is_barrel_file(&resolved_path) { let mut visited = HashSet::new(); - if let Some(actual) = - ctx.resolve_barrel_export(&resolved_path, &original, &mut visited) + if let Some(resolved) = + ctx.resolve_barrel_export(&resolved_path, &target_name, &mut visited) { - target_file = actual; + target_file = resolved.file; + target_name = resolved.name; } } - pairs.insert((original, target_file)); + pairs.insert((target_name, target_file)); } } } @@ -384,14 +391,17 @@ fn emit_named_symbol_rows( continue; } let mut target_file = resolved_path.to_string(); + let mut target_name = original; if ctx.is_barrel_file(resolved_path) { let mut visited = HashSet::new(); - if let Some(actual) = ctx.resolve_barrel_export(resolved_path, &original, &mut visited) + if let Some(resolved) = + ctx.resolve_barrel_export(resolved_path, &target_name, &mut visited) { - target_file = actual; + target_file = resolved.file; + target_name = resolved.name; } } - if let Some(&sym_id) = symbol_node_ids.get(&(original, target_file)) { + if let Some(&sym_id) = symbol_node_ids.get(&(target_name, target_file)) { edges.push(EdgeRow { source_id: file_node_id, target_id: sym_id, @@ -428,7 +438,7 @@ fn emit_barrel_through_rows( let mut visited = HashSet::new(); let actual_source = match ctx.resolve_barrel_export(resolved_path, &original, &mut visited) { - Some(s) => s, + Some(resolved) => resolved.file, None => continue, }; if actual_source == resolved_path || !resolved_sources.insert(actual_source.clone()) { diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index d7f6cc6be..cc1ab296e 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -1791,7 +1791,10 @@ fn collect_exported_var_declarations( fn handle_reexport(node: &Node, source_node: &Node, source: &[u8], symbols: &mut FileSymbols) { let mod_path = node_text(source_node, source) .replace(&['\'', '"'][..], ""); - let reexport_names = extract_import_names(node, source); + let mut reexport_renames = Vec::new(); + let mut type_only_names = Vec::new(); + let reexport_names = + extract_import_names_with_renames(node, source, &mut reexport_renames, &mut type_only_names); let text = node_text(node, source); let is_wildcard = text.contains("export *") || text.contains("export*"); let mut imp = Import::new(mod_path, reexport_names.clone(), start_line(node)); @@ -1799,6 +1802,9 @@ fn handle_reexport(node: &Node, source_node: &Node, source: &[u8], symbols: &mut if is_wildcard && reexport_names.is_empty() { imp.wildcard_reexport = Some(true); } + if !reexport_renames.is_empty() { + imp.renamed_imports = Some(reexport_renames); + } symbols.imports.push(imp); } @@ -3180,14 +3186,6 @@ fn extract_rest_identifier(rest_node: &Node, source: &[u8], names: &mut Vec Vec { - let mut names = Vec::new(); - let mut renamed = Vec::new(); - let mut type_only = Vec::new(); - scan_import_names(node, source, &mut names, &mut renamed, &mut type_only); - names -} - /// Extract import names and collect `{ local, imported }` pairs for /// `import_specifier` nodes that rename a binding (`import { X as Y }`), plus /// the local names of specifiers carrying an inline `type`/`typeof` @@ -3219,9 +3217,16 @@ fn scan_import_names( /// `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). +/// unconditionally (as this function used to) silently drops the local alias +/// for every renamed import: call sites use `Y`, not `X` (#1730). +/// +/// `export_specifier` has the same `name`/`alias` shape but the opposite +/// consumer: `name` (X) is the declaration being re-exported, `alias` (Y) is +/// the external name a consumer of *this* barrel imports. `names` keeps +/// recording X (barrel/reexport tracing keys off the original declaration — +/// see `resolve_barrel_export`), but when the two differ, `renamed_out` also +/// receives the `{ local: Y, imported: X }` pair so barrel resolution can +/// translate a consumer's requested external name back to X (#1823). /// /// The tree-sitter-typescript grammar defines `import_specifier` as /// `optional(choice('type', 'typeof'))` followed by the name/alias fields, so @@ -3268,16 +3273,26 @@ fn scan_import_names_depth( "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")); + // (resolve_barrel_export) keys off the *original* declaration name, so + // this branch keeps picking `name` first — do not unify with the + // import_specifier branch above. When `alias` differs from `name`, the + // rename pair is recorded in renamed_out so resolve_barrel_export can + // map a consumer's requested external name (Y) back to X (#1823). + let source_name_node = node.child_by_field_name("name"); + let alias_node = node.child_by_field_name("alias"); + let name_node = source_name_node.or(alias_node); if let Some(name_node) = name_node { names.push(node_text(&name_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()); } diff --git a/src/domain/graph/builder/call-resolver.ts b/src/domain/graph/builder/call-resolver.ts index e02d551ad..aaf613bc6 100644 --- a/src/domain/graph/builder/call-resolver.ts +++ b/src/domain/graph/builder/call-resolver.ts @@ -26,7 +26,14 @@ export interface CallNodeLookup { ): ReadonlyArray<{ id: number; file: string; kind?: string }>; byName(name: string): ReadonlyArray<{ id: number; file: string; kind?: string }>; isBarrel(file: string): boolean; - resolveBarrel(barrelFile: string, symbolName: string): string | null; + /** + * Resolve `symbolName` through `barrelFile`'s re-export chain. `name` in the + * result is the name actually declared in the returned `file` — identical + * to `symbolName` unless a barrel hop renamed it (`export { X as Y } from …`, + * #1823), in which case callers must search the target file for `name`, not + * the originally-requested `symbolName`. + */ + resolveBarrel(barrelFile: string, symbolName: string): { file: string; name: string } | null; nodeId(name: string, kind: string, file: string, line: number): { id: number } | undefined; } @@ -279,9 +286,9 @@ export function resolveCallTargets( if (importedFrom) { targets = lookup.byNameAndFile(targetName, importedFrom); if (targets.length === 0 && lookup.isBarrel(importedFrom)) { - const actualSource = lookup.resolveBarrel(importedFrom, targetName); - if (actualSource) { - targets = lookup.byNameAndFile(targetName, actualSource); + const resolved = lookup.resolveBarrel(importedFrom, targetName); + if (resolved) { + targets = lookup.byNameAndFile(resolved.name, resolved.file); } } } diff --git a/src/domain/graph/builder/context.ts b/src/domain/graph/builder/context.ts index 94cf99f8a..59ceaa5b8 100644 --- a/src/domain/graph/builder/context.ts +++ b/src/domain/graph/builder/context.ts @@ -17,6 +17,7 @@ import type { ParseChange, PathAliases, } from '../../../types.js'; +import type { BarrelExportResolution } from './stages/resolve-imports.js'; export class PipelineContext { // ── Inputs (set during setup) ────────────────────────────────────── @@ -69,7 +70,7 @@ export class PipelineContext { reexportMap!: Map; barrelOnlyFiles!: Set; /** Phase 8.4: cache for resolveBarrelExport results keyed as "barrelPath|symbolName". */ - barrelExportCache: Map = new Map(); + barrelExportCache: Map = new Map(); // ── Node lookup (set by insertNodes / buildEdges stages) ─────────── nodesByName!: Map; diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index d1961b8b1..8c2c3ca49 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -309,12 +309,25 @@ function isBarrelFile(db: BetterSqlite3Database, relPath: string): boolean { return (reexportCount || 0) > 0; } +/** + * KNOWN LIMITATION, tracked separately in #1967: this is `codegraph watch`'s + * single-file rebuild path (see `watcher.ts`) — unlike the `codegraph build` + * resolver (`resolveBarrelExport` in resolve-imports.ts, fixed for #1823), + * this has no way to recover a barrel's `export { X as Y } from …` rename + * table when the barrel file itself isn't part of the current watch batch — + * that mapping only exists in the barrel's freshly-parsed `Import.renamedImports`, + * which isn't persisted to the DB and this function has no reparse access to. + * It therefore still resolves purely by direct name match — renamed barrel + * re-exports are not resolved when only a consumer file changes under watch. + * `name` in the result mirrors the shared `CallNodeLookup.resolveBarrel` + * shape but is always just the input `symbolName` unchanged. + */ function resolveBarrelTarget( db: BetterSqlite3Database, barrelPath: string, symbolName: string, visited: Set = new Set(), -): string | null { +): { file: string; name: string } | null { if (visited.has(barrelPath)) return null; visited.add(barrelPath); @@ -326,7 +339,7 @@ function resolveBarrelTarget( for (const { file: targetFile } of reexportTargets) { // Check if the symbol is defined in this target file const hasDef = hasDefStmt.get(symbolName, targetFile); - if (hasDef) return targetFile; + if (hasDef) return { file: targetFile, name: symbolName }; // Recurse through barrel chains if (isBarrelFile(db, targetFile)) { @@ -352,7 +365,8 @@ function resolveBarrelImportEdges( if (!isBarrelFile(db, resolvedPath)) return edgesAdded; const resolvedSources = new Set(); for (const { original } of importNamePairs(imp)) { - const actualSource = resolveBarrelTarget(db, resolvedPath, original); + const resolved = resolveBarrelTarget(db, resolvedPath, original); + const actualSource = resolved?.file; if (actualSource && actualSource !== resolvedPath && !resolvedSources.has(actualSource)) { resolvedSources.add(actualSource); const actualRow = stmts.getNodeId.get(actualSource, 'file', actualSource, 0); @@ -390,11 +404,15 @@ function emitNamedSymbolEdges( for (const { original, typeOnly } of importNamePairs(imp)) { if (edgeKind === 'imports-type' && !typeOnly) continue; let targetFile = resolvedPath; + let targetName = original; if (db && isBarrelFile(db, resolvedPath)) { - const actual = resolveBarrelTarget(db, resolvedPath, original); - if (actual) targetFile = actual; + const resolved = resolveBarrelTarget(db, resolvedPath, original); + if (resolved) { + targetFile = resolved.file; + targetName = resolved.name; + } } - const candidates = stmts.findNodeInFile.all(original, targetFile) as Array<{ + const candidates = stmts.findNodeInFile.all(targetName, targetFile) as Array<{ id: number; file: string; }>; @@ -498,12 +516,16 @@ function buildImportedNamesMap( // `computeConfidence` gets `importedFrom === targetFile` and returns 1.0 // instead of the cross-directory fallback (0.3). let targetFile = resolvedPath; + let targetName = original; if (isBarrelFile(db, resolvedPath)) { - const actual = resolveBarrelTarget(db, resolvedPath, original); - if (actual) targetFile = actual; + const resolved = resolveBarrelTarget(db, resolvedPath, original); + if (resolved) { + targetFile = resolved.file; + targetName = resolved.name; + } } importedNames.set(local, targetFile); - if (original !== local) importedOriginalNames.set(local, original); + if (targetName !== local) importedOriginalNames.set(local, targetName); } } return { importedNames, importedOriginalNames }; diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 09580d88b..d85129c3f 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -205,11 +205,15 @@ function emitNamedSymbolEdges( for (const { original, typeOnly } of importNamePairs(imp)) { if (edgeKind === 'imports-type' && !typeOnly) continue; let targetFile = resolvedPath; + let targetName = original; if (isBarrelFile(ctx, resolvedPath)) { - const actual = resolveBarrelExportCached(ctx, resolvedPath, original); - if (actual) targetFile = actual; + const resolved = resolveBarrelExportCached(ctx, resolvedPath, original); + if (resolved) { + targetFile = resolved.file; + targetName = resolved.name; + } } - const candidates = ctx.nodesByNameAndFile.get(`${original}|${targetFile}`); + const candidates = ctx.nodesByNameAndFile.get(`${targetName}|${targetFile}`); if (candidates && candidates.length > 0) { allEdgeRows.push([fileNodeId, candidates[0]!.id, edgeKind, 1.0, 0, null, null]); } @@ -287,7 +291,8 @@ function buildBarrelEdges( ): void { const resolvedSources = new Set(); for (const { original } of importNamePairs(imp)) { - const actualSource = resolveBarrelExportCached(ctx, resolvedPath, original); + const resolved = resolveBarrelExportCached(ctx, resolvedPath, original); + const actualSource = resolved?.file; if (actualSource && actualSource !== resolvedPath && !resolvedSources.has(actualSource)) { resolvedSources.add(actualSource); const actualRow = getNodeIdStmt.get(actualSource, 'file', actualSource, 0); @@ -865,18 +870,24 @@ function buildImportedNamesForNative( const resolvedPath = getResolved(ctx, path.join(rootDir, relPath), imp.source); for (const { local, original } of importNamePairs(imp)) { let targetFile = resolvedPath; + let targetName = original; if (isBarrelFile(ctx, resolvedPath)) { - const actual = resolveBarrelExportCached(ctx, resolvedPath, original); - if (actual) targetFile = actual; + const resolved = resolveBarrelExportCached(ctx, resolvedPath, original); + if (resolved) { + targetFile = resolved.file; + targetName = resolved.name; + } } - // `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. + // `imported` carries the name actually declared in `targetFile` so the + // native resolver can look it up there instead of the local alias + // (which only exists in this file, #1730) or the barrel's external + // rename (which only exists in the re-exporting barrel, #1823). + // Omitted when identical to the local binding. const entry: { name: string; file: string; imported?: string } = { name: local, file: targetFile, }; - if (original !== local) entry.imported = original; + if (targetName !== local) entry.imported = targetName; importedNames.push(entry); } }; @@ -985,12 +996,15 @@ function buildCallEdgesJS( /** * 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). + * (`importedNames`), plus, whenever the name to search for in that file + * differs from the local binding — a renamed specifier (`import { X as Y }`, + * #1730) or a barrel hop that renamed the symbol on re-export + * (`export { X as Y } from …`, #1823) — the name actually declared there + * (`importedOriginalNames`, keyed by local name). * * 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). + * that declared name — neither the renamed local alias nor the barrel's + * external rename exists in the file being imported from. */ function buildImportedNamesMap( ctx: PipelineContext, @@ -1003,15 +1017,19 @@ function buildImportedNamesMap( // 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, originalName: string): string => { - if (!isBarrelFile(ctx, resolvedPath)) return resolvedPath; - const actual = resolveBarrelExportCached(ctx, resolvedPath, originalName); - return actual ?? resolvedPath; + const traceBarrel = ( + resolvedPath: string, + originalName: string, + ): { file: string; name: string } => { + if (!isBarrelFile(ctx, resolvedPath)) return { file: resolvedPath, name: originalName }; + const resolved = resolveBarrelExportCached(ctx, resolvedPath, originalName); + return resolved ?? { file: resolvedPath, name: originalName }; }; 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); + const { file, name } = traceBarrel(resolvedPath, original); + importedNames.set(local, file); + if (name !== local) importedOriginalNames.set(local, name); } }; // Process dynamic imports first (lower priority), then static imports @@ -1049,8 +1067,8 @@ function buildImportArtifactNames( const combined = new Map(importedNames); const traceBarrel = (resolvedPath: string, cleanName: string): string => { if (!isBarrelFile(ctx, resolvedPath)) return resolvedPath; - const actual = resolveBarrelExportCached(ctx, resolvedPath, cleanName); - return actual ?? resolvedPath; + const resolved = resolveBarrelExportCached(ctx, resolvedPath, cleanName); + return resolved?.file ?? resolvedPath; }; for (const binding of symbols.cjsRequireBindings) { const resolvedPath = getResolved(ctx, path.join(rootDir, relPath), binding.source); diff --git a/src/domain/graph/builder/stages/resolve-imports.ts b/src/domain/graph/builder/stages/resolve-imports.ts index 5799030a6..35b158212 100644 --- a/src/domain/graph/builder/stages/resolve-imports.ts +++ b/src/domain/graph/builder/stages/resolve-imports.ts @@ -11,6 +11,14 @@ interface ReexportEntry { source: string; names: string[]; wildcardReexport: boolean; + /** + * `{ local, imported }` pairs for `export { X as Y } from …` specifiers + * within this entry: `local` is the external name (Y) a consumer of the + * barrel imports, `imported` is the name (X) actually declared in `source`. + * Lets `resolveBarrelExport` translate a consumer's requested name back to + * X before matching it against `names` (#1823). + */ + renames: Array<{ local: string; imported: string }>; } /** Collect reexport entries from fileSymbols into the reexportMap. */ @@ -26,6 +34,7 @@ function buildReexportMap(ctx: PipelineContext): void { source: getResolved(ctx, path.join(rootDir, relPath), imp.source), names: imp.names, wildcardReexport: imp.wildcardReexport || false, + renames: imp.renamedImports ?? [], })), ); } @@ -158,6 +167,7 @@ async function reparseBarrelFiles( source: getResolved(ctx, path.join(rootDir, relPath), imp.source), names: imp.names, wildcardReexport: imp.wildcardReexport || false, + renames: imp.renamedImports ?? [], })), ); } @@ -169,14 +179,26 @@ async function reparseBarrelFiles( return added; } +export interface BarrelExportResolution { + /** The file that actually defines the symbol. */ + file: string; + /** + * The name the symbol is declared under in `file`. Identical to the + * requested `symbolName` unless one of the barrel hops in the chain + * renamed it (`export { X as Y } from …`), in which case this is the + * *original* declared name (X) to search for in `file` (#1823). + */ + name: string; +} + export function resolveBarrelExportCached( ctx: PipelineContext, barrelPath: string, symbolName: string, -): string | null { +): BarrelExportResolution | null { const cacheKey = `${barrelPath}|${symbolName}`; if (ctx.barrelExportCache.has(cacheKey)) - return ctx.barrelExportCache.get(cacheKey) as string | null; + return ctx.barrelExportCache.get(cacheKey) as BarrelExportResolution | null; const result = resolveBarrelExport(ctx, barrelPath, symbolName); ctx.barrelExportCache.set(cacheKey, result); return result; @@ -254,12 +276,23 @@ function sourceDefinesSymbol(ctx: PipelineContext, source: string, symbolName: s return targetSymbols.definitions.some((d) => d.name === symbolName); } +/** + * Translate a consumer-requested name through a single reexport entry's + * rename table, if it renamed that name (`export { X as Y } from …` + * records `{ local: Y, imported: X }`). Returns `symbolName` unchanged when + * the entry doesn't rename it — covers both "not renamed at all" and + * "requested name isn't one of this entry's external aliases" (#1823). + */ +function translateThroughRename(re: ReexportEntry, symbolName: string): string { + return re.renames.find((r) => r.local === symbolName)?.imported ?? symbolName; +} + export function resolveBarrelExport( ctx: PipelineContext, barrelPath: string, symbolName: string, visited: Set = new Set(), -): string | null { +): BarrelExportResolution | null { if (visited.has(barrelPath)) return null; visited.add(barrelPath); @@ -267,17 +300,25 @@ export function resolveBarrelExport( if (!reexports) return null; for (const re of reexports) { + // Translate the requested external name (Y) back to the name actually + // declared in `re.source` (X) before matching `re.names`/checking the + // target's definitions — `re.names` always carries the original + // declaration name, never the barrel's external alias (#1823). + const lookupName = translateThroughRename(re, symbolName); + // Named re-export: only follow if the symbol is in the export list if (re.names.length > 0 && !re.wildcardReexport) { - if (!re.names.includes(symbolName)) continue; - if (sourceDefinesSymbol(ctx, re.source, symbolName)) return re.source; - const deeper = resolveBarrelExport(ctx, re.source, symbolName, visited); - return deeper ?? re.source; + if (!re.names.includes(lookupName)) continue; + if (sourceDefinesSymbol(ctx, re.source, lookupName)) + return { file: re.source, name: lookupName }; + const deeper = resolveBarrelExport(ctx, re.source, lookupName, visited); + return deeper ?? { file: re.source, name: lookupName }; } // Wildcard or namespace re-export: check if target defines the symbol - if (sourceDefinesSymbol(ctx, re.source, symbolName)) return re.source; - const deeper = resolveBarrelExport(ctx, re.source, symbolName, visited); + if (sourceDefinesSymbol(ctx, re.source, lookupName)) + return { file: re.source, name: lookupName }; + const deeper = resolveBarrelExport(ctx, re.source, lookupName, visited); if (deeper) return deeper; } diff --git a/src/domain/graph/resolver/strategy.ts b/src/domain/graph/resolver/strategy.ts index 02859cc4d..32179171f 100644 --- a/src/domain/graph/resolver/strategy.ts +++ b/src/domain/graph/resolver/strategy.ts @@ -29,7 +29,7 @@ export interface StrategyLookup { ): ReadonlyArray<{ id: number; file: string; kind?: string }>; byName(name: string): ReadonlyArray<{ id: number; file: string; kind?: string }>; isBarrel(file: string): boolean; - resolveBarrel(barrelFile: string, symbolName: string): string | null; + resolveBarrel(barrelFile: string, symbolName: string): { file: string; name: string } | null; nodeId(name: string, kind: string, file: string, line: number): { id: number } | undefined; } diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 23136156d..9ae2dd986 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -261,7 +261,8 @@ function handleExportCapture( const source = c.exp_node!.childForFieldName('source') || findChild(c.exp_node!, 'string'); if (source && !decl) { const modPath = source.text.replace(/['"]/g, ''); - const reexportNames = extractImportNames(c.exp_node!); + const reexportRenames: Array<{ local: string; imported: string }> = []; + const reexportNames = extractImportNames(c.exp_node!, reexportRenames); const nodeText = c.exp_node!.text; const isWildcard = nodeText.includes('export *') || nodeText.includes('export*'); imports.push({ @@ -270,6 +271,7 @@ function handleExportCapture( line: exportLine, reexport: true, wildcardReexport: isWildcard && reexportNames.length === 0, + ...(reexportRenames.length > 0 ? { renamedImports: reexportRenames } : {}), }); } } @@ -1588,7 +1590,8 @@ function handleExportStmt(node: TreeSitterNode, ctx: ExtractorOutput): void { const source = node.childForFieldName('source') || findChild(node, 'string'); if (source && !decl) { const modPath = source.text.replace(/['"]/g, ''); - const reexportNames = extractImportNames(node); + const reexportRenames: Array<{ local: string; imported: string }> = []; + const reexportNames = extractImportNames(node, reexportRenames); const nodeText = node.text; const isWildcard = nodeText.includes('export *') || nodeText.includes('export*'); ctx.imports.push({ @@ -1597,6 +1600,7 @@ function handleExportStmt(node: TreeSitterNode, ctx: ExtractorOutput): void { line: exportLine, reexport: true, wildcardReexport: isWildcard && reexportNames.length === 0, + ...(reexportRenames.length > 0 ? { renamedImports: reexportRenames } : {}), }); } } @@ -4092,9 +4096,16 @@ function findParentClass(node: TreeSitterNode): string | null { * `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). + * unconditionally (as this function used to) silently drops the local alias + * for every renamed import: call sites use `Y`, not `X` (#1730). + * + * `export_specifier` has the same `name`/`alias` shape but the opposite + * consumer: `name` (X) is the declaration being re-exported, `alias` (Y) is + * the external name a consumer of *this* barrel imports. `names` keeps + * recording X (barrel/reexport tracing keys off the original declaration — + * see `resolveBarrelExport`), but when the two differ, `renamedOut` also + * receives the `{ local: Y, imported: X }` pair so barrel resolution can + * translate a consumer's requested external name back to X (#1823). */ function extractImportNames( node: TreeSitterNode, @@ -4123,12 +4134,21 @@ function extractImportNames( // 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); + // branch keeps picking `name` first — do not unify with the + // import_specifier branch above. When `alias` differs from `name`, the + // rename pair is recorded in renamedOut so resolveBarrelExport can map a + // consumer's requested external name (Y) back to X (#1823). + const sourceNameNode = n.childForFieldName('name'); + const aliasNode = n.childForFieldName('alias'); + const nameNode = sourceNameNode || aliasNode; + if (nameNode) { + names.push(nameNode.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 === 'identifier' && n.parent && n.parent.type === 'import_clause') { names.push(n.text); } else if (n.type === 'namespace_import') { diff --git a/src/types.ts b/src/types.ts index db488d71d..e068af333 100644 --- a/src/types.ts +++ b/src/types.ts @@ -520,8 +520,15 @@ export interface Import { * 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). + * name are omitted. + * + * Also populated for `export { X as Y } from …` reexport specifiers: `local` + * is the external name (Y) a consumer of *this* barrel would import, and + * `imported` is the name (X) actually declared in the source module. `names` + * keeps carrying the original declaration name (X) for reexports (see + * `extractImportNames`), so `resolveBarrelExport` uses this map to translate + * a consumer's requested external name back to X before matching against + * `names`/looking up the underlying definition (#1823). */ renamedImports?: Array<{ local: string; imported: string }>; /** diff --git a/tests/fixtures/barrel-rename-reexport/barrel.ts b/tests/fixtures/barrel-rename-reexport/barrel.ts new file mode 100644 index 000000000..155162015 --- /dev/null +++ b/tests/fixtures/barrel-rename-reexport/barrel.ts @@ -0,0 +1,3 @@ +// Barrel re-export with rename — the external name (friendlyName) differs +// from the underlying declaration (realName). +export { realName as friendlyName } from './underlying.js'; diff --git a/tests/fixtures/barrel-rename-reexport/consumer.ts b/tests/fixtures/barrel-rename-reexport/consumer.ts new file mode 100644 index 000000000..9101fc1ce --- /dev/null +++ b/tests/fixtures/barrel-rename-reexport/consumer.ts @@ -0,0 +1,5 @@ +import { friendlyName } from './barrel.js'; + +export function useIt(): string { + return friendlyName(); +} diff --git a/tests/fixtures/barrel-rename-reexport/underlying.ts b/tests/fixtures/barrel-rename-reexport/underlying.ts new file mode 100644 index 000000000..f3e0b7cff --- /dev/null +++ b/tests/fixtures/barrel-rename-reexport/underlying.ts @@ -0,0 +1,3 @@ +export function realName(): string { + return 'real'; +} diff --git a/tests/integration/issue-1823-barrel-rename-reexport.test.ts b/tests/integration/issue-1823-barrel-rename-reexport.test.ts new file mode 100644 index 000000000..05b05474d --- /dev/null +++ b/tests/integration/issue-1823-barrel-rename-reexport.test.ts @@ -0,0 +1,88 @@ +/** + * Regression test for #1823: barrel re-export with rename + * (`export { X as Y } from '...'`) must be tracked so resolution through + * the barrel works for consumers that import the renamed external name. + * + * Fixture: + * underlying.ts — `export function realName() {}` + * barrel.ts — `export { realName as friendlyName } from './underlying.js';` + * consumer.ts — `import { friendlyName } from './barrel.js';` calls + * `friendlyName()` inside an exported function. + * + * Before the fix, `extractImportNames`'s `export_specifier` branch never + * recorded the rename pair, so `resolveBarrelExport` had no way to translate + * a consumer's requested external name (`friendlyName`) back to the name + * actually declared in the underlying module (`realName`) — the call edge + * was dropped and `codegraph exports underlying.ts` 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. + */ + +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'; +import type { EngineMode } from '../../src/types.js'; + +const FIXTURE_DIR = path.join(import.meta.dirname, '..', 'fixtures', 'barrel-rename-reexport'); + +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(); + } +} + +const ENGINES: EngineMode[] = ['wasm', 'native']; + +describe.each(ENGINES)('barrel re-export rename resolution (#1823) — %s', (engine) => { + let tmpDir: string; + let dbPath: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `codegraph-1823-${engine}-`)); + fs.cpSync(FIXTURE_DIR, tmpDir, { recursive: true }); + await buildGraph(tmpDir, { incremental: false, skipRegistry: true, engine }); + dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + }, 60_000); + + afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('useIt -> realName calls edge exists, resolved through the barrel rename', () => { + const edges = getCallEdges(dbPath); + const edge = edges.find((e) => e.src === 'useIt' && e.tgt === 'realName'); + expect(edge, `Actual call edges:\n${JSON.stringify(edges, null, 2)}`).toBeDefined(); + expect(edge?.tgt_file).toBe('underlying.ts'); + }); + + it('no spurious edge is created against a nonexistent "friendlyName" symbol', () => { + const edges = getCallEdges(dbPath); + expect(edges.find((e) => e.tgt === 'friendlyName')).toBeUndefined(); + }); + + it('codegraph exports credits realName with the barrel-renamed consumer', () => { + const data = exportsData('underlying.ts', dbPath); + const realName = data.results.find((r: { name: string }) => r.name === 'realName'); + expect(realName).toBeDefined(); + expect(realName.consumerCount).toBeGreaterThanOrEqual(1); + expect(realName.consumers.map((c: { name: string }) => c.name)).toContain('useIt'); + }); +}); diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 260733446..81b6174c5 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -163,15 +163,26 @@ describe('JavaScript parser', () => { ]); }); - 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. + it('records the external-alias -> declared-name rename pair for export_specifier (reexport) statements (#1823)', () => { + // export_specifier semantics differ from import_specifier (name = local + // declaration being re-exported, alias = external name a consumer of + // this barrel imports), so `names` keeps recording the declared name + // (collectFiles) — barrel/reexport tracing keys off it (see + // resolveBarrelExport). renamedImports separately records the + // { local: externalAlias, imported: declaredName } pair so barrel + // resolution can translate a consumer's requested external name back + // to the declared name. 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).toEqual([ + { local: 'friendlyName', imported: 'collectFiles' }, + ]); + }); + + it('does not set renamedImports for non-renamed export_specifier (reexport) statements', () => { + const symbols = parseJS(`export { collectFiles } from './helpers';`); expect(symbols.imports[0].renamedImports).toBeUndefined(); }); });