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_invalid_client_export_issues(
877    issues: &mut Vec<CodeClimateIssue>,
878    findings: &[fallow_types::output_dead_code::InvalidClientExportFinding],
879    root: &Path,
880    severity: Severity,
881) {
882    if findings.is_empty() {
883        return;
884    }
885    let level = severity_to_codeclimate(severity);
886    for entry in findings {
887        let e = &entry.export;
888        let path = cc_path(&e.path, root);
889        let fp = fingerprint_hash(&["fallow/invalid-client-export", &path, &e.export_name]);
890        let line = if e.line > 0 { Some(e.line) } else { None };
891        let message = format!(
892            "Export `{}` is not allowed in a \"{}\" file (Next.js server-only / route-config name)",
893            e.export_name, e.directive
894        );
895        issues.push(cc_issue(
896            "fallow/invalid-client-export",
897            &message,
898            level,
899            "Bug Risk",
900            &path,
901            line,
902            &fp,
903        ));
904    }
905}
906
907fn push_mixed_client_server_barrel_issues(
908    issues: &mut Vec<CodeClimateIssue>,
909    findings: &[fallow_types::output_dead_code::MixedClientServerBarrelFinding],
910    root: &Path,
911    severity: Severity,
912) {
913    if findings.is_empty() {
914        return;
915    }
916    let level = severity_to_codeclimate(severity);
917    for entry in findings {
918        let b = &entry.barrel;
919        let path = cc_path(&b.path, root);
920        let fp = fingerprint_hash(&[
921            "fallow/mixed-client-server-barrel",
922            &path,
923            &b.client_origin,
924            &b.server_origin,
925        ]);
926        let line = if b.line > 0 { Some(b.line) } else { None };
927        let message = format!(
928            "Barrel re-exports both a \"use client\" module (`{}`) and a server-only module (`{}`); one import drags the other's directive across the boundary",
929            b.client_origin, b.server_origin
930        );
931        issues.push(cc_issue(
932            "fallow/mixed-client-server-barrel",
933            &message,
934            level,
935            "Bug Risk",
936            &path,
937            line,
938            &fp,
939        ));
940    }
941}
942
943fn push_misplaced_directive_issues(
944    issues: &mut Vec<CodeClimateIssue>,
945    findings: &[fallow_types::output_dead_code::MisplacedDirectiveFinding],
946    root: &Path,
947    severity: Severity,
948) {
949    if findings.is_empty() {
950        return;
951    }
952    let level = severity_to_codeclimate(severity);
953    for entry in findings {
954        let d = &entry.directive_site;
955        let path = cc_path(&d.path, root);
956        let fp = fingerprint_hash(&[
957            "fallow/misplaced-directive",
958            &path,
959            &d.line.to_string(),
960            &d.directive,
961        ]);
962        let line = if d.line > 0 { Some(d.line) } else { None };
963        let message = format!(
964            "Directive `\"{}\"` is not in the leading position, so the RSC bundler ignores it; move it to the top of the file",
965            d.directive
966        );
967        issues.push(cc_issue(
968            "fallow/misplaced-directive",
969            &message,
970            level,
971            "Bug Risk",
972            &path,
973            line,
974            &fp,
975        ));
976    }
977}
978
979fn push_unprovided_inject_issues(
980    issues: &mut Vec<CodeClimateIssue>,
981    findings: &[fallow_types::output_dead_code::UnprovidedInjectFinding],
982    root: &Path,
983    severity: Severity,
984) {
985    if findings.is_empty() {
986        return;
987    }
988    let level = severity_to_codeclimate(severity);
989    for entry in findings {
990        let i = &entry.inject;
991        let path = cc_path(&i.path, root);
992        let fp = fingerprint_hash(&[
993            "fallow/unprovided-inject",
994            &path,
995            &i.line.to_string(),
996            &i.key_name,
997        ]);
998        let line = if i.line > 0 { Some(i.line) } else { None };
999        let message = format!(
1000            "inject(`{}`) has no matching provide(`{}`) in this project; at runtime it returns undefined (provide the key or remove this inject)",
1001            i.key_name, i.key_name
1002        );
1003        issues.push(cc_issue(
1004            "fallow/unprovided-inject",
1005            &message,
1006            level,
1007            "Bug Risk",
1008            &path,
1009            line,
1010            &fp,
1011        ));
1012    }
1013}
1014
1015fn push_unrendered_component_issues(
1016    issues: &mut Vec<CodeClimateIssue>,
1017    findings: &[fallow_types::output_dead_code::UnrenderedComponentFinding],
1018    root: &Path,
1019    severity: Severity,
1020) {
1021    if findings.is_empty() {
1022        return;
1023    }
1024    let level = severity_to_codeclimate(severity);
1025    for entry in findings {
1026        let c = &entry.component;
1027        let path = cc_path(&c.path, root);
1028        let fp = fingerprint_hash(&[
1029            "fallow/unrendered-component",
1030            &path,
1031            &c.line.to_string(),
1032            &c.component_name,
1033        ]);
1034        let line = if c.line > 0 { Some(c.line) } else { None };
1035        let message = format!(
1036            "component `{}` is reachable but rendered nowhere in this project (render it somewhere or remove it)",
1037            c.component_name
1038        );
1039        issues.push(cc_issue(
1040            "fallow/unrendered-component",
1041            &message,
1042            level,
1043            "Bug Risk",
1044            &path,
1045            line,
1046            &fp,
1047        ));
1048    }
1049}
1050
1051fn push_unused_component_prop_issues(
1052    issues: &mut Vec<CodeClimateIssue>,
1053    findings: &[fallow_types::output_dead_code::UnusedComponentPropFinding],
1054    root: &Path,
1055    severity: Severity,
1056) {
1057    if findings.is_empty() {
1058        return;
1059    }
1060    let level = severity_to_codeclimate(severity);
1061    for entry in findings {
1062        let p = &entry.prop;
1063        let path = cc_path(&p.path, root);
1064        let fp = fingerprint_hash(&[
1065            "fallow/unused-component-prop",
1066            &path,
1067            &p.line.to_string(),
1068            &p.prop_name,
1069        ]);
1070        let line = if p.line > 0 { Some(p.line) } else { None };
1071        let message = format!(
1072            "prop `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1073            p.prop_name, p.component_name
1074        );
1075        issues.push(cc_issue(
1076            "fallow/unused-component-prop",
1077            &message,
1078            level,
1079            "Bug Risk",
1080            &path,
1081            line,
1082            &fp,
1083        ));
1084    }
1085}
1086
1087fn push_unused_component_emit_issues(
1088    issues: &mut Vec<CodeClimateIssue>,
1089    findings: &[fallow_types::output_dead_code::UnusedComponentEmitFinding],
1090    root: &Path,
1091    severity: Severity,
1092) {
1093    if findings.is_empty() {
1094        return;
1095    }
1096    let level = severity_to_codeclimate(severity);
1097    for entry in findings {
1098        let e = &entry.emit;
1099        let path = cc_path(&e.path, root);
1100        let fp = fingerprint_hash(&[
1101            "fallow/unused-component-emit",
1102            &path,
1103            &e.line.to_string(),
1104            &e.emit_name,
1105        ]);
1106        let line = if e.line > 0 { Some(e.line) } else { None };
1107        let message = format!(
1108            "emit `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1109            e.emit_name, e.component_name
1110        );
1111        issues.push(cc_issue(
1112            "fallow/unused-component-emit",
1113            &message,
1114            level,
1115            "Bug Risk",
1116            &path,
1117            line,
1118            &fp,
1119        ));
1120    }
1121}
1122
1123fn push_unused_svelte_event_issues(
1124    issues: &mut Vec<CodeClimateIssue>,
1125    findings: &[fallow_types::output_dead_code::UnusedSvelteEventFinding],
1126    root: &Path,
1127    severity: Severity,
1128) {
1129    if findings.is_empty() {
1130        return;
1131    }
1132    let level = severity_to_codeclimate(severity);
1133    for entry in findings {
1134        let e = &entry.event;
1135        let path = cc_path(&e.path, root);
1136        let fp = fingerprint_hash(&[
1137            "fallow/unused-svelte-event",
1138            &path,
1139            &e.line.to_string(),
1140            &e.event_name,
1141        ]);
1142        let line = if e.line > 0 { Some(e.line) } else { None };
1143        let message = format!(
1144            "event `{}` is dispatched by component `{}` but listened to nowhere in the project (remove it or listen for it)",
1145            e.event_name, e.component_name
1146        );
1147        issues.push(cc_issue(
1148            "fallow/unused-svelte-event",
1149            &message,
1150            level,
1151            "Bug Risk",
1152            &path,
1153            line,
1154            &fp,
1155        ));
1156    }
1157}
1158
1159fn push_unused_component_input_issues(
1160    issues: &mut Vec<CodeClimateIssue>,
1161    findings: &[fallow_types::output_dead_code::UnusedComponentInputFinding],
1162    root: &Path,
1163    severity: Severity,
1164) {
1165    if findings.is_empty() {
1166        return;
1167    }
1168    let level = severity_to_codeclimate(severity);
1169    for entry in findings {
1170        let i = &entry.input;
1171        let path = cc_path(&i.path, root);
1172        let fp = fingerprint_hash(&[
1173            "fallow/unused-component-input",
1174            &path,
1175            &i.line.to_string(),
1176            &i.input_name,
1177        ]);
1178        let line = if i.line > 0 { Some(i.line) } else { None };
1179        let message = format!(
1180            "input `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1181            i.input_name, i.component_name
1182        );
1183        issues.push(cc_issue(
1184            "fallow/unused-component-input",
1185            &message,
1186            level,
1187            "Bug Risk",
1188            &path,
1189            line,
1190            &fp,
1191        ));
1192    }
1193}
1194
1195fn push_unused_component_output_issues(
1196    issues: &mut Vec<CodeClimateIssue>,
1197    findings: &[fallow_types::output_dead_code::UnusedComponentOutputFinding],
1198    root: &Path,
1199    severity: Severity,
1200) {
1201    if findings.is_empty() {
1202        return;
1203    }
1204    let level = severity_to_codeclimate(severity);
1205    for entry in findings {
1206        let o = &entry.output;
1207        let path = cc_path(&o.path, root);
1208        let fp = fingerprint_hash(&[
1209            "fallow/unused-component-output",
1210            &path,
1211            &o.line.to_string(),
1212            &o.output_name,
1213        ]);
1214        let line = if o.line > 0 { Some(o.line) } else { None };
1215        let message = format!(
1216            "output `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1217            o.output_name, o.component_name
1218        );
1219        issues.push(cc_issue(
1220            "fallow/unused-component-output",
1221            &message,
1222            level,
1223            "Bug Risk",
1224            &path,
1225            line,
1226            &fp,
1227        ));
1228    }
1229}
1230
1231fn push_unused_server_action_issues(
1232    issues: &mut Vec<CodeClimateIssue>,
1233    findings: &[fallow_types::output_dead_code::UnusedServerActionFinding],
1234    root: &Path,
1235    severity: Severity,
1236) {
1237    if findings.is_empty() {
1238        return;
1239    }
1240    let level = severity_to_codeclimate(severity);
1241    for entry in findings {
1242        let a = &entry.action;
1243        let path = cc_path(&a.path, root);
1244        let fp = fingerprint_hash(&[
1245            "fallow/unused-server-action",
1246            &path,
1247            &a.line.to_string(),
1248            &a.action_name,
1249        ]);
1250        let line = if a.line > 0 { Some(a.line) } else { None };
1251        let message = format!(
1252            "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)",
1253            a.action_name
1254        );
1255        issues.push(cc_issue(
1256            "fallow/unused-server-action",
1257            &message,
1258            level,
1259            "Bug Risk",
1260            &path,
1261            line,
1262            &fp,
1263        ));
1264    }
1265}
1266
1267fn push_unused_load_data_key_issues(
1268    issues: &mut Vec<CodeClimateIssue>,
1269    findings: &[fallow_types::output_dead_code::UnusedLoadDataKeyFinding],
1270    root: &Path,
1271    severity: Severity,
1272) {
1273    if findings.is_empty() {
1274        return;
1275    }
1276    let level = severity_to_codeclimate(severity);
1277    for entry in findings {
1278        let k = &entry.key;
1279        let path = cc_path(&k.path, root);
1280        let fp = fingerprint_hash(&[
1281            "fallow/unused-load-data-key",
1282            &path,
1283            &k.line.to_string(),
1284            &k.key_name,
1285        ]);
1286        let line = if k.line > 0 { Some(k.line) } else { None };
1287        let message = format!(
1288            "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",
1289            k.key_name
1290        );
1291        issues.push(cc_issue(
1292            "fallow/unused-load-data-key",
1293            &message,
1294            level,
1295            "Bug Risk",
1296            &path,
1297            line,
1298            &fp,
1299        ));
1300    }
1301}
1302
1303fn push_route_collision_issues(
1304    issues: &mut Vec<CodeClimateIssue>,
1305    findings: &[fallow_types::output_dead_code::RouteCollisionFinding],
1306    root: &Path,
1307    severity: Severity,
1308) {
1309    if findings.is_empty() {
1310        return;
1311    }
1312    let level = severity_to_codeclimate(severity);
1313    for entry in findings {
1314        let c = &entry.collision;
1315        let path = cc_path(&c.path, root);
1316        let fp = fingerprint_hash(&["fallow/route-collision", &path, &c.url]);
1317        let line = if c.line > 0 { Some(c.line) } else { None };
1318        let message = format!(
1319            "Route file resolves to `{}`, also owned by {} other file(s); Next.js fails the build because a URL can have only one owner",
1320            c.url,
1321            c.conflicting_paths.len()
1322        );
1323        issues.push(cc_issue(
1324            "fallow/route-collision",
1325            &message,
1326            level,
1327            "Bug Risk",
1328            &path,
1329            line,
1330            &fp,
1331        ));
1332    }
1333}
1334
1335fn push_dynamic_segment_name_conflict_issues(
1336    issues: &mut Vec<CodeClimateIssue>,
1337    findings: &[fallow_types::output_dead_code::DynamicSegmentNameConflictFinding],
1338    root: &Path,
1339    severity: Severity,
1340) {
1341    if findings.is_empty() {
1342        return;
1343    }
1344    let level = severity_to_codeclimate(severity);
1345    for entry in findings {
1346        let c = &entry.conflict;
1347        let path = cc_path(&c.path, root);
1348        let fp = fingerprint_hash(&["fallow/dynamic-segment-name-conflict", &path, &c.position]);
1349        let line = if c.line > 0 { Some(c.line) } else { None };
1350        let message = format!(
1351            "Dynamic segments at `{}` use different slug names ({}); Next.js requires one consistent name per dynamic path",
1352            c.position,
1353            c.conflicting_segments.join(", ")
1354        );
1355        issues.push(cc_issue(
1356            "fallow/dynamic-segment-name-conflict",
1357            &message,
1358            level,
1359            "Bug Risk",
1360            &path,
1361            line,
1362            &fp,
1363        ));
1364    }
1365}
1366
1367fn push_stale_suppression_issues(
1368    issues: &mut Vec<CodeClimateIssue>,
1369    suppressions: &[fallow_core::results::StaleSuppression],
1370    root: &Path,
1371    severity: Severity,
1372) {
1373    if suppressions.is_empty() {
1374        return;
1375    }
1376    let level = severity_to_codeclimate(severity);
1377    for s in suppressions {
1378        let path = cc_path(&s.path, root);
1379        let line_str = s.line.to_string();
1380        let fp = fingerprint_hash(&["fallow/stale-suppression", &path, &line_str]);
1381        issues.push(cc_issue(
1382            "fallow/stale-suppression",
1383            &s.display_message(),
1384            level,
1385            "Bug Risk",
1386            &path,
1387            Some(s.line),
1388            &fp,
1389        ));
1390    }
1391}
1392
1393fn push_unused_catalog_entry_issues(
1394    issues: &mut Vec<CodeClimateIssue>,
1395    entries: &[fallow_core::results::UnusedCatalogEntryFinding],
1396    root: &Path,
1397    severity: Severity,
1398) {
1399    if entries.is_empty() {
1400        return;
1401    }
1402    let level = severity_to_codeclimate(severity);
1403    for entry in entries {
1404        let entry = &entry.entry;
1405        let path = cc_path(&entry.path, root);
1406        let line_str = entry.line.to_string();
1407        let fp = fingerprint_hash(&[
1408            "fallow/unused-catalog-entry",
1409            &path,
1410            &line_str,
1411            &entry.catalog_name,
1412            &entry.entry_name,
1413        ]);
1414        let description = if entry.catalog_name == "default" {
1415            format!(
1416                "Catalog entry '{}' is not referenced by any workspace package",
1417                entry.entry_name
1418            )
1419        } else {
1420            format!(
1421                "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
1422                entry.entry_name, entry.catalog_name
1423            )
1424        };
1425        issues.push(cc_issue(
1426            "fallow/unused-catalog-entry",
1427            &description,
1428            level,
1429            "Bug Risk",
1430            &path,
1431            Some(entry.line),
1432            &fp,
1433        ));
1434    }
1435}
1436
1437fn push_unresolved_catalog_reference_issues(
1438    issues: &mut Vec<CodeClimateIssue>,
1439    findings: &[fallow_core::results::UnresolvedCatalogReferenceFinding],
1440    root: &Path,
1441    severity: Severity,
1442) {
1443    if findings.is_empty() {
1444        return;
1445    }
1446    let level = severity_to_codeclimate(severity);
1447    for finding in findings {
1448        let finding = &finding.reference;
1449        let path = cc_path(&finding.path, root);
1450        let line_str = finding.line.to_string();
1451        let fp = fingerprint_hash(&[
1452            "fallow/unresolved-catalog-reference",
1453            &path,
1454            &line_str,
1455            &finding.catalog_name,
1456            &finding.entry_name,
1457        ]);
1458        let catalog_phrase = if finding.catalog_name == "default" {
1459            "the default catalog".to_string()
1460        } else {
1461            format!("catalog '{}'", finding.catalog_name)
1462        };
1463        let mut description = format!(
1464            "Package '{}' is referenced via `catalog:{}` but {} does not declare it; `pnpm install` will fail",
1465            finding.entry_name,
1466            if finding.catalog_name == "default" {
1467                ""
1468            } else {
1469                finding.catalog_name.as_str()
1470            },
1471            catalog_phrase,
1472        );
1473        if !finding.available_in_catalogs.is_empty() {
1474            use std::fmt::Write as _;
1475            let _ = write!(
1476                description,
1477                " (available in: {})",
1478                finding.available_in_catalogs.join(", ")
1479            );
1480        }
1481        issues.push(cc_issue(
1482            "fallow/unresolved-catalog-reference",
1483            &description,
1484            level,
1485            "Bug Risk",
1486            &path,
1487            Some(finding.line),
1488            &fp,
1489        ));
1490    }
1491}
1492
1493fn push_empty_catalog_group_issues(
1494    issues: &mut Vec<CodeClimateIssue>,
1495    groups: &[fallow_core::results::EmptyCatalogGroupFinding],
1496    root: &Path,
1497    severity: Severity,
1498) {
1499    if groups.is_empty() {
1500        return;
1501    }
1502    let level = severity_to_codeclimate(severity);
1503    for group in groups {
1504        let group = &group.group;
1505        let path = cc_path(&group.path, root);
1506        let line_str = group.line.to_string();
1507        let fp = fingerprint_hash(&[
1508            "fallow/empty-catalog-group",
1509            &path,
1510            &line_str,
1511            &group.catalog_name,
1512        ]);
1513        issues.push(cc_issue(
1514            "fallow/empty-catalog-group",
1515            &format!("Catalog group '{}' has no entries", group.catalog_name),
1516            level,
1517            "Bug Risk",
1518            &path,
1519            Some(group.line),
1520            &fp,
1521        ));
1522    }
1523}
1524
1525fn push_unused_dependency_override_issues(
1526    issues: &mut Vec<CodeClimateIssue>,
1527    findings: &[fallow_core::results::UnusedDependencyOverrideFinding],
1528    root: &Path,
1529    severity: Severity,
1530) {
1531    if findings.is_empty() {
1532        return;
1533    }
1534    let level = severity_to_codeclimate(severity);
1535    for finding in findings {
1536        let finding = &finding.entry;
1537        let path = cc_path(&finding.path, root);
1538        let line_str = finding.line.to_string();
1539        let fp = fingerprint_hash(&[
1540            "fallow/unused-dependency-override",
1541            &path,
1542            &line_str,
1543            finding.source.as_label(),
1544            &finding.raw_key,
1545        ]);
1546        let mut description = format!(
1547            "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
1548            finding.raw_key, finding.version_range, finding.target_package,
1549        );
1550        if let Some(hint) = &finding.hint {
1551            use std::fmt::Write as _;
1552            let _ = write!(description, " ({hint})");
1553        }
1554        issues.push(cc_issue(
1555            "fallow/unused-dependency-override",
1556            &description,
1557            level,
1558            "Bug Risk",
1559            &path,
1560            Some(finding.line),
1561            &fp,
1562        ));
1563    }
1564}
1565
1566fn push_misconfigured_dependency_override_issues(
1567    issues: &mut Vec<CodeClimateIssue>,
1568    findings: &[fallow_core::results::MisconfiguredDependencyOverrideFinding],
1569    root: &Path,
1570    severity: Severity,
1571) {
1572    if findings.is_empty() {
1573        return;
1574    }
1575    let level = severity_to_codeclimate(severity);
1576    for finding in findings {
1577        let finding = &finding.entry;
1578        let path = cc_path(&finding.path, root);
1579        let line_str = finding.line.to_string();
1580        let fp = fingerprint_hash(&[
1581            "fallow/misconfigured-dependency-override",
1582            &path,
1583            &line_str,
1584            finding.source.as_label(),
1585            &finding.raw_key,
1586        ]);
1587        let description = format!(
1588            "Override `{}` -> `{}` is malformed: {}",
1589            finding.raw_key,
1590            finding.raw_value,
1591            finding.reason.describe(),
1592        );
1593        issues.push(cc_issue(
1594            "fallow/misconfigured-dependency-override",
1595            &description,
1596            level,
1597            "Bug Risk",
1598            &path,
1599            Some(finding.line),
1600            &fp,
1601        ));
1602    }
1603}
1604
1605/// Serialize a typed CodeClimate issue list to the wire-shape JSON array.
1606/// Centralizes the `serde_json::to_value(&issues)` conversion used by every
1607/// callsite that needs a `serde_json::Value` (PR comment, review envelope,
1608/// CodeClimate format dispatch, combined / audit aggregation).
1609///
1610/// Infallible: `CodeClimateIssue` only contains `String`, `u32`, and enum
1611/// variants serialized as kebab-case strings; serde_json cannot fail on
1612/// these shapes.
1613#[must_use]
1614#[expect(
1615    clippy::expect_used,
1616    reason = "CodeClimateIssue contains only infallibly serializable fields"
1617)]
1618pub fn issues_to_value(issues: &[CodeClimateIssue]) -> serde_json::Value {
1619    serde_json::to_value(issues).expect("CodeClimateIssue serializes infallibly")
1620}
1621
1622/// Build CodeClimate issues from dead-code analysis results.
1623///
1624/// Returns the typed [`CodeClimateIssue`] vec; callers that emit the wire
1625/// shape convert via [`issues_to_value`]. The schema drift gate locks the
1626/// per-issue shape against [`CodeClimateOutput`](
1627/// crate::output_envelope::CodeClimateOutput).
1628#[must_use]
1629pub fn build_codeclimate(
1630    results: &AnalysisResults,
1631    root: &Path,
1632    rules: &RulesConfig,
1633) -> Vec<CodeClimateIssue> {
1634    CodeClimateBuilder {
1635        issues: Vec::new(),
1636        results,
1637        root,
1638        rules,
1639    }
1640    .build()
1641}
1642
1643struct CodeClimateBuilder<'a> {
1644    issues: Vec<CodeClimateIssue>,
1645    results: &'a AnalysisResults,
1646    root: &'a Path,
1647    rules: &'a RulesConfig,
1648}
1649
1650impl CodeClimateBuilder<'_> {
1651    fn build(mut self) -> Vec<CodeClimateIssue> {
1652        self.push_file_and_export_issues();
1653        self.push_private_type_leak_issues();
1654        self.push_package_dependency_issues();
1655        self.push_type_test_dependency_issues();
1656        self.push_member_issues();
1657        self.push_import_and_duplicate_issues();
1658        self.push_graph_issues();
1659        self.push_boundary_issues();
1660        self.push_suppression_and_catalog_issues();
1661        self.push_override_issues();
1662        self.issues
1663    }
1664
1665    fn push_file_and_export_issues(&mut self) {
1666        push_unused_file_issues(
1667            &mut self.issues,
1668            &self.results.unused_files,
1669            self.root,
1670            self.rules.unused_files,
1671        );
1672        push_unused_export_issues(
1673            &mut self.issues,
1674            self.results.unused_exports.iter().map(|e| &e.export),
1675            self.root,
1676            "fallow/unused-export",
1677            "Export",
1678            "Re-export",
1679            self.rules.unused_exports,
1680        );
1681        push_unused_export_issues(
1682            &mut self.issues,
1683            self.results.unused_types.iter().map(|e| &e.export),
1684            self.root,
1685            "fallow/unused-type",
1686            "Type export",
1687            "Type re-export",
1688            self.rules.unused_types,
1689        );
1690    }
1691
1692    fn push_private_type_leak_issues(&mut self) {
1693        push_private_type_leak_issues(
1694            &mut self.issues,
1695            &self.results.private_type_leaks,
1696            self.root,
1697            self.rules.private_type_leaks,
1698        );
1699    }
1700
1701    fn push_package_dependency_issues(&mut self) {
1702        push_dep_cc_issues(
1703            &mut self.issues,
1704            self.results.unused_dependencies.iter().map(|f| &f.dep),
1705            self.root,
1706            "fallow/unused-dependency",
1707            "dependencies",
1708            self.rules.unused_dependencies,
1709        );
1710        push_dep_cc_issues(
1711            &mut self.issues,
1712            self.results.unused_dev_dependencies.iter().map(|f| &f.dep),
1713            self.root,
1714            "fallow/unused-dev-dependency",
1715            "devDependencies",
1716            self.rules.unused_dev_dependencies,
1717        );
1718        push_dep_cc_issues(
1719            &mut self.issues,
1720            self.results
1721                .unused_optional_dependencies
1722                .iter()
1723                .map(|f| &f.dep),
1724            self.root,
1725            "fallow/unused-optional-dependency",
1726            "optionalDependencies",
1727            self.rules.unused_optional_dependencies,
1728        );
1729    }
1730
1731    fn push_type_test_dependency_issues(&mut self) {
1732        push_type_only_dep_issues(
1733            &mut self.issues,
1734            &self.results.type_only_dependencies,
1735            self.root,
1736            self.rules.type_only_dependencies,
1737        );
1738        push_test_only_dep_issues(
1739            &mut self.issues,
1740            &self.results.test_only_dependencies,
1741            self.root,
1742            self.rules.test_only_dependencies,
1743        );
1744    }
1745
1746    fn push_member_issues(&mut self) {
1747        push_unused_member_issues(
1748            &mut self.issues,
1749            self.results.unused_enum_members.iter().map(|m| &m.member),
1750            self.root,
1751            "fallow/unused-enum-member",
1752            "Enum",
1753            self.rules.unused_enum_members,
1754        );
1755        push_unused_member_issues(
1756            &mut self.issues,
1757            self.results.unused_class_members.iter().map(|m| &m.member),
1758            self.root,
1759            "fallow/unused-class-member",
1760            "Class",
1761            self.rules.unused_class_members,
1762        );
1763        push_unused_member_issues(
1764            &mut self.issues,
1765            self.results.unused_store_members.iter().map(|m| &m.member),
1766            self.root,
1767            "fallow/unused-store-member",
1768            "Store",
1769            self.rules.unused_store_members,
1770        );
1771    }
1772
1773    fn push_import_and_duplicate_issues(&mut self) {
1774        push_unresolved_import_issues(
1775            &mut self.issues,
1776            &self.results.unresolved_imports,
1777            self.root,
1778            self.rules.unresolved_imports,
1779        );
1780        push_unlisted_dep_issues(
1781            &mut self.issues,
1782            &self.results.unlisted_dependencies,
1783            self.root,
1784            self.rules.unlisted_dependencies,
1785        );
1786        push_duplicate_export_issues(
1787            &mut self.issues,
1788            &self.results.duplicate_exports,
1789            self.root,
1790            self.rules.duplicate_exports,
1791        );
1792    }
1793
1794    fn push_graph_issues(&mut self) {
1795        push_circular_dep_issues(
1796            &mut self.issues,
1797            &self.results.circular_dependencies,
1798            self.root,
1799            self.rules.circular_dependencies,
1800        );
1801        push_re_export_cycle_issues(
1802            &mut self.issues,
1803            &self.results.re_export_cycles,
1804            self.root,
1805            self.rules.re_export_cycle,
1806        );
1807    }
1808
1809    fn push_boundary_issues(&mut self) {
1810        self.push_architecture_boundary_issues();
1811        self.push_client_server_boundary_issues();
1812        self.push_component_boundary_issues();
1813        self.push_framework_route_issues();
1814    }
1815
1816    fn push_architecture_boundary_issues(&mut self) {
1817        push_boundary_violation_issues(
1818            &mut self.issues,
1819            &self.results.boundary_violations,
1820            self.root,
1821            self.rules.boundary_violation,
1822        );
1823        push_boundary_coverage_issues(
1824            &mut self.issues,
1825            &self.results.boundary_coverage_violations,
1826            self.root,
1827            self.rules.boundary_violation,
1828        );
1829        push_boundary_call_issues(
1830            &mut self.issues,
1831            &self.results.boundary_call_violations,
1832            self.root,
1833            self.rules.boundary_violation,
1834        );
1835        push_policy_violation_issues(&mut self.issues, &self.results.policy_violations, self.root);
1836    }
1837
1838    fn push_client_server_boundary_issues(&mut self) {
1839        push_invalid_client_export_issues(
1840            &mut self.issues,
1841            &self.results.invalid_client_exports,
1842            self.root,
1843            self.rules.invalid_client_export,
1844        );
1845        push_mixed_client_server_barrel_issues(
1846            &mut self.issues,
1847            &self.results.mixed_client_server_barrels,
1848            self.root,
1849            self.rules.mixed_client_server_barrel,
1850        );
1851        push_misplaced_directive_issues(
1852            &mut self.issues,
1853            &self.results.misplaced_directives,
1854            self.root,
1855            self.rules.misplaced_directive,
1856        );
1857    }
1858
1859    fn push_component_boundary_issues(&mut self) {
1860        push_unprovided_inject_issues(
1861            &mut self.issues,
1862            &self.results.unprovided_injects,
1863            self.root,
1864            self.rules.unprovided_injects,
1865        );
1866        push_unrendered_component_issues(
1867            &mut self.issues,
1868            &self.results.unrendered_components,
1869            self.root,
1870            self.rules.unrendered_components,
1871        );
1872        push_unused_component_prop_issues(
1873            &mut self.issues,
1874            &self.results.unused_component_props,
1875            self.root,
1876            self.rules.unused_component_props,
1877        );
1878        push_unused_component_emit_issues(
1879            &mut self.issues,
1880            &self.results.unused_component_emits,
1881            self.root,
1882            self.rules.unused_component_emits,
1883        );
1884        push_unused_component_input_issues(
1885            &mut self.issues,
1886            &self.results.unused_component_inputs,
1887            self.root,
1888            self.rules.unused_component_inputs,
1889        );
1890        push_unused_component_output_issues(
1891            &mut self.issues,
1892            &self.results.unused_component_outputs,
1893            self.root,
1894            self.rules.unused_component_outputs,
1895        );
1896        push_unused_svelte_event_issues(
1897            &mut self.issues,
1898            &self.results.unused_svelte_events,
1899            self.root,
1900            self.rules.unused_svelte_events,
1901        );
1902    }
1903
1904    fn push_framework_route_issues(&mut self) {
1905        push_unused_server_action_issues(
1906            &mut self.issues,
1907            &self.results.unused_server_actions,
1908            self.root,
1909            self.rules.unused_server_actions,
1910        );
1911        push_unused_load_data_key_issues(
1912            &mut self.issues,
1913            &self.results.unused_load_data_keys,
1914            self.root,
1915            self.rules.unused_load_data_keys,
1916        );
1917        push_route_collision_issues(
1918            &mut self.issues,
1919            &self.results.route_collisions,
1920            self.root,
1921            self.rules.route_collision,
1922        );
1923        push_dynamic_segment_name_conflict_issues(
1924            &mut self.issues,
1925            &self.results.dynamic_segment_name_conflicts,
1926            self.root,
1927            self.rules.dynamic_segment_name_conflict,
1928        );
1929    }
1930
1931    fn push_suppression_and_catalog_issues(&mut self) {
1932        push_stale_suppression_issues(
1933            &mut self.issues,
1934            &self.results.stale_suppressions,
1935            self.root,
1936            self.rules.stale_suppressions,
1937        );
1938        push_unused_catalog_entry_issues(
1939            &mut self.issues,
1940            &self.results.unused_catalog_entries,
1941            self.root,
1942            self.rules.unused_catalog_entries,
1943        );
1944        push_empty_catalog_group_issues(
1945            &mut self.issues,
1946            &self.results.empty_catalog_groups,
1947            self.root,
1948            self.rules.empty_catalog_groups,
1949        );
1950        push_unresolved_catalog_reference_issues(
1951            &mut self.issues,
1952            &self.results.unresolved_catalog_references,
1953            self.root,
1954            self.rules.unresolved_catalog_references,
1955        );
1956    }
1957
1958    fn push_override_issues(&mut self) {
1959        push_unused_dependency_override_issues(
1960            &mut self.issues,
1961            &self.results.unused_dependency_overrides,
1962            self.root,
1963            self.rules.unused_dependency_overrides,
1964        );
1965        push_misconfigured_dependency_override_issues(
1966            &mut self.issues,
1967            &self.results.misconfigured_dependency_overrides,
1968            self.root,
1969            self.rules.misconfigured_dependency_overrides,
1970        );
1971    }
1972}
1973
1974/// Print dead-code analysis results in CodeClimate format.
1975pub(super) fn print_codeclimate(
1976    results: &AnalysisResults,
1977    root: &Path,
1978    rules: &RulesConfig,
1979) -> ExitCode {
1980    let issues = build_codeclimate(results, root, rules);
1981    let value = issues_to_value(&issues);
1982    emit_json(&value, "CodeClimate")
1983}
1984
1985/// Print CodeClimate output with owner properties added to each issue.
1986///
1987/// Calls `build_codeclimate` to produce the standard CodeClimate JSON array,
1988/// then post-processes each entry to add `"owner": "@team"` by resolving the
1989/// issue's location path through the `OwnershipResolver`.
1990#[expect(
1991    clippy::expect_used,
1992    reason = "grouped CodeClimate entries are JSON objects created by issues_to_value"
1993)]
1994pub(super) fn print_grouped_codeclimate(
1995    results: &AnalysisResults,
1996    root: &Path,
1997    rules: &RulesConfig,
1998    resolver: &OwnershipResolver,
1999) -> ExitCode {
2000    let issues = build_codeclimate(results, root, rules);
2001    let mut value = issues_to_value(&issues);
2002
2003    if let Some(items) = value.as_array_mut() {
2004        for issue in items {
2005            let path = issue
2006                .pointer("/location/path")
2007                .and_then(|v| v.as_str())
2008                .unwrap_or("");
2009            let owner = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
2010            issue
2011                .as_object_mut()
2012                .expect("CodeClimate issue should be an object")
2013                .insert("owner".to_string(), serde_json::Value::String(owner));
2014        }
2015    }
2016
2017    emit_json(&value, "CodeClimate")
2018}
2019
2020/// Build CodeClimate JSON array from health/complexity analysis results.
2021#[must_use]
2022pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> Vec<CodeClimateIssue> {
2023    let mut issues = Vec::new();
2024    let ctx = HealthCodeClimateContext {
2025        root,
2026        cyc_t: report.summary.max_cyclomatic_threshold,
2027        cog_t: report.summary.max_cognitive_threshold,
2028        crap_t: report.summary.max_crap_threshold,
2029    };
2030
2031    for finding in &report.findings {
2032        issues.push(ctx.complexity_issue(finding));
2033    }
2034
2035    if let Some(ref production) = report.runtime_coverage {
2036        for finding in &production.findings {
2037            issues.push(ctx.runtime_coverage_issue(finding));
2038        }
2039    }
2040
2041    if let Some(ref intelligence) = report.coverage_intelligence {
2042        for finding in &intelligence.findings {
2043            if let Some(issue) = ctx.coverage_intelligence_issue(finding) {
2044                issues.push(issue);
2045            }
2046        }
2047    }
2048
2049    if let Some(ref gaps) = report.coverage_gaps {
2050        for item in &gaps.files {
2051            issues.push(ctx.untested_file_issue(item));
2052        }
2053
2054        for item in &gaps.exports {
2055            issues.push(ctx.untested_export_issue(item));
2056        }
2057    }
2058
2059    issues
2060}
2061
2062/// Print health analysis results in CodeClimate format.
2063pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
2064    let issues = build_health_codeclimate(report, root);
2065    let value = issues_to_value(&issues);
2066    emit_json(&value, "CodeClimate")
2067}
2068
2069/// Print health CodeClimate output with a per-issue `group` field.
2070///
2071/// Mirrors the dead-code grouped CodeClimate pattern
2072/// (`print_grouped_codeclimate`): build the standard payload first, then
2073/// post-process each issue to attach a `group` key derived from the
2074/// `OwnershipResolver`. Lets GitLab Code Quality and other CodeClimate
2075/// consumers partition findings per team / package without re-parsing the
2076/// project structure.
2077#[expect(
2078    clippy::expect_used,
2079    reason = "grouped health CodeClimate entries are JSON objects created by issues_to_value"
2080)]
2081pub(super) fn print_grouped_health_codeclimate(
2082    report: &HealthReport,
2083    root: &Path,
2084    resolver: &OwnershipResolver,
2085) -> ExitCode {
2086    let issues = build_health_codeclimate(report, root);
2087    let mut value = issues_to_value(&issues);
2088
2089    if let Some(items) = value.as_array_mut() {
2090        for issue in items {
2091            let path = issue
2092                .pointer("/location/path")
2093                .and_then(|v| v.as_str())
2094                .unwrap_or("");
2095            let group = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
2096            issue
2097                .as_object_mut()
2098                .expect("CodeClimate issue should be an object")
2099                .insert("group".to_string(), serde_json::Value::String(group));
2100        }
2101    }
2102
2103    emit_json(&value, "CodeClimate")
2104}
2105
2106/// Build CodeClimate JSON array from duplication analysis results.
2107#[must_use]
2108#[expect(
2109    clippy::cast_possible_truncation,
2110    reason = "line numbers are bounded by source size"
2111)]
2112pub fn build_duplication_codeclimate(
2113    report: &DuplicationReport,
2114    root: &Path,
2115) -> Vec<CodeClimateIssue> {
2116    let mut issues = Vec::new();
2117
2118    for (i, group) in report.clone_groups.iter().enumerate() {
2119        let token_str = group.token_count.to_string();
2120        let line_count_str = group.line_count.to_string();
2121        let fragment_prefix: String = group
2122            .instances
2123            .first()
2124            .map(|inst| inst.fragment.chars().take(64).collect())
2125            .unwrap_or_default();
2126
2127        for instance in &group.instances {
2128            let path = cc_path(&instance.file, root);
2129            let start_str = instance.start_line.to_string();
2130            let fp = fingerprint_hash(&[
2131                "fallow/code-duplication",
2132                &path,
2133                &start_str,
2134                &token_str,
2135                &line_count_str,
2136                &fragment_prefix,
2137            ]);
2138            issues.push(cc_issue(
2139                "fallow/code-duplication",
2140                &format!(
2141                    "Code clone group {} ({} lines, {} instances)",
2142                    i + 1,
2143                    group.line_count,
2144                    group.instances.len()
2145                ),
2146                CodeClimateSeverity::Minor,
2147                "Duplication",
2148                &path,
2149                Some(instance.start_line as u32),
2150                &fp,
2151            ));
2152        }
2153    }
2154
2155    issues
2156}
2157
2158/// Print duplication analysis results in CodeClimate format.
2159pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
2160    let issues = build_duplication_codeclimate(report, root);
2161    let value = issues_to_value(&issues);
2162    emit_json(&value, "CodeClimate")
2163}
2164
2165/// Print duplication CodeClimate output with a per-issue `group` field.
2166///
2167/// Mirrors [`print_grouped_health_codeclimate`]: each clone group is attributed
2168/// to its largest owner ([`super::dupes_grouping::largest_owner`]) and every
2169/// CodeClimate issue emitted for that clone group's instances carries the same
2170/// top-level `group` key. Lets GitLab Code Quality and other CodeClimate
2171/// consumers partition findings per team / package / directory without
2172/// re-parsing the project structure.
2173#[expect(
2174    clippy::expect_used,
2175    reason = "grouped duplication CodeClimate entries are JSON objects created by issues_to_value"
2176)]
2177pub(super) fn print_grouped_duplication_codeclimate(
2178    report: &DuplicationReport,
2179    root: &Path,
2180    resolver: &OwnershipResolver,
2181) -> ExitCode {
2182    let issues = build_duplication_codeclimate(report, root);
2183    let mut value = issues_to_value(&issues);
2184
2185    use rustc_hash::FxHashMap;
2186    let mut path_to_owner: FxHashMap<String, String> = FxHashMap::default();
2187    for group in &report.clone_groups {
2188        let owner = super::dupes_grouping::largest_owner(group, root, resolver);
2189        for instance in &group.instances {
2190            let path = cc_path(&instance.file, root);
2191            path_to_owner.insert(path, owner.clone());
2192        }
2193    }
2194
2195    if let Some(items) = value.as_array_mut() {
2196        for issue in items {
2197            let path = issue
2198                .pointer("/location/path")
2199                .and_then(|v| v.as_str())
2200                .unwrap_or("")
2201                .to_string();
2202            let group = path_to_owner
2203                .get(&path)
2204                .cloned()
2205                .unwrap_or_else(|| crate::codeowners::UNOWNED_LABEL.to_string());
2206            issue
2207                .as_object_mut()
2208                .expect("CodeClimate issue should be an object")
2209                .insert("group".to_string(), serde_json::Value::String(group));
2210        }
2211    }
2212
2213    emit_json(&value, "CodeClimate")
2214}
2215
2216#[cfg(test)]
2217mod tests {
2218    use super::*;
2219    use crate::report::test_helpers::sample_results;
2220    use fallow_config::RulesConfig;
2221    use fallow_core::results::*;
2222    use std::path::PathBuf;
2223
2224    /// Compute graduated severity for health findings based on threshold ratio.
2225    /// Kept for unit test coverage of the original CodeClimate severity model.
2226    fn health_severity(value: u16, threshold: u16) -> &'static str {
2227        if threshold == 0 {
2228            return "minor";
2229        }
2230        let ratio = f64::from(value) / f64::from(threshold);
2231        if ratio > 2.5 {
2232            "critical"
2233        } else if ratio > 1.5 {
2234            "major"
2235        } else {
2236            "minor"
2237        }
2238    }
2239
2240    #[test]
2241    fn codeclimate_empty_results_produces_empty_array() {
2242        let root = PathBuf::from("/project");
2243        let results = AnalysisResults::default();
2244        let rules = RulesConfig::default();
2245        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2246        let arr = output.as_array().unwrap();
2247        assert!(arr.is_empty());
2248    }
2249
2250    #[test]
2251    fn codeclimate_produces_array_of_issues() {
2252        let root = PathBuf::from("/project");
2253        let results = sample_results(&root);
2254        let rules = RulesConfig::default();
2255        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2256        assert!(output.is_array());
2257        let arr = output.as_array().unwrap();
2258        assert!(!arr.is_empty());
2259    }
2260
2261    #[test]
2262    fn codeclimate_issue_has_required_fields() {
2263        let root = PathBuf::from("/project");
2264        let mut results = AnalysisResults::default();
2265        results
2266            .unused_files
2267            .push(UnusedFileFinding::with_actions(UnusedFile {
2268                path: root.join("src/dead.ts"),
2269            }));
2270        let rules = RulesConfig::default();
2271        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2272        let issue = &output.as_array().unwrap()[0];
2273
2274        assert_eq!(issue["type"], "issue");
2275        assert_eq!(issue["check_name"], "fallow/unused-file");
2276        assert!(issue["description"].is_string());
2277        assert!(issue["categories"].is_array());
2278        assert!(issue["severity"].is_string());
2279        assert!(issue["fingerprint"].is_string());
2280        assert!(issue["location"].is_object());
2281        assert!(issue["location"]["path"].is_string());
2282        assert!(issue["location"]["lines"].is_object());
2283    }
2284
2285    #[test]
2286    fn codeclimate_unused_file_severity_follows_rules() {
2287        let root = PathBuf::from("/project");
2288        let mut results = AnalysisResults::default();
2289        results
2290            .unused_files
2291            .push(UnusedFileFinding::with_actions(UnusedFile {
2292                path: root.join("src/dead.ts"),
2293            }));
2294
2295        let rules = RulesConfig::default();
2296        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2297        assert_eq!(output[0]["severity"], "major");
2298
2299        let rules = RulesConfig {
2300            unused_files: Severity::Warn,
2301            ..RulesConfig::default()
2302        };
2303        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2304        assert_eq!(output[0]["severity"], "minor");
2305    }
2306
2307    #[test]
2308    fn codeclimate_unused_export_has_line_number() {
2309        let root = PathBuf::from("/project");
2310        let mut results = AnalysisResults::default();
2311        results
2312            .unused_exports
2313            .push(UnusedExportFinding::with_actions(UnusedExport {
2314                path: root.join("src/utils.ts"),
2315                export_name: "helperFn".to_string(),
2316                is_type_only: false,
2317                line: 10,
2318                col: 4,
2319                span_start: 120,
2320                is_re_export: false,
2321            }));
2322        let rules = RulesConfig::default();
2323        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2324        let issue = &output[0];
2325        assert_eq!(issue["location"]["lines"]["begin"], 10);
2326    }
2327
2328    #[test]
2329    fn codeclimate_unused_file_line_defaults_to_1() {
2330        let root = PathBuf::from("/project");
2331        let mut results = AnalysisResults::default();
2332        results
2333            .unused_files
2334            .push(UnusedFileFinding::with_actions(UnusedFile {
2335                path: root.join("src/dead.ts"),
2336            }));
2337        let rules = RulesConfig::default();
2338        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2339        let issue = &output[0];
2340        assert_eq!(issue["location"]["lines"]["begin"], 1);
2341    }
2342
2343    #[test]
2344    fn codeclimate_paths_are_relative() {
2345        let root = PathBuf::from("/project");
2346        let mut results = AnalysisResults::default();
2347        results
2348            .unused_files
2349            .push(UnusedFileFinding::with_actions(UnusedFile {
2350                path: root.join("src/deep/nested/file.ts"),
2351            }));
2352        let rules = RulesConfig::default();
2353        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2354        let path = output[0]["location"]["path"].as_str().unwrap();
2355        assert_eq!(path, "src/deep/nested/file.ts");
2356        assert!(!path.starts_with("/project"));
2357    }
2358
2359    #[test]
2360    fn codeclimate_re_export_label_in_description() {
2361        let root = PathBuf::from("/project");
2362        let mut results = AnalysisResults::default();
2363        results
2364            .unused_exports
2365            .push(UnusedExportFinding::with_actions(UnusedExport {
2366                path: root.join("src/index.ts"),
2367                export_name: "reExported".to_string(),
2368                is_type_only: false,
2369                line: 1,
2370                col: 0,
2371                span_start: 0,
2372                is_re_export: true,
2373            }));
2374        let rules = RulesConfig::default();
2375        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2376        let desc = output[0]["description"].as_str().unwrap();
2377        assert!(desc.contains("Re-export"));
2378    }
2379
2380    #[test]
2381    fn codeclimate_unlisted_dep_one_issue_per_import_site() {
2382        let root = PathBuf::from("/project");
2383        let mut results = AnalysisResults::default();
2384        results
2385            .unlisted_dependencies
2386            .push(UnlistedDependencyFinding::with_actions(
2387                UnlistedDependency {
2388                    package_name: "chalk".to_string(),
2389                    imported_from: vec![
2390                        ImportSite {
2391                            path: root.join("src/a.ts"),
2392                            line: 1,
2393                            col: 0,
2394                        },
2395                        ImportSite {
2396                            path: root.join("src/b.ts"),
2397                            line: 5,
2398                            col: 0,
2399                        },
2400                    ],
2401                },
2402            ));
2403        let rules = RulesConfig::default();
2404        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2405        let arr = output.as_array().unwrap();
2406        assert_eq!(arr.len(), 2);
2407        assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
2408        assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
2409    }
2410
2411    #[test]
2412    fn codeclimate_duplicate_export_one_issue_per_location() {
2413        let root = PathBuf::from("/project");
2414        let mut results = AnalysisResults::default();
2415        results
2416            .duplicate_exports
2417            .push(DuplicateExportFinding::with_actions(DuplicateExport {
2418                export_name: "Config".to_string(),
2419                locations: vec![
2420                    DuplicateLocation {
2421                        path: root.join("src/a.ts"),
2422                        line: 10,
2423                        col: 0,
2424                    },
2425                    DuplicateLocation {
2426                        path: root.join("src/b.ts"),
2427                        line: 20,
2428                        col: 0,
2429                    },
2430                    DuplicateLocation {
2431                        path: root.join("src/c.ts"),
2432                        line: 30,
2433                        col: 0,
2434                    },
2435                ],
2436            }));
2437        let rules = RulesConfig::default();
2438        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2439        let arr = output.as_array().unwrap();
2440        assert_eq!(arr.len(), 3);
2441    }
2442
2443    #[test]
2444    fn codeclimate_circular_dep_emits_chain_in_description() {
2445        let root = PathBuf::from("/project");
2446        let mut results = AnalysisResults::default();
2447        results
2448            .circular_dependencies
2449            .push(CircularDependencyFinding::with_actions(
2450                CircularDependency {
2451                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2452                    length: 2,
2453                    line: 3,
2454                    col: 0,
2455                    edges: Vec::new(),
2456                    is_cross_package: false,
2457                },
2458            ));
2459        let rules = RulesConfig::default();
2460        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2461        let desc = output[0]["description"].as_str().unwrap();
2462        assert!(desc.contains("Circular dependency"));
2463        assert!(desc.contains("src/a.ts"));
2464        assert!(desc.contains("src/b.ts"));
2465    }
2466
2467    #[test]
2468    fn codeclimate_fingerprints_are_deterministic() {
2469        let root = PathBuf::from("/project");
2470        let results = sample_results(&root);
2471        let rules = RulesConfig::default();
2472        let output1 = issues_to_value(&build_codeclimate(&results, &root, &rules));
2473        let output2 = issues_to_value(&build_codeclimate(&results, &root, &rules));
2474
2475        let fps1: Vec<&str> = output1
2476            .as_array()
2477            .unwrap()
2478            .iter()
2479            .map(|i| i["fingerprint"].as_str().unwrap())
2480            .collect();
2481        let fps2: Vec<&str> = output2
2482            .as_array()
2483            .unwrap()
2484            .iter()
2485            .map(|i| i["fingerprint"].as_str().unwrap())
2486            .collect();
2487        assert_eq!(fps1, fps2);
2488    }
2489
2490    #[test]
2491    fn codeclimate_fingerprints_are_unique() {
2492        let root = PathBuf::from("/project");
2493        let results = sample_results(&root);
2494        let rules = RulesConfig::default();
2495        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2496
2497        let mut fps: Vec<&str> = output
2498            .as_array()
2499            .unwrap()
2500            .iter()
2501            .map(|i| i["fingerprint"].as_str().unwrap())
2502            .collect();
2503        let original_len = fps.len();
2504        fps.sort_unstable();
2505        fps.dedup();
2506        assert_eq!(fps.len(), original_len, "fingerprints should be unique");
2507    }
2508
2509    #[test]
2510    fn codeclimate_type_only_dep_has_correct_check_name() {
2511        let root = PathBuf::from("/project");
2512        let mut results = AnalysisResults::default();
2513        results
2514            .type_only_dependencies
2515            .push(TypeOnlyDependencyFinding::with_actions(
2516                TypeOnlyDependency {
2517                    package_name: "zod".to_string(),
2518                    path: root.join("package.json"),
2519                    line: 8,
2520                },
2521            ));
2522        let rules = RulesConfig::default();
2523        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2524        assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
2525        let desc = output[0]["description"].as_str().unwrap();
2526        assert!(desc.contains("zod"));
2527        assert!(desc.contains("type-only"));
2528    }
2529
2530    #[test]
2531    fn codeclimate_dep_with_zero_line_omits_line_number() {
2532        let root = PathBuf::from("/project");
2533        let mut results = AnalysisResults::default();
2534        results
2535            .unused_dependencies
2536            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2537                package_name: "lodash".to_string(),
2538                location: DependencyLocation::Dependencies,
2539                path: root.join("package.json"),
2540                line: 0,
2541                used_in_workspaces: Vec::new(),
2542            }));
2543        let rules = RulesConfig::default();
2544        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2545        assert_eq!(output[0]["location"]["lines"]["begin"], 1);
2546    }
2547
2548    #[test]
2549    fn fingerprint_hash_different_inputs_differ() {
2550        let h1 = fingerprint_hash(&["a", "b"]);
2551        let h2 = fingerprint_hash(&["a", "c"]);
2552        assert_ne!(h1, h2);
2553    }
2554
2555    #[test]
2556    fn fingerprint_hash_order_matters() {
2557        let h1 = fingerprint_hash(&["a", "b"]);
2558        let h2 = fingerprint_hash(&["b", "a"]);
2559        assert_ne!(h1, h2);
2560    }
2561
2562    #[test]
2563    fn fingerprint_hash_separator_prevents_collision() {
2564        let h1 = fingerprint_hash(&["ab", "c"]);
2565        let h2 = fingerprint_hash(&["a", "bc"]);
2566        assert_ne!(h1, h2);
2567    }
2568
2569    #[test]
2570    fn fingerprint_hash_is_16_hex_chars() {
2571        let h = fingerprint_hash(&["test"]);
2572        assert_eq!(h.len(), 16);
2573        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
2574    }
2575
2576    #[test]
2577    fn severity_error_maps_to_major() {
2578        assert_eq!(
2579            severity_to_codeclimate(Severity::Error),
2580            CodeClimateSeverity::Major
2581        );
2582    }
2583
2584    #[test]
2585    fn severity_warn_maps_to_minor() {
2586        assert_eq!(
2587            severity_to_codeclimate(Severity::Warn),
2588            CodeClimateSeverity::Minor
2589        );
2590    }
2591
2592    #[test]
2593    #[should_panic(expected = "internal error: entered unreachable code")]
2594    fn severity_off_is_unreachable() {
2595        let _ = severity_to_codeclimate(Severity::Off);
2596    }
2597
2598    /// Production-mode regression: rules can flip to `Severity::Off` while
2599    /// the matching findings slice arrives empty (the analyzer's own off-
2600    /// rule short-circuit clears the vec, but the generic-iterator helpers
2601    /// in `codeclimate.rs` previously called `severity_to_codeclimate`
2602    /// before checking emptiness and panicked at `Severity::Off`).
2603    /// `fallow dead-code --format codeclimate --production` on any project
2604    /// with a `--production`-suppressed dep / export / member rule used to
2605    /// exit 101 with `entered unreachable code` at `ci/severity.rs:28`.
2606    /// This test exercises all three previously-vulnerable helpers
2607    /// (`push_dep_cc_issues`, `push_unused_export_issues`,
2608    /// `push_unused_member_issues`) through `build_codeclimate`.
2609    #[test]
2610    fn build_codeclimate_with_off_severity_and_empty_findings_does_not_panic() {
2611        let root = PathBuf::from("/project");
2612        let results = AnalysisResults::default();
2613        let rules = RulesConfig {
2614            unused_dependencies: Severity::Off,
2615            unused_dev_dependencies: Severity::Off,
2616            unused_optional_dependencies: Severity::Off,
2617            unused_exports: Severity::Off,
2618            unused_types: Severity::Off,
2619            unused_enum_members: Severity::Off,
2620            unused_class_members: Severity::Off,
2621            ..RulesConfig::default()
2622        };
2623        let issues = build_codeclimate(&results, &root, &rules);
2624        assert!(issues.is_empty());
2625    }
2626
2627    #[test]
2628    fn health_severity_zero_threshold_returns_minor() {
2629        assert_eq!(health_severity(100, 0), "minor");
2630    }
2631
2632    #[test]
2633    fn health_severity_at_threshold_returns_minor() {
2634        assert_eq!(health_severity(10, 10), "minor");
2635    }
2636
2637    #[test]
2638    fn health_severity_1_5x_threshold_returns_minor() {
2639        assert_eq!(health_severity(15, 10), "minor");
2640    }
2641
2642    #[test]
2643    fn health_severity_above_1_5x_returns_major() {
2644        assert_eq!(health_severity(16, 10), "major");
2645    }
2646
2647    #[test]
2648    fn health_severity_at_2_5x_returns_major() {
2649        assert_eq!(health_severity(25, 10), "major");
2650    }
2651
2652    #[test]
2653    fn health_severity_above_2_5x_returns_critical() {
2654        assert_eq!(health_severity(26, 10), "critical");
2655    }
2656
2657    #[test]
2658    fn health_codeclimate_includes_coverage_gaps() {
2659        use crate::health_types::*;
2660
2661        let root = PathBuf::from("/project");
2662        let report = HealthReport {
2663            summary: HealthSummary {
2664                files_analyzed: 10,
2665                functions_analyzed: 50,
2666                ..Default::default()
2667            },
2668            coverage_gaps: Some(CoverageGaps {
2669                summary: CoverageGapSummary {
2670                    runtime_files: 2,
2671                    covered_files: 0,
2672                    file_coverage_pct: 0.0,
2673                    untested_files: 1,
2674                    untested_exports: 1,
2675                },
2676                files: vec![UntestedFileFinding::with_actions(
2677                    UntestedFile {
2678                        path: root.join("src/app.ts"),
2679                        value_export_count: 2,
2680                    },
2681                    &root,
2682                )],
2683                exports: vec![UntestedExportFinding::with_actions(
2684                    UntestedExport {
2685                        path: root.join("src/app.ts"),
2686                        export_name: "loader".into(),
2687                        line: 12,
2688                        col: 4,
2689                    },
2690                    &root,
2691                )],
2692            }),
2693            ..Default::default()
2694        };
2695
2696        let output = issues_to_value(&build_health_codeclimate(&report, &root));
2697        let issues = output.as_array().unwrap();
2698        assert_eq!(issues.len(), 2);
2699        assert_eq!(issues[0]["check_name"], "fallow/untested-file");
2700        assert_eq!(issues[0]["categories"][0], "Coverage");
2701        assert_eq!(issues[0]["location"]["path"], "src/app.ts");
2702        assert_eq!(issues[1]["check_name"], "fallow/untested-export");
2703        assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
2704        assert!(
2705            issues[1]["description"]
2706                .as_str()
2707                .unwrap()
2708                .contains("loader")
2709        );
2710    }
2711
2712    #[test]
2713    fn health_codeclimate_includes_coverage_intelligence_issue() {
2714        use crate::health_types::{
2715            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2716            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2717            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2718            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2719            CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2720            HealthReport, HealthSummary,
2721        };
2722
2723        let root = PathBuf::from("/project");
2724        let report = HealthReport {
2725            summary: HealthSummary {
2726                files_analyzed: 10,
2727                functions_analyzed: 50,
2728                ..Default::default()
2729            },
2730            coverage_intelligence: Some(CoverageIntelligenceReport {
2731                schema_version: CoverageIntelligenceSchemaVersion::V1,
2732                verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2733                summary: CoverageIntelligenceSummary {
2734                    findings: 1,
2735                    high_confidence_deletes: 1,
2736                    ..Default::default()
2737                },
2738                findings: vec![CoverageIntelligenceFinding {
2739                    id: "fallow:coverage-intel:abc123".to_owned(),
2740                    path: root.join("src/dead.ts"),
2741                    identity: Some("deadPath".to_owned()),
2742                    line: 9,
2743                    verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2744                    signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2745                    recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2746                    confidence: CoverageIntelligenceConfidence::High,
2747                    related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2748                    evidence: CoverageIntelligenceEvidence {
2749                        match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2750                        ..Default::default()
2751                    },
2752                    actions: vec![CoverageIntelligenceAction {
2753                        kind: "delete-after-confirming-owner".to_owned(),
2754                        description: "Confirm ownership".to_owned(),
2755                        auto_fixable: false,
2756                    }],
2757                }],
2758            }),
2759            ..Default::default()
2760        };
2761
2762        let output = issues_to_value(&build_health_codeclimate(&report, &root));
2763        let issues = output.as_array().unwrap();
2764        assert_eq!(issues.len(), 1);
2765        assert_eq!(
2766            issues[0]["check_name"],
2767            "fallow/coverage-intelligence-delete"
2768        );
2769        assert!(!issues[0]["fingerprint"].as_str().unwrap().is_empty());
2770        assert_eq!(issues[0]["location"]["path"], "src/dead.ts");
2771        assert!(
2772            issues[0]["description"]
2773                .as_str()
2774                .unwrap()
2775                .contains("deadPath")
2776        );
2777    }
2778
2779    #[test]
2780    fn health_codeclimate_skips_summary_only_coverage_intelligence() {
2781        use crate::health_types::{
2782            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2783            CoverageIntelligenceSummary, CoverageIntelligenceVerdict, HealthReport,
2784        };
2785
2786        let root = PathBuf::from("/project");
2787        let report = HealthReport {
2788            coverage_intelligence: Some(CoverageIntelligenceReport {
2789                schema_version: CoverageIntelligenceSchemaVersion::V1,
2790                verdict: CoverageIntelligenceVerdict::Clean,
2791                summary: CoverageIntelligenceSummary {
2792                    skipped_ambiguous_matches: 2,
2793                    ..Default::default()
2794                },
2795                findings: vec![],
2796            }),
2797            ..Default::default()
2798        };
2799
2800        let issues = build_health_codeclimate(&report, &root);
2801        assert!(issues.is_empty());
2802    }
2803
2804    #[test]
2805    fn health_codeclimate_crap_only_uses_crap_check_name() {
2806        use crate::health_types::{
2807            ComplexityViolation, FindingSeverity, HealthReport, HealthSummary,
2808        };
2809        let root = PathBuf::from("/project");
2810        let report = HealthReport {
2811            findings: vec![
2812                ComplexityViolation {
2813                    path: root.join("src/untested.ts"),
2814                    name: "risky".to_string(),
2815                    line: 7,
2816                    col: 0,
2817                    cyclomatic: 10,
2818                    cognitive: 10,
2819                    line_count: 20,
2820                    param_count: 1,
2821                    react_hook_count: 0,
2822                    react_jsx_max_depth: 0,
2823                    react_prop_count: 0,
2824                    react_hook_profile: None,
2825                    exceeded: crate::health_types::ExceededThreshold::Crap,
2826                    severity: FindingSeverity::High,
2827                    crap: Some(60.0),
2828                    coverage_pct: Some(25.0),
2829                    coverage_tier: None,
2830                    coverage_source: None,
2831                    inherited_from: None,
2832                    component_rollup: None,
2833                    contributions: Vec::new(),
2834                    effective_thresholds: None,
2835                    threshold_source: None,
2836                }
2837                .into(),
2838            ],
2839            summary: HealthSummary {
2840                functions_analyzed: 10,
2841                functions_above_threshold: 1,
2842                ..Default::default()
2843            },
2844            ..Default::default()
2845        };
2846        let json = issues_to_value(&build_health_codeclimate(&report, &root));
2847        let issues = json.as_array().unwrap();
2848        assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
2849        assert_eq!(issues[0]["severity"], "major");
2850        let description = issues[0]["description"].as_str().unwrap();
2851        assert!(description.contains("CRAP score"), "desc: {description}");
2852        assert!(description.contains("coverage 25%"), "desc: {description}");
2853    }
2854}