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