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.
44/// The fields of one Code Climate issue, bundled so `cc_issue` takes a single
45/// descriptor instead of seven positional parameters.
46#[derive(Clone, Copy)]
47struct CcIssue<'a> {
48    check_name: &'a str,
49    description: &'a str,
50    severity: CodeClimateSeverity,
51    category: &'a str,
52    path: &'a str,
53    begin_line: Option<u32>,
54    fingerprint: &'a str,
55}
56
57fn cc_issue(issue: CcIssue<'_>) -> CodeClimateIssue {
58    let CcIssue {
59        check_name,
60        description,
61        severity,
62        category,
63        path,
64        begin_line,
65        fingerprint,
66    } = issue;
67    CodeClimateIssue {
68        kind: CodeClimateIssueKind::Issue,
69        check_name: check_name.to_string(),
70        description: description.to_string(),
71        categories: vec![category.to_string()],
72        severity,
73        fingerprint: fingerprint.to_string(),
74        location: CodeClimateLocation {
75            path: path.to_string(),
76            lines: CodeClimateLines {
77                begin: begin_line.unwrap_or(1),
78            },
79        },
80    }
81}
82
83fn coverage_intelligence_check_name(
84    recommendation: crate::health_types::CoverageIntelligenceRecommendation,
85) -> &'static str {
86    match recommendation {
87        crate::health_types::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
88            "fallow/coverage-intelligence-risky-change"
89        }
90        crate::health_types::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
91            "fallow/coverage-intelligence-delete"
92        }
93        crate::health_types::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
94            "fallow/coverage-intelligence-review"
95        }
96        crate::health_types::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
97            "fallow/coverage-intelligence-refactor"
98        }
99    }
100}
101
102struct HealthCodeClimateContext<'a> {
103    root: &'a Path,
104    cyc_t: u16,
105    cog_t: u16,
106    crap_t: f64,
107}
108
109impl HealthCodeClimateContext<'_> {
110    fn complexity_issue(&self, finding: &ComplexityViolation) -> CodeClimateIssue {
111        let path = cc_path(&finding.path, self.root);
112        let check_name = complexity_check_name(finding);
113        let line_str = finding.line.to_string();
114        let fp = fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
115        cc_issue(CcIssue {
116            check_name,
117            description: &self.complexity_description(finding),
118            severity: health_finding_severity(finding.severity),
119            category: "Complexity",
120            path: &path,
121            begin_line: Some(finding.line),
122            fingerprint: &fp,
123        })
124    }
125
126    fn complexity_description(&self, finding: &ComplexityViolation) -> String {
127        match finding.exceeded {
128            ExceededThreshold::Both => format!(
129                "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
130                finding.name, finding.cyclomatic, self.cyc_t, finding.cognitive, self.cog_t
131            ),
132            ExceededThreshold::Cyclomatic => format!(
133                "'{}' has cyclomatic complexity {} (threshold: {})",
134                finding.name, finding.cyclomatic, self.cyc_t
135            ),
136            ExceededThreshold::Cognitive => format!(
137                "'{}' has cognitive complexity {} (threshold: {})",
138                finding.name, finding.cognitive, self.cog_t
139            ),
140            ExceededThreshold::Crap
141            | ExceededThreshold::CyclomaticCrap
142            | ExceededThreshold::CognitiveCrap
143            | ExceededThreshold::All => {
144                let crap = finding.crap.unwrap_or(0.0);
145                let coverage = finding
146                    .coverage_pct
147                    .map(|pct| format!(", coverage {pct:.0}%"))
148                    .unwrap_or_default();
149                format!(
150                    "'{}' has CRAP score {crap:.1} (threshold: {:.1}, cyclomatic {}{coverage})",
151                    finding.name, self.crap_t, finding.cyclomatic,
152                )
153            }
154        }
155    }
156
157    fn runtime_coverage_issue(&self, finding: &RuntimeCoverageFinding) -> CodeClimateIssue {
158        let path = cc_path(&finding.path, self.root);
159        let check_name = runtime_coverage_check_name(finding.verdict);
160        let invocations_hint = finding.invocations.map_or_else(
161            || "untracked".to_owned(),
162            |hits| format!("{hits} invocations"),
163        );
164        let description = format!(
165            "'{}' runtime coverage verdict: {} ({})",
166            finding.function,
167            finding.verdict.human_label(),
168            invocations_hint,
169        );
170        let fp = fingerprint_hash(&[
171            check_name,
172            &path,
173            &finding.line.to_string(),
174            &finding.function,
175        ]);
176        cc_issue(CcIssue {
177            check_name,
178            description: &description,
179            severity: runtime_coverage_severity(finding.verdict),
180            category: "Bug Risk",
181            path: &path,
182            begin_line: Some(finding.line),
183            fingerprint: &fp,
184        })
185    }
186
187    fn coverage_intelligence_issue(
188        &self,
189        finding: &CoverageIntelligenceFinding,
190    ) -> Option<CodeClimateIssue> {
191        let severity = coverage_intelligence_severity(finding.verdict)?;
192        let path = cc_path(&finding.path, self.root);
193        let check_name = coverage_intelligence_check_name(finding.recommendation);
194        let identity = finding.identity.as_deref().unwrap_or("code");
195        let description = format!(
196            "'{}' coverage intelligence verdict: {} ({})",
197            identity, finding.verdict, finding.recommendation,
198        );
199        let fp = fingerprint_hash(&[
200            check_name,
201            &path,
202            &finding.line.to_string(),
203            identity,
204            &finding.id,
205        ]);
206        Some(cc_issue(CcIssue {
207            check_name,
208            description: &description,
209            severity,
210            category: "Bug Risk",
211            path: &path,
212            begin_line: Some(finding.line),
213            fingerprint: &fp,
214        }))
215    }
216
217    fn untested_file_issue(&self, item: &UntestedFileFinding) -> CodeClimateIssue {
218        let path = cc_path(&item.file.path, self.root);
219        let description = format!(
220            "File is runtime-reachable but has no test dependency path ({} value export{})",
221            item.file.value_export_count,
222            if item.file.value_export_count == 1 {
223                ""
224            } else {
225                "s"
226            },
227        );
228        let fp = fingerprint_hash(&["fallow/untested-file", &path]);
229        cc_issue(CcIssue {
230            check_name: "fallow/untested-file",
231            description: &description,
232            severity: CodeClimateSeverity::Minor,
233            category: "Coverage",
234            path: &path,
235            begin_line: None,
236            fingerprint: &fp,
237        })
238    }
239
240    fn untested_export_issue(&self, item: &UntestedExportFinding) -> CodeClimateIssue {
241        let path = cc_path(&item.export.path, self.root);
242        let description = format!(
243            "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
244            item.export.export_name
245        );
246        let line_str = item.export.line.to_string();
247        let fp = fingerprint_hash(&[
248            "fallow/untested-export",
249            &path,
250            &line_str,
251            &item.export.export_name,
252        ]);
253        cc_issue(CcIssue {
254            check_name: "fallow/untested-export",
255            description: &description,
256            severity: CodeClimateSeverity::Minor,
257            category: "Coverage",
258            path: &path,
259            begin_line: Some(item.export.line),
260            fingerprint: &fp,
261        })
262    }
263}
264
265const fn complexity_check_name(finding: &ComplexityViolation) -> &'static str {
266    match finding.exceeded {
267        ExceededThreshold::Both => "fallow/high-complexity",
268        ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
269        ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
270        ExceededThreshold::Crap
271        | ExceededThreshold::CyclomaticCrap
272        | ExceededThreshold::CognitiveCrap
273        | ExceededThreshold::All => "fallow/high-crap-score",
274    }
275}
276
277const fn health_finding_severity(
278    severity: crate::health_types::FindingSeverity,
279) -> CodeClimateSeverity {
280    match severity {
281        crate::health_types::FindingSeverity::Critical => CodeClimateSeverity::Critical,
282        crate::health_types::FindingSeverity::High => CodeClimateSeverity::Major,
283        crate::health_types::FindingSeverity::Moderate => CodeClimateSeverity::Minor,
284    }
285}
286
287const fn runtime_coverage_check_name(
288    verdict: crate::health_types::RuntimeCoverageVerdict,
289) -> &'static str {
290    match verdict {
291        crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
292            "fallow/runtime-safe-to-delete"
293        }
294        crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
295            "fallow/runtime-review-required"
296        }
297        crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
298        crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
299            "fallow/runtime-coverage-unavailable"
300        }
301        crate::health_types::RuntimeCoverageVerdict::Active
302        | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
303    }
304}
305
306const fn runtime_coverage_severity(
307    verdict: crate::health_types::RuntimeCoverageVerdict,
308) -> CodeClimateSeverity {
309    match verdict {
310        crate::health_types::RuntimeCoverageVerdict::SafeToDelete => CodeClimateSeverity::Critical,
311        crate::health_types::RuntimeCoverageVerdict::ReviewRequired => CodeClimateSeverity::Major,
312        _ => CodeClimateSeverity::Minor,
313    }
314}
315
316const fn coverage_intelligence_severity(
317    verdict: crate::health_types::CoverageIntelligenceVerdict,
318) -> Option<CodeClimateSeverity> {
319    match verdict {
320        crate::health_types::CoverageIntelligenceVerdict::RiskyChangeDetected
321        | crate::health_types::CoverageIntelligenceVerdict::HighConfidenceDelete => {
322            Some(CodeClimateSeverity::Major)
323        }
324        crate::health_types::CoverageIntelligenceVerdict::ReviewRequired
325        | crate::health_types::CoverageIntelligenceVerdict::RefactorCarefully => {
326            Some(CodeClimateSeverity::Minor)
327        }
328        crate::health_types::CoverageIntelligenceVerdict::Clean
329        | crate::health_types::CoverageIntelligenceVerdict::Unknown => None,
330    }
331}
332
333/// Push CodeClimate issues for unused dependencies with a shared structure.
334fn push_dep_cc_issues<'a, I>(
335    issues: &mut Vec<CodeClimateIssue>,
336    deps: I,
337    root: &Path,
338    rule_id: &str,
339    location_label: &str,
340    severity: Severity,
341) where
342    I: IntoIterator<Item = &'a fallow_core::results::UnusedDependency>,
343{
344    for dep in deps {
345        let level = severity_to_codeclimate(severity);
346        let path = cc_path(&dep.path, root);
347        let line = if dep.line > 0 { Some(dep.line) } else { None };
348        let fp = fingerprint_hash(&[rule_id, &dep.package_name]);
349        let workspace_context = if dep.used_in_workspaces.is_empty() {
350            String::new()
351        } else {
352            let workspaces = dep
353                .used_in_workspaces
354                .iter()
355                .map(|path| cc_path(path, root))
356                .collect::<Vec<_>>()
357                .join(", ");
358            format!("; imported in other workspaces: {workspaces}")
359        };
360        issues.push(cc_issue(CcIssue {
361            check_name: rule_id,
362            description: &format!(
363                "Package '{}' is in {location_label} but never imported{workspace_context}",
364                dep.package_name
365            ),
366            severity: level,
367            category: "Bug Risk",
368            path: &path,
369            begin_line: line,
370            fingerprint: &fp,
371        }));
372    }
373}
374
375fn push_unused_file_issues(
376    issues: &mut Vec<CodeClimateIssue>,
377    files: &[fallow_types::output_dead_code::UnusedFileFinding],
378    root: &Path,
379    severity: Severity,
380) {
381    if files.is_empty() {
382        return;
383    }
384    let level = severity_to_codeclimate(severity);
385    for entry in files {
386        let path = cc_path(&entry.file.path, root);
387        let fp = fingerprint_hash(&["fallow/unused-file", &path]);
388        issues.push(cc_issue(CcIssue {
389            check_name: "fallow/unused-file",
390            description: "File is not reachable from any entry point",
391            severity: level,
392            category: "Bug Risk",
393            path: &path,
394            begin_line: None,
395            fingerprint: &fp,
396        }));
397    }
398}
399
400/// Push CodeClimate issues for unused exports or unused types.
401///
402/// `direct_label` / `re_export_label` let the same helper produce the right
403/// prose for both `unused-export` (Export / Re-export) and `unused-type`
404/// (Type export / Type re-export) rule ids.
405struct UnusedExportIssuesInput<'a, I> {
406    issues: &'a mut Vec<CodeClimateIssue>,
407    exports: I,
408    root: &'a Path,
409    rule_id: &'a str,
410    direct_label: &'a str,
411    re_export_label: &'a str,
412    severity: Severity,
413}
414
415fn push_unused_export_issues<'a, I>(input: UnusedExportIssuesInput<'a, I>)
416where
417    I: IntoIterator<Item = &'a fallow_core::results::UnusedExport>,
418{
419    for export in input.exports {
420        let level = severity_to_codeclimate(input.severity);
421        let path = cc_path(&export.path, input.root);
422        let kind = if export.is_re_export {
423            input.re_export_label
424        } else {
425            input.direct_label
426        };
427        let line_str = export.line.to_string();
428        let fp = fingerprint_hash(&[input.rule_id, &path, &line_str, &export.export_name]);
429        input.issues.push(cc_issue(CcIssue {
430            check_name: input.rule_id,
431            description: &format!(
432                "{kind} '{}' is never imported by other modules",
433                export.export_name
434            ),
435            severity: level,
436            category: "Bug Risk",
437            path: &path,
438            begin_line: Some(export.line),
439            fingerprint: &fp,
440        }));
441    }
442}
443
444fn push_private_type_leak_issues(
445    issues: &mut Vec<CodeClimateIssue>,
446    leaks: &[fallow_types::output_dead_code::PrivateTypeLeakFinding],
447    root: &Path,
448    severity: Severity,
449) {
450    if leaks.is_empty() {
451        return;
452    }
453    let level = severity_to_codeclimate(severity);
454    for entry in leaks {
455        let leak = &entry.leak;
456        let path = cc_path(&leak.path, root);
457        let line_str = leak.line.to_string();
458        let fp = fingerprint_hash(&[
459            "fallow/private-type-leak",
460            &path,
461            &line_str,
462            &leak.export_name,
463            &leak.type_name,
464        ]);
465        issues.push(cc_issue(CcIssue {
466            check_name: "fallow/private-type-leak",
467            description: &format!(
468                "Export '{}' references private type '{}'",
469                leak.export_name, leak.type_name
470            ),
471            severity: level,
472            category: "Bug Risk",
473            path: &path,
474            begin_line: Some(leak.line),
475            fingerprint: &fp,
476        }));
477    }
478}
479
480fn push_type_only_dep_issues(
481    issues: &mut Vec<CodeClimateIssue>,
482    deps: &[fallow_core::results::TypeOnlyDependencyFinding],
483    root: &Path,
484    severity: Severity,
485) {
486    if deps.is_empty() {
487        return;
488    }
489    let level = severity_to_codeclimate(severity);
490    for entry in deps {
491        let dep = &entry.dep;
492        let path = cc_path(&dep.path, root);
493        let line = if dep.line > 0 { Some(dep.line) } else { None };
494        let fp = fingerprint_hash(&["fallow/type-only-dependency", &dep.package_name]);
495        issues.push(cc_issue(CcIssue {
496            check_name: "fallow/type-only-dependency",
497            description: &format!(
498                "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
499                dep.package_name
500            ),
501            severity: level,
502            category: "Bug Risk",
503            path: &path,
504            begin_line: line,
505            fingerprint: &fp,
506        }));
507    }
508}
509
510fn push_test_only_dep_issues(
511    issues: &mut Vec<CodeClimateIssue>,
512    deps: &[fallow_core::results::TestOnlyDependencyFinding],
513    root: &Path,
514    severity: Severity,
515) {
516    if deps.is_empty() {
517        return;
518    }
519    let level = severity_to_codeclimate(severity);
520    for entry in deps {
521        let dep = &entry.dep;
522        let path = cc_path(&dep.path, root);
523        let line = if dep.line > 0 { Some(dep.line) } else { None };
524        let fp = fingerprint_hash(&["fallow/test-only-dependency", &dep.package_name]);
525        issues.push(cc_issue(CcIssue {
526            check_name: "fallow/test-only-dependency",
527            description: &format!(
528                "Package '{}' is only imported by test files (consider moving to devDependencies)",
529                dep.package_name
530            ),
531            severity: level,
532            category: "Bug Risk",
533            path: &path,
534            begin_line: line,
535            fingerprint: &fp,
536        }));
537    }
538}
539
540/// Push CodeClimate issues for unused enum or class members.
541///
542/// `entity_label` is `"Enum"` or `"Class"` so the rendered description reads
543/// "Enum member ..." or "Class member ..." accordingly.
544fn push_unused_member_issues<'a, I>(
545    issues: &mut Vec<CodeClimateIssue>,
546    members: I,
547    root: &Path,
548    rule_id: &str,
549    entity_label: &str,
550    severity: Severity,
551) where
552    I: IntoIterator<Item = &'a fallow_core::results::UnusedMember>,
553{
554    for member in members {
555        let level = severity_to_codeclimate(severity);
556        let path = cc_path(&member.path, root);
557        let line_str = member.line.to_string();
558        let fp = fingerprint_hash(&[
559            rule_id,
560            &path,
561            &line_str,
562            &member.parent_name,
563            &member.member_name,
564        ]);
565        issues.push(cc_issue(CcIssue {
566            check_name: rule_id,
567            description: &format!(
568                "{entity_label} member '{}.{}' is never referenced",
569                member.parent_name, member.member_name
570            ),
571            severity: level,
572            category: "Bug Risk",
573            path: &path,
574            begin_line: Some(member.line),
575            fingerprint: &fp,
576        }));
577    }
578}
579
580fn push_unresolved_import_issues(
581    issues: &mut Vec<CodeClimateIssue>,
582    imports: &[fallow_types::output_dead_code::UnresolvedImportFinding],
583    root: &Path,
584    severity: Severity,
585) {
586    if imports.is_empty() {
587        return;
588    }
589    let level = severity_to_codeclimate(severity);
590    for entry in imports {
591        let import = &entry.import;
592        let path = cc_path(&import.path, root);
593        let line_str = import.line.to_string();
594        let fp = fingerprint_hash(&[
595            "fallow/unresolved-import",
596            &path,
597            &line_str,
598            &import.specifier,
599        ]);
600        issues.push(cc_issue(CcIssue {
601            check_name: "fallow/unresolved-import",
602            description: &format!("Import '{}' could not be resolved", import.specifier),
603            severity: level,
604            category: "Bug Risk",
605            path: &path,
606            begin_line: Some(import.line),
607            fingerprint: &fp,
608        }));
609    }
610}
611
612fn push_unlisted_dep_issues(
613    issues: &mut Vec<CodeClimateIssue>,
614    deps: &[fallow_core::results::UnlistedDependencyFinding],
615    root: &Path,
616    severity: Severity,
617) {
618    if deps.is_empty() {
619        return;
620    }
621    let level = severity_to_codeclimate(severity);
622    for entry in deps {
623        let dep = &entry.dep;
624        for site in &dep.imported_from {
625            let path = cc_path(&site.path, root);
626            let line_str = site.line.to_string();
627            let fp = fingerprint_hash(&[
628                "fallow/unlisted-dependency",
629                &path,
630                &line_str,
631                &dep.package_name,
632            ]);
633            issues.push(cc_issue(CcIssue {
634                check_name: "fallow/unlisted-dependency",
635                description: &format!(
636                    "Package '{}' is imported but not listed in package.json",
637                    dep.package_name
638                ),
639                severity: level,
640                category: "Bug Risk",
641                path: &path,
642                begin_line: Some(site.line),
643                fingerprint: &fp,
644            }));
645        }
646    }
647}
648
649fn push_duplicate_export_issues(
650    issues: &mut Vec<CodeClimateIssue>,
651    dups: &[fallow_core::results::DuplicateExportFinding],
652    root: &Path,
653    severity: Severity,
654) {
655    if dups.is_empty() {
656        return;
657    }
658    let level = severity_to_codeclimate(severity);
659    for dup in dups {
660        let dup = &dup.export;
661        for loc in &dup.locations {
662            let path = cc_path(&loc.path, root);
663            let line_str = loc.line.to_string();
664            let fp = fingerprint_hash(&[
665                "fallow/duplicate-export",
666                &path,
667                &line_str,
668                &dup.export_name,
669            ]);
670            issues.push(cc_issue(CcIssue {
671                check_name: "fallow/duplicate-export",
672                description: &format!("Export '{}' appears in multiple modules", dup.export_name),
673                severity: level,
674                category: "Bug Risk",
675                path: &path,
676                begin_line: Some(loc.line),
677                fingerprint: &fp,
678            }));
679        }
680    }
681}
682
683fn push_circular_dep_issues(
684    issues: &mut Vec<CodeClimateIssue>,
685    cycles: &[fallow_types::output_dead_code::CircularDependencyFinding],
686    root: &Path,
687    severity: Severity,
688) {
689    if cycles.is_empty() {
690        return;
691    }
692    let level = severity_to_codeclimate(severity);
693    for entry in cycles {
694        let cycle = &entry.cycle;
695        let Some(first) = cycle.files.first() else {
696            continue;
697        };
698        let path = cc_path(first, root);
699        let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
700        let chain_str = chain.join(":");
701        let fp = fingerprint_hash(&["fallow/circular-dependency", &chain_str]);
702        let line = if cycle.line > 0 {
703            Some(cycle.line)
704        } else {
705            None
706        };
707        issues.push(cc_issue(CcIssue {
708            check_name: "fallow/circular-dependency",
709            description: &format!(
710                "Circular dependency{}: {}",
711                if cycle.is_cross_package {
712                    " (cross-package)"
713                } else {
714                    ""
715                },
716                chain.join(" \u{2192} ")
717            ),
718            severity: level,
719            category: "Bug Risk",
720            path: &path,
721            begin_line: line,
722            fingerprint: &fp,
723        }));
724    }
725}
726
727fn push_re_export_cycle_issues(
728    issues: &mut Vec<CodeClimateIssue>,
729    cycles: &[fallow_types::output_dead_code::ReExportCycleFinding],
730    root: &Path,
731    severity: Severity,
732) {
733    if cycles.is_empty() {
734        return;
735    }
736    let level = severity_to_codeclimate(severity);
737    for entry in cycles {
738        let cycle = &entry.cycle;
739        let Some(first) = cycle.files.first() else {
740            continue;
741        };
742        let path = cc_path(first, root);
743        let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
744        let chain_str = chain.join(":");
745        let kind_token = match cycle.kind {
746            fallow_core::results::ReExportCycleKind::SelfLoop => "self-loop",
747            fallow_core::results::ReExportCycleKind::MultiNode => "multi-node",
748        };
749        let kind_tag = match cycle.kind {
750            fallow_core::results::ReExportCycleKind::SelfLoop => " (self-loop)",
751            fallow_core::results::ReExportCycleKind::MultiNode => "",
752        };
753        let fp = fingerprint_hash(&["fallow/re-export-cycle", kind_token, &chain_str]);
754        issues.push(cc_issue(CcIssue {
755            check_name: "fallow/re-export-cycle",
756            description: &format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
757            severity: level,
758            category: "Bug Risk",
759            path: &path,
760            begin_line: None,
761            fingerprint: &fp,
762        }));
763    }
764}
765
766fn push_boundary_violation_issues(
767    issues: &mut Vec<CodeClimateIssue>,
768    violations: &[fallow_types::output_dead_code::BoundaryViolationFinding],
769    root: &Path,
770    severity: Severity,
771) {
772    if violations.is_empty() {
773        return;
774    }
775    let level = severity_to_codeclimate(severity);
776    for entry in violations {
777        let v = &entry.violation;
778        let path = cc_path(&v.from_path, root);
779        let to = cc_path(&v.to_path, root);
780        let fp = fingerprint_hash(&["fallow/boundary-violation", &path, &to]);
781        let line = if v.line > 0 { Some(v.line) } else { None };
782        issues.push(cc_issue(CcIssue {
783            check_name: "fallow/boundary-violation",
784            description: &format!(
785                "Boundary violation: {} -> {} ({} -> {})",
786                path, to, v.from_zone, v.to_zone
787            ),
788            severity: level,
789            category: "Bug Risk",
790            path: &path,
791            begin_line: line,
792            fingerprint: &fp,
793        }));
794    }
795}
796
797fn push_boundary_coverage_issues(
798    issues: &mut Vec<CodeClimateIssue>,
799    violations: &[fallow_types::output_dead_code::BoundaryCoverageViolationFinding],
800    root: &Path,
801    severity: Severity,
802) {
803    if violations.is_empty() {
804        return;
805    }
806    let level = severity_to_codeclimate(severity);
807    for entry in violations {
808        let v = &entry.violation;
809        let path = cc_path(&v.path, root);
810        let fp = fingerprint_hash(&["fallow/boundary-coverage", &path]);
811        let line = if v.line > 0 { Some(v.line) } else { None };
812        issues.push(cc_issue(CcIssue {
813            check_name: "fallow/boundary-coverage",
814            description: &format!("Boundary coverage: {path} matches no configured zone"),
815            severity: level,
816            category: "Bug Risk",
817            path: &path,
818            begin_line: line,
819            fingerprint: &fp,
820        }));
821    }
822}
823
824fn push_boundary_call_issues(
825    issues: &mut Vec<CodeClimateIssue>,
826    violations: &[fallow_types::output_dead_code::BoundaryCallViolationFinding],
827    root: &Path,
828    severity: Severity,
829) {
830    if violations.is_empty() {
831        return;
832    }
833    let level = severity_to_codeclimate(severity);
834    for entry in violations {
835        let v = &entry.violation;
836        let path = cc_path(&v.path, root);
837        let fp = fingerprint_hash(&["fallow/boundary-call-violation", &path, &v.callee]);
838        let line = if v.line > 0 { Some(v.line) } else { None };
839        issues.push(cc_issue(CcIssue {
840            check_name: "fallow/boundary-call-violation",
841            description: &format!(
842                "Boundary call: `{}` matches forbidden pattern `{}` in zone '{}'",
843                v.callee, v.pattern, v.zone
844            ),
845            severity: level,
846            category: "Bug Risk",
847            path: &path,
848            begin_line: line,
849            fingerprint: &fp,
850        }));
851    }
852}
853
854fn push_policy_violation_issues(
855    issues: &mut Vec<CodeClimateIssue>,
856    violations: &[fallow_types::output_dead_code::PolicyViolationFinding],
857    root: &Path,
858) {
859    use fallow_core::results::PolicyViolationSeverity;
860
861    for entry in violations {
862        let v = &entry.violation;
863        let path = cc_path(&v.path, root);
864        let rule = format!("{}/{}", v.pack, v.rule_id);
865        let fp = fingerprint_hash(&["fallow/policy-violation", &path, &rule, &v.matched]);
866        let line = if v.line > 0 { Some(v.line) } else { None };
867        // Severity comes from the EFFECTIVE per-finding value, not the
868        // policy-violation master, so a severity: "error" rule under a warn
869        // master maps to blocker-level just like the exit-code gate.
870        let level = severity_to_codeclimate(match v.severity {
871            PolicyViolationSeverity::Error => Severity::Error,
872            PolicyViolationSeverity::Warn => Severity::Warn,
873        });
874        let message = match &v.message {
875            Some(message) => format!(
876                "Policy violation: `{}` is banned by `{rule}`. {message}",
877                v.matched
878            ),
879            None => format!("Policy violation: `{}` is banned by `{rule}`", v.matched),
880        };
881        issues.push(cc_issue(CcIssue {
882            check_name: "fallow/policy-violation",
883            description: &message,
884            severity: level,
885            category: "Bug Risk",
886            path: &path,
887            begin_line: line,
888            fingerprint: &fp,
889        }));
890    }
891}
892
893fn push_invalid_client_export_issues(
894    issues: &mut Vec<CodeClimateIssue>,
895    findings: &[fallow_types::output_dead_code::InvalidClientExportFinding],
896    root: &Path,
897    severity: Severity,
898) {
899    if findings.is_empty() {
900        return;
901    }
902    let level = severity_to_codeclimate(severity);
903    for entry in findings {
904        let e = &entry.export;
905        let path = cc_path(&e.path, root);
906        let fp = fingerprint_hash(&["fallow/invalid-client-export", &path, &e.export_name]);
907        let line = if e.line > 0 { Some(e.line) } else { None };
908        let message = format!(
909            "Export `{}` is not allowed in a \"{}\" file (Next.js server-only / route-config name)",
910            e.export_name, e.directive
911        );
912        issues.push(cc_issue(CcIssue {
913            check_name: "fallow/invalid-client-export",
914            description: &message,
915            severity: level,
916            category: "Bug Risk",
917            path: &path,
918            begin_line: line,
919            fingerprint: &fp,
920        }));
921    }
922}
923
924fn push_mixed_client_server_barrel_issues(
925    issues: &mut Vec<CodeClimateIssue>,
926    findings: &[fallow_types::output_dead_code::MixedClientServerBarrelFinding],
927    root: &Path,
928    severity: Severity,
929) {
930    if findings.is_empty() {
931        return;
932    }
933    let level = severity_to_codeclimate(severity);
934    for entry in findings {
935        let b = &entry.barrel;
936        let path = cc_path(&b.path, root);
937        let fp = fingerprint_hash(&[
938            "fallow/mixed-client-server-barrel",
939            &path,
940            &b.client_origin,
941            &b.server_origin,
942        ]);
943        let line = if b.line > 0 { Some(b.line) } else { None };
944        let message = format!(
945            "Barrel re-exports both a \"use client\" module (`{}`) and a server-only module (`{}`); one import drags the other's directive across the boundary",
946            b.client_origin, b.server_origin
947        );
948        issues.push(cc_issue(CcIssue {
949            check_name: "fallow/mixed-client-server-barrel",
950            description: &message,
951            severity: level,
952            category: "Bug Risk",
953            path: &path,
954            begin_line: line,
955            fingerprint: &fp,
956        }));
957    }
958}
959
960fn push_misplaced_directive_issues(
961    issues: &mut Vec<CodeClimateIssue>,
962    findings: &[fallow_types::output_dead_code::MisplacedDirectiveFinding],
963    root: &Path,
964    severity: Severity,
965) {
966    if findings.is_empty() {
967        return;
968    }
969    let level = severity_to_codeclimate(severity);
970    for entry in findings {
971        let d = &entry.directive_site;
972        let path = cc_path(&d.path, root);
973        let fp = fingerprint_hash(&[
974            "fallow/misplaced-directive",
975            &path,
976            &d.line.to_string(),
977            &d.directive,
978        ]);
979        let line = if d.line > 0 { Some(d.line) } else { None };
980        let message = format!(
981            "Directive `\"{}\"` is not in the leading position, so the RSC bundler ignores it; move it to the top of the file",
982            d.directive
983        );
984        issues.push(cc_issue(CcIssue {
985            check_name: "fallow/misplaced-directive",
986            description: &message,
987            severity: level,
988            category: "Bug Risk",
989            path: &path,
990            begin_line: line,
991            fingerprint: &fp,
992        }));
993    }
994}
995
996fn push_unprovided_inject_issues(
997    issues: &mut Vec<CodeClimateIssue>,
998    findings: &[fallow_types::output_dead_code::UnprovidedInjectFinding],
999    root: &Path,
1000    severity: Severity,
1001) {
1002    if findings.is_empty() {
1003        return;
1004    }
1005    let level = severity_to_codeclimate(severity);
1006    for entry in findings {
1007        let i = &entry.inject;
1008        let path = cc_path(&i.path, root);
1009        let fp = fingerprint_hash(&[
1010            "fallow/unprovided-inject",
1011            &path,
1012            &i.line.to_string(),
1013            &i.key_name,
1014        ]);
1015        let line = if i.line > 0 { Some(i.line) } else { None };
1016        let message = format!(
1017            "inject(`{}`) has no matching provide(`{}`) in this project; at runtime it returns undefined (provide the key or remove this inject)",
1018            i.key_name, i.key_name
1019        );
1020        issues.push(cc_issue(CcIssue {
1021            check_name: "fallow/unprovided-inject",
1022            description: &message,
1023            severity: level,
1024            category: "Bug Risk",
1025            path: &path,
1026            begin_line: line,
1027            fingerprint: &fp,
1028        }));
1029    }
1030}
1031
1032fn push_unrendered_component_issues(
1033    issues: &mut Vec<CodeClimateIssue>,
1034    findings: &[fallow_types::output_dead_code::UnrenderedComponentFinding],
1035    root: &Path,
1036    severity: Severity,
1037) {
1038    if findings.is_empty() {
1039        return;
1040    }
1041    let level = severity_to_codeclimate(severity);
1042    for entry in findings {
1043        let c = &entry.component;
1044        let path = cc_path(&c.path, root);
1045        let fp = fingerprint_hash(&[
1046            "fallow/unrendered-component",
1047            &path,
1048            &c.line.to_string(),
1049            &c.component_name,
1050        ]);
1051        let line = if c.line > 0 { Some(c.line) } else { None };
1052        let message = format!(
1053            "component `{}` is reachable but rendered nowhere in this project (render it somewhere or remove it)",
1054            c.component_name
1055        );
1056        issues.push(cc_issue(CcIssue {
1057            check_name: "fallow/unrendered-component",
1058            description: &message,
1059            severity: level,
1060            category: "Bug Risk",
1061            path: &path,
1062            begin_line: line,
1063            fingerprint: &fp,
1064        }));
1065    }
1066}
1067
1068fn push_unused_component_prop_issues(
1069    issues: &mut Vec<CodeClimateIssue>,
1070    findings: &[fallow_types::output_dead_code::UnusedComponentPropFinding],
1071    root: &Path,
1072    severity: Severity,
1073) {
1074    if findings.is_empty() {
1075        return;
1076    }
1077    let level = severity_to_codeclimate(severity);
1078    for entry in findings {
1079        let p = &entry.prop;
1080        let path = cc_path(&p.path, root);
1081        let fp = fingerprint_hash(&[
1082            "fallow/unused-component-prop",
1083            &path,
1084            &p.line.to_string(),
1085            &p.prop_name,
1086        ]);
1087        let line = if p.line > 0 { Some(p.line) } else { None };
1088        let message = format!(
1089            "prop `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1090            p.prop_name, p.component_name
1091        );
1092        issues.push(cc_issue(CcIssue {
1093            check_name: "fallow/unused-component-prop",
1094            description: &message,
1095            severity: level,
1096            category: "Bug Risk",
1097            path: &path,
1098            begin_line: line,
1099            fingerprint: &fp,
1100        }));
1101    }
1102}
1103
1104fn push_unused_component_emit_issues(
1105    issues: &mut Vec<CodeClimateIssue>,
1106    findings: &[fallow_types::output_dead_code::UnusedComponentEmitFinding],
1107    root: &Path,
1108    severity: Severity,
1109) {
1110    if findings.is_empty() {
1111        return;
1112    }
1113    let level = severity_to_codeclimate(severity);
1114    for entry in findings {
1115        let e = &entry.emit;
1116        let path = cc_path(&e.path, root);
1117        let fp = fingerprint_hash(&[
1118            "fallow/unused-component-emit",
1119            &path,
1120            &e.line.to_string(),
1121            &e.emit_name,
1122        ]);
1123        let line = if e.line > 0 { Some(e.line) } else { None };
1124        let message = format!(
1125            "emit `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1126            e.emit_name, e.component_name
1127        );
1128        issues.push(cc_issue(CcIssue {
1129            check_name: "fallow/unused-component-emit",
1130            description: &message,
1131            severity: level,
1132            category: "Bug Risk",
1133            path: &path,
1134            begin_line: line,
1135            fingerprint: &fp,
1136        }));
1137    }
1138}
1139
1140fn push_unused_svelte_event_issues(
1141    issues: &mut Vec<CodeClimateIssue>,
1142    findings: &[fallow_types::output_dead_code::UnusedSvelteEventFinding],
1143    root: &Path,
1144    severity: Severity,
1145) {
1146    if findings.is_empty() {
1147        return;
1148    }
1149    let level = severity_to_codeclimate(severity);
1150    for entry in findings {
1151        let e = &entry.event;
1152        let path = cc_path(&e.path, root);
1153        let fp = fingerprint_hash(&[
1154            "fallow/unused-svelte-event",
1155            &path,
1156            &e.line.to_string(),
1157            &e.event_name,
1158        ]);
1159        let line = if e.line > 0 { Some(e.line) } else { None };
1160        let message = format!(
1161            "event `{}` is dispatched by component `{}` but listened to nowhere in the project (remove it or listen for it)",
1162            e.event_name, e.component_name
1163        );
1164        issues.push(cc_issue(CcIssue {
1165            check_name: "fallow/unused-svelte-event",
1166            description: &message,
1167            severity: level,
1168            category: "Bug Risk",
1169            path: &path,
1170            begin_line: line,
1171            fingerprint: &fp,
1172        }));
1173    }
1174}
1175
1176fn push_unused_component_input_issues(
1177    issues: &mut Vec<CodeClimateIssue>,
1178    findings: &[fallow_types::output_dead_code::UnusedComponentInputFinding],
1179    root: &Path,
1180    severity: Severity,
1181) {
1182    if findings.is_empty() {
1183        return;
1184    }
1185    let level = severity_to_codeclimate(severity);
1186    for entry in findings {
1187        let i = &entry.input;
1188        let path = cc_path(&i.path, root);
1189        let fp = fingerprint_hash(&[
1190            "fallow/unused-component-input",
1191            &path,
1192            &i.line.to_string(),
1193            &i.input_name,
1194        ]);
1195        let line = if i.line > 0 { Some(i.line) } else { None };
1196        let message = format!(
1197            "input `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1198            i.input_name, i.component_name
1199        );
1200        issues.push(cc_issue(CcIssue {
1201            check_name: "fallow/unused-component-input",
1202            description: &message,
1203            severity: level,
1204            category: "Bug Risk",
1205            path: &path,
1206            begin_line: line,
1207            fingerprint: &fp,
1208        }));
1209    }
1210}
1211
1212fn push_unused_component_output_issues(
1213    issues: &mut Vec<CodeClimateIssue>,
1214    findings: &[fallow_types::output_dead_code::UnusedComponentOutputFinding],
1215    root: &Path,
1216    severity: Severity,
1217) {
1218    if findings.is_empty() {
1219        return;
1220    }
1221    let level = severity_to_codeclimate(severity);
1222    for entry in findings {
1223        let o = &entry.output;
1224        let path = cc_path(&o.path, root);
1225        let fp = fingerprint_hash(&[
1226            "fallow/unused-component-output",
1227            &path,
1228            &o.line.to_string(),
1229            &o.output_name,
1230        ]);
1231        let line = if o.line > 0 { Some(o.line) } else { None };
1232        let message = format!(
1233            "output `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1234            o.output_name, o.component_name
1235        );
1236        issues.push(cc_issue(CcIssue {
1237            check_name: "fallow/unused-component-output",
1238            description: &message,
1239            severity: level,
1240            category: "Bug Risk",
1241            path: &path,
1242            begin_line: line,
1243            fingerprint: &fp,
1244        }));
1245    }
1246}
1247
1248fn push_unused_server_action_issues(
1249    issues: &mut Vec<CodeClimateIssue>,
1250    findings: &[fallow_types::output_dead_code::UnusedServerActionFinding],
1251    root: &Path,
1252    severity: Severity,
1253) {
1254    if findings.is_empty() {
1255        return;
1256    }
1257    let level = severity_to_codeclimate(severity);
1258    for entry in findings {
1259        let a = &entry.action;
1260        let path = cc_path(&a.path, root);
1261        let fp = fingerprint_hash(&[
1262            "fallow/unused-server-action",
1263            &path,
1264            &a.line.to_string(),
1265            &a.action_name,
1266        ]);
1267        let line = if a.line > 0 { Some(a.line) } else { None };
1268        let message = format!(
1269            "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)",
1270            a.action_name
1271        );
1272        issues.push(cc_issue(CcIssue {
1273            check_name: "fallow/unused-server-action",
1274            description: &message,
1275            severity: level,
1276            category: "Bug Risk",
1277            path: &path,
1278            begin_line: line,
1279            fingerprint: &fp,
1280        }));
1281    }
1282}
1283
1284fn push_unused_load_data_key_issues(
1285    issues: &mut Vec<CodeClimateIssue>,
1286    findings: &[fallow_types::output_dead_code::UnusedLoadDataKeyFinding],
1287    root: &Path,
1288    severity: Severity,
1289) {
1290    if findings.is_empty() {
1291        return;
1292    }
1293    let level = severity_to_codeclimate(severity);
1294    for entry in findings {
1295        let k = &entry.key;
1296        let path = cc_path(&k.path, root);
1297        let fp = fingerprint_hash(&[
1298            "fallow/unused-load-data-key",
1299            &path,
1300            &k.line.to_string(),
1301            &k.key_name,
1302        ]);
1303        let line = if k.line > 0 { Some(k.line) } else { None };
1304        let message = format!(
1305            "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",
1306            k.key_name
1307        );
1308        issues.push(cc_issue(CcIssue {
1309            check_name: "fallow/unused-load-data-key",
1310            description: &message,
1311            severity: level,
1312            category: "Bug Risk",
1313            path: &path,
1314            begin_line: line,
1315            fingerprint: &fp,
1316        }));
1317    }
1318}
1319
1320fn push_route_collision_issues(
1321    issues: &mut Vec<CodeClimateIssue>,
1322    findings: &[fallow_types::output_dead_code::RouteCollisionFinding],
1323    root: &Path,
1324    severity: Severity,
1325) {
1326    if findings.is_empty() {
1327        return;
1328    }
1329    let level = severity_to_codeclimate(severity);
1330    for entry in findings {
1331        let c = &entry.collision;
1332        let path = cc_path(&c.path, root);
1333        let fp = fingerprint_hash(&["fallow/route-collision", &path, &c.url]);
1334        let line = if c.line > 0 { Some(c.line) } else { None };
1335        let message = format!(
1336            "Route file resolves to `{}`, also owned by {} other file(s); Next.js fails the build because a URL can have only one owner",
1337            c.url,
1338            c.conflicting_paths.len()
1339        );
1340        issues.push(cc_issue(CcIssue {
1341            check_name: "fallow/route-collision",
1342            description: &message,
1343            severity: level,
1344            category: "Bug Risk",
1345            path: &path,
1346            begin_line: line,
1347            fingerprint: &fp,
1348        }));
1349    }
1350}
1351
1352fn push_dynamic_segment_name_conflict_issues(
1353    issues: &mut Vec<CodeClimateIssue>,
1354    findings: &[fallow_types::output_dead_code::DynamicSegmentNameConflictFinding],
1355    root: &Path,
1356    severity: Severity,
1357) {
1358    if findings.is_empty() {
1359        return;
1360    }
1361    let level = severity_to_codeclimate(severity);
1362    for entry in findings {
1363        let c = &entry.conflict;
1364        let path = cc_path(&c.path, root);
1365        let fp = fingerprint_hash(&["fallow/dynamic-segment-name-conflict", &path, &c.position]);
1366        let line = if c.line > 0 { Some(c.line) } else { None };
1367        let message = format!(
1368            "Dynamic segments at `{}` use different slug names ({}); Next.js requires one consistent name per dynamic path",
1369            c.position,
1370            c.conflicting_segments.join(", ")
1371        );
1372        issues.push(cc_issue(CcIssue {
1373            check_name: "fallow/dynamic-segment-name-conflict",
1374            description: &message,
1375            severity: level,
1376            category: "Bug Risk",
1377            path: &path,
1378            begin_line: line,
1379            fingerprint: &fp,
1380        }));
1381    }
1382}
1383
1384fn push_stale_suppression_issues(
1385    issues: &mut Vec<CodeClimateIssue>,
1386    suppressions: &[fallow_core::results::StaleSuppression],
1387    root: &Path,
1388    rules: &RulesConfig,
1389) {
1390    if suppressions.is_empty() {
1391        return;
1392    }
1393    for s in suppressions {
1394        let severity = if s.missing_reason {
1395            rules.require_suppression_reason
1396        } else {
1397            rules.stale_suppressions
1398        };
1399        let level = severity_to_codeclimate(severity);
1400        let path = cc_path(&s.path, root);
1401        let line_str = s.line.to_string();
1402        let check_name = if s.missing_reason {
1403            "fallow/missing-suppression-reason"
1404        } else {
1405            "fallow/stale-suppression"
1406        };
1407        let fp = fingerprint_hash(&[check_name, &path, &line_str]);
1408        issues.push(cc_issue(CcIssue {
1409            check_name,
1410            description: &s.display_message(),
1411            severity: level,
1412            category: "Bug Risk",
1413            path: &path,
1414            begin_line: Some(s.line),
1415            fingerprint: &fp,
1416        }));
1417    }
1418}
1419
1420fn push_unused_catalog_entry_issues(
1421    issues: &mut Vec<CodeClimateIssue>,
1422    entries: &[fallow_core::results::UnusedCatalogEntryFinding],
1423    root: &Path,
1424    severity: Severity,
1425) {
1426    if entries.is_empty() {
1427        return;
1428    }
1429    let level = severity_to_codeclimate(severity);
1430    for entry in entries {
1431        let entry = &entry.entry;
1432        let path = cc_path(&entry.path, root);
1433        let line_str = entry.line.to_string();
1434        let fp = fingerprint_hash(&[
1435            "fallow/unused-catalog-entry",
1436            &path,
1437            &line_str,
1438            &entry.catalog_name,
1439            &entry.entry_name,
1440        ]);
1441        let description = if entry.catalog_name == "default" {
1442            format!(
1443                "Catalog entry '{}' is not referenced by any workspace package",
1444                entry.entry_name
1445            )
1446        } else {
1447            format!(
1448                "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
1449                entry.entry_name, entry.catalog_name
1450            )
1451        };
1452        issues.push(cc_issue(CcIssue {
1453            check_name: "fallow/unused-catalog-entry",
1454            description: &description,
1455            severity: level,
1456            category: "Bug Risk",
1457            path: &path,
1458            begin_line: Some(entry.line),
1459            fingerprint: &fp,
1460        }));
1461    }
1462}
1463
1464fn push_unresolved_catalog_reference_issues(
1465    issues: &mut Vec<CodeClimateIssue>,
1466    findings: &[fallow_core::results::UnresolvedCatalogReferenceFinding],
1467    root: &Path,
1468    severity: Severity,
1469) {
1470    if findings.is_empty() {
1471        return;
1472    }
1473    let level = severity_to_codeclimate(severity);
1474    for finding in findings {
1475        let finding = &finding.reference;
1476        let path = cc_path(&finding.path, root);
1477        let line_str = finding.line.to_string();
1478        let fp = fingerprint_hash(&[
1479            "fallow/unresolved-catalog-reference",
1480            &path,
1481            &line_str,
1482            &finding.catalog_name,
1483            &finding.entry_name,
1484        ]);
1485        let catalog_phrase = if finding.catalog_name == "default" {
1486            "the default catalog".to_string()
1487        } else {
1488            format!("catalog '{}'", finding.catalog_name)
1489        };
1490        let mut description = format!(
1491            "Package '{}' is referenced via `catalog:{}` but {} does not declare it; `pnpm install` will fail",
1492            finding.entry_name,
1493            if finding.catalog_name == "default" {
1494                ""
1495            } else {
1496                finding.catalog_name.as_str()
1497            },
1498            catalog_phrase,
1499        );
1500        if !finding.available_in_catalogs.is_empty() {
1501            use std::fmt::Write as _;
1502            let _ = write!(
1503                description,
1504                " (available in: {})",
1505                finding.available_in_catalogs.join(", ")
1506            );
1507        }
1508        issues.push(cc_issue(CcIssue {
1509            check_name: "fallow/unresolved-catalog-reference",
1510            description: &description,
1511            severity: level,
1512            category: "Bug Risk",
1513            path: &path,
1514            begin_line: Some(finding.line),
1515            fingerprint: &fp,
1516        }));
1517    }
1518}
1519
1520fn push_empty_catalog_group_issues(
1521    issues: &mut Vec<CodeClimateIssue>,
1522    groups: &[fallow_core::results::EmptyCatalogGroupFinding],
1523    root: &Path,
1524    severity: Severity,
1525) {
1526    if groups.is_empty() {
1527        return;
1528    }
1529    let level = severity_to_codeclimate(severity);
1530    for group in groups {
1531        let group = &group.group;
1532        let path = cc_path(&group.path, root);
1533        let line_str = group.line.to_string();
1534        let fp = fingerprint_hash(&[
1535            "fallow/empty-catalog-group",
1536            &path,
1537            &line_str,
1538            &group.catalog_name,
1539        ]);
1540        issues.push(cc_issue(CcIssue {
1541            check_name: "fallow/empty-catalog-group",
1542            description: &format!("Catalog group '{}' has no entries", group.catalog_name),
1543            severity: level,
1544            category: "Bug Risk",
1545            path: &path,
1546            begin_line: Some(group.line),
1547            fingerprint: &fp,
1548        }));
1549    }
1550}
1551
1552fn push_unused_dependency_override_issues(
1553    issues: &mut Vec<CodeClimateIssue>,
1554    findings: &[fallow_core::results::UnusedDependencyOverrideFinding],
1555    root: &Path,
1556    severity: Severity,
1557) {
1558    if findings.is_empty() {
1559        return;
1560    }
1561    let level = severity_to_codeclimate(severity);
1562    for finding in findings {
1563        let finding = &finding.entry;
1564        let path = cc_path(&finding.path, root);
1565        let line_str = finding.line.to_string();
1566        let fp = fingerprint_hash(&[
1567            "fallow/unused-dependency-override",
1568            &path,
1569            &line_str,
1570            finding.source.as_label(),
1571            &finding.raw_key,
1572        ]);
1573        let mut description = format!(
1574            "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
1575            finding.raw_key, finding.version_range, finding.target_package,
1576        );
1577        if let Some(hint) = &finding.hint {
1578            use std::fmt::Write as _;
1579            let _ = write!(description, " ({hint})");
1580        }
1581        issues.push(cc_issue(CcIssue {
1582            check_name: "fallow/unused-dependency-override",
1583            description: &description,
1584            severity: level,
1585            category: "Bug Risk",
1586            path: &path,
1587            begin_line: Some(finding.line),
1588            fingerprint: &fp,
1589        }));
1590    }
1591}
1592
1593fn push_misconfigured_dependency_override_issues(
1594    issues: &mut Vec<CodeClimateIssue>,
1595    findings: &[fallow_core::results::MisconfiguredDependencyOverrideFinding],
1596    root: &Path,
1597    severity: Severity,
1598) {
1599    if findings.is_empty() {
1600        return;
1601    }
1602    let level = severity_to_codeclimate(severity);
1603    for finding in findings {
1604        let finding = &finding.entry;
1605        let path = cc_path(&finding.path, root);
1606        let line_str = finding.line.to_string();
1607        let fp = fingerprint_hash(&[
1608            "fallow/misconfigured-dependency-override",
1609            &path,
1610            &line_str,
1611            finding.source.as_label(),
1612            &finding.raw_key,
1613        ]);
1614        let description = format!(
1615            "Override `{}` -> `{}` is malformed: {}",
1616            finding.raw_key,
1617            finding.raw_value,
1618            finding.reason.describe(),
1619        );
1620        issues.push(cc_issue(CcIssue {
1621            check_name: "fallow/misconfigured-dependency-override",
1622            description: &description,
1623            severity: level,
1624            category: "Bug Risk",
1625            path: &path,
1626            begin_line: Some(finding.line),
1627            fingerprint: &fp,
1628        }));
1629    }
1630}
1631
1632/// Serialize a typed CodeClimate issue list to the wire-shape JSON array.
1633/// Centralizes the `serde_json::to_value(&issues)` conversion used by every
1634/// callsite that needs a `serde_json::Value` (PR comment, review envelope,
1635/// CodeClimate format dispatch, combined / audit aggregation).
1636///
1637/// Infallible: `CodeClimateIssue` only contains `String`, `u32`, and enum
1638/// variants serialized as kebab-case strings; serde_json cannot fail on
1639/// these shapes.
1640#[must_use]
1641#[expect(
1642    clippy::expect_used,
1643    reason = "CodeClimateIssue contains only infallibly serializable fields"
1644)]
1645pub fn issues_to_value(issues: &[CodeClimateIssue]) -> serde_json::Value {
1646    serde_json::to_value(issues).expect("CodeClimateIssue serializes infallibly")
1647}
1648
1649/// Build CodeClimate issues from dead-code analysis results.
1650///
1651/// Returns the typed [`CodeClimateIssue`] vec; callers that emit the wire
1652/// shape convert via [`issues_to_value`]. The schema drift gate locks the
1653/// per-issue shape against [`CodeClimateOutput`](
1654/// crate::output_envelope::CodeClimateOutput).
1655#[must_use]
1656pub fn build_codeclimate(
1657    results: &AnalysisResults,
1658    root: &Path,
1659    rules: &RulesConfig,
1660) -> Vec<CodeClimateIssue> {
1661    CodeClimateBuilder {
1662        issues: Vec::new(),
1663        results,
1664        root,
1665        rules,
1666    }
1667    .build()
1668}
1669
1670struct CodeClimateBuilder<'a> {
1671    issues: Vec<CodeClimateIssue>,
1672    results: &'a AnalysisResults,
1673    root: &'a Path,
1674    rules: &'a RulesConfig,
1675}
1676
1677impl CodeClimateBuilder<'_> {
1678    fn build(mut self) -> Vec<CodeClimateIssue> {
1679        self.push_file_and_export_issues();
1680        self.push_private_type_leak_issues();
1681        self.push_package_dependency_issues();
1682        self.push_type_test_dependency_issues();
1683        self.push_member_issues();
1684        self.push_import_and_duplicate_issues();
1685        self.push_graph_issues();
1686        self.push_boundary_issues();
1687        self.push_suppression_and_catalog_issues();
1688        self.push_override_issues();
1689        self.issues
1690    }
1691
1692    fn push_file_and_export_issues(&mut self) {
1693        push_unused_file_issues(
1694            &mut self.issues,
1695            &self.results.unused_files,
1696            self.root,
1697            self.rules.unused_files,
1698        );
1699        push_unused_export_issues(UnusedExportIssuesInput {
1700            issues: &mut self.issues,
1701            exports: self.results.unused_exports.iter().map(|e| &e.export),
1702            root: self.root,
1703            rule_id: "fallow/unused-export",
1704            direct_label: "Export",
1705            re_export_label: "Re-export",
1706            severity: self.rules.unused_exports,
1707        });
1708        push_unused_export_issues(UnusedExportIssuesInput {
1709            issues: &mut self.issues,
1710            exports: self.results.unused_types.iter().map(|e| &e.export),
1711            root: self.root,
1712            rule_id: "fallow/unused-type",
1713            direct_label: "Type export",
1714            re_export_label: "Type re-export",
1715            severity: self.rules.unused_types,
1716        });
1717    }
1718
1719    fn push_private_type_leak_issues(&mut self) {
1720        push_private_type_leak_issues(
1721            &mut self.issues,
1722            &self.results.private_type_leaks,
1723            self.root,
1724            self.rules.private_type_leaks,
1725        );
1726    }
1727
1728    fn push_package_dependency_issues(&mut self) {
1729        push_dep_cc_issues(
1730            &mut self.issues,
1731            self.results.unused_dependencies.iter().map(|f| &f.dep),
1732            self.root,
1733            "fallow/unused-dependency",
1734            "dependencies",
1735            self.rules.unused_dependencies,
1736        );
1737        push_dep_cc_issues(
1738            &mut self.issues,
1739            self.results.unused_dev_dependencies.iter().map(|f| &f.dep),
1740            self.root,
1741            "fallow/unused-dev-dependency",
1742            "devDependencies",
1743            self.rules.unused_dev_dependencies,
1744        );
1745        push_dep_cc_issues(
1746            &mut self.issues,
1747            self.results
1748                .unused_optional_dependencies
1749                .iter()
1750                .map(|f| &f.dep),
1751            self.root,
1752            "fallow/unused-optional-dependency",
1753            "optionalDependencies",
1754            self.rules.unused_optional_dependencies,
1755        );
1756    }
1757
1758    fn push_type_test_dependency_issues(&mut self) {
1759        push_type_only_dep_issues(
1760            &mut self.issues,
1761            &self.results.type_only_dependencies,
1762            self.root,
1763            self.rules.type_only_dependencies,
1764        );
1765        push_test_only_dep_issues(
1766            &mut self.issues,
1767            &self.results.test_only_dependencies,
1768            self.root,
1769            self.rules.test_only_dependencies,
1770        );
1771    }
1772
1773    fn push_member_issues(&mut self) {
1774        push_unused_member_issues(
1775            &mut self.issues,
1776            self.results.unused_enum_members.iter().map(|m| &m.member),
1777            self.root,
1778            "fallow/unused-enum-member",
1779            "Enum",
1780            self.rules.unused_enum_members,
1781        );
1782        push_unused_member_issues(
1783            &mut self.issues,
1784            self.results.unused_class_members.iter().map(|m| &m.member),
1785            self.root,
1786            "fallow/unused-class-member",
1787            "Class",
1788            self.rules.unused_class_members,
1789        );
1790        push_unused_member_issues(
1791            &mut self.issues,
1792            self.results.unused_store_members.iter().map(|m| &m.member),
1793            self.root,
1794            "fallow/unused-store-member",
1795            "Store",
1796            self.rules.unused_store_members,
1797        );
1798    }
1799
1800    fn push_import_and_duplicate_issues(&mut self) {
1801        push_unresolved_import_issues(
1802            &mut self.issues,
1803            &self.results.unresolved_imports,
1804            self.root,
1805            self.rules.unresolved_imports,
1806        );
1807        push_unlisted_dep_issues(
1808            &mut self.issues,
1809            &self.results.unlisted_dependencies,
1810            self.root,
1811            self.rules.unlisted_dependencies,
1812        );
1813        push_duplicate_export_issues(
1814            &mut self.issues,
1815            &self.results.duplicate_exports,
1816            self.root,
1817            self.rules.duplicate_exports,
1818        );
1819    }
1820
1821    fn push_graph_issues(&mut self) {
1822        push_circular_dep_issues(
1823            &mut self.issues,
1824            &self.results.circular_dependencies,
1825            self.root,
1826            self.rules.circular_dependencies,
1827        );
1828        push_re_export_cycle_issues(
1829            &mut self.issues,
1830            &self.results.re_export_cycles,
1831            self.root,
1832            self.rules.re_export_cycle,
1833        );
1834    }
1835
1836    fn push_boundary_issues(&mut self) {
1837        self.push_architecture_boundary_issues();
1838        self.push_client_server_boundary_issues();
1839        self.push_component_boundary_issues();
1840        self.push_framework_route_issues();
1841    }
1842
1843    fn push_architecture_boundary_issues(&mut self) {
1844        push_boundary_violation_issues(
1845            &mut self.issues,
1846            &self.results.boundary_violations,
1847            self.root,
1848            self.rules.boundary_violation,
1849        );
1850        push_boundary_coverage_issues(
1851            &mut self.issues,
1852            &self.results.boundary_coverage_violations,
1853            self.root,
1854            self.rules.boundary_violation,
1855        );
1856        push_boundary_call_issues(
1857            &mut self.issues,
1858            &self.results.boundary_call_violations,
1859            self.root,
1860            self.rules.boundary_violation,
1861        );
1862        push_policy_violation_issues(&mut self.issues, &self.results.policy_violations, self.root);
1863    }
1864
1865    fn push_client_server_boundary_issues(&mut self) {
1866        push_invalid_client_export_issues(
1867            &mut self.issues,
1868            &self.results.invalid_client_exports,
1869            self.root,
1870            self.rules.invalid_client_export,
1871        );
1872        push_mixed_client_server_barrel_issues(
1873            &mut self.issues,
1874            &self.results.mixed_client_server_barrels,
1875            self.root,
1876            self.rules.mixed_client_server_barrel,
1877        );
1878        push_misplaced_directive_issues(
1879            &mut self.issues,
1880            &self.results.misplaced_directives,
1881            self.root,
1882            self.rules.misplaced_directive,
1883        );
1884    }
1885
1886    fn push_component_boundary_issues(&mut self) {
1887        push_unprovided_inject_issues(
1888            &mut self.issues,
1889            &self.results.unprovided_injects,
1890            self.root,
1891            self.rules.unprovided_injects,
1892        );
1893        push_unrendered_component_issues(
1894            &mut self.issues,
1895            &self.results.unrendered_components,
1896            self.root,
1897            self.rules.unrendered_components,
1898        );
1899        push_unused_component_prop_issues(
1900            &mut self.issues,
1901            &self.results.unused_component_props,
1902            self.root,
1903            self.rules.unused_component_props,
1904        );
1905        push_unused_component_emit_issues(
1906            &mut self.issues,
1907            &self.results.unused_component_emits,
1908            self.root,
1909            self.rules.unused_component_emits,
1910        );
1911        push_unused_component_input_issues(
1912            &mut self.issues,
1913            &self.results.unused_component_inputs,
1914            self.root,
1915            self.rules.unused_component_inputs,
1916        );
1917        push_unused_component_output_issues(
1918            &mut self.issues,
1919            &self.results.unused_component_outputs,
1920            self.root,
1921            self.rules.unused_component_outputs,
1922        );
1923        push_unused_svelte_event_issues(
1924            &mut self.issues,
1925            &self.results.unused_svelte_events,
1926            self.root,
1927            self.rules.unused_svelte_events,
1928        );
1929    }
1930
1931    fn push_framework_route_issues(&mut self) {
1932        push_unused_server_action_issues(
1933            &mut self.issues,
1934            &self.results.unused_server_actions,
1935            self.root,
1936            self.rules.unused_server_actions,
1937        );
1938        push_unused_load_data_key_issues(
1939            &mut self.issues,
1940            &self.results.unused_load_data_keys,
1941            self.root,
1942            self.rules.unused_load_data_keys,
1943        );
1944        push_route_collision_issues(
1945            &mut self.issues,
1946            &self.results.route_collisions,
1947            self.root,
1948            self.rules.route_collision,
1949        );
1950        push_dynamic_segment_name_conflict_issues(
1951            &mut self.issues,
1952            &self.results.dynamic_segment_name_conflicts,
1953            self.root,
1954            self.rules.dynamic_segment_name_conflict,
1955        );
1956    }
1957
1958    fn push_suppression_and_catalog_issues(&mut self) {
1959        push_stale_suppression_issues(
1960            &mut self.issues,
1961            &self.results.stale_suppressions,
1962            self.root,
1963            self.rules,
1964        );
1965        push_unused_catalog_entry_issues(
1966            &mut self.issues,
1967            &self.results.unused_catalog_entries,
1968            self.root,
1969            self.rules.unused_catalog_entries,
1970        );
1971        push_empty_catalog_group_issues(
1972            &mut self.issues,
1973            &self.results.empty_catalog_groups,
1974            self.root,
1975            self.rules.empty_catalog_groups,
1976        );
1977        push_unresolved_catalog_reference_issues(
1978            &mut self.issues,
1979            &self.results.unresolved_catalog_references,
1980            self.root,
1981            self.rules.unresolved_catalog_references,
1982        );
1983    }
1984
1985    fn push_override_issues(&mut self) {
1986        push_unused_dependency_override_issues(
1987            &mut self.issues,
1988            &self.results.unused_dependency_overrides,
1989            self.root,
1990            self.rules.unused_dependency_overrides,
1991        );
1992        push_misconfigured_dependency_override_issues(
1993            &mut self.issues,
1994            &self.results.misconfigured_dependency_overrides,
1995            self.root,
1996            self.rules.misconfigured_dependency_overrides,
1997        );
1998    }
1999}
2000
2001/// Print dead-code analysis results in CodeClimate format.
2002pub(super) fn print_codeclimate(
2003    results: &AnalysisResults,
2004    root: &Path,
2005    rules: &RulesConfig,
2006) -> ExitCode {
2007    let issues = build_codeclimate(results, root, rules);
2008    let value = issues_to_value(&issues);
2009    emit_json(&value, "CodeClimate")
2010}
2011
2012/// Print CodeClimate output with owner properties added to each issue.
2013///
2014/// Calls `build_codeclimate` to produce the standard CodeClimate JSON array,
2015/// then post-processes each entry to add `"owner": "@team"` by resolving the
2016/// issue's location path through the `OwnershipResolver`.
2017#[expect(
2018    clippy::expect_used,
2019    reason = "grouped CodeClimate entries are JSON objects created by issues_to_value"
2020)]
2021pub(super) fn print_grouped_codeclimate(
2022    results: &AnalysisResults,
2023    root: &Path,
2024    rules: &RulesConfig,
2025    resolver: &OwnershipResolver,
2026) -> ExitCode {
2027    let issues = build_codeclimate(results, root, rules);
2028    let mut value = issues_to_value(&issues);
2029
2030    if let Some(items) = value.as_array_mut() {
2031        for issue in items {
2032            let path = issue
2033                .pointer("/location/path")
2034                .and_then(|v| v.as_str())
2035                .unwrap_or("");
2036            let owner = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
2037            issue
2038                .as_object_mut()
2039                .expect("CodeClimate issue should be an object")
2040                .insert("owner".to_string(), serde_json::Value::String(owner));
2041        }
2042    }
2043
2044    emit_json(&value, "CodeClimate")
2045}
2046
2047/// Build CodeClimate JSON array from health/complexity analysis results.
2048#[must_use]
2049pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> Vec<CodeClimateIssue> {
2050    let mut issues = Vec::new();
2051    let ctx = HealthCodeClimateContext {
2052        root,
2053        cyc_t: report.summary.max_cyclomatic_threshold,
2054        cog_t: report.summary.max_cognitive_threshold,
2055        crap_t: report.summary.max_crap_threshold,
2056    };
2057
2058    for finding in &report.findings {
2059        issues.push(ctx.complexity_issue(finding));
2060    }
2061
2062    if let Some(ref production) = report.runtime_coverage {
2063        for finding in &production.findings {
2064            issues.push(ctx.runtime_coverage_issue(finding));
2065        }
2066    }
2067
2068    if let Some(ref intelligence) = report.coverage_intelligence {
2069        for finding in &intelligence.findings {
2070            if let Some(issue) = ctx.coverage_intelligence_issue(finding) {
2071                issues.push(issue);
2072            }
2073        }
2074    }
2075
2076    if let Some(ref gaps) = report.coverage_gaps {
2077        for item in &gaps.files {
2078            issues.push(ctx.untested_file_issue(item));
2079        }
2080
2081        for item in &gaps.exports {
2082            issues.push(ctx.untested_export_issue(item));
2083        }
2084    }
2085
2086    issues
2087}
2088
2089/// Print health analysis results in CodeClimate format.
2090pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
2091    let issues = build_health_codeclimate(report, root);
2092    let value = issues_to_value(&issues);
2093    emit_json(&value, "CodeClimate")
2094}
2095
2096/// Print health CodeClimate output with a per-issue `group` field.
2097///
2098/// Mirrors the dead-code grouped CodeClimate pattern
2099/// (`print_grouped_codeclimate`): build the standard payload first, then
2100/// post-process each issue to attach a `group` key derived from the
2101/// `OwnershipResolver`. Lets GitLab Code Quality and other CodeClimate
2102/// consumers partition findings per team / package without re-parsing the
2103/// project structure.
2104#[expect(
2105    clippy::expect_used,
2106    reason = "grouped health CodeClimate entries are JSON objects created by issues_to_value"
2107)]
2108pub(super) fn print_grouped_health_codeclimate(
2109    report: &HealthReport,
2110    root: &Path,
2111    resolver: &OwnershipResolver,
2112) -> ExitCode {
2113    let issues = build_health_codeclimate(report, root);
2114    let mut value = issues_to_value(&issues);
2115
2116    if let Some(items) = value.as_array_mut() {
2117        for issue in items {
2118            let path = issue
2119                .pointer("/location/path")
2120                .and_then(|v| v.as_str())
2121                .unwrap_or("");
2122            let group = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
2123            issue
2124                .as_object_mut()
2125                .expect("CodeClimate issue should be an object")
2126                .insert("group".to_string(), serde_json::Value::String(group));
2127        }
2128    }
2129
2130    emit_json(&value, "CodeClimate")
2131}
2132
2133/// Build CodeClimate JSON array from duplication analysis results.
2134#[must_use]
2135#[expect(
2136    clippy::cast_possible_truncation,
2137    reason = "line numbers are bounded by source size"
2138)]
2139pub fn build_duplication_codeclimate(
2140    report: &DuplicationReport,
2141    root: &Path,
2142) -> Vec<CodeClimateIssue> {
2143    let mut issues = Vec::new();
2144
2145    for (i, group) in report.clone_groups.iter().enumerate() {
2146        let token_str = group.token_count.to_string();
2147        let line_count_str = group.line_count.to_string();
2148        let fragment_prefix: String = group
2149            .instances
2150            .first()
2151            .map(|inst| inst.fragment.chars().take(64).collect())
2152            .unwrap_or_default();
2153
2154        for instance in &group.instances {
2155            let path = cc_path(&instance.file, root);
2156            let start_str = instance.start_line.to_string();
2157            let fp = fingerprint_hash(&[
2158                "fallow/code-duplication",
2159                &path,
2160                &start_str,
2161                &token_str,
2162                &line_count_str,
2163                &fragment_prefix,
2164            ]);
2165            issues.push(cc_issue(CcIssue {
2166                check_name: "fallow/code-duplication",
2167                description: &format!(
2168                    "Code clone group {} ({} lines, {} instances)",
2169                    i + 1,
2170                    group.line_count,
2171                    group.instances.len()
2172                ),
2173                severity: CodeClimateSeverity::Minor,
2174                category: "Duplication",
2175                path: &path,
2176                begin_line: Some(instance.start_line as u32),
2177                fingerprint: &fp,
2178            }));
2179        }
2180    }
2181
2182    issues
2183}
2184
2185/// Print duplication analysis results in CodeClimate format.
2186pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
2187    let issues = build_duplication_codeclimate(report, root);
2188    let value = issues_to_value(&issues);
2189    emit_json(&value, "CodeClimate")
2190}
2191
2192/// Print duplication CodeClimate output with a per-issue `group` field.
2193///
2194/// Mirrors [`print_grouped_health_codeclimate`]: each clone group is attributed
2195/// to its largest owner ([`super::dupes_grouping::largest_owner`]) and every
2196/// CodeClimate issue emitted for that clone group's instances carries the same
2197/// top-level `group` key. Lets GitLab Code Quality and other CodeClimate
2198/// consumers partition findings per team / package / directory without
2199/// re-parsing the project structure.
2200#[expect(
2201    clippy::expect_used,
2202    reason = "grouped duplication CodeClimate entries are JSON objects created by issues_to_value"
2203)]
2204pub(super) fn print_grouped_duplication_codeclimate(
2205    report: &DuplicationReport,
2206    root: &Path,
2207    resolver: &OwnershipResolver,
2208) -> ExitCode {
2209    let issues = build_duplication_codeclimate(report, root);
2210    let mut value = issues_to_value(&issues);
2211
2212    use rustc_hash::FxHashMap;
2213    let mut path_to_owner: FxHashMap<String, String> = FxHashMap::default();
2214    for group in &report.clone_groups {
2215        let owner = super::dupes_grouping::largest_owner(group, root, resolver);
2216        for instance in &group.instances {
2217            let path = cc_path(&instance.file, root);
2218            path_to_owner.insert(path, owner.clone());
2219        }
2220    }
2221
2222    if let Some(items) = value.as_array_mut() {
2223        for issue in items {
2224            let path = issue
2225                .pointer("/location/path")
2226                .and_then(|v| v.as_str())
2227                .unwrap_or("")
2228                .to_string();
2229            let group = path_to_owner
2230                .get(&path)
2231                .cloned()
2232                .unwrap_or_else(|| crate::codeowners::UNOWNED_LABEL.to_string());
2233            issue
2234                .as_object_mut()
2235                .expect("CodeClimate issue should be an object")
2236                .insert("group".to_string(), serde_json::Value::String(group));
2237        }
2238    }
2239
2240    emit_json(&value, "CodeClimate")
2241}
2242
2243#[cfg(test)]
2244mod tests {
2245    use super::*;
2246    use crate::report::test_helpers::sample_results;
2247    use fallow_config::RulesConfig;
2248    use fallow_core::results::*;
2249    use std::path::PathBuf;
2250
2251    /// Compute graduated severity for health findings based on threshold ratio.
2252    /// Kept for unit test coverage of the original CodeClimate severity model.
2253    fn health_severity(value: u16, threshold: u16) -> &'static str {
2254        if threshold == 0 {
2255            return "minor";
2256        }
2257        let ratio = f64::from(value) / f64::from(threshold);
2258        if ratio > 2.5 {
2259            "critical"
2260        } else if ratio > 1.5 {
2261            "major"
2262        } else {
2263            "minor"
2264        }
2265    }
2266
2267    #[test]
2268    fn codeclimate_empty_results_produces_empty_array() {
2269        let root = PathBuf::from("/project");
2270        let results = AnalysisResults::default();
2271        let rules = RulesConfig::default();
2272        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2273        let arr = output.as_array().unwrap();
2274        assert!(arr.is_empty());
2275    }
2276
2277    #[test]
2278    fn codeclimate_produces_array_of_issues() {
2279        let root = PathBuf::from("/project");
2280        let results = sample_results(&root);
2281        let rules = RulesConfig::default();
2282        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2283        assert!(output.is_array());
2284        let arr = output.as_array().unwrap();
2285        assert!(!arr.is_empty());
2286    }
2287
2288    #[test]
2289    fn codeclimate_missing_suppression_reason_uses_reason_rule_severity() {
2290        let root = PathBuf::from("/project");
2291        let mut results = AnalysisResults::default();
2292        results.stale_suppressions.push(StaleSuppression {
2293            path: root.join("src/file.ts"),
2294            line: 1,
2295            col: 0,
2296            origin: SuppressionOrigin::Comment {
2297                issue_kind: Some("unused-exports".to_string()),
2298                reason: None,
2299                is_file_level: false,
2300                kind_known: true,
2301            },
2302            missing_reason: true,
2303            actions: StaleSuppression::actions_for(true),
2304        });
2305        let rules = RulesConfig {
2306            stale_suppressions: Severity::Off,
2307            require_suppression_reason: Severity::Error,
2308            ..Default::default()
2309        };
2310
2311        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2312
2313        assert_eq!(output[0]["check_name"], "fallow/missing-suppression-reason");
2314        assert_eq!(output[0]["severity"], "major");
2315    }
2316
2317    #[test]
2318    fn codeclimate_stale_and_missing_suppression_have_distinct_identities() {
2319        let root = PathBuf::from("/project");
2320        let mut results = AnalysisResults::default();
2321        let origin = SuppressionOrigin::Comment {
2322            issue_kind: Some("unused-exports".to_string()),
2323            reason: None,
2324            is_file_level: false,
2325            kind_known: true,
2326        };
2327        results.stale_suppressions.push(StaleSuppression {
2328            path: root.join("src/file.ts"),
2329            line: 1,
2330            col: 0,
2331            origin: origin.clone(),
2332            missing_reason: false,
2333            actions: StaleSuppression::actions_for(false),
2334        });
2335        results.stale_suppressions.push(StaleSuppression {
2336            path: root.join("src/file.ts"),
2337            line: 1,
2338            col: 0,
2339            origin,
2340            missing_reason: true,
2341            actions: StaleSuppression::actions_for(true),
2342        });
2343        let rules = RulesConfig {
2344            stale_suppressions: Severity::Warn,
2345            require_suppression_reason: Severity::Error,
2346            ..Default::default()
2347        };
2348
2349        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2350
2351        assert_eq!(output[0]["check_name"], "fallow/stale-suppression");
2352        assert_eq!(output[1]["check_name"], "fallow/missing-suppression-reason");
2353        assert_ne!(output[0]["fingerprint"], output[1]["fingerprint"]);
2354    }
2355
2356    #[test]
2357    fn codeclimate_issue_has_required_fields() {
2358        let root = PathBuf::from("/project");
2359        let mut results = AnalysisResults::default();
2360        results
2361            .unused_files
2362            .push(UnusedFileFinding::with_actions(UnusedFile {
2363                path: root.join("src/dead.ts"),
2364            }));
2365        let rules = RulesConfig::default();
2366        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2367        let issue = &output.as_array().unwrap()[0];
2368
2369        assert_eq!(issue["type"], "issue");
2370        assert_eq!(issue["check_name"], "fallow/unused-file");
2371        assert!(issue["description"].is_string());
2372        assert!(issue["categories"].is_array());
2373        assert!(issue["severity"].is_string());
2374        assert!(issue["fingerprint"].is_string());
2375        assert!(issue["location"].is_object());
2376        assert!(issue["location"]["path"].is_string());
2377        assert!(issue["location"]["lines"].is_object());
2378    }
2379
2380    #[test]
2381    fn codeclimate_unused_file_severity_follows_rules() {
2382        let root = PathBuf::from("/project");
2383        let mut results = AnalysisResults::default();
2384        results
2385            .unused_files
2386            .push(UnusedFileFinding::with_actions(UnusedFile {
2387                path: root.join("src/dead.ts"),
2388            }));
2389
2390        let rules = RulesConfig::default();
2391        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2392        assert_eq!(output[0]["severity"], "major");
2393
2394        let rules = RulesConfig {
2395            unused_files: Severity::Warn,
2396            ..RulesConfig::default()
2397        };
2398        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2399        assert_eq!(output[0]["severity"], "minor");
2400    }
2401
2402    #[test]
2403    fn codeclimate_unused_export_has_line_number() {
2404        let root = PathBuf::from("/project");
2405        let mut results = AnalysisResults::default();
2406        results
2407            .unused_exports
2408            .push(UnusedExportFinding::with_actions(UnusedExport {
2409                path: root.join("src/utils.ts"),
2410                export_name: "helperFn".to_string(),
2411                is_type_only: false,
2412                line: 10,
2413                col: 4,
2414                span_start: 120,
2415                is_re_export: false,
2416            }));
2417        let rules = RulesConfig::default();
2418        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2419        let issue = &output[0];
2420        assert_eq!(issue["location"]["lines"]["begin"], 10);
2421    }
2422
2423    #[test]
2424    fn codeclimate_unused_file_line_defaults_to_1() {
2425        let root = PathBuf::from("/project");
2426        let mut results = AnalysisResults::default();
2427        results
2428            .unused_files
2429            .push(UnusedFileFinding::with_actions(UnusedFile {
2430                path: root.join("src/dead.ts"),
2431            }));
2432        let rules = RulesConfig::default();
2433        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2434        let issue = &output[0];
2435        assert_eq!(issue["location"]["lines"]["begin"], 1);
2436    }
2437
2438    #[test]
2439    fn codeclimate_paths_are_relative() {
2440        let root = PathBuf::from("/project");
2441        let mut results = AnalysisResults::default();
2442        results
2443            .unused_files
2444            .push(UnusedFileFinding::with_actions(UnusedFile {
2445                path: root.join("src/deep/nested/file.ts"),
2446            }));
2447        let rules = RulesConfig::default();
2448        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2449        let path = output[0]["location"]["path"].as_str().unwrap();
2450        assert_eq!(path, "src/deep/nested/file.ts");
2451        assert!(!path.starts_with("/project"));
2452    }
2453
2454    #[test]
2455    fn codeclimate_re_export_label_in_description() {
2456        let root = PathBuf::from("/project");
2457        let mut results = AnalysisResults::default();
2458        results
2459            .unused_exports
2460            .push(UnusedExportFinding::with_actions(UnusedExport {
2461                path: root.join("src/index.ts"),
2462                export_name: "reExported".to_string(),
2463                is_type_only: false,
2464                line: 1,
2465                col: 0,
2466                span_start: 0,
2467                is_re_export: true,
2468            }));
2469        let rules = RulesConfig::default();
2470        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2471        let desc = output[0]["description"].as_str().unwrap();
2472        assert!(desc.contains("Re-export"));
2473    }
2474
2475    #[test]
2476    fn codeclimate_unlisted_dep_one_issue_per_import_site() {
2477        let root = PathBuf::from("/project");
2478        let mut results = AnalysisResults::default();
2479        results
2480            .unlisted_dependencies
2481            .push(UnlistedDependencyFinding::with_actions(
2482                UnlistedDependency {
2483                    package_name: "chalk".to_string(),
2484                    imported_from: vec![
2485                        ImportSite {
2486                            path: root.join("src/a.ts"),
2487                            line: 1,
2488                            col: 0,
2489                        },
2490                        ImportSite {
2491                            path: root.join("src/b.ts"),
2492                            line: 5,
2493                            col: 0,
2494                        },
2495                    ],
2496                },
2497            ));
2498        let rules = RulesConfig::default();
2499        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2500        let arr = output.as_array().unwrap();
2501        assert_eq!(arr.len(), 2);
2502        assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
2503        assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
2504    }
2505
2506    #[test]
2507    fn codeclimate_duplicate_export_one_issue_per_location() {
2508        let root = PathBuf::from("/project");
2509        let mut results = AnalysisResults::default();
2510        results
2511            .duplicate_exports
2512            .push(DuplicateExportFinding::with_actions(DuplicateExport {
2513                export_name: "Config".to_string(),
2514                locations: vec![
2515                    DuplicateLocation {
2516                        path: root.join("src/a.ts"),
2517                        line: 10,
2518                        col: 0,
2519                    },
2520                    DuplicateLocation {
2521                        path: root.join("src/b.ts"),
2522                        line: 20,
2523                        col: 0,
2524                    },
2525                    DuplicateLocation {
2526                        path: root.join("src/c.ts"),
2527                        line: 30,
2528                        col: 0,
2529                    },
2530                ],
2531            }));
2532        let rules = RulesConfig::default();
2533        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2534        let arr = output.as_array().unwrap();
2535        assert_eq!(arr.len(), 3);
2536    }
2537
2538    #[test]
2539    fn codeclimate_circular_dep_emits_chain_in_description() {
2540        let root = PathBuf::from("/project");
2541        let mut results = AnalysisResults::default();
2542        results
2543            .circular_dependencies
2544            .push(CircularDependencyFinding::with_actions(
2545                CircularDependency {
2546                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2547                    length: 2,
2548                    line: 3,
2549                    col: 0,
2550                    edges: Vec::new(),
2551                    is_cross_package: false,
2552                },
2553            ));
2554        let rules = RulesConfig::default();
2555        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2556        let desc = output[0]["description"].as_str().unwrap();
2557        assert!(desc.contains("Circular dependency"));
2558        assert!(desc.contains("src/a.ts"));
2559        assert!(desc.contains("src/b.ts"));
2560    }
2561
2562    #[test]
2563    fn codeclimate_fingerprints_are_deterministic() {
2564        let root = PathBuf::from("/project");
2565        let results = sample_results(&root);
2566        let rules = RulesConfig::default();
2567        let output1 = issues_to_value(&build_codeclimate(&results, &root, &rules));
2568        let output2 = issues_to_value(&build_codeclimate(&results, &root, &rules));
2569
2570        let fps1: Vec<&str> = output1
2571            .as_array()
2572            .unwrap()
2573            .iter()
2574            .map(|i| i["fingerprint"].as_str().unwrap())
2575            .collect();
2576        let fps2: Vec<&str> = output2
2577            .as_array()
2578            .unwrap()
2579            .iter()
2580            .map(|i| i["fingerprint"].as_str().unwrap())
2581            .collect();
2582        assert_eq!(fps1, fps2);
2583    }
2584
2585    #[test]
2586    fn codeclimate_fingerprints_are_unique() {
2587        let root = PathBuf::from("/project");
2588        let results = sample_results(&root);
2589        let rules = RulesConfig::default();
2590        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2591
2592        let mut fps: Vec<&str> = output
2593            .as_array()
2594            .unwrap()
2595            .iter()
2596            .map(|i| i["fingerprint"].as_str().unwrap())
2597            .collect();
2598        let original_len = fps.len();
2599        fps.sort_unstable();
2600        fps.dedup();
2601        assert_eq!(fps.len(), original_len, "fingerprints should be unique");
2602    }
2603
2604    #[test]
2605    fn codeclimate_type_only_dep_has_correct_check_name() {
2606        let root = PathBuf::from("/project");
2607        let mut results = AnalysisResults::default();
2608        results
2609            .type_only_dependencies
2610            .push(TypeOnlyDependencyFinding::with_actions(
2611                TypeOnlyDependency {
2612                    package_name: "zod".to_string(),
2613                    path: root.join("package.json"),
2614                    line: 8,
2615                },
2616            ));
2617        let rules = RulesConfig::default();
2618        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2619        assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
2620        let desc = output[0]["description"].as_str().unwrap();
2621        assert!(desc.contains("zod"));
2622        assert!(desc.contains("type-only"));
2623    }
2624
2625    #[test]
2626    fn codeclimate_dep_with_zero_line_omits_line_number() {
2627        let root = PathBuf::from("/project");
2628        let mut results = AnalysisResults::default();
2629        results
2630            .unused_dependencies
2631            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2632                package_name: "lodash".to_string(),
2633                location: DependencyLocation::Dependencies,
2634                path: root.join("package.json"),
2635                line: 0,
2636                used_in_workspaces: Vec::new(),
2637            }));
2638        let rules = RulesConfig::default();
2639        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2640        assert_eq!(output[0]["location"]["lines"]["begin"], 1);
2641    }
2642
2643    #[test]
2644    fn fingerprint_hash_different_inputs_differ() {
2645        let h1 = fingerprint_hash(&["a", "b"]);
2646        let h2 = fingerprint_hash(&["a", "c"]);
2647        assert_ne!(h1, h2);
2648    }
2649
2650    #[test]
2651    fn fingerprint_hash_order_matters() {
2652        let h1 = fingerprint_hash(&["a", "b"]);
2653        let h2 = fingerprint_hash(&["b", "a"]);
2654        assert_ne!(h1, h2);
2655    }
2656
2657    #[test]
2658    fn fingerprint_hash_separator_prevents_collision() {
2659        let h1 = fingerprint_hash(&["ab", "c"]);
2660        let h2 = fingerprint_hash(&["a", "bc"]);
2661        assert_ne!(h1, h2);
2662    }
2663
2664    #[test]
2665    fn fingerprint_hash_is_16_hex_chars() {
2666        let h = fingerprint_hash(&["test"]);
2667        assert_eq!(h.len(), 16);
2668        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
2669    }
2670
2671    #[test]
2672    fn severity_error_maps_to_major() {
2673        assert_eq!(
2674            severity_to_codeclimate(Severity::Error),
2675            CodeClimateSeverity::Major
2676        );
2677    }
2678
2679    #[test]
2680    fn severity_warn_maps_to_minor() {
2681        assert_eq!(
2682            severity_to_codeclimate(Severity::Warn),
2683            CodeClimateSeverity::Minor
2684        );
2685    }
2686
2687    #[test]
2688    #[should_panic(expected = "internal error: entered unreachable code")]
2689    fn severity_off_is_unreachable() {
2690        let _ = severity_to_codeclimate(Severity::Off);
2691    }
2692
2693    /// Production-mode regression: rules can flip to `Severity::Off` while
2694    /// the matching findings slice arrives empty (the analyzer's own off-
2695    /// rule short-circuit clears the vec, but the generic-iterator helpers
2696    /// in `codeclimate.rs` previously called `severity_to_codeclimate`
2697    /// before checking emptiness and panicked at `Severity::Off`).
2698    /// `fallow dead-code --format codeclimate --production` on any project
2699    /// with a `--production`-suppressed dep / export / member rule used to
2700    /// exit 101 with `entered unreachable code` at `ci/severity.rs:28`.
2701    /// This test exercises all three previously-vulnerable helpers
2702    /// (`push_dep_cc_issues`, `push_unused_export_issues`,
2703    /// `push_unused_member_issues`) through `build_codeclimate`.
2704    #[test]
2705    fn build_codeclimate_with_off_severity_and_empty_findings_does_not_panic() {
2706        let root = PathBuf::from("/project");
2707        let results = AnalysisResults::default();
2708        let rules = RulesConfig {
2709            unused_dependencies: Severity::Off,
2710            unused_dev_dependencies: Severity::Off,
2711            unused_optional_dependencies: Severity::Off,
2712            unused_exports: Severity::Off,
2713            unused_types: Severity::Off,
2714            unused_enum_members: Severity::Off,
2715            unused_class_members: Severity::Off,
2716            ..RulesConfig::default()
2717        };
2718        let issues = build_codeclimate(&results, &root, &rules);
2719        assert!(issues.is_empty());
2720    }
2721
2722    #[test]
2723    fn health_severity_zero_threshold_returns_minor() {
2724        assert_eq!(health_severity(100, 0), "minor");
2725    }
2726
2727    #[test]
2728    fn health_severity_at_threshold_returns_minor() {
2729        assert_eq!(health_severity(10, 10), "minor");
2730    }
2731
2732    #[test]
2733    fn health_severity_1_5x_threshold_returns_minor() {
2734        assert_eq!(health_severity(15, 10), "minor");
2735    }
2736
2737    #[test]
2738    fn health_severity_above_1_5x_returns_major() {
2739        assert_eq!(health_severity(16, 10), "major");
2740    }
2741
2742    #[test]
2743    fn health_severity_at_2_5x_returns_major() {
2744        assert_eq!(health_severity(25, 10), "major");
2745    }
2746
2747    #[test]
2748    fn health_severity_above_2_5x_returns_critical() {
2749        assert_eq!(health_severity(26, 10), "critical");
2750    }
2751
2752    #[test]
2753    fn health_codeclimate_includes_coverage_gaps() {
2754        use crate::health_types::*;
2755
2756        let root = PathBuf::from("/project");
2757        let report = HealthReport {
2758            summary: HealthSummary {
2759                files_analyzed: 10,
2760                functions_analyzed: 50,
2761                ..Default::default()
2762            },
2763            coverage_gaps: Some(CoverageGaps {
2764                summary: CoverageGapSummary {
2765                    runtime_files: 2,
2766                    covered_files: 0,
2767                    file_coverage_pct: 0.0,
2768                    untested_files: 1,
2769                    untested_exports: 1,
2770                },
2771                files: vec![UntestedFileFinding::with_actions(
2772                    UntestedFile {
2773                        path: root.join("src/app.ts"),
2774                        value_export_count: 2,
2775                    },
2776                    &root,
2777                )],
2778                exports: vec![UntestedExportFinding::with_actions(
2779                    UntestedExport {
2780                        path: root.join("src/app.ts"),
2781                        export_name: "loader".into(),
2782                        line: 12,
2783                        col: 4,
2784                    },
2785                    &root,
2786                )],
2787            }),
2788            ..Default::default()
2789        };
2790
2791        let output = issues_to_value(&build_health_codeclimate(&report, &root));
2792        let issues = output.as_array().unwrap();
2793        assert_eq!(issues.len(), 2);
2794        assert_eq!(issues[0]["check_name"], "fallow/untested-file");
2795        assert_eq!(issues[0]["categories"][0], "Coverage");
2796        assert_eq!(issues[0]["location"]["path"], "src/app.ts");
2797        assert_eq!(issues[1]["check_name"], "fallow/untested-export");
2798        assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
2799        assert!(
2800            issues[1]["description"]
2801                .as_str()
2802                .unwrap()
2803                .contains("loader")
2804        );
2805    }
2806
2807    #[test]
2808    fn health_codeclimate_includes_coverage_intelligence_issue() {
2809        use crate::health_types::{
2810            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2811            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2812            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2813            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2814            CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2815            HealthReport, HealthSummary,
2816        };
2817
2818        let root = PathBuf::from("/project");
2819        let report = HealthReport {
2820            summary: HealthSummary {
2821                files_analyzed: 10,
2822                functions_analyzed: 50,
2823                ..Default::default()
2824            },
2825            coverage_intelligence: Some(CoverageIntelligenceReport {
2826                schema_version: CoverageIntelligenceSchemaVersion::V1,
2827                verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2828                summary: CoverageIntelligenceSummary {
2829                    findings: 1,
2830                    high_confidence_deletes: 1,
2831                    ..Default::default()
2832                },
2833                findings: vec![CoverageIntelligenceFinding {
2834                    id: "fallow:coverage-intel:abc123".to_owned(),
2835                    path: root.join("src/dead.ts"),
2836                    identity: Some("deadPath".to_owned()),
2837                    line: 9,
2838                    verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2839                    signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2840                    recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2841                    confidence: CoverageIntelligenceConfidence::High,
2842                    related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2843                    evidence: CoverageIntelligenceEvidence {
2844                        match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2845                        ..Default::default()
2846                    },
2847                    actions: vec![CoverageIntelligenceAction {
2848                        kind: "delete-after-confirming-owner".to_owned(),
2849                        description: "Confirm ownership".to_owned(),
2850                        auto_fixable: false,
2851                    }],
2852                }],
2853            }),
2854            ..Default::default()
2855        };
2856
2857        let output = issues_to_value(&build_health_codeclimate(&report, &root));
2858        let issues = output.as_array().unwrap();
2859        assert_eq!(issues.len(), 1);
2860        assert_eq!(
2861            issues[0]["check_name"],
2862            "fallow/coverage-intelligence-delete"
2863        );
2864        assert!(!issues[0]["fingerprint"].as_str().unwrap().is_empty());
2865        assert_eq!(issues[0]["location"]["path"], "src/dead.ts");
2866        assert!(
2867            issues[0]["description"]
2868                .as_str()
2869                .unwrap()
2870                .contains("deadPath")
2871        );
2872    }
2873
2874    #[test]
2875    fn health_codeclimate_skips_summary_only_coverage_intelligence() {
2876        use crate::health_types::{
2877            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2878            CoverageIntelligenceSummary, CoverageIntelligenceVerdict, HealthReport,
2879        };
2880
2881        let root = PathBuf::from("/project");
2882        let report = HealthReport {
2883            coverage_intelligence: Some(CoverageIntelligenceReport {
2884                schema_version: CoverageIntelligenceSchemaVersion::V1,
2885                verdict: CoverageIntelligenceVerdict::Clean,
2886                summary: CoverageIntelligenceSummary {
2887                    skipped_ambiguous_matches: 2,
2888                    ..Default::default()
2889                },
2890                findings: vec![],
2891            }),
2892            ..Default::default()
2893        };
2894
2895        let issues = build_health_codeclimate(&report, &root);
2896        assert!(issues.is_empty());
2897    }
2898
2899    #[test]
2900    fn health_codeclimate_crap_only_uses_crap_check_name() {
2901        use crate::health_types::{
2902            ComplexityViolation, FindingSeverity, HealthReport, HealthSummary,
2903        };
2904        let root = PathBuf::from("/project");
2905        let report = HealthReport {
2906            findings: vec![
2907                ComplexityViolation {
2908                    path: root.join("src/untested.ts"),
2909                    name: "risky".to_string(),
2910                    line: 7,
2911                    col: 0,
2912                    cyclomatic: 10,
2913                    cognitive: 10,
2914                    line_count: 20,
2915                    param_count: 1,
2916                    react_hook_count: 0,
2917                    react_jsx_max_depth: 0,
2918                    react_prop_count: 0,
2919                    react_hook_profile: None,
2920                    exceeded: crate::health_types::ExceededThreshold::Crap,
2921                    severity: FindingSeverity::High,
2922                    crap: Some(60.0),
2923                    coverage_pct: Some(25.0),
2924                    coverage_tier: None,
2925                    coverage_source: None,
2926                    inherited_from: None,
2927                    component_rollup: None,
2928                    contributions: Vec::new(),
2929                    effective_thresholds: None,
2930                    threshold_source: None,
2931                }
2932                .into(),
2933            ],
2934            summary: HealthSummary {
2935                functions_analyzed: 10,
2936                functions_above_threshold: 1,
2937                ..Default::default()
2938            },
2939            ..Default::default()
2940        };
2941        let json = issues_to_value(&build_health_codeclimate(&report, &root));
2942        let issues = json.as_array().unwrap();
2943        assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
2944        assert_eq!(issues[0]["severity"], "major");
2945        let description = issues[0]["description"].as_str().unwrap();
2946        assert!(description.contains("CRAP score"), "desc: {description}");
2947        assert!(description.contains("coverage 25%"), "desc: {description}");
2948    }
2949
2950    // ---------------------------------------------------------------------------
2951    // coverage_intelligence_check_name: all four recommendation arms
2952    // ---------------------------------------------------------------------------
2953
2954    #[test]
2955    fn coverage_intelligence_check_name_review_before_changing() {
2956        use crate::health_types::CoverageIntelligenceRecommendation;
2957        assert_eq!(
2958            coverage_intelligence_check_name(
2959                CoverageIntelligenceRecommendation::ReviewBeforeChanging
2960            ),
2961            "fallow/coverage-intelligence-review"
2962        );
2963    }
2964
2965    #[test]
2966    fn coverage_intelligence_check_name_refactor_carefully() {
2967        use crate::health_types::CoverageIntelligenceRecommendation;
2968        assert_eq!(
2969            coverage_intelligence_check_name(
2970                CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior
2971            ),
2972            "fallow/coverage-intelligence-refactor"
2973        );
2974    }
2975
2976    // ---------------------------------------------------------------------------
2977    // complexity description: Both / Cyclomatic / Cognitive / All thresholds
2978    // ---------------------------------------------------------------------------
2979
2980    #[test]
2981    fn complexity_description_both_threshold_exceeded() {
2982        use crate::health_types::{ComplexityViolation, ExceededThreshold, FindingSeverity};
2983        let root = PathBuf::from("/project");
2984        let report = crate::health_types::HealthReport {
2985            findings: vec![
2986                ComplexityViolation {
2987                    path: root.join("src/fn.ts"),
2988                    name: "bothFn".to_string(),
2989                    line: 1,
2990                    col: 0,
2991                    cyclomatic: 25,
2992                    cognitive: 20,
2993                    line_count: 50,
2994                    param_count: 2,
2995                    react_hook_count: 0,
2996                    react_jsx_max_depth: 0,
2997                    react_prop_count: 0,
2998                    react_hook_profile: None,
2999                    exceeded: ExceededThreshold::Both,
3000                    severity: FindingSeverity::High,
3001                    crap: None,
3002                    coverage_pct: None,
3003                    coverage_tier: None,
3004                    coverage_source: None,
3005                    inherited_from: None,
3006                    component_rollup: None,
3007                    contributions: vec![],
3008                    effective_thresholds: None,
3009                    threshold_source: None,
3010                }
3011                .into(),
3012            ],
3013            ..Default::default()
3014        };
3015        let json = issues_to_value(&build_health_codeclimate(&report, &root));
3016        let issues = json.as_array().unwrap();
3017        assert_eq!(issues[0]["check_name"], "fallow/high-complexity");
3018        let desc = issues[0]["description"].as_str().unwrap();
3019        assert!(desc.contains("cyclomatic complexity"), "desc: {desc}");
3020        assert!(desc.contains("cognitive complexity"), "desc: {desc}");
3021    }
3022
3023    #[test]
3024    fn complexity_description_cyclomatic_only() {
3025        use crate::health_types::{ComplexityViolation, ExceededThreshold, FindingSeverity};
3026        let root = PathBuf::from("/project");
3027        let report = crate::health_types::HealthReport {
3028            findings: vec![
3029                ComplexityViolation {
3030                    path: root.join("src/fn.ts"),
3031                    name: "cycFn".to_string(),
3032                    line: 1,
3033                    col: 0,
3034                    cyclomatic: 25,
3035                    cognitive: 5,
3036                    line_count: 30,
3037                    param_count: 1,
3038                    react_hook_count: 0,
3039                    react_jsx_max_depth: 0,
3040                    react_prop_count: 0,
3041                    react_hook_profile: None,
3042                    exceeded: ExceededThreshold::Cyclomatic,
3043                    severity: FindingSeverity::High,
3044                    crap: None,
3045                    coverage_pct: None,
3046                    coverage_tier: None,
3047                    coverage_source: None,
3048                    inherited_from: None,
3049                    component_rollup: None,
3050                    contributions: vec![],
3051                    effective_thresholds: None,
3052                    threshold_source: None,
3053                }
3054                .into(),
3055            ],
3056            ..Default::default()
3057        };
3058        let json = issues_to_value(&build_health_codeclimate(&report, &root));
3059        let issues = json.as_array().unwrap();
3060        assert_eq!(issues[0]["check_name"], "fallow/high-cyclomatic-complexity");
3061        let desc = issues[0]["description"].as_str().unwrap();
3062        assert!(desc.contains("cyclomatic complexity"), "desc: {desc}");
3063        assert!(
3064            !desc.contains("cognitive"),
3065            "desc should not mention cognitive: {desc}"
3066        );
3067    }
3068
3069    #[test]
3070    fn complexity_description_cognitive_only() {
3071        use crate::health_types::{ComplexityViolation, ExceededThreshold, FindingSeverity};
3072        let root = PathBuf::from("/project");
3073        let report = crate::health_types::HealthReport {
3074            findings: vec![
3075                ComplexityViolation {
3076                    path: root.join("src/fn.ts"),
3077                    name: "cogFn".to_string(),
3078                    line: 1,
3079                    col: 0,
3080                    cyclomatic: 5,
3081                    cognitive: 30,
3082                    line_count: 30,
3083                    param_count: 1,
3084                    react_hook_count: 0,
3085                    react_jsx_max_depth: 0,
3086                    react_prop_count: 0,
3087                    react_hook_profile: None,
3088                    exceeded: ExceededThreshold::Cognitive,
3089                    severity: FindingSeverity::High,
3090                    crap: None,
3091                    coverage_pct: None,
3092                    coverage_tier: None,
3093                    coverage_source: None,
3094                    inherited_from: None,
3095                    component_rollup: None,
3096                    contributions: vec![],
3097                    effective_thresholds: None,
3098                    threshold_source: None,
3099                }
3100                .into(),
3101            ],
3102            ..Default::default()
3103        };
3104        let json = issues_to_value(&build_health_codeclimate(&report, &root));
3105        let issues = json.as_array().unwrap();
3106        assert_eq!(issues[0]["check_name"], "fallow/high-cognitive-complexity");
3107        let desc = issues[0]["description"].as_str().unwrap();
3108        assert!(desc.contains("cognitive complexity"), "desc: {desc}");
3109        assert!(!desc.contains("cyclomatic"), "desc: {desc}");
3110    }
3111
3112    #[test]
3113    fn complexity_description_all_threshold_crap_no_coverage() {
3114        use crate::health_types::{ComplexityViolation, ExceededThreshold, FindingSeverity};
3115        let root = PathBuf::from("/project");
3116        let report = crate::health_types::HealthReport {
3117            findings: vec![
3118                ComplexityViolation {
3119                    path: root.join("src/fn.ts"),
3120                    name: "allFn".to_string(),
3121                    line: 1,
3122                    col: 0,
3123                    cyclomatic: 30,
3124                    cognitive: 20,
3125                    line_count: 80,
3126                    param_count: 2,
3127                    react_hook_count: 0,
3128                    react_jsx_max_depth: 0,
3129                    react_prop_count: 0,
3130                    react_hook_profile: None,
3131                    exceeded: ExceededThreshold::All,
3132                    severity: FindingSeverity::Critical,
3133                    crap: Some(110.0),
3134                    coverage_pct: None,
3135                    coverage_tier: None,
3136                    coverage_source: None,
3137                    inherited_from: None,
3138                    component_rollup: None,
3139                    contributions: vec![],
3140                    effective_thresholds: None,
3141                    threshold_source: None,
3142                }
3143                .into(),
3144            ],
3145            ..Default::default()
3146        };
3147        let json = issues_to_value(&build_health_codeclimate(&report, &root));
3148        let issues = json.as_array().unwrap();
3149        assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
3150        let desc = issues[0]["description"].as_str().unwrap();
3151        assert!(desc.contains("CRAP score"), "desc: {desc}");
3152        assert!(
3153            !desc.contains("coverage"),
3154            "no coverage pct should appear: {desc}"
3155        );
3156    }
3157
3158    // ---------------------------------------------------------------------------
3159    // coverage_intelligence_issue: None identity falls back to "code"
3160    // ---------------------------------------------------------------------------
3161
3162    #[test]
3163    fn coverage_intelligence_issue_none_identity_uses_code_label() {
3164        use crate::health_types::{
3165            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
3166            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
3167            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
3168            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
3169            CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
3170        };
3171        let root = PathBuf::from("/project");
3172        let report = crate::health_types::HealthReport {
3173            coverage_intelligence: Some(CoverageIntelligenceReport {
3174                schema_version: CoverageIntelligenceSchemaVersion::V1,
3175                verdict: CoverageIntelligenceVerdict::ReviewRequired,
3176                summary: CoverageIntelligenceSummary {
3177                    findings: 1,
3178                    ..Default::default()
3179                },
3180                findings: vec![CoverageIntelligenceFinding {
3181                    id: "fallow:coverage-intel:xyz".to_owned(),
3182                    path: root.join("src/util.ts"),
3183                    identity: None,
3184                    line: 3,
3185                    verdict: CoverageIntelligenceVerdict::ReviewRequired,
3186                    signals: vec![],
3187                    recommendation: CoverageIntelligenceRecommendation::ReviewBeforeChanging,
3188                    confidence: CoverageIntelligenceConfidence::Low,
3189                    related_ids: vec![],
3190                    evidence: CoverageIntelligenceEvidence {
3191                        match_confidence: CoverageIntelligenceMatchConfidence::Direct,
3192                        ..Default::default()
3193                    },
3194                    actions: vec![CoverageIntelligenceAction {
3195                        kind: "review".to_owned(),
3196                        description: "Review before changing".to_owned(),
3197                        auto_fixable: false,
3198                    }],
3199                }],
3200            }),
3201            ..Default::default()
3202        };
3203        let json = issues_to_value(&build_health_codeclimate(&report, &root));
3204        let issues = json.as_array().unwrap();
3205        assert_eq!(issues.len(), 1);
3206        assert_eq!(
3207            issues[0]["check_name"],
3208            "fallow/coverage-intelligence-review"
3209        );
3210        let desc = issues[0]["description"].as_str().unwrap();
3211        assert!(
3212            desc.contains("'code'"),
3213            "description should say 'code': {desc}"
3214        );
3215    }
3216
3217    // ---------------------------------------------------------------------------
3218    // coverage_intelligence_issue: Clean/Unknown verdicts are filtered out
3219    // ---------------------------------------------------------------------------
3220
3221    #[test]
3222    fn coverage_intelligence_clean_verdict_emits_no_issue() {
3223        use crate::health_types::{
3224            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
3225            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
3226            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
3227            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
3228            CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
3229        };
3230        let root = PathBuf::from("/project");
3231        let report = crate::health_types::HealthReport {
3232            coverage_intelligence: Some(CoverageIntelligenceReport {
3233                schema_version: CoverageIntelligenceSchemaVersion::V1,
3234                verdict: CoverageIntelligenceVerdict::Clean,
3235                summary: CoverageIntelligenceSummary {
3236                    findings: 1,
3237                    ..Default::default()
3238                },
3239                findings: vec![CoverageIntelligenceFinding {
3240                    id: "fallow:coverage-intel:clean".to_owned(),
3241                    path: root.join("src/util.ts"),
3242                    identity: Some("cleanFn".to_owned()),
3243                    line: 3,
3244                    verdict: CoverageIntelligenceVerdict::Clean,
3245                    signals: vec![],
3246                    recommendation: CoverageIntelligenceRecommendation::ReviewBeforeChanging,
3247                    confidence: CoverageIntelligenceConfidence::Low,
3248                    related_ids: vec![],
3249                    evidence: CoverageIntelligenceEvidence {
3250                        match_confidence: CoverageIntelligenceMatchConfidence::Direct,
3251                        ..Default::default()
3252                    },
3253                    actions: vec![CoverageIntelligenceAction {
3254                        kind: "review".to_owned(),
3255                        description: "Review before changing".to_owned(),
3256                        auto_fixable: false,
3257                    }],
3258                }],
3259            }),
3260            ..Default::default()
3261        };
3262        let issues = build_health_codeclimate(&report, &root);
3263        assert!(
3264            issues.is_empty(),
3265            "Clean verdict should not produce an issue"
3266        );
3267    }
3268
3269    // ---------------------------------------------------------------------------
3270    // untested_file_issue: singular vs plural export count in description
3271    // ---------------------------------------------------------------------------
3272
3273    #[test]
3274    fn untested_file_singular_export_count() {
3275        use crate::health_types::{
3276            CoverageGapSummary, CoverageGaps, UntestedFile, UntestedFileFinding,
3277        };
3278        let root = PathBuf::from("/project");
3279        let report = crate::health_types::HealthReport {
3280            coverage_gaps: Some(CoverageGaps {
3281                summary: CoverageGapSummary {
3282                    runtime_files: 1,
3283                    covered_files: 0,
3284                    file_coverage_pct: 0.0,
3285                    untested_files: 1,
3286                    untested_exports: 0,
3287                },
3288                files: vec![UntestedFileFinding::with_actions(
3289                    UntestedFile {
3290                        path: root.join("src/single.ts"),
3291                        value_export_count: 1,
3292                    },
3293                    &root,
3294                )],
3295                exports: vec![],
3296            }),
3297            ..Default::default()
3298        };
3299        let json = issues_to_value(&build_health_codeclimate(&report, &root));
3300        let issues = json.as_array().unwrap();
3301        assert_eq!(issues.len(), 1);
3302        let desc = issues[0]["description"].as_str().unwrap();
3303        assert!(desc.contains("1 value export)"), "singular: {desc}");
3304        assert!(
3305            !desc.contains("exports"),
3306            "singular should not say 'exports': {desc}"
3307        );
3308    }
3309
3310    #[test]
3311    fn untested_file_plural_export_count() {
3312        use crate::health_types::{
3313            CoverageGapSummary, CoverageGaps, UntestedFile, UntestedFileFinding,
3314        };
3315        let root = PathBuf::from("/project");
3316        let report = crate::health_types::HealthReport {
3317            coverage_gaps: Some(CoverageGaps {
3318                summary: CoverageGapSummary {
3319                    runtime_files: 1,
3320                    covered_files: 0,
3321                    file_coverage_pct: 0.0,
3322                    untested_files: 1,
3323                    untested_exports: 0,
3324                },
3325                files: vec![UntestedFileFinding::with_actions(
3326                    UntestedFile {
3327                        path: root.join("src/multi.ts"),
3328                        value_export_count: 3,
3329                    },
3330                    &root,
3331                )],
3332                exports: vec![],
3333            }),
3334            ..Default::default()
3335        };
3336        let json = issues_to_value(&build_health_codeclimate(&report, &root));
3337        let issues = json.as_array().unwrap();
3338        let desc = issues[0]["description"].as_str().unwrap();
3339        assert!(desc.contains("3 value exports)"), "plural: {desc}");
3340    }
3341
3342    // ---------------------------------------------------------------------------
3343    // health_finding_severity: Critical -> Critical, Moderate -> Minor
3344    // ---------------------------------------------------------------------------
3345
3346    #[test]
3347    fn health_finding_severity_critical_maps_to_critical() {
3348        use crate::health_types::FindingSeverity;
3349        assert_eq!(
3350            health_finding_severity(FindingSeverity::Critical),
3351            CodeClimateSeverity::Critical
3352        );
3353    }
3354
3355    #[test]
3356    fn health_finding_severity_moderate_maps_to_minor() {
3357        use crate::health_types::FindingSeverity;
3358        assert_eq!(
3359            health_finding_severity(FindingSeverity::Moderate),
3360            CodeClimateSeverity::Minor
3361        );
3362    }
3363
3364    // ---------------------------------------------------------------------------
3365    // runtime_coverage_check_name: all verdict arms
3366    // ---------------------------------------------------------------------------
3367
3368    #[test]
3369    fn runtime_coverage_check_name_safe_to_delete() {
3370        use crate::health_types::RuntimeCoverageVerdict;
3371        assert_eq!(
3372            runtime_coverage_check_name(RuntimeCoverageVerdict::SafeToDelete),
3373            "fallow/runtime-safe-to-delete"
3374        );
3375    }
3376
3377    #[test]
3378    fn runtime_coverage_check_name_low_traffic() {
3379        use crate::health_types::RuntimeCoverageVerdict;
3380        assert_eq!(
3381            runtime_coverage_check_name(RuntimeCoverageVerdict::LowTraffic),
3382            "fallow/runtime-low-traffic"
3383        );
3384    }
3385
3386    #[test]
3387    fn runtime_coverage_check_name_coverage_unavailable() {
3388        use crate::health_types::RuntimeCoverageVerdict;
3389        assert_eq!(
3390            runtime_coverage_check_name(RuntimeCoverageVerdict::CoverageUnavailable),
3391            "fallow/runtime-coverage-unavailable"
3392        );
3393    }
3394
3395    #[test]
3396    fn runtime_coverage_check_name_active_and_unknown_use_generic() {
3397        use crate::health_types::RuntimeCoverageVerdict;
3398        assert_eq!(
3399            runtime_coverage_check_name(RuntimeCoverageVerdict::Active),
3400            "fallow/runtime-coverage"
3401        );
3402        assert_eq!(
3403            runtime_coverage_check_name(RuntimeCoverageVerdict::Unknown),
3404            "fallow/runtime-coverage"
3405        );
3406    }
3407
3408    // ---------------------------------------------------------------------------
3409    // runtime_coverage_severity: verdict arms
3410    // ---------------------------------------------------------------------------
3411
3412    #[test]
3413    fn runtime_coverage_severity_safe_to_delete_is_critical() {
3414        use crate::health_types::RuntimeCoverageVerdict;
3415        assert_eq!(
3416            runtime_coverage_severity(RuntimeCoverageVerdict::SafeToDelete),
3417            CodeClimateSeverity::Critical
3418        );
3419    }
3420
3421    #[test]
3422    fn runtime_coverage_severity_review_required_is_major() {
3423        use crate::health_types::RuntimeCoverageVerdict;
3424        assert_eq!(
3425            runtime_coverage_severity(RuntimeCoverageVerdict::ReviewRequired),
3426            CodeClimateSeverity::Major
3427        );
3428    }
3429
3430    #[test]
3431    fn runtime_coverage_severity_other_verdicts_are_minor() {
3432        use crate::health_types::RuntimeCoverageVerdict;
3433        assert_eq!(
3434            runtime_coverage_severity(RuntimeCoverageVerdict::LowTraffic),
3435            CodeClimateSeverity::Minor
3436        );
3437        assert_eq!(
3438            runtime_coverage_severity(RuntimeCoverageVerdict::Active),
3439            CodeClimateSeverity::Minor
3440        );
3441    }
3442
3443    // ---------------------------------------------------------------------------
3444    // coverage_intelligence_severity: all verdict arms
3445    // ---------------------------------------------------------------------------
3446
3447    #[test]
3448    fn coverage_intelligence_severity_review_required_is_minor() {
3449        use crate::health_types::CoverageIntelligenceVerdict;
3450        assert_eq!(
3451            coverage_intelligence_severity(CoverageIntelligenceVerdict::ReviewRequired),
3452            Some(CodeClimateSeverity::Minor)
3453        );
3454    }
3455
3456    #[test]
3457    fn coverage_intelligence_severity_refactor_carefully_is_minor() {
3458        use crate::health_types::CoverageIntelligenceVerdict;
3459        assert_eq!(
3460            coverage_intelligence_severity(CoverageIntelligenceVerdict::RefactorCarefully),
3461            Some(CodeClimateSeverity::Minor)
3462        );
3463    }
3464
3465    #[test]
3466    fn coverage_intelligence_severity_clean_is_none() {
3467        use crate::health_types::CoverageIntelligenceVerdict;
3468        assert_eq!(
3469            coverage_intelligence_severity(CoverageIntelligenceVerdict::Clean),
3470            None
3471        );
3472    }
3473
3474    #[test]
3475    fn coverage_intelligence_severity_unknown_is_none() {
3476        use crate::health_types::CoverageIntelligenceVerdict;
3477        assert_eq!(
3478            coverage_intelligence_severity(CoverageIntelligenceVerdict::Unknown),
3479            None
3480        );
3481    }
3482
3483    // ---------------------------------------------------------------------------
3484    // push_dep_cc_issues: workspace context included in description
3485    // ---------------------------------------------------------------------------
3486
3487    #[test]
3488    fn unused_dep_with_workspace_context_includes_workspace_in_description() {
3489        let root = PathBuf::from("/project");
3490        let mut results = AnalysisResults::default();
3491        results
3492            .unused_dependencies
3493            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
3494                package_name: "shared-lib".to_string(),
3495                location: DependencyLocation::Dependencies,
3496                path: root.join("package.json"),
3497                line: 10,
3498                used_in_workspaces: vec![root.join("packages/app")],
3499            }));
3500        let rules = RulesConfig::default();
3501        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3502        let desc = output[0]["description"].as_str().unwrap();
3503        assert!(
3504            desc.contains("imported in other workspaces"),
3505            "desc: {desc}"
3506        );
3507        assert!(desc.contains("packages/app"), "desc: {desc}");
3508    }
3509
3510    // ---------------------------------------------------------------------------
3511    // push_private_type_leak_issues
3512    // ---------------------------------------------------------------------------
3513
3514    #[test]
3515    fn private_type_leak_emits_correct_check_name_and_description() {
3516        use fallow_config::Severity;
3517        use fallow_types::output_dead_code::PrivateTypeLeakFinding;
3518        let root = PathBuf::from("/project");
3519        let mut results = AnalysisResults::default();
3520        results
3521            .private_type_leaks
3522            .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
3523                path: root.join("src/api.ts"),
3524                export_name: "ApiResponse".to_string(),
3525                type_name: "InternalState".to_string(),
3526                line: 7,
3527                col: 0,
3528                span_start: 0,
3529            }));
3530        // private_type_leaks defaults to Off; enable it so the issue is emitted.
3531        let rules = RulesConfig {
3532            private_type_leaks: Severity::Error,
3533            ..RulesConfig::default()
3534        };
3535        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3536        let arr = output.as_array().unwrap();
3537        assert_eq!(arr.len(), 1);
3538        assert_eq!(arr[0]["check_name"], "fallow/private-type-leak");
3539        let desc = arr[0]["description"].as_str().unwrap();
3540        assert!(desc.contains("ApiResponse"), "desc: {desc}");
3541        assert!(desc.contains("InternalState"), "desc: {desc}");
3542        assert_eq!(arr[0]["location"]["lines"]["begin"], 7);
3543    }
3544
3545    // ---------------------------------------------------------------------------
3546    // push_type_only_dep_issues / push_test_only_dep_issues: zero line -> line 1
3547    // ---------------------------------------------------------------------------
3548
3549    #[test]
3550    fn type_only_dep_zero_line_defaults_to_1() {
3551        let root = PathBuf::from("/project");
3552        let mut results = AnalysisResults::default();
3553        results
3554            .type_only_dependencies
3555            .push(TypeOnlyDependencyFinding::with_actions(
3556                TypeOnlyDependency {
3557                    package_name: "ts-types".to_string(),
3558                    path: root.join("package.json"),
3559                    line: 0,
3560                },
3561            ));
3562        let rules = RulesConfig::default();
3563        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3564        assert_eq!(output[0]["location"]["lines"]["begin"], 1);
3565    }
3566
3567    #[test]
3568    fn test_only_dep_zero_line_defaults_to_1() {
3569        let root = PathBuf::from("/project");
3570        let mut results = AnalysisResults::default();
3571        results
3572            .test_only_dependencies
3573            .push(TestOnlyDependencyFinding::with_actions(
3574                TestOnlyDependency {
3575                    package_name: "vitest".to_string(),
3576                    path: root.join("package.json"),
3577                    line: 0,
3578                },
3579            ));
3580        let rules = RulesConfig::default();
3581        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3582        assert_eq!(output[0]["check_name"], "fallow/test-only-dependency");
3583        assert_eq!(output[0]["location"]["lines"]["begin"], 1);
3584    }
3585
3586    // ---------------------------------------------------------------------------
3587    // push_re_export_cycle_issues: self-loop and multi-node arms
3588    // ---------------------------------------------------------------------------
3589
3590    #[test]
3591    fn re_export_cycle_self_loop_description_contains_self_loop_tag() {
3592        use fallow_types::output_dead_code::ReExportCycleFinding;
3593        let root = PathBuf::from("/project");
3594        let mut results = AnalysisResults::default();
3595        results
3596            .re_export_cycles
3597            .push(ReExportCycleFinding::with_actions(ReExportCycle {
3598                files: vec![root.join("src/index.ts")],
3599                kind: ReExportCycleKind::SelfLoop,
3600            }));
3601        let rules = RulesConfig::default();
3602        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3603        assert_eq!(output[0]["check_name"], "fallow/re-export-cycle");
3604        let desc = output[0]["description"].as_str().unwrap();
3605        assert!(desc.contains("(self-loop)"), "desc: {desc}");
3606    }
3607
3608    #[test]
3609    fn re_export_cycle_multi_node_description_has_no_kind_tag() {
3610        use fallow_types::output_dead_code::ReExportCycleFinding;
3611        let root = PathBuf::from("/project");
3612        let mut results = AnalysisResults::default();
3613        results
3614            .re_export_cycles
3615            .push(ReExportCycleFinding::with_actions(ReExportCycle {
3616                files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
3617                kind: ReExportCycleKind::MultiNode,
3618            }));
3619        let rules = RulesConfig::default();
3620        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3621        let desc = output[0]["description"].as_str().unwrap();
3622        assert!(desc.starts_with("Re-export cycle: "), "desc: {desc}");
3623        assert!(!desc.contains("(self-loop)"), "desc: {desc}");
3624        assert!(!desc.contains("(multi-node)"), "desc: {desc}");
3625        assert!(desc.contains("src/a.ts"), "desc: {desc}");
3626    }
3627
3628    // ---------------------------------------------------------------------------
3629    // push_boundary_coverage_issues and push_boundary_call_issues
3630    // ---------------------------------------------------------------------------
3631
3632    #[test]
3633    fn boundary_coverage_violation_emits_correct_check_name() {
3634        use fallow_types::output_dead_code::BoundaryCoverageViolationFinding;
3635        let root = PathBuf::from("/project");
3636        let mut results = AnalysisResults::default();
3637        results
3638            .boundary_coverage_violations
3639            .push(BoundaryCoverageViolationFinding::with_actions(
3640                BoundaryCoverageViolation {
3641                    path: root.join("src/orphan.ts"),
3642                    line: 0,
3643                    col: 0,
3644                },
3645            ));
3646        let rules = RulesConfig::default();
3647        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3648        assert_eq!(output[0]["check_name"], "fallow/boundary-coverage");
3649        let desc = output[0]["description"].as_str().unwrap();
3650        assert!(desc.contains("no configured zone"), "desc: {desc}");
3651    }
3652
3653    #[test]
3654    fn boundary_call_violation_emits_pattern_in_description() {
3655        use fallow_types::output_dead_code::BoundaryCallViolationFinding;
3656        let root = PathBuf::from("/project");
3657        let mut results = AnalysisResults::default();
3658        results
3659            .boundary_call_violations
3660            .push(BoundaryCallViolationFinding::with_actions(
3661                BoundaryCallViolation {
3662                    path: root.join("src/ui/App.ts"),
3663                    line: 5,
3664                    col: 0,
3665                    zone: "ui".to_string(),
3666                    callee: "fs.readFile".to_string(),
3667                    pattern: "fs.*".to_string(),
3668                },
3669            ));
3670        let rules = RulesConfig::default();
3671        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3672        assert_eq!(output[0]["check_name"], "fallow/boundary-call-violation");
3673        let desc = output[0]["description"].as_str().unwrap();
3674        assert!(desc.contains("fs.readFile"), "desc: {desc}");
3675        assert!(desc.contains("fs.*"), "desc: {desc}");
3676        assert!(desc.contains("'ui'"), "desc: {desc}");
3677    }
3678
3679    // ---------------------------------------------------------------------------
3680    // push_policy_violation_issues: error severity and optional message
3681    // ---------------------------------------------------------------------------
3682
3683    #[test]
3684    fn policy_violation_error_severity_maps_to_major() {
3685        use fallow_types::output_dead_code::PolicyViolationFinding;
3686        let root = PathBuf::from("/project");
3687        let mut results = AnalysisResults::default();
3688        results
3689            .policy_violations
3690            .push(PolicyViolationFinding::with_actions(PolicyViolation {
3691                path: root.join("src/service.ts"),
3692                line: 2,
3693                col: 0,
3694                pack: "security".to_string(),
3695                rule_id: "no-eval".to_string(),
3696                kind: PolicyRuleKind::BannedCall,
3697                matched: "eval".to_string(),
3698                severity: PolicyViolationSeverity::Error,
3699                message: None,
3700            }));
3701        let rules = RulesConfig::default();
3702        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3703        assert_eq!(output[0]["check_name"], "fallow/policy-violation");
3704        assert_eq!(output[0]["severity"], "major");
3705        let desc = output[0]["description"].as_str().unwrap();
3706        assert!(desc.contains("eval"), "desc: {desc}");
3707        assert!(desc.contains("security/no-eval"), "desc: {desc}");
3708    }
3709
3710    #[test]
3711    fn policy_violation_warn_severity_maps_to_minor() {
3712        use fallow_types::output_dead_code::PolicyViolationFinding;
3713        let root = PathBuf::from("/project");
3714        let mut results = AnalysisResults::default();
3715        results
3716            .policy_violations
3717            .push(PolicyViolationFinding::with_actions(PolicyViolation {
3718                path: root.join("src/service.ts"),
3719                line: 2,
3720                col: 0,
3721                pack: "style".to_string(),
3722                rule_id: "no-console".to_string(),
3723                kind: PolicyRuleKind::BannedCall,
3724                matched: "console.log".to_string(),
3725                severity: PolicyViolationSeverity::Warn,
3726                message: Some("Use the project logger instead".to_string()),
3727            }));
3728        let rules = RulesConfig::default();
3729        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3730        assert_eq!(output[0]["severity"], "minor");
3731        let desc = output[0]["description"].as_str().unwrap();
3732        assert!(
3733            desc.contains("Use the project logger instead"),
3734            "desc: {desc}"
3735        );
3736    }
3737
3738    // ---------------------------------------------------------------------------
3739    // push_invalid_client_export_issues and push_mixed_client_server_barrel_issues
3740    // ---------------------------------------------------------------------------
3741
3742    #[test]
3743    fn invalid_client_export_emits_correct_check_name_and_directive() {
3744        use fallow_types::output_dead_code::InvalidClientExportFinding;
3745        let root = PathBuf::from("/project");
3746        let mut results = AnalysisResults::default();
3747        results
3748            .invalid_client_exports
3749            .push(InvalidClientExportFinding::with_actions(
3750                InvalidClientExport {
3751                    path: root.join("src/page.ts"),
3752                    export_name: "metadata".to_string(),
3753                    directive: "use client".to_string(),
3754                    line: 3,
3755                    col: 0,
3756                },
3757            ));
3758        let rules = RulesConfig::default();
3759        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3760        assert_eq!(output[0]["check_name"], "fallow/invalid-client-export");
3761        let desc = output[0]["description"].as_str().unwrap();
3762        assert!(desc.contains("metadata"), "desc: {desc}");
3763        assert!(desc.contains("use client"), "desc: {desc}");
3764    }
3765
3766    #[test]
3767    fn mixed_client_server_barrel_emits_correct_check_name_and_origins() {
3768        use fallow_types::output_dead_code::MixedClientServerBarrelFinding;
3769        let root = PathBuf::from("/project");
3770        let mut results = AnalysisResults::default();
3771        results
3772            .mixed_client_server_barrels
3773            .push(MixedClientServerBarrelFinding::with_actions(
3774                MixedClientServerBarrel {
3775                    path: root.join("src/index.ts"),
3776                    client_origin: "./client-comp".to_string(),
3777                    server_origin: "./server-only".to_string(),
3778                    line: 1,
3779                    col: 0,
3780                },
3781            ));
3782        let rules = RulesConfig::default();
3783        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3784        assert_eq!(output[0]["check_name"], "fallow/mixed-client-server-barrel");
3785        let desc = output[0]["description"].as_str().unwrap();
3786        assert!(desc.contains("./client-comp"), "desc: {desc}");
3787        assert!(desc.contains("./server-only"), "desc: {desc}");
3788    }
3789
3790    // ---------------------------------------------------------------------------
3791    // push_misplaced_directive_issues
3792    // ---------------------------------------------------------------------------
3793
3794    #[test]
3795    fn misplaced_directive_emits_correct_check_name_and_guidance() {
3796        use fallow_types::output_dead_code::MisplacedDirectiveFinding;
3797        let root = PathBuf::from("/project");
3798        let mut results = AnalysisResults::default();
3799        results
3800            .misplaced_directives
3801            .push(MisplacedDirectiveFinding::with_actions(
3802                MisplacedDirective {
3803                    path: root.join("src/page.ts"),
3804                    directive: "use client".to_string(),
3805                    line: 5,
3806                    col: 0,
3807                },
3808            ));
3809        let rules = RulesConfig::default();
3810        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3811        assert_eq!(output[0]["check_name"], "fallow/misplaced-directive");
3812        let desc = output[0]["description"].as_str().unwrap();
3813        assert!(desc.contains("use client"), "desc: {desc}");
3814        assert!(desc.contains("leading position"), "desc: {desc}");
3815    }
3816
3817    // ---------------------------------------------------------------------------
3818    // push_unrendered_component_issues
3819    // ---------------------------------------------------------------------------
3820
3821    #[test]
3822    fn unrendered_component_emits_correct_check_name_and_description() {
3823        use fallow_types::output_dead_code::UnrenderedComponentFinding;
3824        let root = PathBuf::from("/project");
3825        let mut results = AnalysisResults::default();
3826        results
3827            .unrendered_components
3828            .push(UnrenderedComponentFinding::with_actions(
3829                UnrenderedComponent {
3830                    path: root.join("src/Dead.vue"),
3831                    component_name: "Dead".to_string(),
3832                    framework: "vue".to_string(),
3833                    reachable_via: None,
3834                    line: 1,
3835                    col: 0,
3836                },
3837            ));
3838        let rules = RulesConfig::default();
3839        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3840        assert_eq!(output[0]["check_name"], "fallow/unrendered-component");
3841        let desc = output[0]["description"].as_str().unwrap();
3842        assert!(desc.contains("`Dead`"), "desc: {desc}");
3843        assert!(desc.contains("rendered nowhere"), "desc: {desc}");
3844    }
3845
3846    // ---------------------------------------------------------------------------
3847    // push_unused_component_prop_issues
3848    // ---------------------------------------------------------------------------
3849
3850    #[test]
3851    fn unused_component_prop_emits_correct_check_name_and_description() {
3852        use fallow_types::output_dead_code::UnusedComponentPropFinding;
3853        let root = PathBuf::from("/project");
3854        let mut results = AnalysisResults::default();
3855        results
3856            .unused_component_props
3857            .push(UnusedComponentPropFinding::with_actions(
3858                UnusedComponentProp {
3859                    path: root.join("src/Card.vue"),
3860                    component_name: "Card".to_string(),
3861                    prop_name: "color".to_string(),
3862                    line: 3,
3863                    col: 0,
3864                },
3865            ));
3866        let rules = RulesConfig::default();
3867        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3868        assert_eq!(output[0]["check_name"], "fallow/unused-component-prop");
3869        let desc = output[0]["description"].as_str().unwrap();
3870        assert!(desc.contains("`color`"), "desc: {desc}");
3871        assert!(desc.contains("Card"), "desc: {desc}");
3872    }
3873
3874    // ---------------------------------------------------------------------------
3875    // push_unused_component_emit_issues
3876    // ---------------------------------------------------------------------------
3877
3878    #[test]
3879    fn unused_component_emit_emits_correct_check_name_and_description() {
3880        use fallow_types::output_dead_code::UnusedComponentEmitFinding;
3881        let root = PathBuf::from("/project");
3882        let mut results = AnalysisResults::default();
3883        results
3884            .unused_component_emits
3885            .push(UnusedComponentEmitFinding::with_actions(
3886                UnusedComponentEmit {
3887                    path: root.join("src/Button.vue"),
3888                    component_name: "Button".to_string(),
3889                    emit_name: "close".to_string(),
3890                    line: 4,
3891                    col: 0,
3892                },
3893            ));
3894        let rules = RulesConfig::default();
3895        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3896        assert_eq!(output[0]["check_name"], "fallow/unused-component-emit");
3897        let desc = output[0]["description"].as_str().unwrap();
3898        assert!(desc.contains("`close`"), "desc: {desc}");
3899        assert!(desc.contains("Button"), "desc: {desc}");
3900    }
3901
3902    // ---------------------------------------------------------------------------
3903    // push_unused_svelte_event_issues
3904    // ---------------------------------------------------------------------------
3905
3906    #[test]
3907    fn unused_svelte_event_emits_correct_check_name_and_description() {
3908        use fallow_types::output_dead_code::UnusedSvelteEventFinding;
3909        let root = PathBuf::from("/project");
3910        let mut results = AnalysisResults::default();
3911        results
3912            .unused_svelte_events
3913            .push(UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
3914                path: root.join("src/Child.svelte"),
3915                component_name: "Child".to_string(),
3916                event_name: "submit".to_string(),
3917                line: 2,
3918                col: 0,
3919            }));
3920        let rules = RulesConfig::default();
3921        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3922        assert_eq!(output[0]["check_name"], "fallow/unused-svelte-event");
3923        let desc = output[0]["description"].as_str().unwrap();
3924        assert!(desc.contains("`submit`"), "desc: {desc}");
3925        assert!(desc.contains("listened to nowhere"), "desc: {desc}");
3926    }
3927
3928    // ---------------------------------------------------------------------------
3929    // push_unused_component_input_issues and push_unused_component_output_issues
3930    // ---------------------------------------------------------------------------
3931
3932    #[test]
3933    fn unused_component_input_emits_correct_check_name_and_description() {
3934        use fallow_types::output_dead_code::UnusedComponentInputFinding;
3935        let root = PathBuf::from("/project");
3936        let mut results = AnalysisResults::default();
3937        results
3938            .unused_component_inputs
3939            .push(UnusedComponentInputFinding::with_actions(
3940                UnusedComponentInput {
3941                    path: root.join("src/card.component.ts"),
3942                    component_name: "CardComponent".to_string(),
3943                    input_name: "label".to_string(),
3944                    line: 5,
3945                    col: 0,
3946                },
3947            ));
3948        let rules = RulesConfig::default();
3949        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3950        assert_eq!(output[0]["check_name"], "fallow/unused-component-input");
3951        let desc = output[0]["description"].as_str().unwrap();
3952        assert!(desc.contains("`label`"), "desc: {desc}");
3953        assert!(desc.contains("CardComponent"), "desc: {desc}");
3954    }
3955
3956    #[test]
3957    fn unused_component_output_emits_correct_check_name_and_description() {
3958        use fallow_types::output_dead_code::UnusedComponentOutputFinding;
3959        let root = PathBuf::from("/project");
3960        let mut results = AnalysisResults::default();
3961        results
3962            .unused_component_outputs
3963            .push(UnusedComponentOutputFinding::with_actions(
3964                UnusedComponentOutput {
3965                    path: root.join("src/toggle.component.ts"),
3966                    component_name: "ToggleComponent".to_string(),
3967                    output_name: "changed".to_string(),
3968                    line: 6,
3969                    col: 0,
3970                },
3971            ));
3972        let rules = RulesConfig::default();
3973        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3974        assert_eq!(output[0]["check_name"], "fallow/unused-component-output");
3975        let desc = output[0]["description"].as_str().unwrap();
3976        assert!(desc.contains("`changed`"), "desc: {desc}");
3977        assert!(desc.contains("ToggleComponent"), "desc: {desc}");
3978    }
3979
3980    // ---------------------------------------------------------------------------
3981    // push_unused_server_action_issues
3982    // ---------------------------------------------------------------------------
3983
3984    #[test]
3985    fn unused_server_action_emits_correct_check_name_and_description() {
3986        use fallow_types::output_dead_code::UnusedServerActionFinding;
3987        let root = PathBuf::from("/project");
3988        let mut results = AnalysisResults::default();
3989        results
3990            .unused_server_actions
3991            .push(UnusedServerActionFinding::with_actions(
3992                UnusedServerAction {
3993                    path: root.join("src/app/actions.ts"),
3994                    action_name: "deleteUser".to_string(),
3995                    line: 8,
3996                    col: 0,
3997                },
3998            ));
3999        let rules = RulesConfig::default();
4000        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
4001        assert_eq!(output[0]["check_name"], "fallow/unused-server-action");
4002        let desc = output[0]["description"].as_str().unwrap();
4003        assert!(desc.contains("`deleteUser`"), "desc: {desc}");
4004        assert!(desc.contains("\"use server\""), "desc: {desc}");
4005    }
4006
4007    // ---------------------------------------------------------------------------
4008    // push_unused_load_data_key_issues
4009    // ---------------------------------------------------------------------------
4010
4011    #[test]
4012    fn unused_load_data_key_emits_correct_check_name_and_description() {
4013        use fallow_types::output_dead_code::UnusedLoadDataKeyFinding;
4014        let root = PathBuf::from("/project");
4015        let mut results = AnalysisResults::default();
4016        results
4017            .unused_load_data_keys
4018            .push(UnusedLoadDataKeyFinding::with_actions(UnusedLoadDataKey {
4019                path: root.join("src/routes/+page.ts"),
4020                key_name: "postCount".to_string(),
4021                line: 7,
4022                col: 0,
4023                route_dir: None,
4024            }));
4025        let rules = RulesConfig::default();
4026        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
4027        assert_eq!(output[0]["check_name"], "fallow/unused-load-data-key");
4028        let desc = output[0]["description"].as_str().unwrap();
4029        assert!(desc.contains("`postCount`"), "desc: {desc}");
4030        assert!(desc.contains("no consumer"), "desc: {desc}");
4031    }
4032
4033    // ---------------------------------------------------------------------------
4034    // push_route_collision_issues and push_dynamic_segment_name_conflict_issues
4035    // ---------------------------------------------------------------------------
4036
4037    #[test]
4038    fn route_collision_emits_correct_check_name_and_url_in_description() {
4039        use fallow_types::output_dead_code::RouteCollisionFinding;
4040        let root = PathBuf::from("/project");
4041        let mut results = AnalysisResults::default();
4042        results
4043            .route_collisions
4044            .push(RouteCollisionFinding::with_actions(RouteCollision {
4045                path: root.join("src/app/about/page.tsx"),
4046                url: "/about".to_string(),
4047                conflicting_paths: vec![root.join("src/app/(marketing)/about/page.tsx")],
4048                line: 1,
4049                col: 0,
4050            }));
4051        let rules = RulesConfig::default();
4052        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
4053        assert_eq!(output[0]["check_name"], "fallow/route-collision");
4054        let desc = output[0]["description"].as_str().unwrap();
4055        assert!(desc.contains("`/about`"), "desc: {desc}");
4056        assert!(desc.contains("1 other file"), "desc: {desc}");
4057    }
4058
4059    #[test]
4060    fn dynamic_segment_name_conflict_emits_correct_check_name_and_position() {
4061        use fallow_types::output_dead_code::DynamicSegmentNameConflictFinding;
4062        let root = PathBuf::from("/project");
4063        let mut results = AnalysisResults::default();
4064        results.dynamic_segment_name_conflicts.push(
4065            DynamicSegmentNameConflictFinding::with_actions(DynamicSegmentNameConflict {
4066                path: root.join("src/app/shop/[id]/page.tsx"),
4067                position: "/shop".to_string(),
4068                conflicting_segments: vec!["[id]".to_string(), "[slug]".to_string()],
4069                conflicting_paths: vec![root.join("src/app/shop/[slug]/page.tsx")],
4070                line: 1,
4071                col: 0,
4072            }),
4073        );
4074        let rules = RulesConfig::default();
4075        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
4076        assert_eq!(
4077            output[0]["check_name"],
4078            "fallow/dynamic-segment-name-conflict"
4079        );
4080        let desc = output[0]["description"].as_str().unwrap();
4081        assert!(desc.contains("`/shop`"), "desc: {desc}");
4082        assert!(desc.contains("[id]"), "desc: {desc}");
4083        assert!(desc.contains("[slug]"), "desc: {desc}");
4084    }
4085
4086    // ---------------------------------------------------------------------------
4087    // push_unresolved_catalog_reference_issues: default and named catalog
4088    // ---------------------------------------------------------------------------
4089
4090    #[test]
4091    fn unresolved_catalog_reference_default_catalog_description() {
4092        use fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding;
4093        let root = PathBuf::from("/project");
4094        let mut results = AnalysisResults::default();
4095        results.unresolved_catalog_references.push(
4096            UnresolvedCatalogReferenceFinding::with_actions(UnresolvedCatalogReference {
4097                entry_name: "react".to_string(),
4098                catalog_name: "default".to_string(),
4099                path: root.join("packages/app/package.json"),
4100                line: 5,
4101                available_in_catalogs: vec![],
4102            }),
4103        );
4104        let rules = RulesConfig::default();
4105        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
4106        assert_eq!(
4107            output[0]["check_name"],
4108            "fallow/unresolved-catalog-reference"
4109        );
4110        let desc = output[0]["description"].as_str().unwrap();
4111        assert!(desc.contains("the default catalog"), "desc: {desc}");
4112        assert!(desc.contains("react"), "desc: {desc}");
4113        assert!(desc.contains("catalog:"), "desc: {desc}");
4114    }
4115
4116    #[test]
4117    fn unresolved_catalog_reference_named_catalog_description() {
4118        use fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding;
4119        let root = PathBuf::from("/project");
4120        let mut results = AnalysisResults::default();
4121        results.unresolved_catalog_references.push(
4122            UnresolvedCatalogReferenceFinding::with_actions(UnresolvedCatalogReference {
4123                entry_name: "lodash".to_string(),
4124                catalog_name: "react17".to_string(),
4125                path: root.join("packages/legacy/package.json"),
4126                line: 3,
4127                available_in_catalogs: vec!["default".to_string()],
4128            }),
4129        );
4130        let rules = RulesConfig::default();
4131        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
4132        let desc = output[0]["description"].as_str().unwrap();
4133        assert!(desc.contains("catalog 'react17'"), "desc: {desc}");
4134        assert!(desc.contains("available in"), "desc: {desc}");
4135        assert!(desc.contains("default"), "desc: {desc}");
4136    }
4137
4138    // ---------------------------------------------------------------------------
4139    // push_unused_dependency_override_issues: hint included in description
4140    // ---------------------------------------------------------------------------
4141
4142    #[test]
4143    fn unused_dependency_override_with_hint_includes_hint_in_description() {
4144        use fallow_types::output_dead_code::UnusedDependencyOverrideFinding;
4145        let root = PathBuf::from("/project");
4146        let mut results = AnalysisResults::default();
4147        results
4148            .unused_dependency_overrides
4149            .push(UnusedDependencyOverrideFinding::with_actions(
4150                UnusedDependencyOverride {
4151                    raw_key: "react".to_string(),
4152                    target_package: "react".to_string(),
4153                    parent_package: None,
4154                    version_constraint: None,
4155                    version_range: "^18.0.0".to_string(),
4156                    source: DependencyOverrideSource::PnpmPackageJson,
4157                    path: root.join("package.json"),
4158                    line: 12,
4159                    hint: Some("verify lockfile before removing".to_string()),
4160                },
4161            ));
4162        let rules = RulesConfig::default();
4163        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
4164        assert_eq!(output[0]["check_name"], "fallow/unused-dependency-override");
4165        let desc = output[0]["description"].as_str().unwrap();
4166        assert!(desc.contains("react"), "desc: {desc}");
4167        assert!(desc.contains("verify lockfile"), "desc: {desc}");
4168    }
4169
4170    // ---------------------------------------------------------------------------
4171    // build_health_codeclimate: runtime coverage findings
4172    // ---------------------------------------------------------------------------
4173
4174    #[test]
4175    fn health_codeclimate_runtime_safe_to_delete_maps_to_critical_severity() {
4176        use crate::health_types::{
4177            RuntimeCoverageConfidence, RuntimeCoverageDataSource, RuntimeCoverageEvidence,
4178            RuntimeCoverageFinding, RuntimeCoverageReport, RuntimeCoverageReportVerdict,
4179            RuntimeCoverageSchemaVersion, RuntimeCoverageSummary, RuntimeCoverageVerdict,
4180        };
4181        let root = PathBuf::from("/project");
4182        let report = crate::health_types::HealthReport {
4183            runtime_coverage: Some(RuntimeCoverageReport {
4184                schema_version: RuntimeCoverageSchemaVersion::V1,
4185                verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
4186                signals: vec![],
4187                summary: RuntimeCoverageSummary {
4188                    data_source: RuntimeCoverageDataSource::Local,
4189                    last_received_at: None,
4190                    functions_tracked: 10,
4191                    functions_hit: 8,
4192                    functions_unhit: 2,
4193                    functions_untracked: 0,
4194                    coverage_percent: 80.0,
4195                    trace_count: 1000,
4196                    period_days: 7,
4197                    deployments_seen: 1,
4198                    capture_quality: None,
4199                },
4200                findings: vec![RuntimeCoverageFinding {
4201                    id: "fallow:prod:aabbccdd".to_string(),
4202                    stable_id: None,
4203                    source_hash: None,
4204                    path: root.join("src/legacy.ts"),
4205                    function: "legacyHelper".to_string(),
4206                    line: 12,
4207                    verdict: RuntimeCoverageVerdict::SafeToDelete,
4208                    invocations: Some(0),
4209                    confidence: RuntimeCoverageConfidence::High,
4210                    evidence: RuntimeCoverageEvidence {
4211                        static_status: "unused".to_string(),
4212                        test_coverage: "not_covered".to_string(),
4213                        v8_tracking: "tracked".to_string(),
4214                        untracked_reason: None,
4215                        observation_days: 30,
4216                        deployments_observed: 3,
4217                    },
4218                    actions: vec![],
4219                }],
4220                hot_paths: vec![],
4221                blast_radius: vec![],
4222                importance: vec![],
4223                watermark: None,
4224                warnings: vec![],
4225            }),
4226            ..Default::default()
4227        };
4228        let json = issues_to_value(&build_health_codeclimate(&report, &root));
4229        let issues = json.as_array().unwrap();
4230        assert_eq!(issues.len(), 1);
4231        assert_eq!(issues[0]["check_name"], "fallow/runtime-safe-to-delete");
4232        assert_eq!(issues[0]["severity"], "critical");
4233        let desc = issues[0]["description"].as_str().unwrap();
4234        assert!(desc.contains("legacyHelper"), "desc: {desc}");
4235        assert!(desc.contains("safe to delete"), "desc: {desc}");
4236        assert!(desc.contains("0 invocations"), "desc: {desc}");
4237        assert_eq!(issues[0]["location"]["path"], "src/legacy.ts");
4238        assert_eq!(issues[0]["location"]["lines"]["begin"], 12);
4239    }
4240
4241    #[test]
4242    fn health_codeclimate_runtime_finding_with_none_invocations_shows_untracked() {
4243        use crate::health_types::{
4244            RuntimeCoverageConfidence, RuntimeCoverageDataSource, RuntimeCoverageEvidence,
4245            RuntimeCoverageFinding, RuntimeCoverageReport, RuntimeCoverageReportVerdict,
4246            RuntimeCoverageSchemaVersion, RuntimeCoverageSummary, RuntimeCoverageVerdict,
4247        };
4248        let root = PathBuf::from("/project");
4249        let report = crate::health_types::HealthReport {
4250            runtime_coverage: Some(RuntimeCoverageReport {
4251                schema_version: RuntimeCoverageSchemaVersion::V1,
4252                verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
4253                signals: vec![],
4254                summary: RuntimeCoverageSummary {
4255                    data_source: RuntimeCoverageDataSource::Local,
4256                    last_received_at: None,
4257                    functions_tracked: 5,
4258                    functions_hit: 4,
4259                    functions_unhit: 0,
4260                    functions_untracked: 1,
4261                    coverage_percent: 80.0,
4262                    trace_count: 500,
4263                    period_days: 7,
4264                    deployments_seen: 1,
4265                    capture_quality: None,
4266                },
4267                findings: vec![RuntimeCoverageFinding {
4268                    id: "fallow:prod:11223344".to_string(),
4269                    stable_id: None,
4270                    source_hash: None,
4271                    path: root.join("src/worker.ts"),
4272                    function: "workerFn".to_string(),
4273                    line: 5,
4274                    verdict: RuntimeCoverageVerdict::CoverageUnavailable,
4275                    invocations: None,
4276                    confidence: RuntimeCoverageConfidence::Low,
4277                    evidence: RuntimeCoverageEvidence {
4278                        static_status: "used".to_string(),
4279                        test_coverage: "not_covered".to_string(),
4280                        v8_tracking: "untracked".to_string(),
4281                        untracked_reason: Some("worker_thread".to_string()),
4282                        observation_days: 7,
4283                        deployments_observed: 1,
4284                    },
4285                    actions: vec![],
4286                }],
4287                hot_paths: vec![],
4288                blast_radius: vec![],
4289                importance: vec![],
4290                watermark: None,
4291                warnings: vec![],
4292            }),
4293            ..Default::default()
4294        };
4295        let json = issues_to_value(&build_health_codeclimate(&report, &root));
4296        let issues = json.as_array().unwrap();
4297        let desc = issues[0]["description"].as_str().unwrap();
4298        assert!(desc.contains("untracked"), "desc: {desc}");
4299    }
4300
4301    // ---------------------------------------------------------------------------
4302    // build_duplication_codeclimate
4303    // ---------------------------------------------------------------------------
4304
4305    #[test]
4306    fn duplication_codeclimate_one_issue_per_instance() {
4307        use fallow_core::duplicates::{
4308            CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
4309        };
4310        let root = PathBuf::from("/project");
4311        let report = DuplicationReport {
4312            clone_groups: vec![CloneGroup {
4313                instances: vec![
4314                    CloneInstance {
4315                        file: root.join("src/a.ts"),
4316                        start_line: 10,
4317                        end_line: 20,
4318                        start_col: 0,
4319                        end_col: 0,
4320                        fragment: "const x = 1;\nconst y = 2;".to_string(),
4321                    },
4322                    CloneInstance {
4323                        file: root.join("src/b.ts"),
4324                        start_line: 30,
4325                        end_line: 40,
4326                        start_col: 0,
4327                        end_col: 0,
4328                        fragment: "const x = 1;\nconst y = 2;".to_string(),
4329                    },
4330                ],
4331                token_count: 20,
4332                line_count: 10,
4333            }],
4334            clone_families: vec![],
4335            mirrored_directories: vec![],
4336            stats: DuplicationStats::default(),
4337        };
4338        let output = issues_to_value(&build_duplication_codeclimate(&report, &root));
4339        let issues = output.as_array().unwrap();
4340        assert_eq!(issues.len(), 2, "one issue per clone instance");
4341        assert_eq!(issues[0]["check_name"], "fallow/code-duplication");
4342        assert_eq!(issues[1]["check_name"], "fallow/code-duplication");
4343        assert_eq!(issues[0]["location"]["path"].as_str().unwrap(), "src/a.ts");
4344        assert_eq!(issues[1]["location"]["path"].as_str().unwrap(), "src/b.ts");
4345        assert_eq!(issues[0]["location"]["lines"]["begin"], 10);
4346        assert_eq!(issues[1]["location"]["lines"]["begin"], 30);
4347        assert_eq!(issues[0]["categories"][0], "Duplication");
4348    }
4349
4350    #[test]
4351    fn duplication_codeclimate_description_includes_group_number_and_line_count() {
4352        use fallow_core::duplicates::{
4353            CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
4354        };
4355        let root = PathBuf::from("/project");
4356        let report = DuplicationReport {
4357            clone_groups: vec![
4358                CloneGroup {
4359                    instances: vec![CloneInstance {
4360                        file: root.join("src/a.ts"),
4361                        start_line: 1,
4362                        end_line: 10,
4363                        start_col: 0,
4364                        end_col: 0,
4365                        fragment: "x".to_string(),
4366                    }],
4367                    token_count: 5,
4368                    line_count: 8,
4369                },
4370                CloneGroup {
4371                    instances: vec![CloneInstance {
4372                        file: root.join("src/b.ts"),
4373                        start_line: 5,
4374                        end_line: 12,
4375                        start_col: 0,
4376                        end_col: 0,
4377                        fragment: "y".to_string(),
4378                    }],
4379                    token_count: 3,
4380                    line_count: 6,
4381                },
4382            ],
4383            clone_families: vec![],
4384            mirrored_directories: vec![],
4385            stats: DuplicationStats::default(),
4386        };
4387        let output = issues_to_value(&build_duplication_codeclimate(&report, &root));
4388        let issues = output.as_array().unwrap();
4389        assert_eq!(issues.len(), 2);
4390        let desc0 = issues[0]["description"].as_str().unwrap();
4391        assert!(desc0.contains("group 1"), "first group: {desc0}");
4392        assert!(desc0.contains("8 lines"), "first group line count: {desc0}");
4393        let desc1 = issues[1]["description"].as_str().unwrap();
4394        assert!(desc1.contains("group 2"), "second group: {desc1}");
4395        assert!(
4396            desc1.contains("6 lines"),
4397            "second group line count: {desc1}"
4398        );
4399    }
4400
4401    #[test]
4402    fn duplication_codeclimate_empty_report_produces_empty_array() {
4403        use fallow_core::duplicates::{DuplicationReport, DuplicationStats};
4404        let root = PathBuf::from("/project");
4405        let report = DuplicationReport {
4406            clone_groups: vec![],
4407            clone_families: vec![],
4408            mirrored_directories: vec![],
4409            stats: DuplicationStats::default(),
4410        };
4411        let output = issues_to_value(&build_duplication_codeclimate(&report, &root));
4412        assert!(output.as_array().unwrap().is_empty());
4413    }
4414
4415    #[test]
4416    fn duplication_codeclimate_fingerprints_are_unique_across_instances() {
4417        use fallow_core::duplicates::{
4418            CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
4419        };
4420        let root = PathBuf::from("/project");
4421        let report = DuplicationReport {
4422            clone_groups: vec![CloneGroup {
4423                instances: vec![
4424                    CloneInstance {
4425                        file: root.join("src/x.ts"),
4426                        start_line: 1,
4427                        end_line: 5,
4428                        start_col: 0,
4429                        end_col: 0,
4430                        fragment: "hello".to_string(),
4431                    },
4432                    CloneInstance {
4433                        file: root.join("src/y.ts"),
4434                        start_line: 10,
4435                        end_line: 14,
4436                        start_col: 0,
4437                        end_col: 0,
4438                        fragment: "hello".to_string(),
4439                    },
4440                ],
4441                token_count: 5,
4442                line_count: 4,
4443            }],
4444            clone_families: vec![],
4445            mirrored_directories: vec![],
4446            stats: DuplicationStats::default(),
4447        };
4448        let output = issues_to_value(&build_duplication_codeclimate(&report, &root));
4449        let issues = output.as_array().unwrap();
4450        let fp0 = issues[0]["fingerprint"].as_str().unwrap();
4451        let fp1 = issues[1]["fingerprint"].as_str().unwrap();
4452        assert_ne!(
4453            fp0, fp1,
4454            "different file+line instances must have distinct fingerprints"
4455        );
4456    }
4457
4458    // ---------------------------------------------------------------------------
4459    // circular_dep: cross-package variant includes cross-package label
4460    // ---------------------------------------------------------------------------
4461
4462    #[test]
4463    fn circular_dep_cross_package_description_contains_label() {
4464        use fallow_types::output_dead_code::CircularDependencyFinding;
4465        let root = PathBuf::from("/project");
4466        let mut results = AnalysisResults::default();
4467        results
4468            .circular_dependencies
4469            .push(CircularDependencyFinding::with_actions(
4470                CircularDependency {
4471                    files: vec![
4472                        root.join("packages/a/src/index.ts"),
4473                        root.join("packages/b/src/index.ts"),
4474                    ],
4475                    length: 2,
4476                    line: 0,
4477                    col: 0,
4478                    edges: Vec::new(),
4479                    is_cross_package: true,
4480                },
4481            ));
4482        let rules = RulesConfig::default();
4483        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
4484        let desc = output[0]["description"].as_str().unwrap();
4485        assert!(desc.contains("(cross-package)"), "desc: {desc}");
4486    }
4487
4488    // ---------------------------------------------------------------------------
4489    // is_re_export: unused_types re-export label
4490    // ---------------------------------------------------------------------------
4491
4492    #[test]
4493    fn unused_type_re_export_uses_type_re_export_label() {
4494        use fallow_types::output_dead_code::UnusedTypeFinding;
4495        let root = PathBuf::from("/project");
4496        let mut results = AnalysisResults::default();
4497        results
4498            .unused_types
4499            .push(UnusedTypeFinding::with_actions(UnusedExport {
4500                path: root.join("src/index.ts"),
4501                export_name: "ReExportedType".to_string(),
4502                is_type_only: true,
4503                line: 2,
4504                col: 0,
4505                span_start: 0,
4506                is_re_export: true,
4507            }));
4508        let rules = RulesConfig::default();
4509        let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
4510        assert_eq!(output[0]["check_name"], "fallow/unused-type");
4511        let desc = output[0]["description"].as_str().unwrap();
4512        assert!(desc.contains("Type re-export"), "desc: {desc}");
4513    }
4514}