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