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