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.
391fn push_unused_export_issues<'a, I>(
392    issues: &mut Vec<CodeClimateIssue>,
393    exports: I,
394    root: &Path,
395    rule_id: &str,
396    direct_label: &str,
397    re_export_label: &str,
398    severity: Severity,
399) where
400    I: IntoIterator<Item = &'a fallow_core::results::UnusedExport>,
401{
402    for export in exports {
403        let level = severity_to_codeclimate(severity);
404        let path = cc_path(&export.path, root);
405        let kind = if export.is_re_export {
406            re_export_label
407        } else {
408            direct_label
409        };
410        let line_str = export.line.to_string();
411        let fp = fingerprint_hash(&[rule_id, &path, &line_str, &export.export_name]);
412        issues.push(cc_issue(
413            rule_id,
414            &format!(
415                "{kind} '{}' is never imported by other modules",
416                export.export_name
417            ),
418            level,
419            "Bug Risk",
420            &path,
421            Some(export.line),
422            &fp,
423        ));
424    }
425}
426
427fn push_private_type_leak_issues(
428    issues: &mut Vec<CodeClimateIssue>,
429    leaks: &[fallow_types::output_dead_code::PrivateTypeLeakFinding],
430    root: &Path,
431    severity: Severity,
432) {
433    if leaks.is_empty() {
434        return;
435    }
436    let level = severity_to_codeclimate(severity);
437    for entry in leaks {
438        let leak = &entry.leak;
439        let path = cc_path(&leak.path, root);
440        let line_str = leak.line.to_string();
441        let fp = fingerprint_hash(&[
442            "fallow/private-type-leak",
443            &path,
444            &line_str,
445            &leak.export_name,
446            &leak.type_name,
447        ]);
448        issues.push(cc_issue(
449            "fallow/private-type-leak",
450            &format!(
451                "Export '{}' references private type '{}'",
452                leak.export_name, leak.type_name
453            ),
454            level,
455            "Bug Risk",
456            &path,
457            Some(leak.line),
458            &fp,
459        ));
460    }
461}
462
463fn push_type_only_dep_issues(
464    issues: &mut Vec<CodeClimateIssue>,
465    deps: &[fallow_core::results::TypeOnlyDependencyFinding],
466    root: &Path,
467    severity: Severity,
468) {
469    if deps.is_empty() {
470        return;
471    }
472    let level = severity_to_codeclimate(severity);
473    for entry in deps {
474        let dep = &entry.dep;
475        let path = cc_path(&dep.path, root);
476        let line = if dep.line > 0 { Some(dep.line) } else { None };
477        let fp = fingerprint_hash(&["fallow/type-only-dependency", &dep.package_name]);
478        issues.push(cc_issue(
479            "fallow/type-only-dependency",
480            &format!(
481                "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
482                dep.package_name
483            ),
484            level,
485            "Bug Risk",
486            &path,
487            line,
488            &fp,
489        ));
490    }
491}
492
493fn push_test_only_dep_issues(
494    issues: &mut Vec<CodeClimateIssue>,
495    deps: &[fallow_core::results::TestOnlyDependencyFinding],
496    root: &Path,
497    severity: Severity,
498) {
499    if deps.is_empty() {
500        return;
501    }
502    let level = severity_to_codeclimate(severity);
503    for entry in deps {
504        let dep = &entry.dep;
505        let path = cc_path(&dep.path, root);
506        let line = if dep.line > 0 { Some(dep.line) } else { None };
507        let fp = fingerprint_hash(&["fallow/test-only-dependency", &dep.package_name]);
508        issues.push(cc_issue(
509            "fallow/test-only-dependency",
510            &format!(
511                "Package '{}' is only imported by test files (consider moving to devDependencies)",
512                dep.package_name
513            ),
514            level,
515            "Bug Risk",
516            &path,
517            line,
518            &fp,
519        ));
520    }
521}
522
523/// Push CodeClimate issues for unused enum or class members.
524///
525/// `entity_label` is `"Enum"` or `"Class"` so the rendered description reads
526/// "Enum member ..." or "Class member ..." accordingly.
527fn push_unused_member_issues<'a, I>(
528    issues: &mut Vec<CodeClimateIssue>,
529    members: I,
530    root: &Path,
531    rule_id: &str,
532    entity_label: &str,
533    severity: Severity,
534) where
535    I: IntoIterator<Item = &'a fallow_core::results::UnusedMember>,
536{
537    for member in members {
538        let level = severity_to_codeclimate(severity);
539        let path = cc_path(&member.path, root);
540        let line_str = member.line.to_string();
541        let fp = fingerprint_hash(&[
542            rule_id,
543            &path,
544            &line_str,
545            &member.parent_name,
546            &member.member_name,
547        ]);
548        issues.push(cc_issue(
549            rule_id,
550            &format!(
551                "{entity_label} member '{}.{}' is never referenced",
552                member.parent_name, member.member_name
553            ),
554            level,
555            "Bug Risk",
556            &path,
557            Some(member.line),
558            &fp,
559        ));
560    }
561}
562
563fn push_unresolved_import_issues(
564    issues: &mut Vec<CodeClimateIssue>,
565    imports: &[fallow_types::output_dead_code::UnresolvedImportFinding],
566    root: &Path,
567    severity: Severity,
568) {
569    if imports.is_empty() {
570        return;
571    }
572    let level = severity_to_codeclimate(severity);
573    for entry in imports {
574        let import = &entry.import;
575        let path = cc_path(&import.path, root);
576        let line_str = import.line.to_string();
577        let fp = fingerprint_hash(&[
578            "fallow/unresolved-import",
579            &path,
580            &line_str,
581            &import.specifier,
582        ]);
583        issues.push(cc_issue(
584            "fallow/unresolved-import",
585            &format!("Import '{}' could not be resolved", import.specifier),
586            level,
587            "Bug Risk",
588            &path,
589            Some(import.line),
590            &fp,
591        ));
592    }
593}
594
595fn push_unlisted_dep_issues(
596    issues: &mut Vec<CodeClimateIssue>,
597    deps: &[fallow_core::results::UnlistedDependencyFinding],
598    root: &Path,
599    severity: Severity,
600) {
601    if deps.is_empty() {
602        return;
603    }
604    let level = severity_to_codeclimate(severity);
605    for entry in deps {
606        let dep = &entry.dep;
607        for site in &dep.imported_from {
608            let path = cc_path(&site.path, root);
609            let line_str = site.line.to_string();
610            let fp = fingerprint_hash(&[
611                "fallow/unlisted-dependency",
612                &path,
613                &line_str,
614                &dep.package_name,
615            ]);
616            issues.push(cc_issue(
617                "fallow/unlisted-dependency",
618                &format!(
619                    "Package '{}' is imported but not listed in package.json",
620                    dep.package_name
621                ),
622                level,
623                "Bug Risk",
624                &path,
625                Some(site.line),
626                &fp,
627            ));
628        }
629    }
630}
631
632fn push_duplicate_export_issues(
633    issues: &mut Vec<CodeClimateIssue>,
634    dups: &[fallow_core::results::DuplicateExportFinding],
635    root: &Path,
636    severity: Severity,
637) {
638    if dups.is_empty() {
639        return;
640    }
641    let level = severity_to_codeclimate(severity);
642    for dup in dups {
643        let dup = &dup.export;
644        for loc in &dup.locations {
645            let path = cc_path(&loc.path, root);
646            let line_str = loc.line.to_string();
647            let fp = fingerprint_hash(&[
648                "fallow/duplicate-export",
649                &path,
650                &line_str,
651                &dup.export_name,
652            ]);
653            issues.push(cc_issue(
654                "fallow/duplicate-export",
655                &format!("Export '{}' appears in multiple modules", dup.export_name),
656                level,
657                "Bug Risk",
658                &path,
659                Some(loc.line),
660                &fp,
661            ));
662        }
663    }
664}
665
666fn push_circular_dep_issues(
667    issues: &mut Vec<CodeClimateIssue>,
668    cycles: &[fallow_types::output_dead_code::CircularDependencyFinding],
669    root: &Path,
670    severity: Severity,
671) {
672    if cycles.is_empty() {
673        return;
674    }
675    let level = severity_to_codeclimate(severity);
676    for entry in cycles {
677        let cycle = &entry.cycle;
678        let Some(first) = cycle.files.first() else {
679            continue;
680        };
681        let path = cc_path(first, root);
682        let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
683        let chain_str = chain.join(":");
684        let fp = fingerprint_hash(&["fallow/circular-dependency", &chain_str]);
685        let line = if cycle.line > 0 {
686            Some(cycle.line)
687        } else {
688            None
689        };
690        issues.push(cc_issue(
691            "fallow/circular-dependency",
692            &format!(
693                "Circular dependency{}: {}",
694                if cycle.is_cross_package {
695                    " (cross-package)"
696                } else {
697                    ""
698                },
699                chain.join(" \u{2192} ")
700            ),
701            level,
702            "Bug Risk",
703            &path,
704            line,
705            &fp,
706        ));
707    }
708}
709
710fn push_re_export_cycle_issues(
711    issues: &mut Vec<CodeClimateIssue>,
712    cycles: &[fallow_types::output_dead_code::ReExportCycleFinding],
713    root: &Path,
714    severity: Severity,
715) {
716    if cycles.is_empty() {
717        return;
718    }
719    let level = severity_to_codeclimate(severity);
720    for entry in cycles {
721        let cycle = &entry.cycle;
722        let Some(first) = cycle.files.first() else {
723            continue;
724        };
725        let path = cc_path(first, root);
726        let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
727        let chain_str = chain.join(":");
728        let kind_token = match cycle.kind {
729            fallow_core::results::ReExportCycleKind::SelfLoop => "self-loop",
730            fallow_core::results::ReExportCycleKind::MultiNode => "multi-node",
731        };
732        let kind_tag = match cycle.kind {
733            fallow_core::results::ReExportCycleKind::SelfLoop => " (self-loop)",
734            fallow_core::results::ReExportCycleKind::MultiNode => "",
735        };
736        let fp = fingerprint_hash(&["fallow/re-export-cycle", kind_token, &chain_str]);
737        issues.push(cc_issue(
738            "fallow/re-export-cycle",
739            &format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
740            level,
741            "Bug Risk",
742            &path,
743            None,
744            &fp,
745        ));
746    }
747}
748
749fn push_boundary_violation_issues(
750    issues: &mut Vec<CodeClimateIssue>,
751    violations: &[fallow_types::output_dead_code::BoundaryViolationFinding],
752    root: &Path,
753    severity: Severity,
754) {
755    if violations.is_empty() {
756        return;
757    }
758    let level = severity_to_codeclimate(severity);
759    for entry in violations {
760        let v = &entry.violation;
761        let path = cc_path(&v.from_path, root);
762        let to = cc_path(&v.to_path, root);
763        let fp = fingerprint_hash(&["fallow/boundary-violation", &path, &to]);
764        let line = if v.line > 0 { Some(v.line) } else { None };
765        issues.push(cc_issue(
766            "fallow/boundary-violation",
767            &format!(
768                "Boundary violation: {} -> {} ({} -> {})",
769                path, to, v.from_zone, v.to_zone
770            ),
771            level,
772            "Bug Risk",
773            &path,
774            line,
775            &fp,
776        ));
777    }
778}
779
780fn push_boundary_coverage_issues(
781    issues: &mut Vec<CodeClimateIssue>,
782    violations: &[fallow_types::output_dead_code::BoundaryCoverageViolationFinding],
783    root: &Path,
784    severity: Severity,
785) {
786    if violations.is_empty() {
787        return;
788    }
789    let level = severity_to_codeclimate(severity);
790    for entry in violations {
791        let v = &entry.violation;
792        let path = cc_path(&v.path, root);
793        let fp = fingerprint_hash(&["fallow/boundary-coverage", &path]);
794        let line = if v.line > 0 { Some(v.line) } else { None };
795        issues.push(cc_issue(
796            "fallow/boundary-coverage",
797            &format!("Boundary coverage: {path} matches no configured zone"),
798            level,
799            "Bug Risk",
800            &path,
801            line,
802            &fp,
803        ));
804    }
805}
806
807fn push_boundary_call_issues(
808    issues: &mut Vec<CodeClimateIssue>,
809    violations: &[fallow_types::output_dead_code::BoundaryCallViolationFinding],
810    root: &Path,
811    severity: Severity,
812) {
813    if violations.is_empty() {
814        return;
815    }
816    let level = severity_to_codeclimate(severity);
817    for entry in violations {
818        let v = &entry.violation;
819        let path = cc_path(&v.path, root);
820        let fp = fingerprint_hash(&["fallow/boundary-call-violation", &path, &v.callee]);
821        let line = if v.line > 0 { Some(v.line) } else { None };
822        issues.push(cc_issue(
823            "fallow/boundary-call-violation",
824            &format!(
825                "Boundary call: `{}` matches forbidden pattern `{}` in zone '{}'",
826                v.callee, v.pattern, v.zone
827            ),
828            level,
829            "Bug Risk",
830            &path,
831            line,
832            &fp,
833        ));
834    }
835}
836
837fn push_policy_violation_issues(
838    issues: &mut Vec<CodeClimateIssue>,
839    violations: &[fallow_types::output_dead_code::PolicyViolationFinding],
840    root: &Path,
841) {
842    use fallow_core::results::PolicyViolationSeverity;
843
844    for entry in violations {
845        let v = &entry.violation;
846        let path = cc_path(&v.path, root);
847        let rule = format!("{}/{}", v.pack, v.rule_id);
848        let fp = fingerprint_hash(&["fallow/policy-violation", &path, &rule, &v.matched]);
849        let line = if v.line > 0 { Some(v.line) } else { None };
850        // Severity comes from the EFFECTIVE per-finding value, not the
851        // policy-violation master, so a severity: "error" rule under a warn
852        // master maps to blocker-level just like the exit-code gate.
853        let level = severity_to_codeclimate(match v.severity {
854            PolicyViolationSeverity::Error => Severity::Error,
855            PolicyViolationSeverity::Warn => Severity::Warn,
856        });
857        let message = match &v.message {
858            Some(message) => format!(
859                "Policy violation: `{}` is banned by `{rule}`. {message}",
860                v.matched
861            ),
862            None => format!("Policy violation: `{}` is banned by `{rule}`", v.matched),
863        };
864        issues.push(cc_issue(
865            "fallow/policy-violation",
866            &message,
867            level,
868            "Bug Risk",
869            &path,
870            line,
871            &fp,
872        ));
873    }
874}
875
876fn push_stale_suppression_issues(
877    issues: &mut Vec<CodeClimateIssue>,
878    suppressions: &[fallow_core::results::StaleSuppression],
879    root: &Path,
880    severity: Severity,
881) {
882    if suppressions.is_empty() {
883        return;
884    }
885    let level = severity_to_codeclimate(severity);
886    for s in suppressions {
887        let path = cc_path(&s.path, root);
888        let line_str = s.line.to_string();
889        let fp = fingerprint_hash(&["fallow/stale-suppression", &path, &line_str]);
890        issues.push(cc_issue(
891            "fallow/stale-suppression",
892            &s.display_message(),
893            level,
894            "Bug Risk",
895            &path,
896            Some(s.line),
897            &fp,
898        ));
899    }
900}
901
902fn push_unused_catalog_entry_issues(
903    issues: &mut Vec<CodeClimateIssue>,
904    entries: &[fallow_core::results::UnusedCatalogEntryFinding],
905    root: &Path,
906    severity: Severity,
907) {
908    if entries.is_empty() {
909        return;
910    }
911    let level = severity_to_codeclimate(severity);
912    for entry in entries {
913        let entry = &entry.entry;
914        let path = cc_path(&entry.path, root);
915        let line_str = entry.line.to_string();
916        let fp = fingerprint_hash(&[
917            "fallow/unused-catalog-entry",
918            &path,
919            &line_str,
920            &entry.catalog_name,
921            &entry.entry_name,
922        ]);
923        let description = if entry.catalog_name == "default" {
924            format!(
925                "Catalog entry '{}' is not referenced by any workspace package",
926                entry.entry_name
927            )
928        } else {
929            format!(
930                "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
931                entry.entry_name, entry.catalog_name
932            )
933        };
934        issues.push(cc_issue(
935            "fallow/unused-catalog-entry",
936            &description,
937            level,
938            "Bug Risk",
939            &path,
940            Some(entry.line),
941            &fp,
942        ));
943    }
944}
945
946fn push_unresolved_catalog_reference_issues(
947    issues: &mut Vec<CodeClimateIssue>,
948    findings: &[fallow_core::results::UnresolvedCatalogReferenceFinding],
949    root: &Path,
950    severity: Severity,
951) {
952    if findings.is_empty() {
953        return;
954    }
955    let level = severity_to_codeclimate(severity);
956    for finding in findings {
957        let finding = &finding.reference;
958        let path = cc_path(&finding.path, root);
959        let line_str = finding.line.to_string();
960        let fp = fingerprint_hash(&[
961            "fallow/unresolved-catalog-reference",
962            &path,
963            &line_str,
964            &finding.catalog_name,
965            &finding.entry_name,
966        ]);
967        let catalog_phrase = if finding.catalog_name == "default" {
968            "the default catalog".to_string()
969        } else {
970            format!("catalog '{}'", finding.catalog_name)
971        };
972        let mut description = format!(
973            "Package '{}' is referenced via `catalog:{}` but {} does not declare it; `pnpm install` will fail",
974            finding.entry_name,
975            if finding.catalog_name == "default" {
976                ""
977            } else {
978                finding.catalog_name.as_str()
979            },
980            catalog_phrase,
981        );
982        if !finding.available_in_catalogs.is_empty() {
983            use std::fmt::Write as _;
984            let _ = write!(
985                description,
986                " (available in: {})",
987                finding.available_in_catalogs.join(", ")
988            );
989        }
990        issues.push(cc_issue(
991            "fallow/unresolved-catalog-reference",
992            &description,
993            level,
994            "Bug Risk",
995            &path,
996            Some(finding.line),
997            &fp,
998        ));
999    }
1000}
1001
1002fn push_empty_catalog_group_issues(
1003    issues: &mut Vec<CodeClimateIssue>,
1004    groups: &[fallow_core::results::EmptyCatalogGroupFinding],
1005    root: &Path,
1006    severity: Severity,
1007) {
1008    if groups.is_empty() {
1009        return;
1010    }
1011    let level = severity_to_codeclimate(severity);
1012    for group in groups {
1013        let group = &group.group;
1014        let path = cc_path(&group.path, root);
1015        let line_str = group.line.to_string();
1016        let fp = fingerprint_hash(&[
1017            "fallow/empty-catalog-group",
1018            &path,
1019            &line_str,
1020            &group.catalog_name,
1021        ]);
1022        issues.push(cc_issue(
1023            "fallow/empty-catalog-group",
1024            &format!("Catalog group '{}' has no entries", group.catalog_name),
1025            level,
1026            "Bug Risk",
1027            &path,
1028            Some(group.line),
1029            &fp,
1030        ));
1031    }
1032}
1033
1034fn push_unused_dependency_override_issues(
1035    issues: &mut Vec<CodeClimateIssue>,
1036    findings: &[fallow_core::results::UnusedDependencyOverrideFinding],
1037    root: &Path,
1038    severity: Severity,
1039) {
1040    if findings.is_empty() {
1041        return;
1042    }
1043    let level = severity_to_codeclimate(severity);
1044    for finding in findings {
1045        let finding = &finding.entry;
1046        let path = cc_path(&finding.path, root);
1047        let line_str = finding.line.to_string();
1048        let fp = fingerprint_hash(&[
1049            "fallow/unused-dependency-override",
1050            &path,
1051            &line_str,
1052            finding.source.as_label(),
1053            &finding.raw_key,
1054        ]);
1055        let mut description = format!(
1056            "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
1057            finding.raw_key, finding.version_range, finding.target_package,
1058        );
1059        if let Some(hint) = &finding.hint {
1060            use std::fmt::Write as _;
1061            let _ = write!(description, " ({hint})");
1062        }
1063        issues.push(cc_issue(
1064            "fallow/unused-dependency-override",
1065            &description,
1066            level,
1067            "Bug Risk",
1068            &path,
1069            Some(finding.line),
1070            &fp,
1071        ));
1072    }
1073}
1074
1075fn push_misconfigured_dependency_override_issues(
1076    issues: &mut Vec<CodeClimateIssue>,
1077    findings: &[fallow_core::results::MisconfiguredDependencyOverrideFinding],
1078    root: &Path,
1079    severity: Severity,
1080) {
1081    if findings.is_empty() {
1082        return;
1083    }
1084    let level = severity_to_codeclimate(severity);
1085    for finding in findings {
1086        let finding = &finding.entry;
1087        let path = cc_path(&finding.path, root);
1088        let line_str = finding.line.to_string();
1089        let fp = fingerprint_hash(&[
1090            "fallow/misconfigured-dependency-override",
1091            &path,
1092            &line_str,
1093            finding.source.as_label(),
1094            &finding.raw_key,
1095        ]);
1096        let description = format!(
1097            "Override `{}` -> `{}` is malformed: {}",
1098            finding.raw_key,
1099            finding.raw_value,
1100            finding.reason.describe(),
1101        );
1102        issues.push(cc_issue(
1103            "fallow/misconfigured-dependency-override",
1104            &description,
1105            level,
1106            "Bug Risk",
1107            &path,
1108            Some(finding.line),
1109            &fp,
1110        ));
1111    }
1112}
1113
1114/// Serialize a typed CodeClimate issue list to the wire-shape JSON array.
1115/// Centralizes the `serde_json::to_value(&issues)` conversion used by every
1116/// callsite that needs a `serde_json::Value` (PR comment, review envelope,
1117/// CodeClimate format dispatch, combined / audit aggregation).
1118///
1119/// Infallible: `CodeClimateIssue` only contains `String`, `u32`, and enum
1120/// variants serialized as kebab-case strings; serde_json cannot fail on
1121/// these shapes.
1122#[must_use]
1123#[expect(
1124    clippy::expect_used,
1125    reason = "CodeClimateIssue contains only infallibly serializable fields"
1126)]
1127pub fn issues_to_value(issues: &[CodeClimateIssue]) -> serde_json::Value {
1128    serde_json::to_value(issues).expect("CodeClimateIssue serializes infallibly")
1129}
1130
1131/// Build CodeClimate issues from dead-code analysis results.
1132///
1133/// Returns the typed [`CodeClimateIssue`] vec; callers that emit the wire
1134/// shape convert via [`issues_to_value`]. The schema drift gate locks the
1135/// per-issue shape against [`CodeClimateOutput`](
1136/// crate::output_envelope::CodeClimateOutput).
1137#[must_use]
1138pub fn build_codeclimate(
1139    results: &AnalysisResults,
1140    root: &Path,
1141    rules: &RulesConfig,
1142) -> Vec<CodeClimateIssue> {
1143    CodeClimateBuilder {
1144        issues: Vec::new(),
1145        results,
1146        root,
1147        rules,
1148    }
1149    .build()
1150}
1151
1152struct CodeClimateBuilder<'a> {
1153    issues: Vec<CodeClimateIssue>,
1154    results: &'a AnalysisResults,
1155    root: &'a Path,
1156    rules: &'a RulesConfig,
1157}
1158
1159impl CodeClimateBuilder<'_> {
1160    fn build(mut self) -> Vec<CodeClimateIssue> {
1161        self.push_file_and_export_issues();
1162        self.push_private_type_leak_issues();
1163        self.push_package_dependency_issues();
1164        self.push_type_test_dependency_issues();
1165        self.push_member_issues();
1166        self.push_import_and_duplicate_issues();
1167        self.push_graph_issues();
1168        self.push_boundary_issues();
1169        self.push_suppression_and_catalog_issues();
1170        self.push_override_issues();
1171        self.issues
1172    }
1173
1174    fn push_file_and_export_issues(&mut self) {
1175        push_unused_file_issues(
1176            &mut self.issues,
1177            &self.results.unused_files,
1178            self.root,
1179            self.rules.unused_files,
1180        );
1181        push_unused_export_issues(
1182            &mut self.issues,
1183            self.results.unused_exports.iter().map(|e| &e.export),
1184            self.root,
1185            "fallow/unused-export",
1186            "Export",
1187            "Re-export",
1188            self.rules.unused_exports,
1189        );
1190        push_unused_export_issues(
1191            &mut self.issues,
1192            self.results.unused_types.iter().map(|e| &e.export),
1193            self.root,
1194            "fallow/unused-type",
1195            "Type export",
1196            "Type re-export",
1197            self.rules.unused_types,
1198        );
1199    }
1200
1201    fn push_private_type_leak_issues(&mut self) {
1202        push_private_type_leak_issues(
1203            &mut self.issues,
1204            &self.results.private_type_leaks,
1205            self.root,
1206            self.rules.private_type_leaks,
1207        );
1208    }
1209
1210    fn push_package_dependency_issues(&mut self) {
1211        push_dep_cc_issues(
1212            &mut self.issues,
1213            self.results.unused_dependencies.iter().map(|f| &f.dep),
1214            self.root,
1215            "fallow/unused-dependency",
1216            "dependencies",
1217            self.rules.unused_dependencies,
1218        );
1219        push_dep_cc_issues(
1220            &mut self.issues,
1221            self.results.unused_dev_dependencies.iter().map(|f| &f.dep),
1222            self.root,
1223            "fallow/unused-dev-dependency",
1224            "devDependencies",
1225            self.rules.unused_dev_dependencies,
1226        );
1227        push_dep_cc_issues(
1228            &mut self.issues,
1229            self.results
1230                .unused_optional_dependencies
1231                .iter()
1232                .map(|f| &f.dep),
1233            self.root,
1234            "fallow/unused-optional-dependency",
1235            "optionalDependencies",
1236            self.rules.unused_optional_dependencies,
1237        );
1238    }
1239
1240    fn push_type_test_dependency_issues(&mut self) {
1241        push_type_only_dep_issues(
1242            &mut self.issues,
1243            &self.results.type_only_dependencies,
1244            self.root,
1245            self.rules.type_only_dependencies,
1246        );
1247        push_test_only_dep_issues(
1248            &mut self.issues,
1249            &self.results.test_only_dependencies,
1250            self.root,
1251            self.rules.test_only_dependencies,
1252        );
1253    }
1254
1255    fn push_member_issues(&mut self) {
1256        push_unused_member_issues(
1257            &mut self.issues,
1258            self.results.unused_enum_members.iter().map(|m| &m.member),
1259            self.root,
1260            "fallow/unused-enum-member",
1261            "Enum",
1262            self.rules.unused_enum_members,
1263        );
1264        push_unused_member_issues(
1265            &mut self.issues,
1266            self.results.unused_class_members.iter().map(|m| &m.member),
1267            self.root,
1268            "fallow/unused-class-member",
1269            "Class",
1270            self.rules.unused_class_members,
1271        );
1272    }
1273
1274    fn push_import_and_duplicate_issues(&mut self) {
1275        push_unresolved_import_issues(
1276            &mut self.issues,
1277            &self.results.unresolved_imports,
1278            self.root,
1279            self.rules.unresolved_imports,
1280        );
1281        push_unlisted_dep_issues(
1282            &mut self.issues,
1283            &self.results.unlisted_dependencies,
1284            self.root,
1285            self.rules.unlisted_dependencies,
1286        );
1287        push_duplicate_export_issues(
1288            &mut self.issues,
1289            &self.results.duplicate_exports,
1290            self.root,
1291            self.rules.duplicate_exports,
1292        );
1293    }
1294
1295    fn push_graph_issues(&mut self) {
1296        push_circular_dep_issues(
1297            &mut self.issues,
1298            &self.results.circular_dependencies,
1299            self.root,
1300            self.rules.circular_dependencies,
1301        );
1302        push_re_export_cycle_issues(
1303            &mut self.issues,
1304            &self.results.re_export_cycles,
1305            self.root,
1306            self.rules.re_export_cycle,
1307        );
1308    }
1309
1310    fn push_boundary_issues(&mut self) {
1311        push_boundary_violation_issues(
1312            &mut self.issues,
1313            &self.results.boundary_violations,
1314            self.root,
1315            self.rules.boundary_violation,
1316        );
1317        push_boundary_coverage_issues(
1318            &mut self.issues,
1319            &self.results.boundary_coverage_violations,
1320            self.root,
1321            self.rules.boundary_violation,
1322        );
1323        push_boundary_call_issues(
1324            &mut self.issues,
1325            &self.results.boundary_call_violations,
1326            self.root,
1327            self.rules.boundary_violation,
1328        );
1329        push_policy_violation_issues(&mut self.issues, &self.results.policy_violations, self.root);
1330    }
1331
1332    fn push_suppression_and_catalog_issues(&mut self) {
1333        push_stale_suppression_issues(
1334            &mut self.issues,
1335            &self.results.stale_suppressions,
1336            self.root,
1337            self.rules.stale_suppressions,
1338        );
1339        push_unused_catalog_entry_issues(
1340            &mut self.issues,
1341            &self.results.unused_catalog_entries,
1342            self.root,
1343            self.rules.unused_catalog_entries,
1344        );
1345        push_empty_catalog_group_issues(
1346            &mut self.issues,
1347            &self.results.empty_catalog_groups,
1348            self.root,
1349            self.rules.empty_catalog_groups,
1350        );
1351        push_unresolved_catalog_reference_issues(
1352            &mut self.issues,
1353            &self.results.unresolved_catalog_references,
1354            self.root,
1355            self.rules.unresolved_catalog_references,
1356        );
1357    }
1358
1359    fn push_override_issues(&mut self) {
1360        push_unused_dependency_override_issues(
1361            &mut self.issues,
1362            &self.results.unused_dependency_overrides,
1363            self.root,
1364            self.rules.unused_dependency_overrides,
1365        );
1366        push_misconfigured_dependency_override_issues(
1367            &mut self.issues,
1368            &self.results.misconfigured_dependency_overrides,
1369            self.root,
1370            self.rules.misconfigured_dependency_overrides,
1371        );
1372    }
1373}
1374
1375/// Print dead-code analysis results in CodeClimate format.
1376pub(super) fn print_codeclimate(
1377    results: &AnalysisResults,
1378    root: &Path,
1379    rules: &RulesConfig,
1380) -> ExitCode {
1381    let issues = build_codeclimate(results, root, rules);
1382    let value = issues_to_value(&issues);
1383    emit_json(&value, "CodeClimate")
1384}
1385
1386/// Print CodeClimate output with owner properties added to each issue.
1387///
1388/// Calls `build_codeclimate` to produce the standard CodeClimate JSON array,
1389/// then post-processes each entry to add `"owner": "@team"` by resolving the
1390/// issue's location path through the `OwnershipResolver`.
1391#[expect(
1392    clippy::expect_used,
1393    reason = "grouped CodeClimate entries are JSON objects created by issues_to_value"
1394)]
1395pub(super) fn print_grouped_codeclimate(
1396    results: &AnalysisResults,
1397    root: &Path,
1398    rules: &RulesConfig,
1399    resolver: &OwnershipResolver,
1400) -> ExitCode {
1401    let issues = build_codeclimate(results, root, rules);
1402    let mut value = issues_to_value(&issues);
1403
1404    if let Some(items) = value.as_array_mut() {
1405        for issue in items {
1406            let path = issue
1407                .pointer("/location/path")
1408                .and_then(|v| v.as_str())
1409                .unwrap_or("");
1410            let owner = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
1411            issue
1412                .as_object_mut()
1413                .expect("CodeClimate issue should be an object")
1414                .insert("owner".to_string(), serde_json::Value::String(owner));
1415        }
1416    }
1417
1418    emit_json(&value, "CodeClimate")
1419}
1420
1421/// Build CodeClimate JSON array from health/complexity analysis results.
1422#[must_use]
1423pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> Vec<CodeClimateIssue> {
1424    let mut issues = Vec::new();
1425    let ctx = HealthCodeClimateContext {
1426        root,
1427        cyc_t: report.summary.max_cyclomatic_threshold,
1428        cog_t: report.summary.max_cognitive_threshold,
1429        crap_t: report.summary.max_crap_threshold,
1430    };
1431
1432    for finding in &report.findings {
1433        issues.push(ctx.complexity_issue(finding));
1434    }
1435
1436    if let Some(ref production) = report.runtime_coverage {
1437        for finding in &production.findings {
1438            issues.push(ctx.runtime_coverage_issue(finding));
1439        }
1440    }
1441
1442    if let Some(ref intelligence) = report.coverage_intelligence {
1443        for finding in &intelligence.findings {
1444            if let Some(issue) = ctx.coverage_intelligence_issue(finding) {
1445                issues.push(issue);
1446            }
1447        }
1448    }
1449
1450    if let Some(ref gaps) = report.coverage_gaps {
1451        for item in &gaps.files {
1452            issues.push(ctx.untested_file_issue(item));
1453        }
1454
1455        for item in &gaps.exports {
1456            issues.push(ctx.untested_export_issue(item));
1457        }
1458    }
1459
1460    issues
1461}
1462
1463/// Print health analysis results in CodeClimate format.
1464pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
1465    let issues = build_health_codeclimate(report, root);
1466    let value = issues_to_value(&issues);
1467    emit_json(&value, "CodeClimate")
1468}
1469
1470/// Print health CodeClimate output with a per-issue `group` field.
1471///
1472/// Mirrors the dead-code grouped CodeClimate pattern
1473/// (`print_grouped_codeclimate`): build the standard payload first, then
1474/// post-process each issue to attach a `group` key derived from the
1475/// `OwnershipResolver`. Lets GitLab Code Quality and other CodeClimate
1476/// consumers partition findings per team / package without re-parsing the
1477/// project structure.
1478#[expect(
1479    clippy::expect_used,
1480    reason = "grouped health CodeClimate entries are JSON objects created by issues_to_value"
1481)]
1482pub(super) fn print_grouped_health_codeclimate(
1483    report: &HealthReport,
1484    root: &Path,
1485    resolver: &OwnershipResolver,
1486) -> ExitCode {
1487    let issues = build_health_codeclimate(report, root);
1488    let mut value = issues_to_value(&issues);
1489
1490    if let Some(items) = value.as_array_mut() {
1491        for issue in items {
1492            let path = issue
1493                .pointer("/location/path")
1494                .and_then(|v| v.as_str())
1495                .unwrap_or("");
1496            let group = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
1497            issue
1498                .as_object_mut()
1499                .expect("CodeClimate issue should be an object")
1500                .insert("group".to_string(), serde_json::Value::String(group));
1501        }
1502    }
1503
1504    emit_json(&value, "CodeClimate")
1505}
1506
1507/// Build CodeClimate JSON array from duplication analysis results.
1508#[must_use]
1509#[expect(
1510    clippy::cast_possible_truncation,
1511    reason = "line numbers are bounded by source size"
1512)]
1513pub fn build_duplication_codeclimate(
1514    report: &DuplicationReport,
1515    root: &Path,
1516) -> Vec<CodeClimateIssue> {
1517    let mut issues = Vec::new();
1518
1519    for (i, group) in report.clone_groups.iter().enumerate() {
1520        let token_str = group.token_count.to_string();
1521        let line_count_str = group.line_count.to_string();
1522        let fragment_prefix: String = group
1523            .instances
1524            .first()
1525            .map(|inst| inst.fragment.chars().take(64).collect())
1526            .unwrap_or_default();
1527
1528        for instance in &group.instances {
1529            let path = cc_path(&instance.file, root);
1530            let start_str = instance.start_line.to_string();
1531            let fp = fingerprint_hash(&[
1532                "fallow/code-duplication",
1533                &path,
1534                &start_str,
1535                &token_str,
1536                &line_count_str,
1537                &fragment_prefix,
1538            ]);
1539            issues.push(cc_issue(
1540                "fallow/code-duplication",
1541                &format!(
1542                    "Code clone group {} ({} lines, {} instances)",
1543                    i + 1,
1544                    group.line_count,
1545                    group.instances.len()
1546                ),
1547                CodeClimateSeverity::Minor,
1548                "Duplication",
1549                &path,
1550                Some(instance.start_line as u32),
1551                &fp,
1552            ));
1553        }
1554    }
1555
1556    issues
1557}
1558
1559/// Print duplication analysis results in CodeClimate format.
1560pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
1561    let issues = build_duplication_codeclimate(report, root);
1562    let value = issues_to_value(&issues);
1563    emit_json(&value, "CodeClimate")
1564}
1565
1566/// Print duplication CodeClimate output with a per-issue `group` field.
1567///
1568/// Mirrors [`print_grouped_health_codeclimate`]: each clone group is attributed
1569/// to its largest owner ([`super::dupes_grouping::largest_owner`]) and every
1570/// CodeClimate issue emitted for that clone group's instances carries the same
1571/// top-level `group` key. Lets GitLab Code Quality and other CodeClimate
1572/// consumers partition findings per team / package / directory without
1573/// re-parsing the project structure.
1574#[expect(
1575    clippy::expect_used,
1576    reason = "grouped duplication CodeClimate entries are JSON objects created by issues_to_value"
1577)]
1578pub(super) fn print_grouped_duplication_codeclimate(
1579    report: &DuplicationReport,
1580    root: &Path,
1581    resolver: &OwnershipResolver,
1582) -> ExitCode {
1583    let issues = build_duplication_codeclimate(report, root);
1584    let mut value = issues_to_value(&issues);
1585
1586    use rustc_hash::FxHashMap;
1587    let mut path_to_owner: FxHashMap<String, String> = FxHashMap::default();
1588    for group in &report.clone_groups {
1589        let owner = super::dupes_grouping::largest_owner(group, root, resolver);
1590        for instance in &group.instances {
1591            let path = cc_path(&instance.file, root);
1592            path_to_owner.insert(path, owner.clone());
1593        }
1594    }
1595
1596    if let Some(items) = value.as_array_mut() {
1597        for issue in items {
1598            let path = issue
1599                .pointer("/location/path")
1600                .and_then(|v| v.as_str())
1601                .unwrap_or("")
1602                .to_string();
1603            let group = path_to_owner
1604                .get(&path)
1605                .cloned()
1606                .unwrap_or_else(|| crate::codeowners::UNOWNED_LABEL.to_string());
1607            issue
1608                .as_object_mut()
1609                .expect("CodeClimate issue should be an object")
1610                .insert("group".to_string(), serde_json::Value::String(group));
1611        }
1612    }
1613
1614    emit_json(&value, "CodeClimate")
1615}
1616
1617#[cfg(test)]
1618mod tests {
1619    use super::*;
1620    use crate::report::test_helpers::sample_results;
1621    use fallow_config::RulesConfig;
1622    use fallow_core::results::*;
1623    use std::path::PathBuf;
1624
1625    /// Compute graduated severity for health findings based on threshold ratio.
1626    /// Kept for unit test coverage of the original CodeClimate severity model.
1627    fn health_severity(value: u16, threshold: u16) -> &'static str {
1628        if threshold == 0 {
1629            return "minor";
1630        }
1631        let ratio = f64::from(value) / f64::from(threshold);
1632        if ratio > 2.5 {
1633            "critical"
1634        } else if ratio > 1.5 {
1635            "major"
1636        } else {
1637            "minor"
1638        }
1639    }
1640
1641    #[test]
1642    fn codeclimate_empty_results_produces_empty_array() {
1643        let root = PathBuf::from("/project");
1644        let results = AnalysisResults::default();
1645        let rules = RulesConfig::default();
1646        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1647        let arr = output.as_array().unwrap();
1648        assert!(arr.is_empty());
1649    }
1650
1651    #[test]
1652    fn codeclimate_produces_array_of_issues() {
1653        let root = PathBuf::from("/project");
1654        let results = sample_results(&root);
1655        let rules = RulesConfig::default();
1656        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1657        assert!(output.is_array());
1658        let arr = output.as_array().unwrap();
1659        assert!(!arr.is_empty());
1660    }
1661
1662    #[test]
1663    fn codeclimate_issue_has_required_fields() {
1664        let root = PathBuf::from("/project");
1665        let mut results = AnalysisResults::default();
1666        results
1667            .unused_files
1668            .push(UnusedFileFinding::with_actions(UnusedFile {
1669                path: root.join("src/dead.ts"),
1670            }));
1671        let rules = RulesConfig::default();
1672        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1673        let issue = &output.as_array().unwrap()[0];
1674
1675        assert_eq!(issue["type"], "issue");
1676        assert_eq!(issue["check_name"], "fallow/unused-file");
1677        assert!(issue["description"].is_string());
1678        assert!(issue["categories"].is_array());
1679        assert!(issue["severity"].is_string());
1680        assert!(issue["fingerprint"].is_string());
1681        assert!(issue["location"].is_object());
1682        assert!(issue["location"]["path"].is_string());
1683        assert!(issue["location"]["lines"].is_object());
1684    }
1685
1686    #[test]
1687    fn codeclimate_unused_file_severity_follows_rules() {
1688        let root = PathBuf::from("/project");
1689        let mut results = AnalysisResults::default();
1690        results
1691            .unused_files
1692            .push(UnusedFileFinding::with_actions(UnusedFile {
1693                path: root.join("src/dead.ts"),
1694            }));
1695
1696        let rules = RulesConfig::default();
1697        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1698        assert_eq!(output[0]["severity"], "major");
1699
1700        let rules = RulesConfig {
1701            unused_files: Severity::Warn,
1702            ..RulesConfig::default()
1703        };
1704        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1705        assert_eq!(output[0]["severity"], "minor");
1706    }
1707
1708    #[test]
1709    fn codeclimate_unused_export_has_line_number() {
1710        let root = PathBuf::from("/project");
1711        let mut results = AnalysisResults::default();
1712        results
1713            .unused_exports
1714            .push(UnusedExportFinding::with_actions(UnusedExport {
1715                path: root.join("src/utils.ts"),
1716                export_name: "helperFn".to_string(),
1717                is_type_only: false,
1718                line: 10,
1719                col: 4,
1720                span_start: 120,
1721                is_re_export: false,
1722            }));
1723        let rules = RulesConfig::default();
1724        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1725        let issue = &output[0];
1726        assert_eq!(issue["location"]["lines"]["begin"], 10);
1727    }
1728
1729    #[test]
1730    fn codeclimate_unused_file_line_defaults_to_1() {
1731        let root = PathBuf::from("/project");
1732        let mut results = AnalysisResults::default();
1733        results
1734            .unused_files
1735            .push(UnusedFileFinding::with_actions(UnusedFile {
1736                path: root.join("src/dead.ts"),
1737            }));
1738        let rules = RulesConfig::default();
1739        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1740        let issue = &output[0];
1741        assert_eq!(issue["location"]["lines"]["begin"], 1);
1742    }
1743
1744    #[test]
1745    fn codeclimate_paths_are_relative() {
1746        let root = PathBuf::from("/project");
1747        let mut results = AnalysisResults::default();
1748        results
1749            .unused_files
1750            .push(UnusedFileFinding::with_actions(UnusedFile {
1751                path: root.join("src/deep/nested/file.ts"),
1752            }));
1753        let rules = RulesConfig::default();
1754        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1755        let path = output[0]["location"]["path"].as_str().unwrap();
1756        assert_eq!(path, "src/deep/nested/file.ts");
1757        assert!(!path.starts_with("/project"));
1758    }
1759
1760    #[test]
1761    fn codeclimate_re_export_label_in_description() {
1762        let root = PathBuf::from("/project");
1763        let mut results = AnalysisResults::default();
1764        results
1765            .unused_exports
1766            .push(UnusedExportFinding::with_actions(UnusedExport {
1767                path: root.join("src/index.ts"),
1768                export_name: "reExported".to_string(),
1769                is_type_only: false,
1770                line: 1,
1771                col: 0,
1772                span_start: 0,
1773                is_re_export: true,
1774            }));
1775        let rules = RulesConfig::default();
1776        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1777        let desc = output[0]["description"].as_str().unwrap();
1778        assert!(desc.contains("Re-export"));
1779    }
1780
1781    #[test]
1782    fn codeclimate_unlisted_dep_one_issue_per_import_site() {
1783        let root = PathBuf::from("/project");
1784        let mut results = AnalysisResults::default();
1785        results
1786            .unlisted_dependencies
1787            .push(UnlistedDependencyFinding::with_actions(
1788                UnlistedDependency {
1789                    package_name: "chalk".to_string(),
1790                    imported_from: vec![
1791                        ImportSite {
1792                            path: root.join("src/a.ts"),
1793                            line: 1,
1794                            col: 0,
1795                        },
1796                        ImportSite {
1797                            path: root.join("src/b.ts"),
1798                            line: 5,
1799                            col: 0,
1800                        },
1801                    ],
1802                },
1803            ));
1804        let rules = RulesConfig::default();
1805        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1806        let arr = output.as_array().unwrap();
1807        assert_eq!(arr.len(), 2);
1808        assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
1809        assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
1810    }
1811
1812    #[test]
1813    fn codeclimate_duplicate_export_one_issue_per_location() {
1814        let root = PathBuf::from("/project");
1815        let mut results = AnalysisResults::default();
1816        results
1817            .duplicate_exports
1818            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1819                export_name: "Config".to_string(),
1820                locations: vec![
1821                    DuplicateLocation {
1822                        path: root.join("src/a.ts"),
1823                        line: 10,
1824                        col: 0,
1825                    },
1826                    DuplicateLocation {
1827                        path: root.join("src/b.ts"),
1828                        line: 20,
1829                        col: 0,
1830                    },
1831                    DuplicateLocation {
1832                        path: root.join("src/c.ts"),
1833                        line: 30,
1834                        col: 0,
1835                    },
1836                ],
1837            }));
1838        let rules = RulesConfig::default();
1839        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1840        let arr = output.as_array().unwrap();
1841        assert_eq!(arr.len(), 3);
1842    }
1843
1844    #[test]
1845    fn codeclimate_circular_dep_emits_chain_in_description() {
1846        let root = PathBuf::from("/project");
1847        let mut results = AnalysisResults::default();
1848        results
1849            .circular_dependencies
1850            .push(CircularDependencyFinding::with_actions(
1851                CircularDependency {
1852                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1853                    length: 2,
1854                    line: 3,
1855                    col: 0,
1856                    edges: Vec::new(),
1857                    is_cross_package: false,
1858                },
1859            ));
1860        let rules = RulesConfig::default();
1861        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1862        let desc = output[0]["description"].as_str().unwrap();
1863        assert!(desc.contains("Circular dependency"));
1864        assert!(desc.contains("src/a.ts"));
1865        assert!(desc.contains("src/b.ts"));
1866    }
1867
1868    #[test]
1869    fn codeclimate_fingerprints_are_deterministic() {
1870        let root = PathBuf::from("/project");
1871        let results = sample_results(&root);
1872        let rules = RulesConfig::default();
1873        let output1 = issues_to_value(&build_codeclimate(&results, &root, &rules));
1874        let output2 = issues_to_value(&build_codeclimate(&results, &root, &rules));
1875
1876        let fps1: Vec<&str> = output1
1877            .as_array()
1878            .unwrap()
1879            .iter()
1880            .map(|i| i["fingerprint"].as_str().unwrap())
1881            .collect();
1882        let fps2: Vec<&str> = output2
1883            .as_array()
1884            .unwrap()
1885            .iter()
1886            .map(|i| i["fingerprint"].as_str().unwrap())
1887            .collect();
1888        assert_eq!(fps1, fps2);
1889    }
1890
1891    #[test]
1892    fn codeclimate_fingerprints_are_unique() {
1893        let root = PathBuf::from("/project");
1894        let results = sample_results(&root);
1895        let rules = RulesConfig::default();
1896        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1897
1898        let mut fps: Vec<&str> = output
1899            .as_array()
1900            .unwrap()
1901            .iter()
1902            .map(|i| i["fingerprint"].as_str().unwrap())
1903            .collect();
1904        let original_len = fps.len();
1905        fps.sort_unstable();
1906        fps.dedup();
1907        assert_eq!(fps.len(), original_len, "fingerprints should be unique");
1908    }
1909
1910    #[test]
1911    fn codeclimate_type_only_dep_has_correct_check_name() {
1912        let root = PathBuf::from("/project");
1913        let mut results = AnalysisResults::default();
1914        results
1915            .type_only_dependencies
1916            .push(TypeOnlyDependencyFinding::with_actions(
1917                TypeOnlyDependency {
1918                    package_name: "zod".to_string(),
1919                    path: root.join("package.json"),
1920                    line: 8,
1921                },
1922            ));
1923        let rules = RulesConfig::default();
1924        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1925        assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
1926        let desc = output[0]["description"].as_str().unwrap();
1927        assert!(desc.contains("zod"));
1928        assert!(desc.contains("type-only"));
1929    }
1930
1931    #[test]
1932    fn codeclimate_dep_with_zero_line_omits_line_number() {
1933        let root = PathBuf::from("/project");
1934        let mut results = AnalysisResults::default();
1935        results
1936            .unused_dependencies
1937            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1938                package_name: "lodash".to_string(),
1939                location: DependencyLocation::Dependencies,
1940                path: root.join("package.json"),
1941                line: 0,
1942                used_in_workspaces: Vec::new(),
1943            }));
1944        let rules = RulesConfig::default();
1945        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1946        assert_eq!(output[0]["location"]["lines"]["begin"], 1);
1947    }
1948
1949    #[test]
1950    fn fingerprint_hash_different_inputs_differ() {
1951        let h1 = fingerprint_hash(&["a", "b"]);
1952        let h2 = fingerprint_hash(&["a", "c"]);
1953        assert_ne!(h1, h2);
1954    }
1955
1956    #[test]
1957    fn fingerprint_hash_order_matters() {
1958        let h1 = fingerprint_hash(&["a", "b"]);
1959        let h2 = fingerprint_hash(&["b", "a"]);
1960        assert_ne!(h1, h2);
1961    }
1962
1963    #[test]
1964    fn fingerprint_hash_separator_prevents_collision() {
1965        let h1 = fingerprint_hash(&["ab", "c"]);
1966        let h2 = fingerprint_hash(&["a", "bc"]);
1967        assert_ne!(h1, h2);
1968    }
1969
1970    #[test]
1971    fn fingerprint_hash_is_16_hex_chars() {
1972        let h = fingerprint_hash(&["test"]);
1973        assert_eq!(h.len(), 16);
1974        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
1975    }
1976
1977    #[test]
1978    fn severity_error_maps_to_major() {
1979        assert_eq!(
1980            severity_to_codeclimate(Severity::Error),
1981            CodeClimateSeverity::Major
1982        );
1983    }
1984
1985    #[test]
1986    fn severity_warn_maps_to_minor() {
1987        assert_eq!(
1988            severity_to_codeclimate(Severity::Warn),
1989            CodeClimateSeverity::Minor
1990        );
1991    }
1992
1993    #[test]
1994    #[should_panic(expected = "internal error: entered unreachable code")]
1995    fn severity_off_is_unreachable() {
1996        let _ = severity_to_codeclimate(Severity::Off);
1997    }
1998
1999    /// Production-mode regression: rules can flip to `Severity::Off` while
2000    /// the matching findings slice arrives empty (the analyzer's own off-
2001    /// rule short-circuit clears the vec, but the generic-iterator helpers
2002    /// in `codeclimate.rs` previously called `severity_to_codeclimate`
2003    /// before checking emptiness and panicked at `Severity::Off`).
2004    /// `fallow dead-code --format codeclimate --production` on any project
2005    /// with a `--production`-suppressed dep / export / member rule used to
2006    /// exit 101 with `entered unreachable code` at `ci/severity.rs:28`.
2007    /// This test exercises all three previously-vulnerable helpers
2008    /// (`push_dep_cc_issues`, `push_unused_export_issues`,
2009    /// `push_unused_member_issues`) through `build_codeclimate`.
2010    #[test]
2011    fn build_codeclimate_with_off_severity_and_empty_findings_does_not_panic() {
2012        let root = PathBuf::from("/project");
2013        let results = AnalysisResults::default();
2014        let rules = RulesConfig {
2015            unused_dependencies: Severity::Off,
2016            unused_dev_dependencies: Severity::Off,
2017            unused_optional_dependencies: Severity::Off,
2018            unused_exports: Severity::Off,
2019            unused_types: Severity::Off,
2020            unused_enum_members: Severity::Off,
2021            unused_class_members: Severity::Off,
2022            ..RulesConfig::default()
2023        };
2024        let issues = build_codeclimate(&results, &root, &rules);
2025        assert!(issues.is_empty());
2026    }
2027
2028    #[test]
2029    fn health_severity_zero_threshold_returns_minor() {
2030        assert_eq!(health_severity(100, 0), "minor");
2031    }
2032
2033    #[test]
2034    fn health_severity_at_threshold_returns_minor() {
2035        assert_eq!(health_severity(10, 10), "minor");
2036    }
2037
2038    #[test]
2039    fn health_severity_1_5x_threshold_returns_minor() {
2040        assert_eq!(health_severity(15, 10), "minor");
2041    }
2042
2043    #[test]
2044    fn health_severity_above_1_5x_returns_major() {
2045        assert_eq!(health_severity(16, 10), "major");
2046    }
2047
2048    #[test]
2049    fn health_severity_at_2_5x_returns_major() {
2050        assert_eq!(health_severity(25, 10), "major");
2051    }
2052
2053    #[test]
2054    fn health_severity_above_2_5x_returns_critical() {
2055        assert_eq!(health_severity(26, 10), "critical");
2056    }
2057
2058    #[test]
2059    fn health_codeclimate_includes_coverage_gaps() {
2060        use crate::health_types::*;
2061
2062        let root = PathBuf::from("/project");
2063        let report = HealthReport {
2064            summary: HealthSummary {
2065                files_analyzed: 10,
2066                functions_analyzed: 50,
2067                ..Default::default()
2068            },
2069            coverage_gaps: Some(CoverageGaps {
2070                summary: CoverageGapSummary {
2071                    runtime_files: 2,
2072                    covered_files: 0,
2073                    file_coverage_pct: 0.0,
2074                    untested_files: 1,
2075                    untested_exports: 1,
2076                },
2077                files: vec![UntestedFileFinding::with_actions(
2078                    UntestedFile {
2079                        path: root.join("src/app.ts"),
2080                        value_export_count: 2,
2081                    },
2082                    &root,
2083                )],
2084                exports: vec![UntestedExportFinding::with_actions(
2085                    UntestedExport {
2086                        path: root.join("src/app.ts"),
2087                        export_name: "loader".into(),
2088                        line: 12,
2089                        col: 4,
2090                    },
2091                    &root,
2092                )],
2093            }),
2094            ..Default::default()
2095        };
2096
2097        let output = issues_to_value(&build_health_codeclimate(&report, &root));
2098        let issues = output.as_array().unwrap();
2099        assert_eq!(issues.len(), 2);
2100        assert_eq!(issues[0]["check_name"], "fallow/untested-file");
2101        assert_eq!(issues[0]["categories"][0], "Coverage");
2102        assert_eq!(issues[0]["location"]["path"], "src/app.ts");
2103        assert_eq!(issues[1]["check_name"], "fallow/untested-export");
2104        assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
2105        assert!(
2106            issues[1]["description"]
2107                .as_str()
2108                .unwrap()
2109                .contains("loader")
2110        );
2111    }
2112
2113    #[test]
2114    fn health_codeclimate_includes_coverage_intelligence_issue() {
2115        use crate::health_types::{
2116            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2117            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2118            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2119            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2120            CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2121            HealthReport, HealthSummary,
2122        };
2123
2124        let root = PathBuf::from("/project");
2125        let report = HealthReport {
2126            summary: HealthSummary {
2127                files_analyzed: 10,
2128                functions_analyzed: 50,
2129                ..Default::default()
2130            },
2131            coverage_intelligence: Some(CoverageIntelligenceReport {
2132                schema_version: CoverageIntelligenceSchemaVersion::V1,
2133                verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2134                summary: CoverageIntelligenceSummary {
2135                    findings: 1,
2136                    high_confidence_deletes: 1,
2137                    ..Default::default()
2138                },
2139                findings: vec![CoverageIntelligenceFinding {
2140                    id: "fallow:coverage-intel:abc123".to_owned(),
2141                    path: root.join("src/dead.ts"),
2142                    identity: Some("deadPath".to_owned()),
2143                    line: 9,
2144                    verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2145                    signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2146                    recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2147                    confidence: CoverageIntelligenceConfidence::High,
2148                    related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2149                    evidence: CoverageIntelligenceEvidence {
2150                        match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2151                        ..Default::default()
2152                    },
2153                    actions: vec![CoverageIntelligenceAction {
2154                        kind: "delete-after-confirming-owner".to_owned(),
2155                        description: "Confirm ownership".to_owned(),
2156                        auto_fixable: false,
2157                    }],
2158                }],
2159            }),
2160            ..Default::default()
2161        };
2162
2163        let output = issues_to_value(&build_health_codeclimate(&report, &root));
2164        let issues = output.as_array().unwrap();
2165        assert_eq!(issues.len(), 1);
2166        assert_eq!(
2167            issues[0]["check_name"],
2168            "fallow/coverage-intelligence-delete"
2169        );
2170        assert!(!issues[0]["fingerprint"].as_str().unwrap().is_empty());
2171        assert_eq!(issues[0]["location"]["path"], "src/dead.ts");
2172        assert!(
2173            issues[0]["description"]
2174                .as_str()
2175                .unwrap()
2176                .contains("deadPath")
2177        );
2178    }
2179
2180    #[test]
2181    fn health_codeclimate_skips_summary_only_coverage_intelligence() {
2182        use crate::health_types::{
2183            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2184            CoverageIntelligenceSummary, CoverageIntelligenceVerdict, HealthReport,
2185        };
2186
2187        let root = PathBuf::from("/project");
2188        let report = HealthReport {
2189            coverage_intelligence: Some(CoverageIntelligenceReport {
2190                schema_version: CoverageIntelligenceSchemaVersion::V1,
2191                verdict: CoverageIntelligenceVerdict::Clean,
2192                summary: CoverageIntelligenceSummary {
2193                    skipped_ambiguous_matches: 2,
2194                    ..Default::default()
2195                },
2196                findings: vec![],
2197            }),
2198            ..Default::default()
2199        };
2200
2201        let issues = build_health_codeclimate(&report, &root);
2202        assert!(issues.is_empty());
2203    }
2204
2205    #[test]
2206    fn health_codeclimate_crap_only_uses_crap_check_name() {
2207        use crate::health_types::{
2208            ComplexityViolation, FindingSeverity, HealthReport, HealthSummary,
2209        };
2210        let root = PathBuf::from("/project");
2211        let report = HealthReport {
2212            findings: vec![
2213                ComplexityViolation {
2214                    path: root.join("src/untested.ts"),
2215                    name: "risky".to_string(),
2216                    line: 7,
2217                    col: 0,
2218                    cyclomatic: 10,
2219                    cognitive: 10,
2220                    line_count: 20,
2221                    param_count: 1,
2222                    exceeded: crate::health_types::ExceededThreshold::Crap,
2223                    severity: FindingSeverity::High,
2224                    crap: Some(60.0),
2225                    coverage_pct: Some(25.0),
2226                    coverage_tier: None,
2227                    coverage_source: None,
2228                    inherited_from: None,
2229                    component_rollup: None,
2230                    contributions: Vec::new(),
2231                }
2232                .into(),
2233            ],
2234            summary: HealthSummary {
2235                functions_analyzed: 10,
2236                functions_above_threshold: 1,
2237                ..Default::default()
2238            },
2239            ..Default::default()
2240        };
2241        let json = issues_to_value(&build_health_codeclimate(&report, &root));
2242        let issues = json.as_array().unwrap();
2243        assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
2244        assert_eq!(issues[0]["severity"], "major");
2245        let description = issues[0]["description"].as_str().unwrap();
2246        assert!(description.contains("CRAP score"), "desc: {description}");
2247        assert!(description.contains("coverage 25%"), "desc: {description}");
2248    }
2249}