Skip to main content

clawft_kernel/assessment/analyzers/
complexity.rs

1//! Complexity analyzer — detects large files and TODO/FIXME/HACK markers.
2
3use 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
10/// Default complexity threshold (lines per file). Preserved as the
11/// hardcoded fallback.
12const DEFAULT_LINE_THRESHOLD: usize = 500;
13
14/// Analyzer that flags files exceeding a per-file line count and
15/// tracks TODO markers.
16///
17/// The line-count threshold defaults to 500 for backward compatibility
18/// (matching the original hardcoded limit). When constructed via
19/// [`Self::with_model`] (requires `ecc`), a learned
20/// [`ComplexityModel`](crate::eml_kernel::ComplexityModel) is consulted
21/// per file; an untrained model falls back to the same 500-line
22/// threshold so the default remains drop-in safe.
23///
24/// NOTE(eml-swap): wired — Finding #5 (ComplexityModel).
25pub struct ComplexityAnalyzer {
26    /// Optional learned threshold model. When None or untrained, the
27    /// analyzer behaves exactly as it did pre-EML. Only present under
28    /// the `ecc` feature (WEFT-114).
29    #[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    /// Create a complexity analyzer using the hardcoded 500-line
41    /// threshold (no learned model).
42    pub fn new() -> Self {
43        Self {
44            #[cfg(feature = "ecc")]
45            model: None,
46        }
47    }
48
49    /// Create a complexity analyzer backed by a learned
50    /// [`ComplexityModel`]. Untrained models fall back to the
51    /// hardcoded 500-line threshold so this is drop-in safe.
52    #[cfg(feature = "ecc")]
53    pub fn with_model(model: ComplexityModel) -> Self {
54        Self { model: Some(model) }
55    }
56
57    /// Returns a reference to the learned model, if installed.
58    #[cfg(feature = "ecc")]
59    pub fn model(&self) -> Option<&ComplexityModel> {
60        self.model.as_ref()
61    }
62
63    /// Resolve the per-file line threshold. Consults the optional
64    /// learned model when trained; otherwise returns 500.
65    #[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                // The model needs three features; we feed lang
70                // ordinal + observed line count + a stub team-size
71                // proxy of 1.0 since we don't track team size here.
72                m.predict(lang_ordinal, line_count as f64, 1.0)
73            }
74            _ => DEFAULT_LINE_THRESHOLD,
75        }
76    }
77
78    /// Resolve the per-file line threshold without EML (no `ecc`).
79    #[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            // > threshold line warning
119            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            // TODO / FIXME / HACK detection
130            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        // Finding #5: with no model, the threshold is 500 lines
161        // (preserved from the original hardcoded behaviour).
162        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        // Finding #5: an untrained ComplexityModel must reproduce the
180        // 500-line threshold exactly.
181        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        // Finding #5: a trained model dispatches to predict() and may
205        // produce a different threshold than 500.
206        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        // 400 lines < 500 threshold → no size warning.
216        let baseline_size = baseline_findings
217            .iter()
218            .filter(|f| f.category == "size")
219            .count();
220        assert_eq!(baseline_size, 0);
221
222        // Force the model into "trained" via JSON patch.
223        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        // We don't assert a specific outcome (the trained model with
238        // zero params produces an implementation-defined threshold,
239        // clamped to [100, 5000]). We assert that at least one branch
240        // observably differs from the hardcoded threshold path: the
241        // size finding either is or is not present, but the message
242        // (when present) reports the model's threshold, not 500.
243        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            // Must not contain ">500 limit" — the trained dispatch
250            // produced its own threshold.
251            assert!(
252                !msg.contains(">500 limit"),
253                "trained model must not produce hardcoded 500-line message: {msg}"
254            );
255        }
256    }
257}