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, PrivateTypeLeak};
7
8use super::grouping::{self, OwnershipResolver};
9use super::{emit_json, normalize_uri, relative_path};
10use crate::health_types::{ExceededThreshold, HealthReport};
11
12/// Map fallow severity to CodeClimate severity.
13const fn severity_to_codeclimate(s: Severity) -> &'static str {
14    match s {
15        Severity::Error => "major",
16        Severity::Warn | Severity::Off => "minor",
17    }
18}
19
20/// Compute a relative path string with forward-slash normalization.
21///
22/// Uses `normalize_uri` to ensure forward slashes on all platforms
23/// and percent-encode brackets for Next.js dynamic routes.
24fn cc_path(path: &Path, root: &Path) -> String {
25    normalize_uri(&relative_path(path, root).display().to_string())
26}
27
28/// Compute a deterministic fingerprint hash from key fields.
29///
30/// Uses FNV-1a (64-bit) for guaranteed cross-version stability.
31/// `DefaultHasher` is explicitly not specified across Rust versions.
32fn fingerprint_hash(parts: &[&str]) -> String {
33    let mut hash: u64 = 0xcbf2_9ce4_8422_2325; // FNV offset basis
34    for part in parts {
35        for byte in part.bytes() {
36            hash ^= u64::from(byte);
37            hash = hash.wrapping_mul(0x0100_0000_01b3); // FNV prime
38        }
39        // Separator between parts to avoid "ab"+"c" == "a"+"bc"
40        hash ^= 0xff;
41        hash = hash.wrapping_mul(0x0100_0000_01b3);
42    }
43    format!("{hash:016x}")
44}
45
46/// Build a single CodeClimate issue object.
47fn cc_issue(
48    check_name: &str,
49    description: &str,
50    severity: &str,
51    category: &str,
52    path: &str,
53    begin_line: Option<u32>,
54    fingerprint: &str,
55) -> serde_json::Value {
56    let lines = begin_line.map_or_else(
57        || serde_json::json!({ "begin": 1 }),
58        |line| serde_json::json!({ "begin": line }),
59    );
60
61    serde_json::json!({
62        "type": "issue",
63        "check_name": check_name,
64        "description": description,
65        "categories": [category],
66        "severity": severity,
67        "fingerprint": fingerprint,
68        "location": {
69            "path": path,
70            "lines": lines
71        }
72    })
73}
74
75/// Push CodeClimate issues for unused dependencies with a shared structure.
76fn push_dep_cc_issues(
77    issues: &mut Vec<serde_json::Value>,
78    deps: &[fallow_core::results::UnusedDependency],
79    root: &Path,
80    rule_id: &str,
81    location_label: &str,
82    severity: Severity,
83) {
84    let level = severity_to_codeclimate(severity);
85    for dep in deps {
86        let path = cc_path(&dep.path, root);
87        let line = if dep.line > 0 { Some(dep.line) } else { None };
88        let fp = fingerprint_hash(&[rule_id, &dep.package_name]);
89        let workspace_context = if dep.used_in_workspaces.is_empty() {
90            String::new()
91        } else {
92            let workspaces = dep
93                .used_in_workspaces
94                .iter()
95                .map(|path| cc_path(path, root))
96                .collect::<Vec<_>>()
97                .join(", ");
98            format!("; imported in other workspaces: {workspaces}")
99        };
100        issues.push(cc_issue(
101            rule_id,
102            &format!(
103                "Package '{}' is in {location_label} but never imported{workspace_context}",
104                dep.package_name
105            ),
106            level,
107            "Bug Risk",
108            &path,
109            line,
110            &fp,
111        ));
112    }
113}
114
115fn push_unused_file_issues(
116    issues: &mut Vec<serde_json::Value>,
117    files: &[fallow_core::results::UnusedFile],
118    root: &Path,
119    severity: Severity,
120) {
121    let level = severity_to_codeclimate(severity);
122    for file in files {
123        let path = cc_path(&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(
143    issues: &mut Vec<serde_json::Value>,
144    exports: &[fallow_core::results::UnusedExport],
145    root: &Path,
146    rule_id: &str,
147    direct_label: &str,
148    re_export_label: &str,
149    severity: Severity,
150) {
151    let level = severity_to_codeclimate(severity);
152    for export in exports {
153        let path = cc_path(&export.path, root);
154        let kind = if export.is_re_export {
155            re_export_label
156        } else {
157            direct_label
158        };
159        let line_str = export.line.to_string();
160        let fp = fingerprint_hash(&[rule_id, &path, &line_str, &export.export_name]);
161        issues.push(cc_issue(
162            rule_id,
163            &format!(
164                "{kind} '{}' is never imported by other modules",
165                export.export_name
166            ),
167            level,
168            "Bug Risk",
169            &path,
170            Some(export.line),
171            &fp,
172        ));
173    }
174}
175
176fn push_private_type_leak_issues(
177    issues: &mut Vec<serde_json::Value>,
178    leaks: &[PrivateTypeLeak],
179    root: &Path,
180    severity: Severity,
181) {
182    let level = severity_to_codeclimate(severity);
183    for leak in leaks {
184        let path = cc_path(&leak.path, root);
185        let line_str = leak.line.to_string();
186        let fp = fingerprint_hash(&[
187            "fallow/private-type-leak",
188            &path,
189            &line_str,
190            &leak.export_name,
191            &leak.type_name,
192        ]);
193        issues.push(cc_issue(
194            "fallow/private-type-leak",
195            &format!(
196                "Export '{}' references private type '{}'",
197                leak.export_name, leak.type_name
198            ),
199            level,
200            "Bug Risk",
201            &path,
202            Some(leak.line),
203            &fp,
204        ));
205    }
206}
207
208fn push_type_only_dep_issues(
209    issues: &mut Vec<serde_json::Value>,
210    deps: &[fallow_core::results::TypeOnlyDependency],
211    root: &Path,
212    severity: Severity,
213) {
214    let level = severity_to_codeclimate(severity);
215    for dep in deps {
216        let path = cc_path(&dep.path, root);
217        let line = if dep.line > 0 { Some(dep.line) } else { None };
218        let fp = fingerprint_hash(&["fallow/type-only-dependency", &dep.package_name]);
219        issues.push(cc_issue(
220            "fallow/type-only-dependency",
221            &format!(
222                "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
223                dep.package_name
224            ),
225            level,
226            "Bug Risk",
227            &path,
228            line,
229            &fp,
230        ));
231    }
232}
233
234fn push_test_only_dep_issues(
235    issues: &mut Vec<serde_json::Value>,
236    deps: &[fallow_core::results::TestOnlyDependency],
237    root: &Path,
238    severity: Severity,
239) {
240    let level = severity_to_codeclimate(severity);
241    for dep in deps {
242        let path = cc_path(&dep.path, root);
243        let line = if dep.line > 0 { Some(dep.line) } else { None };
244        let fp = fingerprint_hash(&["fallow/test-only-dependency", &dep.package_name]);
245        issues.push(cc_issue(
246            "fallow/test-only-dependency",
247            &format!(
248                "Package '{}' is only imported by test files (consider moving to devDependencies)",
249                dep.package_name
250            ),
251            level,
252            "Bug Risk",
253            &path,
254            line,
255            &fp,
256        ));
257    }
258}
259
260/// Push CodeClimate issues for unused enum or class members.
261///
262/// `entity_label` is `"Enum"` or `"Class"` so the rendered description reads
263/// "Enum member ..." or "Class member ..." accordingly.
264fn push_unused_member_issues(
265    issues: &mut Vec<serde_json::Value>,
266    members: &[fallow_core::results::UnusedMember],
267    root: &Path,
268    rule_id: &str,
269    entity_label: &str,
270    severity: Severity,
271) {
272    let level = severity_to_codeclimate(severity);
273    for member in members {
274        let path = cc_path(&member.path, root);
275        let line_str = member.line.to_string();
276        let fp = fingerprint_hash(&[
277            rule_id,
278            &path,
279            &line_str,
280            &member.parent_name,
281            &member.member_name,
282        ]);
283        issues.push(cc_issue(
284            rule_id,
285            &format!(
286                "{entity_label} member '{}.{}' is never referenced",
287                member.parent_name, member.member_name
288            ),
289            level,
290            "Bug Risk",
291            &path,
292            Some(member.line),
293            &fp,
294        ));
295    }
296}
297
298fn push_unresolved_import_issues(
299    issues: &mut Vec<serde_json::Value>,
300    imports: &[fallow_core::results::UnresolvedImport],
301    root: &Path,
302    severity: Severity,
303) {
304    let level = severity_to_codeclimate(severity);
305    for import in imports {
306        let path = cc_path(&import.path, root);
307        let line_str = import.line.to_string();
308        let fp = fingerprint_hash(&[
309            "fallow/unresolved-import",
310            &path,
311            &line_str,
312            &import.specifier,
313        ]);
314        issues.push(cc_issue(
315            "fallow/unresolved-import",
316            &format!("Import '{}' could not be resolved", import.specifier),
317            level,
318            "Bug Risk",
319            &path,
320            Some(import.line),
321            &fp,
322        ));
323    }
324}
325
326fn push_unlisted_dep_issues(
327    issues: &mut Vec<serde_json::Value>,
328    deps: &[fallow_core::results::UnlistedDependency],
329    root: &Path,
330    severity: Severity,
331) {
332    let level = severity_to_codeclimate(severity);
333    for dep in deps {
334        for site in &dep.imported_from {
335            let path = cc_path(&site.path, root);
336            let line_str = site.line.to_string();
337            let fp = fingerprint_hash(&[
338                "fallow/unlisted-dependency",
339                &path,
340                &line_str,
341                &dep.package_name,
342            ]);
343            issues.push(cc_issue(
344                "fallow/unlisted-dependency",
345                &format!(
346                    "Package '{}' is imported but not listed in package.json",
347                    dep.package_name
348                ),
349                level,
350                "Bug Risk",
351                &path,
352                Some(site.line),
353                &fp,
354            ));
355        }
356    }
357}
358
359fn push_duplicate_export_issues(
360    issues: &mut Vec<serde_json::Value>,
361    dups: &[fallow_core::results::DuplicateExport],
362    root: &Path,
363    severity: Severity,
364) {
365    let level = severity_to_codeclimate(severity);
366    for dup in dups {
367        for loc in &dup.locations {
368            let path = cc_path(&loc.path, root);
369            let line_str = loc.line.to_string();
370            let fp = fingerprint_hash(&[
371                "fallow/duplicate-export",
372                &path,
373                &line_str,
374                &dup.export_name,
375            ]);
376            issues.push(cc_issue(
377                "fallow/duplicate-export",
378                &format!("Export '{}' appears in multiple modules", dup.export_name),
379                level,
380                "Bug Risk",
381                &path,
382                Some(loc.line),
383                &fp,
384            ));
385        }
386    }
387}
388
389fn push_circular_dep_issues(
390    issues: &mut Vec<serde_json::Value>,
391    cycles: &[fallow_core::results::CircularDependency],
392    root: &Path,
393    severity: Severity,
394) {
395    let level = severity_to_codeclimate(severity);
396    for cycle in cycles {
397        let Some(first) = cycle.files.first() else {
398            continue;
399        };
400        let path = cc_path(first, root);
401        let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
402        let chain_str = chain.join(":");
403        let fp = fingerprint_hash(&["fallow/circular-dependency", &chain_str]);
404        let line = if cycle.line > 0 {
405            Some(cycle.line)
406        } else {
407            None
408        };
409        issues.push(cc_issue(
410            "fallow/circular-dependency",
411            &format!(
412                "Circular dependency{}: {}",
413                if cycle.is_cross_package {
414                    " (cross-package)"
415                } else {
416                    ""
417                },
418                chain.join(" \u{2192} ")
419            ),
420            level,
421            "Bug Risk",
422            &path,
423            line,
424            &fp,
425        ));
426    }
427}
428
429fn push_boundary_violation_issues(
430    issues: &mut Vec<serde_json::Value>,
431    violations: &[fallow_core::results::BoundaryViolation],
432    root: &Path,
433    severity: Severity,
434) {
435    let level = severity_to_codeclimate(severity);
436    for v in violations {
437        let path = cc_path(&v.from_path, root);
438        let to = cc_path(&v.to_path, root);
439        let fp = fingerprint_hash(&["fallow/boundary-violation", &path, &to]);
440        let line = if v.line > 0 { Some(v.line) } else { None };
441        issues.push(cc_issue(
442            "fallow/boundary-violation",
443            &format!(
444                "Boundary violation: {} -> {} ({} -> {})",
445                path, to, v.from_zone, v.to_zone
446            ),
447            level,
448            "Bug Risk",
449            &path,
450            line,
451            &fp,
452        ));
453    }
454}
455
456fn push_stale_suppression_issues(
457    issues: &mut Vec<serde_json::Value>,
458    suppressions: &[fallow_core::results::StaleSuppression],
459    root: &Path,
460    severity: Severity,
461) {
462    let level = severity_to_codeclimate(severity);
463    for s in suppressions {
464        let path = cc_path(&s.path, root);
465        let line_str = s.line.to_string();
466        let fp = fingerprint_hash(&["fallow/stale-suppression", &path, &line_str]);
467        issues.push(cc_issue(
468            "fallow/stale-suppression",
469            &s.description(),
470            level,
471            "Bug Risk",
472            &path,
473            Some(s.line),
474            &fp,
475        ));
476    }
477}
478
479/// Build CodeClimate JSON array from dead-code analysis results.
480#[must_use]
481pub fn build_codeclimate(
482    results: &AnalysisResults,
483    root: &Path,
484    rules: &RulesConfig,
485) -> serde_json::Value {
486    let mut issues = Vec::new();
487
488    push_unused_file_issues(&mut issues, &results.unused_files, root, rules.unused_files);
489    push_unused_export_issues(
490        &mut issues,
491        &results.unused_exports,
492        root,
493        "fallow/unused-export",
494        "Export",
495        "Re-export",
496        rules.unused_exports,
497    );
498    push_unused_export_issues(
499        &mut issues,
500        &results.unused_types,
501        root,
502        "fallow/unused-type",
503        "Type export",
504        "Type re-export",
505        rules.unused_types,
506    );
507    push_private_type_leak_issues(
508        &mut issues,
509        &results.private_type_leaks,
510        root,
511        rules.private_type_leaks,
512    );
513    push_dep_cc_issues(
514        &mut issues,
515        &results.unused_dependencies,
516        root,
517        "fallow/unused-dependency",
518        "dependencies",
519        rules.unused_dependencies,
520    );
521    push_dep_cc_issues(
522        &mut issues,
523        &results.unused_dev_dependencies,
524        root,
525        "fallow/unused-dev-dependency",
526        "devDependencies",
527        rules.unused_dev_dependencies,
528    );
529    push_dep_cc_issues(
530        &mut issues,
531        &results.unused_optional_dependencies,
532        root,
533        "fallow/unused-optional-dependency",
534        "optionalDependencies",
535        rules.unused_optional_dependencies,
536    );
537    push_type_only_dep_issues(
538        &mut issues,
539        &results.type_only_dependencies,
540        root,
541        rules.type_only_dependencies,
542    );
543    push_test_only_dep_issues(
544        &mut issues,
545        &results.test_only_dependencies,
546        root,
547        rules.test_only_dependencies,
548    );
549    push_unused_member_issues(
550        &mut issues,
551        &results.unused_enum_members,
552        root,
553        "fallow/unused-enum-member",
554        "Enum",
555        rules.unused_enum_members,
556    );
557    push_unused_member_issues(
558        &mut issues,
559        &results.unused_class_members,
560        root,
561        "fallow/unused-class-member",
562        "Class",
563        rules.unused_class_members,
564    );
565    push_unresolved_import_issues(
566        &mut issues,
567        &results.unresolved_imports,
568        root,
569        rules.unresolved_imports,
570    );
571    push_unlisted_dep_issues(
572        &mut issues,
573        &results.unlisted_dependencies,
574        root,
575        rules.unlisted_dependencies,
576    );
577    push_duplicate_export_issues(
578        &mut issues,
579        &results.duplicate_exports,
580        root,
581        rules.duplicate_exports,
582    );
583    push_circular_dep_issues(
584        &mut issues,
585        &results.circular_dependencies,
586        root,
587        rules.circular_dependencies,
588    );
589    push_boundary_violation_issues(
590        &mut issues,
591        &results.boundary_violations,
592        root,
593        rules.boundary_violation,
594    );
595    push_stale_suppression_issues(
596        &mut issues,
597        &results.stale_suppressions,
598        root,
599        rules.stale_suppressions,
600    );
601
602    serde_json::Value::Array(issues)
603}
604
605/// Print dead-code analysis results in CodeClimate format.
606pub(super) fn print_codeclimate(
607    results: &AnalysisResults,
608    root: &Path,
609    rules: &RulesConfig,
610) -> ExitCode {
611    let value = build_codeclimate(results, root, rules);
612    emit_json(&value, "CodeClimate")
613}
614
615/// Print CodeClimate output with owner properties added to each issue.
616///
617/// Calls `build_codeclimate` to produce the standard CodeClimate JSON array,
618/// then post-processes each entry to add `"owner": "@team"` by resolving the
619/// issue's location path through the `OwnershipResolver`.
620pub(super) fn print_grouped_codeclimate(
621    results: &AnalysisResults,
622    root: &Path,
623    rules: &RulesConfig,
624    resolver: &OwnershipResolver,
625) -> ExitCode {
626    let mut value = build_codeclimate(results, root, rules);
627
628    if let Some(issues) = value.as_array_mut() {
629        for issue in issues {
630            let path = issue
631                .pointer("/location/path")
632                .and_then(|v| v.as_str())
633                .unwrap_or("");
634            let owner = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
635            issue
636                .as_object_mut()
637                .expect("CodeClimate issue should be an object")
638                .insert("owner".to_string(), serde_json::Value::String(owner));
639        }
640    }
641
642    emit_json(&value, "CodeClimate")
643}
644
645/// Build CodeClimate JSON array from health/complexity analysis results.
646#[must_use]
647#[expect(
648    clippy::too_many_lines,
649    reason = "CRAP adds a fourth exceeded-threshold branch plus its description; splitting the dispatch table would fragment the mapping."
650)]
651pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> serde_json::Value {
652    let mut issues = Vec::new();
653
654    let cyc_t = report.summary.max_cyclomatic_threshold;
655    let cog_t = report.summary.max_cognitive_threshold;
656    let crap_t = report.summary.max_crap_threshold;
657
658    for finding in &report.findings {
659        let path = cc_path(&finding.path, root);
660        let description = match finding.exceeded {
661            ExceededThreshold::Both => format!(
662                "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
663                finding.name, finding.cyclomatic, cyc_t, finding.cognitive, cog_t
664            ),
665            ExceededThreshold::Cyclomatic => format!(
666                "'{}' has cyclomatic complexity {} (threshold: {})",
667                finding.name, finding.cyclomatic, cyc_t
668            ),
669            ExceededThreshold::Cognitive => format!(
670                "'{}' has cognitive complexity {} (threshold: {})",
671                finding.name, finding.cognitive, cog_t
672            ),
673            ExceededThreshold::Crap
674            | ExceededThreshold::CyclomaticCrap
675            | ExceededThreshold::CognitiveCrap
676            | ExceededThreshold::All => {
677                let crap = finding.crap.unwrap_or(0.0);
678                let coverage = finding
679                    .coverage_pct
680                    .map(|pct| format!(", coverage {pct:.0}%"))
681                    .unwrap_or_default();
682                format!(
683                    "'{}' has CRAP score {crap:.1} (threshold: {crap_t:.1}, cyclomatic {}{coverage})",
684                    finding.name, finding.cyclomatic,
685                )
686            }
687        };
688        let check_name = match finding.exceeded {
689            ExceededThreshold::Both => "fallow/high-complexity",
690            ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
691            ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
692            ExceededThreshold::Crap
693            | ExceededThreshold::CyclomaticCrap
694            | ExceededThreshold::CognitiveCrap
695            | ExceededThreshold::All => "fallow/high-crap-score",
696        };
697        // Map finding severity to CodeClimate severity levels
698        let severity = match finding.severity {
699            crate::health_types::FindingSeverity::Critical => "critical",
700            crate::health_types::FindingSeverity::High => "major",
701            crate::health_types::FindingSeverity::Moderate => "minor",
702        };
703        let line_str = finding.line.to_string();
704        let fp = fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
705        issues.push(cc_issue(
706            check_name,
707            &description,
708            severity,
709            "Complexity",
710            &path,
711            Some(finding.line),
712            &fp,
713        ));
714    }
715
716    if let Some(ref production) = report.runtime_coverage {
717        for finding in &production.findings {
718            let path = cc_path(&finding.path, root);
719            let check_name = match finding.verdict {
720                crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
721                    "fallow/runtime-safe-to-delete"
722                }
723                crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
724                    "fallow/runtime-review-required"
725                }
726                crate::health_types::RuntimeCoverageVerdict::LowTraffic => {
727                    "fallow/runtime-low-traffic"
728                }
729                crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
730                    "fallow/runtime-coverage-unavailable"
731                }
732                crate::health_types::RuntimeCoverageVerdict::Active
733                | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
734            };
735            let invocations_hint = finding.invocations.map_or_else(
736                || "untracked".to_owned(),
737                |hits| format!("{hits} invocations"),
738            );
739            let description = format!(
740                "'{}' runtime coverage verdict: {} ({})",
741                finding.function,
742                finding.verdict.human_label(),
743                invocations_hint,
744            );
745            // GitLab Code Quality renders MR inline annotations only for
746            // blocker/critical/major/minor. Any non-cold verdict collapses to
747            // "minor" — "info" is schema-valid but silently dropped from MR
748            // annotations.
749            let severity = match finding.verdict {
750                crate::health_types::RuntimeCoverageVerdict::SafeToDelete => "critical",
751                crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "major",
752                _ => "minor",
753            };
754            let fp = fingerprint_hash(&[
755                check_name,
756                &path,
757                &finding.line.to_string(),
758                &finding.function,
759            ]);
760            issues.push(cc_issue(
761                check_name,
762                &description,
763                severity,
764                // CodeClimate/GitLab Code Quality allows a fixed category set:
765                // Bug Risk | Clarity | Compatibility | Complexity | Duplication
766                // | Performance | Security | Style. Production-coverage
767                // findings are a dead-code signal, so use "Bug Risk" — same
768                // category used by static dead-code issues elsewhere.
769                "Bug Risk",
770                &path,
771                Some(finding.line),
772                &fp,
773            ));
774        }
775    }
776
777    if let Some(ref gaps) = report.coverage_gaps {
778        for item in &gaps.files {
779            let path = cc_path(&item.path, root);
780            let description = format!(
781                "File is runtime-reachable but has no test dependency path ({} value export{})",
782                item.value_export_count,
783                if item.value_export_count == 1 {
784                    ""
785                } else {
786                    "s"
787                },
788            );
789            let fp = fingerprint_hash(&["fallow/untested-file", &path]);
790            issues.push(cc_issue(
791                "fallow/untested-file",
792                &description,
793                "minor",
794                "Coverage",
795                &path,
796                None,
797                &fp,
798            ));
799        }
800
801        for item in &gaps.exports {
802            let path = cc_path(&item.path, root);
803            let description = format!(
804                "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
805                item.export_name
806            );
807            let line_str = item.line.to_string();
808            let fp = fingerprint_hash(&[
809                "fallow/untested-export",
810                &path,
811                &line_str,
812                &item.export_name,
813            ]);
814            issues.push(cc_issue(
815                "fallow/untested-export",
816                &description,
817                "minor",
818                "Coverage",
819                &path,
820                Some(item.line),
821                &fp,
822            ));
823        }
824    }
825
826    serde_json::Value::Array(issues)
827}
828
829/// Print health analysis results in CodeClimate format.
830pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
831    let value = build_health_codeclimate(report, root);
832    emit_json(&value, "CodeClimate")
833}
834
835/// Print health CodeClimate output with a per-issue `group` field.
836///
837/// Mirrors the dead-code grouped CodeClimate pattern
838/// (`print_grouped_codeclimate`): build the standard payload first, then
839/// post-process each issue to attach a `group` key derived from the
840/// `OwnershipResolver`. Lets GitLab Code Quality and other CodeClimate
841/// consumers partition findings per team / package without re-parsing the
842/// project structure.
843pub(super) fn print_grouped_health_codeclimate(
844    report: &HealthReport,
845    root: &Path,
846    resolver: &OwnershipResolver,
847) -> ExitCode {
848    let mut value = build_health_codeclimate(report, root);
849
850    if let Some(issues) = value.as_array_mut() {
851        for issue in issues {
852            let path = issue
853                .pointer("/location/path")
854                .and_then(|v| v.as_str())
855                .unwrap_or("");
856            let group = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
857            issue
858                .as_object_mut()
859                .expect("CodeClimate issue should be an object")
860                .insert("group".to_string(), serde_json::Value::String(group));
861        }
862    }
863
864    emit_json(&value, "CodeClimate")
865}
866
867/// Build CodeClimate JSON array from duplication analysis results.
868#[must_use]
869#[expect(
870    clippy::cast_possible_truncation,
871    reason = "line numbers are bounded by source size"
872)]
873pub fn build_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> serde_json::Value {
874    let mut issues = Vec::new();
875
876    for (i, group) in report.clone_groups.iter().enumerate() {
877        // Content-based fingerprint: hash token_count + line_count + first 64 chars of fragment
878        // This is stable across runs regardless of group ordering.
879        let token_str = group.token_count.to_string();
880        let line_count_str = group.line_count.to_string();
881        let fragment_prefix: String = group
882            .instances
883            .first()
884            .map(|inst| inst.fragment.chars().take(64).collect())
885            .unwrap_or_default();
886
887        for instance in &group.instances {
888            let path = cc_path(&instance.file, root);
889            let start_str = instance.start_line.to_string();
890            let fp = fingerprint_hash(&[
891                "fallow/code-duplication",
892                &path,
893                &start_str,
894                &token_str,
895                &line_count_str,
896                &fragment_prefix,
897            ]);
898            issues.push(cc_issue(
899                "fallow/code-duplication",
900                &format!(
901                    "Code clone group {} ({} lines, {} instances)",
902                    i + 1,
903                    group.line_count,
904                    group.instances.len()
905                ),
906                "minor",
907                "Duplication",
908                &path,
909                Some(instance.start_line as u32),
910                &fp,
911            ));
912        }
913    }
914
915    serde_json::Value::Array(issues)
916}
917
918/// Print duplication analysis results in CodeClimate format.
919pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
920    let value = build_duplication_codeclimate(report, root);
921    emit_json(&value, "CodeClimate")
922}
923
924/// Print duplication CodeClimate output with a per-issue `group` field.
925///
926/// Mirrors [`print_grouped_health_codeclimate`]: each clone group is attributed
927/// to its largest owner ([`super::dupes_grouping::largest_owner`]) and every
928/// CodeClimate issue emitted for that clone group's instances carries the same
929/// top-level `group` key. Lets GitLab Code Quality and other CodeClimate
930/// consumers partition findings per team / package / directory without
931/// re-parsing the project structure.
932pub(super) fn print_grouped_duplication_codeclimate(
933    report: &DuplicationReport,
934    root: &Path,
935    resolver: &OwnershipResolver,
936) -> ExitCode {
937    let mut value = build_duplication_codeclimate(report, root);
938
939    // Build a flat lookup from each instance path -> primary owner. Every
940    // instance of a clone group inherits the group's largest-owner key.
941    use rustc_hash::FxHashMap;
942    let mut path_to_owner: FxHashMap<String, String> = FxHashMap::default();
943    for group in &report.clone_groups {
944        let owner = super::dupes_grouping::largest_owner(group, root, resolver);
945        for instance in &group.instances {
946            let path = cc_path(&instance.file, root);
947            path_to_owner.insert(path, owner.clone());
948        }
949    }
950
951    if let Some(issues) = value.as_array_mut() {
952        for issue in issues {
953            let path = issue
954                .pointer("/location/path")
955                .and_then(|v| v.as_str())
956                .unwrap_or("")
957                .to_string();
958            let group = path_to_owner
959                .get(&path)
960                .cloned()
961                .unwrap_or_else(|| crate::codeowners::UNOWNED_LABEL.to_string());
962            issue
963                .as_object_mut()
964                .expect("CodeClimate issue should be an object")
965                .insert("group".to_string(), serde_json::Value::String(group));
966        }
967    }
968
969    emit_json(&value, "CodeClimate")
970}
971
972#[cfg(test)]
973mod tests {
974    use super::*;
975    use crate::report::test_helpers::sample_results;
976    use fallow_config::RulesConfig;
977    use fallow_core::results::*;
978    use std::path::PathBuf;
979
980    /// Compute graduated severity for health findings based on threshold ratio.
981    /// Kept for unit test coverage of the original CodeClimate severity model.
982    fn health_severity(value: u16, threshold: u16) -> &'static str {
983        if threshold == 0 {
984            return "minor";
985        }
986        let ratio = f64::from(value) / f64::from(threshold);
987        if ratio > 2.5 {
988            "critical"
989        } else if ratio > 1.5 {
990            "major"
991        } else {
992            "minor"
993        }
994    }
995
996    #[test]
997    fn codeclimate_empty_results_produces_empty_array() {
998        let root = PathBuf::from("/project");
999        let results = AnalysisResults::default();
1000        let rules = RulesConfig::default();
1001        let output = build_codeclimate(&results, &root, &rules);
1002        let arr = output.as_array().unwrap();
1003        assert!(arr.is_empty());
1004    }
1005
1006    #[test]
1007    fn codeclimate_produces_array_of_issues() {
1008        let root = PathBuf::from("/project");
1009        let results = sample_results(&root);
1010        let rules = RulesConfig::default();
1011        let output = build_codeclimate(&results, &root, &rules);
1012        assert!(output.is_array());
1013        let arr = output.as_array().unwrap();
1014        // Should have at least one issue per type
1015        assert!(!arr.is_empty());
1016    }
1017
1018    #[test]
1019    fn codeclimate_issue_has_required_fields() {
1020        let root = PathBuf::from("/project");
1021        let mut results = AnalysisResults::default();
1022        results.unused_files.push(UnusedFile {
1023            path: root.join("src/dead.ts"),
1024        });
1025        let rules = RulesConfig::default();
1026        let output = build_codeclimate(&results, &root, &rules);
1027        let issue = &output.as_array().unwrap()[0];
1028
1029        assert_eq!(issue["type"], "issue");
1030        assert_eq!(issue["check_name"], "fallow/unused-file");
1031        assert!(issue["description"].is_string());
1032        assert!(issue["categories"].is_array());
1033        assert!(issue["severity"].is_string());
1034        assert!(issue["fingerprint"].is_string());
1035        assert!(issue["location"].is_object());
1036        assert!(issue["location"]["path"].is_string());
1037        assert!(issue["location"]["lines"].is_object());
1038    }
1039
1040    #[test]
1041    fn codeclimate_unused_file_severity_follows_rules() {
1042        let root = PathBuf::from("/project");
1043        let mut results = AnalysisResults::default();
1044        results.unused_files.push(UnusedFile {
1045            path: root.join("src/dead.ts"),
1046        });
1047
1048        // Error severity -> major
1049        let rules = RulesConfig::default();
1050        let output = build_codeclimate(&results, &root, &rules);
1051        assert_eq!(output[0]["severity"], "major");
1052
1053        // Warn severity -> minor
1054        let rules = RulesConfig {
1055            unused_files: Severity::Warn,
1056            ..RulesConfig::default()
1057        };
1058        let output = build_codeclimate(&results, &root, &rules);
1059        assert_eq!(output[0]["severity"], "minor");
1060    }
1061
1062    #[test]
1063    fn codeclimate_unused_export_has_line_number() {
1064        let root = PathBuf::from("/project");
1065        let mut results = AnalysisResults::default();
1066        results.unused_exports.push(UnusedExport {
1067            path: root.join("src/utils.ts"),
1068            export_name: "helperFn".to_string(),
1069            is_type_only: false,
1070            line: 10,
1071            col: 4,
1072            span_start: 120,
1073            is_re_export: false,
1074        });
1075        let rules = RulesConfig::default();
1076        let output = build_codeclimate(&results, &root, &rules);
1077        let issue = &output[0];
1078        assert_eq!(issue["location"]["lines"]["begin"], 10);
1079    }
1080
1081    #[test]
1082    fn codeclimate_unused_file_line_defaults_to_1() {
1083        let root = PathBuf::from("/project");
1084        let mut results = AnalysisResults::default();
1085        results.unused_files.push(UnusedFile {
1086            path: root.join("src/dead.ts"),
1087        });
1088        let rules = RulesConfig::default();
1089        let output = build_codeclimate(&results, &root, &rules);
1090        let issue = &output[0];
1091        assert_eq!(issue["location"]["lines"]["begin"], 1);
1092    }
1093
1094    #[test]
1095    fn codeclimate_paths_are_relative() {
1096        let root = PathBuf::from("/project");
1097        let mut results = AnalysisResults::default();
1098        results.unused_files.push(UnusedFile {
1099            path: root.join("src/deep/nested/file.ts"),
1100        });
1101        let rules = RulesConfig::default();
1102        let output = build_codeclimate(&results, &root, &rules);
1103        let path = output[0]["location"]["path"].as_str().unwrap();
1104        assert_eq!(path, "src/deep/nested/file.ts");
1105        assert!(!path.starts_with("/project"));
1106    }
1107
1108    #[test]
1109    fn codeclimate_re_export_label_in_description() {
1110        let root = PathBuf::from("/project");
1111        let mut results = AnalysisResults::default();
1112        results.unused_exports.push(UnusedExport {
1113            path: root.join("src/index.ts"),
1114            export_name: "reExported".to_string(),
1115            is_type_only: false,
1116            line: 1,
1117            col: 0,
1118            span_start: 0,
1119            is_re_export: true,
1120        });
1121        let rules = RulesConfig::default();
1122        let output = build_codeclimate(&results, &root, &rules);
1123        let desc = output[0]["description"].as_str().unwrap();
1124        assert!(desc.contains("Re-export"));
1125    }
1126
1127    #[test]
1128    fn codeclimate_unlisted_dep_one_issue_per_import_site() {
1129        let root = PathBuf::from("/project");
1130        let mut results = AnalysisResults::default();
1131        results.unlisted_dependencies.push(UnlistedDependency {
1132            package_name: "chalk".to_string(),
1133            imported_from: vec![
1134                ImportSite {
1135                    path: root.join("src/a.ts"),
1136                    line: 1,
1137                    col: 0,
1138                },
1139                ImportSite {
1140                    path: root.join("src/b.ts"),
1141                    line: 5,
1142                    col: 0,
1143                },
1144            ],
1145        });
1146        let rules = RulesConfig::default();
1147        let output = build_codeclimate(&results, &root, &rules);
1148        let arr = output.as_array().unwrap();
1149        assert_eq!(arr.len(), 2);
1150        assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
1151        assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
1152    }
1153
1154    #[test]
1155    fn codeclimate_duplicate_export_one_issue_per_location() {
1156        let root = PathBuf::from("/project");
1157        let mut results = AnalysisResults::default();
1158        results.duplicate_exports.push(DuplicateExport {
1159            export_name: "Config".to_string(),
1160            locations: vec![
1161                DuplicateLocation {
1162                    path: root.join("src/a.ts"),
1163                    line: 10,
1164                    col: 0,
1165                },
1166                DuplicateLocation {
1167                    path: root.join("src/b.ts"),
1168                    line: 20,
1169                    col: 0,
1170                },
1171                DuplicateLocation {
1172                    path: root.join("src/c.ts"),
1173                    line: 30,
1174                    col: 0,
1175                },
1176            ],
1177        });
1178        let rules = RulesConfig::default();
1179        let output = build_codeclimate(&results, &root, &rules);
1180        let arr = output.as_array().unwrap();
1181        assert_eq!(arr.len(), 3);
1182    }
1183
1184    #[test]
1185    fn codeclimate_circular_dep_emits_chain_in_description() {
1186        let root = PathBuf::from("/project");
1187        let mut results = AnalysisResults::default();
1188        results.circular_dependencies.push(CircularDependency {
1189            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1190            length: 2,
1191            line: 3,
1192            col: 0,
1193            is_cross_package: false,
1194        });
1195        let rules = RulesConfig::default();
1196        let output = build_codeclimate(&results, &root, &rules);
1197        let desc = output[0]["description"].as_str().unwrap();
1198        assert!(desc.contains("Circular dependency"));
1199        assert!(desc.contains("src/a.ts"));
1200        assert!(desc.contains("src/b.ts"));
1201    }
1202
1203    #[test]
1204    fn codeclimate_fingerprints_are_deterministic() {
1205        let root = PathBuf::from("/project");
1206        let results = sample_results(&root);
1207        let rules = RulesConfig::default();
1208        let output1 = build_codeclimate(&results, &root, &rules);
1209        let output2 = build_codeclimate(&results, &root, &rules);
1210
1211        let fps1: Vec<&str> = output1
1212            .as_array()
1213            .unwrap()
1214            .iter()
1215            .map(|i| i["fingerprint"].as_str().unwrap())
1216            .collect();
1217        let fps2: Vec<&str> = output2
1218            .as_array()
1219            .unwrap()
1220            .iter()
1221            .map(|i| i["fingerprint"].as_str().unwrap())
1222            .collect();
1223        assert_eq!(fps1, fps2);
1224    }
1225
1226    #[test]
1227    fn codeclimate_fingerprints_are_unique() {
1228        let root = PathBuf::from("/project");
1229        let results = sample_results(&root);
1230        let rules = RulesConfig::default();
1231        let output = build_codeclimate(&results, &root, &rules);
1232
1233        let mut fps: Vec<&str> = output
1234            .as_array()
1235            .unwrap()
1236            .iter()
1237            .map(|i| i["fingerprint"].as_str().unwrap())
1238            .collect();
1239        let original_len = fps.len();
1240        fps.sort_unstable();
1241        fps.dedup();
1242        assert_eq!(fps.len(), original_len, "fingerprints should be unique");
1243    }
1244
1245    #[test]
1246    fn codeclimate_type_only_dep_has_correct_check_name() {
1247        let root = PathBuf::from("/project");
1248        let mut results = AnalysisResults::default();
1249        results.type_only_dependencies.push(TypeOnlyDependency {
1250            package_name: "zod".to_string(),
1251            path: root.join("package.json"),
1252            line: 8,
1253        });
1254        let rules = RulesConfig::default();
1255        let output = build_codeclimate(&results, &root, &rules);
1256        assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
1257        let desc = output[0]["description"].as_str().unwrap();
1258        assert!(desc.contains("zod"));
1259        assert!(desc.contains("type-only"));
1260    }
1261
1262    #[test]
1263    fn codeclimate_dep_with_zero_line_omits_line_number() {
1264        let root = PathBuf::from("/project");
1265        let mut results = AnalysisResults::default();
1266        results.unused_dependencies.push(UnusedDependency {
1267            package_name: "lodash".to_string(),
1268            location: DependencyLocation::Dependencies,
1269            path: root.join("package.json"),
1270            line: 0,
1271            used_in_workspaces: Vec::new(),
1272        });
1273        let rules = RulesConfig::default();
1274        let output = build_codeclimate(&results, &root, &rules);
1275        // Line 0 -> begin defaults to 1
1276        assert_eq!(output[0]["location"]["lines"]["begin"], 1);
1277    }
1278
1279    // ── fingerprint_hash tests ─────────────────────────────────────
1280
1281    #[test]
1282    fn fingerprint_hash_different_inputs_differ() {
1283        let h1 = fingerprint_hash(&["a", "b"]);
1284        let h2 = fingerprint_hash(&["a", "c"]);
1285        assert_ne!(h1, h2);
1286    }
1287
1288    #[test]
1289    fn fingerprint_hash_order_matters() {
1290        let h1 = fingerprint_hash(&["a", "b"]);
1291        let h2 = fingerprint_hash(&["b", "a"]);
1292        assert_ne!(h1, h2);
1293    }
1294
1295    #[test]
1296    fn fingerprint_hash_separator_prevents_collision() {
1297        // "ab" + "c" should differ from "a" + "bc"
1298        let h1 = fingerprint_hash(&["ab", "c"]);
1299        let h2 = fingerprint_hash(&["a", "bc"]);
1300        assert_ne!(h1, h2);
1301    }
1302
1303    #[test]
1304    fn fingerprint_hash_is_16_hex_chars() {
1305        let h = fingerprint_hash(&["test"]);
1306        assert_eq!(h.len(), 16);
1307        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
1308    }
1309
1310    // ── severity_to_codeclimate ─────────────────────────────────────
1311
1312    #[test]
1313    fn severity_error_maps_to_major() {
1314        assert_eq!(severity_to_codeclimate(Severity::Error), "major");
1315    }
1316
1317    #[test]
1318    fn severity_warn_maps_to_minor() {
1319        assert_eq!(severity_to_codeclimate(Severity::Warn), "minor");
1320    }
1321
1322    #[test]
1323    fn severity_off_maps_to_minor() {
1324        assert_eq!(severity_to_codeclimate(Severity::Off), "minor");
1325    }
1326
1327    // ── health_severity ─────────────────────────────────────────────
1328
1329    #[test]
1330    fn health_severity_zero_threshold_returns_minor() {
1331        assert_eq!(health_severity(100, 0), "minor");
1332    }
1333
1334    #[test]
1335    fn health_severity_at_threshold_returns_minor() {
1336        assert_eq!(health_severity(10, 10), "minor");
1337    }
1338
1339    #[test]
1340    fn health_severity_1_5x_threshold_returns_minor() {
1341        assert_eq!(health_severity(15, 10), "minor");
1342    }
1343
1344    #[test]
1345    fn health_severity_above_1_5x_returns_major() {
1346        assert_eq!(health_severity(16, 10), "major");
1347    }
1348
1349    #[test]
1350    fn health_severity_at_2_5x_returns_major() {
1351        assert_eq!(health_severity(25, 10), "major");
1352    }
1353
1354    #[test]
1355    fn health_severity_above_2_5x_returns_critical() {
1356        assert_eq!(health_severity(26, 10), "critical");
1357    }
1358
1359    #[test]
1360    fn health_codeclimate_includes_coverage_gaps() {
1361        use crate::health_types::*;
1362
1363        let root = PathBuf::from("/project");
1364        let report = HealthReport {
1365            summary: HealthSummary {
1366                files_analyzed: 10,
1367                functions_analyzed: 50,
1368                ..Default::default()
1369            },
1370            coverage_gaps: Some(CoverageGaps {
1371                summary: CoverageGapSummary {
1372                    runtime_files: 2,
1373                    covered_files: 0,
1374                    file_coverage_pct: 0.0,
1375                    untested_files: 1,
1376                    untested_exports: 1,
1377                },
1378                files: vec![UntestedFile {
1379                    path: root.join("src/app.ts"),
1380                    value_export_count: 2,
1381                }],
1382                exports: vec![UntestedExport {
1383                    path: root.join("src/app.ts"),
1384                    export_name: "loader".into(),
1385                    line: 12,
1386                    col: 4,
1387                }],
1388            }),
1389            ..Default::default()
1390        };
1391
1392        let output = build_health_codeclimate(&report, &root);
1393        let issues = output.as_array().unwrap();
1394        assert_eq!(issues.len(), 2);
1395        assert_eq!(issues[0]["check_name"], "fallow/untested-file");
1396        assert_eq!(issues[0]["categories"][0], "Coverage");
1397        assert_eq!(issues[0]["location"]["path"], "src/app.ts");
1398        assert_eq!(issues[1]["check_name"], "fallow/untested-export");
1399        assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
1400        assert!(
1401            issues[1]["description"]
1402                .as_str()
1403                .unwrap()
1404                .contains("loader")
1405        );
1406    }
1407
1408    #[test]
1409    fn health_codeclimate_crap_only_uses_crap_check_name() {
1410        use crate::health_types::{FindingSeverity, HealthFinding, HealthReport, HealthSummary};
1411        let root = PathBuf::from("/project");
1412        let report = HealthReport {
1413            findings: vec![HealthFinding {
1414                path: root.join("src/untested.ts"),
1415                name: "risky".to_string(),
1416                line: 7,
1417                col: 0,
1418                cyclomatic: 10,
1419                cognitive: 10,
1420                line_count: 20,
1421                param_count: 1,
1422                exceeded: crate::health_types::ExceededThreshold::Crap,
1423                severity: FindingSeverity::High,
1424                crap: Some(60.0),
1425                coverage_pct: Some(25.0),
1426                coverage_tier: None,
1427            }],
1428            summary: HealthSummary {
1429                functions_analyzed: 10,
1430                functions_above_threshold: 1,
1431                ..Default::default()
1432            },
1433            ..Default::default()
1434        };
1435        let json = build_health_codeclimate(&report, &root);
1436        let issues = json.as_array().unwrap();
1437        assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
1438        assert_eq!(issues[0]["severity"], "major");
1439        let description = issues[0]["description"].as_str().unwrap();
1440        assert!(description.contains("CRAP score"), "desc: {description}");
1441        assert!(description.contains("coverage 25%"), "desc: {description}");
1442    }
1443}