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