Skip to main content

fallow_cli/report/
codeclimate.rs

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