Skip to main content

fallow_cli/report/
codeclimate.rs

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