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