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
506fn push_unused_catalog_entry_issues(
507    issues: &mut Vec<serde_json::Value>,
508    entries: &[fallow_core::results::UnusedCatalogEntry],
509    root: &Path,
510    severity: Severity,
511) {
512    if entries.is_empty() {
513        return;
514    }
515    let level = severity_to_codeclimate(severity);
516    for entry in entries {
517        let path = cc_path(&entry.path, root);
518        let line_str = entry.line.to_string();
519        let fp = fingerprint_hash(&[
520            "fallow/unused-catalog-entry",
521            &path,
522            &line_str,
523            &entry.catalog_name,
524            &entry.entry_name,
525        ]);
526        let description = if entry.catalog_name == "default" {
527            format!(
528                "Catalog entry '{}' is not referenced by any workspace package",
529                entry.entry_name
530            )
531        } else {
532            format!(
533                "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
534                entry.entry_name, entry.catalog_name
535            )
536        };
537        issues.push(cc_issue(
538            "fallow/unused-catalog-entry",
539            &description,
540            level,
541            "Bug Risk",
542            &path,
543            Some(entry.line),
544            &fp,
545        ));
546    }
547}
548
549fn push_unresolved_catalog_reference_issues(
550    issues: &mut Vec<serde_json::Value>,
551    findings: &[fallow_core::results::UnresolvedCatalogReference],
552    root: &Path,
553    severity: Severity,
554) {
555    if findings.is_empty() {
556        return;
557    }
558    let level = severity_to_codeclimate(severity);
559    for finding in findings {
560        let path = cc_path(&finding.path, root);
561        let line_str = finding.line.to_string();
562        let fp = fingerprint_hash(&[
563            "fallow/unresolved-catalog-reference",
564            &path,
565            &line_str,
566            &finding.catalog_name,
567            &finding.entry_name,
568        ]);
569        let catalog_phrase = if finding.catalog_name == "default" {
570            "the default catalog".to_string()
571        } else {
572            format!("catalog '{}'", finding.catalog_name)
573        };
574        let mut description = format!(
575            "Package '{}' is referenced via `catalog:{}` but {} does not declare it; `pnpm install` will fail",
576            finding.entry_name,
577            if finding.catalog_name == "default" {
578                ""
579            } else {
580                finding.catalog_name.as_str()
581            },
582            catalog_phrase,
583        );
584        if !finding.available_in_catalogs.is_empty() {
585            use std::fmt::Write as _;
586            let _ = write!(
587                description,
588                " (available in: {})",
589                finding.available_in_catalogs.join(", ")
590            );
591        }
592        issues.push(cc_issue(
593            "fallow/unresolved-catalog-reference",
594            &description,
595            level,
596            "Bug Risk",
597            &path,
598            Some(finding.line),
599            &fp,
600        ));
601    }
602}
603
604fn push_empty_catalog_group_issues(
605    issues: &mut Vec<serde_json::Value>,
606    groups: &[fallow_core::results::EmptyCatalogGroup],
607    root: &Path,
608    severity: Severity,
609) {
610    if groups.is_empty() {
611        return;
612    }
613    let level = severity_to_codeclimate(severity);
614    for group in groups {
615        let path = cc_path(&group.path, root);
616        let line_str = group.line.to_string();
617        let fp = fingerprint_hash(&[
618            "fallow/empty-catalog-group",
619            &path,
620            &line_str,
621            &group.catalog_name,
622        ]);
623        issues.push(cc_issue(
624            "fallow/empty-catalog-group",
625            &format!("Catalog group '{}' has no entries", group.catalog_name),
626            level,
627            "Bug Risk",
628            &path,
629            Some(group.line),
630            &fp,
631        ));
632    }
633}
634
635fn push_unused_dependency_override_issues(
636    issues: &mut Vec<serde_json::Value>,
637    findings: &[fallow_core::results::UnusedDependencyOverride],
638    root: &Path,
639    severity: Severity,
640) {
641    if findings.is_empty() {
642        return;
643    }
644    let level = severity_to_codeclimate(severity);
645    for finding in findings {
646        let path = cc_path(&finding.path, root);
647        let line_str = finding.line.to_string();
648        let fp = fingerprint_hash(&[
649            "fallow/unused-dependency-override",
650            &path,
651            &line_str,
652            finding.source.as_label(),
653            &finding.raw_key,
654        ]);
655        let mut description = format!(
656            "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
657            finding.raw_key, finding.version_range, finding.target_package,
658        );
659        if let Some(hint) = &finding.hint {
660            use std::fmt::Write as _;
661            let _ = write!(description, " ({hint})");
662        }
663        issues.push(cc_issue(
664            "fallow/unused-dependency-override",
665            &description,
666            level,
667            "Bug Risk",
668            &path,
669            Some(finding.line),
670            &fp,
671        ));
672    }
673}
674
675fn push_misconfigured_dependency_override_issues(
676    issues: &mut Vec<serde_json::Value>,
677    findings: &[fallow_core::results::MisconfiguredDependencyOverride],
678    root: &Path,
679    severity: Severity,
680) {
681    if findings.is_empty() {
682        return;
683    }
684    let level = severity_to_codeclimate(severity);
685    for finding in findings {
686        let path = cc_path(&finding.path, root);
687        let line_str = finding.line.to_string();
688        let fp = fingerprint_hash(&[
689            "fallow/misconfigured-dependency-override",
690            &path,
691            &line_str,
692            finding.source.as_label(),
693            &finding.raw_key,
694        ]);
695        let description = format!(
696            "Override `{}` -> `{}` is malformed: {}",
697            finding.raw_key,
698            finding.raw_value,
699            finding.reason.describe(),
700        );
701        issues.push(cc_issue(
702            "fallow/misconfigured-dependency-override",
703            &description,
704            level,
705            "Bug Risk",
706            &path,
707            Some(finding.line),
708            &fp,
709        ));
710    }
711}
712
713/// Build CodeClimate JSON array from dead-code analysis results.
714#[must_use]
715pub fn build_codeclimate(
716    results: &AnalysisResults,
717    root: &Path,
718    rules: &RulesConfig,
719) -> serde_json::Value {
720    let mut issues = Vec::new();
721
722    push_unused_file_issues(&mut issues, &results.unused_files, root, rules.unused_files);
723    push_unused_export_issues(
724        &mut issues,
725        &results.unused_exports,
726        root,
727        "fallow/unused-export",
728        "Export",
729        "Re-export",
730        rules.unused_exports,
731    );
732    push_unused_export_issues(
733        &mut issues,
734        &results.unused_types,
735        root,
736        "fallow/unused-type",
737        "Type export",
738        "Type re-export",
739        rules.unused_types,
740    );
741    push_private_type_leak_issues(
742        &mut issues,
743        &results.private_type_leaks,
744        root,
745        rules.private_type_leaks,
746    );
747    push_dep_cc_issues(
748        &mut issues,
749        &results.unused_dependencies,
750        root,
751        "fallow/unused-dependency",
752        "dependencies",
753        rules.unused_dependencies,
754    );
755    push_dep_cc_issues(
756        &mut issues,
757        &results.unused_dev_dependencies,
758        root,
759        "fallow/unused-dev-dependency",
760        "devDependencies",
761        rules.unused_dev_dependencies,
762    );
763    push_dep_cc_issues(
764        &mut issues,
765        &results.unused_optional_dependencies,
766        root,
767        "fallow/unused-optional-dependency",
768        "optionalDependencies",
769        rules.unused_optional_dependencies,
770    );
771    push_type_only_dep_issues(
772        &mut issues,
773        &results.type_only_dependencies,
774        root,
775        rules.type_only_dependencies,
776    );
777    push_test_only_dep_issues(
778        &mut issues,
779        &results.test_only_dependencies,
780        root,
781        rules.test_only_dependencies,
782    );
783    push_unused_member_issues(
784        &mut issues,
785        &results.unused_enum_members,
786        root,
787        "fallow/unused-enum-member",
788        "Enum",
789        rules.unused_enum_members,
790    );
791    push_unused_member_issues(
792        &mut issues,
793        &results.unused_class_members,
794        root,
795        "fallow/unused-class-member",
796        "Class",
797        rules.unused_class_members,
798    );
799    push_unresolved_import_issues(
800        &mut issues,
801        &results.unresolved_imports,
802        root,
803        rules.unresolved_imports,
804    );
805    push_unlisted_dep_issues(
806        &mut issues,
807        &results.unlisted_dependencies,
808        root,
809        rules.unlisted_dependencies,
810    );
811    push_duplicate_export_issues(
812        &mut issues,
813        &results.duplicate_exports,
814        root,
815        rules.duplicate_exports,
816    );
817    push_circular_dep_issues(
818        &mut issues,
819        &results.circular_dependencies,
820        root,
821        rules.circular_dependencies,
822    );
823    push_boundary_violation_issues(
824        &mut issues,
825        &results.boundary_violations,
826        root,
827        rules.boundary_violation,
828    );
829    push_stale_suppression_issues(
830        &mut issues,
831        &results.stale_suppressions,
832        root,
833        rules.stale_suppressions,
834    );
835    push_unused_catalog_entry_issues(
836        &mut issues,
837        &results.unused_catalog_entries,
838        root,
839        rules.unused_catalog_entries,
840    );
841    push_empty_catalog_group_issues(
842        &mut issues,
843        &results.empty_catalog_groups,
844        root,
845        rules.empty_catalog_groups,
846    );
847    push_unresolved_catalog_reference_issues(
848        &mut issues,
849        &results.unresolved_catalog_references,
850        root,
851        rules.unresolved_catalog_references,
852    );
853    push_unused_dependency_override_issues(
854        &mut issues,
855        &results.unused_dependency_overrides,
856        root,
857        rules.unused_dependency_overrides,
858    );
859    push_misconfigured_dependency_override_issues(
860        &mut issues,
861        &results.misconfigured_dependency_overrides,
862        root,
863        rules.misconfigured_dependency_overrides,
864    );
865
866    serde_json::Value::Array(issues)
867}
868
869/// Print dead-code analysis results in CodeClimate format.
870pub(super) fn print_codeclimate(
871    results: &AnalysisResults,
872    root: &Path,
873    rules: &RulesConfig,
874) -> ExitCode {
875    let value = build_codeclimate(results, root, rules);
876    emit_json(&value, "CodeClimate")
877}
878
879/// Print CodeClimate output with owner properties added to each issue.
880///
881/// Calls `build_codeclimate` to produce the standard CodeClimate JSON array,
882/// then post-processes each entry to add `"owner": "@team"` by resolving the
883/// issue's location path through the `OwnershipResolver`.
884pub(super) fn print_grouped_codeclimate(
885    results: &AnalysisResults,
886    root: &Path,
887    rules: &RulesConfig,
888    resolver: &OwnershipResolver,
889) -> ExitCode {
890    let mut value = build_codeclimate(results, root, rules);
891
892    if let Some(issues) = value.as_array_mut() {
893        for issue in issues {
894            let path = issue
895                .pointer("/location/path")
896                .and_then(|v| v.as_str())
897                .unwrap_or("");
898            let owner = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
899            issue
900                .as_object_mut()
901                .expect("CodeClimate issue should be an object")
902                .insert("owner".to_string(), serde_json::Value::String(owner));
903        }
904    }
905
906    emit_json(&value, "CodeClimate")
907}
908
909/// Build CodeClimate JSON array from health/complexity analysis results.
910#[must_use]
911#[expect(
912    clippy::too_many_lines,
913    reason = "CRAP adds a fourth exceeded-threshold branch plus its description; splitting the dispatch table would fragment the mapping."
914)]
915pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> serde_json::Value {
916    let mut issues = Vec::new();
917
918    let cyc_t = report.summary.max_cyclomatic_threshold;
919    let cog_t = report.summary.max_cognitive_threshold;
920    let crap_t = report.summary.max_crap_threshold;
921
922    for finding in &report.findings {
923        let path = cc_path(&finding.path, root);
924        let description = match finding.exceeded {
925            ExceededThreshold::Both => format!(
926                "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
927                finding.name, finding.cyclomatic, cyc_t, finding.cognitive, cog_t
928            ),
929            ExceededThreshold::Cyclomatic => format!(
930                "'{}' has cyclomatic complexity {} (threshold: {})",
931                finding.name, finding.cyclomatic, cyc_t
932            ),
933            ExceededThreshold::Cognitive => format!(
934                "'{}' has cognitive complexity {} (threshold: {})",
935                finding.name, finding.cognitive, cog_t
936            ),
937            ExceededThreshold::Crap
938            | ExceededThreshold::CyclomaticCrap
939            | ExceededThreshold::CognitiveCrap
940            | ExceededThreshold::All => {
941                let crap = finding.crap.unwrap_or(0.0);
942                let coverage = finding
943                    .coverage_pct
944                    .map(|pct| format!(", coverage {pct:.0}%"))
945                    .unwrap_or_default();
946                format!(
947                    "'{}' has CRAP score {crap:.1} (threshold: {crap_t:.1}, cyclomatic {}{coverage})",
948                    finding.name, finding.cyclomatic,
949                )
950            }
951        };
952        let check_name = match finding.exceeded {
953            ExceededThreshold::Both => "fallow/high-complexity",
954            ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
955            ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
956            ExceededThreshold::Crap
957            | ExceededThreshold::CyclomaticCrap
958            | ExceededThreshold::CognitiveCrap
959            | ExceededThreshold::All => "fallow/high-crap-score",
960        };
961        // Map finding severity to CodeClimate severity levels
962        let severity = match finding.severity {
963            crate::health_types::FindingSeverity::Critical => "critical",
964            crate::health_types::FindingSeverity::High => "major",
965            crate::health_types::FindingSeverity::Moderate => "minor",
966        };
967        let line_str = finding.line.to_string();
968        let fp = fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
969        issues.push(cc_issue(
970            check_name,
971            &description,
972            severity,
973            "Complexity",
974            &path,
975            Some(finding.line),
976            &fp,
977        ));
978    }
979
980    // Note: `production.hot_paths` and `production.signals` are
981    // intentionally omitted from CodeClimate output. CodeClimate / GitLab
982    // Code Quality issues are actionable findings; the
983    // `hot-path-touched` signal is a PR-review heads-up and the
984    // `signals[]` array is a programmatic decomposition of the verdict.
985    // JSON consumers that need the full surface read those fields
986    // directly from the JSON output.
987    if let Some(ref production) = report.runtime_coverage {
988        for finding in &production.findings {
989            let path = cc_path(&finding.path, root);
990            let check_name = match finding.verdict {
991                crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
992                    "fallow/runtime-safe-to-delete"
993                }
994                crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
995                    "fallow/runtime-review-required"
996                }
997                crate::health_types::RuntimeCoverageVerdict::LowTraffic => {
998                    "fallow/runtime-low-traffic"
999                }
1000                crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
1001                    "fallow/runtime-coverage-unavailable"
1002                }
1003                crate::health_types::RuntimeCoverageVerdict::Active
1004                | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
1005            };
1006            let invocations_hint = finding.invocations.map_or_else(
1007                || "untracked".to_owned(),
1008                |hits| format!("{hits} invocations"),
1009            );
1010            let description = format!(
1011                "'{}' runtime coverage verdict: {} ({})",
1012                finding.function,
1013                finding.verdict.human_label(),
1014                invocations_hint,
1015            );
1016            // GitLab Code Quality renders MR inline annotations only for
1017            // blocker/critical/major/minor. Any non-cold verdict collapses to
1018            // "minor" — "info" is schema-valid but silently dropped from MR
1019            // annotations.
1020            let severity = match finding.verdict {
1021                crate::health_types::RuntimeCoverageVerdict::SafeToDelete => "critical",
1022                crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "major",
1023                _ => "minor",
1024            };
1025            let fp = fingerprint_hash(&[
1026                check_name,
1027                &path,
1028                &finding.line.to_string(),
1029                &finding.function,
1030            ]);
1031            issues.push(cc_issue(
1032                check_name,
1033                &description,
1034                severity,
1035                // CodeClimate/GitLab Code Quality allows a fixed category set:
1036                // Bug Risk | Clarity | Compatibility | Complexity | Duplication
1037                // | Performance | Security | Style. Production-coverage
1038                // findings are a dead-code signal, so use "Bug Risk" — same
1039                // category used by static dead-code issues elsewhere.
1040                "Bug Risk",
1041                &path,
1042                Some(finding.line),
1043                &fp,
1044            ));
1045        }
1046    }
1047
1048    if let Some(ref gaps) = report.coverage_gaps {
1049        for item in &gaps.files {
1050            let path = cc_path(&item.path, root);
1051            let description = format!(
1052                "File is runtime-reachable but has no test dependency path ({} value export{})",
1053                item.value_export_count,
1054                if item.value_export_count == 1 {
1055                    ""
1056                } else {
1057                    "s"
1058                },
1059            );
1060            let fp = fingerprint_hash(&["fallow/untested-file", &path]);
1061            issues.push(cc_issue(
1062                "fallow/untested-file",
1063                &description,
1064                "minor",
1065                "Coverage",
1066                &path,
1067                None,
1068                &fp,
1069            ));
1070        }
1071
1072        for item in &gaps.exports {
1073            let path = cc_path(&item.path, root);
1074            let description = format!(
1075                "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
1076                item.export_name
1077            );
1078            let line_str = item.line.to_string();
1079            let fp = fingerprint_hash(&[
1080                "fallow/untested-export",
1081                &path,
1082                &line_str,
1083                &item.export_name,
1084            ]);
1085            issues.push(cc_issue(
1086                "fallow/untested-export",
1087                &description,
1088                "minor",
1089                "Coverage",
1090                &path,
1091                Some(item.line),
1092                &fp,
1093            ));
1094        }
1095    }
1096
1097    serde_json::Value::Array(issues)
1098}
1099
1100/// Print health analysis results in CodeClimate format.
1101pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
1102    let value = build_health_codeclimate(report, root);
1103    emit_json(&value, "CodeClimate")
1104}
1105
1106/// Print health CodeClimate output with a per-issue `group` field.
1107///
1108/// Mirrors the dead-code grouped CodeClimate pattern
1109/// (`print_grouped_codeclimate`): build the standard payload first, then
1110/// post-process each issue to attach a `group` key derived from the
1111/// `OwnershipResolver`. Lets GitLab Code Quality and other CodeClimate
1112/// consumers partition findings per team / package without re-parsing the
1113/// project structure.
1114pub(super) fn print_grouped_health_codeclimate(
1115    report: &HealthReport,
1116    root: &Path,
1117    resolver: &OwnershipResolver,
1118) -> ExitCode {
1119    let mut value = build_health_codeclimate(report, root);
1120
1121    if let Some(issues) = value.as_array_mut() {
1122        for issue in issues {
1123            let path = issue
1124                .pointer("/location/path")
1125                .and_then(|v| v.as_str())
1126                .unwrap_or("");
1127            let group = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
1128            issue
1129                .as_object_mut()
1130                .expect("CodeClimate issue should be an object")
1131                .insert("group".to_string(), serde_json::Value::String(group));
1132        }
1133    }
1134
1135    emit_json(&value, "CodeClimate")
1136}
1137
1138/// Build CodeClimate JSON array from duplication analysis results.
1139#[must_use]
1140#[expect(
1141    clippy::cast_possible_truncation,
1142    reason = "line numbers are bounded by source size"
1143)]
1144pub fn build_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> serde_json::Value {
1145    let mut issues = Vec::new();
1146
1147    for (i, group) in report.clone_groups.iter().enumerate() {
1148        // Content-based fingerprint: hash token_count + line_count + first 64 chars of fragment
1149        // This is stable across runs regardless of group ordering.
1150        let token_str = group.token_count.to_string();
1151        let line_count_str = group.line_count.to_string();
1152        let fragment_prefix: String = group
1153            .instances
1154            .first()
1155            .map(|inst| inst.fragment.chars().take(64).collect())
1156            .unwrap_or_default();
1157
1158        for instance in &group.instances {
1159            let path = cc_path(&instance.file, root);
1160            let start_str = instance.start_line.to_string();
1161            let fp = fingerprint_hash(&[
1162                "fallow/code-duplication",
1163                &path,
1164                &start_str,
1165                &token_str,
1166                &line_count_str,
1167                &fragment_prefix,
1168            ]);
1169            issues.push(cc_issue(
1170                "fallow/code-duplication",
1171                &format!(
1172                    "Code clone group {} ({} lines, {} instances)",
1173                    i + 1,
1174                    group.line_count,
1175                    group.instances.len()
1176                ),
1177                "minor",
1178                "Duplication",
1179                &path,
1180                Some(instance.start_line as u32),
1181                &fp,
1182            ));
1183        }
1184    }
1185
1186    serde_json::Value::Array(issues)
1187}
1188
1189/// Print duplication analysis results in CodeClimate format.
1190pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
1191    let value = build_duplication_codeclimate(report, root);
1192    emit_json(&value, "CodeClimate")
1193}
1194
1195/// Print duplication CodeClimate output with a per-issue `group` field.
1196///
1197/// Mirrors [`print_grouped_health_codeclimate`]: each clone group is attributed
1198/// to its largest owner ([`super::dupes_grouping::largest_owner`]) and every
1199/// CodeClimate issue emitted for that clone group's instances carries the same
1200/// top-level `group` key. Lets GitLab Code Quality and other CodeClimate
1201/// consumers partition findings per team / package / directory without
1202/// re-parsing the project structure.
1203pub(super) fn print_grouped_duplication_codeclimate(
1204    report: &DuplicationReport,
1205    root: &Path,
1206    resolver: &OwnershipResolver,
1207) -> ExitCode {
1208    let mut value = build_duplication_codeclimate(report, root);
1209
1210    // Build a flat lookup from each instance path -> primary owner. Every
1211    // instance of a clone group inherits the group's largest-owner key.
1212    use rustc_hash::FxHashMap;
1213    let mut path_to_owner: FxHashMap<String, String> = FxHashMap::default();
1214    for group in &report.clone_groups {
1215        let owner = super::dupes_grouping::largest_owner(group, root, resolver);
1216        for instance in &group.instances {
1217            let path = cc_path(&instance.file, root);
1218            path_to_owner.insert(path, owner.clone());
1219        }
1220    }
1221
1222    if let Some(issues) = value.as_array_mut() {
1223        for issue in issues {
1224            let path = issue
1225                .pointer("/location/path")
1226                .and_then(|v| v.as_str())
1227                .unwrap_or("")
1228                .to_string();
1229            let group = path_to_owner
1230                .get(&path)
1231                .cloned()
1232                .unwrap_or_else(|| crate::codeowners::UNOWNED_LABEL.to_string());
1233            issue
1234                .as_object_mut()
1235                .expect("CodeClimate issue should be an object")
1236                .insert("group".to_string(), serde_json::Value::String(group));
1237        }
1238    }
1239
1240    emit_json(&value, "CodeClimate")
1241}
1242
1243#[cfg(test)]
1244mod tests {
1245    use super::*;
1246    use crate::report::test_helpers::sample_results;
1247    use fallow_config::RulesConfig;
1248    use fallow_core::results::*;
1249    use std::path::PathBuf;
1250
1251    /// Compute graduated severity for health findings based on threshold ratio.
1252    /// Kept for unit test coverage of the original CodeClimate severity model.
1253    fn health_severity(value: u16, threshold: u16) -> &'static str {
1254        if threshold == 0 {
1255            return "minor";
1256        }
1257        let ratio = f64::from(value) / f64::from(threshold);
1258        if ratio > 2.5 {
1259            "critical"
1260        } else if ratio > 1.5 {
1261            "major"
1262        } else {
1263            "minor"
1264        }
1265    }
1266
1267    #[test]
1268    fn codeclimate_empty_results_produces_empty_array() {
1269        let root = PathBuf::from("/project");
1270        let results = AnalysisResults::default();
1271        let rules = RulesConfig::default();
1272        let output = build_codeclimate(&results, &root, &rules);
1273        let arr = output.as_array().unwrap();
1274        assert!(arr.is_empty());
1275    }
1276
1277    #[test]
1278    fn codeclimate_produces_array_of_issues() {
1279        let root = PathBuf::from("/project");
1280        let results = sample_results(&root);
1281        let rules = RulesConfig::default();
1282        let output = build_codeclimate(&results, &root, &rules);
1283        assert!(output.is_array());
1284        let arr = output.as_array().unwrap();
1285        // Should have at least one issue per type
1286        assert!(!arr.is_empty());
1287    }
1288
1289    #[test]
1290    fn codeclimate_issue_has_required_fields() {
1291        let root = PathBuf::from("/project");
1292        let mut results = AnalysisResults::default();
1293        results.unused_files.push(UnusedFile {
1294            path: root.join("src/dead.ts"),
1295        });
1296        let rules = RulesConfig::default();
1297        let output = build_codeclimate(&results, &root, &rules);
1298        let issue = &output.as_array().unwrap()[0];
1299
1300        assert_eq!(issue["type"], "issue");
1301        assert_eq!(issue["check_name"], "fallow/unused-file");
1302        assert!(issue["description"].is_string());
1303        assert!(issue["categories"].is_array());
1304        assert!(issue["severity"].is_string());
1305        assert!(issue["fingerprint"].is_string());
1306        assert!(issue["location"].is_object());
1307        assert!(issue["location"]["path"].is_string());
1308        assert!(issue["location"]["lines"].is_object());
1309    }
1310
1311    #[test]
1312    fn codeclimate_unused_file_severity_follows_rules() {
1313        let root = PathBuf::from("/project");
1314        let mut results = AnalysisResults::default();
1315        results.unused_files.push(UnusedFile {
1316            path: root.join("src/dead.ts"),
1317        });
1318
1319        // Error severity -> major
1320        let rules = RulesConfig::default();
1321        let output = build_codeclimate(&results, &root, &rules);
1322        assert_eq!(output[0]["severity"], "major");
1323
1324        // Warn severity -> minor
1325        let rules = RulesConfig {
1326            unused_files: Severity::Warn,
1327            ..RulesConfig::default()
1328        };
1329        let output = build_codeclimate(&results, &root, &rules);
1330        assert_eq!(output[0]["severity"], "minor");
1331    }
1332
1333    #[test]
1334    fn codeclimate_unused_export_has_line_number() {
1335        let root = PathBuf::from("/project");
1336        let mut results = AnalysisResults::default();
1337        results.unused_exports.push(UnusedExport {
1338            path: root.join("src/utils.ts"),
1339            export_name: "helperFn".to_string(),
1340            is_type_only: false,
1341            line: 10,
1342            col: 4,
1343            span_start: 120,
1344            is_re_export: false,
1345        });
1346        let rules = RulesConfig::default();
1347        let output = build_codeclimate(&results, &root, &rules);
1348        let issue = &output[0];
1349        assert_eq!(issue["location"]["lines"]["begin"], 10);
1350    }
1351
1352    #[test]
1353    fn codeclimate_unused_file_line_defaults_to_1() {
1354        let root = PathBuf::from("/project");
1355        let mut results = AnalysisResults::default();
1356        results.unused_files.push(UnusedFile {
1357            path: root.join("src/dead.ts"),
1358        });
1359        let rules = RulesConfig::default();
1360        let output = build_codeclimate(&results, &root, &rules);
1361        let issue = &output[0];
1362        assert_eq!(issue["location"]["lines"]["begin"], 1);
1363    }
1364
1365    #[test]
1366    fn codeclimate_paths_are_relative() {
1367        let root = PathBuf::from("/project");
1368        let mut results = AnalysisResults::default();
1369        results.unused_files.push(UnusedFile {
1370            path: root.join("src/deep/nested/file.ts"),
1371        });
1372        let rules = RulesConfig::default();
1373        let output = build_codeclimate(&results, &root, &rules);
1374        let path = output[0]["location"]["path"].as_str().unwrap();
1375        assert_eq!(path, "src/deep/nested/file.ts");
1376        assert!(!path.starts_with("/project"));
1377    }
1378
1379    #[test]
1380    fn codeclimate_re_export_label_in_description() {
1381        let root = PathBuf::from("/project");
1382        let mut results = AnalysisResults::default();
1383        results.unused_exports.push(UnusedExport {
1384            path: root.join("src/index.ts"),
1385            export_name: "reExported".to_string(),
1386            is_type_only: false,
1387            line: 1,
1388            col: 0,
1389            span_start: 0,
1390            is_re_export: true,
1391        });
1392        let rules = RulesConfig::default();
1393        let output = build_codeclimate(&results, &root, &rules);
1394        let desc = output[0]["description"].as_str().unwrap();
1395        assert!(desc.contains("Re-export"));
1396    }
1397
1398    #[test]
1399    fn codeclimate_unlisted_dep_one_issue_per_import_site() {
1400        let root = PathBuf::from("/project");
1401        let mut results = AnalysisResults::default();
1402        results.unlisted_dependencies.push(UnlistedDependency {
1403            package_name: "chalk".to_string(),
1404            imported_from: vec![
1405                ImportSite {
1406                    path: root.join("src/a.ts"),
1407                    line: 1,
1408                    col: 0,
1409                },
1410                ImportSite {
1411                    path: root.join("src/b.ts"),
1412                    line: 5,
1413                    col: 0,
1414                },
1415            ],
1416        });
1417        let rules = RulesConfig::default();
1418        let output = build_codeclimate(&results, &root, &rules);
1419        let arr = output.as_array().unwrap();
1420        assert_eq!(arr.len(), 2);
1421        assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
1422        assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
1423    }
1424
1425    #[test]
1426    fn codeclimate_duplicate_export_one_issue_per_location() {
1427        let root = PathBuf::from("/project");
1428        let mut results = AnalysisResults::default();
1429        results.duplicate_exports.push(DuplicateExport {
1430            export_name: "Config".to_string(),
1431            locations: vec![
1432                DuplicateLocation {
1433                    path: root.join("src/a.ts"),
1434                    line: 10,
1435                    col: 0,
1436                },
1437                DuplicateLocation {
1438                    path: root.join("src/b.ts"),
1439                    line: 20,
1440                    col: 0,
1441                },
1442                DuplicateLocation {
1443                    path: root.join("src/c.ts"),
1444                    line: 30,
1445                    col: 0,
1446                },
1447            ],
1448        });
1449        let rules = RulesConfig::default();
1450        let output = build_codeclimate(&results, &root, &rules);
1451        let arr = output.as_array().unwrap();
1452        assert_eq!(arr.len(), 3);
1453    }
1454
1455    #[test]
1456    fn codeclimate_circular_dep_emits_chain_in_description() {
1457        let root = PathBuf::from("/project");
1458        let mut results = AnalysisResults::default();
1459        results.circular_dependencies.push(CircularDependency {
1460            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1461            length: 2,
1462            line: 3,
1463            col: 0,
1464            is_cross_package: false,
1465        });
1466        let rules = RulesConfig::default();
1467        let output = build_codeclimate(&results, &root, &rules);
1468        let desc = output[0]["description"].as_str().unwrap();
1469        assert!(desc.contains("Circular dependency"));
1470        assert!(desc.contains("src/a.ts"));
1471        assert!(desc.contains("src/b.ts"));
1472    }
1473
1474    #[test]
1475    fn codeclimate_fingerprints_are_deterministic() {
1476        let root = PathBuf::from("/project");
1477        let results = sample_results(&root);
1478        let rules = RulesConfig::default();
1479        let output1 = build_codeclimate(&results, &root, &rules);
1480        let output2 = build_codeclimate(&results, &root, &rules);
1481
1482        let fps1: Vec<&str> = output1
1483            .as_array()
1484            .unwrap()
1485            .iter()
1486            .map(|i| i["fingerprint"].as_str().unwrap())
1487            .collect();
1488        let fps2: Vec<&str> = output2
1489            .as_array()
1490            .unwrap()
1491            .iter()
1492            .map(|i| i["fingerprint"].as_str().unwrap())
1493            .collect();
1494        assert_eq!(fps1, fps2);
1495    }
1496
1497    #[test]
1498    fn codeclimate_fingerprints_are_unique() {
1499        let root = PathBuf::from("/project");
1500        let results = sample_results(&root);
1501        let rules = RulesConfig::default();
1502        let output = build_codeclimate(&results, &root, &rules);
1503
1504        let mut fps: Vec<&str> = output
1505            .as_array()
1506            .unwrap()
1507            .iter()
1508            .map(|i| i["fingerprint"].as_str().unwrap())
1509            .collect();
1510        let original_len = fps.len();
1511        fps.sort_unstable();
1512        fps.dedup();
1513        assert_eq!(fps.len(), original_len, "fingerprints should be unique");
1514    }
1515
1516    #[test]
1517    fn codeclimate_type_only_dep_has_correct_check_name() {
1518        let root = PathBuf::from("/project");
1519        let mut results = AnalysisResults::default();
1520        results.type_only_dependencies.push(TypeOnlyDependency {
1521            package_name: "zod".to_string(),
1522            path: root.join("package.json"),
1523            line: 8,
1524        });
1525        let rules = RulesConfig::default();
1526        let output = build_codeclimate(&results, &root, &rules);
1527        assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
1528        let desc = output[0]["description"].as_str().unwrap();
1529        assert!(desc.contains("zod"));
1530        assert!(desc.contains("type-only"));
1531    }
1532
1533    #[test]
1534    fn codeclimate_dep_with_zero_line_omits_line_number() {
1535        let root = PathBuf::from("/project");
1536        let mut results = AnalysisResults::default();
1537        results.unused_dependencies.push(UnusedDependency {
1538            package_name: "lodash".to_string(),
1539            location: DependencyLocation::Dependencies,
1540            path: root.join("package.json"),
1541            line: 0,
1542            used_in_workspaces: Vec::new(),
1543        });
1544        let rules = RulesConfig::default();
1545        let output = build_codeclimate(&results, &root, &rules);
1546        // Line 0 -> begin defaults to 1
1547        assert_eq!(output[0]["location"]["lines"]["begin"], 1);
1548    }
1549
1550    // ── fingerprint_hash tests ─────────────────────────────────────
1551
1552    #[test]
1553    fn fingerprint_hash_different_inputs_differ() {
1554        let h1 = fingerprint_hash(&["a", "b"]);
1555        let h2 = fingerprint_hash(&["a", "c"]);
1556        assert_ne!(h1, h2);
1557    }
1558
1559    #[test]
1560    fn fingerprint_hash_order_matters() {
1561        let h1 = fingerprint_hash(&["a", "b"]);
1562        let h2 = fingerprint_hash(&["b", "a"]);
1563        assert_ne!(h1, h2);
1564    }
1565
1566    #[test]
1567    fn fingerprint_hash_separator_prevents_collision() {
1568        // "ab" + "c" should differ from "a" + "bc"
1569        let h1 = fingerprint_hash(&["ab", "c"]);
1570        let h2 = fingerprint_hash(&["a", "bc"]);
1571        assert_ne!(h1, h2);
1572    }
1573
1574    #[test]
1575    fn fingerprint_hash_is_16_hex_chars() {
1576        let h = fingerprint_hash(&["test"]);
1577        assert_eq!(h.len(), 16);
1578        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
1579    }
1580
1581    // ── severity_to_codeclimate ─────────────────────────────────────
1582
1583    #[test]
1584    fn severity_error_maps_to_major() {
1585        assert_eq!(severity_to_codeclimate(Severity::Error), "major");
1586    }
1587
1588    #[test]
1589    fn severity_warn_maps_to_minor() {
1590        assert_eq!(severity_to_codeclimate(Severity::Warn), "minor");
1591    }
1592
1593    #[test]
1594    #[should_panic(expected = "internal error: entered unreachable code")]
1595    fn severity_off_maps_to_minor() {
1596        let _ = severity_to_codeclimate(Severity::Off);
1597    }
1598
1599    // ── health_severity ─────────────────────────────────────────────
1600
1601    #[test]
1602    fn health_severity_zero_threshold_returns_minor() {
1603        assert_eq!(health_severity(100, 0), "minor");
1604    }
1605
1606    #[test]
1607    fn health_severity_at_threshold_returns_minor() {
1608        assert_eq!(health_severity(10, 10), "minor");
1609    }
1610
1611    #[test]
1612    fn health_severity_1_5x_threshold_returns_minor() {
1613        assert_eq!(health_severity(15, 10), "minor");
1614    }
1615
1616    #[test]
1617    fn health_severity_above_1_5x_returns_major() {
1618        assert_eq!(health_severity(16, 10), "major");
1619    }
1620
1621    #[test]
1622    fn health_severity_at_2_5x_returns_major() {
1623        assert_eq!(health_severity(25, 10), "major");
1624    }
1625
1626    #[test]
1627    fn health_severity_above_2_5x_returns_critical() {
1628        assert_eq!(health_severity(26, 10), "critical");
1629    }
1630
1631    #[test]
1632    fn health_codeclimate_includes_coverage_gaps() {
1633        use crate::health_types::*;
1634
1635        let root = PathBuf::from("/project");
1636        let report = HealthReport {
1637            summary: HealthSummary {
1638                files_analyzed: 10,
1639                functions_analyzed: 50,
1640                ..Default::default()
1641            },
1642            coverage_gaps: Some(CoverageGaps {
1643                summary: CoverageGapSummary {
1644                    runtime_files: 2,
1645                    covered_files: 0,
1646                    file_coverage_pct: 0.0,
1647                    untested_files: 1,
1648                    untested_exports: 1,
1649                },
1650                files: vec![UntestedFile {
1651                    path: root.join("src/app.ts"),
1652                    value_export_count: 2,
1653                }],
1654                exports: vec![UntestedExport {
1655                    path: root.join("src/app.ts"),
1656                    export_name: "loader".into(),
1657                    line: 12,
1658                    col: 4,
1659                }],
1660            }),
1661            ..Default::default()
1662        };
1663
1664        let output = build_health_codeclimate(&report, &root);
1665        let issues = output.as_array().unwrap();
1666        assert_eq!(issues.len(), 2);
1667        assert_eq!(issues[0]["check_name"], "fallow/untested-file");
1668        assert_eq!(issues[0]["categories"][0], "Coverage");
1669        assert_eq!(issues[0]["location"]["path"], "src/app.ts");
1670        assert_eq!(issues[1]["check_name"], "fallow/untested-export");
1671        assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
1672        assert!(
1673            issues[1]["description"]
1674                .as_str()
1675                .unwrap()
1676                .contains("loader")
1677        );
1678    }
1679
1680    #[test]
1681    fn health_codeclimate_crap_only_uses_crap_check_name() {
1682        use crate::health_types::{FindingSeverity, HealthFinding, HealthReport, HealthSummary};
1683        let root = PathBuf::from("/project");
1684        let report = HealthReport {
1685            findings: vec![HealthFinding {
1686                path: root.join("src/untested.ts"),
1687                name: "risky".to_string(),
1688                line: 7,
1689                col: 0,
1690                cyclomatic: 10,
1691                cognitive: 10,
1692                line_count: 20,
1693                param_count: 1,
1694                exceeded: crate::health_types::ExceededThreshold::Crap,
1695                severity: FindingSeverity::High,
1696                crap: Some(60.0),
1697                coverage_pct: Some(25.0),
1698                coverage_tier: None,
1699            }],
1700            summary: HealthSummary {
1701                functions_analyzed: 10,
1702                functions_above_threshold: 1,
1703                ..Default::default()
1704            },
1705            ..Default::default()
1706        };
1707        let json = build_health_codeclimate(&report, &root);
1708        let issues = json.as_array().unwrap();
1709        assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
1710        assert_eq!(issues[0]["severity"], "major");
1711        let description = issues[0]["description"].as_str().unwrap();
1712        assert!(description.contains("CRAP score"), "desc: {description}");
1713        assert!(description.contains("coverage 25%"), "desc: {description}");
1714    }
1715}