use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use crate::graph::{Graph, Node, NodeStatus};
use crate::code_graph::{CodeGraph, NodeKind, EdgeRelation};
use crate::query::QueryEngine;
use crate::validator::Validator;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
Warning,
Error,
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Severity::Info => write!(f, "info"),
Severity::Warning => write!(f, "warning"),
Severity::Error => write!(f, "error"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AdviceType {
CircularDependency,
OrphanNode,
HighFanIn,
HighFanOut,
MissingDescription,
LayerViolation,
DeepDependencyChain,
MissingRef,
DuplicateNode,
SuggestedTaskOrder,
UnreachableTask,
BlockedChain,
DeadCode,
ModuleSuggestion,
}
impl std::fmt::Display for AdviceType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AdviceType::CircularDependency => write!(f, "circular-dependency"),
AdviceType::OrphanNode => write!(f, "orphan-node"),
AdviceType::HighFanIn => write!(f, "high-fan-in"),
AdviceType::HighFanOut => write!(f, "high-fan-out"),
AdviceType::MissingDescription => write!(f, "missing-description"),
AdviceType::LayerViolation => write!(f, "layer-violation"),
AdviceType::DeepDependencyChain => write!(f, "deep-dependency-chain"),
AdviceType::MissingRef => write!(f, "missing-reference"),
AdviceType::DuplicateNode => write!(f, "duplicate-node"),
AdviceType::SuggestedTaskOrder => write!(f, "suggested-task-order"),
AdviceType::UnreachableTask => write!(f, "unreachable-task"),
AdviceType::BlockedChain => write!(f, "blocked-chain"),
AdviceType::DeadCode => write!(f, "dead-code"),
AdviceType::ModuleSuggestion => write!(f, "module-suggestion"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Advice {
pub advice_type: AdviceType,
pub severity: Severity,
pub message: String,
pub nodes: Vec<String>,
pub suggestion: Option<String>,
}
impl std::fmt::Display for Advice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let icon = match self.severity {
Severity::Error => "❌",
Severity::Warning => "⚠️ ",
Severity::Info => "ℹ️ ",
};
write!(f, "{} [{}] {}", icon, self.advice_type, self.message)?;
if !self.nodes.is_empty() {
write!(f, "\n 📍 Nodes: {}", self.nodes.join(", "))?;
}
if let Some(ref suggestion) = self.suggestion {
write!(f, "\n 💡 {}", suggestion)?;
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalysisResult {
pub items: Vec<Advice>,
pub health_score: u8,
pub passed: bool,
}
impl AnalysisResult {
pub fn errors(&self) -> Vec<&Advice> {
self.items.iter().filter(|a| a.severity == Severity::Error).collect()
}
pub fn warnings(&self) -> Vec<&Advice> {
self.items.iter().filter(|a| a.severity == Severity::Warning).collect()
}
pub fn info(&self) -> Vec<&Advice> {
self.items.iter().filter(|a| a.severity == Severity::Info).collect()
}
}
impl std::fmt::Display for AnalysisResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.items.is_empty() {
return write!(f, "✅ Graph is healthy! Score: {}/100", self.health_score);
}
writeln!(f, "📊 Analysis Result")?;
writeln!(f, "═══════════════════════════════════════════════════")?;
writeln!(f)?;
for item in &self.items {
writeln!(f, "{}", item)?;
writeln!(f)?;
}
writeln!(f, "─────────────────────────────────────────────────────")?;
writeln!(f, "Summary: {} errors, {} warnings, {} info",
self.errors().len(),
self.warnings().len(),
self.info().len()
)?;
write!(f, "Health Score: {}/100", self.health_score)?;
Ok(())
}
}
pub fn analyze(graph: &Graph) -> AnalysisResult {
let mut items = Vec::new();
let code_node_types = ["file", "class", "function", "module"];
let validator = Validator::new(graph);
let validation = validator.validate();
for cycle in &validation.cycles {
items.push(Advice {
advice_type: AdviceType::CircularDependency,
severity: Severity::Error,
message: format!("Circular dependency detected: {}", cycle.join(" → ")),
nodes: cycle.clone(),
suggestion: Some("Break the cycle by removing one of the dependencies.".to_string()),
});
}
for missing in &validation.missing_refs {
items.push(Advice {
advice_type: AdviceType::MissingRef,
severity: Severity::Error,
message: format!("Edge references non-existent node '{}'", missing.missing_node),
nodes: vec![missing.edge_from.clone(), missing.edge_to.clone()],
suggestion: Some(format!("Add node '{}' or remove the edge.", missing.missing_node)),
});
}
for dup in &validation.duplicate_nodes {
items.push(Advice {
advice_type: AdviceType::DuplicateNode,
severity: Severity::Error,
message: format!("Duplicate node ID: {}", dup),
nodes: vec![dup.clone()],
suggestion: Some("Rename or remove duplicate nodes.".to_string()),
});
}
for orphan in &validation.orphan_nodes {
let is_code_orphan = orphan.starts_with("code_")
|| orphan.starts_with("const_")
|| orphan.starts_with("method_")
|| graph.get_node(orphan)
.and_then(|n| n.node_type.as_deref())
.map(|t| code_node_types.contains(&t))
.unwrap_or(false);
if !is_code_orphan {
items.push(Advice {
advice_type: AdviceType::OrphanNode,
severity: Severity::Warning,
message: format!("Node '{}' has no connections", orphan),
nodes: vec![orphan.clone()],
suggestion: Some("Connect to related nodes or remove if unused.".to_string()),
});
}
}
let (fan_in, fan_out) = compute_fan_metrics(graph);
const HIGH_FAN_THRESHOLD: usize = 5;
for (node_id, count) in &fan_in {
if *count >= HIGH_FAN_THRESHOLD {
let is_code = node_id.starts_with("code_") || node_id.starts_with("const_");
if !is_code {
items.push(Advice {
advice_type: AdviceType::HighFanIn,
severity: Severity::Warning,
message: format!("Node '{}' has {} dependents (high coupling)", node_id, count),
nodes: vec![node_id.clone()],
suggestion: Some("Consider splitting into smaller components or introducing an abstraction layer.".to_string()),
});
}
}
}
for (node_id, count) in &fan_out {
if *count >= HIGH_FAN_THRESHOLD {
let is_code = node_id.starts_with("code_") || node_id.starts_with("const_");
if !is_code {
items.push(Advice {
advice_type: AdviceType::HighFanOut,
severity: Severity::Warning,
message: format!("Node '{}' depends on {} other nodes (high coupling)", node_id, count),
nodes: vec![node_id.clone()],
suggestion: Some("Consider reducing dependencies or introducing a facade.".to_string()),
});
}
}
}
for node in &graph.nodes {
let is_code_node = node.node_type.as_deref()
.map(|t| code_node_types.contains(&t))
.unwrap_or(false)
|| node.id.starts_with("code_")
|| node.id.starts_with("const_")
|| node.id.starts_with("method_");
if node.description.is_none() && !is_code_node {
items.push(Advice {
advice_type: AdviceType::MissingDescription,
severity: Severity::Info,
message: format!("Node '{}' has no description", node.id),
nodes: vec![node.id.clone()],
suggestion: Some("Add a description to improve documentation.".to_string()),
});
}
}
let chain_depths = compute_chain_depths(graph);
const DEEP_CHAIN_THRESHOLD: usize = 5;
for (node_id, depth) in &chain_depths {
if *depth >= DEEP_CHAIN_THRESHOLD {
items.push(Advice {
advice_type: AdviceType::DeepDependencyChain,
severity: Severity::Info,
message: format!("Node '{}' has dependency chain depth of {}", node_id, depth),
nodes: vec![node_id.clone()],
suggestion: Some("Consider flattening the dependency structure.".to_string()),
});
}
}
let layer_violations = detect_layer_violations(graph);
for (from, to, from_layer, to_layer) in layer_violations {
items.push(Advice {
advice_type: AdviceType::LayerViolation,
severity: Severity::Warning,
message: format!(
"Layer violation: '{}' ({}) depends on '{}' ({})",
from,
from_layer.as_deref().unwrap_or("unassigned"),
to,
to_layer.as_deref().unwrap_or("unassigned")
),
nodes: vec![from.clone(), to.clone()],
suggestion: Some("Ensure dependencies flow from higher to lower layers.".to_string()),
});
}
let blocked_chains = detect_blocked_chains(graph);
for (blocked_node, affected) in blocked_chains {
if !affected.is_empty() {
items.push(Advice {
advice_type: AdviceType::BlockedChain,
severity: Severity::Warning,
message: format!(
"Blocked node '{}' is blocking {} other tasks",
blocked_node, affected.len()
),
nodes: std::iter::once(blocked_node).chain(affected).collect(),
suggestion: Some("Unblock this task to enable dependent work.".to_string()),
});
}
}
let engine = QueryEngine::new(graph);
if let Ok(topo_order) = engine.topological_sort() {
let todo_tasks: Vec<&String> = topo_order.iter()
.filter(|id| {
graph.get_node(id)
.map(|n| n.status == NodeStatus::Todo)
.unwrap_or(false)
})
.collect();
if todo_tasks.len() > 1 {
items.push(Advice {
advice_type: AdviceType::SuggestedTaskOrder,
severity: Severity::Info,
message: format!("Suggested order for {} todo tasks based on dependencies", todo_tasks.len()),
nodes: todo_tasks.iter().take(10).map(|s| s.to_string()).collect(),
suggestion: Some(format!(
"Start with: {}",
todo_tasks.iter().take(3).map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
)),
});
}
}
let dead_code_items = detect_dead_code(graph);
items.extend(dead_code_items);
#[cfg(feature = "infomap")]
{
let module_items = detect_modules(graph);
items.extend(module_items);
}
items.sort_by(|a, b| b.severity.cmp(&a.severity));
let error_count = items.iter().filter(|a| a.severity == Severity::Error).count();
let warning_count = items.iter().filter(|a| a.severity == Severity::Warning).count();
let info_count = items.iter()
.filter(|a| a.severity == Severity::Info
&& a.advice_type != AdviceType::DeadCode
&& a.advice_type != AdviceType::ModuleSuggestion)
.count();
let mut score = 100i32;
score -= (error_count * 25) as i32; score -= (warning_count * 10) as i32; score -= (info_count.min(10) * 2) as i32; let health_score = score.max(0).min(100) as u8;
AnalysisResult {
items,
health_score,
passed: validation.is_valid(),
}
}
fn compute_fan_metrics(graph: &Graph) -> (HashMap<String, usize>, HashMap<String, usize>) {
let mut fan_in: HashMap<String, usize> = HashMap::new();
let mut fan_out: HashMap<String, usize> = HashMap::new();
for edge in &graph.edges {
if edge.relation == "depends_on" {
*fan_in.entry(edge.to.clone()).or_default() += 1;
*fan_out.entry(edge.from.clone()).or_default() += 1;
}
}
(fan_in, fan_out)
}
fn compute_chain_depths(graph: &Graph) -> HashMap<String, usize> {
let mut depths: HashMap<String, usize> = HashMap::new();
let mut deps: HashMap<String, Vec<String>> = HashMap::new();
for edge in &graph.edges {
if edge.relation == "depends_on" {
deps.entry(edge.from.clone()).or_default().push(edge.to.clone());
}
}
fn compute_depth(
node: &str,
deps: &HashMap<String, Vec<String>>,
cache: &mut HashMap<String, usize>,
visiting: &mut HashSet<String>,
) -> usize {
if let Some(&depth) = cache.get(node) {
return depth;
}
if visiting.contains(node) {
return 0; }
visiting.insert(node.to_string());
let depth = deps.get(node)
.map(|children| {
children.iter()
.map(|child| compute_depth(child, deps, cache, visiting) + 1)
.max()
.unwrap_or(0)
})
.unwrap_or(0);
visiting.remove(node);
cache.insert(node.to_string(), depth);
depth
}
let mut visiting = HashSet::new();
for node in &graph.nodes {
compute_depth(&node.id, &deps, &mut depths, &mut visiting);
}
depths
}
fn detect_layer_violations(graph: &Graph) -> Vec<(String, String, Option<String>, Option<String>)> {
fn layer_rank(layer: Option<&str>) -> Option<i32> {
match layer {
Some("interface") | Some("presentation") => Some(4),
Some("application") | Some("service") => Some(3),
Some("domain") | Some("business") => Some(2),
Some("infrastructure") | Some("data") => Some(1),
_ => None,
}
}
let mut violations = Vec::new();
let node_layers: HashMap<&str, Option<&str>> = graph.nodes.iter()
.map(|n| (n.id.as_str(), n.node_type.as_deref()))
.collect();
let node_explicit_layers: HashMap<&str, Option<String>> = graph.nodes.iter()
.map(|n| {
let layer = n.metadata.get("layer")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
(n.id.as_str(), layer)
})
.collect();
for edge in &graph.edges {
if edge.relation == "depends_on" {
let from_layer = node_explicit_layers.get(edge.from.as_str())
.and_then(|l| l.as_ref())
.map(|s| s.as_str())
.or_else(|| node_layers.get(edge.from.as_str()).copied().flatten());
let to_layer = node_explicit_layers.get(edge.to.as_str())
.and_then(|l| l.as_ref())
.map(|s| s.as_str())
.or_else(|| node_layers.get(edge.to.as_str()).copied().flatten());
if let (Some(from_rank), Some(to_rank)) = (layer_rank(from_layer), layer_rank(to_layer)) {
if from_rank < to_rank {
violations.push((
edge.from.clone(),
edge.to.clone(),
from_layer.map(|s| s.to_string()),
to_layer.map(|s| s.to_string()),
));
}
}
}
}
violations
}
fn detect_blocked_chains(graph: &Graph) -> Vec<(String, Vec<String>)> {
let engine = QueryEngine::new(graph);
let mut results = Vec::new();
let blocked: Vec<&Node> = graph.nodes.iter()
.filter(|n| n.status == NodeStatus::Blocked)
.collect();
for node in blocked {
let affected: Vec<String> = engine.impact(&node.id)
.iter()
.filter(|n| n.status == NodeStatus::Todo || n.status == NodeStatus::InProgress)
.map(|n| n.id.clone())
.collect();
if !affected.is_empty() {
results.push((node.id.clone(), affected));
}
}
results
}
fn detect_dead_code(graph: &Graph) -> Vec<Advice> {
let mut items = Vec::new();
let code_functions: Vec<&Node> = graph.nodes.iter()
.filter(|n| n.node_type.as_deref() == Some("function"))
.collect();
if code_functions.is_empty() {
return items;
}
let mut incoming_calls: HashMap<&str, usize> = HashMap::new();
for edge in &graph.edges {
if edge.relation == "calls" {
*incoming_calls.entry(&edge.to).or_default() += 1;
}
}
let dead_functions: Vec<&Node> = code_functions
.into_iter()
.filter(|node| {
if incoming_calls.get(node.id.as_str()).copied().unwrap_or(0) > 0 {
return false;
}
if is_code_entry_point(node) {
return false;
}
if is_test_function(node) {
return false;
}
if is_public_code(node) {
return false;
}
if is_dunder(&node.title) {
return false;
}
if is_trait_impl_method(node, graph) {
return false;
}
if is_serde_default(node) {
return false;
}
if is_trait_definition_method(node, graph) {
return false;
}
if is_method_in_trait_implementing_struct(node, graph) {
return false;
}
true
})
.collect();
if dead_functions.is_empty() {
return items;
}
let mut by_file: HashMap<&str, Vec<&str>> = HashMap::new();
for node in &dead_functions {
let file_path = node.metadata.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
by_file.entry(file_path).or_default().push(&node.title);
}
for (file_path, names) in by_file {
let names_to_report: Vec<&str> = names.iter().take(10).copied().collect();
let remaining = names.len().saturating_sub(10);
let message = if remaining > 0 {
format!(
"{} has {} potentially dead functions: {} (and {} more)",
file_path,
names.len(),
names_to_report.join(", "),
remaining
)
} else {
format!(
"{} has {} potentially dead function(s): {}",
file_path,
names.len(),
names_to_report.join(", ")
)
};
items.push(Advice {
advice_type: AdviceType::DeadCode,
severity: Severity::Info,
message,
nodes: dead_functions.iter()
.filter(|n| {
n.metadata.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("") == file_path
})
.map(|n| n.id.clone())
.collect(),
suggestion: Some("Consider removing unused code or exposing it if intentionally unused.".to_string()),
});
}
items
}
fn is_code_entry_point(node: &Node) -> bool {
let name = &node.title;
let file_path = node.metadata.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("");
if matches!(name.as_str(), "main" | "lib" | "mod" | "index" | "app" | "run" | "start" | "init" | "setup") {
return true;
}
if file_path.ends_with("main.rs") || file_path.ends_with("lib.rs") {
return true;
}
if file_path.ends_with("index.ts")
|| file_path.ends_with("index.js")
|| file_path.ends_with("main.ts")
|| file_path.ends_with("main.js")
{
return true;
}
if name == "__main__" || file_path.ends_with("__main__.py") {
return true;
}
if name.starts_with("cmd_") || name.starts_with("command_") || name.starts_with("handle_") {
return true;
}
if name.starts_with("get_") || name.starts_with("post_") || name.starts_with("put_")
|| name.starts_with("delete_") || name.starts_with("patch_") {
return true;
}
if name.ends_with("_handler") || name.ends_with("_callback") || name.ends_with("_hook")
|| name.ends_with("_middleware") || name.ends_with("_listener") {
return true;
}
if matches!(name.as_str(), "ready" | "message" | "interaction_create" | "guild_member_addition") {
return true;
}
if let Some(sig) = node.metadata.get("signature").and_then(|v| v.as_str()) {
if sig.contains("#[no_mangle]") || sig.contains("extern") {
return true;
}
}
false
}
fn is_test_function(node: &Node) -> bool {
let name = &node.title;
let file_path = node.metadata.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("");
if file_path.contains("/test") || file_path.contains("_test.") || file_path.contains(".test.") || file_path.contains(".spec.") {
return true;
}
if name.starts_with("test_") || name.starts_with("Test") {
return true;
}
if node.id.contains("tests__") || node.id.contains("_tests_") {
return true;
}
if let Some(sig) = node.metadata.get("signature").and_then(|v| v.as_str()) {
if sig.contains("#[test]") || sig.contains("#[tokio::test]") {
return true;
}
}
false
}
fn is_public_code(node: &Node) -> bool {
if let Some(sig) = node.metadata.get("signature").and_then(|v| v.as_str()) {
if sig.starts_with("pub ") || sig.starts_with("pub(") {
return true;
}
if sig.starts_with("export ") {
return true;
}
}
false
}
fn is_trait_impl_method(node: &Node, graph: &Graph) -> bool {
let is_override_target = graph.edges.iter()
.any(|e| e.relation == "overrides" && e.to == node.id);
if is_override_target {
return true;
}
let parent_id = graph.edges.iter()
.find(|e| e.from == node.id && e.relation == "defined_in")
.map(|e| &e.to);
if let Some(parent) = parent_id {
let parent_has_trait = graph.edges.iter()
.any(|e| e.from == *parent && e.relation == "inherits");
if parent_has_trait {
return true;
}
}
let common_trait_methods = [
"fmt", "clone", "default", "eq", "ne", "hash", "cmp", "partial_cmp",
"drop", "deref", "deref_mut", "from", "into", "try_from", "try_into",
"as_ref", "as_mut", "to_owned", "to_string",
"next", "size_hint",
"serialize", "deserialize",
"poll", "wake",
];
if common_trait_methods.contains(&node.title.as_str()) {
let has_parent = graph.edges.iter()
.any(|e| e.from == node.id && e.relation == "defined_in");
if has_parent {
return true;
}
}
false
}
fn is_trait_definition_method(node: &Node, graph: &Graph) -> bool {
let parent_id = graph.edges.iter()
.find(|e| e.from == node.id && e.relation == "defined_in")
.map(|e| &e.to);
if let Some(parent) = parent_id {
if let Some(parent_node) = graph.get_node(parent) {
if let Some(sig) = parent_node.metadata.get("signature").and_then(|v| v.as_str()) {
if sig.contains("trait ") {
return true;
}
}
}
let is_trait = graph.edges.iter()
.any(|e| e.to == *parent && e.relation == "inherits");
if is_trait {
return true;
}
let is_overrides_source = graph.edges.iter()
.any(|e| e.relation == "overrides" && e.from.starts_with(&format!("{}.", parent.rsplit('_').next().unwrap_or(""))));
if is_overrides_source {
return true;
}
}
false
}
fn is_method_in_trait_implementing_struct(node: &Node, graph: &Graph) -> bool {
let parent_id = graph.edges.iter()
.find(|e| e.from == node.id && e.relation == "defined_in")
.map(|e| e.to.clone());
if let Some(parent) = parent_id {
let has_trait = graph.edges.iter()
.any(|e| e.from == parent && e.relation == "inherits");
if has_trait {
return true;
}
}
false
}
fn is_serde_default(node: &Node) -> bool {
let name = &node.title;
if name.starts_with("default_") {
return true;
}
false
}
fn is_dunder(name: &str) -> bool {
name.starts_with("__") && name.ends_with("__")
}
pub fn analyze_code_graph(code_graph: &CodeGraph) -> Vec<Advice> {
let mut items = Vec::new();
let mut incoming_calls: HashMap<&str, usize> = HashMap::new();
for edge in &code_graph.edges {
if edge.relation == EdgeRelation::Calls {
*incoming_calls.entry(&edge.to).or_default() += 1;
}
}
let dead_code: Vec<&crate::code_graph::CodeNode> = code_graph.nodes
.iter()
.filter(|node| {
if node.kind != NodeKind::Function {
return false;
}
if incoming_calls.get(node.id.as_str()).copied().unwrap_or(0) > 0 {
return false;
}
if is_entry_point(node) {
return false;
}
if node.is_test {
return false;
}
if is_public_api(node) {
return false;
}
if is_dunder_method(&node.name) {
return false;
}
if is_trait_impl(node, code_graph) {
return false;
}
true
})
.collect();
let mut by_file: HashMap<&str, Vec<&str>> = HashMap::new();
for node in &dead_code {
by_file.entry(&node.file_path).or_default().push(&node.name);
}
for (file_path, names) in by_file {
let names_to_report: Vec<&str> = names.iter().take(10).copied().collect();
let remaining = names.len().saturating_sub(10);
let message = if remaining > 0 {
format!(
"{} has {} potentially dead functions: {} (and {} more)",
file_path,
names.len(),
names_to_report.join(", "),
remaining
)
} else {
format!(
"{} has {} potentially dead function(s): {}",
file_path,
names.len(),
names_to_report.join(", ")
)
};
items.push(Advice {
advice_type: AdviceType::DeadCode,
severity: Severity::Info,
message,
nodes: dead_code.iter()
.filter(|n| n.file_path == file_path)
.map(|n| n.id.clone())
.collect(),
suggestion: Some("Consider removing unused code or exposing it if intentionally unused.".to_string()),
});
}
items
}
fn is_entry_point(node: &crate::code_graph::CodeNode) -> bool {
let name = &node.name;
if matches!(name.as_str(), "main" | "lib" | "mod" | "index" | "app" | "run" | "start" | "init" | "setup") {
return true;
}
if node.file_path.ends_with("main.rs") || node.file_path.ends_with("lib.rs") {
if name == "main" || name.starts_with("pub ") {
return true;
}
}
if node.file_path.ends_with("index.ts")
|| node.file_path.ends_with("index.js")
|| node.file_path.ends_with("main.ts")
|| node.file_path.ends_with("main.js")
|| node.file_path.ends_with("app.ts")
|| node.file_path.ends_with("app.js")
{
return true;
}
if name == "__main__" || node.file_path.ends_with("__main__.py") {
return true;
}
if name.starts_with("cmd_") || name.starts_with("command_") || name.starts_with("handle_") {
return true;
}
if name.starts_with("get_") || name.starts_with("post_") || name.starts_with("put_")
|| name.starts_with("delete_") || name.starts_with("patch_") {
return true;
}
if name.ends_with("_handler") || name.ends_with("_callback") || name.ends_with("_hook")
|| name.ends_with("_middleware") || name.ends_with("_listener") {
return true;
}
if matches!(name.as_str(), "ready" | "message" | "interaction_create" | "guild_member_addition") {
return true;
}
if node.decorators.iter().any(|d| d.contains("no_mangle") || d.contains("export_name")) {
return true;
}
false
}
fn is_public_api(node: &crate::code_graph::CodeNode) -> bool {
if let Some(ref sig) = node.signature {
if sig.starts_with("pub ") || sig.starts_with("pub(") {
return true;
}
}
if node.decorators.iter().any(|d| d == "export" || d.contains("Export")) {
return true;
}
if node.id.starts_with("method:") {
}
if node.file_path.ends_with(".py") && !node.name.starts_with('_') {
if node.id.starts_with("func:") {
return true;
}
}
false
}
fn is_dunder_method(name: &str) -> bool {
name.starts_with("__") && name.ends_with("__")
}
fn is_trait_impl(node: &crate::code_graph::CodeNode, code_graph: &CodeGraph) -> bool {
let parent_id = code_graph.edges.iter()
.find(|e| e.from == node.id && e.relation == EdgeRelation::DefinedIn)
.map(|e| &e.to);
if let Some(parent) = parent_id {
let has_trait_impl = code_graph.edges.iter()
.any(|e| &e.from == parent && e.relation == EdgeRelation::Inherits);
if has_trait_impl {
return true;
}
}
false
}
#[cfg(feature = "infomap")]
fn detect_modules(graph: &Graph) -> Vec<Advice> {
use infomap_rs::{Network, Infomap};
let mut items = Vec::new();
let code_files: Vec<&Node> = graph.nodes.iter()
.filter(|n| n.node_type.as_deref() == Some("file"))
.collect();
if code_files.len() < 4 {
return items; }
let id_to_idx: HashMap<&str, usize> = code_files.iter()
.enumerate()
.map(|(i, n)| (n.id.as_str(), i))
.collect();
let mut node_to_file_idx: HashMap<&str, usize> = HashMap::new();
for (i, file_node) in code_files.iter().enumerate() {
node_to_file_idx.insert(file_node.id.as_str(), i);
}
for node in &graph.nodes {
if node.node_type.as_deref() == Some("file") {
continue;
}
if let Some(fp) = node.file_path.as_deref()
.or_else(|| node.metadata.get("file_path").and_then(|v| v.as_str()))
{
let file_id = format!("file:{}", fp);
if let Some(&idx) = id_to_idx.get(file_id.as_str()) {
node_to_file_idx.insert(node.id.as_str(), idx);
}
}
}
let mut net = Network::new();
if !code_files.is_empty() {
net.add_edge(0, 0, 0.0); }
let coupling_relations = ["calls", "imports", "depends_on", "inherits", "implements"];
for edge in &graph.edges {
if !coupling_relations.contains(&edge.relation.as_str()) {
continue;
}
let from_idx = node_to_file_idx.get(edge.from.as_str())
.or_else(|| id_to_idx.get(edge.from.as_str()));
let to_idx = node_to_file_idx.get(edge.to.as_str())
.or_else(|| id_to_idx.get(edge.to.as_str()));
if let (Some(&from), Some(&to)) = (from_idx, to_idx) {
if from != to {
let w = edge.weight.unwrap_or(1.0);
net.add_edge(from, to, w);
}
}
}
if net.num_edges() < 2 {
return items;
}
for (i, file_node) in code_files.iter().enumerate() {
let display_name = file_node.file_path.as_deref()
.unwrap_or(&file_node.title);
net.add_node_name(i, display_name);
}
let result = Infomap::new(&net)
.seed(42)
.num_trials(5) .hierarchical(false) .run();
if result.num_modules() < 2 {
return items; }
let mut modules_with_files: Vec<(usize, Vec<&str>)> = Vec::new();
for module_info in result.modules() {
let file_paths: Vec<&str> = module_info.nodes.iter()
.filter_map(|&node_idx| {
code_files.get(node_idx)
.and_then(|n| n.file_path.as_deref()
.or_else(|| n.metadata.get("file_path").and_then(|v| v.as_str())))
})
.collect();
if file_paths.len() >= 2 {
modules_with_files.push((module_info.id, file_paths));
}
}
if modules_with_files.is_empty() {
return items;
}
for (module_id, files) in &modules_with_files {
let directories: HashSet<&str> = files.iter()
.filter_map(|fp| {
let p = std::path::Path::new(fp);
p.parent().and_then(|d| d.to_str())
})
.collect();
if directories.len() > 1 {
let file_list: Vec<String> = files.iter()
.take(8)
.map(|f| f.to_string())
.collect();
let remaining = files.len().saturating_sub(8);
let dir_list: Vec<&str> = directories.iter().take(5).copied().collect();
let message = if remaining > 0 {
format!(
"Module {} ({} files): tightly coupled files span {} directories ({}). Shown: {} (and {} more)",
module_id,
files.len(),
directories.len(),
dir_list.join(", "),
file_list.join(", "),
remaining
)
} else {
format!(
"Module {} ({} files): tightly coupled files span {} directories ({}): {}",
module_id,
files.len(),
directories.len(),
dir_list.join(", "),
file_list.join(", ")
)
};
items.push(Advice {
advice_type: AdviceType::ModuleSuggestion,
severity: Severity::Info,
message,
nodes: files.iter().map(|f| format!("file:{}", f)).collect(),
suggestion: Some(format!(
"Consider co-locating these files into a dedicated module — they form a cohesive unit based on dependency analysis."
)),
});
}
}
if items.is_empty() && modules_with_files.len() >= 2 {
let summary_parts: Vec<String> = modules_with_files.iter()
.take(5)
.map(|(id, files)| {
let sample: Vec<&&str> = files.iter().take(3).collect();
let names: Vec<String> = sample.iter()
.filter_map(|fp| std::path::Path::new(fp).file_name()
.and_then(|f| f.to_str())
.map(String::from))
.collect();
format!("Module {}: {} files ({})", id, files.len(), names.join(", "))
})
.collect();
let remaining_modules = modules_with_files.len().saturating_sub(5);
let mut message = format!(
"Infomap detected {} code modules (codelength {:.3}): {}",
result.num_modules(),
result.codelength(),
summary_parts.join("; ")
);
if remaining_modules > 0 {
message.push_str(&format!(" (and {} more)", remaining_modules));
}
items.push(Advice {
advice_type: AdviceType::ModuleSuggestion,
severity: Severity::Info,
message,
nodes: vec![],
suggestion: Some(
"Code modules are well-organized within their directories.".to_string()
),
});
}
items
}
#[cfg(feature = "infomap")]
pub fn detect_code_modules(graph: &Graph) -> Vec<DetectedModule> {
use infomap_rs::Infomap;
use crate::infer::clustering::build_network;
let (net, idx_to_id) = build_network(graph);
if net.num_nodes() < 2 || net.num_edges() < 1 {
return vec![];
}
let node_map: HashMap<&str, &Node> = graph
.nodes
.iter()
.map(|n| (n.id.as_str(), n))
.collect();
let result = Infomap::new(&net)
.seed(42)
.num_trials(5)
.hierarchical(false)
.run();
result
.modules()
.iter()
.map(|m| {
let files: Vec<String> = m
.nodes
.iter()
.filter_map(|&idx| idx_to_id.get(idx))
.filter_map(|nid| node_map.get(nid.as_str()))
.map(|n| {
n.file_path
.as_deref()
.or_else(|| {
n.metadata
.get("file_path")
.and_then(|v| v.as_str())
})
.unwrap_or(&n.title)
.to_string()
})
.collect();
let node_ids: Vec<String> = m
.nodes
.iter()
.filter_map(|&idx| idx_to_id.get(idx))
.map(|nid| nid.clone())
.collect();
DetectedModule {
id: m.id,
files,
node_ids,
flow: m.flow,
size: m.num_nodes,
}
})
.collect()
}
#[cfg(feature = "infomap")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectedModule {
pub id: usize,
pub files: Vec<String>,
pub node_ids: Vec<String>,
pub flow: f64,
pub size: usize,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::{Node, Edge};
#[test]
fn test_analyze_empty_graph() {
let graph = Graph::new();
let result = analyze(&graph);
assert!(result.passed);
assert_eq!(result.health_score, 100);
}
#[test]
fn test_analyze_orphan_node() {
let mut graph = Graph::new();
graph.add_node(Node::new("orphan", "Orphan Node"));
let result = analyze(&graph);
assert!(result.items.iter().any(|a| a.advice_type == AdviceType::OrphanNode));
}
#[test]
fn test_analyze_cycle() {
let mut graph = Graph::new();
graph.add_node(Node::new("a", "A"));
graph.add_node(Node::new("b", "B"));
graph.add_edge(Edge::depends_on("a", "b"));
graph.add_edge(Edge::depends_on("b", "a"));
let result = analyze(&graph);
assert!(!result.passed);
assert!(result.items.iter().any(|a| a.advice_type == AdviceType::CircularDependency));
}
#[test]
fn test_analyze_high_coupling() {
let mut graph = Graph::new();
graph.add_node(Node::new("hub", "Hub Node"));
for i in 0..6 {
let id = format!("dep{}", i);
graph.add_node(Node::new(&id, &format!("Dep {}", i)));
graph.add_edge(Edge::depends_on(&id, "hub"));
}
let result = analyze(&graph);
assert!(result.items.iter().any(|a| a.advice_type == AdviceType::HighFanIn));
}
fn make_file_node(path: &str) -> Node {
let mut node = Node::new(&format!("file:{}", path), path);
node.node_type = Some("file".to_string());
node.file_path = Some(path.to_string());
node
}
fn make_func_node(path: &str, name: &str) -> Node {
let id = format!("func:{}:{}", path, name);
let mut node = Node::new(&id, name);
node.node_type = Some("function".to_string());
node.file_path = Some(path.to_string());
node
}
#[cfg(feature = "infomap")]
#[test]
fn test_detect_modules_too_few_files() {
let mut graph = Graph::new();
graph.add_node(make_file_node("src/a.rs"));
graph.add_node(make_file_node("src/b.rs"));
graph.add_edge(Edge::new("file:src/a.rs", "file:src/b.rs", "imports"));
let result = detect_modules(&graph);
assert!(result.is_empty(), "Should return nothing for < 4 files");
}
#[cfg(feature = "infomap")]
#[test]
fn test_detect_modules_no_edges() {
let mut graph = Graph::new();
for i in 0..6 {
graph.add_node(make_file_node(&format!("src/file{}.rs", i)));
}
let result = detect_modules(&graph);
assert!(result.is_empty(), "Should return nothing with no edges");
}
#[cfg(feature = "infomap")]
#[test]
fn test_detect_modules_two_clusters() {
let mut graph = Graph::new();
graph.add_node(make_file_node("src/auth/login.rs"));
graph.add_node(make_file_node("src/auth/token.rs"));
graph.add_node(make_file_node("src/auth/middleware.rs"));
graph.add_node(make_file_node("src/api/routes.rs"));
graph.add_node(make_file_node("src/api/handlers.rs"));
graph.add_node(make_file_node("src/api/response.rs"));
graph.add_edge(Edge::new("file:src/auth/login.rs", "file:src/auth/token.rs", "imports"));
graph.add_edge(Edge::new("file:src/auth/token.rs", "file:src/auth/login.rs", "imports"));
graph.add_edge(Edge::new("file:src/auth/middleware.rs", "file:src/auth/token.rs", "imports"));
graph.add_edge(Edge::new("file:src/auth/login.rs", "file:src/auth/middleware.rs", "imports"));
graph.add_edge(Edge::new("file:src/api/routes.rs", "file:src/api/handlers.rs", "imports"));
graph.add_edge(Edge::new("file:src/api/handlers.rs", "file:src/api/routes.rs", "imports"));
graph.add_edge(Edge::new("file:src/api/response.rs", "file:src/api/handlers.rs", "imports"));
graph.add_edge(Edge::new("file:src/api/routes.rs", "file:src/api/response.rs", "imports"));
graph.add_edge({
let mut e = Edge::new("file:src/api/handlers.rs", "file:src/auth/middleware.rs", "imports");
e.weight = Some(0.1);
e
});
let result = detect_modules(&graph);
let modules = detect_code_modules(&graph);
assert!(modules.len() >= 2, "Should detect at least 2 modules, got {}", modules.len());
}
#[cfg(feature = "infomap")]
#[test]
fn test_detect_modules_cross_directory_suggestion() {
let mut graph = Graph::new();
graph.add_node(make_file_node("src/models/user.rs"));
graph.add_node(make_file_node("src/handlers/user_handler.rs"));
graph.add_node(make_file_node("src/validators/user_validator.rs"));
graph.add_node(make_file_node("src/util/hash.rs"));
graph.add_node(make_file_node("src/util/crypto.rs"));
graph.add_node(make_file_node("src/util/encoding.rs"));
graph.add_edge(Edge::new("file:src/models/user.rs", "file:src/handlers/user_handler.rs", "imports"));
graph.add_edge(Edge::new("file:src/handlers/user_handler.rs", "file:src/models/user.rs", "imports"));
graph.add_edge(Edge::new("file:src/validators/user_validator.rs", "file:src/models/user.rs", "imports"));
graph.add_edge(Edge::new("file:src/handlers/user_handler.rs", "file:src/validators/user_validator.rs", "imports"));
graph.add_edge(Edge::new("file:src/util/hash.rs", "file:src/util/crypto.rs", "imports"));
graph.add_edge(Edge::new("file:src/util/crypto.rs", "file:src/util/hash.rs", "imports"));
graph.add_edge(Edge::new("file:src/util/encoding.rs", "file:src/util/crypto.rs", "imports"));
graph.add_edge(Edge::new("file:src/util/encoding.rs", "file:src/util/hash.rs", "imports"));
let result = detect_modules(&graph);
let cross_dir = result.iter()
.any(|a| a.advice_type == AdviceType::ModuleSuggestion
&& a.message.contains("span"));
assert!(cross_dir, "Should suggest grouping for cross-directory coupled files. Items: {:?}",
result.iter().map(|a| &a.message).collect::<Vec<_>>());
}
#[cfg(feature = "infomap")]
#[test]
fn test_detect_modules_function_level_edges() {
let mut graph = Graph::new();
graph.add_node(make_file_node("src/a.rs"));
graph.add_node(make_file_node("src/b.rs"));
graph.add_node(make_file_node("src/c.rs"));
graph.add_node(make_file_node("src/d.rs"));
graph.add_node(make_func_node("src/a.rs", "foo"));
graph.add_node(make_func_node("src/b.rs", "bar"));
graph.add_node(make_func_node("src/c.rs", "baz"));
graph.add_node(make_func_node("src/d.rs", "qux"));
graph.add_edge(Edge::new("func:src/a.rs:foo", "func:src/b.rs:bar", "calls"));
graph.add_edge(Edge::new("func:src/b.rs:bar", "func:src/a.rs:foo", "calls"));
graph.add_edge(Edge::new("func:src/c.rs:baz", "func:src/d.rs:qux", "calls"));
graph.add_edge(Edge::new("func:src/d.rs:qux", "func:src/c.rs:baz", "calls"));
let modules = detect_code_modules(&graph);
assert!(modules.len() >= 1, "Should detect modules from function-level edges");
}
#[cfg(feature = "infomap")]
#[test]
fn test_detect_code_modules_public_api() {
let mut graph = Graph::new();
for i in 0..6 {
graph.add_node(make_file_node(&format!("src/mod{}.rs", i)));
}
graph.add_edge(Edge::new("file:src/mod0.rs", "file:src/mod1.rs", "imports"));
graph.add_edge(Edge::new("file:src/mod1.rs", "file:src/mod2.rs", "imports"));
graph.add_edge(Edge::new("file:src/mod2.rs", "file:src/mod0.rs", "imports"));
graph.add_edge(Edge::new("file:src/mod3.rs", "file:src/mod4.rs", "imports"));
graph.add_edge(Edge::new("file:src/mod4.rs", "file:src/mod5.rs", "imports"));
graph.add_edge(Edge::new("file:src/mod5.rs", "file:src/mod3.rs", "imports"));
let modules = detect_code_modules(&graph);
assert!(modules.len() >= 2, "Public API should detect 2+ modules");
for m in &modules {
assert!(!m.files.is_empty());
assert!(!m.node_ids.is_empty());
assert!(m.size > 0);
assert!(m.flow >= 0.0);
}
}
#[cfg(feature = "infomap")]
#[test]
fn test_module_suggestion_does_not_affect_health_score() {
let mut graph = Graph::new();
for i in 0..3 {
graph.add_node(make_file_node(&format!("src/alpha/f{}.rs", i)));
graph.add_node(make_file_node(&format!("src/beta/g{}.rs", i)));
}
graph.add_edge(Edge::new("file:src/alpha/f0.rs", "file:src/alpha/f1.rs", "imports"));
graph.add_edge(Edge::new("file:src/alpha/f1.rs", "file:src/alpha/f2.rs", "imports"));
graph.add_edge(Edge::new("file:src/alpha/f2.rs", "file:src/alpha/f0.rs", "imports"));
graph.add_edge(Edge::new("file:src/beta/g0.rs", "file:src/beta/g1.rs", "imports"));
graph.add_edge(Edge::new("file:src/beta/g1.rs", "file:src/beta/g2.rs", "imports"));
graph.add_edge(Edge::new("file:src/beta/g2.rs", "file:src/beta/g0.rs", "imports"));
let result = analyze(&graph);
let has_module_suggestion = result.items.iter()
.any(|a| a.advice_type == AdviceType::ModuleSuggestion);
if has_module_suggestion {
assert!(result.health_score >= 50,
"ModuleSuggestion should not heavily impact health score, got {}", result.health_score);
}
}
}