1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
// SATD project analysis: project scanning, directory analysis, and result aggregation.
impl SATDDetector {
/// Analyze project for SATD patterns
/// Toyota Way: Extract Method - reduced complexity from 25-><=8
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub async fn analyze_project(
&self,
root: &Path,
include_tests: bool,
) -> Result<SATDAnalysisResult, TemplateError> {
let files = self.discover_files(root, include_tests).await?;
let mut analysis_stats = ProjectAnalysisStats::new();
self.process_project_files(&files, include_tests, &mut analysis_stats)
.await;
let avg_age_days = self
.calculate_project_debt_age(&analysis_stats.all_debts, root)
.await;
Ok(self.build_analysis_result(analysis_stats, avg_age_days))
}
/// Discover the files an analysis will read.
///
/// `find_source_files` filters every candidate through
/// `is_valid_source_file`, which is `is_source_file() && !is_test_file()`:
/// test files are dropped during DISCOVERY. So `--include-tests` — whose
/// only job is to add them — had nothing left to add, and pointing satd
/// straight at a `tests/` directory reported 0 violations. When tests are
/// wanted the walk below applies the same directory exclusions and the same
/// source-file test, minus that drop.
async fn discover_files(
&self,
root: &Path,
include_tests: bool,
) -> Result<Vec<std::path::PathBuf>, TemplateError> {
if !include_tests {
return self.find_source_files(root).await;
}
let mut files = Vec::new();
self.collect_files_including_tests(root, &mut files).await?;
Ok(files)
}
fn collect_files_including_tests<'a>(
&'a self,
dir: &'a Path,
files: &'a mut Vec<std::path::PathBuf>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), TemplateError>> + Send + 'a>>
{
Box::pin(async move {
if dir.is_file() {
if self.is_source_file(dir) {
files.push(dir.to_path_buf());
}
return Ok(());
}
if !dir.is_dir() {
return Ok(());
}
let mut entries = tokio::fs::read_dir(dir).await.map_err(TemplateError::Io)?;
while let Some(entry) = entries.next_entry().await.map_err(TemplateError::Io)? {
let path = entry.path();
if path.is_dir() {
if !self.should_skip_directory(&path) {
self.collect_files_including_tests(&path, files).await?;
}
} else if self.is_source_file(&path) {
files.push(path);
}
}
Ok(())
})
}
/// Toyota Way: Extract Method - process all files in project (complexity <=8)
async fn process_project_files(
&self,
files: &[std::path::PathBuf],
include_tests: bool,
stats: &mut ProjectAnalysisStats,
) {
for file_path in files {
if self.should_skip_file(file_path, include_tests).await {
continue;
}
stats.total_files_analyzed += 1;
self.process_single_file(file_path, stats).await;
}
}
/// Toyota Way: Extract Method - check if file should be skipped (complexity <=8)
async fn should_skip_file(&self, file_path: &Path, include_tests: bool) -> bool {
// Skip test files if not requested
if !include_tests && self.is_test_file(file_path) {
return true;
}
// Files whose every line is suppressed by `should_exclude_file` were
// read, scanned and thrown away one line at a time, so they still
// counted towards `total_files_analyzed`. A file nothing can be
// reported from was not analysed; saying otherwise is what makes
// "excluded everything" look like "measured clean" (#923).
if self.should_exclude_file(file_path) {
return true;
}
// Skip minified/vendor files
if self.is_minified_or_vendor_file(file_path) {
return true;
}
// Check file size constraints
if let Ok(metadata) = tokio::fs::metadata(file_path).await {
if metadata.len() > crate::services::file_classifier::LARGE_FILE_THRESHOLD as u64 {
eprintln!("Warning: Skipped: {} (large file >500KB)", file_path.display());
return true;
}
if metadata.len() > 1_000_000 && self.is_likely_minified_content(file_path).await {
eprintln!("Warning: Skipped: {} (minified content)", file_path.display());
return true;
}
}
false
}
/// Toyota Way: Extract Method - process individual file (complexity <=8)
async fn process_single_file(&self, file_path: &Path, stats: &mut ProjectAnalysisStats) {
match tokio::fs::read_to_string(file_path).await {
Ok(content) => {
if content.len() > 10_000_000 {
eprintln!(
"Warning: Skipping large file {}: {} bytes",
file_path.display(),
content.len()
);
return;
}
match self.extract_from_content(&content, file_path) {
Ok(debts) => {
if !debts.is_empty() {
stats.files_with_debt += 1;
}
stats.all_debts.extend(debts);
}
Err(_e) => {
// Silently skip files that fail parsing (e.g., line too long)
// Analysis continues successfully with remaining files
// BUG-010: Removed noisy warning that interleaved with progress
}
}
}
Err(_e) => {
// Silently skip unreadable files
// BUG-010: Removed noisy warning that interleaved with progress
}
}
}
/// Toyota Way: Extract Method - calculate debt age (complexity <=3)
async fn calculate_project_debt_age(&self, debts: &[TechnicalDebt], root: &Path) -> f64 {
if !debts.is_empty() && root.join(".git").exists() {
self.calculate_average_debt_age(debts, root)
.await
.unwrap_or(0.0)
} else {
0.0
}
}
/// Toyota Way: Extract Method - build analysis result (complexity <=5)
fn build_analysis_result(
&self,
stats: ProjectAnalysisStats,
avg_age_days: f64,
) -> SATDAnalysisResult {
SATDAnalysisResult {
items: stats.all_debts.clone(),
summary: SATDSummary {
total_items: stats.all_debts.len(),
by_severity: self.group_debts_by_severity(&stats.all_debts),
by_category: self.group_debts_by_category(&stats.all_debts),
files_with_satd: stats.files_with_debt,
avg_age_days,
},
total_files_analyzed: stats.total_files_analyzed,
files_with_debt: stats.files_with_debt,
analysis_timestamp: chrono::Utc::now(),
}
}
/// Toyota Way: Extract Method - group debts by severity (complexity <=3)
fn group_debts_by_severity(
&self,
debts: &[TechnicalDebt],
) -> std::collections::HashMap<String, usize> {
let mut map = std::collections::HashMap::with_capacity(3);
for debt in debts {
*map.entry(format!("{:?}", debt.severity)).or_insert(0) += 1;
}
map
}
/// Toyota Way: Extract Method - group debts by category (complexity <=3)
fn group_debts_by_category(
&self,
debts: &[TechnicalDebt],
) -> std::collections::HashMap<String, usize> {
let mut map = std::collections::HashMap::with_capacity(5);
for debt in debts {
*map.entry(format!("{:?}", debt.category)).or_insert(0) += 1;
}
map
}
/// Analyze debt in a directory recursively (excluding test files by default)
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub async fn analyze_directory(
&self,
root: &Path,
) -> Result<Vec<TechnicalDebt>, TemplateError> {
self.analyze_directory_with_tests(root, false).await
}
/// Analyze debt in a directory recursively with test file inclusion control
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub async fn analyze_directory_with_tests(
&self,
root: &Path,
include_tests: bool,
) -> Result<Vec<TechnicalDebt>, TemplateError> {
let mut all_debts = Vec::new();
let files = self.discover_files(root, include_tests).await?;
let discovered = files.len();
let mut analyzed = 0usize;
for file_path in files {
if self
.should_skip_file_for_analysis(&file_path, include_tests)
.await
{
continue;
}
analyzed += 1;
let debts = self.process_file_for_debts(&file_path).await;
all_debts.extend(debts);
}
if analyzed == 0 {
return Err(Self::nothing_measured(root, discovered));
}
Ok(all_debts)
}
/// The refusal returned when a walk analysed nothing.
///
/// #923, second half: this entry point returns `Vec<TechnicalDebt>`, and an
/// empty vec from it is the *only* thing the CLI sees — so "every candidate
/// was excluded" and "the code is clean" arrived as the same value and were
/// rendered as the same sentence, `Found 0 SATD violations in 0 files`,
/// with exit 0. On the real tree `analyze satd -p <repo>/examples` printed
/// exactly that over 113 `.rs` files holding 10 marker-leading TODO/FIXME
/// comments. A gate cannot pass on a measurement that was never taken, so
/// the absence of a measurement is reported as such, the same way a
/// nonexistent path already is (`ensure_analysis_path_exists`).
///
/// The sentence itself is NOT written here. It used to be — a second copy
/// of the wording `analyze defects` uses for the identical event, which is
/// the shape #923 was about in the first place: one rule, two
/// implementations, free to drift the moment either is edited. Both copies
/// were byte-identical, so this is a pure substitution.
fn nothing_measured(root: &Path, discovered: usize) -> TemplateError {
TemplateError::ValidationError {
parameter: "path".to_string(),
reason: crate::services::defect_detector::unmeasured::refusal(
"SATD",
root,
discovered,
"test, example, fuzz, vendored, generated, minified or oversized",
"point the analysis at the project root, or pass --include-tests to measure \
test code.",
),
}
}
async fn should_skip_file_for_analysis(&self, file_path: &Path, include_tests: bool) -> bool {
// Skip test files unless explicitly requested
if !include_tests && self.is_test_file(file_path) {
return true;
}
// See `should_skip_file`: an excluded file is not an analysed file.
if self.should_exclude_file(file_path) {
return true;
}
// Skip minified/vendor files
if self.is_minified_or_vendor_file(file_path) {
return true;
}
// Check file size and minification for large files
self.should_skip_large_file(file_path).await
}
async fn should_skip_large_file(&self, file_path: &Path) -> bool {
if let Ok(metadata) = tokio::fs::metadata(file_path).await {
if metadata.len() > 1_000_000 && self.is_likely_minified_content(file_path).await {
return true;
}
}
false
}
async fn process_file_for_debts(&self, file_path: &Path) -> Vec<TechnicalDebt> {
match tokio::fs::read_to_string(file_path).await {
Ok(content) => self.extract_debts_from_content(&content, file_path),
Err(_e) => {
// Silently skip unreadable files
// BUG-010: Removed noisy warning that interleaved with progress
Vec::new()
}
}
}
fn extract_debts_from_content(&self, content: &str, file_path: &Path) -> Vec<TechnicalDebt> {
// Validate file size before processing
if content.len() > 10_000_000 {
eprintln!(
"Warning: Skipping large file {}: {} bytes",
file_path.display(),
content.len()
);
return Vec::new();
}
// Silently skip files that fail parsing (BUG-010: Removed noisy warning)
self.extract_from_content(content, file_path)
.unwrap_or_default()
}
}