use crate::detectors::base::{Detector, DetectorConfig};
use crate::graph::GraphStore;
use crate::models::{Finding, Severity};
use anyhow::Result;
use chrono::{DateTime, Duration, Utc};
use std::collections::HashMap;
use std::path::PathBuf;
use tracing::{debug, info, warn};
use uuid::Uuid;
const CRITICAL_FIX_VELOCITY_HOURS: i64 = 24;
const HIGH_FIX_VELOCITY_HOURS: i64 = 48;
const MEDIUM_FIX_VELOCITY_HOURS: i64 = 72;
const CRITICAL_MOD_COUNT: usize = 5;
const HIGH_MOD_COUNT: usize = 3;
const CRITICAL_CHURN_RATIO: f64 = 1.5;
const HIGH_CHURN_RATIO: f64 = 0.8;
const MEDIUM_CHURN_RATIO: f64 = 0.5;
const MIN_CHURN_SCORE: f64 = 0.8;
const DEFAULT_ANALYSIS_WINDOW_DAYS: i64 = 90;
const DEFAULT_MIN_FUNCTION_LINES: usize = 5;
#[derive(Debug, Clone)]
pub struct Modification {
pub timestamp: DateTime<Utc>,
pub commit_sha: String,
pub lines_added: usize,
pub lines_deleted: usize,
}
#[derive(Debug, Clone)]
pub struct FunctionChurnRecord {
pub qualified_name: String,
pub file_path: String,
pub function_name: String,
pub created_at: Option<DateTime<Utc>>,
pub creation_commit: String,
pub lines_original: usize,
pub first_modification_at: Option<DateTime<Utc>>,
pub first_modification_commit: String,
pub modifications: Vec<Modification>,
}
impl FunctionChurnRecord {
pub fn time_to_first_fix(&self) -> Option<Duration> {
match (&self.created_at, &self.first_modification_at) {
(Some(created), Some(first_mod)) => Some(*first_mod - *created),
_ => None,
}
}
pub fn time_to_first_fix_hours(&self) -> Option<f64> {
self.time_to_first_fix()
.map(|d| d.num_seconds() as f64 / 3600.0)
}
pub fn modifications_first_week(&self) -> usize {
let Some(created_at) = self.created_at else {
return 0;
};
let week_cutoff = created_at + Duration::days(7);
self.modifications
.iter()
.filter(|m| m.timestamp <= week_cutoff)
.count()
}
pub fn lines_changed_first_week(&self) -> usize {
let Some(created_at) = self.created_at else {
return 0;
};
let week_cutoff = created_at + Duration::days(7);
self.modifications
.iter()
.filter(|m| m.timestamp <= week_cutoff)
.map(|m| m.lines_added + m.lines_deleted)
.sum()
}
pub fn churn_ratio(&self) -> f64 {
if self.lines_original == 0 {
return 0.0;
}
self.lines_changed_first_week() as f64 / self.lines_original as f64
}
pub fn is_high_velocity_fix(&self) -> bool {
let Some(ttf_hours) = self.time_to_first_fix_hours() else {
return false;
};
ttf_hours < HIGH_FIX_VELOCITY_HOURS as f64 && self.modifications.len() >= 2
}
pub fn ai_churn_score(&self) -> f64 {
let mut score = 0.0;
if let Some(ttf_hours) = self.time_to_first_fix_hours() {
if ttf_hours < CRITICAL_FIX_VELOCITY_HOURS as f64 {
score += 0.4;
} else if ttf_hours < HIGH_FIX_VELOCITY_HOURS as f64 {
score += 0.25;
} else if ttf_hours < MEDIUM_FIX_VELOCITY_HOURS as f64 {
score += 0.1;
}
}
let mods = self.modifications.len();
if mods >= 4 {
score += 0.3;
} else if mods >= 2 {
score += 0.2;
} else if mods >= 1 {
score += 0.1;
}
let churn = self.churn_ratio();
if churn > 1.0 {
score += 0.3;
} else if churn > 0.5 {
score += 0.2;
} else if churn > 0.3 {
score += 0.1;
}
f64::min(score, 1.0)
}
}
pub struct AIChurnDetector {
config: DetectorConfig,
analysis_window_days: i64,
min_function_lines: usize,
}
impl AIChurnDetector {
pub fn new() -> Self {
Self {
config: DetectorConfig::new(),
analysis_window_days: DEFAULT_ANALYSIS_WINDOW_DAYS,
min_function_lines: DEFAULT_MIN_FUNCTION_LINES,
}
}
pub fn with_config(config: DetectorConfig) -> Self {
Self {
analysis_window_days: config
.get_option_or("analysis_window_days", DEFAULT_ANALYSIS_WINDOW_DAYS),
min_function_lines: config
.get_option_or("min_function_lines", DEFAULT_MIN_FUNCTION_LINES),
config,
}
}
fn calculate_severity(&self, record: &FunctionChurnRecord) -> Severity {
let ttf_hours = record.time_to_first_fix_hours();
let mods = record.modifications.len();
let churn = record.churn_ratio();
if churn > CRITICAL_CHURN_RATIO {
return Severity::Critical;
}
if let Some(ttf) = ttf_hours {
if ttf < CRITICAL_FIX_VELOCITY_HOURS as f64 && mods >= CRITICAL_MOD_COUNT {
return Severity::Critical;
}
}
if let Some(ttf) = ttf_hours {
if ttf < HIGH_FIX_VELOCITY_HOURS as f64 && mods >= HIGH_MOD_COUNT {
return Severity::High;
}
}
if churn > HIGH_CHURN_RATIO {
return Severity::High;
}
if let Some(ttf) = ttf_hours {
if ttf < MEDIUM_FIX_VELOCITY_HOURS as f64 && mods >= 2 {
return Severity::Medium;
}
}
if churn > MEDIUM_CHURN_RATIO {
return Severity::Medium;
}
if mods >= 4 {
return Severity::Low;
}
Severity::Info
}
fn create_finding(&self, record: &FunctionChurnRecord) -> Option<Finding> {
if record.ai_churn_score() < MIN_CHURN_SCORE {
return None;
}
let severity = self.calculate_severity(record);
if severity == Severity::Info {
return None;
}
let ttf_hours = record.time_to_first_fix_hours();
let ttf_str = ttf_hours
.map(|h| format!("{:.1} hours", h))
.unwrap_or_else(|| "N/A".to_string());
let created_str = record
.created_at
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "Unknown".to_string());
let mut description = format!(
"Function `{}` in `{}` shows signs of rapid post-creation revision.\n\n\
**Fix Velocity Metrics:**\n\
- Created: {} (commit `{}`)\n\
- Time to first fix: **{}**\n\
- Total modifications in first week: **{}**\n\n\
**Churn Analysis:**\n\
- Original size: {} lines\n\
- Lines changed in first week: {}\n\
- Churn ratio: **{:.2}** ({:.0}% of original code)\n\
- AI churn score: {:.2}",
record.function_name,
record.file_path,
created_str,
record.creation_commit,
ttf_str,
record.modifications_first_week(),
record.lines_original,
record.lines_changed_first_week(),
record.churn_ratio(),
record.churn_ratio() * 100.0,
record.ai_churn_score(),
);
if record.is_high_velocity_fix() {
description.push_str(
"\n\n⚠️ **High fix velocity detected**: This function was modified within 48 hours of creation \
with multiple follow-up changes - a pattern strongly associated with AI-generated code \
that required human correction.",
);
}
if record.churn_ratio() > CRITICAL_CHURN_RATIO {
description.push_str(
"\n\n⚠️ **Critical churn ratio**: More code was changed than originally written, \
indicating significant rewriting was needed.",
);
}
if !record.modifications.is_empty() {
description.push_str("\n\n**Modification Timeline:**");
for (i, m) in record.modifications.iter().take(5).enumerate() {
let time_str = m.timestamp.format("%Y-%m-%d %H:%M").to_string();
description.push_str(&format!(
"\n- {}: commit `{}` (+{} lines)",
time_str, m.commit_sha, m.lines_added
));
if i == 4 && record.modifications.len() > 5 {
description.push_str(&format!(
"\n- ... and {} more modifications",
record.modifications.len() - 5
));
}
}
}
let suggested_fix = match severity {
Severity::Critical => {
"This function shows strong signs of AI-generated code that required extensive correction. \
Consider:\n\
1. **Review thoroughly** for hidden bugs or incomplete logic\n\
2. **Add comprehensive tests** - the rapid changes suggest edge cases may be missed\n\
3. **Document the logic** - ensure the team understands what this code does\n\
4. **Consider rewriting** if the churn continues".to_string()
}
Severity::High => {
"Review this function for correctness issues. Consider:\n\
1. Adding unit tests with edge cases\n\
2. Reviewing for logical errors\n\
3. Ensuring proper error handling".to_string()
}
_ => {
"Monitor this function for continued churn. Consider adding tests \
to stabilize the implementation.".to_string()
}
};
let estimated_effort = if matches!(severity, Severity::Low | Severity::Medium) {
"Small (2-4 hours)"
} else {
"Medium (1-2 days)"
};
Some(Finding {
id: Uuid::new_v4().to_string(),
detector: "AIChurnDetector".to_string(),
severity,
title: format!("AI churn pattern in `{}`", record.function_name),
description,
affected_files: vec![PathBuf::from(&record.file_path)],
line_start: None,
line_end: None,
suggested_fix: Some(suggested_fix),
estimated_effort: Some(estimated_effort.to_string()),
category: Some("ai_churn".to_string()),
cwe_id: None,
why_it_matters: Some(
"Code that requires rapid fixing after creation often indicates AI-generated content \
that wasn't fully understood or tested before commit. This pattern is associated with \
hidden bugs, incomplete error handling, and logic that may not be fully correct."
.to_string(),
),
..Default::default()
})
}
}
impl Default for AIChurnDetector {
fn default() -> Self {
Self::new()
}
}
impl Detector for AIChurnDetector {
fn name(&self) -> &'static str {
"AIChurnDetector"
}
fn description(&self) -> &'static str {
"Detects AI-generated code patterns through fix velocity and churn analysis"
}
fn category(&self) -> &'static str {
"ai_generated"
}
fn config(&self) -> Option<&DetectorConfig> {
Some(&self.config)
} fn detect(&self, graph: &GraphStore) -> Result<Vec<Finding>> {
let _ = graph;
Ok(vec![])
}
}
impl AIChurnDetector {
fn detect_without_git_history(&self, _graph: &GraphStore) -> Result<Vec<Finding>> {
warn!(
"AIChurnDetector: No git history data in graph. \
For full churn detection, ensure git history is indexed."
);
Ok(vec![])
}
}