use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::Path;
use sha2::{Digest, Sha256};
use crate::analyses::code_health::{
CloneSource, CodeHealthRow, HealthScanCtx, run_code_health_scoped,
};
use crate::analyses::coupling::{CouplingAbsence, compute_coupling_absences, run_coupling};
use crate::analyses::import_graph::{
ImportGraph, build_import_graph, build_import_graph_from_edges, tarjan_scc,
};
use crate::analyses::query::query_map_collect;
use crate::complexity::{ComplexityEntity, Tier1Language, compute_for_file};
use crate::constants::{DEFAULT_FISHER_SIGNIFICANCE, DEFAULT_MIN_SHARED_REVS};
use crate::facts::FactsDb;
use crate::facts::ingest::consumer::{dedup_entities, f64_to_i32_clamped};
use crate::imports::{ImportLanguage, extract_imports, resolve_by_extension};
use crate::repo::{WorktreeChange, WorktreeChangeKind};
use crate::{CodeLoreError, Options, Result};
const PROJECTED_COMPLEXITY_TABLE: &str = "complexity_metrics_projected";
const CHANGED_PATHS_TABLE: &str = "changed_paths_v1";
const DELETED_PATHS_TABLE: &str = "deleted_paths_v1";
const BINARY_SNIFF_BYTES: usize = 8000;
const REASON_NOT_TIER1: &str = "not a Tier-1 source file";
const REASON_BINARY: &str = "binary content";
const REASON_SIZE_LIMIT: &str = "file exceeds the AST size limit";
const REASON_DELETED: &str = "deleted at gate time";
pub(crate) const REASON_NEW_FILE: &str = "new file (no history baseline)";
const REASON_NO_HEAD_ROW: &str = "no code-health row at HEAD";
const REASON_NO_PROJECTED_ROW: &str = "no code-health row after projection";
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FileDelta {
pub path: String,
pub kind: String,
pub baseline_score: Option<f64>,
pub projected_score: Option<f64>,
pub delta: Option<f64>,
pub baseline_band: Option<String>,
pub projected_band: Option<String>,
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct HealthProjection {
pub deltas: Vec<FileDelta>,
pub baseline_median: Option<f64>,
pub projected_median: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ChangeSetReport {
pub head_sha: String,
pub merge_in_progress: bool,
pub changes: Vec<WorktreeChange>,
pub health: HealthProjection,
pub base_cyclic_paths: Vec<String>,
pub newly_cyclic_paths: Vec<String>,
pub coupling_absences: Vec<CouplingAbsence>,
pub findings: Vec<Finding>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Finding {
pub id: String,
pub kind: String,
pub path: String,
pub detail: String,
}
pub fn build_change_set_report<R: crate::Repo>(
db: &FactsDb,
repo: &R,
opts: &Options,
cache_root: &Path,
) -> Result<ChangeSetReport> {
let head_sha = repo.head_sha()?;
let changes = repo.worktree_changes()?;
let key = cache::report_key(&head_sha, &changes, opts)?;
if let Some(mut cached) = cache::read(cache_root, &opts.repo_path, &key) {
cached.merge_in_progress = repo.merge_or_rebase_in_progress();
return Ok(cached);
}
let health = project_health(db, repo, opts, &changes)?;
let (base_cyclic_paths, newly_cyclic_paths) = project_cycles(db, repo, opts, &changes)?;
let touched: HashSet<String> = changes
.iter()
.filter(|c| c.kind != WorktreeChangeKind::Deleted)
.map(|c| c.path.clone())
.collect();
let coupling = run_coupling(db, opts)?;
let coupling_absences = compute_coupling_absences(
&coupling,
&touched,
DEFAULT_MIN_SHARED_REVS,
DEFAULT_FISHER_SIGNIFICANCE,
);
let clone_intros = clone_introductions(db, opts, &changes)?;
let findings = assemble_findings(
&health,
&newly_cyclic_paths,
&coupling_absences,
&clone_intros,
&changes,
);
let report = ChangeSetReport {
head_sha,
merge_in_progress: repo.merge_or_rebase_in_progress(),
changes,
health,
base_cyclic_paths,
newly_cyclic_paths,
coupling_absences,
findings,
};
cache::write(cache_root, &opts.repo_path, &key, &report);
Ok(report)
}
pub fn project_health<R: crate::Repo>(
db: &FactsDb,
repo: &R,
opts: &Options,
changes: &[WorktreeChange],
) -> Result<HealthProjection> {
let opts_scan = {
let mut o = opts.with_no_row_limit();
o.min_revs = 1;
o
};
let baseline_ctx = HealthScanCtx {
clone_source: CloneSource::Head,
..HealthScanCtx::head()
};
let baseline_rows = run_code_health_scoped(db, &opts_scan, &baseline_ctx)?;
let head_sha = repo.head_sha()?;
let skip_reasons = build_projected_complexity_table(db, opts, changes, &head_sha)?;
let projected_ctx = HealthScanCtx {
complexity_source: PROJECTED_COMPLEXITY_TABLE.to_string(),
imports_source: "imports".to_string(),
history_cutoff: None,
include_clones: true,
clone_source: CloneSource::WorkingTree,
};
let projected_rows = run_code_health_scoped(db, &opts_scan, &projected_ctx)?;
let mut deltas: Vec<FileDelta> = changes
.iter()
.map(|change| delta_for_change(change, &baseline_rows, &projected_rows, &skip_reasons))
.collect();
sort_deltas(&mut deltas);
Ok(HealthProjection {
deltas,
baseline_median: median(baseline_rows.iter().map(|r| r.score)),
projected_median: median(projected_rows.iter().map(|r| r.score)),
})
}
fn build_projected_complexity_table(
db: &FactsDb,
opts: &Options,
changes: &[WorktreeChange],
head_sha: &str,
) -> Result<HashMap<String, &'static str>> {
populate_path_table(db, CHANGED_PATHS_TABLE, changed_set_paths(changes))?;
db.execute_batch(&format!(
"CREATE OR REPLACE TEMPORARY TABLE {PROJECTED_COMPLEXITY_TABLE} AS \
SELECT * FROM complexity_metrics \
WHERE path NOT IN (SELECT path FROM {CHANGED_PATHS_TABLE})"
))?;
let mut skip_reasons: HashMap<String, &'static str> = HashMap::new();
let mut insert = db
.conn()
.prepare(&format!(
"INSERT INTO {PROJECTED_COMPLEXITY_TABLE} \
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
))
.map_err(|e| {
CodeLoreError::Analysis(format!("prepare {PROJECTED_COMPLEXITY_TABLE}: {e}"))
})?;
for change in changes {
if change.kind == WorktreeChangeKind::Deleted {
continue; }
match parse_worktree_file(&opts.repo_path, &change.path)? {
ParseOutcome::Skipped(reason) => {
skip_reasons.insert(change.path.clone(), reason);
}
ParseOutcome::Entities(entities) => {
for ent in &entities {
insert
.execute(duckdb::params![
change.path,
ent.name,
head_sha,
f64_to_i32_clamped(ent.cyclomatic),
f64_to_i32_clamped(ent.cognitive),
ent.halstead_volume,
ent.halstead_difficulty,
ent.halstead_effort,
ent.mi,
i32::try_from(ent.nom).unwrap_or(i32::MAX),
i32::try_from(ent.nexits).unwrap_or(i32::MAX),
i32::try_from(ent.loc).unwrap_or(i32::MAX),
i32::try_from(ent.sloc).unwrap_or(i32::MAX),
i32::try_from(ent.max_nesting).unwrap_or(i32::MAX),
ent.mean_nesting,
ent.sd_nesting,
i32::try_from(ent.total_nesting).unwrap_or(i32::MAX),
i32::try_from(ent.nargs).unwrap_or(i32::MAX),
i32::try_from(ent.bool_ops).unwrap_or(i32::MAX),
])
.map_err(|e| {
CodeLoreError::Analysis(format!(
"insert {PROJECTED_COMPLEXITY_TABLE}: {e}"
))
})?;
}
}
}
}
Ok(skip_reasons)
}
fn changed_set_paths(changes: &[WorktreeChange]) -> Vec<&str> {
let mut seen: HashSet<&str> = HashSet::new();
let mut paths: Vec<&str> = Vec::new();
for change in changes {
let candidates = std::iter::once(change.path.as_str()).chain(change.rename_from.as_deref());
for path in candidates {
if seen.insert(path) {
paths.push(path);
}
}
}
paths
}
fn deleted_set_paths(changes: &[WorktreeChange]) -> Vec<&str> {
let mut seen: HashSet<&str> = HashSet::new();
let mut paths: Vec<&str> = Vec::new();
for change in changes {
let deleted = (change.kind == WorktreeChangeKind::Deleted).then_some(change.path.as_str());
for path in deleted.into_iter().chain(change.rename_from.as_deref()) {
if seen.insert(path) {
paths.push(path);
}
}
}
paths
}
fn populate_path_table<'a>(
db: &FactsDb,
name: &str,
paths: impl IntoIterator<Item = &'a str>,
) -> Result<()> {
db.execute_batch(&format!(
"CREATE OR REPLACE TEMPORARY TABLE {name} (path TEXT NOT NULL)"
))?;
let mut stmt = db
.conn()
.prepare(&format!("INSERT INTO {name} VALUES (?)"))
.map_err(|e| CodeLoreError::Analysis(format!("prepare {name}: {e}")))?;
for path in paths {
stmt.execute(duckdb::params![path])
.map_err(|e| CodeLoreError::Analysis(format!("insert {name}: {e}")))?;
}
Ok(())
}
enum ParseOutcome {
Skipped(&'static str),
Entities(Vec<ComplexityEntity>),
}
fn parse_worktree_file(repo_root: &Path, rel_path: &str) -> Result<ParseOutcome> {
let Some(lang) = Tier1Language::from_path(rel_path) else {
return Ok(ParseOutcome::Skipped(REASON_NOT_TIER1));
};
let source = std::fs::read(repo_root.join(rel_path))
.map_err(|e| CodeLoreError::Analysis(format!("read worktree file {rel_path}: {e}")))?;
if source.len() > crate::constants::DEFAULT_MAX_AST_FILE_BYTES {
return Ok(ParseOutcome::Skipped(REASON_SIZE_LIMIT));
}
let sniff_end = source.len().min(BINARY_SNIFF_BYTES);
if source[..sniff_end].contains(&0u8) {
return Ok(ParseOutcome::Skipped(REASON_BINARY));
}
let entities = compute_for_file(Path::new(rel_path), source, lang)?;
Ok(ParseOutcome::Entities(dedup_entities(entities)))
}
fn delta_for_change(
change: &WorktreeChange,
baseline_rows: &[CodeHealthRow],
projected_rows: &[CodeHealthRow],
skip_reasons: &HashMap<String, &'static str>,
) -> FileDelta {
let kind = kind_str(change);
let baseline = baseline_rows.iter().find(|r| r.path == change.path);
if change.kind == WorktreeChangeKind::Deleted {
return FileDelta {
path: change.path.clone(),
kind,
baseline_score: baseline.map(|r| r.score),
projected_score: None,
delta: None,
baseline_band: baseline.map(|r| r.band.clone()),
projected_band: None,
reason: Some(REASON_DELETED.to_string()),
};
}
if let Some(reason) = skip_reasons.get(change.path.as_str()) {
return FileDelta {
path: change.path.clone(),
kind,
baseline_score: baseline.map(|r| r.score),
projected_score: None,
delta: None,
baseline_band: baseline.map(|r| r.band.clone()),
projected_band: None,
reason: Some((*reason).to_string()),
};
}
let projected = projected_rows.iter().find(|r| r.path == change.path);
match (baseline, projected) {
(Some(b), Some(p)) => FileDelta {
path: change.path.clone(),
kind,
baseline_score: Some(b.score),
projected_score: Some(p.score),
delta: Some(p.score - b.score),
baseline_band: Some(b.band.clone()),
projected_band: Some(p.band.clone()),
reason: None,
},
(None, projected) => {
let reason = if change.kind == WorktreeChangeKind::Added {
REASON_NEW_FILE
} else {
REASON_NO_HEAD_ROW
};
FileDelta {
path: change.path.clone(),
kind,
baseline_score: None,
projected_score: projected.map(|p| p.score),
delta: None,
baseline_band: None,
projected_band: projected.map(|p| p.band.clone()),
reason: Some(reason.to_string()),
}
}
(Some(b), None) => FileDelta {
path: change.path.clone(),
kind,
baseline_score: Some(b.score),
projected_score: None,
delta: None,
baseline_band: Some(b.band.clone()),
projected_band: None,
reason: Some(REASON_NO_PROJECTED_ROW.to_string()),
},
}
}
fn kind_str(change: &WorktreeChange) -> String {
if change.rename_from.is_some() {
return "renamed".to_string();
}
match change.kind {
WorktreeChangeKind::Added => "added",
WorktreeChangeKind::Modified => "modified",
WorktreeChangeKind::Deleted => "deleted",
}
.to_string()
}
fn sort_deltas(deltas: &mut [FileDelta]) {
deltas.sort_by(|a, b| match (a.delta, b.delta) {
(Some(x), Some(y)) => y
.abs()
.total_cmp(&x.abs())
.then_with(|| a.path.cmp(&b.path)),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.path.cmp(&b.path),
});
}
fn median(scores: impl Iterator<Item = f64>) -> Option<f64> {
let mut v: Vec<f64> = scores.collect();
if v.is_empty() {
return None;
}
v.sort_by(f64::total_cmp);
let mid = v.len() / 2;
Some(if v.len() % 2 == 1 {
v[mid]
} else {
f64::midpoint(v[mid - 1], v[mid])
})
}
fn project_cycles<R: crate::Repo>(
db: &FactsDb,
repo: &R,
opts: &Options,
changes: &[WorktreeChange],
) -> Result<(Vec<String>, Vec<String>)> {
let base_graph = build_import_graph(db)?;
let base_cyclic = cyclic_paths(&base_graph);
let gone: HashSet<&str> = deleted_set_paths(changes).into_iter().collect();
let mut live: HashSet<String> = repo
.tracked_paths_at_head()?
.into_iter()
.filter(|p| !gone.contains(p.as_str()))
.collect();
for change in changes {
if change.kind == WorktreeChangeKind::Added {
live.insert(change.path.clone());
}
}
populate_path_table(db, CHANGED_PATHS_TABLE, changed_set_paths(changes))?;
populate_path_table(db, DELETED_PATHS_TABLE, deleted_set_paths(changes))?;
let mut edges: Vec<(String, String)> = query_map_collect(
db,
&format!(
"SELECT src_path, target_path FROM imports \
WHERE target_path IS NOT NULL \
AND src_path NOT IN (SELECT path FROM {CHANGED_PATHS_TABLE}) \
AND target_path NOT IN (SELECT path FROM {DELETED_PATHS_TABLE})"
),
[],
"change-set surviving import edges",
|r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
)?;
for change in changes {
if change.kind == WorktreeChangeKind::Deleted {
continue;
}
let Some(lang) = ImportLanguage::from_path(Path::new(&change.path)) else {
continue;
};
let source = std::fs::read(opts.repo_path.join(&change.path)).map_err(|e| {
CodeLoreError::Analysis(format!("read worktree file {}: {e}", change.path))
})?;
if source.len() > crate::constants::DEFAULT_MAX_AST_FILE_BYTES {
continue;
}
let imports = match extract_imports(&source, lang) {
Ok(imports) => imports,
Err(e) => {
tracing::warn!("change-set: import extract failed for {}: {e}", change.path);
continue;
}
};
for import in imports {
if let Some(target_path) = resolve_by_extension(&change.path, &import.target, &live) {
edges.push((change.path.clone(), target_path));
}
}
}
let unresolved: Vec<(String, String)> = query_map_collect(
db,
&format!(
"SELECT src_path, target FROM imports \
WHERE NOT resolved \
AND src_path NOT IN (SELECT path FROM {CHANGED_PATHS_TABLE})"
),
[],
"change-set unresolved import sweep",
|r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
)?;
for (src_path, target) in unresolved {
if let Some(target_path) = resolve_by_extension(&src_path, &target, &live) {
edges.push((src_path, target_path));
}
}
let projected_cyclic = cyclic_paths(&build_import_graph_from_edges(&edges));
let newly: Vec<String> = projected_cyclic.difference(&base_cyclic).cloned().collect();
Ok((base_cyclic.into_iter().collect(), newly))
}
fn cyclic_paths(graph: &ImportGraph) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for component in tarjan_scc(&graph.adj) {
if component.len() >= 2 {
for id in component {
out.insert(graph.id_to_path[id].clone());
}
}
}
out
}
struct CloneIntroduction {
path: String,
head_members: u32,
worktree_members: u32,
}
fn clone_introductions(
db: &FactsDb,
opts: &Options,
changes: &[WorktreeChange],
) -> Result<Vec<CloneIntroduction>> {
let head = crate::analyses::clones::head_clone_counts(db)?;
let worktree_rows = crate::analyses::clones::run_clones_memoised(db, opts)?;
let mut worktree: HashMap<&str, u32> = HashMap::new();
for c in worktree_rows.iter() {
*worktree.entry(c.entity.as_str()).or_insert(0) += 1;
}
let mut intros = Vec::new();
for change in changes {
if change.kind == WorktreeChangeKind::Deleted {
continue;
}
let head_members = head.get(&change.path).copied().unwrap_or(0);
let worktree_members = worktree.get(change.path.as_str()).copied().unwrap_or(0);
if worktree_members > head_members {
intros.push(CloneIntroduction {
path: change.path.clone(),
head_members,
worktree_members,
});
}
}
Ok(intros)
}
fn assemble_findings(
health: &HealthProjection,
newly_cyclic: &[String],
absences: &[CouplingAbsence],
clone_intros: &[CloneIntroduction],
changes: &[WorktreeChange],
) -> Vec<Finding> {
let mut findings: Vec<Finding> = Vec::new();
for delta_row in &health.deltas {
let (Some(delta), Some(baseline), Some(projected)) = (
delta_row.delta,
delta_row.baseline_score,
delta_row.projected_score,
) else {
continue;
};
if delta < 0.0 {
findings.push(finding(
"health-drop",
&delta_row.path,
&format!(
"projected code health drops from {baseline:.1} to {projected:.1} ({delta:+.1})."
),
));
}
}
for path in newly_cyclic {
findings.push(finding(
"newly-cyclic",
path,
"enters an import cycle that does not exist at HEAD.",
));
}
for absence in absences {
findings.push(finding(
"coupling-absence",
&absence.touched_file,
&format!(
"historically co-changes with {} ({:.0}% of commits, {} shared revisions), \
which is not in this change set.",
absence.expected_partner,
absence.historical_coupling,
absence.historical_shared_revs,
),
));
}
for intro in clone_intros {
let gained = intro.worktree_members.saturating_sub(intro.head_members);
findings.push(finding(
"clone-introduction",
&intro.path,
&format!(
"introduces {gained} duplicated function(s) absent at HEAD \
(clone-family members rise from {} to {}).",
intro.head_members, intro.worktree_members,
),
));
}
for change in changes {
if change.kind == WorktreeChangeKind::Added {
let detail = match change.rename_from.as_deref() {
Some(source) => {
format!("renamed from {source}; history does not carry over to the new path.")
}
None => "new file with no history baseline.".to_string(),
};
findings.push(finding("new-file", &change.path, &detail));
}
}
for delta_row in &health.deltas {
let Some(reason) = delta_row.reason.as_deref() else {
continue;
};
if reason == REASON_BINARY || reason == REASON_SIZE_LIMIT {
findings.push(finding(
"unparseable",
&delta_row.path,
&format!("could not be re-parsed for the projection: {reason}."),
));
}
}
findings.sort_by(|a, b| {
a.kind
.cmp(&b.kind)
.then_with(|| a.path.cmp(&b.path))
.then_with(|| a.detail.cmp(&b.detail))
});
findings
}
fn finding(kind: &str, path: &str, detail: &str) -> Finding {
let digest = Sha256::digest(format!("{kind}|{path}|{detail}").as_bytes());
let mut id = hex::encode(digest);
id.truncate(12);
Finding {
id,
kind: kind.to_string(),
path: path.to_string(),
detail: detail.to_string(),
}
}
pub mod cache {
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use super::ChangeSetReport;
use crate::cache::repo_cache_dir;
use crate::repo::{WorktreeChange, WorktreeChangeKind};
use crate::{CodeLoreError, Options, Result};
const KEY_SCHEMA: &str = "change-set-v1";
const FILE_STEM_LEN: usize = 16;
pub fn report_key(
head_sha: &str,
changes: &[WorktreeChange],
opts: &Options,
) -> Result<String> {
let mut lines: Vec<String> = Vec::with_capacity(changes.len());
for change in changes {
let content = if change.kind == WorktreeChangeKind::Deleted {
"deleted".to_string()
} else {
let bytes = std::fs::read(opts.repo_path.join(&change.path)).map_err(|e| {
CodeLoreError::Analysis(format!("read worktree file {}: {e}", change.path))
})?;
hex::encode(Sha256::digest(&bytes))
};
lines.push(format!("{}\0{content}", change.path));
}
lines.sort();
let calib = opts
.defect_calibration
.as_deref()
.and_then(|p| std::fs::read(p).ok())
.map(|bytes| hex::encode(Sha256::digest(&bytes)))
.unwrap_or_default();
let opts_digest = opts.canonical_json().to_string();
let material = format!(
"{head_sha}|{}|{}|calib={calib}|rows_limit={:?}|opts={opts_digest}|{KEY_SCHEMA}",
lines.join("\n"),
env!("CARGO_PKG_VERSION"),
opts.rows_limit,
);
Ok(hex::encode(Sha256::digest(material.as_bytes())))
}
#[must_use]
pub fn cache_path(cache_root: &Path, repo_path: &Path, key: &str) -> PathBuf {
let stem = &key[..FILE_STEM_LEN.min(key.len())];
repo_cache_dir(cache_root, repo_path)
.join("change-set")
.join(format!("{stem}.json"))
}
#[must_use]
pub fn read(cache_root: &Path, repo_path: &Path, key: &str) -> Option<ChangeSetReport> {
let path = cache_path(cache_root, repo_path, key);
let text = std::fs::read_to_string(&path).ok()?;
match serde_json::from_str(&text) {
Ok(report) => Some(report),
Err(e) => {
tracing::warn!(
"change-set cache: ignoring corrupt entry {}: {e}",
path.display()
);
None
}
}
}
pub fn write(cache_root: &Path, repo_path: &Path, key: &str, report: &ChangeSetReport) {
let path = cache_path(cache_root, repo_path, key);
let Some(parent) = path.parent() else {
tracing::warn!(
"change-set cache: entry path {} has no parent directory",
path.display()
);
return;
};
if let Err(e) = std::fs::create_dir_all(parent) {
tracing::warn!(
"change-set cache: could not create {}: {e}",
parent.display()
);
return;
}
let json = match serde_json::to_string_pretty(report) {
Ok(json) => json,
Err(e) => {
tracing::warn!("change-set cache: could not serialize entry for {key}: {e}");
return;
}
};
if let Err(e) = std::fs::write(&path, json) {
tracing::warn!("change-set cache: could not write {}: {e}", path.display());
}
}
}