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