Skip to main content

fallow_api/
health_codeclimate.rs

1//! Shared health CodeClimate issue construction.
2
3use 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};
12use fallow_types::output_dead_code::EffectiveSeverity;
13
14struct HealthCodeClimateContext<'a> {
15    root: &'a Path,
16    /// Held instead of the three run-global ceilings so every description
17    /// resolves per finding: an override-affected finding must never be
18    /// described against a ceiling it was not measured with (issue #2163).
19    summary: &'a HealthSummary,
20}
21
22impl HealthCodeClimateContext<'_> {
23    fn complexity_issue(&self, finding: &ComplexityViolation) -> CodeClimateIssue {
24        let path = codeclimate_path(&finding.path, self.root);
25        let check_name = complexity_check_name(finding);
26        let line_str = finding.line.to_string();
27        let fp = codeclimate_fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
28        build_codeclimate_issue(CodeClimateIssueInput {
29            check_name,
30            description: &self.complexity_description(finding),
31            severity: complexity_codeclimate_severity(finding),
32            category: "Complexity",
33            path: &path,
34            begin_line: Some(finding.line),
35            fingerprint: &fp,
36        })
37    }
38
39    fn styling_issue(&self, finding: &StylingFinding) -> CodeClimateIssue {
40        let path = codeclimate_path(Path::new(&finding.path), self.root);
41        let check_name = format!("fallow/{}", finding.code);
42        let description = format!("[{}] {}: {}", finding.code, finding.sub_kind, finding.value);
43        let line_str = finding.line.to_string();
44        let fp = codeclimate_fingerprint_hash(&[
45            &check_name,
46            &path,
47            &line_str,
48            &finding.sub_kind,
49            &finding.value,
50        ]);
51        build_codeclimate_issue(CodeClimateIssueInput {
52            check_name: &check_name,
53            description: &description,
54            severity: styling_finding_severity(finding.effective_severity),
55            category: "Style",
56            path: &path,
57            begin_line: Some(finding.line),
58            fingerprint: &fp,
59        })
60    }
61
62    fn complexity_description(&self, finding: &ComplexityViolation) -> String {
63        let thresholds = finding.resolved_thresholds(self.summary);
64        match finding.exceeded {
65            ExceededThreshold::Both => format!(
66                "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
67                finding.name,
68                finding.cyclomatic,
69                thresholds.max_cyclomatic,
70                finding.cognitive,
71                thresholds.max_cognitive
72            ),
73            ExceededThreshold::Cyclomatic => format!(
74                "'{}' has cyclomatic complexity {} (threshold: {})",
75                finding.name, finding.cyclomatic, thresholds.max_cyclomatic
76            ),
77            ExceededThreshold::Cognitive => format!(
78                "'{}' has cognitive complexity {} (threshold: {})",
79                finding.name, finding.cognitive, thresholds.max_cognitive
80            ),
81            ExceededThreshold::Crap
82            | ExceededThreshold::CyclomaticCrap
83            | ExceededThreshold::CognitiveCrap
84            | ExceededThreshold::All => {
85                let crap = finding.crap.unwrap_or(0.0);
86                let coverage = finding
87                    .coverage_pct
88                    .map(|pct| format!(", coverage {pct:.0}%"))
89                    .unwrap_or_default();
90                format!(
91                    "'{}' has CRAP score {crap:.1} (threshold: {:.1}, cyclomatic {}{coverage})",
92                    finding.name, thresholds.max_crap, finding.cyclomatic,
93                )
94            }
95        }
96    }
97
98    fn runtime_coverage_issue(&self, finding: &RuntimeCoverageFinding) -> CodeClimateIssue {
99        let path = codeclimate_path(&finding.path, self.root);
100        let check_name = runtime_coverage_check_name(finding.verdict);
101        let invocations_hint = finding.invocations.map_or_else(
102            || "untracked".to_owned(),
103            |hits| format!("{hits} invocations"),
104        );
105        let description = format!(
106            "'{}' runtime coverage verdict: {} ({})",
107            finding.function,
108            finding.verdict.human_label(),
109            invocations_hint,
110        );
111        let fp = codeclimate_fingerprint_hash(&[
112            check_name,
113            &path,
114            &finding.line.to_string(),
115            &finding.function,
116        ]);
117        build_codeclimate_issue(CodeClimateIssueInput {
118            check_name,
119            description: &description,
120            severity: runtime_coverage_severity(finding.verdict),
121            category: "Bug Risk",
122            path: &path,
123            begin_line: Some(finding.line),
124            fingerprint: &fp,
125        })
126    }
127
128    fn coverage_intelligence_issue(
129        &self,
130        finding: &CoverageIntelligenceFinding,
131    ) -> Option<CodeClimateIssue> {
132        let severity = coverage_intelligence_severity(finding.verdict)?;
133        let path = codeclimate_path(&finding.path, self.root);
134        let check_name = coverage_intelligence_check_name(finding.recommendation);
135        let identity = finding.identity.as_deref().unwrap_or("code");
136        let description = format!(
137            "'{}' coverage intelligence verdict: {} ({})",
138            identity, finding.verdict, finding.recommendation,
139        );
140        let fp = codeclimate_fingerprint_hash(&[
141            check_name,
142            &path,
143            &finding.line.to_string(),
144            identity,
145            &finding.id,
146        ]);
147        Some(build_codeclimate_issue(CodeClimateIssueInput {
148            check_name,
149            description: &description,
150            severity,
151            category: "Bug Risk",
152            path: &path,
153            begin_line: Some(finding.line),
154            fingerprint: &fp,
155        }))
156    }
157
158    fn untested_file_issue(&self, item: &UntestedFileFinding) -> CodeClimateIssue {
159        let path = codeclimate_path(&item.file.path, self.root);
160        let description = format!(
161            "File is runtime-reachable but has no test dependency path ({} value export{})",
162            item.file.value_export_count,
163            if item.file.value_export_count == 1 {
164                ""
165            } else {
166                "s"
167            },
168        );
169        let fp = codeclimate_fingerprint_hash(&["fallow/untested-file", &path]);
170        build_codeclimate_issue(CodeClimateIssueInput {
171            check_name: "fallow/untested-file",
172            description: &description,
173            severity: CodeClimateSeverity::Minor,
174            category: "Coverage",
175            path: &path,
176            begin_line: None,
177            fingerprint: &fp,
178        })
179    }
180
181    fn untested_export_issue(&self, item: &UntestedExportFinding) -> CodeClimateIssue {
182        let path = codeclimate_path(&item.export.path, self.root);
183        let description = format!(
184            "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
185            item.export.export_name
186        );
187        let line_str = item.export.line.to_string();
188        let fp = codeclimate_fingerprint_hash(&[
189            "fallow/untested-export",
190            &path,
191            &line_str,
192            &item.export.export_name,
193        ]);
194        build_codeclimate_issue(CodeClimateIssueInput {
195            check_name: "fallow/untested-export",
196            description: &description,
197            severity: CodeClimateSeverity::Minor,
198            category: "Coverage",
199            path: &path,
200            begin_line: Some(item.export.line),
201            fingerprint: &fp,
202        })
203    }
204}
205
206/// Build CodeClimate issues from health / complexity analysis results.
207#[must_use]
208pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> Vec<CodeClimateIssue> {
209    let mut issues = Vec::new();
210    let ctx = HealthCodeClimateContext {
211        root,
212        summary: &report.summary,
213    };
214
215    for finding in &report.findings {
216        let mut issue = ctx.complexity_issue(finding);
217        // Audit only: name the measured values that grew against the base
218        // finding. The fingerprint does not read the description.
219        if !finding.worsened_metrics.is_empty() {
220            let class =
221                if finding.attribution == Some(fallow_output::AuditFindingAttribution::Worsened) {
222                    "worsened"
223                } else {
224                    "introduced"
225                };
226            issue.description = format!(
227                "{} ({class} against the base: {})",
228                issue.description,
229                fallow_output::describe_worsened_metrics(&finding.worsened_metrics)
230            );
231        }
232        if finding.gate_exemption == Some(fallow_output::AuditGateExemption::TestFile) {
233            issue.description =
234                format!("{} (test file: audit does not gate it)", issue.description);
235        }
236        issues.push(issue);
237    }
238    for finding in &report.styling_findings {
239        issues.push(ctx.styling_issue(finding));
240    }
241
242    if let Some(ref production) = report.runtime_coverage {
243        for finding in &production.findings {
244            issues.push(ctx.runtime_coverage_issue(finding));
245        }
246    }
247
248    if let Some(ref intelligence) = report.coverage_intelligence {
249        for finding in &intelligence.findings {
250            if let Some(issue) = ctx.coverage_intelligence_issue(finding) {
251                issues.push(issue);
252            }
253        }
254    }
255
256    if let Some(ref gaps) = report.coverage_gaps {
257        for item in &gaps.files {
258            issues.push(ctx.untested_file_issue(item));
259        }
260
261        for item in &gaps.exports {
262            issues.push(ctx.untested_export_issue(item));
263        }
264    }
265
266    issues
267}
268
269fn codeclimate_path(path: &Path, root: &Path) -> String {
270    normalize_uri(
271        &path
272            .strip_prefix(root)
273            .unwrap_or(path)
274            .display()
275            .to_string(),
276    )
277}
278
279const fn coverage_intelligence_check_name(
280    recommendation: CoverageIntelligenceRecommendation,
281) -> &'static str {
282    match recommendation {
283        CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
284            "fallow/coverage-intelligence-risky-change"
285        }
286        CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
287            "fallow/coverage-intelligence-delete"
288        }
289        CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
290            "fallow/coverage-intelligence-review"
291        }
292        CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
293            "fallow/coverage-intelligence-refactor"
294        }
295    }
296}
297
298const fn complexity_check_name(finding: &ComplexityViolation) -> &'static str {
299    match finding.exceeded {
300        ExceededThreshold::Both => "fallow/high-complexity",
301        ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
302        ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
303        ExceededThreshold::Crap
304        | ExceededThreshold::CyclomaticCrap
305        | ExceededThreshold::CognitiveCrap
306        | ExceededThreshold::All => "fallow/high-crap-score",
307    }
308}
309
310/// The CodeClimate severity of a complexity finding.
311///
312/// The gate severity from the `complexity-*` rules sets it: `error` gives
313/// `major` and `warn` gives `minor`, as for dead-code and styling findings.
314/// A finding without a gate severity (a saved report from an older version)
315/// keeps the band mapping.
316const fn complexity_codeclimate_severity(finding: &ComplexityViolation) -> CodeClimateSeverity {
317    match finding.effective_severity {
318        Some(EffectiveSeverity::Error) => CodeClimateSeverity::Major,
319        Some(EffectiveSeverity::Warn) => CodeClimateSeverity::Minor,
320        None => health_finding_severity(finding.severity),
321    }
322}
323
324const fn health_finding_severity(severity: FindingSeverity) -> CodeClimateSeverity {
325    match severity {
326        FindingSeverity::Critical => CodeClimateSeverity::Critical,
327        FindingSeverity::High => CodeClimateSeverity::Major,
328        FindingSeverity::Moderate => CodeClimateSeverity::Minor,
329    }
330}
331
332const fn styling_finding_severity(severity: StylingFindingSeverity) -> CodeClimateSeverity {
333    match severity {
334        StylingFindingSeverity::Error => CodeClimateSeverity::Major,
335        StylingFindingSeverity::Warn => CodeClimateSeverity::Minor,
336    }
337}
338
339const fn runtime_coverage_check_name(verdict: RuntimeCoverageVerdict) -> &'static str {
340    match verdict {
341        RuntimeCoverageVerdict::SafeToDelete => "fallow/runtime-safe-to-delete",
342        RuntimeCoverageVerdict::ReviewRequired => "fallow/runtime-review-required",
343        RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
344        RuntimeCoverageVerdict::CoverageUnavailable => "fallow/runtime-coverage-unavailable",
345        RuntimeCoverageVerdict::Active | RuntimeCoverageVerdict::Unknown => {
346            "fallow/runtime-coverage"
347        }
348    }
349}
350
351const fn runtime_coverage_severity(verdict: RuntimeCoverageVerdict) -> CodeClimateSeverity {
352    match verdict {
353        RuntimeCoverageVerdict::SafeToDelete => CodeClimateSeverity::Critical,
354        RuntimeCoverageVerdict::ReviewRequired => CodeClimateSeverity::Major,
355        _ => CodeClimateSeverity::Minor,
356    }
357}
358
359const fn coverage_intelligence_severity(
360    verdict: CoverageIntelligenceVerdict,
361) -> Option<CodeClimateSeverity> {
362    match verdict {
363        CoverageIntelligenceVerdict::RiskyChangeDetected
364        | CoverageIntelligenceVerdict::HighConfidenceDelete => Some(CodeClimateSeverity::Major),
365        CoverageIntelligenceVerdict::ReviewRequired
366        | CoverageIntelligenceVerdict::RefactorCarefully => Some(CodeClimateSeverity::Minor),
367        CoverageIntelligenceVerdict::Clean | CoverageIntelligenceVerdict::Unknown => None,
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use std::path::{Path, PathBuf};
374
375    use fallow_output::{
376        ComplexityViolation, ExceededThreshold, FindingSeverity, HealthReport, HealthSummary,
377        StylingFinding, StylingFindingSeverity,
378    };
379
380    use super::*;
381
382    #[test]
383    fn health_codeclimate_uses_relative_normalized_paths() {
384        let report = HealthReport {
385            summary: HealthSummary {
386                max_cyclomatic_threshold: 10,
387                max_cognitive_threshold: 8,
388                max_crap_threshold: 30.0,
389                ..HealthSummary::default()
390            },
391            findings: vec![
392                ComplexityViolation {
393                    path: PathBuf::from("/root/app/[id]/page.tsx"),
394                    name: "render".to_string(),
395                    line: 7,
396                    col: 0,
397                    cyclomatic: 12,
398                    standard_cyclomatic: None,
399                    cognitive: 9,
400                    line_count: 20,
401                    param_count: 1,
402                    react_hook_count: 0,
403                    react_jsx_max_depth: 0,
404                    react_prop_count: 0,
405                    react_hook_profile: None,
406                    exceeded: ExceededThreshold::Both,
407                    effective_severity: None,
408                    severity: FindingSeverity::High,
409                    mutation: None,
410                    coverage_pct: None,
411                    crap: None,
412                    coverage_tier: None,
413                    coverage_source: None,
414                    inherited_from: None,
415                    component_rollup: None,
416                    contributions: Vec::new(),
417                    effective_thresholds: None,
418                    threshold_source: None,
419                }
420                .into(),
421            ],
422            ..HealthReport::default()
423        };
424
425        let issues = build_health_codeclimate(&report, Path::new("/root"));
426
427        assert_eq!(issues.len(), 1);
428        let issue = &issues[0];
429        assert_eq!(issue.check_name, "fallow/high-complexity");
430        assert_eq!(issue.location.path, "app/%5Bid%5D/page.tsx");
431        assert_eq!(issue.location.lines.begin, 7);
432        assert_eq!(issue.severity, CodeClimateSeverity::Major);
433    }
434
435    #[test]
436    fn health_codeclimate_includes_styling_findings() {
437        let report = HealthReport {
438            styling_findings: vec![StylingFinding {
439                code: "css-selector-complexity".to_string(),
440                sub_kind: "high-specificity".to_string(),
441                path: "src/styles.css".to_string(),
442                line: 4,
443                value: "#app .card .title".to_string(),
444                effective_severity: StylingFindingSeverity::Error,
445                blast_radius: None,
446                confidence: None,
447                agent_disposition: None,
448                nearest_token: None,
449                fix_hint: None,
450                actions: Vec::new(),
451            }],
452            ..HealthReport::default()
453        };
454
455        let issues = build_health_codeclimate(&report, Path::new("/root"));
456
457        assert_eq!(issues.len(), 1);
458        let issue = &issues[0];
459        assert_eq!(issue.check_name, "fallow/css-selector-complexity");
460        assert_eq!(issue.location.path, "src/styles.css");
461        assert_eq!(issue.location.lines.begin, 4);
462        assert_eq!(issue.severity, CodeClimateSeverity::Major);
463    }
464
465    fn crap_violation(
466        effective_thresholds: Option<fallow_output::HealthEffectiveThresholds>,
467    ) -> ComplexityViolation {
468        ComplexityViolation {
469            path: PathBuf::from("/root/src/Board.astro"),
470            name: "<template>".to_string(),
471            line: 6,
472            col: 3,
473            cyclomatic: 11,
474            standard_cyclomatic: None,
475            cognitive: 4,
476            line_count: 20,
477            param_count: 0,
478            react_hook_count: 0,
479            react_jsx_max_depth: 0,
480            react_prop_count: 0,
481            react_hook_profile: None,
482            exceeded: ExceededThreshold::Crap,
483            effective_severity: None,
484            severity: FindingSeverity::Critical,
485            mutation: None,
486            coverage_pct: None,
487            crap: Some(132.0),
488            coverage_tier: None,
489            coverage_source: None,
490            inherited_from: None,
491            component_rollup: None,
492            contributions: Vec::new(),
493            threshold_source: effective_thresholds
494                .map(|_| fallow_output::ThresholdSource::Override),
495            effective_thresholds,
496        }
497    }
498
499    fn crap_report(
500        effective_thresholds: Option<fallow_output::HealthEffectiveThresholds>,
501    ) -> HealthReport {
502        HealthReport {
503            summary: HealthSummary {
504                max_crap_threshold: 30.0,
505                ..HealthSummary::default()
506            },
507            findings: vec![crap_violation(effective_thresholds).into()],
508            ..HealthReport::default()
509        }
510    }
511
512    /// The CodeClimate description feeds `pr-comment-*` and `review-*` too, so
513    /// it must quote the ceiling the finding was measured with, matching SARIF
514    /// (issue #2163).
515    #[test]
516    fn codeclimate_description_uses_the_override_ceiling_not_the_global_one() {
517        let report = crap_report(Some(fallow_output::HealthEffectiveThresholds {
518            max_cyclomatic: 20,
519            max_cognitive: 15,
520            max_crap: 100.0,
521            max_unit_size: 60,
522        }));
523
524        let issues = build_health_codeclimate(&report, Path::new("/root"));
525
526        assert_eq!(issues.len(), 1);
527        assert!(
528            issues[0].description.contains("threshold: 100.0"),
529            "{}",
530            issues[0].description
531        );
532        assert!(
533            !issues[0].description.contains("threshold: 30.0"),
534            "{}",
535            issues[0].description
536        );
537    }
538
539    #[test]
540    fn codeclimate_description_falls_back_to_the_global_ceiling() {
541        let issues = build_health_codeclimate(&crap_report(None), Path::new("/root"));
542
543        assert_eq!(issues.len(), 1);
544        assert!(
545            issues[0].description.contains("threshold: 30.0"),
546            "{}",
547            issues[0].description
548        );
549    }
550
551    #[test]
552    fn health_severities_reach_serialized_codeclimate_issues() {
553        for (severity, expected) in [
554            (FindingSeverity::Critical, "critical"),
555            (FindingSeverity::High, "major"),
556            (FindingSeverity::Moderate, "minor"),
557        ] {
558            let mut violation = crap_violation(None);
559            violation.severity = severity;
560            let report = HealthReport {
561                findings: vec![violation.into()],
562                ..HealthReport::default()
563            };
564            let issues = fallow_output::codeclimate_issues_to_value(&build_health_codeclimate(
565                &report,
566                Path::new("/root"),
567            ));
568            let issues = issues
569                .as_array()
570                .expect("CodeClimate output must be an array");
571            assert_eq!(issues.len(), 1);
572            assert_eq!(issues[0]["severity"], expected);
573            assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
574        }
575    }
576
577    #[test]
578    fn runtime_verdicts_reach_serialized_codeclimate_issues() {
579        use fallow_output::{
580            RuntimeCoverageConfidence, RuntimeCoverageEvidence, RuntimeCoverageReport,
581        };
582
583        for (verdict, check_name, severity) in [
584            (
585                RuntimeCoverageVerdict::SafeToDelete,
586                "fallow/runtime-safe-to-delete",
587                "critical",
588            ),
589            (
590                RuntimeCoverageVerdict::ReviewRequired,
591                "fallow/runtime-review-required",
592                "major",
593            ),
594            (
595                RuntimeCoverageVerdict::LowTraffic,
596                "fallow/runtime-low-traffic",
597                "minor",
598            ),
599            (
600                RuntimeCoverageVerdict::CoverageUnavailable,
601                "fallow/runtime-coverage-unavailable",
602                "minor",
603            ),
604            (
605                RuntimeCoverageVerdict::Active,
606                "fallow/runtime-coverage",
607                "minor",
608            ),
609            (
610                RuntimeCoverageVerdict::Unknown,
611                "fallow/runtime-coverage",
612                "minor",
613            ),
614        ] {
615            let report = HealthReport {
616                runtime_coverage: Some(RuntimeCoverageReport {
617                    findings: vec![RuntimeCoverageFinding {
618                        id: "runtime-fixture".to_string(),
619                        stable_id: None,
620                        source_hash: None,
621                        path: PathBuf::from("/root/src/legacy.ts"),
622                        function: "legacyHelper".to_string(),
623                        line: 12,
624                        verdict,
625                        invocations: Some(0),
626                        confidence: RuntimeCoverageConfidence::High,
627                        evidence: RuntimeCoverageEvidence {
628                            static_status: "unused".to_string(),
629                            test_coverage: "not_covered".to_string(),
630                            test_only_reference: None,
631                            v8_tracking: "tracked".to_string(),
632                            untracked_reason: None,
633                            observation_days: 30,
634                            deployments_observed: 3,
635                        },
636                        actions: Vec::new(),
637                        discriminators: None,
638                    }],
639                    ..RuntimeCoverageReport::default()
640                }),
641                ..HealthReport::default()
642            };
643            let issues = fallow_output::codeclimate_issues_to_value(&build_health_codeclimate(
644                &report,
645                Path::new("/root"),
646            ));
647            let issues = issues
648                .as_array()
649                .expect("CodeClimate output must be an array");
650            assert_eq!(issues.len(), 1, "{verdict:?}");
651            assert_eq!(issues[0]["check_name"], check_name);
652            assert_eq!(issues[0]["severity"], severity);
653            assert_eq!(issues[0]["location"]["path"], "src/legacy.ts");
654            assert_eq!(issues[0]["location"]["lines"]["begin"], 12);
655        }
656    }
657
658    #[test]
659    fn coverage_intelligence_verdicts_and_recommendations_reach_serialized_issues() {
660        use fallow_output::{
661            CoverageIntelligenceConfidence, CoverageIntelligenceEvidence,
662            CoverageIntelligenceReport,
663        };
664
665        let recommendations = [
666            (
667                CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge,
668                "fallow/coverage-intelligence-risky-change",
669            ),
670            (
671                CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
672                "fallow/coverage-intelligence-delete",
673            ),
674            (
675                CoverageIntelligenceRecommendation::ReviewBeforeChanging,
676                "fallow/coverage-intelligence-review",
677            ),
678            (
679                CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior,
680                "fallow/coverage-intelligence-refactor",
681            ),
682        ];
683        for (verdict, severity) in [
684            (
685                CoverageIntelligenceVerdict::RiskyChangeDetected,
686                Some("major"),
687            ),
688            (
689                CoverageIntelligenceVerdict::HighConfidenceDelete,
690                Some("major"),
691            ),
692            (CoverageIntelligenceVerdict::ReviewRequired, Some("minor")),
693            (
694                CoverageIntelligenceVerdict::RefactorCarefully,
695                Some("minor"),
696            ),
697            (CoverageIntelligenceVerdict::Clean, None),
698            (CoverageIntelligenceVerdict::Unknown, None),
699        ] {
700            for (recommendation, check_name) in recommendations {
701                let report = HealthReport {
702                    coverage_intelligence: Some(CoverageIntelligenceReport {
703                        schema_version: fallow_output::CoverageIntelligenceSchemaVersion::default(),
704                        verdict,
705                        summary: fallow_output::CoverageIntelligenceSummary::default(),
706                        findings: vec![CoverageIntelligenceFinding {
707                            id: "coverage-fixture".to_string(),
708                            path: PathBuf::from("/root/src/legacy.ts"),
709                            identity: Some("legacyHelper".to_string()),
710                            line: 12,
711                            verdict,
712                            signals: Vec::new(),
713                            recommendation,
714                            confidence: CoverageIntelligenceConfidence::High,
715                            related_ids: Vec::new(),
716                            evidence: CoverageIntelligenceEvidence::default(),
717                            actions: Vec::new(),
718                        }],
719                    }),
720                    ..HealthReport::default()
721                };
722                let issues = fallow_output::codeclimate_issues_to_value(&build_health_codeclimate(
723                    &report,
724                    Path::new("/root"),
725                ));
726                let issues = issues
727                    .as_array()
728                    .expect("CodeClimate output must be an array");
729                if let Some(severity) = severity {
730                    assert_eq!(issues.len(), 1, "{verdict:?}");
731                    assert_eq!(issues[0]["check_name"], check_name);
732                    assert_eq!(issues[0]["severity"], severity);
733                    assert_eq!(issues[0]["location"]["path"], "src/legacy.ts");
734                } else {
735                    assert!(issues.is_empty(), "{verdict:?}");
736                }
737            }
738        }
739    }
740}