1use std::path::Path;
4
5use fallow_output::{
6 CodeClimateIssue, CodeClimateIssueInput, CodeClimateSeverity, ComplexityViolation,
7 CoverageIntelligenceFinding, CoverageIntelligenceRecommendation, CoverageIntelligenceVerdict,
8 ExceededThreshold, FindingSeverity, HealthReport, HealthSummary, RuntimeCoverageFinding,
9 RuntimeCoverageVerdict, StylingFinding, StylingFindingSeverity, UntestedExportFinding,
10 UntestedFileFinding, build_codeclimate_issue, codeclimate_fingerprint_hash, normalize_uri,
11};
12
13struct HealthCodeClimateContext<'a> {
14 root: &'a Path,
15 summary: &'a HealthSummary,
19}
20
21impl HealthCodeClimateContext<'_> {
22 fn complexity_issue(&self, finding: &ComplexityViolation) -> CodeClimateIssue {
23 let path = codeclimate_path(&finding.path, self.root);
24 let check_name = complexity_check_name(finding);
25 let line_str = finding.line.to_string();
26 let fp = codeclimate_fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
27 build_codeclimate_issue(CodeClimateIssueInput {
28 check_name,
29 description: &self.complexity_description(finding),
30 severity: health_finding_severity(finding.severity),
31 category: "Complexity",
32 path: &path,
33 begin_line: Some(finding.line),
34 fingerprint: &fp,
35 })
36 }
37
38 fn styling_issue(&self, finding: &StylingFinding) -> CodeClimateIssue {
39 let path = codeclimate_path(Path::new(&finding.path), self.root);
40 let check_name = format!("fallow/{}", finding.code);
41 let description = format!("[{}] {}: {}", finding.code, finding.sub_kind, finding.value);
42 let line_str = finding.line.to_string();
43 let fp = codeclimate_fingerprint_hash(&[
44 &check_name,
45 &path,
46 &line_str,
47 &finding.sub_kind,
48 &finding.value,
49 ]);
50 build_codeclimate_issue(CodeClimateIssueInput {
51 check_name: &check_name,
52 description: &description,
53 severity: styling_finding_severity(finding.effective_severity),
54 category: "Style",
55 path: &path,
56 begin_line: Some(finding.line),
57 fingerprint: &fp,
58 })
59 }
60
61 fn complexity_description(&self, finding: &ComplexityViolation) -> String {
62 let thresholds = finding.resolved_thresholds(self.summary);
63 match finding.exceeded {
64 ExceededThreshold::Both => format!(
65 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
66 finding.name,
67 finding.cyclomatic,
68 thresholds.max_cyclomatic,
69 finding.cognitive,
70 thresholds.max_cognitive
71 ),
72 ExceededThreshold::Cyclomatic => format!(
73 "'{}' has cyclomatic complexity {} (threshold: {})",
74 finding.name, finding.cyclomatic, thresholds.max_cyclomatic
75 ),
76 ExceededThreshold::Cognitive => format!(
77 "'{}' has cognitive complexity {} (threshold: {})",
78 finding.name, finding.cognitive, thresholds.max_cognitive
79 ),
80 ExceededThreshold::Crap
81 | ExceededThreshold::CyclomaticCrap
82 | ExceededThreshold::CognitiveCrap
83 | ExceededThreshold::All => {
84 let crap = finding.crap.unwrap_or(0.0);
85 let coverage = finding
86 .coverage_pct
87 .map(|pct| format!(", coverage {pct:.0}%"))
88 .unwrap_or_default();
89 format!(
90 "'{}' has CRAP score {crap:.1} (threshold: {:.1}, cyclomatic {}{coverage})",
91 finding.name, thresholds.max_crap, finding.cyclomatic,
92 )
93 }
94 }
95 }
96
97 fn runtime_coverage_issue(&self, finding: &RuntimeCoverageFinding) -> CodeClimateIssue {
98 let path = codeclimate_path(&finding.path, self.root);
99 let check_name = runtime_coverage_check_name(finding.verdict);
100 let invocations_hint = finding.invocations.map_or_else(
101 || "untracked".to_owned(),
102 |hits| format!("{hits} invocations"),
103 );
104 let description = format!(
105 "'{}' runtime coverage verdict: {} ({})",
106 finding.function,
107 finding.verdict.human_label(),
108 invocations_hint,
109 );
110 let fp = codeclimate_fingerprint_hash(&[
111 check_name,
112 &path,
113 &finding.line.to_string(),
114 &finding.function,
115 ]);
116 build_codeclimate_issue(CodeClimateIssueInput {
117 check_name,
118 description: &description,
119 severity: runtime_coverage_severity(finding.verdict),
120 category: "Bug Risk",
121 path: &path,
122 begin_line: Some(finding.line),
123 fingerprint: &fp,
124 })
125 }
126
127 fn coverage_intelligence_issue(
128 &self,
129 finding: &CoverageIntelligenceFinding,
130 ) -> Option<CodeClimateIssue> {
131 let severity = coverage_intelligence_severity(finding.verdict)?;
132 let path = codeclimate_path(&finding.path, self.root);
133 let check_name = coverage_intelligence_check_name(finding.recommendation);
134 let identity = finding.identity.as_deref().unwrap_or("code");
135 let description = format!(
136 "'{}' coverage intelligence verdict: {} ({})",
137 identity, finding.verdict, finding.recommendation,
138 );
139 let fp = codeclimate_fingerprint_hash(&[
140 check_name,
141 &path,
142 &finding.line.to_string(),
143 identity,
144 &finding.id,
145 ]);
146 Some(build_codeclimate_issue(CodeClimateIssueInput {
147 check_name,
148 description: &description,
149 severity,
150 category: "Bug Risk",
151 path: &path,
152 begin_line: Some(finding.line),
153 fingerprint: &fp,
154 }))
155 }
156
157 fn untested_file_issue(&self, item: &UntestedFileFinding) -> CodeClimateIssue {
158 let path = codeclimate_path(&item.file.path, self.root);
159 let description = format!(
160 "File is runtime-reachable but has no test dependency path ({} value export{})",
161 item.file.value_export_count,
162 if item.file.value_export_count == 1 {
163 ""
164 } else {
165 "s"
166 },
167 );
168 let fp = codeclimate_fingerprint_hash(&["fallow/untested-file", &path]);
169 build_codeclimate_issue(CodeClimateIssueInput {
170 check_name: "fallow/untested-file",
171 description: &description,
172 severity: CodeClimateSeverity::Minor,
173 category: "Coverage",
174 path: &path,
175 begin_line: None,
176 fingerprint: &fp,
177 })
178 }
179
180 fn untested_export_issue(&self, item: &UntestedExportFinding) -> CodeClimateIssue {
181 let path = codeclimate_path(&item.export.path, self.root);
182 let description = format!(
183 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
184 item.export.export_name
185 );
186 let line_str = item.export.line.to_string();
187 let fp = codeclimate_fingerprint_hash(&[
188 "fallow/untested-export",
189 &path,
190 &line_str,
191 &item.export.export_name,
192 ]);
193 build_codeclimate_issue(CodeClimateIssueInput {
194 check_name: "fallow/untested-export",
195 description: &description,
196 severity: CodeClimateSeverity::Minor,
197 category: "Coverage",
198 path: &path,
199 begin_line: Some(item.export.line),
200 fingerprint: &fp,
201 })
202 }
203}
204
205#[must_use]
207pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> Vec<CodeClimateIssue> {
208 let mut issues = Vec::new();
209 let ctx = HealthCodeClimateContext {
210 root,
211 summary: &report.summary,
212 };
213
214 for finding in &report.findings {
215 issues.push(ctx.complexity_issue(finding));
216 }
217 for finding in &report.styling_findings {
218 issues.push(ctx.styling_issue(finding));
219 }
220
221 if let Some(ref production) = report.runtime_coverage {
222 for finding in &production.findings {
223 issues.push(ctx.runtime_coverage_issue(finding));
224 }
225 }
226
227 if let Some(ref intelligence) = report.coverage_intelligence {
228 for finding in &intelligence.findings {
229 if let Some(issue) = ctx.coverage_intelligence_issue(finding) {
230 issues.push(issue);
231 }
232 }
233 }
234
235 if let Some(ref gaps) = report.coverage_gaps {
236 for item in &gaps.files {
237 issues.push(ctx.untested_file_issue(item));
238 }
239
240 for item in &gaps.exports {
241 issues.push(ctx.untested_export_issue(item));
242 }
243 }
244
245 issues
246}
247
248fn codeclimate_path(path: &Path, root: &Path) -> String {
249 normalize_uri(
250 &path
251 .strip_prefix(root)
252 .unwrap_or(path)
253 .display()
254 .to_string(),
255 )
256}
257
258const fn coverage_intelligence_check_name(
259 recommendation: CoverageIntelligenceRecommendation,
260) -> &'static str {
261 match recommendation {
262 CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
263 "fallow/coverage-intelligence-risky-change"
264 }
265 CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
266 "fallow/coverage-intelligence-delete"
267 }
268 CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
269 "fallow/coverage-intelligence-review"
270 }
271 CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
272 "fallow/coverage-intelligence-refactor"
273 }
274 }
275}
276
277const fn complexity_check_name(finding: &ComplexityViolation) -> &'static str {
278 match finding.exceeded {
279 ExceededThreshold::Both => "fallow/high-complexity",
280 ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
281 ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
282 ExceededThreshold::Crap
283 | ExceededThreshold::CyclomaticCrap
284 | ExceededThreshold::CognitiveCrap
285 | ExceededThreshold::All => "fallow/high-crap-score",
286 }
287}
288
289const fn health_finding_severity(severity: FindingSeverity) -> CodeClimateSeverity {
290 match severity {
291 FindingSeverity::Critical => CodeClimateSeverity::Critical,
292 FindingSeverity::High => CodeClimateSeverity::Major,
293 FindingSeverity::Moderate => CodeClimateSeverity::Minor,
294 }
295}
296
297const fn styling_finding_severity(severity: StylingFindingSeverity) -> CodeClimateSeverity {
298 match severity {
299 StylingFindingSeverity::Error => CodeClimateSeverity::Major,
300 StylingFindingSeverity::Warn => CodeClimateSeverity::Minor,
301 }
302}
303
304const fn runtime_coverage_check_name(verdict: RuntimeCoverageVerdict) -> &'static str {
305 match verdict {
306 RuntimeCoverageVerdict::SafeToDelete => "fallow/runtime-safe-to-delete",
307 RuntimeCoverageVerdict::ReviewRequired => "fallow/runtime-review-required",
308 RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
309 RuntimeCoverageVerdict::CoverageUnavailable => "fallow/runtime-coverage-unavailable",
310 RuntimeCoverageVerdict::Active | RuntimeCoverageVerdict::Unknown => {
311 "fallow/runtime-coverage"
312 }
313 }
314}
315
316const fn runtime_coverage_severity(verdict: RuntimeCoverageVerdict) -> CodeClimateSeverity {
317 match verdict {
318 RuntimeCoverageVerdict::SafeToDelete => CodeClimateSeverity::Critical,
319 RuntimeCoverageVerdict::ReviewRequired => CodeClimateSeverity::Major,
320 _ => CodeClimateSeverity::Minor,
321 }
322}
323
324const fn coverage_intelligence_severity(
325 verdict: CoverageIntelligenceVerdict,
326) -> Option<CodeClimateSeverity> {
327 match verdict {
328 CoverageIntelligenceVerdict::RiskyChangeDetected
329 | CoverageIntelligenceVerdict::HighConfidenceDelete => Some(CodeClimateSeverity::Major),
330 CoverageIntelligenceVerdict::ReviewRequired
331 | CoverageIntelligenceVerdict::RefactorCarefully => Some(CodeClimateSeverity::Minor),
332 CoverageIntelligenceVerdict::Clean | CoverageIntelligenceVerdict::Unknown => None,
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use std::path::{Path, PathBuf};
339
340 use fallow_output::{
341 ComplexityViolation, ExceededThreshold, FindingSeverity, HealthReport, HealthSummary,
342 StylingFinding, StylingFindingSeverity,
343 };
344
345 use super::*;
346
347 #[test]
348 fn health_codeclimate_uses_relative_normalized_paths() {
349 let report = HealthReport {
350 summary: HealthSummary {
351 max_cyclomatic_threshold: 10,
352 max_cognitive_threshold: 8,
353 max_crap_threshold: 30.0,
354 ..HealthSummary::default()
355 },
356 findings: vec![
357 ComplexityViolation {
358 path: PathBuf::from("/root/app/[id]/page.tsx"),
359 name: "render".to_string(),
360 line: 7,
361 col: 0,
362 cyclomatic: 12,
363 cognitive: 9,
364 line_count: 20,
365 param_count: 1,
366 react_hook_count: 0,
367 react_jsx_max_depth: 0,
368 react_prop_count: 0,
369 react_hook_profile: None,
370 exceeded: ExceededThreshold::Both,
371 severity: FindingSeverity::High,
372 coverage_pct: None,
373 crap: None,
374 coverage_tier: None,
375 coverage_source: None,
376 inherited_from: None,
377 component_rollup: None,
378 contributions: Vec::new(),
379 effective_thresholds: None,
380 threshold_source: None,
381 }
382 .into(),
383 ],
384 ..HealthReport::default()
385 };
386
387 let issues = build_health_codeclimate(&report, Path::new("/root"));
388
389 assert_eq!(issues.len(), 1);
390 let issue = &issues[0];
391 assert_eq!(issue.check_name, "fallow/high-complexity");
392 assert_eq!(issue.location.path, "app/%5Bid%5D/page.tsx");
393 assert_eq!(issue.location.lines.begin, 7);
394 assert_eq!(issue.severity, CodeClimateSeverity::Major);
395 }
396
397 #[test]
398 fn health_codeclimate_includes_styling_findings() {
399 let report = HealthReport {
400 styling_findings: vec![StylingFinding {
401 code: "css-selector-complexity".to_string(),
402 sub_kind: "high-specificity".to_string(),
403 path: "src/styles.css".to_string(),
404 line: 4,
405 value: "#app .card .title".to_string(),
406 effective_severity: StylingFindingSeverity::Error,
407 blast_radius: None,
408 confidence: None,
409 agent_disposition: None,
410 nearest_token: None,
411 fix_hint: None,
412 actions: Vec::new(),
413 }],
414 ..HealthReport::default()
415 };
416
417 let issues = build_health_codeclimate(&report, Path::new("/root"));
418
419 assert_eq!(issues.len(), 1);
420 let issue = &issues[0];
421 assert_eq!(issue.check_name, "fallow/css-selector-complexity");
422 assert_eq!(issue.location.path, "src/styles.css");
423 assert_eq!(issue.location.lines.begin, 4);
424 assert_eq!(issue.severity, CodeClimateSeverity::Major);
425 }
426
427 fn crap_violation(
428 effective_thresholds: Option<fallow_output::HealthEffectiveThresholds>,
429 ) -> ComplexityViolation {
430 ComplexityViolation {
431 path: PathBuf::from("/root/src/Board.astro"),
432 name: "<template>".to_string(),
433 line: 6,
434 col: 3,
435 cyclomatic: 11,
436 cognitive: 4,
437 line_count: 20,
438 param_count: 0,
439 react_hook_count: 0,
440 react_jsx_max_depth: 0,
441 react_prop_count: 0,
442 react_hook_profile: None,
443 exceeded: ExceededThreshold::Crap,
444 severity: FindingSeverity::Critical,
445 coverage_pct: None,
446 crap: Some(132.0),
447 coverage_tier: None,
448 coverage_source: None,
449 inherited_from: None,
450 component_rollup: None,
451 contributions: Vec::new(),
452 threshold_source: effective_thresholds
453 .map(|_| fallow_output::ThresholdSource::Override),
454 effective_thresholds,
455 }
456 }
457
458 fn crap_report(
459 effective_thresholds: Option<fallow_output::HealthEffectiveThresholds>,
460 ) -> HealthReport {
461 HealthReport {
462 summary: HealthSummary {
463 max_crap_threshold: 30.0,
464 ..HealthSummary::default()
465 },
466 findings: vec![crap_violation(effective_thresholds).into()],
467 ..HealthReport::default()
468 }
469 }
470
471 #[test]
475 fn codeclimate_description_uses_the_override_ceiling_not_the_global_one() {
476 let report = crap_report(Some(fallow_output::HealthEffectiveThresholds {
477 max_cyclomatic: 20,
478 max_cognitive: 15,
479 max_crap: 100.0,
480 max_unit_size: 60,
481 }));
482
483 let issues = build_health_codeclimate(&report, Path::new("/root"));
484
485 assert_eq!(issues.len(), 1);
486 assert!(
487 issues[0].description.contains("threshold: 100.0"),
488 "{}",
489 issues[0].description
490 );
491 assert!(
492 !issues[0].description.contains("threshold: 30.0"),
493 "{}",
494 issues[0].description
495 );
496 }
497
498 #[test]
499 fn codeclimate_description_falls_back_to_the_global_ceiling() {
500 let issues = build_health_codeclimate(&crap_report(None), Path::new("/root"));
501
502 assert_eq!(issues.len(), 1);
503 assert!(
504 issues[0].description.contains("threshold: 30.0"),
505 "{}",
506 issues[0].description
507 );
508 }
509}