Skip to main content

fallow_cli/report/
codeclimate.rs

1use std::path::Path;
2use std::process::ExitCode;
3
4use fallow_config::{RulesConfig, Severity};
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::AnalysisResults;
7
8use super::ci::{fingerprint, severity};
9use super::grouping::{self, OwnershipResolver};
10use super::{emit_json, normalize_uri, relative_path};
11use crate::health_types::{
12    ComplexityViolation, CoverageIntelligenceFinding, ExceededThreshold, HealthReport,
13    RuntimeCoverageFinding, UntestedExportFinding, UntestedFileFinding,
14};
15use crate::output_envelope::{
16    CodeClimateIssue, CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation,
17    CodeClimateSeverity,
18};
19
20/// Map fallow severity to CodeClimate severity.
21fn severity_to_codeclimate(s: Severity) -> CodeClimateSeverity {
22    severity::codeclimate_severity(s)
23}
24
25/// Compute a relative path string with forward-slash normalization.
26///
27/// Uses `normalize_uri` to ensure forward slashes on all platforms
28/// and percent-encode brackets for Next.js dynamic routes.
29fn cc_path(path: &Path, root: &Path) -> String {
30    normalize_uri(&relative_path(path, root).display().to_string())
31}
32
33/// Compute a deterministic fingerprint hash from key fields.
34///
35/// Uses FNV-1a (64-bit) for guaranteed cross-version stability.
36/// `DefaultHasher` is explicitly not specified across Rust versions.
37fn fingerprint_hash(parts: &[&str]) -> String {
38    fingerprint::fingerprint_hash(parts)
39}
40
41/// Build a single CodeClimate issue. Wire shape is locked by the
42/// [`CodeClimateIssue`] typed envelope (and the schema drift gate);
43/// changes to the wire must flow through that struct.
44fn cc_issue(
45    check_name: &str,
46    description: &str,
47    severity: CodeClimateSeverity,
48    category: &str,
49    path: &str,
50    begin_line: Option<u32>,
51    fingerprint: &str,
52) -> CodeClimateIssue {
53    CodeClimateIssue {
54        kind: CodeClimateIssueKind::Issue,
55        check_name: check_name.to_string(),
56        description: description.to_string(),
57        categories: vec![category.to_string()],
58        severity,
59        fingerprint: fingerprint.to_string(),
60        location: CodeClimateLocation {
61            path: path.to_string(),
62            lines: CodeClimateLines {
63                begin: begin_line.unwrap_or(1),
64            },
65        },
66    }
67}
68
69fn coverage_intelligence_check_name(
70    recommendation: crate::health_types::CoverageIntelligenceRecommendation,
71) -> &'static str {
72    match recommendation {
73        crate::health_types::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
74            "fallow/coverage-intelligence-risky-change"
75        }
76        crate::health_types::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
77            "fallow/coverage-intelligence-delete"
78        }
79        crate::health_types::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
80            "fallow/coverage-intelligence-review"
81        }
82        crate::health_types::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
83            "fallow/coverage-intelligence-refactor"
84        }
85    }
86}
87
88struct HealthCodeClimateContext<'a> {
89    root: &'a Path,
90    cyc_t: u16,
91    cog_t: u16,
92    crap_t: f64,
93}
94
95impl HealthCodeClimateContext<'_> {
96    fn complexity_issue(&self, finding: &ComplexityViolation) -> CodeClimateIssue {
97        let path = cc_path(&finding.path, self.root);
98        let check_name = complexity_check_name(finding);
99        let line_str = finding.line.to_string();
100        let fp = fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
101        cc_issue(
102            check_name,
103            &self.complexity_description(finding),
104            health_finding_severity(finding.severity),
105            "Complexity",
106            &path,
107            Some(finding.line),
108            &fp,
109        )
110    }
111
112    fn complexity_description(&self, finding: &ComplexityViolation) -> String {
113        match finding.exceeded {
114            ExceededThreshold::Both => format!(
115                "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
116                finding.name, finding.cyclomatic, self.cyc_t, finding.cognitive, self.cog_t
117            ),
118            ExceededThreshold::Cyclomatic => format!(
119                "'{}' has cyclomatic complexity {} (threshold: {})",
120                finding.name, finding.cyclomatic, self.cyc_t
121            ),
122            ExceededThreshold::Cognitive => format!(
123                "'{}' has cognitive complexity {} (threshold: {})",
124                finding.name, finding.cognitive, self.cog_t
125            ),
126            ExceededThreshold::Crap
127            | ExceededThreshold::CyclomaticCrap
128            | ExceededThreshold::CognitiveCrap
129            | ExceededThreshold::All => {
130                let crap = finding.crap.unwrap_or(0.0);
131                let coverage = finding
132                    .coverage_pct
133                    .map(|pct| format!(", coverage {pct:.0}%"))
134                    .unwrap_or_default();
135                format!(
136                    "'{}' has CRAP score {crap:.1} (threshold: {:.1}, cyclomatic {}{coverage})",
137                    finding.name, self.crap_t, finding.cyclomatic,
138                )
139            }
140        }
141    }
142
143    fn runtime_coverage_issue(&self, finding: &RuntimeCoverageFinding) -> CodeClimateIssue {
144        let path = cc_path(&finding.path, self.root);
145        let check_name = runtime_coverage_check_name(finding.verdict);
146        let invocations_hint = finding.invocations.map_or_else(
147            || "untracked".to_owned(),
148            |hits| format!("{hits} invocations"),
149        );
150        let description = format!(
151            "'{}' runtime coverage verdict: {} ({})",
152            finding.function,
153            finding.verdict.human_label(),
154            invocations_hint,
155        );
156        let fp = fingerprint_hash(&[
157            check_name,
158            &path,
159            &finding.line.to_string(),
160            &finding.function,
161        ]);
162        cc_issue(
163            check_name,
164            &description,
165            runtime_coverage_severity(finding.verdict),
166            "Bug Risk",
167            &path,
168            Some(finding.line),
169            &fp,
170        )
171    }
172
173    fn coverage_intelligence_issue(
174        &self,
175        finding: &CoverageIntelligenceFinding,
176    ) -> Option<CodeClimateIssue> {
177        let severity = coverage_intelligence_severity(finding.verdict)?;
178        let path = cc_path(&finding.path, self.root);
179        let check_name = coverage_intelligence_check_name(finding.recommendation);
180        let identity = finding.identity.as_deref().unwrap_or("code");
181        let description = format!(
182            "'{}' coverage intelligence verdict: {} ({})",
183            identity, finding.verdict, finding.recommendation,
184        );
185        let fp = fingerprint_hash(&[
186            check_name,
187            &path,
188            &finding.line.to_string(),
189            identity,
190            &finding.id,
191        ]);
192        Some(cc_issue(
193            check_name,
194            &description,
195            severity,
196            "Bug Risk",
197            &path,
198            Some(finding.line),
199            &fp,
200        ))
201    }
202
203    fn untested_file_issue(&self, item: &UntestedFileFinding) -> CodeClimateIssue {
204        let path = cc_path(&item.file.path, self.root);
205        let description = format!(
206            "File is runtime-reachable but has no test dependency path ({} value export{})",
207            item.file.value_export_count,
208            if item.file.value_export_count == 1 {
209                ""
210            } else {
211                "s"
212            },
213        );
214        let fp = fingerprint_hash(&["fallow/untested-file", &path]);
215        cc_issue(
216            "fallow/untested-file",
217            &description,
218            CodeClimateSeverity::Minor,
219            "Coverage",
220            &path,
221            None,
222            &fp,
223        )
224    }
225
226    fn untested_export_issue(&self, item: &UntestedExportFinding) -> CodeClimateIssue {
227        let path = cc_path(&item.export.path, self.root);
228        let description = format!(
229            "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
230            item.export.export_name
231        );
232        let line_str = item.export.line.to_string();
233        let fp = fingerprint_hash(&[
234            "fallow/untested-export",
235            &path,
236            &line_str,
237            &item.export.export_name,
238        ]);
239        cc_issue(
240            "fallow/untested-export",
241            &description,
242            CodeClimateSeverity::Minor,
243            "Coverage",
244            &path,
245            Some(item.export.line),
246            &fp,
247        )
248    }
249}
250
251const fn complexity_check_name(finding: &ComplexityViolation) -> &'static str {
252    match finding.exceeded {
253        ExceededThreshold::Both => "fallow/high-complexity",
254        ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
255        ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
256        ExceededThreshold::Crap
257        | ExceededThreshold::CyclomaticCrap
258        | ExceededThreshold::CognitiveCrap
259        | ExceededThreshold::All => "fallow/high-crap-score",
260    }
261}
262
263const fn health_finding_severity(
264    severity: crate::health_types::FindingSeverity,
265) -> CodeClimateSeverity {
266    match severity {
267        crate::health_types::FindingSeverity::Critical => CodeClimateSeverity::Critical,
268        crate::health_types::FindingSeverity::High => CodeClimateSeverity::Major,
269        crate::health_types::FindingSeverity::Moderate => CodeClimateSeverity::Minor,
270    }
271}
272
273const fn runtime_coverage_check_name(
274    verdict: crate::health_types::RuntimeCoverageVerdict,
275) -> &'static str {
276    match verdict {
277        crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
278            "fallow/runtime-safe-to-delete"
279        }
280        crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
281            "fallow/runtime-review-required"
282        }
283        crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
284        crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
285            "fallow/runtime-coverage-unavailable"
286        }
287        crate::health_types::RuntimeCoverageVerdict::Active
288        | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
289    }
290}
291
292const fn runtime_coverage_severity(
293    verdict: crate::health_types::RuntimeCoverageVerdict,
294) -> CodeClimateSeverity {
295    match verdict {
296        crate::health_types::RuntimeCoverageVerdict::SafeToDelete => CodeClimateSeverity::Critical,
297        crate::health_types::RuntimeCoverageVerdict::ReviewRequired => CodeClimateSeverity::Major,
298        _ => CodeClimateSeverity::Minor,
299    }
300}
301
302const fn coverage_intelligence_severity(
303    verdict: crate::health_types::CoverageIntelligenceVerdict,
304) -> Option<CodeClimateSeverity> {
305    match verdict {
306        crate::health_types::CoverageIntelligenceVerdict::RiskyChangeDetected
307        | crate::health_types::CoverageIntelligenceVerdict::HighConfidenceDelete => {
308            Some(CodeClimateSeverity::Major)
309        }
310        crate::health_types::CoverageIntelligenceVerdict::ReviewRequired
311        | crate::health_types::CoverageIntelligenceVerdict::RefactorCarefully => {
312            Some(CodeClimateSeverity::Minor)
313        }
314        crate::health_types::CoverageIntelligenceVerdict::Clean
315        | crate::health_types::CoverageIntelligenceVerdict::Unknown => None,
316    }
317}
318
319/// Push CodeClimate issues for unused dependencies with a shared structure.
320fn push_dep_cc_issues<'a, I>(
321    issues: &mut Vec<CodeClimateIssue>,
322    deps: I,
323    root: &Path,
324    rule_id: &str,
325    location_label: &str,
326    severity: Severity,
327) where
328    I: IntoIterator<Item = &'a fallow_core::results::UnusedDependency>,
329{
330    for dep in deps {
331        let level = severity_to_codeclimate(severity);
332        let path = cc_path(&dep.path, root);
333        let line = if dep.line > 0 { Some(dep.line) } else { None };
334        let fp = fingerprint_hash(&[rule_id, &dep.package_name]);
335        let workspace_context = if dep.used_in_workspaces.is_empty() {
336            String::new()
337        } else {
338            let workspaces = dep
339                .used_in_workspaces
340                .iter()
341                .map(|path| cc_path(path, root))
342                .collect::<Vec<_>>()
343                .join(", ");
344            format!("; imported in other workspaces: {workspaces}")
345        };
346        issues.push(cc_issue(
347            rule_id,
348            &format!(
349                "Package '{}' is in {location_label} but never imported{workspace_context}",
350                dep.package_name
351            ),
352            level,
353            "Bug Risk",
354            &path,
355            line,
356            &fp,
357        ));
358    }
359}
360
361fn push_unused_file_issues(
362    issues: &mut Vec<CodeClimateIssue>,
363    files: &[fallow_types::output_dead_code::UnusedFileFinding],
364    root: &Path,
365    severity: Severity,
366) {
367    if files.is_empty() {
368        return;
369    }
370    let level = severity_to_codeclimate(severity);
371    for entry in files {
372        let path = cc_path(&entry.file.path, root);
373        let fp = fingerprint_hash(&["fallow/unused-file", &path]);
374        issues.push(cc_issue(
375            "fallow/unused-file",
376            "File is not reachable from any entry point",
377            level,
378            "Bug Risk",
379            &path,
380            None,
381            &fp,
382        ));
383    }
384}
385
386/// Push CodeClimate issues for unused exports or unused types.
387///
388/// `direct_label` / `re_export_label` let the same helper produce the right
389/// prose for both `unused-export` (Export / Re-export) and `unused-type`
390/// (Type export / Type re-export) rule ids.
391struct UnusedExportIssuesInput<'a, I> {
392    issues: &'a mut Vec<CodeClimateIssue>,
393    exports: I,
394    root: &'a Path,
395    rule_id: &'a str,
396    direct_label: &'a str,
397    re_export_label: &'a str,
398    severity: Severity,
399}
400
401fn push_unused_export_issues<'a, I>(input: UnusedExportIssuesInput<'a, I>)
402where
403    I: IntoIterator<Item = &'a fallow_core::results::UnusedExport>,
404{
405    for export in input.exports {
406        let level = severity_to_codeclimate(input.severity);
407        let path = cc_path(&export.path, input.root);
408        let kind = if export.is_re_export {
409            input.re_export_label
410        } else {
411            input.direct_label
412        };
413        let line_str = export.line.to_string();
414        let fp = fingerprint_hash(&[input.rule_id, &path, &line_str, &export.export_name]);
415        input.issues.push(cc_issue(
416            input.rule_id,
417            &format!(
418                "{kind} '{}' is never imported by other modules",
419                export.export_name
420            ),
421            level,
422            "Bug Risk",
423            &path,
424            Some(export.line),
425            &fp,
426        ));
427    }
428}
429
430fn push_private_type_leak_issues(
431    issues: &mut Vec<CodeClimateIssue>,
432    leaks: &[fallow_types::output_dead_code::PrivateTypeLeakFinding],
433    root: &Path,
434    severity: Severity,
435) {
436    if leaks.is_empty() {
437        return;
438    }
439    let level = severity_to_codeclimate(severity);
440    for entry in leaks {
441        let leak = &entry.leak;
442        let path = cc_path(&leak.path, root);
443        let line_str = leak.line.to_string();
444        let fp = fingerprint_hash(&[
445            "fallow/private-type-leak",
446            &path,
447            &line_str,
448            &leak.export_name,
449            &leak.type_name,
450        ]);
451        issues.push(cc_issue(
452            "fallow/private-type-leak",
453            &format!(
454                "Export '{}' references private type '{}'",
455                leak.export_name, leak.type_name
456            ),
457            level,
458            "Bug Risk",
459            &path,
460            Some(leak.line),
461            &fp,
462        ));
463    }
464}
465
466fn push_type_only_dep_issues(
467    issues: &mut Vec<CodeClimateIssue>,
468    deps: &[fallow_core::results::TypeOnlyDependencyFinding],
469    root: &Path,
470    severity: Severity,
471) {
472    if deps.is_empty() {
473        return;
474    }
475    let level = severity_to_codeclimate(severity);
476    for entry in deps {
477        let dep = &entry.dep;
478        let path = cc_path(&dep.path, root);
479        let line = if dep.line > 0 { Some(dep.line) } else { None };
480        let fp = fingerprint_hash(&["fallow/type-only-dependency", &dep.package_name]);
481        issues.push(cc_issue(
482            "fallow/type-only-dependency",
483            &format!(
484                "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
485                dep.package_name
486            ),
487            level,
488            "Bug Risk",
489            &path,
490            line,
491            &fp,
492        ));
493    }
494}
495
496fn push_test_only_dep_issues(
497    issues: &mut Vec<CodeClimateIssue>,
498    deps: &[fallow_core::results::TestOnlyDependencyFinding],
499    root: &Path,
500    severity: Severity,
501) {
502    if deps.is_empty() {
503        return;
504    }
505    let level = severity_to_codeclimate(severity);
506    for entry in deps {
507        let dep = &entry.dep;
508        let path = cc_path(&dep.path, root);
509        let line = if dep.line > 0 { Some(dep.line) } else { None };
510        let fp = fingerprint_hash(&["fallow/test-only-dependency", &dep.package_name]);
511        issues.push(cc_issue(
512            "fallow/test-only-dependency",
513            &format!(
514                "Package '{}' is only imported by test files (consider moving to devDependencies)",
515                dep.package_name
516            ),
517            level,
518            "Bug Risk",
519            &path,
520            line,
521            &fp,
522        ));
523    }
524}
525
526/// Push CodeClimate issues for unused enum or class members.
527///
528/// `entity_label` is `"Enum"` or `"Class"` so the rendered description reads
529/// "Enum member ..." or "Class member ..." accordingly.
530fn push_unused_member_issues<'a, I>(
531    issues: &mut Vec<CodeClimateIssue>,
532    members: I,
533    root: &Path,
534    rule_id: &str,
535    entity_label: &str,
536    severity: Severity,
537) where
538    I: IntoIterator<Item = &'a fallow_core::results::UnusedMember>,
539{
540    for member in members {
541        let level = severity_to_codeclimate(severity);
542        let path = cc_path(&member.path, root);
543        let line_str = member.line.to_string();
544        let fp = fingerprint_hash(&[
545            rule_id,
546            &path,
547            &line_str,
548            &member.parent_name,
549            &member.member_name,
550        ]);
551        issues.push(cc_issue(
552            rule_id,
553            &format!(
554                "{entity_label} member '{}.{}' is never referenced",
555                member.parent_name, member.member_name
556            ),
557            level,
558            "Bug Risk",
559            &path,
560            Some(member.line),
561            &fp,
562        ));
563    }
564}
565
566fn push_unresolved_import_issues(
567    issues: &mut Vec<CodeClimateIssue>,
568    imports: &[fallow_types::output_dead_code::UnresolvedImportFinding],
569    root: &Path,
570    severity: Severity,
571) {
572    if imports.is_empty() {
573        return;
574    }
575    let level = severity_to_codeclimate(severity);
576    for entry in imports {
577        let import = &entry.import;
578        let path = cc_path(&import.path, root);
579        let line_str = import.line.to_string();
580        let fp = fingerprint_hash(&[
581            "fallow/unresolved-import",
582            &path,
583            &line_str,
584            &import.specifier,
585        ]);
586        issues.push(cc_issue(
587            "fallow/unresolved-import",
588            &format!("Import '{}' could not be resolved", import.specifier),
589            level,
590            "Bug Risk",
591            &path,
592            Some(import.line),
593            &fp,
594        ));
595    }
596}
597
598fn push_unlisted_dep_issues(
599    issues: &mut Vec<CodeClimateIssue>,
600    deps: &[fallow_core::results::UnlistedDependencyFinding],
601    root: &Path,
602    severity: Severity,
603) {
604    if deps.is_empty() {
605        return;
606    }
607    let level = severity_to_codeclimate(severity);
608    for entry in deps {
609        let dep = &entry.dep;
610        for site in &dep.imported_from {
611            let path = cc_path(&site.path, root);
612            let line_str = site.line.to_string();
613            let fp = fingerprint_hash(&[
614                "fallow/unlisted-dependency",
615                &path,
616                &line_str,
617                &dep.package_name,
618            ]);
619            issues.push(cc_issue(
620                "fallow/unlisted-dependency",
621                &format!(
622                    "Package '{}' is imported but not listed in package.json",
623                    dep.package_name
624                ),
625                level,
626                "Bug Risk",
627                &path,
628                Some(site.line),
629                &fp,
630            ));
631        }
632    }
633}
634
635fn push_duplicate_export_issues(
636    issues: &mut Vec<CodeClimateIssue>,
637    dups: &[fallow_core::results::DuplicateExportFinding],
638    root: &Path,
639    severity: Severity,
640) {
641    if dups.is_empty() {
642        return;
643    }
644    let level = severity_to_codeclimate(severity);
645    for dup in dups {
646        let dup = &dup.export;
647        for loc in &dup.locations {
648            let path = cc_path(&loc.path, root);
649            let line_str = loc.line.to_string();
650            let fp = fingerprint_hash(&[
651                "fallow/duplicate-export",
652                &path,
653                &line_str,
654                &dup.export_name,
655            ]);
656            issues.push(cc_issue(
657                "fallow/duplicate-export",
658                &format!("Export '{}' appears in multiple modules", dup.export_name),
659                level,
660                "Bug Risk",
661                &path,
662                Some(loc.line),
663                &fp,
664            ));
665        }
666    }
667}
668
669fn push_circular_dep_issues(
670    issues: &mut Vec<CodeClimateIssue>,
671    cycles: &[fallow_types::output_dead_code::CircularDependencyFinding],
672    root: &Path,
673    severity: Severity,
674) {
675    if cycles.is_empty() {
676        return;
677    }
678    let level = severity_to_codeclimate(severity);
679    for entry in cycles {
680        let cycle = &entry.cycle;
681        let Some(first) = cycle.files.first() else {
682            continue;
683        };
684        let path = cc_path(first, root);
685        let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
686        let chain_str = chain.join(":");
687        let fp = fingerprint_hash(&["fallow/circular-dependency", &chain_str]);
688        let line = if cycle.line > 0 {
689            Some(cycle.line)
690        } else {
691            None
692        };
693        issues.push(cc_issue(
694            "fallow/circular-dependency",
695            &format!(
696                "Circular dependency{}: {}",
697                if cycle.is_cross_package {
698                    " (cross-package)"
699                } else {
700                    ""
701                },
702                chain.join(" \u{2192} ")
703            ),
704            level,
705            "Bug Risk",
706            &path,
707            line,
708            &fp,
709        ));
710    }
711}
712
713fn push_re_export_cycle_issues(
714    issues: &mut Vec<CodeClimateIssue>,
715    cycles: &[fallow_types::output_dead_code::ReExportCycleFinding],
716    root: &Path,
717    severity: Severity,
718) {
719    if cycles.is_empty() {
720        return;
721    }
722    let level = severity_to_codeclimate(severity);
723    for entry in cycles {
724        let cycle = &entry.cycle;
725        let Some(first) = cycle.files.first() else {
726            continue;
727        };
728        let path = cc_path(first, root);
729        let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
730        let chain_str = chain.join(":");
731        let kind_token = match cycle.kind {
732            fallow_core::results::ReExportCycleKind::SelfLoop => "self-loop",
733            fallow_core::results::ReExportCycleKind::MultiNode => "multi-node",
734        };
735        let kind_tag = match cycle.kind {
736            fallow_core::results::ReExportCycleKind::SelfLoop => " (self-loop)",
737            fallow_core::results::ReExportCycleKind::MultiNode => "",
738        };
739        let fp = fingerprint_hash(&["fallow/re-export-cycle", kind_token, &chain_str]);
740        issues.push(cc_issue(
741            "fallow/re-export-cycle",
742            &format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
743            level,
744            "Bug Risk",
745            &path,
746            None,
747            &fp,
748        ));
749    }
750}
751
752fn push_boundary_violation_issues(
753    issues: &mut Vec<CodeClimateIssue>,
754    violations: &[fallow_types::output_dead_code::BoundaryViolationFinding],
755    root: &Path,
756    severity: Severity,
757) {
758    if violations.is_empty() {
759        return;
760    }
761    let level = severity_to_codeclimate(severity);
762    for entry in violations {
763        let v = &entry.violation;
764        let path = cc_path(&v.from_path, root);
765        let to = cc_path(&v.to_path, root);
766        let fp = fingerprint_hash(&["fallow/boundary-violation", &path, &to]);
767        let line = if v.line > 0 { Some(v.line) } else { None };
768        issues.push(cc_issue(
769            "fallow/boundary-violation",
770            &format!(
771                "Boundary violation: {} -> {} ({} -> {})",
772                path, to, v.from_zone, v.to_zone
773            ),
774            level,
775            "Bug Risk",
776            &path,
777            line,
778            &fp,
779        ));
780    }
781}
782
783fn push_boundary_coverage_issues(
784    issues: &mut Vec<CodeClimateIssue>,
785    violations: &[fallow_types::output_dead_code::BoundaryCoverageViolationFinding],
786    root: &Path,
787    severity: Severity,
788) {
789    if violations.is_empty() {
790        return;
791    }
792    let level = severity_to_codeclimate(severity);
793    for entry in violations {
794        let v = &entry.violation;
795        let path = cc_path(&v.path, root);
796        let fp = fingerprint_hash(&["fallow/boundary-coverage", &path]);
797        let line = if v.line > 0 { Some(v.line) } else { None };
798        issues.push(cc_issue(
799            "fallow/boundary-coverage",
800            &format!("Boundary coverage: {path} matches no configured zone"),
801            level,
802            "Bug Risk",
803            &path,
804            line,
805            &fp,
806        ));
807    }
808}
809
810fn push_boundary_call_issues(
811    issues: &mut Vec<CodeClimateIssue>,
812    violations: &[fallow_types::output_dead_code::BoundaryCallViolationFinding],
813    root: &Path,
814    severity: Severity,
815) {
816    if violations.is_empty() {
817        return;
818    }
819    let level = severity_to_codeclimate(severity);
820    for entry in violations {
821        let v = &entry.violation;
822        let path = cc_path(&v.path, root);
823        let fp = fingerprint_hash(&["fallow/boundary-call-violation", &path, &v.callee]);
824        let line = if v.line > 0 { Some(v.line) } else { None };
825        issues.push(cc_issue(
826            "fallow/boundary-call-violation",
827            &format!(
828                "Boundary call: `{}` matches forbidden pattern `{}` in zone '{}'",
829                v.callee, v.pattern, v.zone
830            ),
831            level,
832            "Bug Risk",
833            &path,
834            line,
835            &fp,
836        ));
837    }
838}
839
840fn push_policy_violation_issues(
841    issues: &mut Vec<CodeClimateIssue>,
842    violations: &[fallow_types::output_dead_code::PolicyViolationFinding],
843    root: &Path,
844) {
845    use fallow_core::results::PolicyViolationSeverity;
846
847    for entry in violations {
848        let v = &entry.violation;
849        let path = cc_path(&v.path, root);
850        let rule = format!("{}/{}", v.pack, v.rule_id);
851        let fp = fingerprint_hash(&["fallow/policy-violation", &path, &rule, &v.matched]);
852        let line = if v.line > 0 { Some(v.line) } else { None };
853        // Severity comes from the EFFECTIVE per-finding value, not the
854        // policy-violation master, so a severity: "error" rule under a warn
855        // master maps to blocker-level just like the exit-code gate.
856        let level = severity_to_codeclimate(match v.severity {
857            PolicyViolationSeverity::Error => Severity::Error,
858            PolicyViolationSeverity::Warn => Severity::Warn,
859        });
860        let message = match &v.message {
861            Some(message) => format!(
862                "Policy violation: `{}` is banned by `{rule}`. {message}",
863                v.matched
864            ),
865            None => format!("Policy violation: `{}` is banned by `{rule}`", v.matched),
866        };
867        issues.push(cc_issue(
868            "fallow/policy-violation",
869            &message,
870            level,
871            "Bug Risk",
872            &path,
873            line,
874            &fp,
875        ));
876    }
877}
878
879fn push_invalid_client_export_issues(
880    issues: &mut Vec<CodeClimateIssue>,
881    findings: &[fallow_types::output_dead_code::InvalidClientExportFinding],
882    root: &Path,
883    severity: Severity,
884) {
885    if findings.is_empty() {
886        return;
887    }
888    let level = severity_to_codeclimate(severity);
889    for entry in findings {
890        let e = &entry.export;
891        let path = cc_path(&e.path, root);
892        let fp = fingerprint_hash(&["fallow/invalid-client-export", &path, &e.export_name]);
893        let line = if e.line > 0 { Some(e.line) } else { None };
894        let message = format!(
895            "Export `{}` is not allowed in a \"{}\" file (Next.js server-only / route-config name)",
896            e.export_name, e.directive
897        );
898        issues.push(cc_issue(
899            "fallow/invalid-client-export",
900            &message,
901            level,
902            "Bug Risk",
903            &path,
904            line,
905            &fp,
906        ));
907    }
908}
909
910fn push_mixed_client_server_barrel_issues(
911    issues: &mut Vec<CodeClimateIssue>,
912    findings: &[fallow_types::output_dead_code::MixedClientServerBarrelFinding],
913    root: &Path,
914    severity: Severity,
915) {
916    if findings.is_empty() {
917        return;
918    }
919    let level = severity_to_codeclimate(severity);
920    for entry in findings {
921        let b = &entry.barrel;
922        let path = cc_path(&b.path, root);
923        let fp = fingerprint_hash(&[
924            "fallow/mixed-client-server-barrel",
925            &path,
926            &b.client_origin,
927            &b.server_origin,
928        ]);
929        let line = if b.line > 0 { Some(b.line) } else { None };
930        let message = format!(
931            "Barrel re-exports both a \"use client\" module (`{}`) and a server-only module (`{}`); one import drags the other's directive across the boundary",
932            b.client_origin, b.server_origin
933        );
934        issues.push(cc_issue(
935            "fallow/mixed-client-server-barrel",
936            &message,
937            level,
938            "Bug Risk",
939            &path,
940            line,
941            &fp,
942        ));
943    }
944}
945
946fn push_misplaced_directive_issues(
947    issues: &mut Vec<CodeClimateIssue>,
948    findings: &[fallow_types::output_dead_code::MisplacedDirectiveFinding],
949    root: &Path,
950    severity: Severity,
951) {
952    if findings.is_empty() {
953        return;
954    }
955    let level = severity_to_codeclimate(severity);
956    for entry in findings {
957        let d = &entry.directive_site;
958        let path = cc_path(&d.path, root);
959        let fp = fingerprint_hash(&[
960            "fallow/misplaced-directive",
961            &path,
962            &d.line.to_string(),
963            &d.directive,
964        ]);
965        let line = if d.line > 0 { Some(d.line) } else { None };
966        let message = format!(
967            "Directive `\"{}\"` is not in the leading position, so the RSC bundler ignores it; move it to the top of the file",
968            d.directive
969        );
970        issues.push(cc_issue(
971            "fallow/misplaced-directive",
972            &message,
973            level,
974            "Bug Risk",
975            &path,
976            line,
977            &fp,
978        ));
979    }
980}
981
982fn push_unprovided_inject_issues(
983    issues: &mut Vec<CodeClimateIssue>,
984    findings: &[fallow_types::output_dead_code::UnprovidedInjectFinding],
985    root: &Path,
986    severity: Severity,
987) {
988    if findings.is_empty() {
989        return;
990    }
991    let level = severity_to_codeclimate(severity);
992    for entry in findings {
993        let i = &entry.inject;
994        let path = cc_path(&i.path, root);
995        let fp = fingerprint_hash(&[
996            "fallow/unprovided-inject",
997            &path,
998            &i.line.to_string(),
999            &i.key_name,
1000        ]);
1001        let line = if i.line > 0 { Some(i.line) } else { None };
1002        let message = format!(
1003            "inject(`{}`) has no matching provide(`{}`) in this project; at runtime it returns undefined (provide the key or remove this inject)",
1004            i.key_name, i.key_name
1005        );
1006        issues.push(cc_issue(
1007            "fallow/unprovided-inject",
1008            &message,
1009            level,
1010            "Bug Risk",
1011            &path,
1012            line,
1013            &fp,
1014        ));
1015    }
1016}
1017
1018fn push_unrendered_component_issues(
1019    issues: &mut Vec<CodeClimateIssue>,
1020    findings: &[fallow_types::output_dead_code::UnrenderedComponentFinding],
1021    root: &Path,
1022    severity: Severity,
1023) {
1024    if findings.is_empty() {
1025        return;
1026    }
1027    let level = severity_to_codeclimate(severity);
1028    for entry in findings {
1029        let c = &entry.component;
1030        let path = cc_path(&c.path, root);
1031        let fp = fingerprint_hash(&[
1032            "fallow/unrendered-component",
1033            &path,
1034            &c.line.to_string(),
1035            &c.component_name,
1036        ]);
1037        let line = if c.line > 0 { Some(c.line) } else { None };
1038        let message = format!(
1039            "component `{}` is reachable but rendered nowhere in this project (render it somewhere or remove it)",
1040            c.component_name
1041        );
1042        issues.push(cc_issue(
1043            "fallow/unrendered-component",
1044            &message,
1045            level,
1046            "Bug Risk",
1047            &path,
1048            line,
1049            &fp,
1050        ));
1051    }
1052}
1053
1054fn push_unused_component_prop_issues(
1055    issues: &mut Vec<CodeClimateIssue>,
1056    findings: &[fallow_types::output_dead_code::UnusedComponentPropFinding],
1057    root: &Path,
1058    severity: Severity,
1059) {
1060    if findings.is_empty() {
1061        return;
1062    }
1063    let level = severity_to_codeclimate(severity);
1064    for entry in findings {
1065        let p = &entry.prop;
1066        let path = cc_path(&p.path, root);
1067        let fp = fingerprint_hash(&[
1068            "fallow/unused-component-prop",
1069            &path,
1070            &p.line.to_string(),
1071            &p.prop_name,
1072        ]);
1073        let line = if p.line > 0 { Some(p.line) } else { None };
1074        let message = format!(
1075            "prop `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1076            p.prop_name, p.component_name
1077        );
1078        issues.push(cc_issue(
1079            "fallow/unused-component-prop",
1080            &message,
1081            level,
1082            "Bug Risk",
1083            &path,
1084            line,
1085            &fp,
1086        ));
1087    }
1088}
1089
1090fn push_unused_component_emit_issues(
1091    issues: &mut Vec<CodeClimateIssue>,
1092    findings: &[fallow_types::output_dead_code::UnusedComponentEmitFinding],
1093    root: &Path,
1094    severity: Severity,
1095) {
1096    if findings.is_empty() {
1097        return;
1098    }
1099    let level = severity_to_codeclimate(severity);
1100    for entry in findings {
1101        let e = &entry.emit;
1102        let path = cc_path(&e.path, root);
1103        let fp = fingerprint_hash(&[
1104            "fallow/unused-component-emit",
1105            &path,
1106            &e.line.to_string(),
1107            &e.emit_name,
1108        ]);
1109        let line = if e.line > 0 { Some(e.line) } else { None };
1110        let message = format!(
1111            "emit `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1112            e.emit_name, e.component_name
1113        );
1114        issues.push(cc_issue(
1115            "fallow/unused-component-emit",
1116            &message,
1117            level,
1118            "Bug Risk",
1119            &path,
1120            line,
1121            &fp,
1122        ));
1123    }
1124}
1125
1126fn push_unused_svelte_event_issues(
1127    issues: &mut Vec<CodeClimateIssue>,
1128    findings: &[fallow_types::output_dead_code::UnusedSvelteEventFinding],
1129    root: &Path,
1130    severity: Severity,
1131) {
1132    if findings.is_empty() {
1133        return;
1134    }
1135    let level = severity_to_codeclimate(severity);
1136    for entry in findings {
1137        let e = &entry.event;
1138        let path = cc_path(&e.path, root);
1139        let fp = fingerprint_hash(&[
1140            "fallow/unused-svelte-event",
1141            &path,
1142            &e.line.to_string(),
1143            &e.event_name,
1144        ]);
1145        let line = if e.line > 0 { Some(e.line) } else { None };
1146        let message = format!(
1147            "event `{}` is dispatched by component `{}` but listened to nowhere in the project (remove it or listen for it)",
1148            e.event_name, e.component_name
1149        );
1150        issues.push(cc_issue(
1151            "fallow/unused-svelte-event",
1152            &message,
1153            level,
1154            "Bug Risk",
1155            &path,
1156            line,
1157            &fp,
1158        ));
1159    }
1160}
1161
1162fn push_unused_component_input_issues(
1163    issues: &mut Vec<CodeClimateIssue>,
1164    findings: &[fallow_types::output_dead_code::UnusedComponentInputFinding],
1165    root: &Path,
1166    severity: Severity,
1167) {
1168    if findings.is_empty() {
1169        return;
1170    }
1171    let level = severity_to_codeclimate(severity);
1172    for entry in findings {
1173        let i = &entry.input;
1174        let path = cc_path(&i.path, root);
1175        let fp = fingerprint_hash(&[
1176            "fallow/unused-component-input",
1177            &path,
1178            &i.line.to_string(),
1179            &i.input_name,
1180        ]);
1181        let line = if i.line > 0 { Some(i.line) } else { None };
1182        let message = format!(
1183            "input `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1184            i.input_name, i.component_name
1185        );
1186        issues.push(cc_issue(
1187            "fallow/unused-component-input",
1188            &message,
1189            level,
1190            "Bug Risk",
1191            &path,
1192            line,
1193            &fp,
1194        ));
1195    }
1196}
1197
1198fn push_unused_component_output_issues(
1199    issues: &mut Vec<CodeClimateIssue>,
1200    findings: &[fallow_types::output_dead_code::UnusedComponentOutputFinding],
1201    root: &Path,
1202    severity: Severity,
1203) {
1204    if findings.is_empty() {
1205        return;
1206    }
1207    let level = severity_to_codeclimate(severity);
1208    for entry in findings {
1209        let o = &entry.output;
1210        let path = cc_path(&o.path, root);
1211        let fp = fingerprint_hash(&[
1212            "fallow/unused-component-output",
1213            &path,
1214            &o.line.to_string(),
1215            &o.output_name,
1216        ]);
1217        let line = if o.line > 0 { Some(o.line) } else { None };
1218        let message = format!(
1219            "output `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1220            o.output_name, o.component_name
1221        );
1222        issues.push(cc_issue(
1223            "fallow/unused-component-output",
1224            &message,
1225            level,
1226            "Bug Risk",
1227            &path,
1228            line,
1229            &fp,
1230        ));
1231    }
1232}
1233
1234fn push_unused_server_action_issues(
1235    issues: &mut Vec<CodeClimateIssue>,
1236    findings: &[fallow_types::output_dead_code::UnusedServerActionFinding],
1237    root: &Path,
1238    severity: Severity,
1239) {
1240    if findings.is_empty() {
1241        return;
1242    }
1243    let level = severity_to_codeclimate(severity);
1244    for entry in findings {
1245        let a = &entry.action;
1246        let path = cc_path(&a.path, root);
1247        let fp = fingerprint_hash(&[
1248            "fallow/unused-server-action",
1249            &path,
1250            &a.line.to_string(),
1251            &a.action_name,
1252        ]);
1253        let line = if a.line > 0 { Some(a.line) } else { None };
1254        let message = format!(
1255            "server action `{}` is exported from a \"use server\" file but no code in this project references it (wire it to a consumer or remove it)",
1256            a.action_name
1257        );
1258        issues.push(cc_issue(
1259            "fallow/unused-server-action",
1260            &message,
1261            level,
1262            "Bug Risk",
1263            &path,
1264            line,
1265            &fp,
1266        ));
1267    }
1268}
1269
1270fn push_unused_load_data_key_issues(
1271    issues: &mut Vec<CodeClimateIssue>,
1272    findings: &[fallow_types::output_dead_code::UnusedLoadDataKeyFinding],
1273    root: &Path,
1274    severity: Severity,
1275) {
1276    if findings.is_empty() {
1277        return;
1278    }
1279    let level = severity_to_codeclimate(severity);
1280    for entry in findings {
1281        let k = &entry.key;
1282        let path = cc_path(&k.path, root);
1283        let fp = fingerprint_hash(&[
1284            "fallow/unused-load-data-key",
1285            &path,
1286            &k.line.to_string(),
1287            &k.key_name,
1288        ]);
1289        let line = if k.line > 0 { Some(k.line) } else { None };
1290        let message = format!(
1291            "load() return key `{}` is read by no consumer (sibling +page.svelte data.<key> or project-wide page.data.<key>); delete the key or wire a consumer",
1292            k.key_name
1293        );
1294        issues.push(cc_issue(
1295            "fallow/unused-load-data-key",
1296            &message,
1297            level,
1298            "Bug Risk",
1299            &path,
1300            line,
1301            &fp,
1302        ));
1303    }
1304}
1305
1306fn push_route_collision_issues(
1307    issues: &mut Vec<CodeClimateIssue>,
1308    findings: &[fallow_types::output_dead_code::RouteCollisionFinding],
1309    root: &Path,
1310    severity: Severity,
1311) {
1312    if findings.is_empty() {
1313        return;
1314    }
1315    let level = severity_to_codeclimate(severity);
1316    for entry in findings {
1317        let c = &entry.collision;
1318        let path = cc_path(&c.path, root);
1319        let fp = fingerprint_hash(&["fallow/route-collision", &path, &c.url]);
1320        let line = if c.line > 0 { Some(c.line) } else { None };
1321        let message = format!(
1322            "Route file resolves to `{}`, also owned by {} other file(s); Next.js fails the build because a URL can have only one owner",
1323            c.url,
1324            c.conflicting_paths.len()
1325        );
1326        issues.push(cc_issue(
1327            "fallow/route-collision",
1328            &message,
1329            level,
1330            "Bug Risk",
1331            &path,
1332            line,
1333            &fp,
1334        ));
1335    }
1336}
1337
1338fn push_dynamic_segment_name_conflict_issues(
1339    issues: &mut Vec<CodeClimateIssue>,
1340    findings: &[fallow_types::output_dead_code::DynamicSegmentNameConflictFinding],
1341    root: &Path,
1342    severity: Severity,
1343) {
1344    if findings.is_empty() {
1345        return;
1346    }
1347    let level = severity_to_codeclimate(severity);
1348    for entry in findings {
1349        let c = &entry.conflict;
1350        let path = cc_path(&c.path, root);
1351        let fp = fingerprint_hash(&["fallow/dynamic-segment-name-conflict", &path, &c.position]);
1352        let line = if c.line > 0 { Some(c.line) } else { None };
1353        let message = format!(
1354            "Dynamic segments at `{}` use different slug names ({}); Next.js requires one consistent name per dynamic path",
1355            c.position,
1356            c.conflicting_segments.join(", ")
1357        );
1358        issues.push(cc_issue(
1359            "fallow/dynamic-segment-name-conflict",
1360            &message,
1361            level,
1362            "Bug Risk",
1363            &path,
1364            line,
1365            &fp,
1366        ));
1367    }
1368}
1369
1370fn push_stale_suppression_issues(
1371    issues: &mut Vec<CodeClimateIssue>,
1372    suppressions: &[fallow_core::results::StaleSuppression],
1373    root: &Path,
1374    rules: &RulesConfig,
1375) {
1376    if suppressions.is_empty() {
1377        return;
1378    }
1379    for s in suppressions {
1380        let severity = if s.missing_reason {
1381            rules.require_suppression_reason
1382        } else {
1383            rules.stale_suppressions
1384        };
1385        let level = severity_to_codeclimate(severity);
1386        let path = cc_path(&s.path, root);
1387        let line_str = s.line.to_string();
1388        let check_name = if s.missing_reason {
1389            "fallow/missing-suppression-reason"
1390        } else {
1391            "fallow/stale-suppression"
1392        };
1393        let fp = fingerprint_hash(&[check_name, &path, &line_str]);
1394        issues.push(cc_issue(
1395            check_name,
1396            &s.display_message(),
1397            level,
1398            "Bug Risk",
1399            &path,
1400            Some(s.line),
1401            &fp,
1402        ));
1403    }
1404}
1405
1406fn push_unused_catalog_entry_issues(
1407    issues: &mut Vec<CodeClimateIssue>,
1408    entries: &[fallow_core::results::UnusedCatalogEntryFinding],
1409    root: &Path,
1410    severity: Severity,
1411) {
1412    if entries.is_empty() {
1413        return;
1414    }
1415    let level = severity_to_codeclimate(severity);
1416    for entry in entries {
1417        let entry = &entry.entry;
1418        let path = cc_path(&entry.path, root);
1419        let line_str = entry.line.to_string();
1420        let fp = fingerprint_hash(&[
1421            "fallow/unused-catalog-entry",
1422            &path,
1423            &line_str,
1424            &entry.catalog_name,
1425            &entry.entry_name,
1426        ]);
1427        let description = if entry.catalog_name == "default" {
1428            format!(
1429                "Catalog entry '{}' is not referenced by any workspace package",
1430                entry.entry_name
1431            )
1432        } else {
1433            format!(
1434                "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
1435                entry.entry_name, entry.catalog_name
1436            )
1437        };
1438        issues.push(cc_issue(
1439            "fallow/unused-catalog-entry",
1440            &description,
1441            level,
1442            "Bug Risk",
1443            &path,
1444            Some(entry.line),
1445            &fp,
1446        ));
1447    }
1448}
1449
1450fn push_unresolved_catalog_reference_issues(
1451    issues: &mut Vec<CodeClimateIssue>,
1452    findings: &[fallow_core::results::UnresolvedCatalogReferenceFinding],
1453    root: &Path,
1454    severity: Severity,
1455) {
1456    if findings.is_empty() {
1457        return;
1458    }
1459    let level = severity_to_codeclimate(severity);
1460    for finding in findings {
1461        let finding = &finding.reference;
1462        let path = cc_path(&finding.path, root);
1463        let line_str = finding.line.to_string();
1464        let fp = fingerprint_hash(&[
1465            "fallow/unresolved-catalog-reference",
1466            &path,
1467            &line_str,
1468            &finding.catalog_name,
1469            &finding.entry_name,
1470        ]);
1471        let catalog_phrase = if finding.catalog_name == "default" {
1472            "the default catalog".to_string()
1473        } else {
1474            format!("catalog '{}'", finding.catalog_name)
1475        };
1476        let mut description = format!(
1477            "Package '{}' is referenced via `catalog:{}` but {} does not declare it; `pnpm install` will fail",
1478            finding.entry_name,
1479            if finding.catalog_name == "default" {
1480                ""
1481            } else {
1482                finding.catalog_name.as_str()
1483            },
1484            catalog_phrase,
1485        );
1486        if !finding.available_in_catalogs.is_empty() {
1487            use std::fmt::Write as _;
1488            let _ = write!(
1489                description,
1490                " (available in: {})",
1491                finding.available_in_catalogs.join(", ")
1492            );
1493        }
1494        issues.push(cc_issue(
1495            "fallow/unresolved-catalog-reference",
1496            &description,
1497            level,
1498            "Bug Risk",
1499            &path,
1500            Some(finding.line),
1501            &fp,
1502        ));
1503    }
1504}
1505
1506fn push_empty_catalog_group_issues(
1507    issues: &mut Vec<CodeClimateIssue>,
1508    groups: &[fallow_core::results::EmptyCatalogGroupFinding],
1509    root: &Path,
1510    severity: Severity,
1511) {
1512    if groups.is_empty() {
1513        return;
1514    }
1515    let level = severity_to_codeclimate(severity);
1516    for group in groups {
1517        let group = &group.group;
1518        let path = cc_path(&group.path, root);
1519        let line_str = group.line.to_string();
1520        let fp = fingerprint_hash(&[
1521            "fallow/empty-catalog-group",
1522            &path,
1523            &line_str,
1524            &group.catalog_name,
1525        ]);
1526        issues.push(cc_issue(
1527            "fallow/empty-catalog-group",
1528            &format!("Catalog group '{}' has no entries", group.catalog_name),
1529            level,
1530            "Bug Risk",
1531            &path,
1532            Some(group.line),
1533            &fp,
1534        ));
1535    }
1536}
1537
1538fn push_unused_dependency_override_issues(
1539    issues: &mut Vec<CodeClimateIssue>,
1540    findings: &[fallow_core::results::UnusedDependencyOverrideFinding],
1541    root: &Path,
1542    severity: Severity,
1543) {
1544    if findings.is_empty() {
1545        return;
1546    }
1547    let level = severity_to_codeclimate(severity);
1548    for finding in findings {
1549        let finding = &finding.entry;
1550        let path = cc_path(&finding.path, root);
1551        let line_str = finding.line.to_string();
1552        let fp = fingerprint_hash(&[
1553            "fallow/unused-dependency-override",
1554            &path,
1555            &line_str,
1556            finding.source.as_label(),
1557            &finding.raw_key,
1558        ]);
1559        let mut description = format!(
1560            "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
1561            finding.raw_key, finding.version_range, finding.target_package,
1562        );
1563        if let Some(hint) = &finding.hint {
1564            use std::fmt::Write as _;
1565            let _ = write!(description, " ({hint})");
1566        }
1567        issues.push(cc_issue(
1568            "fallow/unused-dependency-override",
1569            &description,
1570            level,
1571            "Bug Risk",
1572            &path,
1573            Some(finding.line),
1574            &fp,
1575        ));
1576    }
1577}
1578
1579fn push_misconfigured_dependency_override_issues(
1580    issues: &mut Vec<CodeClimateIssue>,
1581    findings: &[fallow_core::results::MisconfiguredDependencyOverrideFinding],
1582    root: &Path,
1583    severity: Severity,
1584) {
1585    if findings.is_empty() {
1586        return;
1587    }
1588    let level = severity_to_codeclimate(severity);
1589    for finding in findings {
1590        let finding = &finding.entry;
1591        let path = cc_path(&finding.path, root);
1592        let line_str = finding.line.to_string();
1593        let fp = fingerprint_hash(&[
1594            "fallow/misconfigured-dependency-override",
1595            &path,
1596            &line_str,
1597            finding.source.as_label(),
1598            &finding.raw_key,
1599        ]);
1600        let description = format!(
1601            "Override `{}` -> `{}` is malformed: {}",
1602            finding.raw_key,
1603            finding.raw_value,
1604            finding.reason.describe(),
1605        );
1606        issues.push(cc_issue(
1607            "fallow/misconfigured-dependency-override",
1608            &description,
1609            level,
1610            "Bug Risk",
1611            &path,
1612            Some(finding.line),
1613            &fp,
1614        ));
1615    }
1616}
1617
1618/// Serialize a typed CodeClimate issue list to the wire-shape JSON array.
1619/// Centralizes the `serde_json::to_value(&issues)` conversion used by every
1620/// callsite that needs a `serde_json::Value` (PR comment, review envelope,
1621/// CodeClimate format dispatch, combined / audit aggregation).
1622///
1623/// Infallible: `CodeClimateIssue` only contains `String`, `u32`, and enum
1624/// variants serialized as kebab-case strings; serde_json cannot fail on
1625/// these shapes.
1626#[must_use]
1627#[expect(
1628    clippy::expect_used,
1629    reason = "CodeClimateIssue contains only infallibly serializable fields"
1630)]
1631pub fn issues_to_value(issues: &[CodeClimateIssue]) -> serde_json::Value {
1632    serde_json::to_value(issues).expect("CodeClimateIssue serializes infallibly")
1633}
1634
1635/// Build CodeClimate issues from dead-code analysis results.
1636///
1637/// Returns the typed [`CodeClimateIssue`] vec; callers that emit the wire
1638/// shape convert via [`issues_to_value`]. The schema drift gate locks the
1639/// per-issue shape against [`CodeClimateOutput`](
1640/// crate::output_envelope::CodeClimateOutput).
1641#[must_use]
1642pub fn build_codeclimate(
1643    results: &AnalysisResults,
1644    root: &Path,
1645    rules: &RulesConfig,
1646) -> Vec<CodeClimateIssue> {
1647    CodeClimateBuilder {
1648        issues: Vec::new(),
1649        results,
1650        root,
1651        rules,
1652    }
1653    .build()
1654}
1655
1656struct CodeClimateBuilder<'a> {
1657    issues: Vec<CodeClimateIssue>,
1658    results: &'a AnalysisResults,
1659    root: &'a Path,
1660    rules: &'a RulesConfig,
1661}
1662
1663impl CodeClimateBuilder<'_> {
1664    fn build(mut self) -> Vec<CodeClimateIssue> {
1665        self.push_file_and_export_issues();
1666        self.push_private_type_leak_issues();
1667        self.push_package_dependency_issues();
1668        self.push_type_test_dependency_issues();
1669        self.push_member_issues();
1670        self.push_import_and_duplicate_issues();
1671        self.push_graph_issues();
1672        self.push_boundary_issues();
1673        self.push_suppression_and_catalog_issues();
1674        self.push_override_issues();
1675        self.issues
1676    }
1677
1678    fn push_file_and_export_issues(&mut self) {
1679        push_unused_file_issues(
1680            &mut self.issues,
1681            &self.results.unused_files,
1682            self.root,
1683            self.rules.unused_files,
1684        );
1685        push_unused_export_issues(UnusedExportIssuesInput {
1686            issues: &mut self.issues,
1687            exports: self.results.unused_exports.iter().map(|e| &e.export),
1688            root: self.root,
1689            rule_id: "fallow/unused-export",
1690            direct_label: "Export",
1691            re_export_label: "Re-export",
1692            severity: self.rules.unused_exports,
1693        });
1694        push_unused_export_issues(UnusedExportIssuesInput {
1695            issues: &mut self.issues,
1696            exports: self.results.unused_types.iter().map(|e| &e.export),
1697            root: self.root,
1698            rule_id: "fallow/unused-type",
1699            direct_label: "Type export",
1700            re_export_label: "Type re-export",
1701            severity: self.rules.unused_types,
1702        });
1703    }
1704
1705    fn push_private_type_leak_issues(&mut self) {
1706        push_private_type_leak_issues(
1707            &mut self.issues,
1708            &self.results.private_type_leaks,
1709            self.root,
1710            self.rules.private_type_leaks,
1711        );
1712    }
1713
1714    fn push_package_dependency_issues(&mut self) {
1715        push_dep_cc_issues(
1716            &mut self.issues,
1717            self.results.unused_dependencies.iter().map(|f| &f.dep),
1718            self.root,
1719            "fallow/unused-dependency",
1720            "dependencies",
1721            self.rules.unused_dependencies,
1722        );
1723        push_dep_cc_issues(
1724            &mut self.issues,
1725            self.results.unused_dev_dependencies.iter().map(|f| &f.dep),
1726            self.root,
1727            "fallow/unused-dev-dependency",
1728            "devDependencies",
1729            self.rules.unused_dev_dependencies,
1730        );
1731        push_dep_cc_issues(
1732            &mut self.issues,
1733            self.results
1734                .unused_optional_dependencies
1735                .iter()
1736                .map(|f| &f.dep),
1737            self.root,
1738            "fallow/unused-optional-dependency",
1739            "optionalDependencies",
1740            self.rules.unused_optional_dependencies,
1741        );
1742    }
1743
1744    fn push_type_test_dependency_issues(&mut self) {
1745        push_type_only_dep_issues(
1746            &mut self.issues,
1747            &self.results.type_only_dependencies,
1748            self.root,
1749            self.rules.type_only_dependencies,
1750        );
1751        push_test_only_dep_issues(
1752            &mut self.issues,
1753            &self.results.test_only_dependencies,
1754            self.root,
1755            self.rules.test_only_dependencies,
1756        );
1757    }
1758
1759    fn push_member_issues(&mut self) {
1760        push_unused_member_issues(
1761            &mut self.issues,
1762            self.results.unused_enum_members.iter().map(|m| &m.member),
1763            self.root,
1764            "fallow/unused-enum-member",
1765            "Enum",
1766            self.rules.unused_enum_members,
1767        );
1768        push_unused_member_issues(
1769            &mut self.issues,
1770            self.results.unused_class_members.iter().map(|m| &m.member),
1771            self.root,
1772            "fallow/unused-class-member",
1773            "Class",
1774            self.rules.unused_class_members,
1775        );
1776        push_unused_member_issues(
1777            &mut self.issues,
1778            self.results.unused_store_members.iter().map(|m| &m.member),
1779            self.root,
1780            "fallow/unused-store-member",
1781            "Store",
1782            self.rules.unused_store_members,
1783        );
1784    }
1785
1786    fn push_import_and_duplicate_issues(&mut self) {
1787        push_unresolved_import_issues(
1788            &mut self.issues,
1789            &self.results.unresolved_imports,
1790            self.root,
1791            self.rules.unresolved_imports,
1792        );
1793        push_unlisted_dep_issues(
1794            &mut self.issues,
1795            &self.results.unlisted_dependencies,
1796            self.root,
1797            self.rules.unlisted_dependencies,
1798        );
1799        push_duplicate_export_issues(
1800            &mut self.issues,
1801            &self.results.duplicate_exports,
1802            self.root,
1803            self.rules.duplicate_exports,
1804        );
1805    }
1806
1807    fn push_graph_issues(&mut self) {
1808        push_circular_dep_issues(
1809            &mut self.issues,
1810            &self.results.circular_dependencies,
1811            self.root,
1812            self.rules.circular_dependencies,
1813        );
1814        push_re_export_cycle_issues(
1815            &mut self.issues,
1816            &self.results.re_export_cycles,
1817            self.root,
1818            self.rules.re_export_cycle,
1819        );
1820    }
1821
1822    fn push_boundary_issues(&mut self) {
1823        self.push_architecture_boundary_issues();
1824        self.push_client_server_boundary_issues();
1825        self.push_component_boundary_issues();
1826        self.push_framework_route_issues();
1827    }
1828
1829    fn push_architecture_boundary_issues(&mut self) {
1830        push_boundary_violation_issues(
1831            &mut self.issues,
1832            &self.results.boundary_violations,
1833            self.root,
1834            self.rules.boundary_violation,
1835        );
1836        push_boundary_coverage_issues(
1837            &mut self.issues,
1838            &self.results.boundary_coverage_violations,
1839            self.root,
1840            self.rules.boundary_violation,
1841        );
1842        push_boundary_call_issues(
1843            &mut self.issues,
1844            &self.results.boundary_call_violations,
1845            self.root,
1846            self.rules.boundary_violation,
1847        );
1848        push_policy_violation_issues(&mut self.issues, &self.results.policy_violations, self.root);
1849    }
1850
1851    fn push_client_server_boundary_issues(&mut self) {
1852        push_invalid_client_export_issues(
1853            &mut self.issues,
1854            &self.results.invalid_client_exports,
1855            self.root,
1856            self.rules.invalid_client_export,
1857        );
1858        push_mixed_client_server_barrel_issues(
1859            &mut self.issues,
1860            &self.results.mixed_client_server_barrels,
1861            self.root,
1862            self.rules.mixed_client_server_barrel,
1863        );
1864        push_misplaced_directive_issues(
1865            &mut self.issues,
1866            &self.results.misplaced_directives,
1867            self.root,
1868            self.rules.misplaced_directive,
1869        );
1870    }
1871
1872    fn push_component_boundary_issues(&mut self) {
1873        push_unprovided_inject_issues(
1874            &mut self.issues,
1875            &self.results.unprovided_injects,
1876            self.root,
1877            self.rules.unprovided_injects,
1878        );
1879        push_unrendered_component_issues(
1880            &mut self.issues,
1881            &self.results.unrendered_components,
1882            self.root,
1883            self.rules.unrendered_components,
1884        );
1885        push_unused_component_prop_issues(
1886            &mut self.issues,
1887            &self.results.unused_component_props,
1888            self.root,
1889            self.rules.unused_component_props,
1890        );
1891        push_unused_component_emit_issues(
1892            &mut self.issues,
1893            &self.results.unused_component_emits,
1894            self.root,
1895            self.rules.unused_component_emits,
1896        );
1897        push_unused_component_input_issues(
1898            &mut self.issues,
1899            &self.results.unused_component_inputs,
1900            self.root,
1901            self.rules.unused_component_inputs,
1902        );
1903        push_unused_component_output_issues(
1904            &mut self.issues,
1905            &self.results.unused_component_outputs,
1906            self.root,
1907            self.rules.unused_component_outputs,
1908        );
1909        push_unused_svelte_event_issues(
1910            &mut self.issues,
1911            &self.results.unused_svelte_events,
1912            self.root,
1913            self.rules.unused_svelte_events,
1914        );
1915    }
1916
1917    fn push_framework_route_issues(&mut self) {
1918        push_unused_server_action_issues(
1919            &mut self.issues,
1920            &self.results.unused_server_actions,
1921            self.root,
1922            self.rules.unused_server_actions,
1923        );
1924        push_unused_load_data_key_issues(
1925            &mut self.issues,
1926            &self.results.unused_load_data_keys,
1927            self.root,
1928            self.rules.unused_load_data_keys,
1929        );
1930        push_route_collision_issues(
1931            &mut self.issues,
1932            &self.results.route_collisions,
1933            self.root,
1934            self.rules.route_collision,
1935        );
1936        push_dynamic_segment_name_conflict_issues(
1937            &mut self.issues,
1938            &self.results.dynamic_segment_name_conflicts,
1939            self.root,
1940            self.rules.dynamic_segment_name_conflict,
1941        );
1942    }
1943
1944    fn push_suppression_and_catalog_issues(&mut self) {
1945        push_stale_suppression_issues(
1946            &mut self.issues,
1947            &self.results.stale_suppressions,
1948            self.root,
1949            self.rules,
1950        );
1951        push_unused_catalog_entry_issues(
1952            &mut self.issues,
1953            &self.results.unused_catalog_entries,
1954            self.root,
1955            self.rules.unused_catalog_entries,
1956        );
1957        push_empty_catalog_group_issues(
1958            &mut self.issues,
1959            &self.results.empty_catalog_groups,
1960            self.root,
1961            self.rules.empty_catalog_groups,
1962        );
1963        push_unresolved_catalog_reference_issues(
1964            &mut self.issues,
1965            &self.results.unresolved_catalog_references,
1966            self.root,
1967            self.rules.unresolved_catalog_references,
1968        );
1969    }
1970
1971    fn push_override_issues(&mut self) {
1972        push_unused_dependency_override_issues(
1973            &mut self.issues,
1974            &self.results.unused_dependency_overrides,
1975            self.root,
1976            self.rules.unused_dependency_overrides,
1977        );
1978        push_misconfigured_dependency_override_issues(
1979            &mut self.issues,
1980            &self.results.misconfigured_dependency_overrides,
1981            self.root,
1982            self.rules.misconfigured_dependency_overrides,
1983        );
1984    }
1985}
1986
1987/// Print dead-code analysis results in CodeClimate format.
1988pub(super) fn print_codeclimate(
1989    results: &AnalysisResults,
1990    root: &Path,
1991    rules: &RulesConfig,
1992) -> ExitCode {
1993    let issues = build_codeclimate(results, root, rules);
1994    let value = issues_to_value(&issues);
1995    emit_json(&value, "CodeClimate")
1996}
1997
1998/// Print CodeClimate output with owner properties added to each issue.
1999///
2000/// Calls `build_codeclimate` to produce the standard CodeClimate JSON array,
2001/// then post-processes each entry to add `"owner": "@team"` by resolving the
2002/// issue's location path through the `OwnershipResolver`.
2003#[expect(
2004    clippy::expect_used,
2005    reason = "grouped CodeClimate entries are JSON objects created by issues_to_value"
2006)]
2007pub(super) fn print_grouped_codeclimate(
2008    results: &AnalysisResults,
2009    root: &Path,
2010    rules: &RulesConfig,
2011    resolver: &OwnershipResolver,
2012) -> ExitCode {
2013    let issues = build_codeclimate(results, root, rules);
2014    let mut value = issues_to_value(&issues);
2015
2016    if let Some(items) = value.as_array_mut() {
2017        for issue in items {
2018            let path = issue
2019                .pointer("/location/path")
2020                .and_then(|v| v.as_str())
2021                .unwrap_or("");
2022            let owner = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
2023            issue
2024                .as_object_mut()
2025                .expect("CodeClimate issue should be an object")
2026                .insert("owner".to_string(), serde_json::Value::String(owner));
2027        }
2028    }
2029
2030    emit_json(&value, "CodeClimate")
2031}
2032
2033/// Build CodeClimate JSON array from health/complexity analysis results.
2034#[must_use]
2035pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> Vec<CodeClimateIssue> {
2036    let mut issues = Vec::new();
2037    let ctx = HealthCodeClimateContext {
2038        root,
2039        cyc_t: report.summary.max_cyclomatic_threshold,
2040        cog_t: report.summary.max_cognitive_threshold,
2041        crap_t: report.summary.max_crap_threshold,
2042    };
2043
2044    for finding in &report.findings {
2045        issues.push(ctx.complexity_issue(finding));
2046    }
2047
2048    if let Some(ref production) = report.runtime_coverage {
2049        for finding in &production.findings {
2050            issues.push(ctx.runtime_coverage_issue(finding));
2051        }
2052    }
2053
2054    if let Some(ref intelligence) = report.coverage_intelligence {
2055        for finding in &intelligence.findings {
2056            if let Some(issue) = ctx.coverage_intelligence_issue(finding) {
2057                issues.push(issue);
2058            }
2059        }
2060    }
2061
2062    if let Some(ref gaps) = report.coverage_gaps {
2063        for item in &gaps.files {
2064            issues.push(ctx.untested_file_issue(item));
2065        }
2066
2067        for item in &gaps.exports {
2068            issues.push(ctx.untested_export_issue(item));
2069        }
2070    }
2071
2072    issues
2073}
2074
2075/// Print health analysis results in CodeClimate format.
2076pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
2077    let issues = build_health_codeclimate(report, root);
2078    let value = issues_to_value(&issues);
2079    emit_json(&value, "CodeClimate")
2080}
2081
2082/// Print health CodeClimate output with a per-issue `group` field.
2083///
2084/// Mirrors the dead-code grouped CodeClimate pattern
2085/// (`print_grouped_codeclimate`): build the standard payload first, then
2086/// post-process each issue to attach a `group` key derived from the
2087/// `OwnershipResolver`. Lets GitLab Code Quality and other CodeClimate
2088/// consumers partition findings per team / package without re-parsing the
2089/// project structure.
2090#[expect(
2091    clippy::expect_used,
2092    reason = "grouped health CodeClimate entries are JSON objects created by issues_to_value"
2093)]
2094pub(super) fn print_grouped_health_codeclimate(
2095    report: &HealthReport,
2096    root: &Path,
2097    resolver: &OwnershipResolver,
2098) -> ExitCode {
2099    let issues = build_health_codeclimate(report, root);
2100    let mut value = issues_to_value(&issues);
2101
2102    if let Some(items) = value.as_array_mut() {
2103        for issue in items {
2104            let path = issue
2105                .pointer("/location/path")
2106                .and_then(|v| v.as_str())
2107                .unwrap_or("");
2108            let group = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
2109            issue
2110                .as_object_mut()
2111                .expect("CodeClimate issue should be an object")
2112                .insert("group".to_string(), serde_json::Value::String(group));
2113        }
2114    }
2115
2116    emit_json(&value, "CodeClimate")
2117}
2118
2119/// Build CodeClimate JSON array from duplication analysis results.
2120#[must_use]
2121#[expect(
2122    clippy::cast_possible_truncation,
2123    reason = "line numbers are bounded by source size"
2124)]
2125pub fn build_duplication_codeclimate(
2126    report: &DuplicationReport,
2127    root: &Path,
2128) -> Vec<CodeClimateIssue> {
2129    let mut issues = Vec::new();
2130
2131    for (i, group) in report.clone_groups.iter().enumerate() {
2132        let token_str = group.token_count.to_string();
2133        let line_count_str = group.line_count.to_string();
2134        let fragment_prefix: String = group
2135            .instances
2136            .first()
2137            .map(|inst| inst.fragment.chars().take(64).collect())
2138            .unwrap_or_default();
2139
2140        for instance in &group.instances {
2141            let path = cc_path(&instance.file, root);
2142            let start_str = instance.start_line.to_string();
2143            let fp = fingerprint_hash(&[
2144                "fallow/code-duplication",
2145                &path,
2146                &start_str,
2147                &token_str,
2148                &line_count_str,
2149                &fragment_prefix,
2150            ]);
2151            issues.push(cc_issue(
2152                "fallow/code-duplication",
2153                &format!(
2154                    "Code clone group {} ({} lines, {} instances)",
2155                    i + 1,
2156                    group.line_count,
2157                    group.instances.len()
2158                ),
2159                CodeClimateSeverity::Minor,
2160                "Duplication",
2161                &path,
2162                Some(instance.start_line as u32),
2163                &fp,
2164            ));
2165        }
2166    }
2167
2168    issues
2169}
2170
2171/// Print duplication analysis results in CodeClimate format.
2172pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
2173    let issues = build_duplication_codeclimate(report, root);
2174    let value = issues_to_value(&issues);
2175    emit_json(&value, "CodeClimate")
2176}
2177
2178/// Print duplication CodeClimate output with a per-issue `group` field.
2179///
2180/// Mirrors [`print_grouped_health_codeclimate`]: each clone group is attributed
2181/// to its largest owner ([`super::dupes_grouping::largest_owner`]) and every
2182/// CodeClimate issue emitted for that clone group's instances carries the same
2183/// top-level `group` key. Lets GitLab Code Quality and other CodeClimate
2184/// consumers partition findings per team / package / directory without
2185/// re-parsing the project structure.
2186#[expect(
2187    clippy::expect_used,
2188    reason = "grouped duplication CodeClimate entries are JSON objects created by issues_to_value"
2189)]
2190pub(super) fn print_grouped_duplication_codeclimate(
2191    report: &DuplicationReport,
2192    root: &Path,
2193    resolver: &OwnershipResolver,
2194) -> ExitCode {
2195    let issues = build_duplication_codeclimate(report, root);
2196    let mut value = issues_to_value(&issues);
2197
2198    use rustc_hash::FxHashMap;
2199    let mut path_to_owner: FxHashMap<String, String> = FxHashMap::default();
2200    for group in &report.clone_groups {
2201        let owner = super::dupes_grouping::largest_owner(group, root, resolver);
2202        for instance in &group.instances {
2203            let path = cc_path(&instance.file, root);
2204            path_to_owner.insert(path, owner.clone());
2205        }
2206    }
2207
2208    if let Some(items) = value.as_array_mut() {
2209        for issue in items {
2210            let path = issue
2211                .pointer("/location/path")
2212                .and_then(|v| v.as_str())
2213                .unwrap_or("")
2214                .to_string();
2215            let group = path_to_owner
2216                .get(&path)
2217                .cloned()
2218                .unwrap_or_else(|| crate::codeowners::UNOWNED_LABEL.to_string());
2219            issue
2220                .as_object_mut()
2221                .expect("CodeClimate issue should be an object")
2222                .insert("group".to_string(), serde_json::Value::String(group));
2223        }
2224    }
2225
2226    emit_json(&value, "CodeClimate")
2227}
2228
2229#[cfg(test)]
2230mod tests {
2231    use super::*;
2232    use crate::report::test_helpers::sample_results;
2233    use fallow_config::RulesConfig;
2234    use fallow_core::results::*;
2235    use std::path::PathBuf;
2236
2237    /// Compute graduated severity for health findings based on threshold ratio.
2238    /// Kept for unit test coverage of the original CodeClimate severity model.
2239    fn health_severity(value: u16, threshold: u16) -> &'static str {
2240        if threshold == 0 {
2241            return "minor";
2242        }
2243        let ratio = f64::from(value) / f64::from(threshold);
2244        if ratio > 2.5 {
2245            "critical"
2246        } else if ratio > 1.5 {
2247            "major"
2248        } else {
2249            "minor"
2250        }
2251    }
2252
2253    #[test]
2254    fn codeclimate_empty_results_produces_empty_array() {
2255        let root = PathBuf::from("/project");
2256        let results = AnalysisResults::default();
2257        let rules = RulesConfig::default();
2258        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2259        let arr = output.as_array().unwrap();
2260        assert!(arr.is_empty());
2261    }
2262
2263    #[test]
2264    fn codeclimate_produces_array_of_issues() {
2265        let root = PathBuf::from("/project");
2266        let results = sample_results(&root);
2267        let rules = RulesConfig::default();
2268        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2269        assert!(output.is_array());
2270        let arr = output.as_array().unwrap();
2271        assert!(!arr.is_empty());
2272    }
2273
2274    #[test]
2275    fn codeclimate_missing_suppression_reason_uses_reason_rule_severity() {
2276        let root = PathBuf::from("/project");
2277        let mut results = AnalysisResults::default();
2278        results.stale_suppressions.push(StaleSuppression {
2279            path: root.join("src/file.ts"),
2280            line: 1,
2281            col: 0,
2282            origin: SuppressionOrigin::Comment {
2283                issue_kind: Some("unused-exports".to_string()),
2284                reason: None,
2285                is_file_level: false,
2286                kind_known: true,
2287            },
2288            missing_reason: true,
2289            actions: StaleSuppression::actions_for(true),
2290        });
2291        let rules = RulesConfig {
2292            stale_suppressions: Severity::Off,
2293            require_suppression_reason: Severity::Error,
2294            ..Default::default()
2295        };
2296
2297        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2298
2299        assert_eq!(output[0]["check_name"], "fallow/missing-suppression-reason");
2300        assert_eq!(output[0]["severity"], "major");
2301    }
2302
2303    #[test]
2304    fn codeclimate_stale_and_missing_suppression_have_distinct_identities() {
2305        let root = PathBuf::from("/project");
2306        let mut results = AnalysisResults::default();
2307        let origin = SuppressionOrigin::Comment {
2308            issue_kind: Some("unused-exports".to_string()),
2309            reason: None,
2310            is_file_level: false,
2311            kind_known: true,
2312        };
2313        results.stale_suppressions.push(StaleSuppression {
2314            path: root.join("src/file.ts"),
2315            line: 1,
2316            col: 0,
2317            origin: origin.clone(),
2318            missing_reason: false,
2319            actions: StaleSuppression::actions_for(false),
2320        });
2321        results.stale_suppressions.push(StaleSuppression {
2322            path: root.join("src/file.ts"),
2323            line: 1,
2324            col: 0,
2325            origin,
2326            missing_reason: true,
2327            actions: StaleSuppression::actions_for(true),
2328        });
2329        let rules = RulesConfig {
2330            stale_suppressions: Severity::Warn,
2331            require_suppression_reason: Severity::Error,
2332            ..Default::default()
2333        };
2334
2335        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2336
2337        assert_eq!(output[0]["check_name"], "fallow/stale-suppression");
2338        assert_eq!(output[1]["check_name"], "fallow/missing-suppression-reason");
2339        assert_ne!(output[0]["fingerprint"], output[1]["fingerprint"]);
2340    }
2341
2342    #[test]
2343    fn codeclimate_issue_has_required_fields() {
2344        let root = PathBuf::from("/project");
2345        let mut results = AnalysisResults::default();
2346        results
2347            .unused_files
2348            .push(UnusedFileFinding::with_actions(UnusedFile {
2349                path: root.join("src/dead.ts"),
2350            }));
2351        let rules = RulesConfig::default();
2352        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2353        let issue = &output.as_array().unwrap()[0];
2354
2355        assert_eq!(issue["type"], "issue");
2356        assert_eq!(issue["check_name"], "fallow/unused-file");
2357        assert!(issue["description"].is_string());
2358        assert!(issue["categories"].is_array());
2359        assert!(issue["severity"].is_string());
2360        assert!(issue["fingerprint"].is_string());
2361        assert!(issue["location"].is_object());
2362        assert!(issue["location"]["path"].is_string());
2363        assert!(issue["location"]["lines"].is_object());
2364    }
2365
2366    #[test]
2367    fn codeclimate_unused_file_severity_follows_rules() {
2368        let root = PathBuf::from("/project");
2369        let mut results = AnalysisResults::default();
2370        results
2371            .unused_files
2372            .push(UnusedFileFinding::with_actions(UnusedFile {
2373                path: root.join("src/dead.ts"),
2374            }));
2375
2376        let rules = RulesConfig::default();
2377        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2378        assert_eq!(output[0]["severity"], "major");
2379
2380        let rules = RulesConfig {
2381            unused_files: Severity::Warn,
2382            ..RulesConfig::default()
2383        };
2384        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2385        assert_eq!(output[0]["severity"], "minor");
2386    }
2387
2388    #[test]
2389    fn codeclimate_unused_export_has_line_number() {
2390        let root = PathBuf::from("/project");
2391        let mut results = AnalysisResults::default();
2392        results
2393            .unused_exports
2394            .push(UnusedExportFinding::with_actions(UnusedExport {
2395                path: root.join("src/utils.ts"),
2396                export_name: "helperFn".to_string(),
2397                is_type_only: false,
2398                line: 10,
2399                col: 4,
2400                span_start: 120,
2401                is_re_export: false,
2402            }));
2403        let rules = RulesConfig::default();
2404        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2405        let issue = &output[0];
2406        assert_eq!(issue["location"]["lines"]["begin"], 10);
2407    }
2408
2409    #[test]
2410    fn codeclimate_unused_file_line_defaults_to_1() {
2411        let root = PathBuf::from("/project");
2412        let mut results = AnalysisResults::default();
2413        results
2414            .unused_files
2415            .push(UnusedFileFinding::with_actions(UnusedFile {
2416                path: root.join("src/dead.ts"),
2417            }));
2418        let rules = RulesConfig::default();
2419        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2420        let issue = &output[0];
2421        assert_eq!(issue["location"]["lines"]["begin"], 1);
2422    }
2423
2424    #[test]
2425    fn codeclimate_paths_are_relative() {
2426        let root = PathBuf::from("/project");
2427        let mut results = AnalysisResults::default();
2428        results
2429            .unused_files
2430            .push(UnusedFileFinding::with_actions(UnusedFile {
2431                path: root.join("src/deep/nested/file.ts"),
2432            }));
2433        let rules = RulesConfig::default();
2434        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2435        let path = output[0]["location"]["path"].as_str().unwrap();
2436        assert_eq!(path, "src/deep/nested/file.ts");
2437        assert!(!path.starts_with("/project"));
2438    }
2439
2440    #[test]
2441    fn codeclimate_re_export_label_in_description() {
2442        let root = PathBuf::from("/project");
2443        let mut results = AnalysisResults::default();
2444        results
2445            .unused_exports
2446            .push(UnusedExportFinding::with_actions(UnusedExport {
2447                path: root.join("src/index.ts"),
2448                export_name: "reExported".to_string(),
2449                is_type_only: false,
2450                line: 1,
2451                col: 0,
2452                span_start: 0,
2453                is_re_export: true,
2454            }));
2455        let rules = RulesConfig::default();
2456        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2457        let desc = output[0]["description"].as_str().unwrap();
2458        assert!(desc.contains("Re-export"));
2459    }
2460
2461    #[test]
2462    fn codeclimate_unlisted_dep_one_issue_per_import_site() {
2463        let root = PathBuf::from("/project");
2464        let mut results = AnalysisResults::default();
2465        results
2466            .unlisted_dependencies
2467            .push(UnlistedDependencyFinding::with_actions(
2468                UnlistedDependency {
2469                    package_name: "chalk".to_string(),
2470                    imported_from: vec![
2471                        ImportSite {
2472                            path: root.join("src/a.ts"),
2473                            line: 1,
2474                            col: 0,
2475                        },
2476                        ImportSite {
2477                            path: root.join("src/b.ts"),
2478                            line: 5,
2479                            col: 0,
2480                        },
2481                    ],
2482                },
2483            ));
2484        let rules = RulesConfig::default();
2485        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2486        let arr = output.as_array().unwrap();
2487        assert_eq!(arr.len(), 2);
2488        assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
2489        assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
2490    }
2491
2492    #[test]
2493    fn codeclimate_duplicate_export_one_issue_per_location() {
2494        let root = PathBuf::from("/project");
2495        let mut results = AnalysisResults::default();
2496        results
2497            .duplicate_exports
2498            .push(DuplicateExportFinding::with_actions(DuplicateExport {
2499                export_name: "Config".to_string(),
2500                locations: vec![
2501                    DuplicateLocation {
2502                        path: root.join("src/a.ts"),
2503                        line: 10,
2504                        col: 0,
2505                    },
2506                    DuplicateLocation {
2507                        path: root.join("src/b.ts"),
2508                        line: 20,
2509                        col: 0,
2510                    },
2511                    DuplicateLocation {
2512                        path: root.join("src/c.ts"),
2513                        line: 30,
2514                        col: 0,
2515                    },
2516                ],
2517            }));
2518        let rules = RulesConfig::default();
2519        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2520        let arr = output.as_array().unwrap();
2521        assert_eq!(arr.len(), 3);
2522    }
2523
2524    #[test]
2525    fn codeclimate_circular_dep_emits_chain_in_description() {
2526        let root = PathBuf::from("/project");
2527        let mut results = AnalysisResults::default();
2528        results
2529            .circular_dependencies
2530            .push(CircularDependencyFinding::with_actions(
2531                CircularDependency {
2532                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2533                    length: 2,
2534                    line: 3,
2535                    col: 0,
2536                    edges: Vec::new(),
2537                    is_cross_package: false,
2538                },
2539            ));
2540        let rules = RulesConfig::default();
2541        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2542        let desc = output[0]["description"].as_str().unwrap();
2543        assert!(desc.contains("Circular dependency"));
2544        assert!(desc.contains("src/a.ts"));
2545        assert!(desc.contains("src/b.ts"));
2546    }
2547
2548    #[test]
2549    fn codeclimate_fingerprints_are_deterministic() {
2550        let root = PathBuf::from("/project");
2551        let results = sample_results(&root);
2552        let rules = RulesConfig::default();
2553        let output1 = issues_to_value(&build_codeclimate(&results, &root, &rules));
2554        let output2 = issues_to_value(&build_codeclimate(&results, &root, &rules));
2555
2556        let fps1: Vec<&str> = output1
2557            .as_array()
2558            .unwrap()
2559            .iter()
2560            .map(|i| i["fingerprint"].as_str().unwrap())
2561            .collect();
2562        let fps2: Vec<&str> = output2
2563            .as_array()
2564            .unwrap()
2565            .iter()
2566            .map(|i| i["fingerprint"].as_str().unwrap())
2567            .collect();
2568        assert_eq!(fps1, fps2);
2569    }
2570
2571    #[test]
2572    fn codeclimate_fingerprints_are_unique() {
2573        let root = PathBuf::from("/project");
2574        let results = sample_results(&root);
2575        let rules = RulesConfig::default();
2576        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2577
2578        let mut fps: Vec<&str> = output
2579            .as_array()
2580            .unwrap()
2581            .iter()
2582            .map(|i| i["fingerprint"].as_str().unwrap())
2583            .collect();
2584        let original_len = fps.len();
2585        fps.sort_unstable();
2586        fps.dedup();
2587        assert_eq!(fps.len(), original_len, "fingerprints should be unique");
2588    }
2589
2590    #[test]
2591    fn codeclimate_type_only_dep_has_correct_check_name() {
2592        let root = PathBuf::from("/project");
2593        let mut results = AnalysisResults::default();
2594        results
2595            .type_only_dependencies
2596            .push(TypeOnlyDependencyFinding::with_actions(
2597                TypeOnlyDependency {
2598                    package_name: "zod".to_string(),
2599                    path: root.join("package.json"),
2600                    line: 8,
2601                },
2602            ));
2603        let rules = RulesConfig::default();
2604        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2605        assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
2606        let desc = output[0]["description"].as_str().unwrap();
2607        assert!(desc.contains("zod"));
2608        assert!(desc.contains("type-only"));
2609    }
2610
2611    #[test]
2612    fn codeclimate_dep_with_zero_line_omits_line_number() {
2613        let root = PathBuf::from("/project");
2614        let mut results = AnalysisResults::default();
2615        results
2616            .unused_dependencies
2617            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2618                package_name: "lodash".to_string(),
2619                location: DependencyLocation::Dependencies,
2620                path: root.join("package.json"),
2621                line: 0,
2622                used_in_workspaces: Vec::new(),
2623            }));
2624        let rules = RulesConfig::default();
2625        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2626        assert_eq!(output[0]["location"]["lines"]["begin"], 1);
2627    }
2628
2629    #[test]
2630    fn fingerprint_hash_different_inputs_differ() {
2631        let h1 = fingerprint_hash(&["a", "b"]);
2632        let h2 = fingerprint_hash(&["a", "c"]);
2633        assert_ne!(h1, h2);
2634    }
2635
2636    #[test]
2637    fn fingerprint_hash_order_matters() {
2638        let h1 = fingerprint_hash(&["a", "b"]);
2639        let h2 = fingerprint_hash(&["b", "a"]);
2640        assert_ne!(h1, h2);
2641    }
2642
2643    #[test]
2644    fn fingerprint_hash_separator_prevents_collision() {
2645        let h1 = fingerprint_hash(&["ab", "c"]);
2646        let h2 = fingerprint_hash(&["a", "bc"]);
2647        assert_ne!(h1, h2);
2648    }
2649
2650    #[test]
2651    fn fingerprint_hash_is_16_hex_chars() {
2652        let h = fingerprint_hash(&["test"]);
2653        assert_eq!(h.len(), 16);
2654        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
2655    }
2656
2657    #[test]
2658    fn severity_error_maps_to_major() {
2659        assert_eq!(
2660            severity_to_codeclimate(Severity::Error),
2661            CodeClimateSeverity::Major
2662        );
2663    }
2664
2665    #[test]
2666    fn severity_warn_maps_to_minor() {
2667        assert_eq!(
2668            severity_to_codeclimate(Severity::Warn),
2669            CodeClimateSeverity::Minor
2670        );
2671    }
2672
2673    #[test]
2674    #[should_panic(expected = "internal error: entered unreachable code")]
2675    fn severity_off_is_unreachable() {
2676        let _ = severity_to_codeclimate(Severity::Off);
2677    }
2678
2679    /// Production-mode regression: rules can flip to `Severity::Off` while
2680    /// the matching findings slice arrives empty (the analyzer's own off-
2681    /// rule short-circuit clears the vec, but the generic-iterator helpers
2682    /// in `codeclimate.rs` previously called `severity_to_codeclimate`
2683    /// before checking emptiness and panicked at `Severity::Off`).
2684    /// `fallow dead-code --format codeclimate --production` on any project
2685    /// with a `--production`-suppressed dep / export / member rule used to
2686    /// exit 101 with `entered unreachable code` at `ci/severity.rs:28`.
2687    /// This test exercises all three previously-vulnerable helpers
2688    /// (`push_dep_cc_issues`, `push_unused_export_issues`,
2689    /// `push_unused_member_issues`) through `build_codeclimate`.
2690    #[test]
2691    fn build_codeclimate_with_off_severity_and_empty_findings_does_not_panic() {
2692        let root = PathBuf::from("/project");
2693        let results = AnalysisResults::default();
2694        let rules = RulesConfig {
2695            unused_dependencies: Severity::Off,
2696            unused_dev_dependencies: Severity::Off,
2697            unused_optional_dependencies: Severity::Off,
2698            unused_exports: Severity::Off,
2699            unused_types: Severity::Off,
2700            unused_enum_members: Severity::Off,
2701            unused_class_members: Severity::Off,
2702            ..RulesConfig::default()
2703        };
2704        let issues = build_codeclimate(&results, &root, &rules);
2705        assert!(issues.is_empty());
2706    }
2707
2708    #[test]
2709    fn health_severity_zero_threshold_returns_minor() {
2710        assert_eq!(health_severity(100, 0), "minor");
2711    }
2712
2713    #[test]
2714    fn health_severity_at_threshold_returns_minor() {
2715        assert_eq!(health_severity(10, 10), "minor");
2716    }
2717
2718    #[test]
2719    fn health_severity_1_5x_threshold_returns_minor() {
2720        assert_eq!(health_severity(15, 10), "minor");
2721    }
2722
2723    #[test]
2724    fn health_severity_above_1_5x_returns_major() {
2725        assert_eq!(health_severity(16, 10), "major");
2726    }
2727
2728    #[test]
2729    fn health_severity_at_2_5x_returns_major() {
2730        assert_eq!(health_severity(25, 10), "major");
2731    }
2732
2733    #[test]
2734    fn health_severity_above_2_5x_returns_critical() {
2735        assert_eq!(health_severity(26, 10), "critical");
2736    }
2737
2738    #[test]
2739    fn health_codeclimate_includes_coverage_gaps() {
2740        use crate::health_types::*;
2741
2742        let root = PathBuf::from("/project");
2743        let report = HealthReport {
2744            summary: HealthSummary {
2745                files_analyzed: 10,
2746                functions_analyzed: 50,
2747                ..Default::default()
2748            },
2749            coverage_gaps: Some(CoverageGaps {
2750                summary: CoverageGapSummary {
2751                    runtime_files: 2,
2752                    covered_files: 0,
2753                    file_coverage_pct: 0.0,
2754                    untested_files: 1,
2755                    untested_exports: 1,
2756                },
2757                files: vec![UntestedFileFinding::with_actions(
2758                    UntestedFile {
2759                        path: root.join("src/app.ts"),
2760                        value_export_count: 2,
2761                    },
2762                    &root,
2763                )],
2764                exports: vec![UntestedExportFinding::with_actions(
2765                    UntestedExport {
2766                        path: root.join("src/app.ts"),
2767                        export_name: "loader".into(),
2768                        line: 12,
2769                        col: 4,
2770                    },
2771                    &root,
2772                )],
2773            }),
2774            ..Default::default()
2775        };
2776
2777        let output = issues_to_value(&build_health_codeclimate(&report, &root));
2778        let issues = output.as_array().unwrap();
2779        assert_eq!(issues.len(), 2);
2780        assert_eq!(issues[0]["check_name"], "fallow/untested-file");
2781        assert_eq!(issues[0]["categories"][0], "Coverage");
2782        assert_eq!(issues[0]["location"]["path"], "src/app.ts");
2783        assert_eq!(issues[1]["check_name"], "fallow/untested-export");
2784        assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
2785        assert!(
2786            issues[1]["description"]
2787                .as_str()
2788                .unwrap()
2789                .contains("loader")
2790        );
2791    }
2792
2793    #[test]
2794    fn health_codeclimate_includes_coverage_intelligence_issue() {
2795        use crate::health_types::{
2796            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2797            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2798            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2799            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2800            CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2801            HealthReport, HealthSummary,
2802        };
2803
2804        let root = PathBuf::from("/project");
2805        let report = HealthReport {
2806            summary: HealthSummary {
2807                files_analyzed: 10,
2808                functions_analyzed: 50,
2809                ..Default::default()
2810            },
2811            coverage_intelligence: Some(CoverageIntelligenceReport {
2812                schema_version: CoverageIntelligenceSchemaVersion::V1,
2813                verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2814                summary: CoverageIntelligenceSummary {
2815                    findings: 1,
2816                    high_confidence_deletes: 1,
2817                    ..Default::default()
2818                },
2819                findings: vec![CoverageIntelligenceFinding {
2820                    id: "fallow:coverage-intel:abc123".to_owned(),
2821                    path: root.join("src/dead.ts"),
2822                    identity: Some("deadPath".to_owned()),
2823                    line: 9,
2824                    verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2825                    signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2826                    recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2827                    confidence: CoverageIntelligenceConfidence::High,
2828                    related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2829                    evidence: CoverageIntelligenceEvidence {
2830                        match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2831                        ..Default::default()
2832                    },
2833                    actions: vec![CoverageIntelligenceAction {
2834                        kind: "delete-after-confirming-owner".to_owned(),
2835                        description: "Confirm ownership".to_owned(),
2836                        auto_fixable: false,
2837                    }],
2838                }],
2839            }),
2840            ..Default::default()
2841        };
2842
2843        let output = issues_to_value(&build_health_codeclimate(&report, &root));
2844        let issues = output.as_array().unwrap();
2845        assert_eq!(issues.len(), 1);
2846        assert_eq!(
2847            issues[0]["check_name"],
2848            "fallow/coverage-intelligence-delete"
2849        );
2850        assert!(!issues[0]["fingerprint"].as_str().unwrap().is_empty());
2851        assert_eq!(issues[0]["location"]["path"], "src/dead.ts");
2852        assert!(
2853            issues[0]["description"]
2854                .as_str()
2855                .unwrap()
2856                .contains("deadPath")
2857        );
2858    }
2859
2860    #[test]
2861    fn health_codeclimate_skips_summary_only_coverage_intelligence() {
2862        use crate::health_types::{
2863            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2864            CoverageIntelligenceSummary, CoverageIntelligenceVerdict, HealthReport,
2865        };
2866
2867        let root = PathBuf::from("/project");
2868        let report = HealthReport {
2869            coverage_intelligence: Some(CoverageIntelligenceReport {
2870                schema_version: CoverageIntelligenceSchemaVersion::V1,
2871                verdict: CoverageIntelligenceVerdict::Clean,
2872                summary: CoverageIntelligenceSummary {
2873                    skipped_ambiguous_matches: 2,
2874                    ..Default::default()
2875                },
2876                findings: vec![],
2877            }),
2878            ..Default::default()
2879        };
2880
2881        let issues = build_health_codeclimate(&report, &root);
2882        assert!(issues.is_empty());
2883    }
2884
2885    #[test]
2886    fn health_codeclimate_crap_only_uses_crap_check_name() {
2887        use crate::health_types::{
2888            ComplexityViolation, FindingSeverity, HealthReport, HealthSummary,
2889        };
2890        let root = PathBuf::from("/project");
2891        let report = HealthReport {
2892            findings: vec![
2893                ComplexityViolation {
2894                    path: root.join("src/untested.ts"),
2895                    name: "risky".to_string(),
2896                    line: 7,
2897                    col: 0,
2898                    cyclomatic: 10,
2899                    cognitive: 10,
2900                    line_count: 20,
2901                    param_count: 1,
2902                    react_hook_count: 0,
2903                    react_jsx_max_depth: 0,
2904                    react_prop_count: 0,
2905                    react_hook_profile: None,
2906                    exceeded: crate::health_types::ExceededThreshold::Crap,
2907                    severity: FindingSeverity::High,
2908                    crap: Some(60.0),
2909                    coverage_pct: Some(25.0),
2910                    coverage_tier: None,
2911                    coverage_source: None,
2912                    inherited_from: None,
2913                    component_rollup: None,
2914                    contributions: Vec::new(),
2915                    effective_thresholds: None,
2916                    threshold_source: None,
2917                }
2918                .into(),
2919            ],
2920            summary: HealthSummary {
2921                functions_analyzed: 10,
2922                functions_above_threshold: 1,
2923                ..Default::default()
2924            },
2925            ..Default::default()
2926        };
2927        let json = issues_to_value(&build_health_codeclimate(&report, &root));
2928        let issues = json.as_array().unwrap();
2929        assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
2930        assert_eq!(issues[0]["severity"], "major");
2931        let description = issues[0]["description"].as_str().unwrap();
2932        assert!(description.contains("CRAP score"), "desc: {description}");
2933        assert!(description.contains("coverage 25%"), "desc: {description}");
2934    }
2935}