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
20pub 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
41fn 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#[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
60fn 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#[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
162pub(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
171pub(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#[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
198pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
200 emit_codeclimate(api_health_codeclimate_issues(report, root))
201}
202
203pub(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#[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
232pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
234 emit_codeclimate(api_duplication_codeclimate_issues(report, root))
235}
236
237pub(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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}