use petgraph::algo::kosaraju_scc;
use petgraph::graph::{DiGraph, NodeIndex};
use std::collections::HashMap;
use std::time::{Duration, Instant};
use tracing::debug;
use crate::config::{Exemptions, ScoringConfig, Thresholds};
use crate::models::DriftScore;
#[derive(Debug, Default, Clone, Copy)]
pub struct DriftTimings {
pub cycle: Duration,
pub layering: Duration,
pub boundary_rules: Duration,
pub hub: Duration,
pub coupling: Duration,
pub cognitive: Duration,
pub instability: Duration,
pub fan_deltas: Duration,
}
pub const LEGACY_BOUNDARY_RULES: &[(&str, &str)] = &[
("packages::", "apps::"),
("lib::", "apps::"),
("core::", "apps::"),
("shared::", "apps::"),
("packages::", "cmd::"),
("lib::", "cmd::"),
("packages/", "apps/"),
("libs/", "apps/"),
("libs/", "cli/"),
("ext/", "cli/"),
("runtime/", "cli/"),
("libs/", "runtime/"),
];
fn compute_cycle_debt(graph: &DiGraph<String, u32>) -> (f64, usize) {
let n = graph.node_count();
if n == 0 {
return (0.0, 0);
}
let sccs = kosaraju_scc(graph);
let non_trivial: Vec<&Vec<NodeIndex>> = sccs.iter().filter(|scc| scc.len() > 1).collect();
let scc_count = non_trivial.len();
if scc_count == 0 {
return (0.0, 0);
}
let cyclic_nodes: usize = non_trivial.iter().map(|scc| scc.len()).sum();
let cyclic_fraction = cyclic_nodes as f64 / n as f64;
let max_scc_size = non_trivial.iter().map(|scc| scc.len()).max().unwrap_or(0);
let max_scc_fraction = max_scc_size as f64 / n as f64;
let count_score = ((1.0 + scc_count as f64).ln() / (1.0 + n as f64 / 4.0).ln()).min(1.0);
let raw = 0.50 * cyclic_fraction + 0.30 * max_scc_fraction + 0.20 * count_score;
let debt = 100.0 * (1.0 - (-3.0 * raw).exp());
(debt, scc_count)
}
fn compute_layering_debt(graph: &DiGraph<String, u32>) -> (f64, usize) {
let n = graph.node_count();
let e = graph.edge_count();
if n < 3 || e == 0 {
return (0.0, 0);
}
let sccs = kosaraju_scc(graph);
let mut node_to_scc: HashMap<NodeIndex, usize> = HashMap::new();
for (scc_idx, scc) in sccs.iter().enumerate() {
for &node in scc {
node_to_scc.insert(node, scc_idx);
}
}
let mut scc_internal_edges: Vec<usize> = vec![0; sccs.len()];
for edge_idx in graph.edge_indices() {
let (src, tgt) = graph.edge_endpoints(edge_idx).unwrap();
let src_scc = node_to_scc[&src];
let tgt_scc = node_to_scc[&tgt];
if src_scc == tgt_scc && sccs[src_scc].len() > 1 {
scc_internal_edges[src_scc] += 1;
}
}
let mut violations = 0usize;
for (scc_idx, scc) in sccs.iter().enumerate() {
if scc.len() > 1 {
let internal = scc_internal_edges[scc_idx];
violations += internal.saturating_sub(scc.len());
}
}
if violations == 0 {
return (0.0, 0);
}
let violation_ratio = violations as f64 / e as f64;
let debt = 100.0 * (1.0 - (-3.0 * violation_ratio).exp());
(debt, violations)
}
fn compute_boundary_rule_violations(graph: &DiGraph<String, u32>, config: &ScoringConfig) -> usize {
if config.boundaries.is_empty() {
return 0;
}
let mut source_rule_cache: HashMap<NodeIndex, Vec<usize>> = HashMap::new();
for node_idx in graph.node_indices() {
let from = &graph[node_idx];
let matching_rules: Vec<usize> = config
.boundaries
.iter()
.enumerate()
.filter_map(|(rule_idx, rule)| rule.matches_from(from).then_some(rule_idx))
.collect();
if !matching_rules.is_empty() {
source_rule_cache.insert(node_idx, matching_rules);
}
}
let mut target_rule_cache: HashMap<(usize, NodeIndex), bool> = HashMap::new();
let mut violations = 0usize;
for edge_idx in graph.edge_indices() {
let Some((src, tgt)) = graph.edge_endpoints(edge_idx) else {
continue;
};
let Some(rule_indices) = source_rule_cache.get(&src) else {
continue;
};
let to = &graph[tgt];
if rule_indices.iter().any(|&rule_idx| {
*target_rule_cache
.entry((rule_idx, tgt))
.or_insert_with(|| config.boundaries[rule_idx].matches_to(to))
}) {
violations += 1;
}
}
violations
}
fn compute_hub_debt(
graph: &DiGraph<String, u32>,
thresholds: &Thresholds,
exemptions: &Exemptions,
) -> f64 {
let n = graph.node_count();
if n < 6 {
return 0.0;
}
let threshold = (2.0 * (n as f64).sqrt()).max(8.0);
let mut total_god_penalty = 0.0f64;
let mut god_count = 0u32;
for node_idx in graph.node_indices() {
let module_name = &graph[node_idx];
if exemptions
.hub_exempt
.iter()
.any(|e| module_name.contains(e.as_str()))
{
continue;
}
if is_entry_point_stem(module_name, &exemptions.entry_point_stems) {
continue;
}
let fan_in = graph
.neighbors_directed(node_idx, petgraph::Direction::Incoming)
.count();
let fan_out = graph
.neighbors_directed(node_idx, petgraph::Direction::Outgoing)
.count();
let total_degree = fan_in + fan_out;
if (total_degree as f64) < threshold {
continue;
}
let hub_ratio = fan_out as f64 / (fan_in as f64 + 1.0);
if hub_ratio < thresholds.hub_exemption_ratio {
continue;
}
if fan_in <= thresholds.entry_point_max_fan_in {
continue;
}
let excess_ratio = (total_degree as f64 - threshold) / threshold;
let weighted_degree: u32 = graph
.edges_directed(node_idx, petgraph::Direction::Incoming)
.map(|e| *e.weight())
.sum::<u32>()
+ graph
.edges_directed(node_idx, petgraph::Direction::Outgoing)
.map(|e| *e.weight())
.sum::<u32>();
let weight_multiplier =
(weighted_degree as f64 / total_degree.max(1) as f64 / 2.0).clamp(0.5, 2.0);
let penalty = excess_ratio * weight_multiplier * hub_ratio.min(1.0);
total_god_penalty += penalty;
god_count += 1;
}
if god_count == 0 {
return 0.0;
}
let raw = (total_god_penalty / god_count as f64) * (1.0 + 0.3 * (god_count - 1) as f64);
(100.0 * (1.0 - (-2.0 * raw).exp())).min(100.0)
}
fn compute_coupling_debt(graph: &DiGraph<String, u32>) -> f64 {
let n = graph.node_count();
let e = graph.edge_count();
if n < 3 || e == 0 {
return 0.0;
}
let log_weights: Vec<f64> = graph
.edge_indices()
.map(|ei| (1.0 + graph[ei] as f64).ln())
.collect();
let total_log_weight: f64 = log_weights.iter().sum();
let log_density = total_log_weight / n as f64;
let expected = 3.0 + 2.0 * (n as f64).ln();
let density_excess = ((log_density - expected).max(0.0) / expected).min(1.0);
let mean_log = total_log_weight / e as f64;
let variance: f64 = log_weights
.iter()
.map(|w| (w - mean_log).powi(2))
.sum::<f64>()
/ e as f64;
let std_dev = variance.sqrt();
let cv = if mean_log > 0.0 {
std_dev / mean_log
} else {
0.0
};
let concentration = (cv / 2.0).min(1.0);
let raw = 0.60 * density_excess + 0.40 * concentration;
100.0 * (1.0 - (-3.0 * raw).exp())
}
fn compute_cognitive_debt(graph: &DiGraph<String, u32>) -> f64 {
let n = graph.node_count();
let e = graph.edge_count();
if n < 3 {
return 0.0;
}
let baseline_edges = 2 * n;
let edge_excess = if baseline_edges > 0 {
((e as f64 / baseline_edges as f64) - 1.0).max(0.0)
} else {
0.0
};
let excess_ratio = (edge_excess / 2.0).min(1.0);
let avg_degree = if n > 0 {
2.0 * e as f64 / n as f64
} else {
0.0
};
let expected_avg = (2.0 * (n as f64).ln()).max(3.0);
let degree_excess = ((avg_degree / expected_avg) - 1.0).clamp(0.0, 1.0);
let scale = 1.0 - (-(n as f64) / 20.0).exp();
let raw = (0.50 * excess_ratio + 0.50 * degree_excess) * scale;
100.0 * (1.0 - (-3.0 * raw).exp())
}
fn compute_instability_debt(
graph: &DiGraph<String, u32>,
thresholds: &Thresholds,
exemptions: &Exemptions,
) -> f64 {
let n = graph.node_count();
if n < 3 {
return 0.0;
}
let mut brittle_count = 0usize;
let mut eligible_count = 0usize;
for node_idx in graph.node_indices() {
let module_name = &graph[node_idx];
if exemptions
.instability_exempt
.iter()
.any(|e| module_name.contains(e.as_str()))
{
continue;
}
if is_entry_point_stem(module_name, &exemptions.entry_point_stems) {
continue;
}
let ca = graph
.neighbors_directed(node_idx, petgraph::Direction::Incoming)
.count();
let ce = graph
.neighbors_directed(node_idx, petgraph::Direction::Outgoing)
.count();
let total = ca + ce;
if total < 3 {
continue;
}
if ca == 0 {
continue;
}
eligible_count += 1;
let instability = ce as f64 / total as f64;
if instability > thresholds.brittle_instability_ratio {
brittle_count += 1;
}
}
if eligible_count == 0 {
return 0.0;
}
let brittle_ratio = brittle_count as f64 / eligible_count as f64;
let scale = 1.0 - (-(n as f64) / 30.0).exp();
let raw = brittle_ratio * scale;
100.0 * (1.0 - (-3.0 * raw).exp())
}
fn compute_fan_deltas(
graph: &DiGraph<String, u32>,
prev_graph: Option<&DiGraph<String, u32>>,
) -> (i32, i32) {
let prev = match prev_graph {
Some(p) => p,
None => return (0, 0),
};
let current_map = build_fan_map(graph);
let prev_map = build_fan_map(prev);
let mut in_deltas: Vec<i32> = Vec::new();
let mut out_deltas: Vec<i32> = Vec::new();
for (name, (cur_in, cur_out)) in ¤t_map {
if let Some(&(prev_in, prev_out)) = prev_map.get(name) {
in_deltas.push(*cur_in as i32 - prev_in as i32);
out_deltas.push(*cur_out as i32 - prev_out as i32);
}
}
(median_i32(&mut in_deltas), median_i32(&mut out_deltas))
}
fn build_fan_map(graph: &DiGraph<String, u32>) -> HashMap<String, (usize, usize)> {
let mut map = HashMap::new();
for node_idx in graph.node_indices() {
let fan_in = graph
.neighbors_directed(node_idx, petgraph::Direction::Incoming)
.count();
let fan_out = graph
.neighbors_directed(node_idx, petgraph::Direction::Outgoing)
.count();
map.insert(graph[node_idx].clone(), (fan_in, fan_out));
}
map
}
fn median_i32(values: &mut [i32]) -> i32 {
if values.is_empty() {
return 0;
}
values.sort_unstable();
let mid = values.len() / 2;
if values.len().is_multiple_of(2) {
((values[mid - 1] as i64 + values[mid] as i64) / 2) as i32
} else {
values[mid]
}
}
pub fn compute_instability_metrics(
graph: &DiGraph<String, u32>,
) -> Vec<(String, f64, usize, usize)> {
let mut metrics = Vec::new();
for node_idx in graph.node_indices() {
let ca = graph
.neighbors_directed(node_idx, petgraph::Direction::Incoming)
.count();
let ce = graph
.neighbors_directed(node_idx, petgraph::Direction::Outgoing)
.count();
let instability = if ca + ce > 0 {
ce as f64 / (ca + ce) as f64
} else {
0.0
};
metrics.push((graph[node_idx].clone(), instability, ca, ce));
}
metrics.sort_by(|a, b| {
let total_b = b.2 + b.3;
let total_a = a.2 + a.3;
total_b
.cmp(&total_a)
.then(b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal))
});
metrics
}
pub fn calculate_drift(
graph: &DiGraph<String, u32>,
prev_graph: Option<&DiGraph<String, u32>>,
timestamp: i64,
config: &ScoringConfig,
) -> DriftScore {
let n = graph.node_count();
if n == 0 {
return DriftScore {
total: 0,
fan_in_delta: 0,
fan_out_delta: 0,
new_cycles: 0,
boundary_violations: 0,
layering_violations: 0,
cognitive_complexity: 0.0,
timestamp,
cycle_debt: 0.0,
layering_debt: 0.0,
hub_debt: 0.0,
coupling_debt: 0.0,
cognitive_debt: 0.0,
instability_debt: 0.0,
};
}
let (cycle_score, scc_count) = compute_cycle_debt(graph);
let (structural_layering_score, layering_violations) = compute_layering_debt(graph);
let boundary_violations = compute_boundary_rule_violations(graph, config);
let boundary_rule_score = if graph.edge_count() == 0 || boundary_violations == 0 {
0.0
} else {
let ratio = boundary_violations as f64 / graph.edge_count() as f64;
100.0 * (1.0 - (-3.0 * ratio).exp())
};
let layering_score = structural_layering_score.max(boundary_rule_score);
let hub_score = compute_hub_debt(graph, &config.thresholds, &config.exemptions);
let coupling_score = compute_coupling_debt(graph);
let cognitive_score = compute_cognitive_debt(graph);
let instability_score = compute_instability_debt(graph, &config.thresholds, &config.exemptions);
let w = config.weights.normalized();
let total_debt = (w.cycle * cycle_score
+ w.layering * layering_score
+ w.hub * hub_score
+ w.coupling * coupling_score
+ w.cognitive * cognitive_score
+ w.instability * instability_score)
.round()
.min(100.0) as u8;
let (fan_in_delta, fan_out_delta) = compute_fan_deltas(graph, prev_graph);
let cog_complexity = (cognitive_score * 10.0).round() / 10.0;
debug!(
total_debt,
cycle_score,
layering_score,
structural_layering_score,
boundary_rule_score,
hub_score,
coupling_score,
cognitive_score,
instability_score,
"Architectural Health assessment complete"
);
DriftScore {
total: total_debt,
fan_in_delta,
fan_out_delta,
new_cycles: scc_count,
boundary_violations,
layering_violations,
cognitive_complexity: cog_complexity,
timestamp,
cycle_debt: (cycle_score * 10.0).round() / 10.0,
layering_debt: (layering_score * 10.0).round() / 10.0,
hub_debt: (hub_score * 10.0).round() / 10.0,
coupling_debt: (coupling_score * 10.0).round() / 10.0,
cognitive_debt: (cognitive_score * 10.0).round() / 10.0,
instability_debt: (instability_score * 10.0).round() / 10.0,
}
}
pub fn calculate_drift_profiled(
graph: &DiGraph<String, u32>,
prev_graph: Option<&DiGraph<String, u32>>,
timestamp: i64,
config: &ScoringConfig,
) -> (DriftScore, DriftTimings) {
let mut timings = DriftTimings::default();
let n = graph.node_count();
if n == 0 {
return (
DriftScore {
total: 0,
fan_in_delta: 0,
fan_out_delta: 0,
new_cycles: 0,
boundary_violations: 0,
layering_violations: 0,
cognitive_complexity: 0.0,
timestamp,
cycle_debt: 0.0,
layering_debt: 0.0,
hub_debt: 0.0,
coupling_debt: 0.0,
cognitive_debt: 0.0,
instability_debt: 0.0,
},
timings,
);
}
let cycle_start = Instant::now();
let (cycle_score, scc_count) = compute_cycle_debt(graph);
timings.cycle += cycle_start.elapsed();
let layering_start = Instant::now();
let (structural_layering_score, layering_violations) = compute_layering_debt(graph);
timings.layering += layering_start.elapsed();
let boundary_start = Instant::now();
let boundary_violations = compute_boundary_rule_violations(graph, config);
timings.boundary_rules += boundary_start.elapsed();
let boundary_rule_score = if graph.edge_count() == 0 || boundary_violations == 0 {
0.0
} else {
let ratio = boundary_violations as f64 / graph.edge_count() as f64;
100.0 * (1.0 - (-3.0 * ratio).exp())
};
let layering_score = structural_layering_score.max(boundary_rule_score);
let hub_start = Instant::now();
let hub_score = compute_hub_debt(graph, &config.thresholds, &config.exemptions);
timings.hub += hub_start.elapsed();
let coupling_start = Instant::now();
let coupling_score = compute_coupling_debt(graph);
timings.coupling += coupling_start.elapsed();
let cognitive_start = Instant::now();
let cognitive_score = compute_cognitive_debt(graph);
timings.cognitive += cognitive_start.elapsed();
let instability_start = Instant::now();
let instability_score = compute_instability_debt(graph, &config.thresholds, &config.exemptions);
timings.instability += instability_start.elapsed();
let w = config.weights.normalized();
let total_debt = (w.cycle * cycle_score
+ w.layering * layering_score
+ w.hub * hub_score
+ w.coupling * coupling_score
+ w.cognitive * cognitive_score
+ w.instability * instability_score)
.round()
.min(100.0) as u8;
let fan_delta_start = Instant::now();
let (fan_in_delta, fan_out_delta) = compute_fan_deltas(graph, prev_graph);
timings.fan_deltas += fan_delta_start.elapsed();
let cog_complexity = (cognitive_score * 10.0).round() / 10.0;
debug!(
total_debt,
cycle_score,
layering_score,
structural_layering_score,
boundary_rule_score,
hub_score,
coupling_score,
cognitive_score,
instability_score,
"Architectural Health assessment complete"
);
(
DriftScore {
total: total_debt,
fan_in_delta,
fan_out_delta,
new_cycles: scc_count,
boundary_violations,
layering_violations,
cognitive_complexity: cog_complexity,
timestamp,
cycle_debt: (cycle_score * 10.0).round() / 10.0,
layering_debt: (layering_score * 10.0).round() / 10.0,
hub_debt: (hub_score * 10.0).round() / 10.0,
coupling_debt: (coupling_score * 10.0).round() / 10.0,
cognitive_debt: (cognitive_score * 10.0).round() / 10.0,
instability_debt: (instability_score * 10.0).round() / 10.0,
},
timings,
)
}
fn count_cycles(graph: &DiGraph<String, u32>) -> usize {
kosaraju_scc(graph)
.iter()
.filter(|scc| scc.len() > 1)
.count()
}
pub fn count_cycles_public(graph: &DiGraph<String, u32>) -> usize {
count_cycles(graph)
}
pub fn extract_scc_members(graph: &DiGraph<String, u32>) -> Vec<Vec<String>> {
let sccs = kosaraju_scc(graph);
let mut groups: Vec<Vec<String>> = sccs
.into_iter()
.filter(|scc| scc.len() > 1)
.map(|scc| {
let mut names: Vec<String> = scc.iter().map(|&idx| graph[idx].clone()).collect();
names.sort();
names
})
.collect();
groups.sort_by_key(|g| std::cmp::Reverse(g.len()));
groups
}
pub fn edges_to_pairs(edges: &[crate::models::DependencyEdge]) -> Vec<(String, String)> {
edges
.iter()
.map(|e| (e.from_module.clone(), e.to_module.clone()))
.collect()
}
fn is_entry_point_stem(module_name: &str, stems: &[String]) -> bool {
let basename = module_name.split('/').next_back().unwrap_or(module_name);
let stem = basename.split('.').next().unwrap_or(basename);
stems.iter().any(|s| s == stem)
}
pub fn generate_diagnostics(
graph: &DiGraph<String, u32>,
drift: &crate::models::DriftScore,
config: &ScoringConfig,
) -> Vec<String> {
let mut lines = Vec::new();
let n = graph.node_count();
let e = graph.edge_count();
if drift.cycle_debt > 10.0 {
let sccs = kosaraju_scc(graph);
let non_trivial: Vec<_> = sccs.iter().filter(|scc| scc.len() > 1).collect();
if !non_trivial.is_empty() {
let largest = non_trivial.iter().map(|s| s.len()).max().unwrap_or(0);
let cyclic_nodes: usize = non_trivial.iter().map(|s| s.len()).sum();
if non_trivial.len() == 1 {
lines.push(format!(
"{} modules form a circular dependency chain. \
Break the cycle with interfaces or traits.",
cyclic_nodes
));
} else {
lines.push(format!(
"{} circular dependency groups found ({} modules involved, \
largest spans {}). Consider dependency inversion.",
non_trivial.len(),
cyclic_nodes,
largest
));
}
}
}
if drift.layering_debt > 10.0 && drift.layering_violations > 0 {
let count = drift.layering_violations;
if count == 1 {
lines.push(
"1 extra cross-cutting edge inside a cycle. \
Simplifying internal wiring would improve clarity."
.to_string(),
);
} else {
lines.push(format!(
"{} extra edges inside dependency cycles make the \
structure harder to follow. Simplify internal wiring.",
count
));
}
}
if drift.boundary_violations > 0 {
if drift.boundary_violations == 1 {
lines.push(
"1 configured package boundary is being crossed. Tighten module ownership."
.to_string(),
);
} else {
lines.push(format!(
"{} configured package boundaries are being crossed. Review the violating edges.",
drift.boundary_violations
));
}
}
if drift.hub_debt > 10.0 {
let threshold = (2.0 * (n as f64).sqrt()).max(8.0);
let mut hubs: Vec<(String, usize, usize)> = Vec::new();
for node_idx in graph.node_indices() {
let module_name = &graph[node_idx];
if config
.exemptions
.hub_exempt
.iter()
.any(|e| module_name.contains(e.as_str()))
{
continue;
}
let fi = graph
.neighbors_directed(node_idx, petgraph::Direction::Incoming)
.count();
let fo = graph
.neighbors_directed(node_idx, petgraph::Direction::Outgoing)
.count();
let total = fi + fo;
if total as f64 >= threshold {
let ratio = fo as f64 / (fi as f64 + 1.0);
if ratio >= config.thresholds.hub_exemption_ratio
&& fi > config.thresholds.entry_point_max_fan_in
{
hubs.push((graph[node_idx].clone(), fi, fo));
}
}
}
hubs.sort_by(|a, b| (b.1 + b.2).cmp(&(a.1 + a.2)));
for (name, fi, fo) in hubs.iter().take(2) {
lines.push(format!(
"{} has {} incoming and {} outgoing deps — \
it's doing too much. Split responsibilities.",
name, fi, fo
));
}
}
if drift.coupling_debt > 10.0 {
let total_log_w: f64 = graph
.edge_indices()
.map(|ei| (1.0 + graph[ei] as f64).ln())
.sum();
let log_density = total_log_w / n.max(1) as f64;
let expected = 3.0 + 2.0 * (n as f64).ln();
let mut heaviest = ("?".to_string(), "?".to_string(), 0u32);
for edge_idx in graph.edge_indices() {
let w = graph[edge_idx];
if w > heaviest.2 {
let (src, tgt) = graph.edge_endpoints(edge_idx).unwrap();
heaviest = (graph[src].clone(), graph[tgt].clone(), w);
}
}
let ratio = log_density / expected;
if ratio > 1.5 {
lines.push(format!(
"Modules are more tightly connected than expected ({:.1}x). \
Introduce abstractions to reduce direct dependencies.",
ratio
));
} else {
lines.push(
"Some modules share more dependencies than expected. \
Review coupling and consider interfaces."
.to_string(),
);
}
if heaviest.2 > 5 {
lines.push(format!(
"Strongest link: {} \u{2192} {} ({} imports). \
This tight binding makes both harder to change.",
heaviest.0, heaviest.1, heaviest.2
));
}
}
if drift.cognitive_debt > 10.0 {
let baseline = 2 * n;
let pct = if baseline > 0 {
(((e as f64 / baseline as f64) - 1.0) * 100.0).round() as i32
} else {
0
};
if pct > 0 {
lines.push(format!(
"The graph has {}% more connections than typical. \
Fewer links would make the architecture easier to reason about.",
pct
));
}
}
if drift.instability_debt > 10.0 {
let mut brittle_names: Vec<String> = Vec::new();
let mut eligible = 0usize;
for node_idx in graph.node_indices() {
let module_name = &graph[node_idx];
if config
.exemptions
.instability_exempt
.iter()
.any(|e| module_name.contains(e.as_str()))
{
continue;
}
let ca = graph
.neighbors_directed(node_idx, petgraph::Direction::Incoming)
.count();
let ce = graph
.neighbors_directed(node_idx, petgraph::Direction::Outgoing)
.count();
if ca + ce >= 3 && ca > 0 {
eligible += 1;
if ce as f64 / (ca + ce) as f64 > config.thresholds.brittle_instability_ratio {
let name = graph[node_idx].clone();
let basename = name.split('/').next_back().unwrap_or(&name);
let stem = basename.split('.').next().unwrap_or(basename);
let is_entry = config
.exemptions
.entry_point_stems
.iter()
.any(|s| s == stem);
if !is_entry {
brittle_names.push(name);
}
}
}
}
if !brittle_names.is_empty() {
if brittle_names.len() <= 3 {
lines.push(format!(
"{} fragile: depends on many others but few depend on \
it. Changes upstream will likely cascade here.",
brittle_names.join(", ")
));
} else {
lines.push(format!(
"{} of {} core modules are fragile — they depend heavily \
on others. Stabilize with dependency injection.",
brittle_names.len(),
eligible
));
}
}
}
lines
}
#[derive(Debug, Clone)]
pub struct CognitiveDebtDetail {
pub score: f64,
pub edge_excess_ratio: f64,
pub degree_excess: f64,
pub avg_degree: f64,
pub expected_avg_degree: f64,
pub baseline_edges: usize,
pub actual_edges: usize,
pub scale_factor: f64,
}
pub fn extract_cognitive_detail(graph: &DiGraph<String, u32>) -> CognitiveDebtDetail {
let n = graph.node_count();
let e = graph.edge_count();
if n < 3 {
return CognitiveDebtDetail {
score: 0.0,
edge_excess_ratio: 0.0,
degree_excess: 0.0,
avg_degree: 0.0,
expected_avg_degree: 0.0,
baseline_edges: 0,
actual_edges: e,
scale_factor: 0.0,
};
}
let baseline_edges = 2 * n;
let edge_excess = if baseline_edges > 0 {
((e as f64 / baseline_edges as f64) - 1.0).max(0.0)
} else {
0.0
};
let excess_ratio = (edge_excess / 2.0).min(1.0);
let avg_degree = 2.0 * e as f64 / n as f64;
let expected_avg = (2.0 * (n as f64).ln()).max(3.0);
let degree_excess = ((avg_degree / expected_avg) - 1.0).clamp(0.0, 1.0);
let scale = 1.0 - (-(n as f64) / 20.0).exp();
let raw = (0.50 * excess_ratio + 0.50 * degree_excess) * scale;
let score = 100.0 * (1.0 - (-3.0 * raw).exp());
CognitiveDebtDetail {
score,
edge_excess_ratio: (excess_ratio * 100.0).round() / 100.0,
degree_excess: (degree_excess * 100.0).round() / 100.0,
avg_degree: (avg_degree * 100.0).round() / 100.0,
expected_avg_degree: (expected_avg * 100.0).round() / 100.0,
baseline_edges,
actual_edges: e,
scale_factor: (scale * 100.0).round() / 100.0,
}
}
#[derive(Debug, Clone)]
pub struct GodModuleInfo {
pub name: String,
pub fan_in: usize,
pub fan_out: usize,
pub hub_ratio: f64,
pub excess_ratio: f64,
}
pub fn extract_god_modules(
graph: &DiGraph<String, u32>,
thresholds: &Thresholds,
exemptions: &Exemptions,
) -> Vec<GodModuleInfo> {
let n = graph.node_count();
if n < 6 {
return Vec::new();
}
let threshold = (2.0 * (n as f64).sqrt()).max(8.0);
let mut gods = Vec::new();
for node_idx in graph.node_indices() {
let module_name = &graph[node_idx];
if exemptions
.hub_exempt
.iter()
.any(|e| module_name.contains(e.as_str()))
{
continue;
}
if is_entry_point_stem(module_name, &exemptions.entry_point_stems) {
continue;
}
let fan_in = graph
.neighbors_directed(node_idx, petgraph::Direction::Incoming)
.count();
let fan_out = graph
.neighbors_directed(node_idx, petgraph::Direction::Outgoing)
.count();
let total_degree = fan_in + fan_out;
if (total_degree as f64) < threshold {
continue;
}
let hub_ratio = fan_out as f64 / (fan_in as f64 + 1.0);
if hub_ratio < thresholds.hub_exemption_ratio {
continue;
}
if fan_in <= thresholds.entry_point_max_fan_in {
continue;
}
let excess = (total_degree as f64 - threshold) / threshold;
gods.push(GodModuleInfo {
name: module_name.clone(),
fan_in,
fan_out,
hub_ratio: (hub_ratio * 100.0).round() / 100.0,
excess_ratio: (excess * 100.0).round() / 100.0,
});
}
gods.sort_by(|a, b| (b.fan_in + b.fan_out).cmp(&(a.fan_in + a.fan_out)));
gods.truncate(10);
gods
}
#[derive(Debug, Clone)]
pub struct BoundaryViolationDetail {
pub from_module: String,
pub to_module: String,
pub rule_from_pattern: String,
pub rule_deny_patterns: Vec<String>,
}
pub fn extract_boundary_violations(
graph: &DiGraph<String, u32>,
config: &ScoringConfig,
) -> Vec<BoundaryViolationDetail> {
if config.boundaries.is_empty() {
return Vec::new();
}
let mut source_rule_cache: HashMap<NodeIndex, Vec<usize>> = HashMap::new();
for node_idx in graph.node_indices() {
let from = &graph[node_idx];
let matching_rules: Vec<usize> = config
.boundaries
.iter()
.enumerate()
.filter_map(|(rule_idx, rule)| rule.matches_from(from).then_some(rule_idx))
.collect();
if !matching_rules.is_empty() {
source_rule_cache.insert(node_idx, matching_rules);
}
}
let mut target_rule_cache: HashMap<(usize, NodeIndex), bool> = HashMap::new();
let mut violations = Vec::new();
for edge_idx in graph.edge_indices() {
let Some((src, tgt)) = graph.edge_endpoints(edge_idx) else {
continue;
};
let Some(rule_indices) = source_rule_cache.get(&src) else {
continue;
};
let to = &graph[tgt];
for &rule_idx in rule_indices {
let is_match = *target_rule_cache
.entry((rule_idx, tgt))
.or_insert_with(|| config.boundaries[rule_idx].matches_to(to));
if is_match {
violations.push(BoundaryViolationDetail {
from_module: graph[src].clone(),
to_module: graph[tgt].clone(),
rule_from_pattern: config.boundaries[rule_idx].from.clone(),
rule_deny_patterns: config.boundaries[rule_idx].deny.clone(),
});
break; }
}
}
violations.truncate(15);
violations
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ProjectConfig, ScoringConfig};
fn default_config() -> ScoringConfig {
ScoringConfig::default()
}
fn make_tree_graph(n: usize) -> DiGraph<String, u32> {
let mut g = DiGraph::new();
let nodes: Vec<_> = (0..n).map(|i| g.add_node(format!("node_{i}"))).collect();
for i in 1..n {
g.add_edge(nodes[i / 2], nodes[i], 1);
}
g
}
fn make_layered_graph(layers: &[usize]) -> DiGraph<String, u32> {
let mut g = DiGraph::new();
let mut layer_nodes: Vec<Vec<NodeIndex>> = Vec::new();
for (l, &count) in layers.iter().enumerate() {
let mut nodes = Vec::new();
for i in 0..count {
nodes.push(g.add_node(format!("L{l}_{i}")));
}
layer_nodes.push(nodes);
}
for l in 0..layer_nodes.len() - 1 {
for &src in &layer_nodes[l] {
for &tgt in &layer_nodes[l + 1] {
g.add_edge(src, tgt, 1);
}
}
}
g
}
fn make_simple_graph() -> DiGraph<String, u32> {
let mut g = DiGraph::new();
let a = g.add_node("A".to_string());
let b = g.add_node("B".to_string());
let c = g.add_node("C".to_string());
g.add_edge(a, b, 1);
g.add_edge(a, c, 1);
g.add_edge(b, c, 1);
g
}
fn make_cyclic_graph() -> DiGraph<String, u32> {
let mut g = DiGraph::new();
let a = g.add_node("A".to_string());
let b = g.add_node("B".to_string());
let c = g.add_node("C".to_string());
g.add_edge(a, b, 1);
g.add_edge(b, c, 1);
g.add_edge(c, a, 1);
g
}
#[test]
fn test_empty_graph_zero_debt() {
let g: DiGraph<String, u32> = DiGraph::new();
let score = calculate_drift(&g, None, 0, &default_config());
assert_eq!(score.total, 0, "Empty graph should have 0 debt");
assert_eq!(score.cycle_debt, 0.0);
assert_eq!(score.layering_debt, 0.0);
}
#[test]
fn test_perfect_tree_low_debt() {
let g = make_tree_graph(31); let score = calculate_drift(&g, None, 0, &default_config());
assert!(
score.total < 10,
"Perfect tree should have <10 debt, got: {}",
score.total
);
assert_eq!(score.new_cycles, 0, "Tree should have no cycles");
}
#[test]
fn test_clean_layered_graph() {
let g = make_layered_graph(&[3, 5, 4]); let score = calculate_drift(&g, None, 0, &default_config());
assert!(
score.total <= 20,
"Clean layered graph should have ≤20 debt, got: {}",
score.total
);
assert_eq!(score.new_cycles, 0, "No cycles in clean layers");
assert_eq!(
score.boundary_violations, 0,
"No back-edges in clean layers"
);
}
#[test]
fn test_single_cycle() {
let g = make_cyclic_graph();
let score = calculate_drift(&g, None, 0, &default_config());
assert!(
score.total >= 10 && score.total <= 40,
"Single cycle should produce 10-40 debt, got: {}",
score.total
);
assert_eq!(score.new_cycles, 1, "Should detect 1 SCC");
assert!(score.cycle_debt > 0.0, "Cycle debt sub-score should be > 0");
}
#[test]
fn test_large_scc_high_cycle_debt() {
let mut g = DiGraph::new();
let nodes: Vec<_> = (0..10).map(|i| g.add_node(format!("cyc_{i}"))).collect();
for i in 0..10 {
g.add_edge(nodes[i], nodes[(i + 1) % 10], 1);
}
for i in 10..20 {
let n = g.add_node(format!("leaf_{i}"));
g.add_edge(nodes[0], n, 1);
}
let score = calculate_drift(&g, None, 0, &default_config());
assert!(
score.cycle_debt > 30.0,
"Large SCC should produce high cycle debt, got: {}",
score.cycle_debt
);
}
#[test]
fn test_legitimate_hub_zero_penalty() {
let mut g = DiGraph::new();
let core = g.add_node("shared_core".to_string());
for i in 0..30 {
let n = g.add_node(format!("consumer_{i}"));
g.add_edge(n, core, 1);
}
let score = calculate_drift(&g, None, 0, &default_config());
assert_eq!(
score.hub_debt, 0.0,
"Legitimate hub (high in, zero out) should have 0 hub debt"
);
}
#[test]
fn test_god_module_high_hub_debt() {
let mut g = DiGraph::new();
let god = g.add_node("god_module".to_string());
for i in 0..15 {
let n = g.add_node(format!("dep_{i}"));
g.add_edge(n, god, 1);
}
for i in 0..10 {
let n = g.add_node(format!("target_{i}"));
g.add_edge(god, n, 1);
}
let score = calculate_drift(&g, None, 0, &default_config());
assert!(
score.hub_debt > 0.0,
"God module (high in AND out) should have positive hub debt, got: {}",
score.hub_debt
);
}
#[test]
fn test_heavy_weights_coupling_debt() {
let mut g_light = DiGraph::new();
let mut g_heavy = DiGraph::new();
let nodes_l: Vec<_> = (0..10)
.map(|i| g_light.add_node(format!("n_{i}")))
.collect();
let nodes_h: Vec<_> = (0..10)
.map(|i| g_heavy.add_node(format!("n_{i}")))
.collect();
for i in 0..9 {
g_light.add_edge(nodes_l[i], nodes_l[i + 1], 1);
g_heavy.add_edge(nodes_h[i], nodes_h[i + 1], 20); }
let score_light = calculate_drift(&g_light, None, 0, &default_config());
let score_heavy = calculate_drift(&g_heavy, None, 0, &default_config());
assert!(
score_heavy.coupling_debt >= score_light.coupling_debt,
"Heavy weights should produce ≥ coupling debt: light={}, heavy={}",
score_light.coupling_debt,
score_heavy.coupling_debt
);
}
#[test]
fn test_back_edges_layering_debt() {
let mut g = make_layered_graph(&[3, 4, 3]);
let l2_nodes: Vec<_> = g
.node_indices()
.filter(|&n| g[n].starts_with("L2"))
.collect();
let l0_nodes: Vec<_> = g
.node_indices()
.filter(|&n| g[n].starts_with("L0"))
.collect();
for &src in &l2_nodes {
for &tgt in &l0_nodes {
g.add_edge(src, tgt, 1);
}
}
let score = calculate_drift(&g, None, 0, &default_config());
assert!(
score.layering_debt > 0.0,
"Back-edges should produce layering debt, got: {}",
score.layering_debt
);
assert!(
score.layering_violations > 0,
"Back-edges should be counted as layering violations"
);
}
#[test]
fn test_boundary_rule_violations_raise_layering_debt() {
let mut g = DiGraph::new();
let packages_core = g.add_node("packages/core".to_string());
let apps_web = g.add_node("apps/web".to_string());
g.add_edge(packages_core, apps_web, 1);
let config: ProjectConfig = toml::from_str(
r#"
[[scoring.boundaries]]
from = "packages/**"
deny = ["apps/**"]
"#,
)
.unwrap();
let score = calculate_drift(&g, None, 0, &config.scoring);
assert_eq!(score.layering_violations, 0);
assert_eq!(score.boundary_violations, 1);
assert!(score.layering_debt > 0.0);
}
#[test]
fn test_leaf_packages_zero_instability() {
let mut g = DiGraph::new();
let core = g.add_node("core".to_string());
for i in 0..5 {
let leaf = g.add_node(format!("leaf_{i}"));
g.add_edge(leaf, core, 1);
}
let score = calculate_drift(&g, None, 0, &default_config());
assert_eq!(
score.instability_debt, 0.0,
"Leaf packages (ca=0) should not contribute instability debt"
);
}
#[test]
fn test_nonleaf_brittle_instability() {
let mut g = DiGraph::new();
let hub = g.add_node("hub".to_string());
let brittle = g.add_node("brittle".to_string());
let t1 = g.add_node("target1".to_string());
let t2 = g.add_node("target2".to_string());
let t3 = g.add_node("target3".to_string());
let t4 = g.add_node("target4".to_string());
g.add_edge(hub, brittle, 1);
g.add_edge(brittle, t1, 1);
g.add_edge(brittle, t2, 1);
g.add_edge(brittle, t3, 1);
g.add_edge(brittle, t4, 1);
for i in 0..20 {
let n = g.add_node(format!("extra_{i}"));
g.add_edge(hub, n, 1);
}
let score = calculate_drift(&g, None, 0, &default_config());
assert!(
score.instability_debt >= 0.0,
"Instability debt should be non-negative"
);
}
#[test]
fn test_100_node_clean_graph_high_health() {
let g = make_tree_graph(100);
let score = calculate_drift(&g, None, 0, &default_config());
let health = 100 - score.total;
assert!(
health >= 85,
"100-node clean tree should have health ≥ 85%, got: {}%",
health
);
}
#[test]
fn test_100_node_spaghetti_low_health() {
let mut g = DiGraph::new();
let nodes: Vec<_> = (0..100).map(|i| g.add_node(format!("s_{i}"))).collect();
for i in 0..100 {
for j in 0..100 {
if i != j && (i * 7 + j * 13) % 5 == 0 {
g.add_edge(nodes[i], nodes[j], ((i + j) % 10 + 1) as u32);
}
}
}
let score = calculate_drift(&g, None, 0, &default_config());
let health = 100u8.saturating_sub(score.total);
assert!(
health < 40,
"100-node spaghetti should have health < 40%, got: {}%",
health
);
}
#[test]
fn test_drift_score_backward_compat() {
let old_json = r#"{
"total": 42,
"fan_in_delta": 2,
"fan_out_delta": -1,
"new_cycles": 1,
"boundary_violations": 0,
"cognitive_complexity": 3.5,
"timestamp": 1234567890
}"#;
let score: DriftScore = serde_json::from_str(old_json).unwrap();
assert_eq!(score.total, 42);
assert_eq!(score.cycle_debt, 0.0, "Default for missing sub-scores");
assert_eq!(score.layering_debt, 0.0);
assert_eq!(score.hub_debt, 0.0);
assert_eq!(score.coupling_debt, 0.0);
assert_eq!(score.cognitive_debt, 0.0);
assert_eq!(score.instability_debt, 0.0);
}
#[test]
fn test_median_fan_delta() {
let mut prev = DiGraph::new();
let pa = prev.add_node("A".to_string());
let pb = prev.add_node("B".to_string());
let pc = prev.add_node("C".to_string());
prev.add_edge(pa, pb, 1);
prev.add_edge(pa, pc, 1);
let mut curr = DiGraph::new();
let ca = curr.add_node("A".to_string());
let cb = curr.add_node("B".to_string());
let cc = curr.add_node("C".to_string());
let cd = curr.add_node("D".to_string());
curr.add_edge(ca, cb, 1);
curr.add_edge(ca, cc, 1);
curr.add_edge(ca, cd, 1); curr.add_edge(cb, cc, 1);
let score = calculate_drift(&curr, Some(&prev), 0, &default_config());
assert!(
score.fan_in_delta.abs() <= 5,
"Fan-in delta should be small: {}",
score.fan_in_delta
);
assert!(
score.fan_out_delta.abs() <= 5,
"Fan-out delta should be small: {}",
score.fan_out_delta
);
}
#[test]
fn test_calculate_health_clean_small() {
let graph = make_simple_graph();
let score = calculate_drift(&graph, None, 0, &default_config());
assert!(
score.total <= 10,
"Score should be very low for a clean tiny graph: {}",
score.total
);
}
#[test]
fn test_calculate_health_with_cycle() {
let graph = make_cyclic_graph();
let score = calculate_drift(&graph, None, 0, &default_config());
assert!(
score.total >= 10 && score.total <= 50,
"Cycle should add significant debt: {}",
score.total
);
}
#[test]
fn test_entry_point_exemption() {
let mut g = DiGraph::new();
let entry = g.add_node("src/main.rs".to_string());
let mut targets = Vec::new();
for i in 0..15 {
let n = g.add_node(format!("module_{i}"));
targets.push(n);
g.add_edge(entry, n, 1);
}
let child = g.add_node("child".to_string());
g.add_edge(targets[0], child, 1);
g.add_edge(child, targets[0], 1);
let _score = calculate_drift(&g, None, 0, &default_config());
let mut g_regular = DiGraph::new();
let reg = g_regular.add_node("src/utils.rs".to_string());
for i in 0..15 {
let n = g_regular.add_node(format!("module_{i}"));
g_regular.add_edge(reg, n, 1);
g_regular.add_edge(n, reg, 1); }
let _score_reg = calculate_drift(&g_regular, None, 0, &default_config());
let mock_score = DriftScore {
total: 50,
fan_in_delta: 0,
fan_out_delta: 0,
new_cycles: 0,
boundary_violations: 0,
layering_violations: 0,
cognitive_complexity: 0.0,
timestamp: 0,
cognitive_debt: 0.0,
cycle_debt: 0.0,
layering_debt: 0.0,
coupling_debt: 0.0,
hub_debt: 20.0, instability_debt: 20.0, };
let diagnostics = generate_diagnostics(&g, &mock_score, &default_config());
let joined_diagnostics = diagnostics.join(" ");
assert!(
!joined_diagnostics.contains("main.rs fragile"),
"Entry points should not be flagged as fragile"
);
}
#[test]
fn test_is_entry_point_stem_matching() {
let stems: Vec<String> = ["main", "index", "app"]
.iter()
.map(|s| s.to_string())
.collect();
assert!(is_entry_point_stem("src/main.rs", &stems));
assert!(is_entry_point_stem("cli/index", &stems));
assert!(is_entry_point_stem("app", &stems));
assert!(is_entry_point_stem("packages/web/app.ts", &stems));
assert!(!is_entry_point_stem("src/utils.rs", &stems));
assert!(!is_entry_point_stem("core/server", &stems));
assert!(!is_entry_point_stem("main_helper", &stems));
}
#[test]
fn test_entry_point_stems_exempt_from_hub_and_instability_scoring() {
let mut g = DiGraph::new();
let app = g.add_node("cli/app".to_string());
for i in 0..12 {
let n = g.add_node(format!("module_{i}"));
g.add_edge(app, n, 1); g.add_edge(n, app, 1); }
let cfg = default_config();
let hub = compute_hub_debt(&g, &cfg.thresholds, &cfg.exemptions);
let instability = compute_instability_debt(&g, &cfg.thresholds, &cfg.exemptions);
assert_eq!(hub, 0.0, "Entry point 'app' should be exempt from hub debt");
assert_eq!(
instability, 0.0,
"Entry point 'app' should be exempt from instability debt"
);
let mut cfg_no_app = default_config();
cfg_no_app.exemptions.entry_point_stems =
["main", "index"].iter().map(|s| s.to_string()).collect();
let hub_no_exempt = compute_hub_debt(&g, &cfg_no_app.thresholds, &cfg_no_app.exemptions);
assert!(
hub_no_exempt > 0.0,
"Without 'app' in stems, hub debt should be non-zero"
);
}
}