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