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::{emit_json, normalize_uri, relative_path};
9use crate::health_types::{ExceededThreshold, HealthReport};
10
11const fn severity_to_codeclimate(s: Severity) -> &'static str {
13 match s {
14 Severity::Error => "major",
15 Severity::Warn | Severity::Off => "minor",
16 }
17}
18
19fn cc_path(path: &Path, root: &Path) -> String {
24 normalize_uri(&relative_path(path, root).display().to_string())
25}
26
27fn fingerprint_hash(parts: &[&str]) -> String {
32 let mut hash: u64 = 0xcbf2_9ce4_8422_2325; for part in parts {
34 for byte in part.bytes() {
35 hash ^= u64::from(byte);
36 hash = hash.wrapping_mul(0x0100_0000_01b3); }
38 hash ^= 0xff;
40 hash = hash.wrapping_mul(0x0100_0000_01b3);
41 }
42 format!("{hash:016x}")
43}
44
45fn cc_issue(
47 check_name: &str,
48 description: &str,
49 severity: &str,
50 category: &str,
51 path: &str,
52 begin_line: Option<u32>,
53 fingerprint: &str,
54) -> serde_json::Value {
55 let lines = begin_line.map_or_else(
56 || serde_json::json!({ "begin": 1 }),
57 |line| serde_json::json!({ "begin": line }),
58 );
59
60 serde_json::json!({
61 "type": "issue",
62 "check_name": check_name,
63 "description": description,
64 "categories": [category],
65 "severity": severity,
66 "fingerprint": fingerprint,
67 "location": {
68 "path": path,
69 "lines": lines
70 }
71 })
72}
73
74fn push_dep_cc_issues(
76 issues: &mut Vec<serde_json::Value>,
77 deps: &[fallow_core::results::UnusedDependency],
78 root: &Path,
79 rule_id: &str,
80 location_label: &str,
81 severity: Severity,
82) {
83 let level = severity_to_codeclimate(severity);
84 for dep in deps {
85 let path = cc_path(&dep.path, root);
86 let line = if dep.line > 0 { Some(dep.line) } else { None };
87 let fp = fingerprint_hash(&[rule_id, &dep.package_name]);
88 issues.push(cc_issue(
89 rule_id,
90 &format!(
91 "Package '{}' is in {location_label} but never imported",
92 dep.package_name
93 ),
94 level,
95 "Bug Risk",
96 &path,
97 line,
98 &fp,
99 ));
100 }
101}
102
103#[must_use]
105pub fn build_codeclimate(
106 results: &AnalysisResults,
107 root: &Path,
108 rules: &RulesConfig,
109) -> serde_json::Value {
110 let mut issues = Vec::new();
111
112 let level = severity_to_codeclimate(rules.unused_files);
114 for file in &results.unused_files {
115 let path = cc_path(&file.path, root);
116 let fp = fingerprint_hash(&["fallow/unused-file", &path]);
117 issues.push(cc_issue(
118 "fallow/unused-file",
119 "File is not reachable from any entry point",
120 level,
121 "Bug Risk",
122 &path,
123 None,
124 &fp,
125 ));
126 }
127
128 let level = severity_to_codeclimate(rules.unused_exports);
130 for export in &results.unused_exports {
131 let path = cc_path(&export.path, root);
132 let kind = if export.is_re_export {
133 "Re-export"
134 } else {
135 "Export"
136 };
137 let line_str = export.line.to_string();
138 let fp = fingerprint_hash(&[
139 "fallow/unused-export",
140 &path,
141 &line_str,
142 &export.export_name,
143 ]);
144 issues.push(cc_issue(
145 "fallow/unused-export",
146 &format!(
147 "{kind} '{}' is never imported by other modules",
148 export.export_name
149 ),
150 level,
151 "Bug Risk",
152 &path,
153 Some(export.line),
154 &fp,
155 ));
156 }
157
158 let level = severity_to_codeclimate(rules.unused_types);
160 for export in &results.unused_types {
161 let path = cc_path(&export.path, root);
162 let kind = if export.is_re_export {
163 "Type re-export"
164 } else {
165 "Type export"
166 };
167 let line_str = export.line.to_string();
168 let fp = fingerprint_hash(&["fallow/unused-type", &path, &line_str, &export.export_name]);
169 issues.push(cc_issue(
170 "fallow/unused-type",
171 &format!(
172 "{kind} '{}' is never imported by other modules",
173 export.export_name
174 ),
175 level,
176 "Bug Risk",
177 &path,
178 Some(export.line),
179 &fp,
180 ));
181 }
182
183 push_dep_cc_issues(
185 &mut issues,
186 &results.unused_dependencies,
187 root,
188 "fallow/unused-dependency",
189 "dependencies",
190 rules.unused_dependencies,
191 );
192 push_dep_cc_issues(
193 &mut issues,
194 &results.unused_dev_dependencies,
195 root,
196 "fallow/unused-dev-dependency",
197 "devDependencies",
198 rules.unused_dev_dependencies,
199 );
200 push_dep_cc_issues(
201 &mut issues,
202 &results.unused_optional_dependencies,
203 root,
204 "fallow/unused-optional-dependency",
205 "optionalDependencies",
206 rules.unused_optional_dependencies,
207 );
208
209 let level = severity_to_codeclimate(rules.type_only_dependencies);
211 for dep in &results.type_only_dependencies {
212 let path = cc_path(&dep.path, root);
213 let line = if dep.line > 0 { Some(dep.line) } else { None };
214 let fp = fingerprint_hash(&["fallow/type-only-dependency", &dep.package_name]);
215 issues.push(cc_issue(
216 "fallow/type-only-dependency",
217 &format!(
218 "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
219 dep.package_name
220 ),
221 level,
222 "Bug Risk",
223 &path,
224 line,
225 &fp,
226 ));
227 }
228
229 let level = severity_to_codeclimate(rules.test_only_dependencies);
231 for dep in &results.test_only_dependencies {
232 let path = cc_path(&dep.path, root);
233 let line = if dep.line > 0 { Some(dep.line) } else { None };
234 let fp = fingerprint_hash(&["fallow/test-only-dependency", &dep.package_name]);
235 issues.push(cc_issue(
236 "fallow/test-only-dependency",
237 &format!(
238 "Package '{}' is only imported by test files (consider moving to devDependencies)",
239 dep.package_name
240 ),
241 level,
242 "Bug Risk",
243 &path,
244 line,
245 &fp,
246 ));
247 }
248
249 let level = severity_to_codeclimate(rules.unused_enum_members);
251 for member in &results.unused_enum_members {
252 let path = cc_path(&member.path, root);
253 let line_str = member.line.to_string();
254 let fp = fingerprint_hash(&[
255 "fallow/unused-enum-member",
256 &path,
257 &line_str,
258 &member.parent_name,
259 &member.member_name,
260 ]);
261 issues.push(cc_issue(
262 "fallow/unused-enum-member",
263 &format!(
264 "Enum member '{}.{}' is never referenced",
265 member.parent_name, member.member_name
266 ),
267 level,
268 "Bug Risk",
269 &path,
270 Some(member.line),
271 &fp,
272 ));
273 }
274
275 let level = severity_to_codeclimate(rules.unused_class_members);
277 for member in &results.unused_class_members {
278 let path = cc_path(&member.path, root);
279 let line_str = member.line.to_string();
280 let fp = fingerprint_hash(&[
281 "fallow/unused-class-member",
282 &path,
283 &line_str,
284 &member.parent_name,
285 &member.member_name,
286 ]);
287 issues.push(cc_issue(
288 "fallow/unused-class-member",
289 &format!(
290 "Class member '{}.{}' is never referenced",
291 member.parent_name, member.member_name
292 ),
293 level,
294 "Bug Risk",
295 &path,
296 Some(member.line),
297 &fp,
298 ));
299 }
300
301 let level = severity_to_codeclimate(rules.unresolved_imports);
303 for import in &results.unresolved_imports {
304 let path = cc_path(&import.path, root);
305 let line_str = import.line.to_string();
306 let fp = fingerprint_hash(&[
307 "fallow/unresolved-import",
308 &path,
309 &line_str,
310 &import.specifier,
311 ]);
312 issues.push(cc_issue(
313 "fallow/unresolved-import",
314 &format!("Import '{}' could not be resolved", import.specifier),
315 level,
316 "Bug Risk",
317 &path,
318 Some(import.line),
319 &fp,
320 ));
321 }
322
323 let level = severity_to_codeclimate(rules.unlisted_dependencies);
325 for dep in &results.unlisted_dependencies {
326 for site in &dep.imported_from {
327 let path = cc_path(&site.path, root);
328 let line_str = site.line.to_string();
329 let fp = fingerprint_hash(&[
330 "fallow/unlisted-dependency",
331 &path,
332 &line_str,
333 &dep.package_name,
334 ]);
335 issues.push(cc_issue(
336 "fallow/unlisted-dependency",
337 &format!(
338 "Package '{}' is imported but not listed in package.json",
339 dep.package_name
340 ),
341 level,
342 "Bug Risk",
343 &path,
344 Some(site.line),
345 &fp,
346 ));
347 }
348 }
349
350 let level = severity_to_codeclimate(rules.duplicate_exports);
352 for dup in &results.duplicate_exports {
353 for loc in &dup.locations {
354 let path = cc_path(&loc.path, root);
355 let line_str = loc.line.to_string();
356 let fp = fingerprint_hash(&[
357 "fallow/duplicate-export",
358 &path,
359 &line_str,
360 &dup.export_name,
361 ]);
362 issues.push(cc_issue(
363 "fallow/duplicate-export",
364 &format!("Export '{}' appears in multiple modules", dup.export_name),
365 level,
366 "Bug Risk",
367 &path,
368 Some(loc.line),
369 &fp,
370 ));
371 }
372 }
373
374 let level = severity_to_codeclimate(rules.circular_dependencies);
376 for cycle in &results.circular_dependencies {
377 let Some(first) = cycle.files.first() else {
378 continue;
379 };
380 let path = cc_path(first, root);
381 let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
382 let chain_str = chain.join(":");
383 let fp = fingerprint_hash(&["fallow/circular-dependency", &chain_str]);
384 let line = if cycle.line > 0 {
385 Some(cycle.line)
386 } else {
387 None
388 };
389 issues.push(cc_issue(
390 "fallow/circular-dependency",
391 &format!("Circular dependency: {}", chain.join(" \u{2192} ")),
392 level,
393 "Bug Risk",
394 &path,
395 line,
396 &fp,
397 ));
398 }
399
400 let level = severity_to_codeclimate(rules.boundary_violation);
402 for v in &results.boundary_violations {
403 let path = cc_path(&v.from_path, root);
404 let to = cc_path(&v.to_path, root);
405 let fp = fingerprint_hash(&["fallow/boundary-violation", &path, &to]);
406 let line = if v.line > 0 { Some(v.line) } else { None };
407 issues.push(cc_issue(
408 "fallow/boundary-violation",
409 &format!(
410 "Boundary violation: {} -> {} ({} -> {})",
411 path, to, v.from_zone, v.to_zone
412 ),
413 level,
414 "Bug Risk",
415 &path,
416 line,
417 &fp,
418 ));
419 }
420
421 serde_json::Value::Array(issues)
422}
423
424pub(super) fn print_codeclimate(
426 results: &AnalysisResults,
427 root: &Path,
428 rules: &RulesConfig,
429) -> ExitCode {
430 let value = build_codeclimate(results, root, rules);
431 emit_json(&value, "CodeClimate")
432}
433
434fn health_severity(value: u16, threshold: u16) -> &'static str {
440 if threshold == 0 {
441 return "minor";
442 }
443 let ratio = f64::from(value) / f64::from(threshold);
444 if ratio > 2.5 {
445 "critical"
446 } else if ratio > 1.5 {
447 "major"
448 } else {
449 "minor"
450 }
451}
452
453#[must_use]
455pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> serde_json::Value {
456 let mut issues = Vec::new();
457
458 let cyc_t = report.summary.max_cyclomatic_threshold;
459 let cog_t = report.summary.max_cognitive_threshold;
460
461 for finding in &report.findings {
462 let path = cc_path(&finding.path, root);
463 let description = match finding.exceeded {
464 ExceededThreshold::Both => format!(
465 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
466 finding.name, finding.cyclomatic, cyc_t, finding.cognitive, cog_t
467 ),
468 ExceededThreshold::Cyclomatic => format!(
469 "'{}' has cyclomatic complexity {} (threshold: {})",
470 finding.name, finding.cyclomatic, cyc_t
471 ),
472 ExceededThreshold::Cognitive => format!(
473 "'{}' has cognitive complexity {} (threshold: {})",
474 finding.name, finding.cognitive, cog_t
475 ),
476 };
477 let check_name = match finding.exceeded {
478 ExceededThreshold::Both => "fallow/high-complexity",
479 ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
480 ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
481 };
482 let severity = match finding.exceeded {
484 ExceededThreshold::Both => {
485 let cyc_sev = health_severity(finding.cyclomatic, cyc_t);
486 let cog_sev = health_severity(finding.cognitive, cog_t);
487 match (cyc_sev, cog_sev) {
489 ("critical", _) | (_, "critical") => "critical",
490 ("major", _) | (_, "major") => "major",
491 _ => "minor",
492 }
493 }
494 ExceededThreshold::Cyclomatic => health_severity(finding.cyclomatic, cyc_t),
495 ExceededThreshold::Cognitive => health_severity(finding.cognitive, cog_t),
496 };
497 let line_str = finding.line.to_string();
498 let fp = fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
499 issues.push(cc_issue(
500 check_name,
501 &description,
502 severity,
503 "Complexity",
504 &path,
505 Some(finding.line),
506 &fp,
507 ));
508 }
509
510 serde_json::Value::Array(issues)
511}
512
513pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
515 let value = build_health_codeclimate(report, root);
516 emit_json(&value, "CodeClimate")
517}
518
519#[must_use]
521#[expect(
522 clippy::cast_possible_truncation,
523 reason = "line numbers are bounded by source size"
524)]
525pub fn build_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> serde_json::Value {
526 let mut issues = Vec::new();
527
528 for (i, group) in report.clone_groups.iter().enumerate() {
529 let token_str = group.token_count.to_string();
532 let line_count_str = group.line_count.to_string();
533 let fragment_prefix: String = group
534 .instances
535 .first()
536 .map(|inst| inst.fragment.chars().take(64).collect())
537 .unwrap_or_default();
538
539 for instance in &group.instances {
540 let path = cc_path(&instance.file, root);
541 let start_str = instance.start_line.to_string();
542 let fp = fingerprint_hash(&[
543 "fallow/code-duplication",
544 &path,
545 &start_str,
546 &token_str,
547 &line_count_str,
548 &fragment_prefix,
549 ]);
550 issues.push(cc_issue(
551 "fallow/code-duplication",
552 &format!(
553 "Code clone group {} ({} lines, {} instances)",
554 i + 1,
555 group.line_count,
556 group.instances.len()
557 ),
558 "minor",
559 "Duplication",
560 &path,
561 Some(instance.start_line as u32),
562 &fp,
563 ));
564 }
565 }
566
567 serde_json::Value::Array(issues)
568}
569
570pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
572 let value = build_duplication_codeclimate(report, root);
573 emit_json(&value, "CodeClimate")
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579 use crate::report::test_helpers::sample_results;
580 use fallow_config::RulesConfig;
581 use fallow_core::results::*;
582 use std::path::PathBuf;
583
584 #[test]
585 fn codeclimate_empty_results_produces_empty_array() {
586 let root = PathBuf::from("/project");
587 let results = AnalysisResults::default();
588 let rules = RulesConfig::default();
589 let output = build_codeclimate(&results, &root, &rules);
590 let arr = output.as_array().unwrap();
591 assert!(arr.is_empty());
592 }
593
594 #[test]
595 fn codeclimate_produces_array_of_issues() {
596 let root = PathBuf::from("/project");
597 let results = sample_results(&root);
598 let rules = RulesConfig::default();
599 let output = build_codeclimate(&results, &root, &rules);
600 assert!(output.is_array());
601 let arr = output.as_array().unwrap();
602 assert!(!arr.is_empty());
604 }
605
606 #[test]
607 fn codeclimate_issue_has_required_fields() {
608 let root = PathBuf::from("/project");
609 let mut results = AnalysisResults::default();
610 results.unused_files.push(UnusedFile {
611 path: root.join("src/dead.ts"),
612 });
613 let rules = RulesConfig::default();
614 let output = build_codeclimate(&results, &root, &rules);
615 let issue = &output.as_array().unwrap()[0];
616
617 assert_eq!(issue["type"], "issue");
618 assert_eq!(issue["check_name"], "fallow/unused-file");
619 assert!(issue["description"].is_string());
620 assert!(issue["categories"].is_array());
621 assert!(issue["severity"].is_string());
622 assert!(issue["fingerprint"].is_string());
623 assert!(issue["location"].is_object());
624 assert!(issue["location"]["path"].is_string());
625 assert!(issue["location"]["lines"].is_object());
626 }
627
628 #[test]
629 fn codeclimate_unused_file_severity_follows_rules() {
630 let root = PathBuf::from("/project");
631 let mut results = AnalysisResults::default();
632 results.unused_files.push(UnusedFile {
633 path: root.join("src/dead.ts"),
634 });
635
636 let rules = RulesConfig::default();
638 let output = build_codeclimate(&results, &root, &rules);
639 assert_eq!(output[0]["severity"], "major");
640
641 let rules = RulesConfig {
643 unused_files: Severity::Warn,
644 ..RulesConfig::default()
645 };
646 let output = build_codeclimate(&results, &root, &rules);
647 assert_eq!(output[0]["severity"], "minor");
648 }
649
650 #[test]
651 fn codeclimate_unused_export_has_line_number() {
652 let root = PathBuf::from("/project");
653 let mut results = AnalysisResults::default();
654 results.unused_exports.push(UnusedExport {
655 path: root.join("src/utils.ts"),
656 export_name: "helperFn".to_string(),
657 is_type_only: false,
658 line: 10,
659 col: 4,
660 span_start: 120,
661 is_re_export: false,
662 });
663 let rules = RulesConfig::default();
664 let output = build_codeclimate(&results, &root, &rules);
665 let issue = &output[0];
666 assert_eq!(issue["location"]["lines"]["begin"], 10);
667 }
668
669 #[test]
670 fn codeclimate_unused_file_line_defaults_to_1() {
671 let root = PathBuf::from("/project");
672 let mut results = AnalysisResults::default();
673 results.unused_files.push(UnusedFile {
674 path: root.join("src/dead.ts"),
675 });
676 let rules = RulesConfig::default();
677 let output = build_codeclimate(&results, &root, &rules);
678 let issue = &output[0];
679 assert_eq!(issue["location"]["lines"]["begin"], 1);
680 }
681
682 #[test]
683 fn codeclimate_paths_are_relative() {
684 let root = PathBuf::from("/project");
685 let mut results = AnalysisResults::default();
686 results.unused_files.push(UnusedFile {
687 path: root.join("src/deep/nested/file.ts"),
688 });
689 let rules = RulesConfig::default();
690 let output = build_codeclimate(&results, &root, &rules);
691 let path = output[0]["location"]["path"].as_str().unwrap();
692 assert_eq!(path, "src/deep/nested/file.ts");
693 assert!(!path.starts_with("/project"));
694 }
695
696 #[test]
697 fn codeclimate_re_export_label_in_description() {
698 let root = PathBuf::from("/project");
699 let mut results = AnalysisResults::default();
700 results.unused_exports.push(UnusedExport {
701 path: root.join("src/index.ts"),
702 export_name: "reExported".to_string(),
703 is_type_only: false,
704 line: 1,
705 col: 0,
706 span_start: 0,
707 is_re_export: true,
708 });
709 let rules = RulesConfig::default();
710 let output = build_codeclimate(&results, &root, &rules);
711 let desc = output[0]["description"].as_str().unwrap();
712 assert!(desc.contains("Re-export"));
713 }
714
715 #[test]
716 fn codeclimate_unlisted_dep_one_issue_per_import_site() {
717 let root = PathBuf::from("/project");
718 let mut results = AnalysisResults::default();
719 results.unlisted_dependencies.push(UnlistedDependency {
720 package_name: "chalk".to_string(),
721 imported_from: vec![
722 ImportSite {
723 path: root.join("src/a.ts"),
724 line: 1,
725 col: 0,
726 },
727 ImportSite {
728 path: root.join("src/b.ts"),
729 line: 5,
730 col: 0,
731 },
732 ],
733 });
734 let rules = RulesConfig::default();
735 let output = build_codeclimate(&results, &root, &rules);
736 let arr = output.as_array().unwrap();
737 assert_eq!(arr.len(), 2);
738 assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
739 assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
740 }
741
742 #[test]
743 fn codeclimate_duplicate_export_one_issue_per_location() {
744 let root = PathBuf::from("/project");
745 let mut results = AnalysisResults::default();
746 results.duplicate_exports.push(DuplicateExport {
747 export_name: "Config".to_string(),
748 locations: vec![
749 DuplicateLocation {
750 path: root.join("src/a.ts"),
751 line: 10,
752 col: 0,
753 },
754 DuplicateLocation {
755 path: root.join("src/b.ts"),
756 line: 20,
757 col: 0,
758 },
759 DuplicateLocation {
760 path: root.join("src/c.ts"),
761 line: 30,
762 col: 0,
763 },
764 ],
765 });
766 let rules = RulesConfig::default();
767 let output = build_codeclimate(&results, &root, &rules);
768 let arr = output.as_array().unwrap();
769 assert_eq!(arr.len(), 3);
770 }
771
772 #[test]
773 fn codeclimate_circular_dep_emits_chain_in_description() {
774 let root = PathBuf::from("/project");
775 let mut results = AnalysisResults::default();
776 results.circular_dependencies.push(CircularDependency {
777 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
778 length: 2,
779 line: 3,
780 col: 0,
781 });
782 let rules = RulesConfig::default();
783 let output = build_codeclimate(&results, &root, &rules);
784 let desc = output[0]["description"].as_str().unwrap();
785 assert!(desc.contains("Circular dependency"));
786 assert!(desc.contains("src/a.ts"));
787 assert!(desc.contains("src/b.ts"));
788 }
789
790 #[test]
791 fn codeclimate_fingerprints_are_deterministic() {
792 let root = PathBuf::from("/project");
793 let results = sample_results(&root);
794 let rules = RulesConfig::default();
795 let output1 = build_codeclimate(&results, &root, &rules);
796 let output2 = build_codeclimate(&results, &root, &rules);
797
798 let fps1: Vec<&str> = output1
799 .as_array()
800 .unwrap()
801 .iter()
802 .map(|i| i["fingerprint"].as_str().unwrap())
803 .collect();
804 let fps2: Vec<&str> = output2
805 .as_array()
806 .unwrap()
807 .iter()
808 .map(|i| i["fingerprint"].as_str().unwrap())
809 .collect();
810 assert_eq!(fps1, fps2);
811 }
812
813 #[test]
814 fn codeclimate_fingerprints_are_unique() {
815 let root = PathBuf::from("/project");
816 let results = sample_results(&root);
817 let rules = RulesConfig::default();
818 let output = build_codeclimate(&results, &root, &rules);
819
820 let mut fps: Vec<&str> = output
821 .as_array()
822 .unwrap()
823 .iter()
824 .map(|i| i["fingerprint"].as_str().unwrap())
825 .collect();
826 let original_len = fps.len();
827 fps.sort_unstable();
828 fps.dedup();
829 assert_eq!(fps.len(), original_len, "fingerprints should be unique");
830 }
831
832 #[test]
833 fn codeclimate_type_only_dep_has_correct_check_name() {
834 let root = PathBuf::from("/project");
835 let mut results = AnalysisResults::default();
836 results.type_only_dependencies.push(TypeOnlyDependency {
837 package_name: "zod".to_string(),
838 path: root.join("package.json"),
839 line: 8,
840 });
841 let rules = RulesConfig::default();
842 let output = build_codeclimate(&results, &root, &rules);
843 assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
844 let desc = output[0]["description"].as_str().unwrap();
845 assert!(desc.contains("zod"));
846 assert!(desc.contains("type-only"));
847 }
848
849 #[test]
850 fn codeclimate_dep_with_zero_line_omits_line_number() {
851 let root = PathBuf::from("/project");
852 let mut results = AnalysisResults::default();
853 results.unused_dependencies.push(UnusedDependency {
854 package_name: "lodash".to_string(),
855 location: DependencyLocation::Dependencies,
856 path: root.join("package.json"),
857 line: 0,
858 });
859 let rules = RulesConfig::default();
860 let output = build_codeclimate(&results, &root, &rules);
861 assert_eq!(output[0]["location"]["lines"]["begin"], 1);
863 }
864
865 #[test]
868 fn fingerprint_hash_different_inputs_differ() {
869 let h1 = fingerprint_hash(&["a", "b"]);
870 let h2 = fingerprint_hash(&["a", "c"]);
871 assert_ne!(h1, h2);
872 }
873
874 #[test]
875 fn fingerprint_hash_order_matters() {
876 let h1 = fingerprint_hash(&["a", "b"]);
877 let h2 = fingerprint_hash(&["b", "a"]);
878 assert_ne!(h1, h2);
879 }
880
881 #[test]
882 fn fingerprint_hash_separator_prevents_collision() {
883 let h1 = fingerprint_hash(&["ab", "c"]);
885 let h2 = fingerprint_hash(&["a", "bc"]);
886 assert_ne!(h1, h2);
887 }
888
889 #[test]
890 fn fingerprint_hash_is_16_hex_chars() {
891 let h = fingerprint_hash(&["test"]);
892 assert_eq!(h.len(), 16);
893 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
894 }
895
896 #[test]
899 fn severity_error_maps_to_major() {
900 assert_eq!(severity_to_codeclimate(Severity::Error), "major");
901 }
902
903 #[test]
904 fn severity_warn_maps_to_minor() {
905 assert_eq!(severity_to_codeclimate(Severity::Warn), "minor");
906 }
907
908 #[test]
909 fn severity_off_maps_to_minor() {
910 assert_eq!(severity_to_codeclimate(Severity::Off), "minor");
911 }
912
913 #[test]
916 fn health_severity_zero_threshold_returns_minor() {
917 assert_eq!(health_severity(100, 0), "minor");
918 }
919
920 #[test]
921 fn health_severity_at_threshold_returns_minor() {
922 assert_eq!(health_severity(10, 10), "minor");
923 }
924
925 #[test]
926 fn health_severity_1_5x_threshold_returns_minor() {
927 assert_eq!(health_severity(15, 10), "minor");
928 }
929
930 #[test]
931 fn health_severity_above_1_5x_returns_major() {
932 assert_eq!(health_severity(16, 10), "major");
933 }
934
935 #[test]
936 fn health_severity_at_2_5x_returns_major() {
937 assert_eq!(health_severity(25, 10), "major");
938 }
939
940 #[test]
941 fn health_severity_above_2_5x_returns_critical() {
942 assert_eq!(health_severity(26, 10), "critical");
943 }
944}