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