use std::collections::{HashMap, HashSet};
use crate::analyses::architecture_trend::{
ArchitectureTrendRow, import_graph_from_live_paths, live_paths_at, sampled_commits,
};
use crate::analyses::code_health::{
CloneSource, CodeHealthRow, HealthScanCtx, run_code_health_scoped,
};
use crate::analyses::import_graph::{GraphMetrics, graph_metrics};
use crate::facts::FactsDb;
use crate::facts::ingest::at_rev::{ingest_complexity_at_rev, materialize_imports_at_rev};
use crate::repo::Repo;
use crate::{CodeLoreError, Options, Result};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct HealthTrendRow {
pub date: String,
pub rev: String,
pub files: u32,
pub arch_health: f64,
pub code_health: f64,
pub combined_health: f64,
pub arch_band: String,
pub code_band: String,
pub combined_band: String,
}
pub use crate::bands::health_band;
#[must_use]
pub fn arch_health(m: &GraphMetrics) -> f64 {
if m.n == 0 {
return 100.0;
}
let n = f64::from(u32::try_from(m.n).unwrap_or(u32::MAX));
let arch_risk = 0.5 * m.propagation_cost
+ 0.3 * (f64::from(m.cyclic_nodes) / n)
+ 0.2 * (f64::from(m.largest_cycle) / n);
100.0 * (1.0 - arch_risk.min(1.0))
}
#[must_use]
pub(crate) fn repo_code_health(rows: &[CodeHealthRow]) -> f64 {
if rows.is_empty() {
return 100.0;
}
let sum: f64 = rows.iter().map(|r| r.score).sum();
let count = f64::from(u32::try_from(rows.len()).unwrap_or(u32::MAX));
sum / count
}
#[must_use]
pub(crate) fn combined_health(arch: f64, code: f64) -> f64 {
0.5 * arch + 0.5 * code
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct FileHealthPoint {
pub path: String,
pub date: String,
pub score: f64,
pub band: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct HealthTransitionRow {
pub path: String,
pub date: String,
pub from_band: String,
pub to_band: String,
pub direction: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct HealthTrendDetail {
pub trend: Vec<HealthTrendRow>,
pub file_series: Vec<FileHealthPoint>,
pub transitions: Vec<HealthTransitionRow>,
}
pub struct SampleTrends {
pub architecture: Vec<ArchitectureTrendRow>,
pub health: HealthTrendDetail,
}
const CM_AT_REV: &str = "cm_at_rev";
const IMPORTS_AT_REV: &str = "imports_at_rev";
fn top_hotspot_paths(db: &FactsDb, opts: &Options, cap: usize) -> Result<HashSet<String>> {
let cap_i64 = i64::try_from(cap).unwrap_or(i64::MAX);
let sql = "
SELECT path
FROM changes
GROUP BY path
HAVING COUNT(rev) >= ?
ORDER BY COUNT(rev) DESC, path ASC
LIMIT ?
";
let mut stmt = db
.conn()
.prepare(sql)
.map_err(|e| CodeLoreError::Analysis(format!("prepare top-hotspot-paths: {e}")))?;
let rows = stmt
.query_map(duckdb::params![opts.min_revs, cap_i64], |r| {
r.get::<_, String>(0)
})
.map_err(|e| CodeLoreError::Analysis(format!("query top-hotspot-paths: {e}")))?;
rows.collect::<std::result::Result<HashSet<_>, _>>()
.map_err(|e| CodeLoreError::Analysis(format!("collect top-hotspot-paths: {e}")))
}
fn detect_transitions(
prev_bands: &HashMap<String, String>,
code_rows: &[CodeHealthRow],
date: &str,
) -> Vec<HealthTransitionRow> {
let mut out = Vec::new();
for row in code_rows {
let Some(prev) = prev_bands.get(&row.path) else {
continue;
};
let curr = &row.band;
if prev == curr {
continue;
}
let direction = if curr == "red" && prev != "red" {
"regressed"
} else if (prev == "red" && curr != "red") || (curr == "green" && prev != "green") {
"improved"
} else {
continue;
};
out.push(HealthTransitionRow {
path: row.path.clone(),
date: date.to_string(),
from_band: prev.clone(),
to_band: curr.clone(),
direction: direction.to_string(),
});
}
out
}
#[tracing::instrument(name = "sample-trends", skip_all)]
pub fn run_sample_trends<R: Repo>(db: &FactsDb, repo: &R, opts: &Options) -> Result<SampleTrends> {
const FILE_SERIES_CAP: usize = 50;
let samples = sampled_commits(db)?;
let scan_opts = opts.with_no_row_limit();
let top_paths = top_hotspot_paths(db, opts, FILE_SERIES_CAP)?;
let mut arch_rows = Vec::with_capacity(samples.len());
let mut trend = Vec::with_capacity(samples.len());
let mut file_series: Vec<FileHealthPoint> = Vec::new();
let mut all_transitions: Vec<HealthTransitionRow> = Vec::new();
let mut prev_bands: HashMap<String, String> = HashMap::new();
for (rev, ts) in &samples {
let date = ts.get(..10).unwrap_or(ts);
let live = live_paths_at(db, ts)?;
let graph = import_graph_from_live_paths(repo, rev, &live);
let m = graph_metrics(&graph);
let files = u32::try_from(m.n).unwrap_or(u32::MAX);
arch_rows.push(ArchitectureTrendRow {
date: date.to_string(),
rev: rev.chars().take(12).collect(),
files,
propagation_cost: m.propagation_cost,
cycle_count: m.cycle_count,
largest_cycle: m.largest_cycle,
});
let arch = arch_health(&m);
ingest_complexity_at_rev(db, repo, rev, &live, CM_AT_REV)?;
materialize_imports_at_rev(db, &graph.resolved_edges(), IMPORTS_AT_REV)?;
let cx = HealthScanCtx {
complexity_source: CM_AT_REV.to_string(),
imports_source: IMPORTS_AT_REV.to_string(),
history_cutoff: Some(ts.clone()),
include_clones: false,
clone_source: CloneSource::WorkingTree,
};
let code_rows = run_code_health_scoped(db, &scan_opts, &cx)?;
let code = repo_code_health(&code_rows);
let combined = combined_health(arch, code);
trend.push(HealthTrendRow {
date: date.to_string(),
rev: rev.chars().take(12).collect(),
files,
arch_health: arch,
code_health: code,
combined_health: combined,
arch_band: health_band(arch).to_string(),
code_band: health_band(code).to_string(),
combined_band: health_band(combined).to_string(),
});
for row in &code_rows {
if top_paths.contains(&row.path) {
file_series.push(FileHealthPoint {
path: row.path.clone(),
date: date.to_string(),
score: row.score,
band: row.band.clone(),
});
}
}
if !prev_bands.is_empty() {
let transitions = detect_transitions(&prev_bands, &code_rows, date);
all_transitions.extend(transitions);
}
prev_bands.clear();
for row in &code_rows {
prev_bands.insert(row.path.clone(), row.band.clone());
}
}
all_transitions.reverse();
Ok(SampleTrends {
architecture: arch_rows,
health: HealthTrendDetail {
trend,
file_series,
transitions: all_transitions,
},
})
}
pub fn run_health_trend_detail<R: Repo>(
db: &FactsDb,
repo: &R,
opts: &Options,
) -> Result<HealthTrendDetail> {
Ok(run_sample_trends(db, repo, opts)?.health)
}
#[tracing::instrument(name = "health-trend", skip_all)]
pub fn run_health_trend<R: Repo>(
db: &FactsDb,
repo: &R,
opts: &Options,
) -> Result<Vec<HealthTrendRow>> {
run_health_trend_detail(db, repo, opts).map(|d| d.trend)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analyses::import_graph::GraphMetrics;
fn metrics(n: usize, pc: f64, cyclic: u32, largest: u32) -> GraphMetrics {
GraphMetrics {
n,
ccd: 0.0,
propagation_cost: pc,
cycle_count: 0,
largest_cycle: largest,
cyclic_nodes: cyclic,
}
}
#[test]
fn arch_health_empty_graph_is_perfect() {
assert!((arch_health(&metrics(0, 0.0, 0, 0)) - 100.0).abs() < 1e-9);
}
#[test]
fn arch_health_acyclic_is_100_minus_half_pc() {
let h = arch_health(&metrics(10, 0.2, 0, 0));
assert!((h - 90.0).abs() < 1e-9, "got {h}");
}
#[test]
fn arch_health_fully_tangled_is_low() {
let h = arch_health(&metrics(10, 1.0, 10, 10));
assert!(h.abs() < 1e-9, "got {h}");
}
#[test]
fn arch_risk_maxes_at_health_zero() {
let h = arch_health(&metrics(2, 1.0, 2, 2));
assert!(h.abs() < 1e-9, "worst-case health must be 0, got {h}");
}
#[test]
fn combined_is_mean_of_arch_and_code() {
assert!((combined_health(80.0, 60.0) - 70.0).abs() < 1e-9);
}
#[test]
fn repo_code_health_empty_is_100() {
assert!((repo_code_health(&[]) - 100.0).abs() < 1e-9);
}
#[test]
fn repo_code_health_averages_scores() {
let rows = vec![
CodeHealthRow {
path: "a".into(),
cognitive: 0.0,
score: 90.0,
structural_risk: 0.0,
percentile: 0.0,
band: "green".into(),
corpus_percentile: None,
beyond_corpus: false,
corpus_percentile_ci_low: None,
corpus_percentile_ci_high: None,
},
CodeHealthRow {
path: "b".into(),
cognitive: 0.0,
score: 50.0,
structural_risk: 0.0,
percentile: 0.0,
band: "yellow".into(),
corpus_percentile: None,
beyond_corpus: false,
corpus_percentile_ci_low: None,
corpus_percentile_ci_high: None,
},
];
assert!((repo_code_health(&rows) - 70.0).abs() < 1e-9);
}
}