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    let root_text = root.to_string_lossy().into_owned();
159    // No total budget beyond the reader's per-file limit: a scan covers the
160    // project, as before.
161    let mut remaining = usize::MAX;
162    for entry in walker.flatten() {
163        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
164            continue;
165        }
166        let path = entry.path();
167        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
168        if !HEALTH_SOURCE_EXTS.contains(&ext) {
169            continue;
170        }
171        let rel = path
172            .strip_prefix(root)
173            .unwrap_or(path)
174            .to_string_lossy()
175            .replace('\\', "/");
176        // Skip vendored / minified / generated files (e.g. `*.min.js`, `vendor/`,
177        // `dist/`): they are third-party or machine-emitted, so their complexity
178        // is not the project's quality signal. Reuses the shared noise filter.
179        if crate::core::auto_findings::is_noise_path(&rel) {
180            continue;
181        }
182        // Health is a derivation that is rendered (function names, line
183        // numbers) and persisted (the score): it is computed from the ADMITTED
184        // text of a source whose ORIGINAL may be kept, read through the common
185        // rooted reader under `root` (G01, E3). A source that is withheld,
186        // restricted or incompletely decoded is not scanned.
187        let read = crate::core::admitted_sources::read(
188            &path.to_string_lossy(),
189            &root_text,
190            "ctx_quality",
191            &mut remaining,
192        );
193        if let Some(content) = read.storable() {
194            out.push((rel, content.to_owned(), ext.to_string()));
195        }
196    }
197    out.sort_by(|a, b| a.0.cmp(&b.0));
198    out
199}
200
201#[cfg(test)]
202mod admission_tests {
203    use super::walk_sources;
204
205    const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
206
207    /// G01: the health scan read every source with `fs::read_to_string` — a
208    /// source the project policy blocks was scanned and its function names
209    /// reported. It now scans admitted, storable text only; a clean source
210    /// keeps its bytes.
211    #[test]
212    fn the_health_scan_reads_admitted_storable_sources_only() {
213        let _data = crate::core::data_dir::isolated_data_dir();
214        let project = tempfile::tempdir().unwrap();
215        let clean = "fn add_one(x: i32) -> i32 { x + 1 }\n";
216        std::fs::write(project.path().join("clean.rs"), clean).unwrap();
217        std::fs::write(
218            project.path().join("keyed.rs"),
219            format!("fn connect() {{\n    let aws_key = \"{AWS_KEY}\";\n}}\n"),
220        )
221        .unwrap();
222        std::fs::write(
223            project.path().join("plan.rs"),
224            "CONFIDENTIAL\nfn hidden_marker() {}\n",
225        )
226        .unwrap();
227        let content_of = |sources: &[(String, String, String)], name: &str| {
228            sources
229                .iter()
230                .find(|(path, _, _)| path == name)
231                .map(|(_, content, _)| content.clone())
232        };
233        {
234            let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
235            let sources = walk_sources(project.path());
236            assert_eq!(content_of(&sources, "clean.rs").as_deref(), Some(clean));
237            assert!(content_of(&sources, "keyed.rs").is_some_and(|text| !text.contains(AWS_KEY)));
238            assert!(content_of(&sources, "plan.rs").is_some());
239        }
240        let policy = crate::core::policy::load(
241            "name = \"code-health-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
242             [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
243        )
244        .unwrap();
245        let _policy = crate::core::policy::runtime::TestPolicyOverride::set(Some(policy));
246        let sources = walk_sources(project.path());
247        assert_eq!(content_of(&sources, "clean.rs").as_deref(), Some(clean));
248        assert_eq!(content_of(&sources, "plan.rs"), None);
249    }
250}
251
252#[cfg(all(test, feature = "tree-sitter"))]
253pub mod tests {
254    use super::*;
255
256    fn write(dir: &Path, name: &str, body: &str) {
257        std::fs::write(dir.join(name), body).expect("write fixture");
258    }
259
260    #[test]
261    fn scan_aggregates_hotspots_and_score() {
262        let tmp = tempfile::tempdir().expect("tempdir");
263        write(
264            tmp.path(),
265            "clean.rs",
266            "fn add_one(x: i32) -> i32 { x + 1 }\n",
267        );
268        write(
269            tmp.path(),
270            "messy.rs",
271            "fn deep(a: bool) { if a { if a { if a { if a { if a { if a {} } } } } } }\n",
272        );
273
274        let health = scan_project(tmp.path(), 15, Some("gpt-5.4"), 10);
275        assert_eq!(health.files.len(), 1, "only the messy file is reported");
276        assert_eq!(health.files[0].file, "messy.rs");
277        assert_eq!(health.score.over_threshold, 1);
278        assert!(health.score.worst_cognitive >= 16);
279        assert!(health.score.score < 100, "complexity lowers the score");
280        assert!(
281            health.score.estimated_waste_usd > 0.0,
282            "tax priced from tokens"
283        );
284    }
285
286    #[test]
287    fn clean_project_is_perfect() {
288        let tmp = tempfile::tempdir().expect("tempdir");
289        write(tmp.path(), "ok.rs", "fn add_one(x: i32) -> i32 { x + 1 }\n");
290        let health = scan_project(tmp.path(), 15, None, 10);
291        assert_eq!(health.score.score, 100);
292        assert_eq!(health.grade(), 'A');
293        assert!(health.files.is_empty());
294    }
295}