clawft_kernel/assessment/analyzers/
complexity.rs1use std::path::{Path, PathBuf};
4
5use crate::assessment::analyzer::Analyzer;
6use crate::assessment::{Finding, analyzer::AnalysisContext};
7#[cfg(feature = "ecc")]
8use crate::eml_kernel::ComplexityModel;
9
10const DEFAULT_LINE_THRESHOLD: usize = 500;
13
14pub struct ComplexityAnalyzer {
26 #[cfg(feature = "ecc")]
30 model: Option<ComplexityModel>,
31}
32
33impl Default for ComplexityAnalyzer {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl ComplexityAnalyzer {
40 pub fn new() -> Self {
43 Self {
44 #[cfg(feature = "ecc")]
45 model: None,
46 }
47 }
48
49 #[cfg(feature = "ecc")]
53 pub fn with_model(model: ComplexityModel) -> Self {
54 Self { model: Some(model) }
55 }
56
57 #[cfg(feature = "ecc")]
59 pub fn model(&self) -> Option<&ComplexityModel> {
60 self.model.as_ref()
61 }
62
63 #[cfg(feature = "ecc")]
66 fn threshold_for(&self, lang_ordinal: u32, line_count: usize) -> usize {
67 match self.model.as_ref() {
68 Some(m) if m.is_trained() => {
69 m.predict(lang_ordinal, line_count as f64, 1.0)
73 }
74 _ => DEFAULT_LINE_THRESHOLD,
75 }
76 }
77
78 #[cfg(not(feature = "ecc"))]
80 fn threshold_for(&self, _lang_ordinal: u32, _line_count: usize) -> usize {
81 DEFAULT_LINE_THRESHOLD
82 }
83}
84
85impl Analyzer for ComplexityAnalyzer {
86 fn id(&self) -> &str {
87 "complexity"
88 }
89
90 fn name(&self) -> &str {
91 "Complexity Analyzer"
92 }
93
94 fn categories(&self) -> &[&str] {
95 &["size", "todo"]
96 }
97
98 fn analyze(
99 &self,
100 project: &Path,
101 files: &[PathBuf],
102 _context: &AnalysisContext,
103 ) -> Vec<Finding> {
104 let mut findings = Vec::new();
105
106 for path in files {
107 let rel = path.strip_prefix(project).unwrap_or(path);
108 let rel_str = rel.display().to_string();
109
110 let content = match std::fs::read_to_string(path) {
111 Ok(c) => c,
112 Err(_) => continue,
113 };
114
115 let line_count = content.lines().count();
116 let threshold = self.threshold_for(0, line_count);
117
118 if line_count > threshold {
120 findings.push(Finding {
121 severity: "warning".into(),
122 category: "size".into(),
123 file: rel_str.clone(),
124 line: None,
125 message: format!("File has {line_count} lines (>{threshold} limit)"),
126 });
127 }
128
129 for (i, line) in content.lines().enumerate() {
131 if line.contains("TODO") || line.contains("FIXME") || line.contains("HACK") {
132 findings.push(Finding {
133 severity: "info".into(),
134 category: "todo".into(),
135 file: rel_str.clone(),
136 line: Some(i + 1),
137 message: line.trim().to_string(),
138 });
139 }
140 }
141 }
142
143 findings
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 fn write_file(dir: &Path, name: &str, lines: usize) -> PathBuf {
152 let p = dir.join(name);
153 let body: String = (0..lines).map(|i| format!("line {i}\n")).collect();
154 std::fs::write(&p, body).unwrap();
155 p
156 }
157
158 #[test]
159 fn analyzer_default_uses_500_threshold() {
160 let dir = tempfile::tempdir().unwrap();
163 let big = write_file(dir.path(), "big.txt", 600);
164 let small = write_file(dir.path(), "small.txt", 100);
165
166 let analyzer = ComplexityAnalyzer::new();
167 let ctx = AnalysisContext::default();
168 let findings = analyzer.analyze(dir.path(), &[big.clone(), small.clone()], &ctx);
169
170 let size_findings: Vec<_> = findings.iter().filter(|f| f.category == "size").collect();
171 assert_eq!(size_findings.len(), 1);
172 assert!(size_findings[0].message.contains("600 lines"));
173 assert!(size_findings[0].message.contains(">500 limit"));
174 }
175
176 #[test]
177 #[cfg(feature = "ecc")]
178 fn analyzer_untrained_model_matches_default() {
179 let dir = tempfile::tempdir().unwrap();
182 let big = write_file(dir.path(), "b.txt", 700);
183
184 let baseline = ComplexityAnalyzer::new();
185 let model = ComplexityModel::new();
186 assert!(!model.is_trained());
187 let wired = ComplexityAnalyzer::with_model(model);
188
189 let ctx = AnalysisContext::default();
190 let baseline_findings = baseline.analyze(dir.path(), std::slice::from_ref(&big), &ctx);
191 let wired_findings = wired.analyze(dir.path(), std::slice::from_ref(&big), &ctx);
192
193 assert_eq!(baseline_findings.len(), wired_findings.len());
194 for (a, b) in baseline_findings.iter().zip(wired_findings.iter()) {
195 assert_eq!(a.message, b.message);
196 assert_eq!(a.severity, b.severity);
197 assert_eq!(a.category, b.category);
198 }
199 }
200
201 #[test]
202 #[cfg(feature = "ecc")]
203 fn analyzer_trained_model_can_change_threshold() {
204 let dir = tempfile::tempdir().unwrap();
207 let medium = write_file(dir.path(), "m.txt", 400);
208
209 let baseline = ComplexityAnalyzer::new();
210 let baseline_findings = baseline.analyze(
211 dir.path(),
212 std::slice::from_ref(&medium),
213 &AnalysisContext::default(),
214 );
215 let baseline_size = baseline_findings
217 .iter()
218 .filter(|f| f.category == "size")
219 .count();
220 assert_eq!(baseline_size, 0);
221
222 let model = ComplexityModel::new();
224 let mut json = serde_json::to_value(&model).unwrap();
225 if let Some(inner) = json.get_mut("inner").and_then(|v| v.as_object_mut()) {
226 inner.insert("trained".into(), serde_json::Value::Bool(true));
227 }
228 let forced: ComplexityModel = serde_json::from_value(json).unwrap();
229 assert!(forced.is_trained());
230
231 let wired = ComplexityAnalyzer::with_model(forced);
232 let wired_findings = wired.analyze(
233 dir.path(),
234 std::slice::from_ref(&medium),
235 &AnalysisContext::default(),
236 );
237 let size_msgs: Vec<&str> = wired_findings
244 .iter()
245 .filter(|f| f.category == "size")
246 .map(|f| f.message.as_str())
247 .collect();
248 for msg in size_msgs {
249 assert!(
252 !msg.contains(">500 limit"),
253 "trained model must not produce hardcoded 500-line message: {msg}"
254 );
255 }
256 }
257}