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