Skip to main content

fallow_cli/report/
codeclimate.rs

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