Skip to main content

lean_ctx/core/code_health/
scan.rs

1//! Project-wide code-health scan — the shared report behind `lean-ctx health`,
2//! the `ctx_quality` tool, and the dashboard.
3//!
4//! Walks the repo once, analyzes each source file with the engine, and
5//! aggregates into a [`NavigabilityScore`] plus focused per-file detail. The
6//! "quality tax" is grounded in real data: the token count of the function
7//! bodies that exceed the threshold — the tokens an agent must read in full
8//! because the code cannot be navigated by signature.
9
10use super::{Hotspot, NamingFinding, NavigabilityInputs, NavigabilityScore, analyze_file, grade};
11use std::path::Path;
12
13/// Source extensions the engine can analyze (mirrors `core::chunks_ts`).
14const HEALTH_SOURCE_EXTS: &[&str] = &[
15    "rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "c", "h", "cpp", "cc", "cxx", "hpp",
16];
17
18/// Per-file health detail (only files with findings are retained in a report).
19#[derive(Debug, Clone)]
20pub struct FileReport {
21    pub file: String,
22    pub total_functions: usize,
23    pub over_threshold: usize,
24    pub worst_cognitive: u32,
25    pub hotspots: Vec<Hotspot>,
26    pub naming: Vec<NamingFinding>,
27    /// Tokens locked inside over-threshold functions.
28    pub wasted_tokens: u64,
29}
30
31/// Aggregated project health.
32#[derive(Debug, Clone)]
33pub struct ProjectHealth {
34    pub score: NavigabilityScore,
35    /// Files with at least one hotspot or naming finding, worst first.
36    pub files: Vec<FileReport>,
37    pub naming_count: usize,
38}
39
40impl ProjectHealth {
41    /// Letter grade for the project score.
42    pub fn grade(&self) -> char {
43        grade(self.score.score)
44    }
45}
46
47/// Scan `root` for code-health, pricing the quality tax with `model` (or the
48/// blended fallback when `None`). `top_n` bounds the hotspot list in the score.
49pub fn scan_project(
50    root: &Path,
51    threshold: u32,
52    model: Option<&str>,
53    top_n: usize,
54) -> ProjectHealth {
55    use rayon::prelude::*;
56
57    let files = walk_sources(root);
58    let mut reports: Vec<FileReport> = files
59        .par_iter()
60        .filter_map(|(path, content, ext)| analyze_one(path, content, ext, threshold))
61        .collect();
62    reports.sort_by(|a, b| {
63        b.worst_cognitive
64            .cmp(&a.worst_cognitive)
65            .then_with(|| a.file.cmp(&b.file))
66    });
67
68    let functions_total: usize = reports.iter().map(|r| r.total_functions).sum();
69    let over_threshold: usize = reports.iter().map(|r| r.over_threshold).sum();
70    let worst_cognitive: u32 = reports.iter().map(|r| r.worst_cognitive).max().unwrap_or(0);
71    let wasted_tokens: u64 = reports.iter().map(|r| r.wasted_tokens).sum();
72    let naming_count: usize = reports.iter().map(|r| r.naming.len()).sum();
73
74    let mut all_hotspots: Vec<Hotspot> = reports.iter().flat_map(|r| r.hotspots.clone()).collect();
75    all_hotspots.sort_by(|a, b| {
76        b.cognitive
77            .cmp(&a.cognitive)
78            .then_with(|| a.file.cmp(&b.file))
79            .then_with(|| a.line.cmp(&b.line))
80    });
81
82    let input_price_per_m = crate::core::gain::model_pricing::ModelPricing::load()
83        .quote(model)
84        .cost
85        .input_per_m;
86
87    let score = super::navigability(NavigabilityInputs {
88        functions_total,
89        over_threshold,
90        worst_cognitive,
91        import_cycles: 0,
92        wasted_tokens,
93        input_price_per_m,
94        hotspots: &all_hotspots,
95        top_n,
96    });
97
98    // Keep only files that actually have something to report.
99    reports.retain(|r| r.over_threshold > 0 || !r.naming.is_empty());
100
101    ProjectHealth {
102        score,
103        files: reports,
104        naming_count,
105    }
106}
107
108fn analyze_one(path: &str, content: &str, ext: &str, threshold: u32) -> Option<FileReport> {
109    let health = analyze_file(content, ext)?;
110    let lines: Vec<&str> = content.lines().collect();
111
112    let mut hotspots = Vec::new();
113    let mut wasted_tokens: u64 = 0;
114    for f in &health.functions {
115        if f.cognitive > threshold {
116            hotspots.push(Hotspot {
117                file: path.to_string(),
118                symbol: f.name.clone(),
119                line: f.line,
120                cognitive: f.cognitive,
121            });
122            wasted_tokens += span_tokens(&lines, f.line, f.end_line);
123        }
124    }
125
126    Some(FileReport {
127        file: path.to_string(),
128        total_functions: health.functions.len(),
129        over_threshold: hotspots.len(),
130        worst_cognitive: health.worst_cognitive(),
131        hotspots,
132        naming: health.naming,
133        wasted_tokens,
134    })
135}
136
137/// Token count of the source lines `start..=end` (1-based, inclusive).
138fn span_tokens(lines: &[&str], start: usize, end: usize) -> u64 {
139    if start == 0 || start > lines.len() {
140        return 0;
141    }
142    let hi = end.min(lines.len());
143    let body = lines[start - 1..hi].join("\n");
144    crate::core::tokens::count_tokens(&body) as u64
145}
146
147/// Walk `root` for engine-supported source files. Returns `(rel_path, content,
148/// ext)` sorted by path for deterministic aggregation.
149fn walk_sources(root: &Path) -> Vec<(String, String, String)> {
150    let walker = ignore::WalkBuilder::new(root)
151        .hidden(true)
152        .git_ignore(true)
153        .require_git(false)
154        .filter_entry(crate::core::walk_filter::keep_entry)
155        .build();
156
157    let mut out: Vec<(String, String, String)> = Vec::new();
158    for entry in walker.flatten() {
159        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
160            continue;
161        }
162        let path = entry.path();
163        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
164        if !HEALTH_SOURCE_EXTS.contains(&ext) {
165            continue;
166        }
167        let rel = path
168            .strip_prefix(root)
169            .unwrap_or(path)
170            .to_string_lossy()
171            .replace('\\', "/");
172        // Skip vendored / minified / generated files (e.g. `*.min.js`, `vendor/`,
173        // `dist/`): they are third-party or machine-emitted, so their complexity
174        // is not the project's quality signal. Reuses the shared noise filter.
175        if crate::core::auto_findings::is_noise_path(&rel) {
176            continue;
177        }
178        if let Ok(content) = std::fs::read_to_string(path) {
179            out.push((rel, content, ext.to_string()));
180        }
181    }
182    out.sort_by(|a, b| a.0.cmp(&b.0));
183    out
184}
185
186#[cfg(all(test, feature = "tree-sitter"))]
187mod tests {
188    use super::*;
189
190    fn write(dir: &Path, name: &str, body: &str) {
191        std::fs::write(dir.join(name), body).expect("write fixture");
192    }
193
194    #[test]
195    fn scan_aggregates_hotspots_and_score() {
196        let tmp = tempfile::tempdir().expect("tempdir");
197        write(
198            tmp.path(),
199            "clean.rs",
200            "fn add_one(x: i32) -> i32 { x + 1 }\n",
201        );
202        write(
203            tmp.path(),
204            "messy.rs",
205            "fn deep(a: bool) { if a { if a { if a { if a { if a { if a {} } } } } } }\n",
206        );
207
208        let health = scan_project(tmp.path(), 15, Some("gpt-5.4"), 10);
209        assert_eq!(health.files.len(), 1, "only the messy file is reported");
210        assert_eq!(health.files[0].file, "messy.rs");
211        assert_eq!(health.score.over_threshold, 1);
212        assert!(health.score.worst_cognitive >= 16);
213        assert!(health.score.score < 100, "complexity lowers the score");
214        assert!(
215            health.score.estimated_waste_usd > 0.0,
216            "tax priced from tokens"
217        );
218    }
219
220    #[test]
221    fn clean_project_is_perfect() {
222        let tmp = tempfile::tempdir().expect("tempdir");
223        write(tmp.path(), "ok.rs", "fn add_one(x: i32) -> i32 { x + 1 }\n");
224        let health = scan_project(tmp.path(), 15, None, 10);
225        assert_eq!(health.score.score, 100);
226        assert_eq!(health.grade(), 'A');
227        assert!(health.files.is_empty());
228    }
229}