use crate::delta::Delta;
use crate::risk::RiskBand;
use crate::snapshot::{FunctionSnapshot, Snapshot};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct FileAggregates {
pub file: String,
pub sum_lrs: f64,
pub max_lrs: f64,
pub high_plus_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct DirectoryAggregates {
pub directory: String,
pub sum_lrs: f64,
pub max_lrs: f64,
pub high_plus_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct FileRiskView {
pub file: String,
pub function_count: usize,
pub loc: usize,
pub max_cc: usize,
pub avg_cc: f64,
pub critical_count: usize,
pub file_churn: u64,
pub file_risk_score: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct ModuleInstability {
pub module: String,
pub file_count: usize,
pub function_count: usize,
pub avg_complexity: f64,
pub afferent: usize,
pub efferent: usize,
pub instability: f64,
pub module_risk: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct SnapshotAggregates {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub files: Vec<FileAggregates>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub directories: Vec<DirectoryAggregates>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub file_risk: Vec<FileRiskView>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub co_change: Vec<crate::git::CoChangePair>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub modules: Vec<ModuleInstability>,
#[serde(skip_serializing_if = "Option::is_none")]
pub models: Option<crate::models::ModelRiskMap>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct FileDeltaAggregates {
pub file: String,
pub net_lrs_delta: f64,
pub regression_count: usize,
pub improvement_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct CoChangeDeltaEntry {
pub file_a: String,
pub file_b: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub prev_risk: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub curr_risk: Option<String>,
pub co_change_count: usize,
pub coupling_ratio: f64,
pub has_static_dep: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct DeltaAggregates {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub files: Vec<FileDeltaAggregates>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub co_change_delta: Vec<CoChangeDeltaEntry>,
}
const TRIAGE_TOP_N: usize = 5;
const HIDDEN_COUPLING_TOP_N: usize = 20;
const FILE_RISK_TOP_N: usize = 10;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct AgentMetrics {
pub cc: usize,
pub nd: usize,
pub fo: usize,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct AgentFunctionView {
pub function: String,
pub file: String,
pub line: u32,
pub band: String,
pub quadrant: String,
pub driver: String,
pub action: &'static str,
pub lrs: f64,
pub activity_risk: f64,
pub metrics: AgentMetrics,
#[serde(skip_serializing_if = "Option::is_none")]
pub touches_30d: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub days_since_changed: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fan_in: Option<usize>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub patterns: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct TriageQuadrant {
pub count: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub top: Vec<AgentFunctionView>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct TriageView {
pub fire: TriageQuadrant,
pub debt: TriageQuadrant,
pub watch: TriageQuadrant,
pub ok: TriageQuadrant,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct AgentCoChangeView {
pub hidden_coupling: Vec<crate::git::CoChangePair>,
pub hidden_count: usize,
pub total_pairs: usize,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct AgentArchitectureView {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub file_risk: Vec<FileRiskView>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub modules: Vec<ModuleInstability>,
#[serde(skip_serializing_if = "Option::is_none")]
pub models: Option<crate::models::ModelRiskMap>,
}
pub const AGENT_SNAPSHOT_SCHEMA_VERSION: u32 = 4;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct AgentSnapshotOutput {
pub schema_version: u32,
pub commit: crate::snapshot::CommitInfo,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<crate::snapshot::SnapshotSummary>,
pub triage: TriageView,
#[serde(skip_serializing_if = "Option::is_none")]
pub architecture: Option<AgentArchitectureView>,
pub co_change: AgentCoChangeView,
}
impl AgentSnapshotOutput {
pub fn to_json(&self) -> anyhow::Result<String> {
serde_json::to_string_pretty(self).map_err(|e| anyhow::anyhow!("{}", e))
}
pub fn write_json_to<W: std::io::Write>(&self, writer: &mut W) -> anyhow::Result<()> {
serde_json::to_writer_pretty(writer as &mut dyn std::io::Write, self)
.map_err(|e| anyhow::anyhow!("{}", e))?;
writeln!(writer).map_err(|e| anyhow::anyhow!("{}", e))
}
}
fn to_agent_view(
fns: &[&FunctionSnapshot],
repo_root: &std::path::Path,
top_n: usize,
) -> Vec<AgentFunctionView> {
fns.iter()
.take(top_n)
.map(|func| {
let function_name = func
.function_id
.split("::")
.last()
.unwrap_or(&func.function_id)
.to_string();
let file = normalize_path_relative_to_repo(&func.file, repo_root)
.unwrap_or_else(|| func.file.clone());
let driver = func.driver.as_deref().unwrap_or("composite");
let action = crate::snapshot::driver_action_for_quadrant(
driver,
func.quadrant.as_deref().unwrap_or(""),
);
AgentFunctionView {
function: function_name,
file,
line: func.line,
band: func.band.as_str().to_string(),
quadrant: func.quadrant.clone().unwrap_or_else(|| "ok".to_string()),
driver: driver.to_string(),
action,
lrs: func.lrs,
activity_risk: func.activity_risk.unwrap_or(func.lrs),
metrics: AgentMetrics {
cc: func.metrics.cc as usize,
nd: func.metrics.nd as usize,
fo: func.metrics.fo as usize,
},
touches_30d: func.touch_count_30d,
days_since_changed: func.days_since_last_change,
fan_in: func.callgraph.as_ref().map(|cg| cg.fan_in),
patterns: func.patterns.clone(),
}
})
.collect()
}
pub fn compute_agent_snapshot_output(
snapshot: &crate::snapshot::Snapshot,
aggregates: &SnapshotAggregates,
repo_root: &std::path::Path,
) -> AgentSnapshotOutput {
let mut fire_fns: Vec<&FunctionSnapshot> = Vec::new();
let mut debt_fns: Vec<&FunctionSnapshot> = Vec::new();
let mut watch_fns: Vec<&FunctionSnapshot> = Vec::new();
let mut ok_count = 0usize;
for func in &snapshot.functions {
match func.quadrant.as_deref() {
Some("fire") => fire_fns.push(func),
Some("debt") => debt_fns.push(func),
Some("watch") => watch_fns.push(func),
_ => ok_count += 1,
}
}
let sort_by_risk = |fns: &mut Vec<&FunctionSnapshot>| {
fns.sort_by(|a, b| {
let a_score = a.activity_risk.unwrap_or(a.lrs);
let b_score = b.activity_risk.unwrap_or(b.lrs);
b_score
.partial_cmp(&a_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
};
sort_by_risk(&mut fire_fns);
sort_by_risk(&mut debt_fns);
sort_by_risk(&mut watch_fns);
let triage = TriageView {
fire: TriageQuadrant {
count: fire_fns.len(),
top: to_agent_view(&fire_fns, repo_root, TRIAGE_TOP_N),
},
debt: TriageQuadrant {
count: debt_fns.len(),
top: to_agent_view(&debt_fns, repo_root, TRIAGE_TOP_N),
},
watch: TriageQuadrant {
count: watch_fns.len(),
top: to_agent_view(&watch_fns, repo_root, TRIAGE_TOP_N),
},
ok: TriageQuadrant {
count: ok_count,
top: Vec::new(),
},
};
const SRC_EXTS: &[&str] = &[
".rs", ".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".java", ".c", ".cpp", ".h",
];
let is_src = |f: &str| SRC_EXTS.iter().any(|ext| f.ends_with(ext));
let total_pairs = aggregates.co_change.len();
let mut hidden_pairs: Vec<&crate::git::CoChangePair> = aggregates
.co_change
.iter()
.filter(|p| {
!p.has_static_dep
&& is_src(&p.file_a)
&& is_src(&p.file_b)
&& (p.risk == "high" || p.risk == "moderate")
})
.collect();
let hidden_count = hidden_pairs.len();
hidden_pairs.sort_by(|a, b| {
b.coupling_ratio
.partial_cmp(&a.coupling_ratio)
.unwrap_or(std::cmp::Ordering::Equal)
});
let hidden_coupling: Vec<crate::git::CoChangePair> = hidden_pairs
.into_iter()
.take(HIDDEN_COUPLING_TOP_N)
.cloned()
.collect();
let file_risk: Vec<FileRiskView> = aggregates
.file_risk
.iter()
.take(FILE_RISK_TOP_N)
.map(|fr| {
let rel_file = normalize_path_relative_to_repo(&fr.file, repo_root)
.unwrap_or_else(|| fr.file.clone());
FileRiskView {
file: rel_file,
..fr.clone()
}
})
.collect();
let models = aggregates
.models
.as_ref()
.map(|model_map| normalize_model_risk_map(model_map, repo_root));
let architecture = if file_risk.is_empty() && aggregates.modules.is_empty() && models.is_none()
{
None
} else {
Some(AgentArchitectureView {
file_risk,
modules: aggregates.modules.clone(),
models,
})
};
AgentSnapshotOutput {
schema_version: AGENT_SNAPSHOT_SCHEMA_VERSION,
commit: snapshot.commit.clone(),
summary: snapshot.summary.clone(),
triage,
architecture,
co_change: AgentCoChangeView {
hidden_coupling,
hidden_count,
total_pairs,
},
}
}
fn normalize_model_risk_map(
map: &crate::models::ModelRiskMap,
repo_root: &std::path::Path,
) -> crate::models::ModelRiskMap {
crate::models::ModelRiskMap {
models: map
.models
.iter()
.map(|model| {
let functions = model
.functions
.iter()
.map(|function| crate::models::ModelFunction {
file: normalize_path_relative_to_repo(&function.file, repo_root)
.unwrap_or_else(|| function.file.clone()),
function_id: relativize_function_id(&function.function_id, repo_root),
..function.clone()
})
.collect();
crate::models::ModelRiskEntry {
file: normalize_path_relative_to_repo(&model.file, repo_root)
.unwrap_or_else(|| model.file.clone()),
functions,
..model.clone()
}
})
.collect(),
links: map.links.clone(),
}
}
fn relativize_function_id(function_id: &str, repo_root: &std::path::Path) -> String {
let Some((file, function)) = function_id.split_once("::") else {
return function_id.to_string();
};
normalize_path_relative_to_repo(file, repo_root)
.map(|rel| format!("{rel}::{function}"))
.unwrap_or_else(|| function_id.to_string())
}
fn is_high_plus(band: RiskBand) -> bool {
matches!(band, RiskBand::High | RiskBand::Critical)
}
fn extract_directory(file_path: &str) -> String {
if let Some(last_slash) = file_path.rfind('/') {
file_path[..last_slash].to_string()
} else {
".".to_string()
}
}
fn normalize_path_relative_to_repo(file_path: &str, repo_root: &std::path::Path) -> Option<String> {
let file_path_buf = std::path::PathBuf::from(file_path);
if let Ok(relative) = file_path_buf.strip_prefix(repo_root) {
Some(relative.to_string_lossy().replace('\\', "/"))
} else {
if !file_path_buf.is_absolute() {
Some(file_path.to_string())
} else {
None
}
}
}
pub fn compute_file_aggregates(functions: &[FunctionSnapshot]) -> Vec<FileAggregates> {
let mut file_data: HashMap<String, (f64, f64, usize)> = HashMap::new();
for func in functions {
let entry = file_data.entry(func.file.clone()).or_insert((0.0, 0.0, 0));
entry.0 += func.lrs;
if func.lrs > entry.1 {
entry.1 = func.lrs;
}
if is_high_plus(func.band) {
entry.2 += 1;
}
}
let mut aggregates: Vec<FileAggregates> = file_data
.into_iter()
.map(
|(file, (sum_lrs, max_lrs, high_plus_count))| FileAggregates {
file,
sum_lrs,
max_lrs,
high_plus_count,
},
)
.collect();
aggregates.sort_by(|a, b| a.file.cmp(&b.file));
aggregates
}
pub fn compute_directory_aggregates(
file_aggregates: &[FileAggregates],
repo_root: &std::path::Path,
) -> Vec<DirectoryAggregates> {
let mut dir_data: HashMap<String, (f64, f64, usize)> = HashMap::new();
for file_agg in file_aggregates {
let normalized_file = match normalize_path_relative_to_repo(&file_agg.file, repo_root) {
Some(path) => path,
None => continue, };
let mut current_path = normalized_file.clone();
loop {
let dir = extract_directory(¤t_path);
if dir == current_path || dir.is_empty() {
break;
}
if dir.starts_with('/') && !dir.starts_with("./") {
break;
}
let entry = dir_data.entry(dir.clone()).or_insert((0.0, 0.0, 0));
entry.0 += file_agg.sum_lrs;
if file_agg.max_lrs > entry.1 {
entry.1 = file_agg.max_lrs;
}
entry.2 += file_agg.high_plus_count;
current_path = dir;
}
}
let mut aggregates: Vec<DirectoryAggregates> = dir_data
.into_iter()
.map(
|(directory, (sum_lrs, max_lrs, high_plus_count))| DirectoryAggregates {
directory,
sum_lrs,
max_lrs,
high_plus_count,
},
)
.collect();
aggregates.sort_by(|a, b| a.directory.cmp(&b.directory));
aggregates
}
pub fn compute_file_risk_views(functions: &[FunctionSnapshot]) -> Vec<FileRiskView> {
let mut file_data: HashMap<String, (usize, usize, usize, usize, usize, u64)> = HashMap::new();
for func in functions {
let e = file_data
.entry(func.file.clone())
.or_insert((0, 0, 0, 0, 0, 0));
e.0 += func.metrics.cc as usize;
e.1 = e.1.max(func.metrics.cc as usize);
e.2 += 1;
if func.band == RiskBand::Critical {
e.3 += 1;
}
e.4 += func.metrics.loc as usize;
if let Some(churn) = &func.churn {
let lines = (churn.lines_added + churn.lines_deleted) as u64;
e.5 = e.5.max(lines);
}
}
let mut views: Vec<FileRiskView> = file_data
.into_iter()
.map(
|(file, (sum_cc, max_cc, function_count, critical_count, loc, file_churn))| {
let avg_cc = if function_count > 0 {
sum_cc as f64 / function_count as f64
} else {
0.0
};
let churn_factor = (file_churn as f64 / 100.0).min(10.0);
let score = max_cc as f64 * 0.4
+ avg_cc * 0.3
+ (function_count as f64 + 1.0).log2() * 0.2
+ churn_factor * 0.1;
FileRiskView {
file,
function_count,
loc,
max_cc,
avg_cc: (avg_cc * 100.0).round() / 100.0,
critical_count,
file_churn,
file_risk_score: (score * 100.0).round() / 100.0,
}
},
)
.collect();
views.sort_by(|a, b| {
b.file_risk_score
.partial_cmp(&a.file_risk_score)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.file.cmp(&b.file))
});
views
}
pub fn annotate_static_deps(
pairs: &mut [crate::git::CoChangePair],
edges: &[(String, String)],
repo_root: &std::path::Path,
) {
let mut edge_set: std::collections::HashSet<(String, String)> =
std::collections::HashSet::new();
for (a, b) in edges {
let a_rel = normalize_path_relative_to_repo(a, repo_root).unwrap_or_else(|| a.clone());
let b_rel = normalize_path_relative_to_repo(b, repo_root).unwrap_or_else(|| b.clone());
edge_set.insert((a_rel.clone(), b_rel.clone()));
edge_set.insert((b_rel, a_rel));
}
for pair in pairs.iter_mut() {
let has_dep = edge_set.contains(&(pair.file_a.clone(), pair.file_b.clone()));
pair.has_static_dep = has_dep;
if has_dep {
pair.risk = "expected".to_string();
}
}
}
fn compute_module_instability_from_edges(
functions: &[FunctionSnapshot],
edges: &[(String, String)],
repo_root: &std::path::Path,
) -> Vec<ModuleInstability> {
let file_dir = |file: &str| -> Option<String> {
let normalized = normalize_path_relative_to_repo(file, repo_root)?;
Some(extract_directory(&normalized))
};
let mut efferent: HashMap<String, usize> = HashMap::new();
let mut afferent: HashMap<String, usize> = HashMap::new();
for (from_file, to_file) in edges {
let from_dir = match file_dir(from_file) {
Some(d) => d,
None => continue,
};
let to_dir = match file_dir(to_file) {
Some(d) => d,
None => continue,
};
if from_dir != to_dir {
*efferent.entry(from_dir.clone()).or_insert(0) += 1;
*afferent.entry(to_dir.clone()).or_insert(0) += 1;
}
}
struct DirStats {
files: std::collections::HashSet<String>,
function_count: usize,
sum_cc: usize,
}
let mut dir_stats: HashMap<String, DirStats> = HashMap::new();
for func in functions {
let dir = match file_dir(&func.file) {
Some(d) => d,
None => continue,
};
let stats = dir_stats.entry(dir).or_insert_with(|| DirStats {
files: std::collections::HashSet::new(),
function_count: 0,
sum_cc: 0,
});
stats.files.insert(func.file.clone());
stats.function_count += 1;
stats.sum_cc += func.metrics.cc as usize;
}
let all_dirs: std::collections::HashSet<String> = dir_stats
.keys()
.chain(efferent.keys())
.chain(afferent.keys())
.cloned()
.collect();
let mut modules: Vec<ModuleInstability> = all_dirs
.into_iter()
.filter_map(|dir| {
let stats = dir_stats.get(&dir)?;
let eff = *efferent.get(&dir).unwrap_or(&0);
let aff = *afferent.get(&dir).unwrap_or(&0);
let instability = if eff + aff == 0 {
0.5 } else {
eff as f64 / (eff + aff) as f64
};
let avg_complexity = if stats.function_count > 0 {
stats.sum_cc as f64 / stats.function_count as f64
} else {
0.0
};
let module_risk = if instability < 0.3 && avg_complexity > 10.0 {
"high".to_string()
} else {
"low".to_string()
};
Some(ModuleInstability {
module: dir,
file_count: stats.files.len(),
function_count: stats.function_count,
avg_complexity: (avg_complexity * 100.0).round() / 100.0,
afferent: aff,
efferent: eff,
instability: (instability * 1000.0).round() / 1000.0,
module_risk,
})
})
.collect();
modules.sort_by(|a, b| {
b.module_risk
.cmp(&a.module_risk) .then(
a.instability
.partial_cmp(&b.instability)
.unwrap_or(std::cmp::Ordering::Equal),
)
.then(a.module.cmp(&b.module))
});
modules
}
pub fn compute_module_instability(
functions: &[FunctionSnapshot],
repo_root: &std::path::Path,
) -> Vec<ModuleInstability> {
let mut unique_files: Vec<String> = functions
.iter()
.map(|f| f.file.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
unique_files.sort();
let files_as_str: Vec<&str> = unique_files.iter().map(|s| s.as_str()).collect();
let edges = crate::imports::resolve_file_deps(&files_as_str, repo_root);
compute_module_instability_from_edges(functions, &edges, repo_root)
}
pub fn compute_snapshot_aggregates(
snapshot: &Snapshot,
repo_root: &std::path::Path,
co_change_window_days: u64,
co_change_min_count: usize,
) -> SnapshotAggregates {
compute_snapshot_aggregates_with_models(
snapshot,
repo_root,
co_change_window_days,
co_change_min_count,
None,
)
}
pub fn compute_snapshot_aggregates_with_models(
snapshot: &Snapshot,
repo_root: &std::path::Path,
co_change_window_days: u64,
co_change_min_count: usize,
model_source_root: Option<&std::path::Path>,
) -> SnapshotAggregates {
let files = compute_file_aggregates(&snapshot.functions);
let directories = compute_directory_aggregates(&files, repo_root);
let file_risk = compute_file_risk_views(&snapshot.functions);
let mut unique_files: Vec<String> = snapshot
.functions
.iter()
.map(|f| f.file.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
unique_files.sort();
let files_as_str: Vec<&str> = unique_files.iter().map(|s| s.as_str()).collect();
let mut all_edges = crate::imports::resolve_file_deps(&files_as_str, repo_root);
all_edges.extend(crate::imports::resolve_cargo_workspace_edges(
repo_root,
&files_as_str,
));
let mut co_change =
crate::git::extract_co_change_pairs(repo_root, co_change_window_days, co_change_min_count)
.unwrap_or_default();
annotate_static_deps(&mut co_change, &all_edges, repo_root);
let modules = compute_module_instability_from_edges(&snapshot.functions, &all_edges, repo_root);
let models = model_source_root.and_then(|source_root| {
crate::models::compute_model_risk_map(source_root, repo_root, snapshot, Some(10)).ok()
});
SnapshotAggregates {
files,
directories,
file_risk,
co_change,
modules,
models,
}
}
fn risk_rank(risk: &str) -> u8 {
match risk {
"critical" => 4,
"high" => 3,
"moderate" => 2,
"low" => 1,
_ => 0,
}
}
pub fn diff_co_change_pairs(
prev: &[crate::git::CoChangePair],
curr: &[crate::git::CoChangePair],
) -> Vec<CoChangeDeltaEntry> {
let normalize = |a: &str, b: &str| -> (String, String) {
if a <= b {
(a.to_string(), b.to_string())
} else {
(b.to_string(), a.to_string())
}
};
let prev_map: HashMap<(String, String), &crate::git::CoChangePair> = prev
.iter()
.map(|p| (normalize(&p.file_a, &p.file_b), p))
.collect();
let curr_map: HashMap<(String, String), &crate::git::CoChangePair> = curr
.iter()
.map(|p| (normalize(&p.file_a, &p.file_b), p))
.collect();
let mut result: Vec<CoChangeDeltaEntry> = Vec::new();
for pair in curr {
let key = normalize(&pair.file_a, &pair.file_b);
let status = if let Some(prev_pair) = prev_map.get(&key) {
let pr = risk_rank(&prev_pair.risk);
let cr = risk_rank(&pair.risk);
if cr > pr {
"risk_increased"
} else if cr < pr {
"risk_decreased"
} else {
continue; }
} else {
"new"
};
let prev_risk = prev_map.get(&key).map(|p| p.risk.clone());
result.push(CoChangeDeltaEntry {
file_a: pair.file_a.clone(),
file_b: pair.file_b.clone(),
status: status.to_string(),
prev_risk,
curr_risk: Some(pair.risk.clone()),
co_change_count: pair.co_change_count,
coupling_ratio: pair.coupling_ratio,
has_static_dep: pair.has_static_dep,
});
}
for pair in prev {
let key = normalize(&pair.file_a, &pair.file_b);
if !curr_map.contains_key(&key) {
result.push(CoChangeDeltaEntry {
file_a: pair.file_a.clone(),
file_b: pair.file_b.clone(),
status: "dropped".to_string(),
prev_risk: Some(pair.risk.clone()),
curr_risk: None,
co_change_count: pair.co_change_count,
coupling_ratio: pair.coupling_ratio,
has_static_dep: pair.has_static_dep,
});
}
}
result.sort_by(|a, b| {
let a_dropped = a.status == "dropped";
let b_dropped = b.status == "dropped";
a_dropped
.cmp(&b_dropped)
.then_with(|| {
let ar = a.curr_risk.as_deref().map(risk_rank).unwrap_or(0);
let br = b.curr_risk.as_deref().map(risk_rank).unwrap_or(0);
br.cmp(&ar)
})
.then(a.file_a.cmp(&b.file_a))
.then(a.file_b.cmp(&b.file_b))
});
result
}
pub fn compute_delta_aggregates(
delta: &Delta,
current_co_change: &[crate::git::CoChangePair],
prev_co_change: &[crate::git::CoChangePair],
) -> DeltaAggregates {
let mut file_data: HashMap<String, (f64, usize, usize)> = HashMap::new();
for entry in &delta.deltas {
let file = if let Some(sep_pos) = entry.function_id.rfind("::") {
entry.function_id[..sep_pos].to_string()
} else {
continue; };
let e = file_data.entry(file).or_insert((0.0, 0, 0));
if let Some(delta_val) = &entry.delta {
e.0 += delta_val.lrs;
if delta_val.lrs > 0.0 {
e.1 += 1; } else if delta_val.lrs < 0.0 {
e.2 += 1; }
} else {
match entry.status {
crate::delta::FunctionStatus::New => {
if let Some(after) = &entry.after {
e.0 += after.lrs;
}
}
crate::delta::FunctionStatus::Deleted => {
if let Some(before) = &entry.before {
e.0 -= before.lrs;
e.2 += 1; }
}
_ => {}
}
}
}
let mut aggregates: Vec<FileDeltaAggregates> = file_data
.into_iter()
.map(
|(file, (net_lrs_delta, regression_count, improvement_count))| FileDeltaAggregates {
file,
net_lrs_delta,
regression_count,
improvement_count,
},
)
.collect();
aggregates.sort_by(|a, b| {
b.net_lrs_delta
.partial_cmp(&a.net_lrs_delta)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.file.cmp(&b.file))
});
let co_change_delta = diff_co_change_pairs(prev_co_change, current_co_change);
DeltaAggregates {
files: aggregates,
co_change_delta,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::MetricsReport;
use crate::snapshot::FunctionSnapshot;
fn create_test_function(file: &str, function: &str, lrs: f64, band: &str) -> FunctionSnapshot {
FunctionSnapshot {
function_id: format!("{}::{}", file, function),
file: file.to_string(),
line: 1,
language: crate::language::Language::TypeScript,
metrics: MetricsReport {
cc: 1,
nd: 0,
fo: 0,
ns: 0,
loc: 10,
},
lrs,
band: crate::risk::RiskBand::parse(band).unwrap_or(crate::risk::RiskBand::Low),
suppression_reason: None,
churn: None,
touch_count_30d: None,
days_since_last_change: None,
callgraph: None,
activity_risk: None,
risk_factors: None,
percentile: None,
driver: None,
driver_detail: None,
quadrant: None,
patterns: vec![],
pattern_details: None,
subsystem: None,
authors_90d: None,
}
}
#[test]
fn test_file_aggregates() {
let functions = vec![
create_test_function("src/foo.ts", "func1", 5.0, "moderate"),
create_test_function("src/foo.ts", "func2", 8.0, "high"),
create_test_function("src/bar.ts", "func3", 3.0, "low"),
];
let aggregates = compute_file_aggregates(&functions);
assert_eq!(aggregates.len(), 2);
let foo_agg = aggregates.iter().find(|a| a.file == "src/foo.ts").unwrap();
assert_eq!(foo_agg.sum_lrs, 13.0);
assert_eq!(foo_agg.max_lrs, 8.0);
assert_eq!(foo_agg.high_plus_count, 1);
let bar_agg = aggregates.iter().find(|a| a.file == "src/bar.ts").unwrap();
assert_eq!(bar_agg.sum_lrs, 3.0);
assert_eq!(bar_agg.max_lrs, 3.0);
assert_eq!(bar_agg.high_plus_count, 0);
}
#[test]
fn test_directory_aggregates() {
let file_aggregates = vec![
FileAggregates {
file: "src/api/handler.ts".to_string(),
sum_lrs: 10.0,
max_lrs: 8.0,
high_plus_count: 1,
},
FileAggregates {
file: "src/api/router.ts".to_string(),
sum_lrs: 5.0,
max_lrs: 5.0,
high_plus_count: 0,
},
FileAggregates {
file: "src/utils.ts".to_string(),
sum_lrs: 3.0,
max_lrs: 3.0,
high_plus_count: 0,
},
];
let repo_root = std::path::Path::new("/test/repo");
let dir_aggregates = compute_directory_aggregates(&file_aggregates, repo_root);
let api_dir = dir_aggregates
.iter()
.find(|a| a.directory == "src/api")
.unwrap();
assert_eq!(api_dir.sum_lrs, 15.0);
assert_eq!(api_dir.max_lrs, 8.0);
assert_eq!(api_dir.high_plus_count, 1);
let src_dir = dir_aggregates
.iter()
.find(|a| a.directory == "src")
.unwrap();
assert_eq!(src_dir.sum_lrs, 18.0); assert_eq!(src_dir.max_lrs, 8.0);
assert_eq!(src_dir.high_plus_count, 1);
}
#[test]
fn test_is_high_plus() {
assert!(is_high_plus(crate::risk::RiskBand::High));
assert!(is_high_plus(crate::risk::RiskBand::Critical));
assert!(!is_high_plus(crate::risk::RiskBand::Moderate));
assert!(!is_high_plus(crate::risk::RiskBand::Low));
}
}