kardo_core/analysis/
staleness.rs1use serde::Serialize;
4
5use crate::git::GitFileInfo;
6use crate::git::CouplingIssue;
7
8#[derive(Debug, Clone, Serialize)]
10pub struct FileFreshness {
11 pub relative_path: String,
13 pub score: f64,
15 pub days_since_modified: Option<i64>,
17 pub coupling_penalty: f64,
19 pub importance_weight: f64,
21}
22
23#[derive(Debug, Clone, Serialize)]
25pub struct FreshnessResult {
26 pub score: f64,
28 pub file_scores: Vec<FileFreshness>,
30}
31
32pub struct StalenessAnalyzer;
34
35fn importance_weight(path: &str) -> f64 {
40 let lower = path.to_lowercase();
41 let filename = lower.rsplit('/').next().unwrap_or(&lower);
42
43 match filename {
44 "claude.md" | "agents.md" => 3.0,
45 _ if lower == ".claude/instructions" => 2.0,
46 "readme.md" => 1.5,
47 ".cursorrules" | ".clinerules" | ".windsurfrules" => 1.5,
48 ".mcp.json" => 1.5,
49 _ => 1.0,
50 }
51}
52
53impl StalenessAnalyzer {
54 fn time_decay(days: i64) -> f64 {
62 if days <= 7 {
63 1.0
64 } else if days <= 30 {
65 1.0 - 0.4 * ((days - 7) as f64 / 23.0)
67 } else if days <= 90 {
68 0.6 - 0.4 * ((days - 30) as f64 / 60.0)
70 } else {
71 0.1
72 }
73 }
74
75 pub fn analyze(
80 git_infos: &[GitFileInfo],
81 coupling_issues: &[CouplingIssue],
82 ) -> FreshnessResult {
83 if git_infos.is_empty() {
84 return FreshnessResult {
85 score: 0.5,
86 file_scores: vec![],
87 };
88 }
89
90 let mut file_scores = Vec::with_capacity(git_infos.len());
91
92 for info in git_infos {
93 let base_score = match info.days_since_modified {
94 Some(days) => Self::time_decay(days),
95 None => 0.5, };
97
98 let coupling_penalty = coupling_issues
100 .iter()
101 .filter(|issue| issue.doc_path == info.relative_path)
102 .map(|issue| {
103 (issue.gap_days as f64 / 90.0).min(0.3)
105 })
106 .fold(0.0_f64, |acc, p| (acc + p).min(0.3));
107
108 let final_score = (base_score - coupling_penalty).max(0.0);
109 let weight = importance_weight(&info.relative_path);
110
111 file_scores.push(FileFreshness {
112 relative_path: info.relative_path.clone(),
113 score: final_score,
114 days_since_modified: info.days_since_modified,
115 coupling_penalty,
116 importance_weight: weight,
117 });
118 }
119
120 let avg_score = if file_scores.is_empty() {
122 0.5
123 } else {
124 let weighted_sum: f64 = file_scores.iter().map(|f| f.score * f.importance_weight).sum();
125 let weight_sum: f64 = file_scores.iter().map(|f| f.importance_weight).sum();
126 weighted_sum / weight_sum
127 };
128
129 FreshnessResult {
130 score: avg_score,
131 file_scores,
132 }
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use std::path::PathBuf;
140
141 fn make_info(path: &str, days: Option<i64>) -> GitFileInfo {
142 GitFileInfo {
143 path: PathBuf::from(path),
144 relative_path: path.to_string(),
145 last_modified: Some("2026-01-01T00:00:00+00:00".to_string()),
146 commit_count: 1,
147 last_author: Some("Test".to_string()),
148 days_since_modified: days,
149 }
150 }
151
152 #[test]
153 fn test_fresh_files() {
154 let infos = vec![make_info("README.md", Some(0))];
155 let result = StalenessAnalyzer::analyze(&infos, &[]);
156 assert!((result.score - 1.0).abs() < 0.01);
157 }
158
159 #[test]
160 fn test_week_old_files() {
161 let infos = vec![make_info("README.md", Some(7))];
162 let result = StalenessAnalyzer::analyze(&infos, &[]);
163 assert!((result.score - 1.0).abs() < 0.01);
164 }
165
166 #[test]
167 fn test_month_old_files() {
168 let infos = vec![make_info("README.md", Some(30))];
169 let result = StalenessAnalyzer::analyze(&infos, &[]);
170 assert!((result.score - 0.6).abs() < 0.05);
171 }
172
173 #[test]
174 fn test_very_old_files() {
175 let infos = vec![make_info("README.md", Some(100))];
176 let result = StalenessAnalyzer::analyze(&infos, &[]);
177 assert!((result.score - 0.1).abs() < 0.05);
178 }
179
180 #[test]
181 fn test_coupling_penalty() {
182 let infos = vec![make_info("docs/api.md", Some(0))];
183 let coupling = vec![CouplingIssue {
184 source_path: "src/api.rs".to_string(),
185 doc_path: "docs/api.md".to_string(),
186 source_last_modified: "2026-01-02T00:00:00+00:00".to_string(),
187 doc_last_modified: "2026-01-01T00:00:00+00:00".to_string(),
188 gap_days: 30,
189 }];
190 let result = StalenessAnalyzer::analyze(&infos, &coupling);
191 assert!(result.score < 0.75);
193 assert!(result.score > 0.5);
194 }
195
196 #[test]
197 fn test_empty_files() {
198 let result = StalenessAnalyzer::analyze(&[], &[]);
199 assert!((result.score - 0.5).abs() < 0.01);
200 }
201
202 #[test]
203 fn test_mixed_freshness() {
204 let infos = vec![
205 make_info("README.md", Some(0)), make_info("old.md", Some(100)), ];
208 let result = StalenessAnalyzer::analyze(&infos, &[]);
209 assert!((result.score - 0.64).abs() < 0.05);
211 }
212
213 #[test]
214 fn test_claude_md_staleness_weighted_higher() {
215 let infos = vec![
221 make_info("CLAUDE.md", Some(100)),
222 make_info("other.md", Some(0)),
223 ];
224 let result = StalenessAnalyzer::analyze(&infos, &[]);
225 assert!(result.score < 0.45, "Stale CLAUDE.md should pull score down, got {}", result.score);
226 }
227
228 #[test]
229 fn test_importance_weight_values() {
230 assert!((importance_weight("CLAUDE.md") - 3.0).abs() < 0.01);
231 assert!((importance_weight("AGENTS.md") - 3.0).abs() < 0.01);
232 assert!((importance_weight(".claude/instructions") - 2.0).abs() < 0.01);
233 assert!((importance_weight("README.md") - 1.5).abs() < 0.01);
234 assert!((importance_weight(".cursorrules") - 1.5).abs() < 0.01);
235 assert!((importance_weight("docs/guide.md") - 1.0).abs() < 0.01);
236 }
237}