Skip to content
Merged
189 changes: 189 additions & 0 deletions crates/codegraph-core/src/barrel_resolution.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<ReexportRef<'_>>>;

/// 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<String>,
) -> Option<String> {
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<String, Vec<(String, Vec<String>, bool)>>,
definitions: HashMap<String, HashSet<String>>,
}

impl BarrelContext for TestContext {
fn reexports_for(&self, barrel_path: &str) -> Option<Vec<ReexportRef<'_>>> {
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);
}
}
3 changes: 2 additions & 1 deletion crates/codegraph-core/src/build_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
27 changes: 27 additions & 0 deletions crates/codegraph-core/src/constants.rs
Original file line number Diff line number Diff line change
@@ -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;
14 changes: 7 additions & 7 deletions crates/codegraph-core/src/dataflow.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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 });
Expand Down Expand Up @@ -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),
});
}
Expand All @@ -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() {
Expand Down Expand Up @@ -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),
});
}
Expand Down Expand Up @@ -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),
});
}
Expand Down
Loading
Loading