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                    cognitive: 9,
399                    line_count: 20,
400                    param_count: 1,
401                    react_hook_count: 0,
402                    react_jsx_max_depth: 0,
403                    react_prop_count: 0,
404                    react_hook_profile: None,
405                    exceeded: ExceededThreshold::Both,
406                    effective_severity: None,
407                    severity: FindingSeverity::High,
408                    mutation: None,
409                    coverage_pct: None,
410                    crap: None,
411                    coverage_tier: None,
412                    coverage_source: None,
413                    inherited_from: None,
414                    component_rollup: None,
415                    contributions: Vec::new(),
416                    effective_thresholds: None,
417                    threshold_source: None,
418                }
419                .into(),
420            ],
421            ..HealthReport::default()
422        };
423
424        let issues = build_health_codeclimate(&report, Path::new("/root"));
425
426        assert_eq!(issues.len(), 1);
427        let issue = &issues[0];
428        assert_eq!(issue.check_name, "fallow/high-complexity");
429        assert_eq!(issue.location.path, "app/%5Bid%5D/page.tsx");
430        assert_eq!(issue.location.lines.begin, 7);
431        assert_eq!(issue.severity, CodeClimateSeverity::Major);
432    }
433
434    #[test]
435    fn health_codeclimate_includes_styling_findings() {
436        let report = HealthReport {
437            styling_findings: vec![StylingFinding {
438                code: "css-selector-complexity".to_string(),
439                sub_kind: "high-specificity".to_string(),
440                path: "src/styles.css".to_string(),
441                line: 4,
442                value: "#app .card .title".to_string(),
443                effective_severity: StylingFindingSeverity::Error,
444                blast_radius: None,
445                confidence: None,
446                agent_disposition: None,
447                nearest_token: None,
448                fix_hint: None,
449                actions: Vec::new(),
450            }],
451            ..HealthReport::default()
452        };
453
454        let issues = build_health_codeclimate(&report, Path::new("/root"));
455
456        assert_eq!(issues.len(), 1);
457        let issue = &issues[0];
458        assert_eq!(issue.check_name, "fallow/css-selector-complexity");
459        assert_eq!(issue.location.path, "src/styles.css");
460        assert_eq!(issue.location.lines.begin, 4);
461        assert_eq!(issue.severity, CodeClimateSeverity::Major);
462    }
463
464    fn crap_violation(
465        effective_thresholds: Option<fallow_output::HealthEffectiveThresholds>,
466    ) -> ComplexityViolation {
467        ComplexityViolation {
468            path: PathBuf::from("/root/src/Board.astro"),
469            name: "<template>".to_string(),
470            line: 6,
471            col: 3,
472            cyclomatic: 11,
473            cognitive: 4,
474            line_count: 20,
475            param_count: 0,
476            react_hook_count: 0,
477            react_jsx_max_depth: 0,
478            react_prop_count: 0,
479            react_hook_profile: None,
480            exceeded: ExceededThreshold::Crap,
481            effective_severity: None,
482            severity: FindingSeverity::Critical,
483            mutation: None,
484            coverage_pct: None,
485            crap: Some(132.0),
486            coverage_tier: None,
487            coverage_source: None,
488            inherited_from: None,
489            component_rollup: None,
490            contributions: Vec::new(),
491            threshold_source: effective_thresholds
492                .map(|_| fallow_output::ThresholdSource::Override),
493            effective_thresholds,
494        }
495    }
496
497    fn crap_report(
498        effective_thresholds: Option<fallow_output::HealthEffectiveThresholds>,
499    ) -> HealthReport {
500        HealthReport {
501            summary: HealthSummary {
502                max_crap_threshold: 30.0,
503                ..HealthSummary::default()
504            },
505            findings: vec![crap_violation(effective_thresholds).into()],
506            ..HealthReport::default()
507        }
508    }
509
510    /// The CodeClimate description feeds `pr-comment-*` and `review-*` too, so
511    /// it must quote the ceiling the finding was measured with, matching SARIF
512    /// (issue #2163).
513    #[test]
514    fn codeclimate_description_uses_the_override_ceiling_not_the_global_one() {
515        let report = crap_report(Some(fallow_output::HealthEffectiveThresholds {
516            max_cyclomatic: 20,
517            max_cognitive: 15,
518            max_crap: 100.0,
519            max_unit_size: 60,
520        }));
521
522        let issues = build_health_codeclimate(&report, Path::new("/root"));
523
524        assert_eq!(issues.len(), 1);
525        assert!(
526            issues[0].description.contains("threshold: 100.0"),
527            "{}",
528            issues[0].description
529        );
530        assert!(
531            !issues[0].description.contains("threshold: 30.0"),
532            "{}",
533            issues[0].description
534        );
535    }
536
537    #[test]
538    fn codeclimate_description_falls_back_to_the_global_ceiling() {
539        let issues = build_health_codeclimate(&crap_report(None), Path::new("/root"));
540
541        assert_eq!(issues.len(), 1);
542        assert!(
543            issues[0].description.contains("threshold: 30.0"),
544            "{}",
545            issues[0].description
546        );
547    }
548
549    #[test]
550    fn health_severities_reach_serialized_codeclimate_issues() {
551        for (severity, expected) in [
552            (FindingSeverity::Critical, "critical"),
553            (FindingSeverity::High, "major"),
554            (FindingSeverity::Moderate, "minor"),
555        ] {
556            let mut violation = crap_violation(None);
557            violation.severity = severity;
558            let report = HealthReport {
559                findings: vec![violation.into()],
560                ..HealthReport::default()
561            };
562            let issues = fallow_output::codeclimate_issues_to_value(&build_health_codeclimate(
563                &report,
564                Path::new("/root"),
565            ));
566            let issues = issues
567                .as_array()
568                .expect("CodeClimate output must be an array");
569            assert_eq!(issues.len(), 1);
570            assert_eq!(issues[0]["severity"], expected);
571            assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
572        }
573    }
574
575    #[test]
576    fn runtime_verdicts_reach_serialized_codeclimate_issues() {
577        use fallow_output::{
578            RuntimeCoverageConfidence, RuntimeCoverageEvidence, RuntimeCoverageReport,
579        };
580
581        for (verdict, check_name, severity) in [
582            (
583                RuntimeCoverageVerdict::SafeToDelete,
584                "fallow/runtime-safe-to-delete",
585                "critical",
586            ),
587            (
588                RuntimeCoverageVerdict::ReviewRequired,
589                "fallow/runtime-review-required",
590                "major",
591            ),
592            (
593                RuntimeCoverageVerdict::LowTraffic,
594                "fallow/runtime-low-traffic",
595                "minor",
596            ),
597            (
598                RuntimeCoverageVerdict::CoverageUnavailable,
599                "fallow/runtime-coverage-unavailable",
600                "minor",
601            ),
602            (
603                RuntimeCoverageVerdict::Active,
604                "fallow/runtime-coverage",
605                "minor",
606            ),
607            (
608                RuntimeCoverageVerdict::Unknown,
609                "fallow/runtime-coverage",
610                "minor",
611            ),
612        ] {
613            let report = HealthReport {
614                runtime_coverage: Some(RuntimeCoverageReport {
615                    findings: vec![RuntimeCoverageFinding {
616                        id: "runtime-fixture".to_string(),
617                        stable_id: None,
618                        source_hash: None,
619                        path: PathBuf::from("/root/src/legacy.ts"),
620                        function: "legacyHelper".to_string(),
621                        line: 12,
622                        verdict,
623                        invocations: Some(0),
624                        confidence: RuntimeCoverageConfidence::High,
625                        evidence: RuntimeCoverageEvidence {
626                            static_status: "unused".to_string(),
627                            test_coverage: "not_covered".to_string(),
628                            test_only_reference: None,
629                            v8_tracking: "tracked".to_string(),
630                            untracked_reason: None,
631                            observation_days: 30,
632                            deployments_observed: 3,
633                        },
634                        actions: Vec::new(),
635                        discriminators: None,
636                    }],
637                    ..RuntimeCoverageReport::default()
638                }),
639                ..HealthReport::default()
640            };
641            let issues = fallow_output::codeclimate_issues_to_value(&build_health_codeclimate(
642                &report,
643                Path::new("/root"),
644            ));
645            let issues = issues
646                .as_array()
647                .expect("CodeClimate output must be an array");
648            assert_eq!(issues.len(), 1, "{verdict:?}");
649            assert_eq!(issues[0]["check_name"], check_name);
650            assert_eq!(issues[0]["severity"], severity);
651            assert_eq!(issues[0]["location"]["path"], "src/legacy.ts");
652            assert_eq!(issues[0]["location"]["lines"]["begin"], 12);
653        }
654    }
655
656    #[test]
657    fn coverage_intelligence_verdicts_and_recommendations_reach_serialized_issues() {
658        use fallow_output::{
659            CoverageIntelligenceConfidence, CoverageIntelligenceEvidence,
660            CoverageIntelligenceReport,
661        };
662
663        let recommendations = [
664            (
665                CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge,
666                "fallow/coverage-intelligence-risky-change",
667            ),
668            (
669                CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
670                "fallow/coverage-intelligence-delete",
671            ),
672            (
673                CoverageIntelligenceRecommendation::ReviewBeforeChanging,
674                "fallow/coverage-intelligence-review",
675            ),
676            (
677                CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior,
678                "fallow/coverage-intelligence-refactor",
679            ),
680        ];
681        for (verdict, severity) in [
682            (
683                CoverageIntelligenceVerdict::RiskyChangeDetected,
684                Some("major"),
685            ),
686            (
687                CoverageIntelligenceVerdict::HighConfidenceDelete,
688                Some("major"),
689            ),
690            (CoverageIntelligenceVerdict::ReviewRequired, Some("minor")),
691            (
692                CoverageIntelligenceVerdict::RefactorCarefully,
693                Some("minor"),
694            ),
695            (CoverageIntelligenceVerdict::Clean, None),
696            (CoverageIntelligenceVerdict::Unknown, None),
697        ] {
698            for (recommendation, check_name) in recommendations {
699                let report = HealthReport {
700                    coverage_intelligence: Some(CoverageIntelligenceReport {
701                        schema_version: fallow_output::CoverageIntelligenceSchemaVersion::default(),
702                        verdict,
703                        summary: fallow_output::CoverageIntelligenceSummary::default(),
704                        findings: vec![CoverageIntelligenceFinding {
705                            id: "coverage-fixture".to_string(),
706                            path: PathBuf::from("/root/src/legacy.ts"),
707                            identity: Some("legacyHelper".to_string()),
708                            line: 12,
709                            verdict,
710                            signals: Vec::new(),
711                            recommendation,
712                            confidence: CoverageIntelligenceConfidence::High,
713                            related_ids: Vec::new(),
714                            evidence: CoverageIntelligenceEvidence::default(),
715                            actions: Vec::new(),
716                        }],
717                    }),
718                    ..HealthReport::default()
719                };
720                let issues = fallow_output::codeclimate_issues_to_value(&build_health_codeclimate(
721                    &report,
722                    Path::new("/root"),
723                ));
724                let issues = issues
725                    .as_array()
726                    .expect("CodeClimate output must be an array");
727                if let Some(severity) = severity {
728                    assert_eq!(issues.len(), 1, "{verdict:?}");
729                    assert_eq!(issues[0]["check_name"], check_name);
730                    assert_eq!(issues[0]["severity"], severity);
731                    assert_eq!(issues[0]["location"]["path"], "src/legacy.ts");
732                } else {
733                    assert!(issues.is_empty(), "{verdict:?}");
734                }
735            }
736        }
737    }
738}