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]
496pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> serde_json::Value {
497 let mut issues = Vec::new();
498
499 let cyc_t = report.summary.max_cyclomatic_threshold;
500 let cog_t = report.summary.max_cognitive_threshold;
501
502 for finding in &report.findings {
503 let path = cc_path(&finding.path, root);
504 let description = match finding.exceeded {
505 ExceededThreshold::Both => format!(
506 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
507 finding.name, finding.cyclomatic, cyc_t, finding.cognitive, cog_t
508 ),
509 ExceededThreshold::Cyclomatic => format!(
510 "'{}' has cyclomatic complexity {} (threshold: {})",
511 finding.name, finding.cyclomatic, cyc_t
512 ),
513 ExceededThreshold::Cognitive => format!(
514 "'{}' has cognitive complexity {} (threshold: {})",
515 finding.name, finding.cognitive, cog_t
516 ),
517 };
518 let check_name = match finding.exceeded {
519 ExceededThreshold::Both => "fallow/high-complexity",
520 ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
521 ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
522 };
523 let severity = match finding.severity {
525 crate::health_types::FindingSeverity::Critical => "critical",
526 crate::health_types::FindingSeverity::High => "major",
527 crate::health_types::FindingSeverity::Moderate => "minor",
528 };
529 let line_str = finding.line.to_string();
530 let fp = fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
531 issues.push(cc_issue(
532 check_name,
533 &description,
534 severity,
535 "Complexity",
536 &path,
537 Some(finding.line),
538 &fp,
539 ));
540 }
541
542 if let Some(ref production) = report.production_coverage {
543 for finding in &production.findings {
544 let path = cc_path(&finding.path, root);
545 let check_name = match finding.verdict {
546 crate::health_types::ProductionCoverageVerdict::SafeToDelete => {
547 "fallow/production-safe-to-delete"
548 }
549 crate::health_types::ProductionCoverageVerdict::ReviewRequired => {
550 "fallow/production-review-required"
551 }
552 crate::health_types::ProductionCoverageVerdict::LowTraffic => {
553 "fallow/production-low-traffic"
554 }
555 crate::health_types::ProductionCoverageVerdict::CoverageUnavailable => {
556 "fallow/production-coverage-unavailable"
557 }
558 crate::health_types::ProductionCoverageVerdict::Active
559 | crate::health_types::ProductionCoverageVerdict::Unknown => {
560 "fallow/production-coverage"
561 }
562 };
563 let invocations_hint = finding.invocations.map_or_else(
564 || "untracked".to_owned(),
565 |hits| format!("{hits} invocations"),
566 );
567 let description = format!(
568 "'{}' production coverage verdict: {} ({})",
569 finding.function,
570 finding.verdict.human_label(),
571 invocations_hint,
572 );
573 let severity = match finding.verdict {
578 crate::health_types::ProductionCoverageVerdict::SafeToDelete => "critical",
579 crate::health_types::ProductionCoverageVerdict::ReviewRequired => "major",
580 _ => "minor",
581 };
582 let fp = fingerprint_hash(&[
583 check_name,
584 &path,
585 &finding.line.to_string(),
586 &finding.function,
587 ]);
588 issues.push(cc_issue(
589 check_name,
590 &description,
591 severity,
592 "Bug Risk",
598 &path,
599 Some(finding.line),
600 &fp,
601 ));
602 }
603 }
604
605 if let Some(ref gaps) = report.coverage_gaps {
606 for item in &gaps.files {
607 let path = cc_path(&item.path, root);
608 let description = format!(
609 "File is runtime-reachable but has no test dependency path ({} value export{})",
610 item.value_export_count,
611 if item.value_export_count == 1 {
612 ""
613 } else {
614 "s"
615 },
616 );
617 let fp = fingerprint_hash(&["fallow/untested-file", &path]);
618 issues.push(cc_issue(
619 "fallow/untested-file",
620 &description,
621 "minor",
622 "Coverage",
623 &path,
624 None,
625 &fp,
626 ));
627 }
628
629 for item in &gaps.exports {
630 let path = cc_path(&item.path, root);
631 let description = format!(
632 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
633 item.export_name
634 );
635 let line_str = item.line.to_string();
636 let fp = fingerprint_hash(&[
637 "fallow/untested-export",
638 &path,
639 &line_str,
640 &item.export_name,
641 ]);
642 issues.push(cc_issue(
643 "fallow/untested-export",
644 &description,
645 "minor",
646 "Coverage",
647 &path,
648 Some(item.line),
649 &fp,
650 ));
651 }
652 }
653
654 serde_json::Value::Array(issues)
655}
656
657pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
659 let value = build_health_codeclimate(report, root);
660 emit_json(&value, "CodeClimate")
661}
662
663#[must_use]
665#[expect(
666 clippy::cast_possible_truncation,
667 reason = "line numbers are bounded by source size"
668)]
669pub fn build_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> serde_json::Value {
670 let mut issues = Vec::new();
671
672 for (i, group) in report.clone_groups.iter().enumerate() {
673 let token_str = group.token_count.to_string();
676 let line_count_str = group.line_count.to_string();
677 let fragment_prefix: String = group
678 .instances
679 .first()
680 .map(|inst| inst.fragment.chars().take(64).collect())
681 .unwrap_or_default();
682
683 for instance in &group.instances {
684 let path = cc_path(&instance.file, root);
685 let start_str = instance.start_line.to_string();
686 let fp = fingerprint_hash(&[
687 "fallow/code-duplication",
688 &path,
689 &start_str,
690 &token_str,
691 &line_count_str,
692 &fragment_prefix,
693 ]);
694 issues.push(cc_issue(
695 "fallow/code-duplication",
696 &format!(
697 "Code clone group {} ({} lines, {} instances)",
698 i + 1,
699 group.line_count,
700 group.instances.len()
701 ),
702 "minor",
703 "Duplication",
704 &path,
705 Some(instance.start_line as u32),
706 &fp,
707 ));
708 }
709 }
710
711 serde_json::Value::Array(issues)
712}
713
714pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
716 let value = build_duplication_codeclimate(report, root);
717 emit_json(&value, "CodeClimate")
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723 use crate::report::test_helpers::sample_results;
724 use fallow_config::RulesConfig;
725 use fallow_core::results::*;
726 use std::path::PathBuf;
727
728 fn health_severity(value: u16, threshold: u16) -> &'static str {
731 if threshold == 0 {
732 return "minor";
733 }
734 let ratio = f64::from(value) / f64::from(threshold);
735 if ratio > 2.5 {
736 "critical"
737 } else if ratio > 1.5 {
738 "major"
739 } else {
740 "minor"
741 }
742 }
743
744 #[test]
745 fn codeclimate_empty_results_produces_empty_array() {
746 let root = PathBuf::from("/project");
747 let results = AnalysisResults::default();
748 let rules = RulesConfig::default();
749 let output = build_codeclimate(&results, &root, &rules);
750 let arr = output.as_array().unwrap();
751 assert!(arr.is_empty());
752 }
753
754 #[test]
755 fn codeclimate_produces_array_of_issues() {
756 let root = PathBuf::from("/project");
757 let results = sample_results(&root);
758 let rules = RulesConfig::default();
759 let output = build_codeclimate(&results, &root, &rules);
760 assert!(output.is_array());
761 let arr = output.as_array().unwrap();
762 assert!(!arr.is_empty());
764 }
765
766 #[test]
767 fn codeclimate_issue_has_required_fields() {
768 let root = PathBuf::from("/project");
769 let mut results = AnalysisResults::default();
770 results.unused_files.push(UnusedFile {
771 path: root.join("src/dead.ts"),
772 });
773 let rules = RulesConfig::default();
774 let output = build_codeclimate(&results, &root, &rules);
775 let issue = &output.as_array().unwrap()[0];
776
777 assert_eq!(issue["type"], "issue");
778 assert_eq!(issue["check_name"], "fallow/unused-file");
779 assert!(issue["description"].is_string());
780 assert!(issue["categories"].is_array());
781 assert!(issue["severity"].is_string());
782 assert!(issue["fingerprint"].is_string());
783 assert!(issue["location"].is_object());
784 assert!(issue["location"]["path"].is_string());
785 assert!(issue["location"]["lines"].is_object());
786 }
787
788 #[test]
789 fn codeclimate_unused_file_severity_follows_rules() {
790 let root = PathBuf::from("/project");
791 let mut results = AnalysisResults::default();
792 results.unused_files.push(UnusedFile {
793 path: root.join("src/dead.ts"),
794 });
795
796 let rules = RulesConfig::default();
798 let output = build_codeclimate(&results, &root, &rules);
799 assert_eq!(output[0]["severity"], "major");
800
801 let rules = RulesConfig {
803 unused_files: Severity::Warn,
804 ..RulesConfig::default()
805 };
806 let output = build_codeclimate(&results, &root, &rules);
807 assert_eq!(output[0]["severity"], "minor");
808 }
809
810 #[test]
811 fn codeclimate_unused_export_has_line_number() {
812 let root = PathBuf::from("/project");
813 let mut results = AnalysisResults::default();
814 results.unused_exports.push(UnusedExport {
815 path: root.join("src/utils.ts"),
816 export_name: "helperFn".to_string(),
817 is_type_only: false,
818 line: 10,
819 col: 4,
820 span_start: 120,
821 is_re_export: false,
822 });
823 let rules = RulesConfig::default();
824 let output = build_codeclimate(&results, &root, &rules);
825 let issue = &output[0];
826 assert_eq!(issue["location"]["lines"]["begin"], 10);
827 }
828
829 #[test]
830 fn codeclimate_unused_file_line_defaults_to_1() {
831 let root = PathBuf::from("/project");
832 let mut results = AnalysisResults::default();
833 results.unused_files.push(UnusedFile {
834 path: root.join("src/dead.ts"),
835 });
836 let rules = RulesConfig::default();
837 let output = build_codeclimate(&results, &root, &rules);
838 let issue = &output[0];
839 assert_eq!(issue["location"]["lines"]["begin"], 1);
840 }
841
842 #[test]
843 fn codeclimate_paths_are_relative() {
844 let root = PathBuf::from("/project");
845 let mut results = AnalysisResults::default();
846 results.unused_files.push(UnusedFile {
847 path: root.join("src/deep/nested/file.ts"),
848 });
849 let rules = RulesConfig::default();
850 let output = build_codeclimate(&results, &root, &rules);
851 let path = output[0]["location"]["path"].as_str().unwrap();
852 assert_eq!(path, "src/deep/nested/file.ts");
853 assert!(!path.starts_with("/project"));
854 }
855
856 #[test]
857 fn codeclimate_re_export_label_in_description() {
858 let root = PathBuf::from("/project");
859 let mut results = AnalysisResults::default();
860 results.unused_exports.push(UnusedExport {
861 path: root.join("src/index.ts"),
862 export_name: "reExported".to_string(),
863 is_type_only: false,
864 line: 1,
865 col: 0,
866 span_start: 0,
867 is_re_export: true,
868 });
869 let rules = RulesConfig::default();
870 let output = build_codeclimate(&results, &root, &rules);
871 let desc = output[0]["description"].as_str().unwrap();
872 assert!(desc.contains("Re-export"));
873 }
874
875 #[test]
876 fn codeclimate_unlisted_dep_one_issue_per_import_site() {
877 let root = PathBuf::from("/project");
878 let mut results = AnalysisResults::default();
879 results.unlisted_dependencies.push(UnlistedDependency {
880 package_name: "chalk".to_string(),
881 imported_from: vec![
882 ImportSite {
883 path: root.join("src/a.ts"),
884 line: 1,
885 col: 0,
886 },
887 ImportSite {
888 path: root.join("src/b.ts"),
889 line: 5,
890 col: 0,
891 },
892 ],
893 });
894 let rules = RulesConfig::default();
895 let output = build_codeclimate(&results, &root, &rules);
896 let arr = output.as_array().unwrap();
897 assert_eq!(arr.len(), 2);
898 assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
899 assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
900 }
901
902 #[test]
903 fn codeclimate_duplicate_export_one_issue_per_location() {
904 let root = PathBuf::from("/project");
905 let mut results = AnalysisResults::default();
906 results.duplicate_exports.push(DuplicateExport {
907 export_name: "Config".to_string(),
908 locations: vec![
909 DuplicateLocation {
910 path: root.join("src/a.ts"),
911 line: 10,
912 col: 0,
913 },
914 DuplicateLocation {
915 path: root.join("src/b.ts"),
916 line: 20,
917 col: 0,
918 },
919 DuplicateLocation {
920 path: root.join("src/c.ts"),
921 line: 30,
922 col: 0,
923 },
924 ],
925 });
926 let rules = RulesConfig::default();
927 let output = build_codeclimate(&results, &root, &rules);
928 let arr = output.as_array().unwrap();
929 assert_eq!(arr.len(), 3);
930 }
931
932 #[test]
933 fn codeclimate_circular_dep_emits_chain_in_description() {
934 let root = PathBuf::from("/project");
935 let mut results = AnalysisResults::default();
936 results.circular_dependencies.push(CircularDependency {
937 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
938 length: 2,
939 line: 3,
940 col: 0,
941 is_cross_package: false,
942 });
943 let rules = RulesConfig::default();
944 let output = build_codeclimate(&results, &root, &rules);
945 let desc = output[0]["description"].as_str().unwrap();
946 assert!(desc.contains("Circular dependency"));
947 assert!(desc.contains("src/a.ts"));
948 assert!(desc.contains("src/b.ts"));
949 }
950
951 #[test]
952 fn codeclimate_fingerprints_are_deterministic() {
953 let root = PathBuf::from("/project");
954 let results = sample_results(&root);
955 let rules = RulesConfig::default();
956 let output1 = build_codeclimate(&results, &root, &rules);
957 let output2 = build_codeclimate(&results, &root, &rules);
958
959 let fps1: Vec<&str> = output1
960 .as_array()
961 .unwrap()
962 .iter()
963 .map(|i| i["fingerprint"].as_str().unwrap())
964 .collect();
965 let fps2: Vec<&str> = output2
966 .as_array()
967 .unwrap()
968 .iter()
969 .map(|i| i["fingerprint"].as_str().unwrap())
970 .collect();
971 assert_eq!(fps1, fps2);
972 }
973
974 #[test]
975 fn codeclimate_fingerprints_are_unique() {
976 let root = PathBuf::from("/project");
977 let results = sample_results(&root);
978 let rules = RulesConfig::default();
979 let output = build_codeclimate(&results, &root, &rules);
980
981 let mut fps: Vec<&str> = output
982 .as_array()
983 .unwrap()
984 .iter()
985 .map(|i| i["fingerprint"].as_str().unwrap())
986 .collect();
987 let original_len = fps.len();
988 fps.sort_unstable();
989 fps.dedup();
990 assert_eq!(fps.len(), original_len, "fingerprints should be unique");
991 }
992
993 #[test]
994 fn codeclimate_type_only_dep_has_correct_check_name() {
995 let root = PathBuf::from("/project");
996 let mut results = AnalysisResults::default();
997 results.type_only_dependencies.push(TypeOnlyDependency {
998 package_name: "zod".to_string(),
999 path: root.join("package.json"),
1000 line: 8,
1001 });
1002 let rules = RulesConfig::default();
1003 let output = build_codeclimate(&results, &root, &rules);
1004 assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
1005 let desc = output[0]["description"].as_str().unwrap();
1006 assert!(desc.contains("zod"));
1007 assert!(desc.contains("type-only"));
1008 }
1009
1010 #[test]
1011 fn codeclimate_dep_with_zero_line_omits_line_number() {
1012 let root = PathBuf::from("/project");
1013 let mut results = AnalysisResults::default();
1014 results.unused_dependencies.push(UnusedDependency {
1015 package_name: "lodash".to_string(),
1016 location: DependencyLocation::Dependencies,
1017 path: root.join("package.json"),
1018 line: 0,
1019 });
1020 let rules = RulesConfig::default();
1021 let output = build_codeclimate(&results, &root, &rules);
1022 assert_eq!(output[0]["location"]["lines"]["begin"], 1);
1024 }
1025
1026 #[test]
1029 fn fingerprint_hash_different_inputs_differ() {
1030 let h1 = fingerprint_hash(&["a", "b"]);
1031 let h2 = fingerprint_hash(&["a", "c"]);
1032 assert_ne!(h1, h2);
1033 }
1034
1035 #[test]
1036 fn fingerprint_hash_order_matters() {
1037 let h1 = fingerprint_hash(&["a", "b"]);
1038 let h2 = fingerprint_hash(&["b", "a"]);
1039 assert_ne!(h1, h2);
1040 }
1041
1042 #[test]
1043 fn fingerprint_hash_separator_prevents_collision() {
1044 let h1 = fingerprint_hash(&["ab", "c"]);
1046 let h2 = fingerprint_hash(&["a", "bc"]);
1047 assert_ne!(h1, h2);
1048 }
1049
1050 #[test]
1051 fn fingerprint_hash_is_16_hex_chars() {
1052 let h = fingerprint_hash(&["test"]);
1053 assert_eq!(h.len(), 16);
1054 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
1055 }
1056
1057 #[test]
1060 fn severity_error_maps_to_major() {
1061 assert_eq!(severity_to_codeclimate(Severity::Error), "major");
1062 }
1063
1064 #[test]
1065 fn severity_warn_maps_to_minor() {
1066 assert_eq!(severity_to_codeclimate(Severity::Warn), "minor");
1067 }
1068
1069 #[test]
1070 fn severity_off_maps_to_minor() {
1071 assert_eq!(severity_to_codeclimate(Severity::Off), "minor");
1072 }
1073
1074 #[test]
1077 fn health_severity_zero_threshold_returns_minor() {
1078 assert_eq!(health_severity(100, 0), "minor");
1079 }
1080
1081 #[test]
1082 fn health_severity_at_threshold_returns_minor() {
1083 assert_eq!(health_severity(10, 10), "minor");
1084 }
1085
1086 #[test]
1087 fn health_severity_1_5x_threshold_returns_minor() {
1088 assert_eq!(health_severity(15, 10), "minor");
1089 }
1090
1091 #[test]
1092 fn health_severity_above_1_5x_returns_major() {
1093 assert_eq!(health_severity(16, 10), "major");
1094 }
1095
1096 #[test]
1097 fn health_severity_at_2_5x_returns_major() {
1098 assert_eq!(health_severity(25, 10), "major");
1099 }
1100
1101 #[test]
1102 fn health_severity_above_2_5x_returns_critical() {
1103 assert_eq!(health_severity(26, 10), "critical");
1104 }
1105
1106 #[test]
1107 fn health_codeclimate_includes_coverage_gaps() {
1108 use crate::health_types::*;
1109
1110 let root = PathBuf::from("/project");
1111 let report = HealthReport {
1112 summary: HealthSummary {
1113 files_analyzed: 10,
1114 functions_analyzed: 50,
1115 ..Default::default()
1116 },
1117 coverage_gaps: Some(CoverageGaps {
1118 summary: CoverageGapSummary {
1119 runtime_files: 2,
1120 covered_files: 0,
1121 file_coverage_pct: 0.0,
1122 untested_files: 1,
1123 untested_exports: 1,
1124 },
1125 files: vec![UntestedFile {
1126 path: root.join("src/app.ts"),
1127 value_export_count: 2,
1128 }],
1129 exports: vec![UntestedExport {
1130 path: root.join("src/app.ts"),
1131 export_name: "loader".into(),
1132 line: 12,
1133 col: 4,
1134 }],
1135 }),
1136 ..Default::default()
1137 };
1138
1139 let output = build_health_codeclimate(&report, &root);
1140 let issues = output.as_array().unwrap();
1141 assert_eq!(issues.len(), 2);
1142 assert_eq!(issues[0]["check_name"], "fallow/untested-file");
1143 assert_eq!(issues[0]["categories"][0], "Coverage");
1144 assert_eq!(issues[0]["location"]["path"], "src/app.ts");
1145 assert_eq!(issues[1]["check_name"], "fallow/untested-export");
1146 assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
1147 assert!(
1148 issues[1]["description"]
1149 .as_str()
1150 .unwrap()
1151 .contains("loader")
1152 );
1153 }
1154}