use std::path::{Path, PathBuf};
use regex::Regex;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::{CodeLoreError, Result};
pub mod szz;
pub mod validate;
pub const DEFECT_FORMAT_VERSION: u32 = 2;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OracleConfig {
pub extra_patterns: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct DefectOracle {
conventional: Regex,
word_boundary: Regex,
extra: Vec<Regex>,
}
const REVERT_PREFIX: &str = "Revert \"";
impl DefectOracle {
pub fn new(cfg: &OracleConfig) -> Result<Self> {
let conventional = Regex::new(r"(?i)^fix(\([^)]*\))?:").map_err(|e| {
CodeLoreError::Analysis(format!("built-in oracle conventional pattern: {e}"))
})?;
let word_boundary =
Regex::new(r"(?i)(?:\bbugfix|\b(?:bug|fix(?:es|ed)?|defect|regression|hotfix)\b)")
.map_err(|e| {
CodeLoreError::Analysis(format!("built-in oracle word-boundary pattern: {e}"))
})?;
let mut extra = Vec::with_capacity(cfg.extra_patterns.len());
for pattern in &cfg.extra_patterns {
let re = Regex::new(pattern).map_err(|e| {
CodeLoreError::InvalidOptions(format!(
"defect oracle extra pattern {pattern:?}: {e}"
))
})?;
extra.push(re);
}
Ok(Self {
conventional,
word_boundary,
extra,
})
}
#[must_use]
pub fn is_fix(&self, message: &str, is_merge: bool) -> bool {
if is_merge || message.starts_with(REVERT_PREFIX) {
return false;
}
self.conventional.is_match(message)
|| self.word_boundary.is_match(message)
|| self.extra.iter().any(|re| re.is_match(message))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MiningStats {
pub fixes_found: u32,
pub links_found: u32,
pub files_blamed: u32,
pub lines_considered: u32,
pub lines_dropped_cosmetic: u32,
pub blame_failures: u32,
pub pure_addition_fixes: u32,
#[serde(skip)]
pub fixes_excluded_tangled: u32,
#[serde(skip)]
pub ghost_files_skipped: u32,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ValidationMetrics {
pub band_table: Vec<(String, u32, f64)>,
pub auc_default: Option<f64>,
pub precision_at_10: Option<f64>,
pub precision_at_red: Option<f64>,
pub implicated_files: u32,
pub linked_defects: u32,
pub sample_dates: Vec<String>,
pub excluded_no_data: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "outcome")]
pub enum TuningDecision {
Applied {
auc_train: f64,
auc_validation_default: f64,
auc_validation_tuned: f64,
},
DefaultsKept {
reason: String,
auc_validation_default: Option<f64>,
auc_validation_tuned: Option<f64>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DefectArtifact {
pub format_version: u32,
pub repo_identity: String,
pub head_at_mining: String,
pub vintage: String,
pub generated_at: String,
pub oracle: OracleConfig,
pub mining: MiningStats,
pub validation: ValidationMetrics,
pub weights: Vec<(String, f64)>,
pub tuning: TuningDecision,
}
pub fn save(artifact: &DefectArtifact, path: &Path) -> Result<()> {
let bytes = serde_json::to_vec(artifact).map_err(|e| {
CodeLoreError::Analysis(format!("serialize defect-calibration artifact: {e}"))
})?;
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, &bytes)?;
Ok(())
}
pub fn load(path: &Path) -> Result<DefectArtifact> {
let bytes = std::fs::read(path).map_err(|e| {
CodeLoreError::RepoIo(std::io::Error::new(
e.kind(),
format!("read defect-calibration artifact {}: {e}", path.display()),
))
})?;
let artifact: DefectArtifact = serde_json::from_slice(&bytes).map_err(|e| {
CodeLoreError::Analysis(format!(
"parse defect-calibration artifact {}: {e}",
path.display()
))
})?;
if artifact.format_version != DEFECT_FORMAT_VERSION {
return Err(CodeLoreError::Analysis(format!(
"unknown defect-calibration format_version {} (this build supports {DEFECT_FORMAT_VERSION}): {}",
artifact.format_version,
path.display()
)));
}
Ok(artifact)
}
fn canonicalize_repo_path(repo_path: &Path) -> PathBuf {
match std::fs::canonicalize(repo_path) {
Ok(canonical) => canonical,
Err(e) => {
tracing::debug!(
"defect_calibration::repo_identity: canonicalize fallback for {} ({e}); using raw path",
repo_path.display()
);
repo_path.to_path_buf()
}
}
}
fn root_commit_identity(repo_path: &Path) -> std::result::Result<String, String> {
let repo = gix::open(repo_path).map_err(|e| format!("not a git repository ({e})"))?;
let head = repo
.head_id()
.map_err(|e| format!("cannot resolve HEAD ({e})"))?;
let walk = repo
.rev_walk([head])
.all()
.map_err(|e| format!("history walk failed ({e})"))?;
let mut roots: Vec<String> = Vec::new();
for info in walk {
let info = info.map_err(|e| format!("history walk failed ({e})"))?;
if info.parent_ids.is_empty() {
roots.push(info.id.to_hex().to_string());
}
}
if roots.is_empty() {
return Err("no root commit reachable (shallow clone?)".to_string());
}
roots.sort_unstable();
let mut hasher = Sha256::new();
hasher.update(roots.join("\0").as_bytes());
Ok(hex::encode(hasher.finalize()))
}
#[must_use]
pub fn repo_identity(repo_path: &Path) -> String {
match root_commit_identity(repo_path) {
Ok(identity) => identity,
Err(reason) => {
tracing::warn!(
"defect_calibration::repo_identity: could not resolve root commit for {} ({reason}); falling back to path-based identity",
repo_path.display()
);
let canonical = canonicalize_repo_path(repo_path);
let mut hasher = Sha256::new();
hasher.update(canonical.to_string_lossy().as_bytes());
hex::encode(hasher.finalize())
}
}
}
pub fn check_repo_identity(
art: &DefectArtifact,
repo_path: &Path,
allow_foreign: bool,
) -> Result<()> {
if allow_foreign {
return Ok(());
}
let actual = repo_identity(repo_path);
if actual == art.repo_identity {
return Ok(());
}
Err(CodeLoreError::Analysis(format!(
"defect-calibration artifact was mined from a different repository (artifact identity {}, this repo is {actual}); pass --allow-foreign-calibration to apply it anyway",
art.repo_identity
)))
}
pub type WeightsAndVintage = (Vec<(String, f64)>, String);
pub fn active_weights(opts: &crate::Options) -> Result<Option<WeightsAndVintage>> {
let Some(path) = &opts.defect_calibration else {
return Ok(None);
};
let art = load(path)?;
check_repo_identity(&art, &opts.repo_path, opts.allow_foreign_calibration)?;
let expected = crate::analyses::code_health::SMELL_WEIGHTS;
if art.weights.len() != expected.len()
|| art
.weights
.iter()
.zip(expected)
.any(|((name, w), &(exp, _))| name != exp || !w.is_finite() || *w < 0.0)
{
return Err(CodeLoreError::Analysis(format!(
"defect-calibration artifact {} has malformed weights: expected the {} built-in smells in canonical order with finite non-negative values",
path.display(),
expected.len()
)));
}
Ok(Some((art.weights, art.vintage)))
}
pub fn active_vintage(opts: &crate::Options) -> Result<Option<String>> {
Ok(active_weights(opts)?.map(|(_, vintage)| vintage))
}
#[cfg(test)]
mod tests {
use super::*;
fn oracle(extra_patterns: &[&str]) -> DefectOracle {
let cfg = OracleConfig {
extra_patterns: extra_patterns.iter().map(|s| (*s).to_string()).collect(),
};
DefectOracle::new(&cfg).expect("valid oracle config compiles")
}
#[test]
fn conventional_prefix_matches_case_insensitively() {
let o = oracle(&[]);
assert!(o.is_fix("fix: null deref", false));
assert!(o.is_fix("Fix(parser): does the thing", false));
}
#[test]
fn word_boundary_terms_match() {
let o = oracle(&[]);
assert!(o.is_fix("bugfix for #12", false));
assert!(o.is_fix("regression in DSM", false));
}
#[test]
fn bugfix_is_leading_only_so_its_plural_compound_still_matches() {
let o = oracle(&[]);
assert!(o.is_fix("prefix bugfixes", false));
}
#[test]
fn fixture_vocabulary_never_classifies_as_a_fix() {
let o = oracle(&[]);
assert!(!o.is_fix("test(fixtures): biomarker repo exercises nesting", false));
assert!(!o.is_fix("Shared test fixtures as checked-in git bundles", false));
assert!(!o.is_fix("hotfixture deploy", false));
assert!(!o.is_fix("defective by design", false));
}
#[test]
fn mid_word_occurrence_without_a_leading_boundary_does_not_match() {
let o = oracle(&[]);
assert!(!o.is_fix("affix labels", false));
}
#[test]
fn patch_is_not_in_the_strict_vocabulary() {
let o = oracle(&[]);
assert!(!o.is_fix("patch bump", false));
}
#[test]
fn revert_prefix_excludes_regardless_of_body_content() {
let o = oracle(&[]);
assert!(!o.is_fix("Revert \"fix: x\"", false));
}
#[test]
fn merge_flag_excludes_regardless_of_message() {
let o = oracle(&[]);
assert!(!o.is_fix("fix: this would otherwise match", true));
}
#[test]
fn extra_pattern_is_ored_in() {
let o = oracle(&["JIRA-\\d+"]);
assert!(o.is_fix("JIRA-77 crash", false));
assert!(o.is_fix("fix: still works", false));
assert!(!o.is_fix("refactor: tidy imports", false));
}
#[test]
fn invalid_extra_pattern_is_a_typed_configuration_error() {
let cfg = OracleConfig {
extra_patterns: vec!["(unclosed".to_string()],
};
let err = DefectOracle::new(&cfg).expect_err("invalid regex must fail to compile");
assert!(
matches!(err, CodeLoreError::InvalidOptions(_)),
"invalid extra pattern must surface as InvalidOptions, got: {err:?}"
);
}
#[test]
fn repo_identity_is_deterministic_and_full_length_hex() {
let dir = std::env::temp_dir();
let a = repo_identity(&dir);
let b = repo_identity(&dir);
assert_eq!(
a, b,
"repo_identity must be deterministic for the same path"
);
assert_eq!(a.len(), 64, "expected full 64-char sha256 hex, got {a:?}");
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn repo_identity_differs_for_different_paths() {
let a = repo_identity(Path::new("/tmp"));
let b = repo_identity(Path::new("/var"));
assert_ne!(a, b, "distinct paths must hash to distinct identities");
}
#[cfg(feature = "test-support")]
fn run_git(dir: &Path, args: &[&str]) {
let ok = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.status()
.expect("spawn git")
.success();
assert!(ok, "git {args:?} failed");
}
#[cfg(feature = "test-support")]
fn init_repo_with_commit(dir: &Path, message: &str, content: &str) {
run_git(dir, &["init", "-b", "main", "--quiet"]);
run_git(dir, &["config", "user.email", "fixture@example.com"]);
run_git(dir, &["config", "user.name", "Fixture"]);
std::fs::write(dir.join("file.txt"), content).expect("write fixture file");
run_git(dir, &["add", "."]);
let date = "2026-01-01T00:00:00Z";
let ok = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["commit", "--quiet", "-m", message])
.env("GIT_AUTHOR_DATE", date)
.env("GIT_COMMITTER_DATE", date)
.status()
.expect("spawn git commit")
.success();
assert!(ok, "git commit failed");
}
#[cfg(feature = "test-support")]
fn root_sha_via_git(dir: &Path) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["rev-list", "--max-parents=0", "HEAD"])
.output()
.expect("spawn git rev-list");
assert!(out.status.success(), "git rev-list failed");
String::from_utf8(out.stdout)
.expect("utf8 sha")
.trim()
.to_string()
}
#[cfg(feature = "test-support")]
fn path_only_identity(dir: &Path) -> String {
let canonical = canonicalize_repo_path(dir);
let mut hasher = Sha256::new();
hasher.update(canonical.to_string_lossy().as_bytes());
hex::encode(hasher.finalize())
}
#[test]
#[cfg(feature = "test-support")]
fn repo_identity_is_stable_across_reclone_to_a_new_path() {
let origin = tempfile::tempdir().expect("origin tempdir");
init_repo_with_commit(origin.path(), "root commit", "alpha\n");
let id_origin = repo_identity(origin.path());
let clone_parent = tempfile::tempdir().expect("clone tempdir");
let clone_path = clone_parent.path().join("reclone");
let ok = std::process::Command::new("git")
.args(["clone", "--quiet"])
.arg(origin.path())
.arg(&clone_path)
.status()
.expect("spawn git clone")
.success();
assert!(ok, "git clone failed");
let id_clone = repo_identity(&clone_path);
assert_eq!(
id_origin, id_clone,
"a moved / re-cloned repo must keep its identity (root commit SHA is path-independent)"
);
}
#[test]
#[cfg(feature = "test-support")]
fn repo_identity_differs_for_independently_initialised_repos() {
let a = tempfile::tempdir().expect("tempdir a");
let b = tempfile::tempdir().expect("tempdir b");
init_repo_with_commit(a.path(), "root of repo a", "content-a\n");
init_repo_with_commit(b.path(), "root of repo b", "content-b\n");
assert_ne!(
repo_identity(a.path()),
repo_identity(b.path()),
"independent repos with distinct root commits must have distinct identities"
);
}
#[test]
#[cfg(feature = "test-support")]
fn repo_identity_uses_root_commit_sha_not_path_for_a_git_repo() {
let dir = tempfile::tempdir().expect("tempdir");
init_repo_with_commit(dir.path(), "sole commit", "body\n");
let identity = repo_identity(dir.path());
assert_ne!(
identity,
path_only_identity(dir.path()),
"for a real git repo the identity must derive from the root commit, not the path"
);
let mut hasher = Sha256::new();
hasher.update(root_sha_via_git(dir.path()).as_bytes());
let expected = hex::encode(hasher.finalize());
assert_eq!(
identity, expected,
"identity must be sha256 of the root commit SHA"
);
}
#[cfg(feature = "test-support")]
fn add_commit(dir: &Path, message: &str, content: &str) {
std::fs::write(dir.join("file.txt"), content).expect("write fixture file");
run_git(dir, &["add", "."]);
let date = "2026-02-01T00:00:00Z";
let ok = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["commit", "--quiet", "-m", message])
.env("GIT_AUTHOR_DATE", date)
.env("GIT_COMMITTER_DATE", date)
.status()
.expect("spawn git commit")
.success();
assert!(ok, "git commit failed");
}
#[test]
#[cfg(feature = "test-support")]
fn repo_identity_is_stable_when_head_advances() {
let dir = tempfile::tempdir().expect("tempdir");
init_repo_with_commit(dir.path(), "root commit", "first\n");
let root = root_sha_via_git(dir.path());
let id_before = repo_identity(dir.path());
add_commit(dir.path(), "second commit", "second\n");
let head = root_sha_via_git(dir.path()); assert_eq!(root, head, "the root commit is unchanged by the new tip");
let head_tip = {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["rev-parse", "HEAD"])
.output()
.expect("spawn git rev-parse");
String::from_utf8(out.stdout)
.expect("utf8")
.trim()
.to_string()
};
assert_ne!(head_tip, root, "HEAD must have advanced past the root");
let id_after = repo_identity(dir.path());
assert_eq!(
id_before, id_after,
"identity must track the root commit, so it is unchanged when HEAD advances"
);
}
}