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