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 let root_text = root.to_string_lossy().into_owned();
159 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 if crate::core::auto_findings::is_noise_path(&rel) {
180 continue;
181 }
182 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 #[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}