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