diff --git a/crates/codegraph-core/src/barrel_resolution.rs b/crates/codegraph-core/src/barrel_resolution.rs new file mode 100644 index 00000000..70db62c5 --- /dev/null +++ b/crates/codegraph-core/src/barrel_resolution.rs @@ -0,0 +1,189 @@ +//! Shared barrel-file resolution logic. +//! +//! Both `edge_builder.rs` (napi-driven) and `import_edges.rs` (SQLite-driven) +//! need to recursively resolve a symbol through barrel reexport chains. +//! This module extracts the common algorithm so both callers share a single +//! implementation. + +use std::collections::HashSet; + +/// 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, +} + +/// Trait that abstracts over the different context types in `edge_builder` and +/// `import_edges`. Each implementor provides access to its own reexport map +/// and definition index so the resolution algorithm stays generic. +pub trait BarrelContext { + /// Return the reexport entries for `barrel_path`, or `None` if the path + /// has no reexports. + fn reexports_for(&self, barrel_path: &str) -> Option>>; + + /// Return `true` if `file_path` contains a definition named `symbol`. + fn has_definition(&self, file_path: &str, symbol: &str) -> bool; +} + +/// Recursively resolve a symbol through barrel reexport chains. +/// +/// Mirrors `resolveBarrelExport()` in `resolve-imports.ts`. +/// The caller provides a `visited` set to prevent infinite loops on circular +/// reexport chains. +pub fn resolve_barrel_export<'a, C: BarrelContext>( + ctx: &'a C, + barrel_path: &str, + symbol_name: &str, + visited: &mut HashSet, +) -> Option { + if visited.contains(barrel_path) { + return None; + } + visited.insert(barrel_path.to_string()); + + let reexports = ctx.reexports_for(barrel_path)?; + + for re in &reexports { + // 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()); + } + let deeper = resolve_barrel_export(ctx, re.source, symbol_name, visited); + if deeper.is_some() { + return deeper; + } + // Fallback: return source even if no definition found + return Some(re.source.to_string()); + } + continue; + } + + // Wildcard or empty-names reexports + if ctx.has_definition(re.source, symbol_name) { + return Some(re.source.to_string()); + } + let deeper = resolve_barrel_export(ctx, re.source, symbol_name, visited); + if deeper.is_some() { + return deeper; + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + struct TestContext { + reexports: HashMap, bool)>>, + definitions: HashMap>, + } + + impl BarrelContext for TestContext { + fn reexports_for(&self, barrel_path: &str) -> Option>> { + self.reexports.get(barrel_path).map(|entries| { + entries + .iter() + .map(|(source, names, wildcard)| ReexportRef { + source: source.as_str(), + names: names.as_slice(), + wildcard_reexport: *wildcard, + }) + .collect() + }) + } + + fn has_definition(&self, file_path: &str, symbol: &str) -> bool { + self.definitions + .get(file_path) + .map_or(false, |defs| defs.contains(symbol)) + } + } + + #[test] + fn resolves_named_reexport() { + let mut reexports = HashMap::new(); + reexports.insert( + "src/index.ts".to_string(), + vec![("src/utils.ts".to_string(), vec!["foo".to_string()], false)], + ); + let mut definitions = HashMap::new(); + definitions.insert( + "src/utils.ts".to_string(), + HashSet::from(["foo".to_string()]), + ); + + 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")); + } + + #[test] + fn resolves_wildcard_reexport() { + let mut reexports = HashMap::new(); + reexports.insert( + "src/index.ts".to_string(), + vec![("src/utils.ts".to_string(), vec![], true)], + ); + let mut definitions = HashMap::new(); + definitions.insert( + "src/utils.ts".to_string(), + HashSet::from(["bar".to_string()]), + ); + + 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")); + } + + #[test] + fn resolves_transitive_chain() { + let mut reexports = HashMap::new(); + reexports.insert( + "src/index.ts".to_string(), + vec![("src/mid.ts".to_string(), vec![], true)], + ); + reexports.insert( + "src/mid.ts".to_string(), + vec![("src/deep.ts".to_string(), vec!["baz".to_string()], false)], + ); + let mut definitions = HashMap::new(); + definitions.insert( + "src/deep.ts".to_string(), + HashSet::from(["baz".to_string()]), + ); + + 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")); + } + + #[test] + fn prevents_circular_reexport() { + let mut reexports = HashMap::new(); + reexports.insert( + "src/a.ts".to_string(), + vec![("src/b.ts".to_string(), vec![], true)], + ); + reexports.insert( + "src/b.ts".to_string(), + vec![("src/a.ts".to_string(), vec![], true)], + ); + + let ctx = TestContext { + reexports, + definitions: HashMap::new(), + }; + let mut visited = HashSet::new(); + let result = resolve_barrel_export(&ctx, "src/a.ts", "missing", &mut visited); + assert_eq!(result, None); + } +} diff --git a/crates/codegraph-core/src/build_pipeline.rs b/crates/codegraph-core/src/build_pipeline.rs index b565fe57..09e745e9 100644 --- a/crates/codegraph-core/src/build_pipeline.rs +++ b/crates/codegraph-core/src/build_pipeline.rs @@ -18,6 +18,7 @@ use crate::change_detection; use crate::config::{BuildConfig, BuildOpts, BuildPathAliases}; +use crate::constants::{FAST_PATH_MAX_CHANGED_FILES, FAST_PATH_MIN_EXISTING_FILES}; use crate::file_collector; use crate::import_edges::{self, ImportEdgeContext}; use crate::import_resolution; @@ -492,7 +493,7 @@ pub fn run_pipeline( // reverse-dep files added for edge rebuilding, which inflates the count // and would skip the fast path even for single-file incremental builds. let use_fast_path = - !change_result.is_full_build && parse_changes.len() <= 5 && existing_file_count > 20; + !change_result.is_full_build && parse_changes.len() <= FAST_PATH_MAX_CHANGED_FILES && existing_file_count > FAST_PATH_MIN_EXISTING_FILES; if use_fast_path { structure::update_changed_file_metrics( diff --git a/crates/codegraph-core/src/constants.rs b/crates/codegraph-core/src/constants.rs index d1156147..f5a7e2b5 100644 --- a/crates/codegraph-core/src/constants.rs +++ b/crates/codegraph-core/src/constants.rs @@ -1,3 +1,30 @@ /// Maximum recursion depth for AST traversal to prevent stack overflow /// on deeply nested trees. Used by extractors, complexity, CFG, and dataflow. pub const MAX_WALK_DEPTH: usize = 200; + +// ─── Louvain community detection ──────────────────────────────────── + +/// Maximum number of coarsening levels in the Louvain algorithm. +pub const LOUVAIN_MAX_LEVELS: usize = 50; + +/// Maximum number of local-move passes per level before stopping. +pub const LOUVAIN_MAX_PASSES: usize = 20; + +/// Minimum modularity gain to accept a node move (avoids floating-point noise). +pub const LOUVAIN_MIN_GAIN: f64 = 1e-12; + +/// Default random seed for deterministic community detection. +pub const DEFAULT_RANDOM_SEED: u32 = 42; + +// ─── Dataflow analysis ────────────────────────────────────────────── + +/// Maximum character length for truncated dataflow expressions. +pub const DATAFLOW_TRUNCATION_LIMIT: usize = 120; + +// ─── Build pipeline ───────────────────────────────────────────────── + +/// Maximum number of changed files eligible for the incremental fast path. +pub const FAST_PATH_MAX_CHANGED_FILES: usize = 5; + +/// Minimum existing file count required before the fast path is considered. +pub const FAST_PATH_MIN_EXISTING_FILES: i64 = 20; diff --git a/crates/codegraph-core/src/dataflow.rs b/crates/codegraph-core/src/dataflow.rs index af736be0..f22313ac 100644 --- a/crates/codegraph-core/src/dataflow.rs +++ b/crates/codegraph-core/src/dataflow.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use tree_sitter::{Node, Tree}; -use crate::constants::MAX_WALK_DEPTH; +use crate::constants::{DATAFLOW_TRUNCATION_LIMIT, MAX_WALK_DEPTH}; use crate::types::{ DataflowArgFlow, DataflowAssignment, DataflowMutation, DataflowParam, DataflowResult, DataflowReturn, @@ -1196,7 +1196,7 @@ fn handle_var_declarator( var_name: n.clone(), caller_func: Some(func_name.clone()), source_call_name: callee.clone(), - expression: truncate(node_text(node, source), 120), + expression: truncate(node_text(node, source), DATAFLOW_TRUNCATION_LIMIT), line: node_line(node), }); scope @@ -1209,7 +1209,7 @@ fn handle_var_declarator( var_name: var_name.clone(), caller_func: Some(func_name), source_call_name: callee.clone(), - expression: truncate(node_text(node, source), 120), + expression: truncate(node_text(node, source), DATAFLOW_TRUNCATION_LIMIT), line: node_line(node), }); scope.locals.insert(var_name, LocalSource::CallReturn { callee }); @@ -1245,7 +1245,7 @@ fn handle_assignment( func_name: Some(func_name.clone()), receiver_name: receiver, binding_type: binding.as_ref().map(|b| b.binding_type.clone()), - mutating_expr: truncate(node_text(node, source), 120), + mutating_expr: truncate(node_text(node, source), DATAFLOW_TRUNCATION_LIMIT), line: node_line(node), }); } @@ -1264,7 +1264,7 @@ fn handle_assignment( var_name: var_name.clone(), caller_func: Some(func_name), source_call_name: callee.clone(), - expression: truncate(node_text(node, source), 120), + expression: truncate(node_text(node, source), DATAFLOW_TRUNCATION_LIMIT), line: node_line(node), }); if let Some(scope) = scope_stack.last_mut() { @@ -1340,7 +1340,7 @@ fn handle_call_expr( arg_name: Some(tracked.clone()), binding_type: binding.as_ref().map(|b| b.binding_type.clone()), confidence: conf, - expression: truncate(node_text(&arg_raw, source), 120), + expression: truncate(node_text(&arg_raw, source), DATAFLOW_TRUNCATION_LIMIT), line: node_line(node), }); } @@ -1442,7 +1442,7 @@ fn handle_expr_stmt_mutation( func_name, receiver_name: recv, binding_type: binding.as_ref().map(|b| b.binding_type.clone()), - mutating_expr: truncate(node_text(&expr, source), 120), + mutating_expr: truncate(node_text(&expr, source), DATAFLOW_TRUNCATION_LIMIT), line: node_line(node), }); } diff --git a/crates/codegraph-core/src/edge_builder.rs b/crates/codegraph-core/src/edge_builder.rs index 8d03dbd0..3a4194ac 100644 --- a/crates/codegraph-core/src/edge_builder.rs +++ b/crates/codegraph-core/src/edge_builder.rs @@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet}; use napi_derive::napi; +use crate::barrel_resolution::{self, BarrelContext, ReexportRef}; use crate::import_resolution; /// Kind sets for hierarchy edge resolution -- mirrors the JS constants in @@ -466,55 +467,25 @@ impl<'a> ImportEdgeContext<'a> { } } -/// Recursively resolve a symbol through barrel reexport chains. -/// Mirrors `resolveBarrelExport()` in resolve-imports.ts. -fn resolve_barrel_export<'a>( - ctx: &'a ImportEdgeContext<'a>, - barrel_path: &'a str, - symbol_name: &str, - visited: &mut HashSet<&'a str>, -) -> Option<&'a str> { - if visited.contains(barrel_path) { - return None; +impl<'a> BarrelContext for ImportEdgeContext<'a> { + fn reexports_for(&self, barrel_path: &str) -> Option>> { + self.reexport_map.get(barrel_path).map(|entries| { + entries + .iter() + .map(|re| ReexportRef { + source: re.source.as_str(), + names: &re.names, + wildcard_reexport: re.wildcard_reexport, + }) + .collect() + }) } - visited.insert(barrel_path); - let reexports = ctx.reexport_map.get(barrel_path)?; - - for re in reexports.iter() { - // Named reexports (non-wildcard) - if !re.names.is_empty() && !re.wildcard_reexport { - if re.names.iter().any(|n| n == symbol_name) { - if let Some(defs) = ctx.file_defs.get(re.source.as_str()) { - if defs.contains(symbol_name) { - return Some(re.source.as_str()); - } - let deeper = resolve_barrel_export(ctx, re.source.as_str(), symbol_name, visited); - if deeper.is_some() { - return deeper; - } - } - // Fallback: return source even if no definition found - return Some(re.source.as_str()); - } - continue; - } - - // Wildcard or empty-names reexports - if re.wildcard_reexport || re.names.is_empty() { - if let Some(defs) = ctx.file_defs.get(re.source.as_str()) { - if defs.contains(symbol_name) { - return Some(re.source.as_str()); - } - let deeper = resolve_barrel_export(ctx, re.source.as_str(), symbol_name, visited); - if deeper.is_some() { - return deeper; - } - } - } + fn has_definition(&self, file_path: &str, symbol: &str) -> bool { + self.file_defs + .get(file_path) + .map_or(false, |defs| defs.contains(symbol)) } - - None } /// Build import and barrel-through edges in Rust. @@ -583,7 +554,7 @@ pub fn build_import_edges( // Barrel resolution: if not reexport and target is a barrel file if !imp.reexport && ctx.barrel_set.contains(resolved_path) { - let mut resolved_sources: HashSet<&str> = HashSet::new(); + let mut resolved_sources: HashSet = HashSet::new(); for name in &imp.names { let clean_name = if name.starts_with("* as ") || name.starts_with("*\tas ") { // Strip "* as " or "*\tas " prefix (both exactly 5 bytes) @@ -594,12 +565,11 @@ pub fn build_import_edges( }; let mut visited = HashSet::new(); - let actual = resolve_barrel_export(&ctx, resolved_path, clean_name, &mut visited); + let actual = barrel_resolution::resolve_barrel_export(&ctx, resolved_path, clean_name, &mut visited); if let Some(actual_source) = actual { - if actual_source != resolved_path && !resolved_sources.contains(actual_source) { - resolved_sources.insert(actual_source); - if let Some(&actual_node_id) = ctx.file_node_map.get(actual_source) { + if actual_source != resolved_path && !resolved_sources.contains(&actual_source) { + if let Some(&actual_node_id) = ctx.file_node_map.get(actual_source.as_str()) { let barrel_kind = match edge_kind { "imports-type" => "imports-type", "dynamic-imports" => "dynamic-imports", @@ -613,6 +583,7 @@ pub fn build_import_edges( dynamic: 0, }); } + resolved_sources.insert(actual_source); } } } diff --git a/crates/codegraph-core/src/extractors/helpers.rs b/crates/codegraph-core/src/extractors/helpers.rs index a5f6f199..77ac8220 100644 --- a/crates/codegraph-core/src/extractors/helpers.rs +++ b/crates/codegraph-core/src/extractors/helpers.rs @@ -389,6 +389,105 @@ pub fn walk_ast_nodes_with_config( walk_ast_nodes_with_config_depth(node, source, ast_nodes, config, 0); } +/// Classify a tree-sitter node against the language AST config. +/// Returns the AST kind string if matched, or `None` to skip. +fn classify_ast_node<'a>(kind: &str, config: &'a LangAstConfig) -> Option<&'a str> { + if config.new_types.contains(&kind) { + Some("new") + } else if config.throw_types.contains(&kind) { + Some("throw") + } else if config.await_types.contains(&kind) { + Some("await") + } else if config.string_types.contains(&kind) { + Some("string") + } else if config.regex_types.contains(&kind) { + Some("regex") + } else { + None + } +} + +/// Build an AstNode for a "new" expression. +fn build_new_node(node: &Node, source: &[u8]) -> AstNode { + AstNode { + kind: "new".to_string(), + name: extract_constructor_name(node, source), + line: start_line(node), + text: Some(truncate(node_text(node, source), AST_TEXT_MAX)), + receiver: None, + } +} + +/// Build an AstNode for a "throw" statement. +fn build_throw_node(node: &Node, source: &[u8], config: &LangAstConfig) -> AstNode { + AstNode { + kind: "throw".to_string(), + name: extract_throw_target(node, source, config), + line: start_line(node), + text: extract_child_expression_text(node, source), + receiver: None, + } +} + +/// Build an AstNode for an "await" expression. +fn build_await_node(node: &Node, source: &[u8]) -> AstNode { + AstNode { + kind: "await".to_string(), + name: extract_awaited_name(node, source), + line: start_line(node), + text: extract_child_expression_text(node, source), + receiver: None, + } +} + +/// Build an AstNode for a string literal. +/// Returns `None` if the string content is too short (< 2 chars). +fn build_string_node(node: &Node, source: &[u8], config: &LangAstConfig) -> Option { + let raw = node_text(node, source); + let kind = node.kind(); + let is_raw_string = kind.contains("raw_string"); + // Strip language prefix modifiers before quote chars: + // - C# verbatim `@"..."`, Rust raw strings `r"..."`, Python prefixes: r, b, f, u + let without_prefix = raw.trim_start_matches('@') + .trim_start_matches(|c: char| config.string_prefixes.contains(&c)); + let without_prefix = if is_raw_string { + without_prefix.trim_start_matches('r').trim_start_matches('#') + } else { + without_prefix + }; + let content = without_prefix + .trim_start_matches(|c: char| config.quote_chars.contains(&c)); + let content = if is_raw_string { + content.trim_end_matches('#') + } else { + content + }; + let content = content + .trim_end_matches(|c: char| config.quote_chars.contains(&c)); + if content.chars().count() < 2 { + return None; + } + Some(AstNode { + kind: "string".to_string(), + name: truncate(content, 100), + line: start_line(node), + text: Some(truncate(raw, AST_TEXT_MAX)), + receiver: None, + }) +} + +/// Build an AstNode for a regex literal. +fn build_regex_node(node: &Node, source: &[u8]) -> AstNode { + let raw = node_text(node, source); + AstNode { + kind: "regex".to_string(), + name: if raw.is_empty() { "?".to_string() } else { raw.to_string() }, + line: start_line(node), + text: Some(truncate(raw, AST_TEXT_MAX)), + receiver: None, + } +} + fn walk_ast_nodes_with_config_depth( node: &Node, source: &[u8], @@ -399,98 +498,34 @@ fn walk_ast_nodes_with_config_depth( if depth >= MAX_WALK_DEPTH { return; } - let kind = node.kind(); - if config.new_types.contains(&kind) { - let name = extract_constructor_name(node, source); - let text = truncate(node_text(node, source), AST_TEXT_MAX); - ast_nodes.push(AstNode { - kind: "new".to_string(), - name, - line: start_line(node), - text: Some(text), - receiver: None, - }); - // Fall through to recurse children (e.g. string args inside `new`) - } else if config.throw_types.contains(&kind) { - let name = extract_throw_target(node, source, config); - let text = extract_child_expression_text(node, source); - ast_nodes.push(AstNode { - kind: "throw".to_string(), - name, - line: start_line(node), - text, - receiver: None, - }); - // Fall through to recurse children (e.g. `new` inside `throw new ...`) - } else if config.await_types.contains(&kind) { - let name = extract_awaited_name(node, source); - let text = extract_child_expression_text(node, source); - ast_nodes.push(AstNode { - kind: "await".to_string(), - name, - line: start_line(node), - text, - receiver: None, - }); - // Fall through to recurse children — captures strings, etc. inside await expr. - } else if config.string_types.contains(&kind) { - let raw = node_text(node, source); - let is_raw_string = kind.contains("raw_string"); - // Strip language prefix modifiers before quote chars: - // - C# verbatim `@"..."` - // - Rust raw strings `r"..."`, `r#"..."#` - // - Python prefixes: r, b, f, u and combos like rb, fr - let without_prefix = raw.trim_start_matches('@') - .trim_start_matches(|c: char| config.string_prefixes.contains(&c)); - // For raw string node types (e.g. Rust `r#"..."#`), strip the `r` prefix - // and `#` delimiters. This must be conditional — the unconditional - // `.trim_start_matches('r')` that was here before double-stripped 'r' for - // languages like Python where 'r' is already in string_prefixes. - let without_prefix = if is_raw_string { - without_prefix.trim_start_matches('r').trim_start_matches('#') - } else { - without_prefix - }; - let content = without_prefix - .trim_start_matches(|c: char| config.quote_chars.contains(&c)); - let content = if is_raw_string { - content.trim_end_matches('#') - } else { - content - }; - let content = content - .trim_end_matches(|c: char| config.quote_chars.contains(&c)); - if content.chars().count() < 2 { - for i in 0..node.child_count() { - if let Some(child) = node.child(i) { - walk_ast_nodes_with_config_depth(&child, source, ast_nodes, config, depth + 1); + if let Some(ast_kind) = classify_ast_node(node.kind(), config) { + match ast_kind { + "new" => { + ast_nodes.push(build_new_node(node, source)); + } + "throw" => { + ast_nodes.push(build_throw_node(node, source, config)); + } + "await" => { + ast_nodes.push(build_await_node(node, source)); + } + "string" => { + if build_string_node(node, source, config).map(|n| ast_nodes.push(n)).is_none() { + // Short string: recurse children then skip outer loop + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + walk_ast_nodes_with_config_depth(&child, source, ast_nodes, config, depth + 1); + } + } + return; } } - return; + "regex" => { + ast_nodes.push(build_regex_node(node, source)); + } + _ => {} } - let name = truncate(content, 100); - let text = truncate(raw, AST_TEXT_MAX); - ast_nodes.push(AstNode { - kind: "string".to_string(), - name, - line: start_line(node), - text: Some(text), - receiver: None, - }); - // Fall through to recurse children (template strings may have nested expressions) - } else if config.regex_types.contains(&kind) { - let raw = node_text(node, source); - let name = if raw.is_empty() { "?".to_string() } else { raw.to_string() }; - let text = truncate(raw, AST_TEXT_MAX); - ast_nodes.push(AstNode { - kind: "regex".to_string(), - name, - line: start_line(node), - text: Some(text), - receiver: None, - }); - // Fall through to recurse children } for i in 0..node.child_count() { diff --git a/crates/codegraph-core/src/graph_algorithms.rs b/crates/codegraph-core/src/graph_algorithms.rs index 78dbf448..f2dc9889 100644 --- a/crates/codegraph-core/src/graph_algorithms.rs +++ b/crates/codegraph-core/src/graph_algorithms.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet, VecDeque}; +use crate::constants::{DEFAULT_RANDOM_SEED, LOUVAIN_MAX_LEVELS, LOUVAIN_MAX_PASSES, LOUVAIN_MIN_GAIN}; use crate::types::GraphEdge; use napi_derive::napi; @@ -242,7 +243,7 @@ pub fn louvain_communities( &edges, &node_ids, resolution.unwrap_or(1.0), - random_seed.unwrap_or(42), + random_seed.unwrap_or(DEFAULT_RANDOM_SEED), ) } @@ -314,7 +315,7 @@ fn louvain_impl( // edges, inflating the penalty term and causing under-merging at coarser levels. let total_m2: f64 = 2.0 * total_weight; - for _level in 0..50 { + for _level in 0..LOUVAIN_MAX_LEVELS { if cur_edges.is_empty() { break; } @@ -337,7 +338,7 @@ fn louvain_impl( } let mut any_moved = false; - for _pass in 0..20 { + for _pass in 0..LOUVAIN_MAX_PASSES { let mut pass_moved = false; for &node in &order { let node_comm = level_comm[node]; @@ -368,7 +369,7 @@ fn louvain_impl( } } - if best_comm != node_comm && best_gain > 1e-12 { + if best_comm != node_comm && best_gain > LOUVAIN_MIN_GAIN { comm_total[node_comm] -= node_deg; comm_total[best_comm] += node_deg; level_comm[node] = best_comm; diff --git a/crates/codegraph-core/src/import_edges.rs b/crates/codegraph-core/src/import_edges.rs index 1dfcd4d0..8d3966a7 100644 --- a/crates/codegraph-core/src/import_edges.rs +++ b/crates/codegraph-core/src/import_edges.rs @@ -4,6 +4,7 @@ //! the barrel detection from `resolve-imports.ts:isBarrelFile()`, and the //! recursive barrel export resolution from `resolveBarrelExport()`. +use crate::barrel_resolution::{self, BarrelContext, ReexportRef}; use crate::import_resolution; use crate::types::{FileSymbols, PathAliases}; use rusqlite::Connection; @@ -79,58 +80,36 @@ impl ImportEdgeContext { } /// Recursively resolve a barrel export to its actual source file. + /// + /// Delegates to the shared [`barrel_resolution::resolve_barrel_export`] algorithm. pub fn resolve_barrel_export( &self, barrel_path: &str, symbol_name: &str, visited: &mut HashSet, ) -> Option { - if visited.contains(barrel_path) { - return None; - } - visited.insert(barrel_path.to_string()); + barrel_resolution::resolve_barrel_export(self, barrel_path, symbol_name, visited) + } +} - let reexports = self.reexport_map.get(barrel_path)?; - for re in reexports { - // Named reexport (not wildcard) - if !re.names.is_empty() && !re.wildcard_reexport { - if re.names.iter().any(|n| n == symbol_name) { - if let Some(target_symbols) = self.file_symbols.get(&re.source) { - let has_def = target_symbols - .definitions - .iter() - .any(|d| d.name == symbol_name); - if has_def { - return Some(re.source.clone()); - } - let deeper = self.resolve_barrel_export(&re.source, symbol_name, visited); - if deeper.is_some() { - return deeper; - } - } - return Some(re.source.clone()); - } - continue; - } +impl BarrelContext for ImportEdgeContext { + fn reexports_for(&self, barrel_path: &str) -> Option>> { + self.reexport_map.get(barrel_path).map(|entries| { + entries + .iter() + .map(|re| ReexportRef { + source: re.source.as_str(), + names: &re.names, + wildcard_reexport: re.wildcard_reexport, + }) + .collect() + }) + } - // Wildcard reexport or unnamed - if re.wildcard_reexport || re.names.is_empty() { - if let Some(target_symbols) = self.file_symbols.get(&re.source) { - let has_def = target_symbols - .definitions - .iter() - .any(|d| d.name == symbol_name); - if has_def { - return Some(re.source.clone()); - } - let deeper = self.resolve_barrel_export(&re.source, symbol_name, visited); - if deeper.is_some() { - return deeper; - } - } - } - } - None + fn has_definition(&self, file_path: &str, symbol: &str) -> bool { + self.file_symbols + .get(file_path) + .map_or(false, |s| s.definitions.iter().any(|d| d.name == symbol)) } } diff --git a/crates/codegraph-core/src/lib.rs b/crates/codegraph-core/src/lib.rs index 1b16b029..5fbe317d 100644 --- a/crates/codegraph-core/src/lib.rs +++ b/crates/codegraph-core/src/lib.rs @@ -1,5 +1,6 @@ pub mod analysis; pub mod ast_db; +pub mod barrel_resolution; pub mod build_pipeline; pub mod change_detection; pub mod cfg; diff --git a/src/domain/graph/builder/stages/resolve-imports.ts b/src/domain/graph/builder/stages/resolve-imports.ts index 5ab36d03..1ddb5219 100644 --- a/src/domain/graph/builder/stages/resolve-imports.ts +++ b/src/domain/graph/builder/stages/resolve-imports.ts @@ -180,6 +180,13 @@ export function isBarrelFile(ctx: PipelineContext, relPath: string): boolean { return reexports.length >= ownDefs; } +/** Check if a re-export source directly defines the symbol. */ +function sourceDefinesSymbol(ctx: PipelineContext, source: string, symbolName: string): boolean { + const targetSymbols = ctx.fileSymbols.get(source); + if (!targetSymbols) return false; + return targetSymbols.definitions.some((d) => d.name === symbolName); +} + export function resolveBarrelExport( ctx: PipelineContext, barrelPath: string, @@ -188,31 +195,24 @@ export function resolveBarrelExport( ): string | null { if (visited.has(barrelPath)) return null; visited.add(barrelPath); + const reexports = ctx.reexportMap.get(barrelPath) as ReexportEntry[] | undefined; if (!reexports) return null; + for (const re of reexports) { + // 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)) { - const targetSymbols = ctx.fileSymbols.get(re.source); - if (targetSymbols) { - const hasDef = targetSymbols.definitions.some((d) => d.name === symbolName); - if (hasDef) return re.source; - const deeper = resolveBarrelExport(ctx, re.source, symbolName, visited); - if (deeper) return deeper; - } - return re.source; - } - continue; - } - if (re.wildcardReexport || re.names.length === 0) { - const targetSymbols = ctx.fileSymbols.get(re.source); - if (targetSymbols) { - const hasDef = targetSymbols.definitions.some((d) => d.name === symbolName); - if (hasDef) return re.source; - const deeper = resolveBarrelExport(ctx, re.source, symbolName, visited); - if (deeper) return deeper; - } + 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; } + + // 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 (deeper) return deeper; } + return null; } diff --git a/src/graph/algorithms/louvain.ts b/src/graph/algorithms/louvain.ts index f1c610b3..6cece3f5 100644 --- a/src/graph/algorithms/louvain.ts +++ b/src/graph/algorithms/louvain.ts @@ -12,6 +12,9 @@ import type { CodeGraph } from '../model.js'; import type { DetectClustersResult } from './leiden/index.js'; import { detectClusters } from './leiden/index.js'; +/** Default random seed for deterministic community detection. */ +const DEFAULT_RANDOM_SEED = 42; + export interface LouvainOptions { resolution?: number; maxLevels?: number; @@ -42,7 +45,7 @@ export function louvainCommunities(graph: CodeGraph, opts: LouvainOptions = {}): } const edges = graph.toEdgeArray(); const nodeIds = graph.nodeIds(); - const result = native.louvainCommunities(edges, nodeIds, resolution, 42); + const result = native.louvainCommunities(edges, nodeIds, resolution, DEFAULT_RANDOM_SEED); const assignments = new Map(); for (const entry of result.assignments) { assignments.set(entry.node, entry.community); @@ -57,7 +60,7 @@ export function louvainCommunities(graph: CodeGraph, opts: LouvainOptions = {}): function louvainJS(graph: CodeGraph, opts: LouvainOptions, resolution: number): LouvainResult { const result: DetectClustersResult = detectClusters(graph, { resolution, - randomSeed: 42, + randomSeed: DEFAULT_RANDOM_SEED, directed: false, ...(opts.maxLevels != null && { maxLevels: opts.maxLevels }), ...(opts.maxLocalPasses != null && { maxLocalPasses: opts.maxLocalPasses }),