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        issues.push(ctx.complexity_issue(finding));
217    }
218    for finding in &report.styling_findings {
219        issues.push(ctx.styling_issue(finding));
220    }
221
222    if let Some(ref production) = report.runtime_coverage {
223        for finding in &production.findings {
224            issues.push(ctx.runtime_coverage_issue(finding));
225        }
226    }
227
228    if let Some(ref intelligence) = report.coverage_intelligence {
229        for finding in &intelligence.findings {
230            if let Some(issue) = ctx.coverage_intelligence_issue(finding) {
231                issues.push(issue);
232            }
233        }
234    }
235
236    if let Some(ref gaps) = report.coverage_gaps {
237        for item in &gaps.files {
238            issues.push(ctx.untested_file_issue(item));
239        }
240
241        for item in &gaps.exports {
242            issues.push(ctx.untested_export_issue(item));
243        }
244    }
245
246    issues
247}
248
249fn codeclimate_path(path: &Path, root: &Path) -> String {
250    normalize_uri(
251        &path
252            .strip_prefix(root)
253            .unwrap_or(path)
254            .display()
255            .to_string(),
256    )
257}
258
259const fn coverage_intelligence_check_name(
260    recommendation: CoverageIntelligenceRecommendation,
261) -> &'static str {
262    match recommendation {
263        CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
264            "fallow/coverage-intelligence-risky-change"
265        }
266        CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
267            "fallow/coverage-intelligence-delete"
268        }
269        CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
270            "fallow/coverage-intelligence-review"
271        }
272        CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
273            "fallow/coverage-intelligence-refactor"
274        }
275    }
276}
277
278const fn complexity_check_name(finding: &ComplexityViolation) -> &'static str {
279    match finding.exceeded {
280        ExceededThreshold::Both => "fallow/high-complexity",
281        ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
282        ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
283        ExceededThreshold::Crap
284        | ExceededThreshold::CyclomaticCrap
285        | ExceededThreshold::CognitiveCrap
286        | ExceededThreshold::All => "fallow/high-crap-score",
287    }
288}
289
290/// The CodeClimate severity of a complexity finding.
291///
292/// The gate severity from the `complexity-*` rules sets it: `error` gives
293/// `major` and `warn` gives `minor`, as for dead-code and styling findings.
294/// A finding without a gate severity (a saved report from an older version)
295/// keeps the band mapping.
296const fn complexity_codeclimate_severity(finding: &ComplexityViolation) -> CodeClimateSeverity {
297    match finding.effective_severity {
298        Some(EffectiveSeverity::Error) => CodeClimateSeverity::Major,
299        Some(EffectiveSeverity::Warn) => CodeClimateSeverity::Minor,
300        None => health_finding_severity(finding.severity),
301    }
302}
303
304const fn health_finding_severity(severity: FindingSeverity) -> CodeClimateSeverity {
305    match severity {
306        FindingSeverity::Critical => CodeClimateSeverity::Critical,
307        FindingSeverity::High => CodeClimateSeverity::Major,
308        FindingSeverity::Moderate => CodeClimateSeverity::Minor,
309    }
310}
311
312const fn styling_finding_severity(severity: StylingFindingSeverity) -> CodeClimateSeverity {
313    match severity {
314        StylingFindingSeverity::Error => CodeClimateSeverity::Major,
315        StylingFindingSeverity::Warn => CodeClimateSeverity::Minor,
316    }
317}
318
319const fn runtime_coverage_check_name(verdict: RuntimeCoverageVerdict) -> &'static str {
320    match verdict {
321        RuntimeCoverageVerdict::SafeToDelete => "fallow/runtime-safe-to-delete",
322        RuntimeCoverageVerdict::ReviewRequired => "fallow/runtime-review-required",
323        RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
324        RuntimeCoverageVerdict::CoverageUnavailable => "fallow/runtime-coverage-unavailable",
325        RuntimeCoverageVerdict::Active | RuntimeCoverageVerdict::Unknown => {
326            "fallow/runtime-coverage"
327        }
328    }
329}
330
331const fn runtime_coverage_severity(verdict: RuntimeCoverageVerdict) -> CodeClimateSeverity {
332    match verdict {
333        RuntimeCoverageVerdict::SafeToDelete => CodeClimateSeverity::Critical,
334        RuntimeCoverageVerdict::ReviewRequired => CodeClimateSeverity::Major,
335        _ => CodeClimateSeverity::Minor,
336    }
337}
338
339const fn coverage_intelligence_severity(
340    verdict: CoverageIntelligenceVerdict,
341) -> Option<CodeClimateSeverity> {
342    match verdict {
343        CoverageIntelligenceVerdict::RiskyChangeDetected
344        | CoverageIntelligenceVerdict::HighConfidenceDelete => Some(CodeClimateSeverity::Major),
345        CoverageIntelligenceVerdict::ReviewRequired
346        | CoverageIntelligenceVerdict::RefactorCarefully => Some(CodeClimateSeverity::Minor),
347        CoverageIntelligenceVerdict::Clean | CoverageIntelligenceVerdict::Unknown => None,
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use std::path::{Path, PathBuf};
354
355    use fallow_output::{
356        ComplexityViolation, ExceededThreshold, FindingSeverity, HealthReport, HealthSummary,
357        StylingFinding, StylingFindingSeverity,
358    };
359
360    use super::*;
361
362    #[test]
363    fn health_codeclimate_uses_relative_normalized_paths() {
364        let report = HealthReport {
365            summary: HealthSummary {
366                max_cyclomatic_threshold: 10,
367                max_cognitive_threshold: 8,
368                max_crap_threshold: 30.0,
369                ..HealthSummary::default()
370            },
371            findings: vec![
372                ComplexityViolation {
373                    path: PathBuf::from("/root/app/[id]/page.tsx"),
374                    name: "render".to_string(),
375                    line: 7,
376                    col: 0,
377                    cyclomatic: 12,
378                    cognitive: 9,
379                    line_count: 20,
380                    param_count: 1,
381                    react_hook_count: 0,
382                    react_jsx_max_depth: 0,
383                    react_prop_count: 0,
384                    react_hook_profile: None,
385                    exceeded: ExceededThreshold::Both,
386                    effective_severity: None,
387                    severity: FindingSeverity::High,
388                    coverage_pct: None,
389                    crap: None,
390                    coverage_tier: None,
391                    coverage_source: None,
392                    inherited_from: None,
393                    component_rollup: None,
394                    contributions: Vec::new(),
395                    effective_thresholds: None,
396                    threshold_source: None,
397                }
398                .into(),
399            ],
400            ..HealthReport::default()
401        };
402
403        let issues = build_health_codeclimate(&report, Path::new("/root"));
404
405        assert_eq!(issues.len(), 1);
406        let issue = &issues[0];
407        assert_eq!(issue.check_name, "fallow/high-complexity");
408        assert_eq!(issue.location.path, "app/%5Bid%5D/page.tsx");
409        assert_eq!(issue.location.lines.begin, 7);
410        assert_eq!(issue.severity, CodeClimateSeverity::Major);
411    }
412
413    #[test]
414    fn health_codeclimate_includes_styling_findings() {
415        let report = HealthReport {
416            styling_findings: vec![StylingFinding {
417                code: "css-selector-complexity".to_string(),
418                sub_kind: "high-specificity".to_string(),
419                path: "src/styles.css".to_string(),
420                line: 4,
421                value: "#app .card .title".to_string(),
422                effective_severity: StylingFindingSeverity::Error,
423                blast_radius: None,
424                confidence: None,
425                agent_disposition: None,
426                nearest_token: None,
427                fix_hint: None,
428                actions: Vec::new(),
429            }],
430            ..HealthReport::default()
431        };
432
433        let issues = build_health_codeclimate(&report, Path::new("/root"));
434
435        assert_eq!(issues.len(), 1);
436        let issue = &issues[0];
437        assert_eq!(issue.check_name, "fallow/css-selector-complexity");
438        assert_eq!(issue.location.path, "src/styles.css");
439        assert_eq!(issue.location.lines.begin, 4);
440        assert_eq!(issue.severity, CodeClimateSeverity::Major);
441    }
442
443    fn crap_violation(
444        effective_thresholds: Option<fallow_output::HealthEffectiveThresholds>,
445    ) -> ComplexityViolation {
446        ComplexityViolation {
447            path: PathBuf::from("/root/src/Board.astro"),
448            name: "<template>".to_string(),
449            line: 6,
450            col: 3,
451            cyclomatic: 11,
452            cognitive: 4,
453            line_count: 20,
454            param_count: 0,
455            react_hook_count: 0,
456            react_jsx_max_depth: 0,
457            react_prop_count: 0,
458            react_hook_profile: None,
459            exceeded: ExceededThreshold::Crap,
460            effective_severity: None,
461            severity: FindingSeverity::Critical,
462            coverage_pct: None,
463            crap: Some(132.0),
464            coverage_tier: None,
465            coverage_source: None,
466            inherited_from: None,
467            component_rollup: None,
468            contributions: Vec::new(),
469            threshold_source: effective_thresholds
470                .map(|_| fallow_output::ThresholdSource::Override),
471            effective_thresholds,
472        }
473    }
474
475    fn crap_report(
476        effective_thresholds: Option<fallow_output::HealthEffectiveThresholds>,
477    ) -> HealthReport {
478        HealthReport {
479            summary: HealthSummary {
480                max_crap_threshold: 30.0,
481                ..HealthSummary::default()
482            },
483            findings: vec![crap_violation(effective_thresholds).into()],
484            ..HealthReport::default()
485        }
486    }
487
488    /// The CodeClimate description feeds `pr-comment-*` and `review-*` too, so
489    /// it must quote the ceiling the finding was measured with, matching SARIF
490    /// (issue #2163).
491    #[test]
492    fn codeclimate_description_uses_the_override_ceiling_not_the_global_one() {
493        let report = crap_report(Some(fallow_output::HealthEffectiveThresholds {
494            max_cyclomatic: 20,
495            max_cognitive: 15,
496            max_crap: 100.0,
497            max_unit_size: 60,
498        }));
499
500        let issues = build_health_codeclimate(&report, Path::new("/root"));
501
502        assert_eq!(issues.len(), 1);
503        assert!(
504            issues[0].description.contains("threshold: 100.0"),
505            "{}",
506            issues[0].description
507        );
508        assert!(
509            !issues[0].description.contains("threshold: 30.0"),
510            "{}",
511            issues[0].description
512        );
513    }
514
515    #[test]
516    fn codeclimate_description_falls_back_to_the_global_ceiling() {
517        let issues = build_health_codeclimate(&crap_report(None), Path::new("/root"));
518
519        assert_eq!(issues.len(), 1);
520        assert!(
521            issues[0].description.contains("threshold: 30.0"),
522            "{}",
523            issues[0].description
524        );
525    }
526
527    #[test]
528    fn health_severities_reach_serialized_codeclimate_issues() {
529        for (severity, expected) in [
530            (FindingSeverity::Critical, "critical"),
531            (FindingSeverity::High, "major"),
532            (FindingSeverity::Moderate, "minor"),
533        ] {
534            let mut violation = crap_violation(None);
535            violation.severity = severity;
536            let report = HealthReport {
537                findings: vec![violation.into()],
538                ..HealthReport::default()
539            };
540            let issues = fallow_output::codeclimate_issues_to_value(&build_health_codeclimate(
541                &report,
542                Path::new("/root"),
543            ));
544            let issues = issues
545                .as_array()
546                .expect("CodeClimate output must be an array");
547            assert_eq!(issues.len(), 1);
548            assert_eq!(issues[0]["severity"], expected);
549            assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
550        }
551    }
552
553    #[test]
554    fn runtime_verdicts_reach_serialized_codeclimate_issues() {
555        use fallow_output::{
556            RuntimeCoverageConfidence, RuntimeCoverageEvidence, RuntimeCoverageReport,
557        };
558
559        for (verdict, check_name, severity) in [
560            (
561                RuntimeCoverageVerdict::SafeToDelete,
562                "fallow/runtime-safe-to-delete",
563                "critical",
564            ),
565            (
566                RuntimeCoverageVerdict::ReviewRequired,
567                "fallow/runtime-review-required",
568                "major",
569            ),
570            (
571                RuntimeCoverageVerdict::LowTraffic,
572                "fallow/runtime-low-traffic",
573                "minor",
574            ),
575            (
576                RuntimeCoverageVerdict::CoverageUnavailable,
577                "fallow/runtime-coverage-unavailable",
578                "minor",
579            ),
580            (
581                RuntimeCoverageVerdict::Active,
582                "fallow/runtime-coverage",
583                "minor",
584            ),
585            (
586                RuntimeCoverageVerdict::Unknown,
587                "fallow/runtime-coverage",
588                "minor",
589            ),
590        ] {
591            let report = HealthReport {
592                runtime_coverage: Some(RuntimeCoverageReport {
593                    findings: vec![RuntimeCoverageFinding {
594                        id: "runtime-fixture".to_string(),
595                        stable_id: None,
596                        source_hash: None,
597                        path: PathBuf::from("/root/src/legacy.ts"),
598                        function: "legacyHelper".to_string(),
599                        line: 12,
600                        verdict,
601                        invocations: Some(0),
602                        confidence: RuntimeCoverageConfidence::High,
603                        evidence: RuntimeCoverageEvidence {
604                            static_status: "unused".to_string(),
605                            test_coverage: "not_covered".to_string(),
606                            test_only_reference: None,
607                            v8_tracking: "tracked".to_string(),
608                            untracked_reason: None,
609                            observation_days: 30,
610                            deployments_observed: 3,
611                        },
612                        actions: Vec::new(),
613                        discriminators: None,
614                    }],
615                    ..RuntimeCoverageReport::default()
616                }),
617                ..HealthReport::default()
618            };
619            let issues = fallow_output::codeclimate_issues_to_value(&build_health_codeclimate(
620                &report,
621                Path::new("/root"),
622            ));
623            let issues = issues
624                .as_array()
625                .expect("CodeClimate output must be an array");
626            assert_eq!(issues.len(), 1, "{verdict:?}");
627            assert_eq!(issues[0]["check_name"], check_name);
628            assert_eq!(issues[0]["severity"], severity);
629            assert_eq!(issues[0]["location"]["path"], "src/legacy.ts");
630            assert_eq!(issues[0]["location"]["lines"]["begin"], 12);
631        }
632    }
633
634    #[test]
635    fn coverage_intelligence_verdicts_and_recommendations_reach_serialized_issues() {
636        use fallow_output::{
637            CoverageIntelligenceConfidence, CoverageIntelligenceEvidence,
638            CoverageIntelligenceReport,
639        };
640
641        let recommendations = [
642            (
643                CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge,
644                "fallow/coverage-intelligence-risky-change",
645            ),
646            (
647                CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
648                "fallow/coverage-intelligence-delete",
649            ),
650            (
651                CoverageIntelligenceRecommendation::ReviewBeforeChanging,
652                "fallow/coverage-intelligence-review",
653            ),
654            (
655                CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior,
656                "fallow/coverage-intelligence-refactor",
657            ),
658        ];
659        for (verdict, severity) in [
660            (
661                CoverageIntelligenceVerdict::RiskyChangeDetected,
662                Some("major"),
663            ),
664            (
665                CoverageIntelligenceVerdict::HighConfidenceDelete,
666                Some("major"),
667            ),
668            (CoverageIntelligenceVerdict::ReviewRequired, Some("minor")),
669            (
670                CoverageIntelligenceVerdict::RefactorCarefully,
671                Some("minor"),
672            ),
673            (CoverageIntelligenceVerdict::Clean, None),
674            (CoverageIntelligenceVerdict::Unknown, None),
675        ] {
676            for (recommendation, check_name) in recommendations {
677                let report = HealthReport {
678                    coverage_intelligence: Some(CoverageIntelligenceReport {
679                        schema_version: fallow_output::CoverageIntelligenceSchemaVersion::default(),
680                        verdict,
681                        summary: fallow_output::CoverageIntelligenceSummary::default(),
682                        findings: vec![CoverageIntelligenceFinding {
683                            id: "coverage-fixture".to_string(),
684                            path: PathBuf::from("/root/src/legacy.ts"),
685                            identity: Some("legacyHelper".to_string()),
686                            line: 12,
687                            verdict,
688                            signals: Vec::new(),
689                            recommendation,
690                            confidence: CoverageIntelligenceConfidence::High,
691                            related_ids: Vec::new(),
692                            evidence: CoverageIntelligenceEvidence::default(),
693                            actions: Vec::new(),
694                        }],
695                    }),
696                    ..HealthReport::default()
697                };
698                let issues = fallow_output::codeclimate_issues_to_value(&build_health_codeclimate(
699                    &report,
700                    Path::new("/root"),
701                ));
702                let issues = issues
703                    .as_array()
704                    .expect("CodeClimate output must be an array");
705                if let Some(severity) = severity {
706                    assert_eq!(issues.len(), 1, "{verdict:?}");
707                    assert_eq!(issues[0]["check_name"], check_name);
708                    assert_eq!(issues[0]["severity"], severity);
709                    assert_eq!(issues[0]["location"]["path"], "src/legacy.ts");
710                } else {
711                    assert!(issues.is_empty(), "{verdict:?}");
712                }
713            }
714        }
715    }
716}