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]
106pub fn build_codeclimate(
107    results: &AnalysisResults,
108    root: &Path,
109    rules: &RulesConfig,
110) -> serde_json::Value {
111    let mut issues = Vec::new();
112
113    // Unused files
114    let level = severity_to_codeclimate(rules.unused_files);
115    for file in &results.unused_files {
116        let path = cc_path(&file.path, root);
117        let fp = fingerprint_hash(&["fallow/unused-file", &path]);
118        issues.push(cc_issue(
119            "fallow/unused-file",
120            "File is not reachable from any entry point",
121            level,
122            "Bug Risk",
123            &path,
124            None,
125            &fp,
126        ));
127    }
128
129    // Unused exports
130    let level = severity_to_codeclimate(rules.unused_exports);
131    for export in &results.unused_exports {
132        let path = cc_path(&export.path, root);
133        let kind = if export.is_re_export {
134            "Re-export"
135        } else {
136            "Export"
137        };
138        let line_str = export.line.to_string();
139        let fp = fingerprint_hash(&[
140            "fallow/unused-export",
141            &path,
142            &line_str,
143            &export.export_name,
144        ]);
145        issues.push(cc_issue(
146            "fallow/unused-export",
147            &format!(
148                "{kind} '{}' is never imported by other modules",
149                export.export_name
150            ),
151            level,
152            "Bug Risk",
153            &path,
154            Some(export.line),
155            &fp,
156        ));
157    }
158
159    // Unused types
160    let level = severity_to_codeclimate(rules.unused_types);
161    for export in &results.unused_types {
162        let path = cc_path(&export.path, root);
163        let kind = if export.is_re_export {
164            "Type re-export"
165        } else {
166            "Type export"
167        };
168        let line_str = export.line.to_string();
169        let fp = fingerprint_hash(&["fallow/unused-type", &path, &line_str, &export.export_name]);
170        issues.push(cc_issue(
171            "fallow/unused-type",
172            &format!(
173                "{kind} '{}' is never imported by other modules",
174                export.export_name
175            ),
176            level,
177            "Bug Risk",
178            &path,
179            Some(export.line),
180            &fp,
181        ));
182    }
183
184    // Unused dependencies
185    push_dep_cc_issues(
186        &mut issues,
187        &results.unused_dependencies,
188        root,
189        "fallow/unused-dependency",
190        "dependencies",
191        rules.unused_dependencies,
192    );
193    push_dep_cc_issues(
194        &mut issues,
195        &results.unused_dev_dependencies,
196        root,
197        "fallow/unused-dev-dependency",
198        "devDependencies",
199        rules.unused_dev_dependencies,
200    );
201    push_dep_cc_issues(
202        &mut issues,
203        &results.unused_optional_dependencies,
204        root,
205        "fallow/unused-optional-dependency",
206        "optionalDependencies",
207        rules.unused_optional_dependencies,
208    );
209
210    // Type-only dependencies
211    let level = severity_to_codeclimate(rules.type_only_dependencies);
212    for dep in &results.type_only_dependencies {
213        let path = cc_path(&dep.path, root);
214        let line = if dep.line > 0 { Some(dep.line) } else { None };
215        let fp = fingerprint_hash(&["fallow/type-only-dependency", &dep.package_name]);
216        issues.push(cc_issue(
217            "fallow/type-only-dependency",
218            &format!(
219                "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
220                dep.package_name
221            ),
222            level,
223            "Bug Risk",
224            &path,
225            line,
226            &fp,
227        ));
228    }
229
230    // Test-only dependencies
231    let level = severity_to_codeclimate(rules.test_only_dependencies);
232    for dep in &results.test_only_dependencies {
233        let path = cc_path(&dep.path, root);
234        let line = if dep.line > 0 { Some(dep.line) } else { None };
235        let fp = fingerprint_hash(&["fallow/test-only-dependency", &dep.package_name]);
236        issues.push(cc_issue(
237            "fallow/test-only-dependency",
238            &format!(
239                "Package '{}' is only imported by test files (consider moving to devDependencies)",
240                dep.package_name
241            ),
242            level,
243            "Bug Risk",
244            &path,
245            line,
246            &fp,
247        ));
248    }
249
250    // Unused enum members
251    let level = severity_to_codeclimate(rules.unused_enum_members);
252    for member in &results.unused_enum_members {
253        let path = cc_path(&member.path, root);
254        let line_str = member.line.to_string();
255        let fp = fingerprint_hash(&[
256            "fallow/unused-enum-member",
257            &path,
258            &line_str,
259            &member.parent_name,
260            &member.member_name,
261        ]);
262        issues.push(cc_issue(
263            "fallow/unused-enum-member",
264            &format!(
265                "Enum member '{}.{}' is never referenced",
266                member.parent_name, member.member_name
267            ),
268            level,
269            "Bug Risk",
270            &path,
271            Some(member.line),
272            &fp,
273        ));
274    }
275
276    // Unused class members
277    let level = severity_to_codeclimate(rules.unused_class_members);
278    for member in &results.unused_class_members {
279        let path = cc_path(&member.path, root);
280        let line_str = member.line.to_string();
281        let fp = fingerprint_hash(&[
282            "fallow/unused-class-member",
283            &path,
284            &line_str,
285            &member.parent_name,
286            &member.member_name,
287        ]);
288        issues.push(cc_issue(
289            "fallow/unused-class-member",
290            &format!(
291                "Class member '{}.{}' is never referenced",
292                member.parent_name, member.member_name
293            ),
294            level,
295            "Bug Risk",
296            &path,
297            Some(member.line),
298            &fp,
299        ));
300    }
301
302    // Unresolved imports
303    let level = severity_to_codeclimate(rules.unresolved_imports);
304    for import in &results.unresolved_imports {
305        let path = cc_path(&import.path, root);
306        let line_str = import.line.to_string();
307        let fp = fingerprint_hash(&[
308            "fallow/unresolved-import",
309            &path,
310            &line_str,
311            &import.specifier,
312        ]);
313        issues.push(cc_issue(
314            "fallow/unresolved-import",
315            &format!("Import '{}' could not be resolved", import.specifier),
316            level,
317            "Bug Risk",
318            &path,
319            Some(import.line),
320            &fp,
321        ));
322    }
323
324    // Unlisted dependencies — one issue per import site
325    let level = severity_to_codeclimate(rules.unlisted_dependencies);
326    for dep in &results.unlisted_dependencies {
327        for site in &dep.imported_from {
328            let path = cc_path(&site.path, root);
329            let line_str = site.line.to_string();
330            let fp = fingerprint_hash(&[
331                "fallow/unlisted-dependency",
332                &path,
333                &line_str,
334                &dep.package_name,
335            ]);
336            issues.push(cc_issue(
337                "fallow/unlisted-dependency",
338                &format!(
339                    "Package '{}' is imported but not listed in package.json",
340                    dep.package_name
341                ),
342                level,
343                "Bug Risk",
344                &path,
345                Some(site.line),
346                &fp,
347            ));
348        }
349    }
350
351    // Duplicate exports — one issue per location
352    let level = severity_to_codeclimate(rules.duplicate_exports);
353    for dup in &results.duplicate_exports {
354        for loc in &dup.locations {
355            let path = cc_path(&loc.path, root);
356            let line_str = loc.line.to_string();
357            let fp = fingerprint_hash(&[
358                "fallow/duplicate-export",
359                &path,
360                &line_str,
361                &dup.export_name,
362            ]);
363            issues.push(cc_issue(
364                "fallow/duplicate-export",
365                &format!("Export '{}' appears in multiple modules", dup.export_name),
366                level,
367                "Bug Risk",
368                &path,
369                Some(loc.line),
370                &fp,
371            ));
372        }
373    }
374
375    // Circular dependencies
376    let level = severity_to_codeclimate(rules.circular_dependencies);
377    for cycle in &results.circular_dependencies {
378        let Some(first) = cycle.files.first() else {
379            continue;
380        };
381        let path = cc_path(first, root);
382        let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
383        let chain_str = chain.join(":");
384        let fp = fingerprint_hash(&["fallow/circular-dependency", &chain_str]);
385        let line = if cycle.line > 0 {
386            Some(cycle.line)
387        } else {
388            None
389        };
390        issues.push(cc_issue(
391            "fallow/circular-dependency",
392            &format!(
393                "Circular dependency{}: {}",
394                if cycle.is_cross_package {
395                    " (cross-package)"
396                } else {
397                    ""
398                },
399                chain.join(" \u{2192} ")
400            ),
401            level,
402            "Bug Risk",
403            &path,
404            line,
405            &fp,
406        ));
407    }
408
409    // Boundary violations
410    let level = severity_to_codeclimate(rules.boundary_violation);
411    for v in &results.boundary_violations {
412        let path = cc_path(&v.from_path, root);
413        let to = cc_path(&v.to_path, root);
414        let fp = fingerprint_hash(&["fallow/boundary-violation", &path, &to]);
415        let line = if v.line > 0 { Some(v.line) } else { None };
416        issues.push(cc_issue(
417            "fallow/boundary-violation",
418            &format!(
419                "Boundary violation: {} -> {} ({} -> {})",
420                path, to, v.from_zone, v.to_zone
421            ),
422            level,
423            "Bug Risk",
424            &path,
425            line,
426            &fp,
427        ));
428    }
429
430    serde_json::Value::Array(issues)
431}
432
433/// Print dead-code analysis results in CodeClimate format.
434pub(super) fn print_codeclimate(
435    results: &AnalysisResults,
436    root: &Path,
437    rules: &RulesConfig,
438) -> ExitCode {
439    let value = build_codeclimate(results, root, rules);
440    emit_json(&value, "CodeClimate")
441}
442
443/// Print CodeClimate output with owner properties added to each issue.
444///
445/// Calls `build_codeclimate` to produce the standard CodeClimate JSON array,
446/// then post-processes each entry to add `"owner": "@team"` by resolving the
447/// issue's location path through the `OwnershipResolver`.
448pub(super) fn print_grouped_codeclimate(
449    results: &AnalysisResults,
450    root: &Path,
451    rules: &RulesConfig,
452    resolver: &OwnershipResolver,
453) {
454    let mut value = build_codeclimate(results, root, rules);
455
456    if let Some(issues) = value.as_array_mut() {
457        for issue in issues {
458            let path = issue
459                .pointer("/location/path")
460                .and_then(|v| v.as_str())
461                .unwrap_or("");
462            let owner = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
463            issue
464                .as_object_mut()
465                .expect("CodeClimate issue should be an object")
466                .insert("owner".to_string(), serde_json::Value::String(owner));
467        }
468    }
469
470    let _ = emit_json(&value, "CodeClimate");
471}
472
473/// Compute graduated severity for health findings based on threshold ratio.
474///
475/// - 1.0×–1.5× threshold → minor
476/// - 1.5×–2.5× threshold → major
477/// - >2.5× threshold → critical
478fn health_severity(value: u16, threshold: u16) -> &'static str {
479    if threshold == 0 {
480        return "minor";
481    }
482    let ratio = f64::from(value) / f64::from(threshold);
483    if ratio > 2.5 {
484        "critical"
485    } else if ratio > 1.5 {
486        "major"
487    } else {
488        "minor"
489    }
490}
491
492/// Build CodeClimate JSON array from health/complexity analysis results.
493#[must_use]
494pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> serde_json::Value {
495    let mut issues = Vec::new();
496
497    let cyc_t = report.summary.max_cyclomatic_threshold;
498    let cog_t = report.summary.max_cognitive_threshold;
499
500    for finding in &report.findings {
501        let path = cc_path(&finding.path, root);
502        let description = match finding.exceeded {
503            ExceededThreshold::Both => format!(
504                "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
505                finding.name, finding.cyclomatic, cyc_t, finding.cognitive, cog_t
506            ),
507            ExceededThreshold::Cyclomatic => format!(
508                "'{}' has cyclomatic complexity {} (threshold: {})",
509                finding.name, finding.cyclomatic, cyc_t
510            ),
511            ExceededThreshold::Cognitive => format!(
512                "'{}' has cognitive complexity {} (threshold: {})",
513                finding.name, finding.cognitive, cog_t
514            ),
515        };
516        let check_name = match finding.exceeded {
517            ExceededThreshold::Both => "fallow/high-complexity",
518            ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
519            ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
520        };
521        // Graduate severity: use the worst exceeded metric
522        let severity = match finding.exceeded {
523            ExceededThreshold::Both => {
524                let cyc_sev = health_severity(finding.cyclomatic, cyc_t);
525                let cog_sev = health_severity(finding.cognitive, cog_t);
526                // Pick the more severe of the two
527                match (cyc_sev, cog_sev) {
528                    ("critical", _) | (_, "critical") => "critical",
529                    ("major", _) | (_, "major") => "major",
530                    _ => "minor",
531                }
532            }
533            ExceededThreshold::Cyclomatic => health_severity(finding.cyclomatic, cyc_t),
534            ExceededThreshold::Cognitive => health_severity(finding.cognitive, cog_t),
535        };
536        let line_str = finding.line.to_string();
537        let fp = fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
538        issues.push(cc_issue(
539            check_name,
540            &description,
541            severity,
542            "Complexity",
543            &path,
544            Some(finding.line),
545            &fp,
546        ));
547    }
548
549    if let Some(ref gaps) = report.coverage_gaps {
550        for item in &gaps.files {
551            let path = cc_path(&item.path, root);
552            let description = format!(
553                "File is runtime-reachable but has no test dependency path ({} value export{})",
554                item.value_export_count,
555                if item.value_export_count == 1 {
556                    ""
557                } else {
558                    "s"
559                },
560            );
561            let fp = fingerprint_hash(&["fallow/untested-file", &path]);
562            issues.push(cc_issue(
563                "fallow/untested-file",
564                &description,
565                "minor",
566                "Coverage",
567                &path,
568                None,
569                &fp,
570            ));
571        }
572
573        for item in &gaps.exports {
574            let path = cc_path(&item.path, root);
575            let description = format!(
576                "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
577                item.export_name
578            );
579            let line_str = item.line.to_string();
580            let fp = fingerprint_hash(&[
581                "fallow/untested-export",
582                &path,
583                &line_str,
584                &item.export_name,
585            ]);
586            issues.push(cc_issue(
587                "fallow/untested-export",
588                &description,
589                "minor",
590                "Coverage",
591                &path,
592                Some(item.line),
593                &fp,
594            ));
595        }
596    }
597
598    serde_json::Value::Array(issues)
599}
600
601/// Print health analysis results in CodeClimate format.
602pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
603    let value = build_health_codeclimate(report, root);
604    emit_json(&value, "CodeClimate")
605}
606
607/// Build CodeClimate JSON array from duplication analysis results.
608#[must_use]
609#[expect(
610    clippy::cast_possible_truncation,
611    reason = "line numbers are bounded by source size"
612)]
613pub fn build_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> serde_json::Value {
614    let mut issues = Vec::new();
615
616    for (i, group) in report.clone_groups.iter().enumerate() {
617        // Content-based fingerprint: hash token_count + line_count + first 64 chars of fragment
618        // This is stable across runs regardless of group ordering.
619        let token_str = group.token_count.to_string();
620        let line_count_str = group.line_count.to_string();
621        let fragment_prefix: String = group
622            .instances
623            .first()
624            .map(|inst| inst.fragment.chars().take(64).collect())
625            .unwrap_or_default();
626
627        for instance in &group.instances {
628            let path = cc_path(&instance.file, root);
629            let start_str = instance.start_line.to_string();
630            let fp = fingerprint_hash(&[
631                "fallow/code-duplication",
632                &path,
633                &start_str,
634                &token_str,
635                &line_count_str,
636                &fragment_prefix,
637            ]);
638            issues.push(cc_issue(
639                "fallow/code-duplication",
640                &format!(
641                    "Code clone group {} ({} lines, {} instances)",
642                    i + 1,
643                    group.line_count,
644                    group.instances.len()
645                ),
646                "minor",
647                "Duplication",
648                &path,
649                Some(instance.start_line as u32),
650                &fp,
651            ));
652        }
653    }
654
655    serde_json::Value::Array(issues)
656}
657
658/// Print duplication analysis results in CodeClimate format.
659pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
660    let value = build_duplication_codeclimate(report, root);
661    emit_json(&value, "CodeClimate")
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667    use crate::report::test_helpers::sample_results;
668    use fallow_config::RulesConfig;
669    use fallow_core::results::*;
670    use std::path::PathBuf;
671
672    #[test]
673    fn codeclimate_empty_results_produces_empty_array() {
674        let root = PathBuf::from("/project");
675        let results = AnalysisResults::default();
676        let rules = RulesConfig::default();
677        let output = build_codeclimate(&results, &root, &rules);
678        let arr = output.as_array().unwrap();
679        assert!(arr.is_empty());
680    }
681
682    #[test]
683    fn codeclimate_produces_array_of_issues() {
684        let root = PathBuf::from("/project");
685        let results = sample_results(&root);
686        let rules = RulesConfig::default();
687        let output = build_codeclimate(&results, &root, &rules);
688        assert!(output.is_array());
689        let arr = output.as_array().unwrap();
690        // Should have at least one issue per type
691        assert!(!arr.is_empty());
692    }
693
694    #[test]
695    fn codeclimate_issue_has_required_fields() {
696        let root = PathBuf::from("/project");
697        let mut results = AnalysisResults::default();
698        results.unused_files.push(UnusedFile {
699            path: root.join("src/dead.ts"),
700        });
701        let rules = RulesConfig::default();
702        let output = build_codeclimate(&results, &root, &rules);
703        let issue = &output.as_array().unwrap()[0];
704
705        assert_eq!(issue["type"], "issue");
706        assert_eq!(issue["check_name"], "fallow/unused-file");
707        assert!(issue["description"].is_string());
708        assert!(issue["categories"].is_array());
709        assert!(issue["severity"].is_string());
710        assert!(issue["fingerprint"].is_string());
711        assert!(issue["location"].is_object());
712        assert!(issue["location"]["path"].is_string());
713        assert!(issue["location"]["lines"].is_object());
714    }
715
716    #[test]
717    fn codeclimate_unused_file_severity_follows_rules() {
718        let root = PathBuf::from("/project");
719        let mut results = AnalysisResults::default();
720        results.unused_files.push(UnusedFile {
721            path: root.join("src/dead.ts"),
722        });
723
724        // Error severity -> major
725        let rules = RulesConfig::default();
726        let output = build_codeclimate(&results, &root, &rules);
727        assert_eq!(output[0]["severity"], "major");
728
729        // Warn severity -> minor
730        let rules = RulesConfig {
731            unused_files: Severity::Warn,
732            ..RulesConfig::default()
733        };
734        let output = build_codeclimate(&results, &root, &rules);
735        assert_eq!(output[0]["severity"], "minor");
736    }
737
738    #[test]
739    fn codeclimate_unused_export_has_line_number() {
740        let root = PathBuf::from("/project");
741        let mut results = AnalysisResults::default();
742        results.unused_exports.push(UnusedExport {
743            path: root.join("src/utils.ts"),
744            export_name: "helperFn".to_string(),
745            is_type_only: false,
746            line: 10,
747            col: 4,
748            span_start: 120,
749            is_re_export: false,
750        });
751        let rules = RulesConfig::default();
752        let output = build_codeclimate(&results, &root, &rules);
753        let issue = &output[0];
754        assert_eq!(issue["location"]["lines"]["begin"], 10);
755    }
756
757    #[test]
758    fn codeclimate_unused_file_line_defaults_to_1() {
759        let root = PathBuf::from("/project");
760        let mut results = AnalysisResults::default();
761        results.unused_files.push(UnusedFile {
762            path: root.join("src/dead.ts"),
763        });
764        let rules = RulesConfig::default();
765        let output = build_codeclimate(&results, &root, &rules);
766        let issue = &output[0];
767        assert_eq!(issue["location"]["lines"]["begin"], 1);
768    }
769
770    #[test]
771    fn codeclimate_paths_are_relative() {
772        let root = PathBuf::from("/project");
773        let mut results = AnalysisResults::default();
774        results.unused_files.push(UnusedFile {
775            path: root.join("src/deep/nested/file.ts"),
776        });
777        let rules = RulesConfig::default();
778        let output = build_codeclimate(&results, &root, &rules);
779        let path = output[0]["location"]["path"].as_str().unwrap();
780        assert_eq!(path, "src/deep/nested/file.ts");
781        assert!(!path.starts_with("/project"));
782    }
783
784    #[test]
785    fn codeclimate_re_export_label_in_description() {
786        let root = PathBuf::from("/project");
787        let mut results = AnalysisResults::default();
788        results.unused_exports.push(UnusedExport {
789            path: root.join("src/index.ts"),
790            export_name: "reExported".to_string(),
791            is_type_only: false,
792            line: 1,
793            col: 0,
794            span_start: 0,
795            is_re_export: true,
796        });
797        let rules = RulesConfig::default();
798        let output = build_codeclimate(&results, &root, &rules);
799        let desc = output[0]["description"].as_str().unwrap();
800        assert!(desc.contains("Re-export"));
801    }
802
803    #[test]
804    fn codeclimate_unlisted_dep_one_issue_per_import_site() {
805        let root = PathBuf::from("/project");
806        let mut results = AnalysisResults::default();
807        results.unlisted_dependencies.push(UnlistedDependency {
808            package_name: "chalk".to_string(),
809            imported_from: vec![
810                ImportSite {
811                    path: root.join("src/a.ts"),
812                    line: 1,
813                    col: 0,
814                },
815                ImportSite {
816                    path: root.join("src/b.ts"),
817                    line: 5,
818                    col: 0,
819                },
820            ],
821        });
822        let rules = RulesConfig::default();
823        let output = build_codeclimate(&results, &root, &rules);
824        let arr = output.as_array().unwrap();
825        assert_eq!(arr.len(), 2);
826        assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
827        assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
828    }
829
830    #[test]
831    fn codeclimate_duplicate_export_one_issue_per_location() {
832        let root = PathBuf::from("/project");
833        let mut results = AnalysisResults::default();
834        results.duplicate_exports.push(DuplicateExport {
835            export_name: "Config".to_string(),
836            locations: vec![
837                DuplicateLocation {
838                    path: root.join("src/a.ts"),
839                    line: 10,
840                    col: 0,
841                },
842                DuplicateLocation {
843                    path: root.join("src/b.ts"),
844                    line: 20,
845                    col: 0,
846                },
847                DuplicateLocation {
848                    path: root.join("src/c.ts"),
849                    line: 30,
850                    col: 0,
851                },
852            ],
853        });
854        let rules = RulesConfig::default();
855        let output = build_codeclimate(&results, &root, &rules);
856        let arr = output.as_array().unwrap();
857        assert_eq!(arr.len(), 3);
858    }
859
860    #[test]
861    fn codeclimate_circular_dep_emits_chain_in_description() {
862        let root = PathBuf::from("/project");
863        let mut results = AnalysisResults::default();
864        results.circular_dependencies.push(CircularDependency {
865            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
866            length: 2,
867            line: 3,
868            col: 0,
869            is_cross_package: false,
870        });
871        let rules = RulesConfig::default();
872        let output = build_codeclimate(&results, &root, &rules);
873        let desc = output[0]["description"].as_str().unwrap();
874        assert!(desc.contains("Circular dependency"));
875        assert!(desc.contains("src/a.ts"));
876        assert!(desc.contains("src/b.ts"));
877    }
878
879    #[test]
880    fn codeclimate_fingerprints_are_deterministic() {
881        let root = PathBuf::from("/project");
882        let results = sample_results(&root);
883        let rules = RulesConfig::default();
884        let output1 = build_codeclimate(&results, &root, &rules);
885        let output2 = build_codeclimate(&results, &root, &rules);
886
887        let fps1: Vec<&str> = output1
888            .as_array()
889            .unwrap()
890            .iter()
891            .map(|i| i["fingerprint"].as_str().unwrap())
892            .collect();
893        let fps2: Vec<&str> = output2
894            .as_array()
895            .unwrap()
896            .iter()
897            .map(|i| i["fingerprint"].as_str().unwrap())
898            .collect();
899        assert_eq!(fps1, fps2);
900    }
901
902    #[test]
903    fn codeclimate_fingerprints_are_unique() {
904        let root = PathBuf::from("/project");
905        let results = sample_results(&root);
906        let rules = RulesConfig::default();
907        let output = build_codeclimate(&results, &root, &rules);
908
909        let mut fps: Vec<&str> = output
910            .as_array()
911            .unwrap()
912            .iter()
913            .map(|i| i["fingerprint"].as_str().unwrap())
914            .collect();
915        let original_len = fps.len();
916        fps.sort_unstable();
917        fps.dedup();
918        assert_eq!(fps.len(), original_len, "fingerprints should be unique");
919    }
920
921    #[test]
922    fn codeclimate_type_only_dep_has_correct_check_name() {
923        let root = PathBuf::from("/project");
924        let mut results = AnalysisResults::default();
925        results.type_only_dependencies.push(TypeOnlyDependency {
926            package_name: "zod".to_string(),
927            path: root.join("package.json"),
928            line: 8,
929        });
930        let rules = RulesConfig::default();
931        let output = build_codeclimate(&results, &root, &rules);
932        assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
933        let desc = output[0]["description"].as_str().unwrap();
934        assert!(desc.contains("zod"));
935        assert!(desc.contains("type-only"));
936    }
937
938    #[test]
939    fn codeclimate_dep_with_zero_line_omits_line_number() {
940        let root = PathBuf::from("/project");
941        let mut results = AnalysisResults::default();
942        results.unused_dependencies.push(UnusedDependency {
943            package_name: "lodash".to_string(),
944            location: DependencyLocation::Dependencies,
945            path: root.join("package.json"),
946            line: 0,
947        });
948        let rules = RulesConfig::default();
949        let output = build_codeclimate(&results, &root, &rules);
950        // Line 0 -> begin defaults to 1
951        assert_eq!(output[0]["location"]["lines"]["begin"], 1);
952    }
953
954    // ── fingerprint_hash tests ─────────────────────────────────────
955
956    #[test]
957    fn fingerprint_hash_different_inputs_differ() {
958        let h1 = fingerprint_hash(&["a", "b"]);
959        let h2 = fingerprint_hash(&["a", "c"]);
960        assert_ne!(h1, h2);
961    }
962
963    #[test]
964    fn fingerprint_hash_order_matters() {
965        let h1 = fingerprint_hash(&["a", "b"]);
966        let h2 = fingerprint_hash(&["b", "a"]);
967        assert_ne!(h1, h2);
968    }
969
970    #[test]
971    fn fingerprint_hash_separator_prevents_collision() {
972        // "ab" + "c" should differ from "a" + "bc"
973        let h1 = fingerprint_hash(&["ab", "c"]);
974        let h2 = fingerprint_hash(&["a", "bc"]);
975        assert_ne!(h1, h2);
976    }
977
978    #[test]
979    fn fingerprint_hash_is_16_hex_chars() {
980        let h = fingerprint_hash(&["test"]);
981        assert_eq!(h.len(), 16);
982        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
983    }
984
985    // ── severity_to_codeclimate ─────────────────────────────────────
986
987    #[test]
988    fn severity_error_maps_to_major() {
989        assert_eq!(severity_to_codeclimate(Severity::Error), "major");
990    }
991
992    #[test]
993    fn severity_warn_maps_to_minor() {
994        assert_eq!(severity_to_codeclimate(Severity::Warn), "minor");
995    }
996
997    #[test]
998    fn severity_off_maps_to_minor() {
999        assert_eq!(severity_to_codeclimate(Severity::Off), "minor");
1000    }
1001
1002    // ── health_severity ─────────────────────────────────────────────
1003
1004    #[test]
1005    fn health_severity_zero_threshold_returns_minor() {
1006        assert_eq!(health_severity(100, 0), "minor");
1007    }
1008
1009    #[test]
1010    fn health_severity_at_threshold_returns_minor() {
1011        assert_eq!(health_severity(10, 10), "minor");
1012    }
1013
1014    #[test]
1015    fn health_severity_1_5x_threshold_returns_minor() {
1016        assert_eq!(health_severity(15, 10), "minor");
1017    }
1018
1019    #[test]
1020    fn health_severity_above_1_5x_returns_major() {
1021        assert_eq!(health_severity(16, 10), "major");
1022    }
1023
1024    #[test]
1025    fn health_severity_at_2_5x_returns_major() {
1026        assert_eq!(health_severity(25, 10), "major");
1027    }
1028
1029    #[test]
1030    fn health_severity_above_2_5x_returns_critical() {
1031        assert_eq!(health_severity(26, 10), "critical");
1032    }
1033
1034    #[test]
1035    fn health_codeclimate_includes_coverage_gaps() {
1036        use crate::health_types::*;
1037
1038        let root = PathBuf::from("/project");
1039        let report = HealthReport {
1040            findings: vec![],
1041            summary: HealthSummary {
1042                files_analyzed: 10,
1043                functions_analyzed: 50,
1044                functions_above_threshold: 0,
1045                max_cyclomatic_threshold: 20,
1046                max_cognitive_threshold: 15,
1047                files_scored: None,
1048                average_maintainability: None,
1049            },
1050            vital_signs: None,
1051            health_score: None,
1052            file_scores: vec![],
1053            coverage_gaps: Some(CoverageGaps {
1054                summary: CoverageGapSummary {
1055                    runtime_files: 2,
1056                    covered_files: 0,
1057                    file_coverage_pct: 0.0,
1058                    untested_files: 1,
1059                    untested_exports: 1,
1060                },
1061                files: vec![UntestedFile {
1062                    path: root.join("src/app.ts"),
1063                    value_export_count: 2,
1064                }],
1065                exports: vec![UntestedExport {
1066                    path: root.join("src/app.ts"),
1067                    export_name: "loader".into(),
1068                    line: 12,
1069                    col: 4,
1070                }],
1071            }),
1072            hotspots: vec![],
1073            hotspot_summary: None,
1074            targets: vec![],
1075            target_thresholds: None,
1076            health_trend: None,
1077        };
1078
1079        let output = build_health_codeclimate(&report, &root);
1080        let issues = output.as_array().unwrap();
1081        assert_eq!(issues.len(), 2);
1082        assert_eq!(issues[0]["check_name"], "fallow/untested-file");
1083        assert_eq!(issues[0]["categories"][0], "Coverage");
1084        assert_eq!(issues[0]["location"]["path"], "src/app.ts");
1085        assert_eq!(issues[1]["check_name"], "fallow/untested-export");
1086        assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
1087        assert!(
1088            issues[1]["description"]
1089                .as_str()
1090                .unwrap()
1091                .contains("loader")
1092        );
1093    }
1094}