lean_ctx/core/code_health/
scan.rs1use super::{Hotspot, NamingFinding, NavigabilityInputs, NavigabilityScore, analyze_file, grade};
11use std::path::Path;
12
13const HEALTH_SOURCE_EXTS: &[&str] = &[
15 "rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "c", "h", "cpp", "cc", "cxx", "hpp",
16];
17
18#[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 pub wasted_tokens: u64,
29}
30
31#[derive(Debug, Clone)]
33pub struct ProjectHealth {
34 pub score: NavigabilityScore,
35 pub files: Vec<FileReport>,
37 pub naming_count: usize,
38}
39
40impl ProjectHealth {
41 pub fn grade(&self) -> char {
43 grade(self.score.score)
44 }
45}
46
47pub 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 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
137fn 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
147fn 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 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}