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
510 #[test]
511 fn health_severities_reach_serialized_codeclimate_issues() {
512 for (severity, expected) in [
513 (FindingSeverity::Critical, "critical"),
514 (FindingSeverity::High, "major"),
515 (FindingSeverity::Moderate, "minor"),
516 ] {
517 let mut violation = crap_violation(None);
518 violation.severity = severity;
519 let report = HealthReport {
520 findings: vec![violation.into()],
521 ..HealthReport::default()
522 };
523 let issues = fallow_output::codeclimate_issues_to_value(&build_health_codeclimate(
524 &report,
525 Path::new("/root"),
526 ));
527 let issues = issues
528 .as_array()
529 .expect("CodeClimate output must be an array");
530 assert_eq!(issues.len(), 1);
531 assert_eq!(issues[0]["severity"], expected);
532 assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
533 }
534 }
535
536 #[test]
537 fn runtime_verdicts_reach_serialized_codeclimate_issues() {
538 use fallow_output::{
539 RuntimeCoverageConfidence, RuntimeCoverageEvidence, RuntimeCoverageReport,
540 };
541
542 for (verdict, check_name, severity) in [
543 (
544 RuntimeCoverageVerdict::SafeToDelete,
545 "fallow/runtime-safe-to-delete",
546 "critical",
547 ),
548 (
549 RuntimeCoverageVerdict::ReviewRequired,
550 "fallow/runtime-review-required",
551 "major",
552 ),
553 (
554 RuntimeCoverageVerdict::LowTraffic,
555 "fallow/runtime-low-traffic",
556 "minor",
557 ),
558 (
559 RuntimeCoverageVerdict::CoverageUnavailable,
560 "fallow/runtime-coverage-unavailable",
561 "minor",
562 ),
563 (
564 RuntimeCoverageVerdict::Active,
565 "fallow/runtime-coverage",
566 "minor",
567 ),
568 (
569 RuntimeCoverageVerdict::Unknown,
570 "fallow/runtime-coverage",
571 "minor",
572 ),
573 ] {
574 let report = HealthReport {
575 runtime_coverage: Some(RuntimeCoverageReport {
576 findings: vec![RuntimeCoverageFinding {
577 id: "runtime-fixture".to_string(),
578 stable_id: None,
579 source_hash: None,
580 path: PathBuf::from("/root/src/legacy.ts"),
581 function: "legacyHelper".to_string(),
582 line: 12,
583 verdict,
584 invocations: Some(0),
585 confidence: RuntimeCoverageConfidence::High,
586 evidence: RuntimeCoverageEvidence {
587 static_status: "unused".to_string(),
588 test_coverage: "not_covered".to_string(),
589 test_only_reference: None,
590 v8_tracking: "tracked".to_string(),
591 untracked_reason: None,
592 observation_days: 30,
593 deployments_observed: 3,
594 },
595 actions: Vec::new(),
596 discriminators: None,
597 }],
598 ..RuntimeCoverageReport::default()
599 }),
600 ..HealthReport::default()
601 };
602 let issues = fallow_output::codeclimate_issues_to_value(&build_health_codeclimate(
603 &report,
604 Path::new("/root"),
605 ));
606 let issues = issues
607 .as_array()
608 .expect("CodeClimate output must be an array");
609 assert_eq!(issues.len(), 1, "{verdict:?}");
610 assert_eq!(issues[0]["check_name"], check_name);
611 assert_eq!(issues[0]["severity"], severity);
612 assert_eq!(issues[0]["location"]["path"], "src/legacy.ts");
613 assert_eq!(issues[0]["location"]["lines"]["begin"], 12);
614 }
615 }
616
617 #[test]
618 fn coverage_intelligence_verdicts_and_recommendations_reach_serialized_issues() {
619 use fallow_output::{
620 CoverageIntelligenceConfidence, CoverageIntelligenceEvidence,
621 CoverageIntelligenceReport,
622 };
623
624 let recommendations = [
625 (
626 CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge,
627 "fallow/coverage-intelligence-risky-change",
628 ),
629 (
630 CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
631 "fallow/coverage-intelligence-delete",
632 ),
633 (
634 CoverageIntelligenceRecommendation::ReviewBeforeChanging,
635 "fallow/coverage-intelligence-review",
636 ),
637 (
638 CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior,
639 "fallow/coverage-intelligence-refactor",
640 ),
641 ];
642 for (verdict, severity) in [
643 (
644 CoverageIntelligenceVerdict::RiskyChangeDetected,
645 Some("major"),
646 ),
647 (
648 CoverageIntelligenceVerdict::HighConfidenceDelete,
649 Some("major"),
650 ),
651 (CoverageIntelligenceVerdict::ReviewRequired, Some("minor")),
652 (
653 CoverageIntelligenceVerdict::RefactorCarefully,
654 Some("minor"),
655 ),
656 (CoverageIntelligenceVerdict::Clean, None),
657 (CoverageIntelligenceVerdict::Unknown, None),
658 ] {
659 for (recommendation, check_name) in recommendations {
660 let report = HealthReport {
661 coverage_intelligence: Some(CoverageIntelligenceReport {
662 schema_version: fallow_output::CoverageIntelligenceSchemaVersion::default(),
663 verdict,
664 summary: fallow_output::CoverageIntelligenceSummary::default(),
665 findings: vec![CoverageIntelligenceFinding {
666 id: "coverage-fixture".to_string(),
667 path: PathBuf::from("/root/src/legacy.ts"),
668 identity: Some("legacyHelper".to_string()),
669 line: 12,
670 verdict,
671 signals: Vec::new(),
672 recommendation,
673 confidence: CoverageIntelligenceConfidence::High,
674 related_ids: Vec::new(),
675 evidence: CoverageIntelligenceEvidence::default(),
676 actions: Vec::new(),
677 }],
678 }),
679 ..HealthReport::default()
680 };
681 let issues = fallow_output::codeclimate_issues_to_value(&build_health_codeclimate(
682 &report,
683 Path::new("/root"),
684 ));
685 let issues = issues
686 .as_array()
687 .expect("CodeClimate output must be an array");
688 if let Some(severity) = severity {
689 assert_eq!(issues.len(), 1, "{verdict:?}");
690 assert_eq!(issues[0]["check_name"], check_name);
691 assert_eq!(issues[0]["severity"], severity);
692 assert_eq!(issues[0]["location"]["path"], "src/legacy.ts");
693 } else {
694 assert!(issues.is_empty(), "{verdict:?}");
695 }
696 }
697 }
698 }
699}