Skip to main content

fallow_cli/report/
codeclimate.rs

1use std::path::Path;
2use std::process::ExitCode;
3
4use fallow_config::RulesConfig;
5#[cfg(test)]
6use fallow_config::Severity;
7use fallow_output::{
8    CodeClimateAnnotationField, CodeClimateIssue, HealthReport, annotate_codeclimate_issues,
9    codeclimate_issues_to_value,
10};
11#[cfg(test)]
12use fallow_output::{CodeClimateSeverity, codeclimate_fingerprint_hash};
13use fallow_types::duplicates::DuplicationReport;
14use fallow_types::results::AnalysisResults;
15
16use super::github::report_prefix;
17use super::grouping::{self, OwnershipResolver};
18use super::{emit_json, normalize_uri, relative_path};
19
20/// Rebase every issue path onto the repository root.
21///
22/// CI platforms address files by repo-root-relative path: GitLab's Code
23/// Quality widget matches `location.path` against the MR diff, and the review
24/// APIs reject a `new_path` that names no file in the diff. The CodeClimate
25/// builders emit analysis-root-relative paths, which only coincide with the
26/// repo root for a single-package repo.
27///
28/// The review and sticky-summary formats derive their paths from these issues,
29/// so rebasing here covers every CI surface but `github-annotations`, which
30/// renders from JSON and applies the same rebase itself.
31pub fn rebase_codeclimate_paths(issues: &mut [CodeClimateIssue]) {
32    let prefix = report_prefix();
33    if prefix.is_empty() {
34        return;
35    }
36    for issue in issues {
37        issue.location.path = fallow_output::apply_path_prefix(prefix, &issue.location.path);
38    }
39}
40
41/// Rebase, then serialize. The single point where CodeClimate issues leave as
42/// the CodeClimate wire format; everything upstream keeps analysis-root-relative
43/// paths so diff lookups and CODEOWNERS resolution stay in one namespace.
44fn emit_codeclimate(mut issues: Vec<CodeClimateIssue>) -> ExitCode {
45    rebase_codeclimate_paths(&mut issues);
46    let value = codeclimate_issues_to_value(&issues);
47    emit_json(&value, "CodeClimate")
48}
49
50/// Map fallow severity to CodeClimate severity.
51#[cfg(test)]
52fn severity_to_codeclimate(s: Severity) -> CodeClimateSeverity {
53    match s {
54        Severity::Error => CodeClimateSeverity::Major,
55        Severity::Warn => CodeClimateSeverity::Minor,
56        Severity::Off => unreachable!(),
57    }
58}
59
60/// Compute a relative path string with forward-slash normalization.
61///
62/// Uses `normalize_uri` to ensure forward slashes on all platforms
63/// and percent-encode brackets for Next.js dynamic routes.
64fn cc_path(path: &Path, root: &Path) -> String {
65    normalize_uri(&relative_path(path, root).display().to_string())
66}
67
68#[cfg(test)]
69fn fingerprint_hash(parts: &[&str]) -> String {
70    codeclimate_fingerprint_hash(parts)
71}
72
73#[cfg(test)]
74fn coverage_intelligence_check_name(
75    recommendation: fallow_output::CoverageIntelligenceRecommendation,
76) -> &'static str {
77    match recommendation {
78        fallow_output::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
79            "fallow/coverage-intelligence-risky-change"
80        }
81        fallow_output::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
82            "fallow/coverage-intelligence-delete"
83        }
84        fallow_output::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
85            "fallow/coverage-intelligence-review"
86        }
87        fallow_output::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
88            "fallow/coverage-intelligence-refactor"
89        }
90    }
91}
92
93#[cfg(test)]
94const fn health_finding_severity(severity: fallow_output::FindingSeverity) -> CodeClimateSeverity {
95    match severity {
96        fallow_output::FindingSeverity::Critical => CodeClimateSeverity::Critical,
97        fallow_output::FindingSeverity::High => CodeClimateSeverity::Major,
98        fallow_output::FindingSeverity::Moderate => CodeClimateSeverity::Minor,
99    }
100}
101
102#[cfg(test)]
103const fn runtime_coverage_check_name(
104    verdict: fallow_output::RuntimeCoverageVerdict,
105) -> &'static str {
106    match verdict {
107        fallow_output::RuntimeCoverageVerdict::SafeToDelete => "fallow/runtime-safe-to-delete",
108        fallow_output::RuntimeCoverageVerdict::ReviewRequired => "fallow/runtime-review-required",
109        fallow_output::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
110        fallow_output::RuntimeCoverageVerdict::CoverageUnavailable => {
111            "fallow/runtime-coverage-unavailable"
112        }
113        fallow_output::RuntimeCoverageVerdict::Active
114        | fallow_output::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
115    }
116}
117
118#[cfg(test)]
119const fn runtime_coverage_severity(
120    verdict: fallow_output::RuntimeCoverageVerdict,
121) -> CodeClimateSeverity {
122    match verdict {
123        fallow_output::RuntimeCoverageVerdict::SafeToDelete => CodeClimateSeverity::Critical,
124        fallow_output::RuntimeCoverageVerdict::ReviewRequired => CodeClimateSeverity::Major,
125        _ => CodeClimateSeverity::Minor,
126    }
127}
128
129#[cfg(test)]
130const fn coverage_intelligence_severity(
131    verdict: fallow_output::CoverageIntelligenceVerdict,
132) -> Option<CodeClimateSeverity> {
133    match verdict {
134        fallow_output::CoverageIntelligenceVerdict::RiskyChangeDetected
135        | fallow_output::CoverageIntelligenceVerdict::HighConfidenceDelete => {
136            Some(CodeClimateSeverity::Major)
137        }
138        fallow_output::CoverageIntelligenceVerdict::ReviewRequired
139        | fallow_output::CoverageIntelligenceVerdict::RefactorCarefully => {
140            Some(CodeClimateSeverity::Minor)
141        }
142        fallow_output::CoverageIntelligenceVerdict::Clean
143        | fallow_output::CoverageIntelligenceVerdict::Unknown => None,
144    }
145}
146
147/// Fetch CodeClimate issues from the API-owned dead-code output builder.
148///
149/// Returns the typed [`CodeClimateIssue`] vec; callers that emit the wire
150/// shape convert via [`fallow_output::codeclimate_issues_to_value`]. The schema
151/// drift gate locks the per-issue shape against
152/// [`fallow_output::CodeClimateOutput`].
153#[must_use]
154pub(super) fn api_codeclimate_issues(
155    results: &AnalysisResults,
156    root: &Path,
157    rules: &RulesConfig,
158) -> Vec<CodeClimateIssue> {
159    fallow_api::build_codeclimate(results, root, rules)
160}
161
162/// Print dead-code analysis results in CodeClimate format.
163pub(super) fn print_codeclimate(
164    results: &AnalysisResults,
165    root: &Path,
166    rules: &RulesConfig,
167) -> ExitCode {
168    emit_codeclimate(api_codeclimate_issues(results, root, rules))
169}
170
171/// Print CodeClimate output with owner properties added to each issue.
172///
173/// Calls `api_codeclimate_issues` to produce the standard CodeClimate issues, then
174/// annotates each entry with `"owner": "@team"` by resolving the
175/// issue's location path through the `OwnershipResolver`.
176pub(super) fn print_grouped_codeclimate(
177    results: &AnalysisResults,
178    root: &Path,
179    rules: &RulesConfig,
180    resolver: &OwnershipResolver,
181) -> ExitCode {
182    let mut issues = api_codeclimate_issues(results, root, rules);
183    annotate_codeclimate_issues(&mut issues, CodeClimateAnnotationField::Owner, |path| {
184        grouping::resolve_owner(Path::new(path), Path::new(""), resolver)
185    });
186    emit_codeclimate(issues)
187}
188
189/// Fetch CodeClimate issues from the API-owned health output builder.
190#[must_use]
191pub(super) fn api_health_codeclimate_issues(
192    report: &HealthReport,
193    root: &Path,
194) -> Vec<CodeClimateIssue> {
195    fallow_api::build_health_codeclimate(report, root)
196}
197
198/// Print health analysis results in CodeClimate format.
199pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
200    emit_codeclimate(api_health_codeclimate_issues(report, root))
201}
202
203/// Print health CodeClimate output with a per-issue `group` field.
204///
205/// Mirrors the dead-code grouped CodeClimate pattern
206/// (`print_grouped_codeclimate`): build the standard payload first, then
207/// post-process each issue to attach a `group` key derived from the
208/// `OwnershipResolver`. Lets GitLab Code Quality and other CodeClimate
209/// consumers partition findings per team / package without re-parsing the
210/// project structure.
211pub(super) fn print_grouped_health_codeclimate(
212    report: &HealthReport,
213    root: &Path,
214    resolver: &OwnershipResolver,
215) -> ExitCode {
216    let mut issues = api_health_codeclimate_issues(report, root);
217    annotate_codeclimate_issues(&mut issues, CodeClimateAnnotationField::Group, |path| {
218        grouping::resolve_owner(Path::new(path), Path::new(""), resolver)
219    });
220    emit_codeclimate(issues)
221}
222
223/// Fetch CodeClimate issues from the API-owned duplication output builder.
224#[must_use]
225pub(super) fn api_duplication_codeclimate_issues(
226    report: &DuplicationReport,
227    root: &Path,
228) -> Vec<CodeClimateIssue> {
229    fallow_api::build_duplication_codeclimate(report, root)
230}
231
232/// Print duplication analysis results in CodeClimate format.
233pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
234    emit_codeclimate(api_duplication_codeclimate_issues(report, root))
235}
236
237/// Print duplication CodeClimate output with a per-issue `group` field.
238///
239/// Mirrors [`print_grouped_health_codeclimate`]: each clone group is attributed
240/// to its largest owner ([`super::dupes_grouping::largest_owner`]) and every
241/// CodeClimate issue emitted for that clone group's instances carries the same
242/// top-level `group` key. Lets GitLab Code Quality and other CodeClimate
243/// consumers partition findings per team / package / directory without
244/// re-parsing the project structure.
245pub(super) fn print_grouped_duplication_codeclimate(
246    report: &DuplicationReport,
247    root: &Path,
248    resolver: &OwnershipResolver,
249) -> ExitCode {
250    let mut issues = api_duplication_codeclimate_issues(report, root);
251
252    use rustc_hash::FxHashMap;
253    let mut path_to_owner: FxHashMap<String, String> = FxHashMap::default();
254    for group in &report.clone_groups {
255        let owner = super::dupes_grouping::largest_owner(group, root, resolver);
256        for instance in &group.instances {
257            path_to_owner.insert(cc_path(&instance.file, root), owner.clone());
258        }
259    }
260
261    annotate_codeclimate_issues(&mut issues, CodeClimateAnnotationField::Group, |path| {
262        path_to_owner
263            .get(path)
264            .cloned()
265            .unwrap_or_else(|| crate::codeowners::UNOWNED_LABEL.to_string())
266    });
267    emit_codeclimate(issues)
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::report::test_helpers::sample_results;
274    use fallow_config::RulesConfig;
275    use fallow_types::output_dead_code::*;
276    use fallow_types::results::*;
277    use std::path::PathBuf;
278
279    /// Compute graduated severity for health findings based on threshold ratio.
280    /// Kept for unit test coverage of the original CodeClimate severity model.
281    fn health_severity(value: u16, threshold: u16) -> &'static str {
282        if threshold == 0 {
283            return "minor";
284        }
285        let ratio = f64::from(value) / f64::from(threshold);
286        if ratio > 2.5 {
287            "critical"
288        } else if ratio > 1.5 {
289            "major"
290        } else {
291            "minor"
292        }
293    }
294
295    #[test]
296    fn codeclimate_empty_results_produces_empty_array() {
297        let root = PathBuf::from("/project");
298        let results = AnalysisResults::default();
299        let rules = RulesConfig::default();
300        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
301        let arr = output.as_array().unwrap();
302        assert!(arr.is_empty());
303    }
304
305    #[test]
306    fn codeclimate_produces_array_of_issues() {
307        let root = PathBuf::from("/project");
308        let results = sample_results(&root);
309        let rules = RulesConfig::default();
310        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
311        assert!(output.is_array());
312        let arr = output.as_array().unwrap();
313        assert!(!arr.is_empty());
314    }
315
316    #[test]
317    fn codeclimate_missing_suppression_reason_uses_reason_rule_severity() {
318        let root = PathBuf::from("/project");
319        let mut results = AnalysisResults::default();
320        results.stale_suppressions.push(StaleSuppression {
321            path: root.join("src/file.ts"),
322            line: 1,
323            col: 0,
324            origin: SuppressionOrigin::Comment {
325                issue_kind: Some("unused-exports".to_string()),
326                reason: None,
327                is_file_level: false,
328                kind_known: true,
329            },
330            missing_reason: true,
331            actions: StaleSuppression::actions_for(true),
332        });
333        let rules = RulesConfig {
334            stale_suppressions: Severity::Off,
335            require_suppression_reason: Severity::Error,
336            ..Default::default()
337        };
338
339        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
340
341        assert_eq!(output[0]["check_name"], "fallow/missing-suppression-reason");
342        assert_eq!(output[0]["severity"], "major");
343    }
344
345    #[test]
346    fn codeclimate_stale_and_missing_suppression_have_distinct_identities() {
347        let root = PathBuf::from("/project");
348        let mut results = AnalysisResults::default();
349        let origin = SuppressionOrigin::Comment {
350            issue_kind: Some("unused-exports".to_string()),
351            reason: None,
352            is_file_level: false,
353            kind_known: true,
354        };
355        results.stale_suppressions.push(StaleSuppression {
356            path: root.join("src/file.ts"),
357            line: 1,
358            col: 0,
359            origin: origin.clone(),
360            missing_reason: false,
361            actions: StaleSuppression::actions_for(false),
362        });
363        results.stale_suppressions.push(StaleSuppression {
364            path: root.join("src/file.ts"),
365            line: 1,
366            col: 0,
367            origin,
368            missing_reason: true,
369            actions: StaleSuppression::actions_for(true),
370        });
371        let rules = RulesConfig {
372            stale_suppressions: Severity::Warn,
373            require_suppression_reason: Severity::Error,
374            ..Default::default()
375        };
376
377        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
378
379        assert_eq!(output[0]["check_name"], "fallow/stale-suppression");
380        assert_eq!(output[1]["check_name"], "fallow/missing-suppression-reason");
381        assert_ne!(output[0]["fingerprint"], output[1]["fingerprint"]);
382    }
383
384    #[test]
385    fn codeclimate_issue_has_required_fields() {
386        let root = PathBuf::from("/project");
387        let mut results = AnalysisResults::default();
388        results
389            .unused_files
390            .push(UnusedFileFinding::with_actions(UnusedFile {
391                path: root.join("src/dead.ts"),
392            }));
393        let rules = RulesConfig::default();
394        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
395        let issue = &output.as_array().unwrap()[0];
396
397        assert_eq!(issue["type"], "issue");
398        assert_eq!(issue["check_name"], "fallow/unused-file");
399        assert!(issue["description"].is_string());
400        assert!(issue["categories"].is_array());
401        assert!(issue["severity"].is_string());
402        assert!(issue["fingerprint"].is_string());
403        assert!(issue["location"].is_object());
404        assert!(issue["location"]["path"].is_string());
405        assert!(issue["location"]["lines"].is_object());
406    }
407
408    #[test]
409    fn codeclimate_unused_file_severity_follows_rules() {
410        let root = PathBuf::from("/project");
411        let mut results = AnalysisResults::default();
412        results
413            .unused_files
414            .push(UnusedFileFinding::with_actions(UnusedFile {
415                path: root.join("src/dead.ts"),
416            }));
417
418        let rules = RulesConfig::default();
419        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
420        assert_eq!(output[0]["severity"], "major");
421
422        let rules = RulesConfig {
423            unused_files: Severity::Warn,
424            ..RulesConfig::default()
425        };
426        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
427        assert_eq!(output[0]["severity"], "minor");
428    }
429
430    #[test]
431    fn codeclimate_unused_export_has_line_number() {
432        let root = PathBuf::from("/project");
433        let mut results = AnalysisResults::default();
434        results
435            .unused_exports
436            .push(UnusedExportFinding::with_actions(UnusedExport {
437                path: root.join("src/utils.ts"),
438                export_name: "helperFn".to_string(),
439                is_type_only: false,
440                line: 10,
441                col: 4,
442                span_start: 120,
443                is_re_export: false,
444            }));
445        let rules = RulesConfig::default();
446        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
447        let issue = &output[0];
448        assert_eq!(issue["location"]["lines"]["begin"], 10);
449    }
450
451    #[test]
452    fn codeclimate_unused_file_line_defaults_to_1() {
453        let root = PathBuf::from("/project");
454        let mut results = AnalysisResults::default();
455        results
456            .unused_files
457            .push(UnusedFileFinding::with_actions(UnusedFile {
458                path: root.join("src/dead.ts"),
459            }));
460        let rules = RulesConfig::default();
461        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
462        let issue = &output[0];
463        assert_eq!(issue["location"]["lines"]["begin"], 1);
464    }
465
466    #[test]
467    fn codeclimate_paths_are_relative() {
468        let root = PathBuf::from("/project");
469        let mut results = AnalysisResults::default();
470        results
471            .unused_files
472            .push(UnusedFileFinding::with_actions(UnusedFile {
473                path: root.join("src/deep/nested/file.ts"),
474            }));
475        let rules = RulesConfig::default();
476        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
477        let path = output[0]["location"]["path"].as_str().unwrap();
478        assert_eq!(path, "src/deep/nested/file.ts");
479        assert!(!path.starts_with("/project"));
480    }
481
482    #[test]
483    fn codeclimate_re_export_label_in_description() {
484        let root = PathBuf::from("/project");
485        let mut results = AnalysisResults::default();
486        results
487            .unused_exports
488            .push(UnusedExportFinding::with_actions(UnusedExport {
489                path: root.join("src/index.ts"),
490                export_name: "reExported".to_string(),
491                is_type_only: false,
492                line: 1,
493                col: 0,
494                span_start: 0,
495                is_re_export: true,
496            }));
497        let rules = RulesConfig::default();
498        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
499        let desc = output[0]["description"].as_str().unwrap();
500        assert!(desc.contains("Re-export"));
501    }
502
503    #[test]
504    fn codeclimate_unlisted_dep_one_issue_per_import_site() {
505        let root = PathBuf::from("/project");
506        let mut results = AnalysisResults::default();
507        results
508            .unlisted_dependencies
509            .push(UnlistedDependencyFinding::with_actions(
510                UnlistedDependency {
511                    package_name: "chalk".to_string(),
512                    imported_from: vec![
513                        ImportSite {
514                            path: root.join("src/a.ts"),
515                            line: 1,
516                            col: 0,
517                        },
518                        ImportSite {
519                            path: root.join("src/b.ts"),
520                            line: 5,
521                            col: 0,
522                        },
523                    ],
524                },
525            ));
526        let rules = RulesConfig::default();
527        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
528        let arr = output.as_array().unwrap();
529        assert_eq!(arr.len(), 2);
530        assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
531        assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
532    }
533
534    #[test]
535    fn codeclimate_duplicate_export_one_issue_per_location() {
536        let root = PathBuf::from("/project");
537        let mut results = AnalysisResults::default();
538        results
539            .duplicate_exports
540            .push(DuplicateExportFinding::with_actions(DuplicateExport {
541                export_name: "Config".to_string(),
542                locations: vec![
543                    DuplicateLocation {
544                        path: root.join("src/a.ts"),
545                        line: 10,
546                        col: 0,
547                    },
548                    DuplicateLocation {
549                        path: root.join("src/b.ts"),
550                        line: 20,
551                        col: 0,
552                    },
553                    DuplicateLocation {
554                        path: root.join("src/c.ts"),
555                        line: 30,
556                        col: 0,
557                    },
558                ],
559            }));
560        let rules = RulesConfig::default();
561        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
562        let arr = output.as_array().unwrap();
563        assert_eq!(arr.len(), 3);
564    }
565
566    #[test]
567    fn codeclimate_circular_dep_emits_chain_in_description() {
568        let root = PathBuf::from("/project");
569        let mut results = AnalysisResults::default();
570        results
571            .circular_dependencies
572            .push(CircularDependencyFinding::with_actions(
573                CircularDependency {
574                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
575                    length: 2,
576                    line: 3,
577                    col: 0,
578                    edges: Vec::new(),
579                    is_cross_package: false,
580                },
581            ));
582        let rules = RulesConfig::default();
583        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
584        let desc = output[0]["description"].as_str().unwrap();
585        assert!(desc.contains("Circular dependency"));
586        assert!(desc.contains("src/a.ts"));
587        assert!(desc.contains("src/b.ts"));
588    }
589
590    #[test]
591    fn codeclimate_fingerprints_are_deterministic() {
592        let root = PathBuf::from("/project");
593        let results = sample_results(&root);
594        let rules = RulesConfig::default();
595        let output1 = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
596        let output2 = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
597
598        let fps1: Vec<&str> = output1
599            .as_array()
600            .unwrap()
601            .iter()
602            .map(|i| i["fingerprint"].as_str().unwrap())
603            .collect();
604        let fps2: Vec<&str> = output2
605            .as_array()
606            .unwrap()
607            .iter()
608            .map(|i| i["fingerprint"].as_str().unwrap())
609            .collect();
610        assert_eq!(fps1, fps2);
611    }
612
613    #[test]
614    fn codeclimate_fingerprints_are_unique() {
615        let root = PathBuf::from("/project");
616        let results = sample_results(&root);
617        let rules = RulesConfig::default();
618        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
619
620        let mut fps: Vec<&str> = output
621            .as_array()
622            .unwrap()
623            .iter()
624            .map(|i| i["fingerprint"].as_str().unwrap())
625            .collect();
626        let original_len = fps.len();
627        fps.sort_unstable();
628        fps.dedup();
629        assert_eq!(fps.len(), original_len, "fingerprints should be unique");
630    }
631
632    #[test]
633    fn codeclimate_type_only_dep_has_correct_check_name() {
634        let root = PathBuf::from("/project");
635        let mut results = AnalysisResults::default();
636        results
637            .type_only_dependencies
638            .push(TypeOnlyDependencyFinding::with_actions(
639                TypeOnlyDependency {
640                    package_name: "zod".to_string(),
641                    path: root.join("package.json"),
642                    line: 8,
643                },
644            ));
645        let rules = RulesConfig::default();
646        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
647        assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
648        let desc = output[0]["description"].as_str().unwrap();
649        assert!(desc.contains("zod"));
650        assert!(desc.contains("type-only"));
651    }
652
653    #[test]
654    fn codeclimate_dep_with_zero_line_omits_line_number() {
655        let root = PathBuf::from("/project");
656        let mut results = AnalysisResults::default();
657        results
658            .unused_dependencies
659            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
660                package_name: "lodash".to_string(),
661                location: DependencyLocation::Dependencies,
662                path: root.join("package.json"),
663                line: 0,
664                used_in_workspaces: Vec::new(),
665            }));
666        let rules = RulesConfig::default();
667        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
668        assert_eq!(output[0]["location"]["lines"]["begin"], 1);
669    }
670
671    #[test]
672    fn fingerprint_hash_different_inputs_differ() {
673        let h1 = fingerprint_hash(&["a", "b"]);
674        let h2 = fingerprint_hash(&["a", "c"]);
675        assert_ne!(h1, h2);
676    }
677
678    #[test]
679    fn fingerprint_hash_order_matters() {
680        let h1 = fingerprint_hash(&["a", "b"]);
681        let h2 = fingerprint_hash(&["b", "a"]);
682        assert_ne!(h1, h2);
683    }
684
685    #[test]
686    fn fingerprint_hash_separator_prevents_collision() {
687        let h1 = fingerprint_hash(&["ab", "c"]);
688        let h2 = fingerprint_hash(&["a", "bc"]);
689        assert_ne!(h1, h2);
690    }
691
692    #[test]
693    fn fingerprint_hash_is_16_hex_chars() {
694        let h = fingerprint_hash(&["test"]);
695        assert_eq!(h.len(), 16);
696        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
697    }
698
699    #[test]
700    fn severity_error_maps_to_major() {
701        assert_eq!(
702            severity_to_codeclimate(Severity::Error),
703            CodeClimateSeverity::Major
704        );
705    }
706
707    #[test]
708    fn severity_warn_maps_to_minor() {
709        assert_eq!(
710            severity_to_codeclimate(Severity::Warn),
711            CodeClimateSeverity::Minor
712        );
713    }
714
715    #[test]
716    #[should_panic(expected = "internal error: entered unreachable code")]
717    fn severity_off_is_unreachable() {
718        let _ = severity_to_codeclimate(Severity::Off);
719    }
720
721    /// Production-mode regression: rules can flip to `Severity::Off` while
722    /// the matching findings slice arrives empty (the analyzer's own off-
723    /// rule short-circuit clears the vec, but the generic-iterator helpers
724    /// in `codeclimate.rs` previously called `severity_to_codeclimate`
725    /// before checking emptiness and panicked at `Severity::Off`).
726    /// `fallow dead-code --format codeclimate --production` on any project
727    /// with a `--production`-suppressed dep / export / member rule used to
728    /// exit 101 with `entered unreachable code` at `ci/severity.rs:28`.
729    /// This test exercises all three previously-vulnerable helpers
730    /// (`push_dep_cc_issues`, `push_unused_export_issues`,
731    /// `push_unused_member_issues`) through `api_codeclimate_issues`.
732    #[test]
733    fn api_codeclimate_issues_with_off_severity_and_empty_findings_does_not_panic() {
734        let root = PathBuf::from("/project");
735        let results = AnalysisResults::default();
736        let rules = RulesConfig {
737            unused_dependencies: Severity::Off,
738            unused_dev_dependencies: Severity::Off,
739            unused_optional_dependencies: Severity::Off,
740            unused_exports: Severity::Off,
741            unused_types: Severity::Off,
742            unused_enum_members: Severity::Off,
743            unused_class_members: Severity::Off,
744            ..RulesConfig::default()
745        };
746        let issues = api_codeclimate_issues(&results, &root, &rules);
747        assert!(issues.is_empty());
748    }
749
750    #[test]
751    fn health_severity_zero_threshold_returns_minor() {
752        assert_eq!(health_severity(100, 0), "minor");
753    }
754
755    #[test]
756    fn health_severity_at_threshold_returns_minor() {
757        assert_eq!(health_severity(10, 10), "minor");
758    }
759
760    #[test]
761    fn health_severity_1_5x_threshold_returns_minor() {
762        assert_eq!(health_severity(15, 10), "minor");
763    }
764
765    #[test]
766    fn health_severity_above_1_5x_returns_major() {
767        assert_eq!(health_severity(16, 10), "major");
768    }
769
770    #[test]
771    fn health_severity_at_2_5x_returns_major() {
772        assert_eq!(health_severity(25, 10), "major");
773    }
774
775    #[test]
776    fn health_severity_above_2_5x_returns_critical() {
777        assert_eq!(health_severity(26, 10), "critical");
778    }
779
780    #[test]
781    fn health_codeclimate_includes_coverage_gaps() {
782        use fallow_output::*;
783
784        let root = PathBuf::from("/project");
785        let report = HealthReport {
786            summary: HealthSummary {
787                files_analyzed: 10,
788                functions_analyzed: 50,
789                ..Default::default()
790            },
791            coverage_gaps: Some(CoverageGaps {
792                summary: CoverageGapSummary {
793                    runtime_files: 2,
794                    covered_files: 0,
795                    file_coverage_pct: 0.0,
796                    untested_files: 1,
797                    untested_exports: 1,
798                },
799                files: vec![UntestedFileFinding::with_actions(
800                    UntestedFile {
801                        path: root.join("src/app.ts"),
802                        value_export_count: 2,
803                    },
804                    &root,
805                )],
806                exports: vec![UntestedExportFinding::with_actions(
807                    UntestedExport {
808                        path: root.join("src/app.ts"),
809                        export_name: "loader".into(),
810                        line: 12,
811                        col: 4,
812                    },
813                    &root,
814                )],
815            }),
816            ..Default::default()
817        };
818
819        let output = codeclimate_issues_to_value(&api_health_codeclimate_issues(&report, &root));
820        let issues = output.as_array().unwrap();
821        assert_eq!(issues.len(), 2);
822        assert_eq!(issues[0]["check_name"], "fallow/untested-file");
823        assert_eq!(issues[0]["categories"][0], "Coverage");
824        assert_eq!(issues[0]["location"]["path"], "src/app.ts");
825        assert_eq!(issues[1]["check_name"], "fallow/untested-export");
826        assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
827        assert!(
828            issues[1]["description"]
829                .as_str()
830                .unwrap()
831                .contains("loader")
832        );
833    }
834
835    #[test]
836    fn health_codeclimate_includes_coverage_intelligence_issue() {
837        use fallow_output::{
838            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
839            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
840            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
841            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
842            CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
843            HealthReport, HealthSummary,
844        };
845
846        let root = PathBuf::from("/project");
847        let report = HealthReport {
848            summary: HealthSummary {
849                files_analyzed: 10,
850                functions_analyzed: 50,
851                ..Default::default()
852            },
853            coverage_intelligence: Some(CoverageIntelligenceReport {
854                schema_version: CoverageIntelligenceSchemaVersion::V1,
855                verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
856                summary: CoverageIntelligenceSummary {
857                    findings: 1,
858                    high_confidence_deletes: 1,
859                    ..Default::default()
860                },
861                findings: vec![CoverageIntelligenceFinding {
862                    id: "fallow:coverage-intel:abc123".to_owned(),
863                    path: root.join("src/dead.ts"),
864                    identity: Some("deadPath".to_owned()),
865                    line: 9,
866                    verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
867                    signals: vec![CoverageIntelligenceSignal::RuntimeCold],
868                    recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
869                    confidence: CoverageIntelligenceConfidence::High,
870                    related_ids: vec!["fallow:prod:deadbeef".to_owned()],
871                    evidence: CoverageIntelligenceEvidence {
872                        match_confidence: CoverageIntelligenceMatchConfidence::Direct,
873                        ..Default::default()
874                    },
875                    actions: vec![CoverageIntelligenceAction {
876                        kind: "delete-after-confirming-owner".to_owned(),
877                        description: "Confirm ownership".to_owned(),
878                        auto_fixable: false,
879                    }],
880                }],
881            }),
882            ..Default::default()
883        };
884
885        let output = codeclimate_issues_to_value(&api_health_codeclimate_issues(&report, &root));
886        let issues = output.as_array().unwrap();
887        assert_eq!(issues.len(), 1);
888        assert_eq!(
889            issues[0]["check_name"],
890            "fallow/coverage-intelligence-delete"
891        );
892        assert!(!issues[0]["fingerprint"].as_str().unwrap().is_empty());
893        assert_eq!(issues[0]["location"]["path"], "src/dead.ts");
894        assert!(
895            issues[0]["description"]
896                .as_str()
897                .unwrap()
898                .contains("deadPath")
899        );
900    }
901
902    #[test]
903    fn health_codeclimate_skips_summary_only_coverage_intelligence() {
904        use fallow_output::{
905            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
906            CoverageIntelligenceSummary, CoverageIntelligenceVerdict, HealthReport,
907        };
908
909        let root = PathBuf::from("/project");
910        let report = HealthReport {
911            coverage_intelligence: Some(CoverageIntelligenceReport {
912                schema_version: CoverageIntelligenceSchemaVersion::V1,
913                verdict: CoverageIntelligenceVerdict::Clean,
914                summary: CoverageIntelligenceSummary {
915                    skipped_ambiguous_matches: 2,
916                    ..Default::default()
917                },
918                findings: vec![],
919            }),
920            ..Default::default()
921        };
922
923        let issues = api_health_codeclimate_issues(&report, &root);
924        assert!(issues.is_empty());
925    }
926
927    #[test]
928    fn health_codeclimate_crap_only_uses_crap_check_name() {
929        use fallow_output::{ComplexityViolation, FindingSeverity, HealthReport, HealthSummary};
930        let root = PathBuf::from("/project");
931        let report = HealthReport {
932            findings: vec![
933                ComplexityViolation {
934                    path: root.join("src/untested.ts"),
935                    name: "risky".to_string(),
936                    line: 7,
937                    col: 0,
938                    cyclomatic: 10,
939                    cognitive: 10,
940                    line_count: 20,
941                    param_count: 1,
942                    react_hook_count: 0,
943                    react_jsx_max_depth: 0,
944                    react_prop_count: 0,
945                    react_hook_profile: None,
946                    exceeded: fallow_output::ExceededThreshold::Crap,
947                    severity: FindingSeverity::High,
948                    crap: Some(60.0),
949                    coverage_pct: Some(25.0),
950                    coverage_tier: None,
951                    coverage_source: None,
952                    inherited_from: None,
953                    component_rollup: None,
954                    contributions: Vec::new(),
955                    effective_thresholds: None,
956                    threshold_source: None,
957                }
958                .into(),
959            ],
960            summary: HealthSummary {
961                functions_analyzed: 10,
962                functions_above_threshold: 1,
963                ..Default::default()
964            },
965            ..Default::default()
966        };
967        let json = codeclimate_issues_to_value(&api_health_codeclimate_issues(&report, &root));
968        let issues = json.as_array().unwrap();
969        assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
970        assert_eq!(issues[0]["severity"], "major");
971        let description = issues[0]["description"].as_str().unwrap();
972        assert!(description.contains("CRAP score"), "desc: {description}");
973        assert!(description.contains("coverage 25%"), "desc: {description}");
974    }
975
976    // ---------------------------------------------------------------------------
977    // coverage_intelligence_check_name: all four recommendation arms
978    // ---------------------------------------------------------------------------
979
980    #[test]
981    fn coverage_intelligence_check_name_review_before_changing() {
982        use fallow_output::CoverageIntelligenceRecommendation;
983        assert_eq!(
984            coverage_intelligence_check_name(
985                CoverageIntelligenceRecommendation::ReviewBeforeChanging
986            ),
987            "fallow/coverage-intelligence-review"
988        );
989    }
990
991    #[test]
992    fn coverage_intelligence_check_name_refactor_carefully() {
993        use fallow_output::CoverageIntelligenceRecommendation;
994        assert_eq!(
995            coverage_intelligence_check_name(
996                CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior
997            ),
998            "fallow/coverage-intelligence-refactor"
999        );
1000    }
1001
1002    // ---------------------------------------------------------------------------
1003    // complexity description: Both / Cyclomatic / Cognitive / All thresholds
1004    // ---------------------------------------------------------------------------
1005
1006    #[test]
1007    fn complexity_description_both_threshold_exceeded() {
1008        use fallow_output::{ComplexityViolation, ExceededThreshold, FindingSeverity};
1009        let root = PathBuf::from("/project");
1010        let report = fallow_output::HealthReport {
1011            findings: vec![
1012                ComplexityViolation {
1013                    path: root.join("src/fn.ts"),
1014                    name: "bothFn".to_string(),
1015                    line: 1,
1016                    col: 0,
1017                    cyclomatic: 25,
1018                    cognitive: 20,
1019                    line_count: 50,
1020                    param_count: 2,
1021                    react_hook_count: 0,
1022                    react_jsx_max_depth: 0,
1023                    react_prop_count: 0,
1024                    react_hook_profile: None,
1025                    exceeded: ExceededThreshold::Both,
1026                    severity: FindingSeverity::High,
1027                    crap: None,
1028                    coverage_pct: None,
1029                    coverage_tier: None,
1030                    coverage_source: None,
1031                    inherited_from: None,
1032                    component_rollup: None,
1033                    contributions: vec![],
1034                    effective_thresholds: None,
1035                    threshold_source: None,
1036                }
1037                .into(),
1038            ],
1039            ..Default::default()
1040        };
1041        let json = codeclimate_issues_to_value(&api_health_codeclimate_issues(&report, &root));
1042        let issues = json.as_array().unwrap();
1043        assert_eq!(issues[0]["check_name"], "fallow/high-complexity");
1044        let desc = issues[0]["description"].as_str().unwrap();
1045        assert!(desc.contains("cyclomatic complexity"), "desc: {desc}");
1046        assert!(desc.contains("cognitive complexity"), "desc: {desc}");
1047    }
1048
1049    #[test]
1050    fn complexity_description_cyclomatic_only() {
1051        use fallow_output::{ComplexityViolation, ExceededThreshold, FindingSeverity};
1052        let root = PathBuf::from("/project");
1053        let report = fallow_output::HealthReport {
1054            findings: vec![
1055                ComplexityViolation {
1056                    path: root.join("src/fn.ts"),
1057                    name: "cycFn".to_string(),
1058                    line: 1,
1059                    col: 0,
1060                    cyclomatic: 25,
1061                    cognitive: 5,
1062                    line_count: 30,
1063                    param_count: 1,
1064                    react_hook_count: 0,
1065                    react_jsx_max_depth: 0,
1066                    react_prop_count: 0,
1067                    react_hook_profile: None,
1068                    exceeded: ExceededThreshold::Cyclomatic,
1069                    severity: FindingSeverity::High,
1070                    crap: None,
1071                    coverage_pct: None,
1072                    coverage_tier: None,
1073                    coverage_source: None,
1074                    inherited_from: None,
1075                    component_rollup: None,
1076                    contributions: vec![],
1077                    effective_thresholds: None,
1078                    threshold_source: None,
1079                }
1080                .into(),
1081            ],
1082            ..Default::default()
1083        };
1084        let json = codeclimate_issues_to_value(&api_health_codeclimate_issues(&report, &root));
1085        let issues = json.as_array().unwrap();
1086        assert_eq!(issues[0]["check_name"], "fallow/high-cyclomatic-complexity");
1087        let desc = issues[0]["description"].as_str().unwrap();
1088        assert!(desc.contains("cyclomatic complexity"), "desc: {desc}");
1089        assert!(
1090            !desc.contains("cognitive"),
1091            "desc should not mention cognitive: {desc}"
1092        );
1093    }
1094
1095    #[test]
1096    fn complexity_description_cognitive_only() {
1097        use fallow_output::{ComplexityViolation, ExceededThreshold, FindingSeverity};
1098        let root = PathBuf::from("/project");
1099        let report = fallow_output::HealthReport {
1100            findings: vec![
1101                ComplexityViolation {
1102                    path: root.join("src/fn.ts"),
1103                    name: "cogFn".to_string(),
1104                    line: 1,
1105                    col: 0,
1106                    cyclomatic: 5,
1107                    cognitive: 30,
1108                    line_count: 30,
1109                    param_count: 1,
1110                    react_hook_count: 0,
1111                    react_jsx_max_depth: 0,
1112                    react_prop_count: 0,
1113                    react_hook_profile: None,
1114                    exceeded: ExceededThreshold::Cognitive,
1115                    severity: FindingSeverity::High,
1116                    crap: None,
1117                    coverage_pct: None,
1118                    coverage_tier: None,
1119                    coverage_source: None,
1120                    inherited_from: None,
1121                    component_rollup: None,
1122                    contributions: vec![],
1123                    effective_thresholds: None,
1124                    threshold_source: None,
1125                }
1126                .into(),
1127            ],
1128            ..Default::default()
1129        };
1130        let json = codeclimate_issues_to_value(&api_health_codeclimate_issues(&report, &root));
1131        let issues = json.as_array().unwrap();
1132        assert_eq!(issues[0]["check_name"], "fallow/high-cognitive-complexity");
1133        let desc = issues[0]["description"].as_str().unwrap();
1134        assert!(desc.contains("cognitive complexity"), "desc: {desc}");
1135        assert!(!desc.contains("cyclomatic"), "desc: {desc}");
1136    }
1137
1138    #[test]
1139    fn complexity_description_all_threshold_crap_no_coverage() {
1140        use fallow_output::{ComplexityViolation, ExceededThreshold, FindingSeverity};
1141        let root = PathBuf::from("/project");
1142        let report = fallow_output::HealthReport {
1143            findings: vec![
1144                ComplexityViolation {
1145                    path: root.join("src/fn.ts"),
1146                    name: "allFn".to_string(),
1147                    line: 1,
1148                    col: 0,
1149                    cyclomatic: 30,
1150                    cognitive: 20,
1151                    line_count: 80,
1152                    param_count: 2,
1153                    react_hook_count: 0,
1154                    react_jsx_max_depth: 0,
1155                    react_prop_count: 0,
1156                    react_hook_profile: None,
1157                    exceeded: ExceededThreshold::All,
1158                    severity: FindingSeverity::Critical,
1159                    crap: Some(110.0),
1160                    coverage_pct: None,
1161                    coverage_tier: None,
1162                    coverage_source: None,
1163                    inherited_from: None,
1164                    component_rollup: None,
1165                    contributions: vec![],
1166                    effective_thresholds: None,
1167                    threshold_source: None,
1168                }
1169                .into(),
1170            ],
1171            ..Default::default()
1172        };
1173        let json = codeclimate_issues_to_value(&api_health_codeclimate_issues(&report, &root));
1174        let issues = json.as_array().unwrap();
1175        assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
1176        let desc = issues[0]["description"].as_str().unwrap();
1177        assert!(desc.contains("CRAP score"), "desc: {desc}");
1178        assert!(
1179            !desc.contains("coverage"),
1180            "no coverage pct should appear: {desc}"
1181        );
1182    }
1183
1184    // ---------------------------------------------------------------------------
1185    // coverage_intelligence_issue: None identity falls back to "code"
1186    // ---------------------------------------------------------------------------
1187
1188    #[test]
1189    fn coverage_intelligence_issue_none_identity_uses_code_label() {
1190        use fallow_output::{
1191            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
1192            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
1193            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
1194            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
1195            CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
1196        };
1197        let root = PathBuf::from("/project");
1198        let report = fallow_output::HealthReport {
1199            coverage_intelligence: Some(CoverageIntelligenceReport {
1200                schema_version: CoverageIntelligenceSchemaVersion::V1,
1201                verdict: CoverageIntelligenceVerdict::ReviewRequired,
1202                summary: CoverageIntelligenceSummary {
1203                    findings: 1,
1204                    ..Default::default()
1205                },
1206                findings: vec![CoverageIntelligenceFinding {
1207                    id: "fallow:coverage-intel:xyz".to_owned(),
1208                    path: root.join("src/util.ts"),
1209                    identity: None,
1210                    line: 3,
1211                    verdict: CoverageIntelligenceVerdict::ReviewRequired,
1212                    signals: vec![],
1213                    recommendation: CoverageIntelligenceRecommendation::ReviewBeforeChanging,
1214                    confidence: CoverageIntelligenceConfidence::Low,
1215                    related_ids: vec![],
1216                    evidence: CoverageIntelligenceEvidence {
1217                        match_confidence: CoverageIntelligenceMatchConfidence::Direct,
1218                        ..Default::default()
1219                    },
1220                    actions: vec![CoverageIntelligenceAction {
1221                        kind: "review".to_owned(),
1222                        description: "Review before changing".to_owned(),
1223                        auto_fixable: false,
1224                    }],
1225                }],
1226            }),
1227            ..Default::default()
1228        };
1229        let json = codeclimate_issues_to_value(&api_health_codeclimate_issues(&report, &root));
1230        let issues = json.as_array().unwrap();
1231        assert_eq!(issues.len(), 1);
1232        assert_eq!(
1233            issues[0]["check_name"],
1234            "fallow/coverage-intelligence-review"
1235        );
1236        let desc = issues[0]["description"].as_str().unwrap();
1237        assert!(
1238            desc.contains("'code'"),
1239            "description should say 'code': {desc}"
1240        );
1241    }
1242
1243    // ---------------------------------------------------------------------------
1244    // coverage_intelligence_issue: Clean/Unknown verdicts are filtered out
1245    // ---------------------------------------------------------------------------
1246
1247    #[test]
1248    fn coverage_intelligence_clean_verdict_emits_no_issue() {
1249        use fallow_output::{
1250            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
1251            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
1252            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
1253            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
1254            CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
1255        };
1256        let root = PathBuf::from("/project");
1257        let report = fallow_output::HealthReport {
1258            coverage_intelligence: Some(CoverageIntelligenceReport {
1259                schema_version: CoverageIntelligenceSchemaVersion::V1,
1260                verdict: CoverageIntelligenceVerdict::Clean,
1261                summary: CoverageIntelligenceSummary {
1262                    findings: 1,
1263                    ..Default::default()
1264                },
1265                findings: vec![CoverageIntelligenceFinding {
1266                    id: "fallow:coverage-intel:clean".to_owned(),
1267                    path: root.join("src/util.ts"),
1268                    identity: Some("cleanFn".to_owned()),
1269                    line: 3,
1270                    verdict: CoverageIntelligenceVerdict::Clean,
1271                    signals: vec![],
1272                    recommendation: CoverageIntelligenceRecommendation::ReviewBeforeChanging,
1273                    confidence: CoverageIntelligenceConfidence::Low,
1274                    related_ids: vec![],
1275                    evidence: CoverageIntelligenceEvidence {
1276                        match_confidence: CoverageIntelligenceMatchConfidence::Direct,
1277                        ..Default::default()
1278                    },
1279                    actions: vec![CoverageIntelligenceAction {
1280                        kind: "review".to_owned(),
1281                        description: "Review before changing".to_owned(),
1282                        auto_fixable: false,
1283                    }],
1284                }],
1285            }),
1286            ..Default::default()
1287        };
1288        let issues = api_health_codeclimate_issues(&report, &root);
1289        assert!(
1290            issues.is_empty(),
1291            "Clean verdict should not produce an issue"
1292        );
1293    }
1294
1295    // ---------------------------------------------------------------------------
1296    // untested_file_issue: singular vs plural export count in description
1297    // ---------------------------------------------------------------------------
1298
1299    #[test]
1300    fn untested_file_singular_export_count() {
1301        use fallow_output::{CoverageGapSummary, CoverageGaps, UntestedFile, UntestedFileFinding};
1302        let root = PathBuf::from("/project");
1303        let report = fallow_output::HealthReport {
1304            coverage_gaps: Some(CoverageGaps {
1305                summary: CoverageGapSummary {
1306                    runtime_files: 1,
1307                    covered_files: 0,
1308                    file_coverage_pct: 0.0,
1309                    untested_files: 1,
1310                    untested_exports: 0,
1311                },
1312                files: vec![UntestedFileFinding::with_actions(
1313                    UntestedFile {
1314                        path: root.join("src/single.ts"),
1315                        value_export_count: 1,
1316                    },
1317                    &root,
1318                )],
1319                exports: vec![],
1320            }),
1321            ..Default::default()
1322        };
1323        let json = codeclimate_issues_to_value(&api_health_codeclimate_issues(&report, &root));
1324        let issues = json.as_array().unwrap();
1325        assert_eq!(issues.len(), 1);
1326        let desc = issues[0]["description"].as_str().unwrap();
1327        assert!(desc.contains("1 value export)"), "singular: {desc}");
1328        assert!(
1329            !desc.contains("exports"),
1330            "singular should not say 'exports': {desc}"
1331        );
1332    }
1333
1334    #[test]
1335    fn untested_file_plural_export_count() {
1336        use fallow_output::{CoverageGapSummary, CoverageGaps, UntestedFile, UntestedFileFinding};
1337        let root = PathBuf::from("/project");
1338        let report = fallow_output::HealthReport {
1339            coverage_gaps: Some(CoverageGaps {
1340                summary: CoverageGapSummary {
1341                    runtime_files: 1,
1342                    covered_files: 0,
1343                    file_coverage_pct: 0.0,
1344                    untested_files: 1,
1345                    untested_exports: 0,
1346                },
1347                files: vec![UntestedFileFinding::with_actions(
1348                    UntestedFile {
1349                        path: root.join("src/multi.ts"),
1350                        value_export_count: 3,
1351                    },
1352                    &root,
1353                )],
1354                exports: vec![],
1355            }),
1356            ..Default::default()
1357        };
1358        let json = codeclimate_issues_to_value(&api_health_codeclimate_issues(&report, &root));
1359        let issues = json.as_array().unwrap();
1360        let desc = issues[0]["description"].as_str().unwrap();
1361        assert!(desc.contains("3 value exports)"), "plural: {desc}");
1362    }
1363
1364    // ---------------------------------------------------------------------------
1365    // health_finding_severity: Critical -> Critical, Moderate -> Minor
1366    // ---------------------------------------------------------------------------
1367
1368    #[test]
1369    fn health_finding_severity_critical_maps_to_critical() {
1370        use fallow_output::FindingSeverity;
1371        assert_eq!(
1372            health_finding_severity(FindingSeverity::Critical),
1373            CodeClimateSeverity::Critical
1374        );
1375    }
1376
1377    #[test]
1378    fn health_finding_severity_moderate_maps_to_minor() {
1379        use fallow_output::FindingSeverity;
1380        assert_eq!(
1381            health_finding_severity(FindingSeverity::Moderate),
1382            CodeClimateSeverity::Minor
1383        );
1384    }
1385
1386    // ---------------------------------------------------------------------------
1387    // runtime_coverage_check_name: all verdict arms
1388    // ---------------------------------------------------------------------------
1389
1390    #[test]
1391    fn runtime_coverage_check_name_safe_to_delete() {
1392        use fallow_output::RuntimeCoverageVerdict;
1393        assert_eq!(
1394            runtime_coverage_check_name(RuntimeCoverageVerdict::SafeToDelete),
1395            "fallow/runtime-safe-to-delete"
1396        );
1397    }
1398
1399    #[test]
1400    fn runtime_coverage_check_name_low_traffic() {
1401        use fallow_output::RuntimeCoverageVerdict;
1402        assert_eq!(
1403            runtime_coverage_check_name(RuntimeCoverageVerdict::LowTraffic),
1404            "fallow/runtime-low-traffic"
1405        );
1406    }
1407
1408    #[test]
1409    fn runtime_coverage_check_name_coverage_unavailable() {
1410        use fallow_output::RuntimeCoverageVerdict;
1411        assert_eq!(
1412            runtime_coverage_check_name(RuntimeCoverageVerdict::CoverageUnavailable),
1413            "fallow/runtime-coverage-unavailable"
1414        );
1415    }
1416
1417    #[test]
1418    fn runtime_coverage_check_name_active_and_unknown_use_generic() {
1419        use fallow_output::RuntimeCoverageVerdict;
1420        assert_eq!(
1421            runtime_coverage_check_name(RuntimeCoverageVerdict::Active),
1422            "fallow/runtime-coverage"
1423        );
1424        assert_eq!(
1425            runtime_coverage_check_name(RuntimeCoverageVerdict::Unknown),
1426            "fallow/runtime-coverage"
1427        );
1428    }
1429
1430    // ---------------------------------------------------------------------------
1431    // runtime_coverage_severity: verdict arms
1432    // ---------------------------------------------------------------------------
1433
1434    #[test]
1435    fn runtime_coverage_severity_safe_to_delete_is_critical() {
1436        use fallow_output::RuntimeCoverageVerdict;
1437        assert_eq!(
1438            runtime_coverage_severity(RuntimeCoverageVerdict::SafeToDelete),
1439            CodeClimateSeverity::Critical
1440        );
1441    }
1442
1443    #[test]
1444    fn runtime_coverage_severity_review_required_is_major() {
1445        use fallow_output::RuntimeCoverageVerdict;
1446        assert_eq!(
1447            runtime_coverage_severity(RuntimeCoverageVerdict::ReviewRequired),
1448            CodeClimateSeverity::Major
1449        );
1450    }
1451
1452    #[test]
1453    fn runtime_coverage_severity_other_verdicts_are_minor() {
1454        use fallow_output::RuntimeCoverageVerdict;
1455        assert_eq!(
1456            runtime_coverage_severity(RuntimeCoverageVerdict::LowTraffic),
1457            CodeClimateSeverity::Minor
1458        );
1459        assert_eq!(
1460            runtime_coverage_severity(RuntimeCoverageVerdict::Active),
1461            CodeClimateSeverity::Minor
1462        );
1463    }
1464
1465    // ---------------------------------------------------------------------------
1466    // coverage_intelligence_severity: all verdict arms
1467    // ---------------------------------------------------------------------------
1468
1469    #[test]
1470    fn coverage_intelligence_severity_review_required_is_minor() {
1471        use fallow_output::CoverageIntelligenceVerdict;
1472        assert_eq!(
1473            coverage_intelligence_severity(CoverageIntelligenceVerdict::ReviewRequired),
1474            Some(CodeClimateSeverity::Minor)
1475        );
1476    }
1477
1478    #[test]
1479    fn coverage_intelligence_severity_refactor_carefully_is_minor() {
1480        use fallow_output::CoverageIntelligenceVerdict;
1481        assert_eq!(
1482            coverage_intelligence_severity(CoverageIntelligenceVerdict::RefactorCarefully),
1483            Some(CodeClimateSeverity::Minor)
1484        );
1485    }
1486
1487    #[test]
1488    fn coverage_intelligence_severity_clean_is_none() {
1489        use fallow_output::CoverageIntelligenceVerdict;
1490        assert_eq!(
1491            coverage_intelligence_severity(CoverageIntelligenceVerdict::Clean),
1492            None
1493        );
1494    }
1495
1496    #[test]
1497    fn coverage_intelligence_severity_unknown_is_none() {
1498        use fallow_output::CoverageIntelligenceVerdict;
1499        assert_eq!(
1500            coverage_intelligence_severity(CoverageIntelligenceVerdict::Unknown),
1501            None
1502        );
1503    }
1504
1505    // ---------------------------------------------------------------------------
1506    // push_dep_cc_issues: workspace context included in description
1507    // ---------------------------------------------------------------------------
1508
1509    #[test]
1510    fn unused_dep_with_workspace_context_includes_workspace_in_description() {
1511        let root = PathBuf::from("/project");
1512        let mut results = AnalysisResults::default();
1513        results
1514            .unused_dependencies
1515            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1516                package_name: "shared-lib".to_string(),
1517                location: DependencyLocation::Dependencies,
1518                path: root.join("package.json"),
1519                line: 10,
1520                used_in_workspaces: vec![root.join("packages/app")],
1521            }));
1522        let rules = RulesConfig::default();
1523        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
1524        let desc = output[0]["description"].as_str().unwrap();
1525        assert!(
1526            desc.contains("imported in other workspaces"),
1527            "desc: {desc}"
1528        );
1529        assert!(desc.contains("packages/app"), "desc: {desc}");
1530    }
1531
1532    // ---------------------------------------------------------------------------
1533    // push_private_type_leak_issues
1534    // ---------------------------------------------------------------------------
1535
1536    #[test]
1537    fn private_type_leak_emits_correct_check_name_and_description() {
1538        use fallow_config::Severity;
1539        use fallow_types::output_dead_code::PrivateTypeLeakFinding;
1540        let root = PathBuf::from("/project");
1541        let mut results = AnalysisResults::default();
1542        results
1543            .private_type_leaks
1544            .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
1545                path: root.join("src/api.ts"),
1546                export_name: "ApiResponse".to_string(),
1547                type_name: "InternalState".to_string(),
1548                line: 7,
1549                col: 0,
1550                span_start: 0,
1551            }));
1552        // private_type_leaks defaults to Off; enable it so the issue is emitted.
1553        let rules = RulesConfig {
1554            private_type_leaks: Severity::Error,
1555            ..RulesConfig::default()
1556        };
1557        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
1558        let arr = output.as_array().unwrap();
1559        assert_eq!(arr.len(), 1);
1560        assert_eq!(arr[0]["check_name"], "fallow/private-type-leak");
1561        let desc = arr[0]["description"].as_str().unwrap();
1562        assert!(desc.contains("ApiResponse"), "desc: {desc}");
1563        assert!(desc.contains("InternalState"), "desc: {desc}");
1564        assert_eq!(arr[0]["location"]["lines"]["begin"], 7);
1565    }
1566
1567    // ---------------------------------------------------------------------------
1568    // push_type_only_dep_issues / push_test_only_dep_issues: zero line -> line 1
1569    // ---------------------------------------------------------------------------
1570
1571    #[test]
1572    fn type_only_dep_zero_line_defaults_to_1() {
1573        let root = PathBuf::from("/project");
1574        let mut results = AnalysisResults::default();
1575        results
1576            .type_only_dependencies
1577            .push(TypeOnlyDependencyFinding::with_actions(
1578                TypeOnlyDependency {
1579                    package_name: "ts-types".to_string(),
1580                    path: root.join("package.json"),
1581                    line: 0,
1582                },
1583            ));
1584        let rules = RulesConfig::default();
1585        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
1586        assert_eq!(output[0]["location"]["lines"]["begin"], 1);
1587    }
1588
1589    #[test]
1590    fn test_only_dep_zero_line_defaults_to_1() {
1591        let root = PathBuf::from("/project");
1592        let mut results = AnalysisResults::default();
1593        results
1594            .test_only_dependencies
1595            .push(TestOnlyDependencyFinding::with_actions(
1596                TestOnlyDependency {
1597                    package_name: "vitest".to_string(),
1598                    path: root.join("package.json"),
1599                    line: 0,
1600                },
1601            ));
1602        let rules = RulesConfig::default();
1603        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
1604        assert_eq!(output[0]["check_name"], "fallow/test-only-dependency");
1605        assert_eq!(output[0]["location"]["lines"]["begin"], 1);
1606    }
1607
1608    // ---------------------------------------------------------------------------
1609    // push_re_export_cycle_issues: self-loop and multi-node arms
1610    // ---------------------------------------------------------------------------
1611
1612    #[test]
1613    fn re_export_cycle_self_loop_description_contains_self_loop_tag() {
1614        use fallow_types::output_dead_code::ReExportCycleFinding;
1615        let root = PathBuf::from("/project");
1616        let mut results = AnalysisResults::default();
1617        results
1618            .re_export_cycles
1619            .push(ReExportCycleFinding::with_actions(ReExportCycle {
1620                files: vec![root.join("src/index.ts")],
1621                kind: ReExportCycleKind::SelfLoop,
1622            }));
1623        let rules = RulesConfig::default();
1624        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
1625        assert_eq!(output[0]["check_name"], "fallow/re-export-cycle");
1626        let desc = output[0]["description"].as_str().unwrap();
1627        assert!(desc.contains("(self-loop)"), "desc: {desc}");
1628    }
1629
1630    #[test]
1631    fn re_export_cycle_multi_node_description_has_no_kind_tag() {
1632        use fallow_types::output_dead_code::ReExportCycleFinding;
1633        let root = PathBuf::from("/project");
1634        let mut results = AnalysisResults::default();
1635        results
1636            .re_export_cycles
1637            .push(ReExportCycleFinding::with_actions(ReExportCycle {
1638                files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1639                kind: ReExportCycleKind::MultiNode,
1640            }));
1641        let rules = RulesConfig::default();
1642        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
1643        let desc = output[0]["description"].as_str().unwrap();
1644        assert!(desc.starts_with("Re-export cycle: "), "desc: {desc}");
1645        assert!(!desc.contains("(self-loop)"), "desc: {desc}");
1646        assert!(!desc.contains("(multi-node)"), "desc: {desc}");
1647        assert!(desc.contains("src/a.ts"), "desc: {desc}");
1648    }
1649
1650    // ---------------------------------------------------------------------------
1651    // push_boundary_coverage_issues and push_boundary_call_issues
1652    // ---------------------------------------------------------------------------
1653
1654    #[test]
1655    fn boundary_coverage_violation_emits_correct_check_name() {
1656        use fallow_types::output_dead_code::BoundaryCoverageViolationFinding;
1657        let root = PathBuf::from("/project");
1658        let mut results = AnalysisResults::default();
1659        results
1660            .boundary_coverage_violations
1661            .push(BoundaryCoverageViolationFinding::with_actions(
1662                BoundaryCoverageViolation {
1663                    path: root.join("src/orphan.ts"),
1664                    line: 0,
1665                    col: 0,
1666                },
1667            ));
1668        let rules = RulesConfig::default();
1669        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
1670        assert_eq!(output[0]["check_name"], "fallow/boundary-coverage");
1671        let desc = output[0]["description"].as_str().unwrap();
1672        assert!(desc.contains("no configured zone"), "desc: {desc}");
1673    }
1674
1675    #[test]
1676    fn boundary_call_violation_emits_pattern_in_description() {
1677        use fallow_types::output_dead_code::BoundaryCallViolationFinding;
1678        let root = PathBuf::from("/project");
1679        let mut results = AnalysisResults::default();
1680        results
1681            .boundary_call_violations
1682            .push(BoundaryCallViolationFinding::with_actions(
1683                BoundaryCallViolation {
1684                    path: root.join("src/ui/App.ts"),
1685                    line: 5,
1686                    col: 0,
1687                    zone: "ui".to_string(),
1688                    callee: "fs.readFile".to_string(),
1689                    pattern: "fs.*".to_string(),
1690                },
1691            ));
1692        let rules = RulesConfig::default();
1693        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
1694        assert_eq!(output[0]["check_name"], "fallow/boundary-call-violation");
1695        let desc = output[0]["description"].as_str().unwrap();
1696        assert!(desc.contains("fs.readFile"), "desc: {desc}");
1697        assert!(desc.contains("fs.*"), "desc: {desc}");
1698        assert!(desc.contains("'ui'"), "desc: {desc}");
1699    }
1700
1701    // ---------------------------------------------------------------------------
1702    // push_policy_violation_issues: error severity and optional message
1703    // ---------------------------------------------------------------------------
1704
1705    #[test]
1706    fn policy_violation_error_severity_maps_to_major() {
1707        use fallow_types::output_dead_code::PolicyViolationFinding;
1708        let root = PathBuf::from("/project");
1709        let mut results = AnalysisResults::default();
1710        results
1711            .policy_violations
1712            .push(PolicyViolationFinding::with_actions(PolicyViolation {
1713                path: root.join("src/service.ts"),
1714                line: 2,
1715                col: 0,
1716                pack: "security".to_string(),
1717                rule_id: "no-eval".to_string(),
1718                kind: PolicyRuleKind::BannedCall,
1719                matched: "eval".to_string(),
1720                severity: PolicyViolationSeverity::Error,
1721                message: None,
1722            }));
1723        let rules = RulesConfig::default();
1724        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
1725        assert_eq!(output[0]["check_name"], "fallow/policy-violation");
1726        assert_eq!(output[0]["severity"], "major");
1727        let desc = output[0]["description"].as_str().unwrap();
1728        assert!(desc.contains("eval"), "desc: {desc}");
1729        assert!(desc.contains("security/no-eval"), "desc: {desc}");
1730    }
1731
1732    #[test]
1733    fn policy_violation_warn_severity_maps_to_minor() {
1734        use fallow_types::output_dead_code::PolicyViolationFinding;
1735        let root = PathBuf::from("/project");
1736        let mut results = AnalysisResults::default();
1737        results
1738            .policy_violations
1739            .push(PolicyViolationFinding::with_actions(PolicyViolation {
1740                path: root.join("src/service.ts"),
1741                line: 2,
1742                col: 0,
1743                pack: "style".to_string(),
1744                rule_id: "no-console".to_string(),
1745                kind: PolicyRuleKind::BannedCall,
1746                matched: "console.log".to_string(),
1747                severity: PolicyViolationSeverity::Warn,
1748                message: Some("Use the project logger instead".to_string()),
1749            }));
1750        let rules = RulesConfig::default();
1751        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
1752        assert_eq!(output[0]["severity"], "minor");
1753        let desc = output[0]["description"].as_str().unwrap();
1754        assert!(
1755            desc.contains("Use the project logger instead"),
1756            "desc: {desc}"
1757        );
1758    }
1759
1760    // ---------------------------------------------------------------------------
1761    // push_invalid_client_export_issues and push_mixed_client_server_barrel_issues
1762    // ---------------------------------------------------------------------------
1763
1764    #[test]
1765    fn invalid_client_export_emits_correct_check_name_and_directive() {
1766        use fallow_types::output_dead_code::InvalidClientExportFinding;
1767        let root = PathBuf::from("/project");
1768        let mut results = AnalysisResults::default();
1769        results
1770            .invalid_client_exports
1771            .push(InvalidClientExportFinding::with_actions(
1772                InvalidClientExport {
1773                    path: root.join("src/page.ts"),
1774                    export_name: "metadata".to_string(),
1775                    directive: "use client".to_string(),
1776                    line: 3,
1777                    col: 0,
1778                },
1779            ));
1780        let rules = RulesConfig::default();
1781        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
1782        assert_eq!(output[0]["check_name"], "fallow/invalid-client-export");
1783        let desc = output[0]["description"].as_str().unwrap();
1784        assert!(desc.contains("metadata"), "desc: {desc}");
1785        assert!(desc.contains("use client"), "desc: {desc}");
1786    }
1787
1788    #[test]
1789    fn mixed_client_server_barrel_emits_correct_check_name_and_origins() {
1790        use fallow_types::output_dead_code::MixedClientServerBarrelFinding;
1791        let root = PathBuf::from("/project");
1792        let mut results = AnalysisResults::default();
1793        results
1794            .mixed_client_server_barrels
1795            .push(MixedClientServerBarrelFinding::with_actions(
1796                MixedClientServerBarrel {
1797                    path: root.join("src/index.ts"),
1798                    client_origin: "./client-comp".to_string(),
1799                    server_origin: "./server-only".to_string(),
1800                    line: 1,
1801                    col: 0,
1802                },
1803            ));
1804        let rules = RulesConfig::default();
1805        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
1806        assert_eq!(output[0]["check_name"], "fallow/mixed-client-server-barrel");
1807        let desc = output[0]["description"].as_str().unwrap();
1808        assert!(desc.contains("./client-comp"), "desc: {desc}");
1809        assert!(desc.contains("./server-only"), "desc: {desc}");
1810    }
1811
1812    // ---------------------------------------------------------------------------
1813    // push_misplaced_directive_issues
1814    // ---------------------------------------------------------------------------
1815
1816    #[test]
1817    fn misplaced_directive_emits_correct_check_name_and_guidance() {
1818        use fallow_types::output_dead_code::MisplacedDirectiveFinding;
1819        let root = PathBuf::from("/project");
1820        let mut results = AnalysisResults::default();
1821        results
1822            .misplaced_directives
1823            .push(MisplacedDirectiveFinding::with_actions(
1824                MisplacedDirective {
1825                    path: root.join("src/page.ts"),
1826                    directive: "use client".to_string(),
1827                    line: 5,
1828                    col: 0,
1829                },
1830            ));
1831        let rules = RulesConfig::default();
1832        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
1833        assert_eq!(output[0]["check_name"], "fallow/misplaced-directive");
1834        let desc = output[0]["description"].as_str().unwrap();
1835        assert!(desc.contains("use client"), "desc: {desc}");
1836        assert!(desc.contains("leading position"), "desc: {desc}");
1837    }
1838
1839    // ---------------------------------------------------------------------------
1840    // push_unrendered_component_issues
1841    // ---------------------------------------------------------------------------
1842
1843    #[test]
1844    fn unrendered_component_emits_correct_check_name_and_description() {
1845        use fallow_types::output_dead_code::UnrenderedComponentFinding;
1846        let root = PathBuf::from("/project");
1847        let mut results = AnalysisResults::default();
1848        results
1849            .unrendered_components
1850            .push(UnrenderedComponentFinding::with_actions(
1851                UnrenderedComponent {
1852                    path: root.join("src/Dead.vue"),
1853                    component_name: "Dead".to_string(),
1854                    framework: "vue".to_string(),
1855                    reachable_via: None,
1856                    line: 1,
1857                    col: 0,
1858                },
1859            ));
1860        let rules = RulesConfig::default();
1861        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
1862        assert_eq!(output[0]["check_name"], "fallow/unrendered-component");
1863        let desc = output[0]["description"].as_str().unwrap();
1864        assert!(desc.contains("`Dead`"), "desc: {desc}");
1865        assert!(desc.contains("rendered nowhere"), "desc: {desc}");
1866    }
1867
1868    // ---------------------------------------------------------------------------
1869    // push_unused_component_prop_issues
1870    // ---------------------------------------------------------------------------
1871
1872    #[test]
1873    fn unused_component_prop_emits_correct_check_name_and_description() {
1874        use fallow_types::output_dead_code::UnusedComponentPropFinding;
1875        let root = PathBuf::from("/project");
1876        let mut results = AnalysisResults::default();
1877        results
1878            .unused_component_props
1879            .push(UnusedComponentPropFinding::with_actions(
1880                UnusedComponentProp {
1881                    path: root.join("src/Card.vue"),
1882                    component_name: "Card".to_string(),
1883                    prop_name: "color".to_string(),
1884                    line: 3,
1885                    col: 0,
1886                },
1887            ));
1888        let rules = RulesConfig::default();
1889        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
1890        assert_eq!(output[0]["check_name"], "fallow/unused-component-prop");
1891        let desc = output[0]["description"].as_str().unwrap();
1892        assert!(desc.contains("`color`"), "desc: {desc}");
1893        assert!(desc.contains("Card"), "desc: {desc}");
1894    }
1895
1896    // ---------------------------------------------------------------------------
1897    // push_unused_component_emit_issues
1898    // ---------------------------------------------------------------------------
1899
1900    #[test]
1901    fn unused_component_emit_emits_correct_check_name_and_description() {
1902        use fallow_types::output_dead_code::UnusedComponentEmitFinding;
1903        let root = PathBuf::from("/project");
1904        let mut results = AnalysisResults::default();
1905        results
1906            .unused_component_emits
1907            .push(UnusedComponentEmitFinding::with_actions(
1908                UnusedComponentEmit {
1909                    path: root.join("src/Button.vue"),
1910                    component_name: "Button".to_string(),
1911                    emit_name: "close".to_string(),
1912                    line: 4,
1913                    col: 0,
1914                },
1915            ));
1916        let rules = RulesConfig::default();
1917        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
1918        assert_eq!(output[0]["check_name"], "fallow/unused-component-emit");
1919        let desc = output[0]["description"].as_str().unwrap();
1920        assert!(desc.contains("`close`"), "desc: {desc}");
1921        assert!(desc.contains("Button"), "desc: {desc}");
1922    }
1923
1924    // ---------------------------------------------------------------------------
1925    // push_unused_svelte_event_issues
1926    // ---------------------------------------------------------------------------
1927
1928    #[test]
1929    fn unused_svelte_event_emits_correct_check_name_and_description() {
1930        use fallow_types::output_dead_code::UnusedSvelteEventFinding;
1931        let root = PathBuf::from("/project");
1932        let mut results = AnalysisResults::default();
1933        results
1934            .unused_svelte_events
1935            .push(UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
1936                path: root.join("src/Child.svelte"),
1937                component_name: "Child".to_string(),
1938                event_name: "submit".to_string(),
1939                line: 2,
1940                col: 0,
1941            }));
1942        let rules = RulesConfig::default();
1943        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
1944        assert_eq!(output[0]["check_name"], "fallow/unused-svelte-event");
1945        let desc = output[0]["description"].as_str().unwrap();
1946        assert!(desc.contains("`submit`"), "desc: {desc}");
1947        assert!(desc.contains("listened to nowhere"), "desc: {desc}");
1948    }
1949
1950    // ---------------------------------------------------------------------------
1951    // push_unused_component_input_issues and push_unused_component_output_issues
1952    // ---------------------------------------------------------------------------
1953
1954    #[test]
1955    fn unused_component_input_emits_correct_check_name_and_description() {
1956        use fallow_types::output_dead_code::UnusedComponentInputFinding;
1957        let root = PathBuf::from("/project");
1958        let mut results = AnalysisResults::default();
1959        results
1960            .unused_component_inputs
1961            .push(UnusedComponentInputFinding::with_actions(
1962                UnusedComponentInput {
1963                    path: root.join("src/card.component.ts"),
1964                    component_name: "CardComponent".to_string(),
1965                    input_name: "label".to_string(),
1966                    line: 5,
1967                    col: 0,
1968                },
1969            ));
1970        let rules = RulesConfig::default();
1971        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
1972        assert_eq!(output[0]["check_name"], "fallow/unused-component-input");
1973        let desc = output[0]["description"].as_str().unwrap();
1974        assert!(desc.contains("`label`"), "desc: {desc}");
1975        assert!(desc.contains("CardComponent"), "desc: {desc}");
1976    }
1977
1978    #[test]
1979    fn unused_component_output_emits_correct_check_name_and_description() {
1980        use fallow_types::output_dead_code::UnusedComponentOutputFinding;
1981        let root = PathBuf::from("/project");
1982        let mut results = AnalysisResults::default();
1983        results
1984            .unused_component_outputs
1985            .push(UnusedComponentOutputFinding::with_actions(
1986                UnusedComponentOutput {
1987                    path: root.join("src/toggle.component.ts"),
1988                    component_name: "ToggleComponent".to_string(),
1989                    output_name: "changed".to_string(),
1990                    line: 6,
1991                    col: 0,
1992                },
1993            ));
1994        let rules = RulesConfig::default();
1995        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
1996        assert_eq!(output[0]["check_name"], "fallow/unused-component-output");
1997        let desc = output[0]["description"].as_str().unwrap();
1998        assert!(desc.contains("`changed`"), "desc: {desc}");
1999        assert!(desc.contains("ToggleComponent"), "desc: {desc}");
2000    }
2001
2002    // ---------------------------------------------------------------------------
2003    // push_unused_server_action_issues
2004    // ---------------------------------------------------------------------------
2005
2006    #[test]
2007    fn unused_server_action_emits_correct_check_name_and_description() {
2008        use fallow_types::output_dead_code::UnusedServerActionFinding;
2009        let root = PathBuf::from("/project");
2010        let mut results = AnalysisResults::default();
2011        results
2012            .unused_server_actions
2013            .push(UnusedServerActionFinding::with_actions(
2014                UnusedServerAction {
2015                    path: root.join("src/app/actions.ts"),
2016                    action_name: "deleteUser".to_string(),
2017                    line: 8,
2018                    col: 0,
2019                },
2020            ));
2021        let rules = RulesConfig::default();
2022        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
2023        assert_eq!(output[0]["check_name"], "fallow/unused-server-action");
2024        let desc = output[0]["description"].as_str().unwrap();
2025        assert!(desc.contains("`deleteUser`"), "desc: {desc}");
2026        assert!(desc.contains("\"use server\""), "desc: {desc}");
2027    }
2028
2029    // ---------------------------------------------------------------------------
2030    // push_unused_load_data_key_issues
2031    // ---------------------------------------------------------------------------
2032
2033    #[test]
2034    fn unused_load_data_key_emits_correct_check_name_and_description() {
2035        use fallow_types::output_dead_code::UnusedLoadDataKeyFinding;
2036        let root = PathBuf::from("/project");
2037        let mut results = AnalysisResults::default();
2038        results
2039            .unused_load_data_keys
2040            .push(UnusedLoadDataKeyFinding::with_actions(UnusedLoadDataKey {
2041                path: root.join("src/routes/+page.ts"),
2042                key_name: "postCount".to_string(),
2043                line: 7,
2044                col: 0,
2045                route_dir: None,
2046            }));
2047        let rules = RulesConfig::default();
2048        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
2049        assert_eq!(output[0]["check_name"], "fallow/unused-load-data-key");
2050        let desc = output[0]["description"].as_str().unwrap();
2051        assert!(desc.contains("`postCount`"), "desc: {desc}");
2052        assert!(desc.contains("no consumer"), "desc: {desc}");
2053    }
2054
2055    // ---------------------------------------------------------------------------
2056    // push_route_collision_issues and push_dynamic_segment_name_conflict_issues
2057    // ---------------------------------------------------------------------------
2058
2059    #[test]
2060    fn route_collision_emits_correct_check_name_and_url_in_description() {
2061        use fallow_types::output_dead_code::RouteCollisionFinding;
2062        let root = PathBuf::from("/project");
2063        let mut results = AnalysisResults::default();
2064        results
2065            .route_collisions
2066            .push(RouteCollisionFinding::with_actions(RouteCollision {
2067                path: root.join("src/app/about/page.tsx"),
2068                url: "/about".to_string(),
2069                conflicting_paths: vec![root.join("src/app/(marketing)/about/page.tsx")],
2070                line: 1,
2071                col: 0,
2072            }));
2073        let rules = RulesConfig::default();
2074        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
2075        assert_eq!(output[0]["check_name"], "fallow/route-collision");
2076        let desc = output[0]["description"].as_str().unwrap();
2077        assert!(desc.contains("`/about`"), "desc: {desc}");
2078        assert!(desc.contains("1 other file"), "desc: {desc}");
2079    }
2080
2081    #[test]
2082    fn dynamic_segment_name_conflict_emits_correct_check_name_and_position() {
2083        use fallow_types::output_dead_code::DynamicSegmentNameConflictFinding;
2084        let root = PathBuf::from("/project");
2085        let mut results = AnalysisResults::default();
2086        results.dynamic_segment_name_conflicts.push(
2087            DynamicSegmentNameConflictFinding::with_actions(DynamicSegmentNameConflict {
2088                path: root.join("src/app/shop/[id]/page.tsx"),
2089                position: "/shop".to_string(),
2090                conflicting_segments: vec!["[id]".to_string(), "[slug]".to_string()],
2091                conflicting_paths: vec![root.join("src/app/shop/[slug]/page.tsx")],
2092                line: 1,
2093                col: 0,
2094            }),
2095        );
2096        let rules = RulesConfig::default();
2097        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
2098        assert_eq!(
2099            output[0]["check_name"],
2100            "fallow/dynamic-segment-name-conflict"
2101        );
2102        let desc = output[0]["description"].as_str().unwrap();
2103        assert!(desc.contains("`/shop`"), "desc: {desc}");
2104        assert!(desc.contains("[id]"), "desc: {desc}");
2105        assert!(desc.contains("[slug]"), "desc: {desc}");
2106    }
2107
2108    // ---------------------------------------------------------------------------
2109    // push_unresolved_catalog_reference_issues: default and named catalog
2110    // ---------------------------------------------------------------------------
2111
2112    #[test]
2113    fn unresolved_catalog_reference_default_catalog_description() {
2114        use fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding;
2115        let root = PathBuf::from("/project");
2116        let mut results = AnalysisResults::default();
2117        results.unresolved_catalog_references.push(
2118            UnresolvedCatalogReferenceFinding::with_actions(UnresolvedCatalogReference {
2119                entry_name: "react".to_string(),
2120                catalog_name: "default".to_string(),
2121                path: root.join("packages/app/package.json"),
2122                line: 5,
2123                available_in_catalogs: vec![],
2124            }),
2125        );
2126        let rules = RulesConfig::default();
2127        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
2128        assert_eq!(
2129            output[0]["check_name"],
2130            "fallow/unresolved-catalog-reference"
2131        );
2132        let desc = output[0]["description"].as_str().unwrap();
2133        assert!(desc.contains("the default catalog"), "desc: {desc}");
2134        assert!(desc.contains("react"), "desc: {desc}");
2135        assert!(desc.contains("catalog:"), "desc: {desc}");
2136    }
2137
2138    #[test]
2139    fn unresolved_catalog_reference_named_catalog_description() {
2140        use fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding;
2141        let root = PathBuf::from("/project");
2142        let mut results = AnalysisResults::default();
2143        results.unresolved_catalog_references.push(
2144            UnresolvedCatalogReferenceFinding::with_actions(UnresolvedCatalogReference {
2145                entry_name: "lodash".to_string(),
2146                catalog_name: "react17".to_string(),
2147                path: root.join("packages/legacy/package.json"),
2148                line: 3,
2149                available_in_catalogs: vec!["default".to_string()],
2150            }),
2151        );
2152        let rules = RulesConfig::default();
2153        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
2154        let desc = output[0]["description"].as_str().unwrap();
2155        assert!(desc.contains("catalog 'react17'"), "desc: {desc}");
2156        assert!(desc.contains("available in"), "desc: {desc}");
2157        assert!(desc.contains("default"), "desc: {desc}");
2158    }
2159
2160    // ---------------------------------------------------------------------------
2161    // push_unused_dependency_override_issues: hint included in description
2162    // ---------------------------------------------------------------------------
2163
2164    #[test]
2165    fn unused_dependency_override_with_hint_includes_hint_in_description() {
2166        use fallow_types::output_dead_code::UnusedDependencyOverrideFinding;
2167        let root = PathBuf::from("/project");
2168        let mut results = AnalysisResults::default();
2169        results
2170            .unused_dependency_overrides
2171            .push(UnusedDependencyOverrideFinding::with_actions(
2172                UnusedDependencyOverride {
2173                    raw_key: "react".to_string(),
2174                    target_package: "react".to_string(),
2175                    parent_package: None,
2176                    version_constraint: None,
2177                    version_range: "^18.0.0".to_string(),
2178                    source: DependencyOverrideSource::PnpmPackageJson,
2179                    path: root.join("package.json"),
2180                    line: 12,
2181                    hint: Some("verify lockfile before removing".to_string()),
2182                },
2183            ));
2184        let rules = RulesConfig::default();
2185        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
2186        assert_eq!(output[0]["check_name"], "fallow/unused-dependency-override");
2187        let desc = output[0]["description"].as_str().unwrap();
2188        assert!(desc.contains("react"), "desc: {desc}");
2189        assert!(desc.contains("verify lockfile"), "desc: {desc}");
2190    }
2191
2192    // ---------------------------------------------------------------------------
2193    // api_health_codeclimate_issues: runtime coverage findings
2194    // ---------------------------------------------------------------------------
2195
2196    #[test]
2197    fn health_codeclimate_runtime_safe_to_delete_maps_to_critical_severity() {
2198        use fallow_output::{
2199            RuntimeCoverageConfidence, RuntimeCoverageDataSource, RuntimeCoverageEvidence,
2200            RuntimeCoverageFinding, RuntimeCoverageReport, RuntimeCoverageReportVerdict,
2201            RuntimeCoverageSchemaVersion, RuntimeCoverageSummary, RuntimeCoverageVerdict,
2202        };
2203        let root = PathBuf::from("/project");
2204        let report = fallow_output::HealthReport {
2205            runtime_coverage: Some(RuntimeCoverageReport {
2206                schema_version: RuntimeCoverageSchemaVersion::V1,
2207                verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
2208                signals: vec![],
2209                summary: RuntimeCoverageSummary {
2210                    data_source: RuntimeCoverageDataSource::Local,
2211                    last_received_at: None,
2212                    functions_tracked: 10,
2213                    functions_hit: 8,
2214                    functions_unhit: 2,
2215                    functions_untracked: 0,
2216                    coverage_percent: 80.0,
2217                    trace_count: 1000,
2218                    period_days: 7,
2219                    deployments_seen: 1,
2220                    capture_quality: None,
2221                },
2222                findings: vec![RuntimeCoverageFinding {
2223                    id: "fallow:prod:aabbccdd".to_string(),
2224                    stable_id: None,
2225                    source_hash: None,
2226                    path: root.join("src/legacy.ts"),
2227                    function: "legacyHelper".to_string(),
2228                    line: 12,
2229                    verdict: RuntimeCoverageVerdict::SafeToDelete,
2230                    invocations: Some(0),
2231                    confidence: RuntimeCoverageConfidence::High,
2232                    evidence: RuntimeCoverageEvidence {
2233                        static_status: "unused".to_string(),
2234                        test_coverage: "not_covered".to_string(),
2235                        v8_tracking: "tracked".to_string(),
2236                        untracked_reason: None,
2237                        observation_days: 30,
2238                        deployments_observed: 3,
2239                    },
2240                    actions: vec![],
2241                    discriminators: None,
2242                }],
2243                hot_paths: vec![],
2244                blast_radius: vec![],
2245                importance: vec![],
2246                watermark: None,
2247                warnings: vec![],
2248                actionable: true,
2249                actionability_reason: None,
2250                actionability_verdict: None,
2251                provenance: fallow_output::RuntimeCoverageProvenance::default(),
2252            }),
2253            ..Default::default()
2254        };
2255        let json = codeclimate_issues_to_value(&api_health_codeclimate_issues(&report, &root));
2256        let issues = json.as_array().unwrap();
2257        assert_eq!(issues.len(), 1);
2258        assert_eq!(issues[0]["check_name"], "fallow/runtime-safe-to-delete");
2259        assert_eq!(issues[0]["severity"], "critical");
2260        let desc = issues[0]["description"].as_str().unwrap();
2261        assert!(desc.contains("legacyHelper"), "desc: {desc}");
2262        assert!(desc.contains("safe to delete"), "desc: {desc}");
2263        assert!(desc.contains("0 invocations"), "desc: {desc}");
2264        assert_eq!(issues[0]["location"]["path"], "src/legacy.ts");
2265        assert_eq!(issues[0]["location"]["lines"]["begin"], 12);
2266    }
2267
2268    #[test]
2269    fn health_codeclimate_runtime_finding_with_none_invocations_shows_untracked() {
2270        use fallow_output::{
2271            RuntimeCoverageConfidence, RuntimeCoverageDataSource, RuntimeCoverageEvidence,
2272            RuntimeCoverageFinding, RuntimeCoverageReport, RuntimeCoverageReportVerdict,
2273            RuntimeCoverageSchemaVersion, RuntimeCoverageSummary, RuntimeCoverageVerdict,
2274        };
2275        let root = PathBuf::from("/project");
2276        let report = fallow_output::HealthReport {
2277            runtime_coverage: Some(RuntimeCoverageReport {
2278                schema_version: RuntimeCoverageSchemaVersion::V1,
2279                verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
2280                signals: vec![],
2281                summary: RuntimeCoverageSummary {
2282                    data_source: RuntimeCoverageDataSource::Local,
2283                    last_received_at: None,
2284                    functions_tracked: 5,
2285                    functions_hit: 4,
2286                    functions_unhit: 0,
2287                    functions_untracked: 1,
2288                    coverage_percent: 80.0,
2289                    trace_count: 500,
2290                    period_days: 7,
2291                    deployments_seen: 1,
2292                    capture_quality: None,
2293                },
2294                findings: vec![RuntimeCoverageFinding {
2295                    id: "fallow:prod:11223344".to_string(),
2296                    stable_id: None,
2297                    source_hash: None,
2298                    path: root.join("src/worker.ts"),
2299                    function: "workerFn".to_string(),
2300                    line: 5,
2301                    verdict: RuntimeCoverageVerdict::CoverageUnavailable,
2302                    invocations: None,
2303                    confidence: RuntimeCoverageConfidence::Low,
2304                    evidence: RuntimeCoverageEvidence {
2305                        static_status: "used".to_string(),
2306                        test_coverage: "not_covered".to_string(),
2307                        v8_tracking: "untracked".to_string(),
2308                        untracked_reason: Some("worker_thread".to_string()),
2309                        observation_days: 7,
2310                        deployments_observed: 1,
2311                    },
2312                    actions: vec![],
2313                    discriminators: None,
2314                }],
2315                hot_paths: vec![],
2316                blast_radius: vec![],
2317                importance: vec![],
2318                watermark: None,
2319                warnings: vec![],
2320                actionable: true,
2321                actionability_reason: None,
2322                actionability_verdict: None,
2323                provenance: fallow_output::RuntimeCoverageProvenance::default(),
2324            }),
2325            ..Default::default()
2326        };
2327        let json = codeclimate_issues_to_value(&api_health_codeclimate_issues(&report, &root));
2328        let issues = json.as_array().unwrap();
2329        let desc = issues[0]["description"].as_str().unwrap();
2330        assert!(desc.contains("untracked"), "desc: {desc}");
2331    }
2332
2333    // ---------------------------------------------------------------------------
2334    // api_duplication_codeclimate_issues
2335    // ---------------------------------------------------------------------------
2336
2337    #[test]
2338    fn duplication_codeclimate_one_issue_per_instance() {
2339        use fallow_types::duplicates::{
2340            CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
2341        };
2342        let root = PathBuf::from("/project");
2343        let report = DuplicationReport {
2344            clone_groups: vec![CloneGroup {
2345                instances: vec![
2346                    CloneInstance {
2347                        file: root.join("src/a.ts"),
2348                        start_line: 10,
2349                        end_line: 20,
2350                        start_col: 0,
2351                        end_col: 0,
2352                        fragment: "const x = 1;\nconst y = 2;".to_string(),
2353                    },
2354                    CloneInstance {
2355                        file: root.join("src/b.ts"),
2356                        start_line: 30,
2357                        end_line: 40,
2358                        start_col: 0,
2359                        end_col: 0,
2360                        fragment: "const x = 1;\nconst y = 2;".to_string(),
2361                    },
2362                ],
2363                token_count: 20,
2364                line_count: 10,
2365            }],
2366            clone_families: vec![],
2367            mirrored_directories: vec![],
2368            stats: DuplicationStats::default(),
2369        };
2370        let output =
2371            codeclimate_issues_to_value(&api_duplication_codeclimate_issues(&report, &root));
2372        let issues = output.as_array().unwrap();
2373        assert_eq!(issues.len(), 2, "one issue per clone instance");
2374        assert_eq!(issues[0]["check_name"], "fallow/code-duplication");
2375        assert_eq!(issues[1]["check_name"], "fallow/code-duplication");
2376        assert_eq!(issues[0]["location"]["path"].as_str().unwrap(), "src/a.ts");
2377        assert_eq!(issues[1]["location"]["path"].as_str().unwrap(), "src/b.ts");
2378        assert_eq!(issues[0]["location"]["lines"]["begin"], 10);
2379        assert_eq!(issues[1]["location"]["lines"]["begin"], 30);
2380        assert_eq!(issues[0]["categories"][0], "Duplication");
2381    }
2382
2383    #[test]
2384    fn duplication_codeclimate_description_includes_group_number_and_line_count() {
2385        use fallow_types::duplicates::{
2386            CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
2387        };
2388        let root = PathBuf::from("/project");
2389        let report = DuplicationReport {
2390            clone_groups: vec![
2391                CloneGroup {
2392                    instances: vec![CloneInstance {
2393                        file: root.join("src/a.ts"),
2394                        start_line: 1,
2395                        end_line: 10,
2396                        start_col: 0,
2397                        end_col: 0,
2398                        fragment: "x".to_string(),
2399                    }],
2400                    token_count: 5,
2401                    line_count: 8,
2402                },
2403                CloneGroup {
2404                    instances: vec![CloneInstance {
2405                        file: root.join("src/b.ts"),
2406                        start_line: 5,
2407                        end_line: 12,
2408                        start_col: 0,
2409                        end_col: 0,
2410                        fragment: "y".to_string(),
2411                    }],
2412                    token_count: 3,
2413                    line_count: 6,
2414                },
2415            ],
2416            clone_families: vec![],
2417            mirrored_directories: vec![],
2418            stats: DuplicationStats::default(),
2419        };
2420        let output =
2421            codeclimate_issues_to_value(&api_duplication_codeclimate_issues(&report, &root));
2422        let issues = output.as_array().unwrap();
2423        assert_eq!(issues.len(), 2);
2424        let desc0 = issues[0]["description"].as_str().unwrap();
2425        assert!(desc0.contains("group 1"), "first group: {desc0}");
2426        assert!(desc0.contains("8 lines"), "first group line count: {desc0}");
2427        let desc1 = issues[1]["description"].as_str().unwrap();
2428        assert!(desc1.contains("group 2"), "second group: {desc1}");
2429        assert!(
2430            desc1.contains("6 lines"),
2431            "second group line count: {desc1}"
2432        );
2433    }
2434
2435    #[test]
2436    fn duplication_codeclimate_empty_report_produces_empty_array() {
2437        use fallow_types::duplicates::{DuplicationReport, DuplicationStats};
2438        let root = PathBuf::from("/project");
2439        let report = DuplicationReport {
2440            clone_groups: vec![],
2441            clone_families: vec![],
2442            mirrored_directories: vec![],
2443            stats: DuplicationStats::default(),
2444        };
2445        let output =
2446            codeclimate_issues_to_value(&api_duplication_codeclimate_issues(&report, &root));
2447        assert!(output.as_array().unwrap().is_empty());
2448    }
2449
2450    #[test]
2451    fn duplication_codeclimate_fingerprints_are_unique_across_instances() {
2452        use fallow_types::duplicates::{
2453            CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
2454        };
2455        let root = PathBuf::from("/project");
2456        let report = DuplicationReport {
2457            clone_groups: vec![CloneGroup {
2458                instances: vec![
2459                    CloneInstance {
2460                        file: root.join("src/x.ts"),
2461                        start_line: 1,
2462                        end_line: 5,
2463                        start_col: 0,
2464                        end_col: 0,
2465                        fragment: "hello".to_string(),
2466                    },
2467                    CloneInstance {
2468                        file: root.join("src/y.ts"),
2469                        start_line: 10,
2470                        end_line: 14,
2471                        start_col: 0,
2472                        end_col: 0,
2473                        fragment: "hello".to_string(),
2474                    },
2475                ],
2476                token_count: 5,
2477                line_count: 4,
2478            }],
2479            clone_families: vec![],
2480            mirrored_directories: vec![],
2481            stats: DuplicationStats::default(),
2482        };
2483        let output =
2484            codeclimate_issues_to_value(&api_duplication_codeclimate_issues(&report, &root));
2485        let issues = output.as_array().unwrap();
2486        let fp0 = issues[0]["fingerprint"].as_str().unwrap();
2487        let fp1 = issues[1]["fingerprint"].as_str().unwrap();
2488        assert_ne!(
2489            fp0, fp1,
2490            "different file+line instances must have distinct fingerprints"
2491        );
2492    }
2493
2494    // ---------------------------------------------------------------------------
2495    // circular_dep: cross-package variant includes cross-package label
2496    // ---------------------------------------------------------------------------
2497
2498    #[test]
2499    fn circular_dep_cross_package_description_contains_label() {
2500        use fallow_types::output_dead_code::CircularDependencyFinding;
2501        let root = PathBuf::from("/project");
2502        let mut results = AnalysisResults::default();
2503        results
2504            .circular_dependencies
2505            .push(CircularDependencyFinding::with_actions(
2506                CircularDependency {
2507                    files: vec![
2508                        root.join("packages/a/src/index.ts"),
2509                        root.join("packages/b/src/index.ts"),
2510                    ],
2511                    length: 2,
2512                    line: 0,
2513                    col: 0,
2514                    edges: Vec::new(),
2515                    is_cross_package: true,
2516                },
2517            ));
2518        let rules = RulesConfig::default();
2519        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
2520        let desc = output[0]["description"].as_str().unwrap();
2521        assert!(desc.contains("(cross-package)"), "desc: {desc}");
2522    }
2523
2524    // ---------------------------------------------------------------------------
2525    // is_re_export: unused_types re-export label
2526    // ---------------------------------------------------------------------------
2527
2528    #[test]
2529    fn unused_type_re_export_uses_type_re_export_label() {
2530        use fallow_types::output_dead_code::UnusedTypeFinding;
2531        let root = PathBuf::from("/project");
2532        let mut results = AnalysisResults::default();
2533        results
2534            .unused_types
2535            .push(UnusedTypeFinding::with_actions(UnusedExport {
2536                path: root.join("src/index.ts"),
2537                export_name: "ReExportedType".to_string(),
2538                is_type_only: true,
2539                line: 2,
2540                col: 0,
2541                span_start: 0,
2542                is_re_export: true,
2543            }));
2544        let rules = RulesConfig::default();
2545        let output = codeclimate_issues_to_value(&api_codeclimate_issues(&results, &root, &rules));
2546        assert_eq!(output[0]["check_name"], "fallow/unused-type");
2547        let desc = output[0]["description"].as_str().unwrap();
2548        assert!(desc.contains("Type re-export"), "desc: {desc}");
2549    }
2550
2551    #[test]
2552    fn codeclimate_result_contract_exclusions_are_explicit() {
2553        use std::collections::BTreeSet;
2554
2555        let missing: BTreeSet<&str> = fallow_output::issue_output_contracts()
2556            .filter(|contract| contract.codeclimate_check_names.is_empty())
2557            .map(|contract| contract.code)
2558            .collect();
2559
2560        assert_eq!(
2561            BTreeSet::from(["duplicate-prop-shape", "prop-drilling", "thin-wrapper"]),
2562            missing
2563        );
2564
2565        let check_names: BTreeSet<String> = fallow_output::issue_output_contracts()
2566            .flat_map(|contract| contract.codeclimate_check_names)
2567            .collect();
2568        assert!(check_names.contains("fallow/stale-suppression"));
2569        assert!(check_names.contains("fallow/missing-suppression-reason"));
2570    }
2571}