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