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!("Circular dependency: {}", chain.join(" \u{2192} ")),
393 level,
394 "Bug Risk",
395 &path,
396 line,
397 &fp,
398 ));
399 }
400
401 let level = severity_to_codeclimate(rules.boundary_violation);
403 for v in &results.boundary_violations {
404 let path = cc_path(&v.from_path, root);
405 let to = cc_path(&v.to_path, root);
406 let fp = fingerprint_hash(&["fallow/boundary-violation", &path, &to]);
407 let line = if v.line > 0 { Some(v.line) } else { None };
408 issues.push(cc_issue(
409 "fallow/boundary-violation",
410 &format!(
411 "Boundary violation: {} -> {} ({} -> {})",
412 path, to, v.from_zone, v.to_zone
413 ),
414 level,
415 "Bug Risk",
416 &path,
417 line,
418 &fp,
419 ));
420 }
421
422 serde_json::Value::Array(issues)
423}
424
425pub(super) fn print_codeclimate(
427 results: &AnalysisResults,
428 root: &Path,
429 rules: &RulesConfig,
430) -> ExitCode {
431 let value = build_codeclimate(results, root, rules);
432 emit_json(&value, "CodeClimate")
433}
434
435pub(super) fn print_grouped_codeclimate(
441 results: &AnalysisResults,
442 root: &Path,
443 rules: &RulesConfig,
444 resolver: &OwnershipResolver,
445) {
446 let mut value = build_codeclimate(results, root, rules);
447
448 if let Some(issues) = value.as_array_mut() {
449 for issue in issues {
450 let path = issue
451 .pointer("/location/path")
452 .and_then(|v| v.as_str())
453 .unwrap_or("");
454 let owner = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
455 issue
456 .as_object_mut()
457 .expect("CodeClimate issue should be an object")
458 .insert("owner".to_string(), serde_json::Value::String(owner));
459 }
460 }
461
462 let _ = emit_json(&value, "CodeClimate");
463}
464
465fn health_severity(value: u16, threshold: u16) -> &'static str {
471 if threshold == 0 {
472 return "minor";
473 }
474 let ratio = f64::from(value) / f64::from(threshold);
475 if ratio > 2.5 {
476 "critical"
477 } else if ratio > 1.5 {
478 "major"
479 } else {
480 "minor"
481 }
482}
483
484#[must_use]
486pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> serde_json::Value {
487 let mut issues = Vec::new();
488
489 let cyc_t = report.summary.max_cyclomatic_threshold;
490 let cog_t = report.summary.max_cognitive_threshold;
491
492 for finding in &report.findings {
493 let path = cc_path(&finding.path, root);
494 let description = match finding.exceeded {
495 ExceededThreshold::Both => format!(
496 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
497 finding.name, finding.cyclomatic, cyc_t, finding.cognitive, cog_t
498 ),
499 ExceededThreshold::Cyclomatic => format!(
500 "'{}' has cyclomatic complexity {} (threshold: {})",
501 finding.name, finding.cyclomatic, cyc_t
502 ),
503 ExceededThreshold::Cognitive => format!(
504 "'{}' has cognitive complexity {} (threshold: {})",
505 finding.name, finding.cognitive, cog_t
506 ),
507 };
508 let check_name = match finding.exceeded {
509 ExceededThreshold::Both => "fallow/high-complexity",
510 ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
511 ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
512 };
513 let severity = match finding.exceeded {
515 ExceededThreshold::Both => {
516 let cyc_sev = health_severity(finding.cyclomatic, cyc_t);
517 let cog_sev = health_severity(finding.cognitive, cog_t);
518 match (cyc_sev, cog_sev) {
520 ("critical", _) | (_, "critical") => "critical",
521 ("major", _) | (_, "major") => "major",
522 _ => "minor",
523 }
524 }
525 ExceededThreshold::Cyclomatic => health_severity(finding.cyclomatic, cyc_t),
526 ExceededThreshold::Cognitive => health_severity(finding.cognitive, cog_t),
527 };
528 let line_str = finding.line.to_string();
529 let fp = fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
530 issues.push(cc_issue(
531 check_name,
532 &description,
533 severity,
534 "Complexity",
535 &path,
536 Some(finding.line),
537 &fp,
538 ));
539 }
540
541 serde_json::Value::Array(issues)
542}
543
544pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
546 let value = build_health_codeclimate(report, root);
547 emit_json(&value, "CodeClimate")
548}
549
550#[must_use]
552#[expect(
553 clippy::cast_possible_truncation,
554 reason = "line numbers are bounded by source size"
555)]
556pub fn build_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> serde_json::Value {
557 let mut issues = Vec::new();
558
559 for (i, group) in report.clone_groups.iter().enumerate() {
560 let token_str = group.token_count.to_string();
563 let line_count_str = group.line_count.to_string();
564 let fragment_prefix: String = group
565 .instances
566 .first()
567 .map(|inst| inst.fragment.chars().take(64).collect())
568 .unwrap_or_default();
569
570 for instance in &group.instances {
571 let path = cc_path(&instance.file, root);
572 let start_str = instance.start_line.to_string();
573 let fp = fingerprint_hash(&[
574 "fallow/code-duplication",
575 &path,
576 &start_str,
577 &token_str,
578 &line_count_str,
579 &fragment_prefix,
580 ]);
581 issues.push(cc_issue(
582 "fallow/code-duplication",
583 &format!(
584 "Code clone group {} ({} lines, {} instances)",
585 i + 1,
586 group.line_count,
587 group.instances.len()
588 ),
589 "minor",
590 "Duplication",
591 &path,
592 Some(instance.start_line as u32),
593 &fp,
594 ));
595 }
596 }
597
598 serde_json::Value::Array(issues)
599}
600
601pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
603 let value = build_duplication_codeclimate(report, root);
604 emit_json(&value, "CodeClimate")
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610 use crate::report::test_helpers::sample_results;
611 use fallow_config::RulesConfig;
612 use fallow_core::results::*;
613 use std::path::PathBuf;
614
615 #[test]
616 fn codeclimate_empty_results_produces_empty_array() {
617 let root = PathBuf::from("/project");
618 let results = AnalysisResults::default();
619 let rules = RulesConfig::default();
620 let output = build_codeclimate(&results, &root, &rules);
621 let arr = output.as_array().unwrap();
622 assert!(arr.is_empty());
623 }
624
625 #[test]
626 fn codeclimate_produces_array_of_issues() {
627 let root = PathBuf::from("/project");
628 let results = sample_results(&root);
629 let rules = RulesConfig::default();
630 let output = build_codeclimate(&results, &root, &rules);
631 assert!(output.is_array());
632 let arr = output.as_array().unwrap();
633 assert!(!arr.is_empty());
635 }
636
637 #[test]
638 fn codeclimate_issue_has_required_fields() {
639 let root = PathBuf::from("/project");
640 let mut results = AnalysisResults::default();
641 results.unused_files.push(UnusedFile {
642 path: root.join("src/dead.ts"),
643 });
644 let rules = RulesConfig::default();
645 let output = build_codeclimate(&results, &root, &rules);
646 let issue = &output.as_array().unwrap()[0];
647
648 assert_eq!(issue["type"], "issue");
649 assert_eq!(issue["check_name"], "fallow/unused-file");
650 assert!(issue["description"].is_string());
651 assert!(issue["categories"].is_array());
652 assert!(issue["severity"].is_string());
653 assert!(issue["fingerprint"].is_string());
654 assert!(issue["location"].is_object());
655 assert!(issue["location"]["path"].is_string());
656 assert!(issue["location"]["lines"].is_object());
657 }
658
659 #[test]
660 fn codeclimate_unused_file_severity_follows_rules() {
661 let root = PathBuf::from("/project");
662 let mut results = AnalysisResults::default();
663 results.unused_files.push(UnusedFile {
664 path: root.join("src/dead.ts"),
665 });
666
667 let rules = RulesConfig::default();
669 let output = build_codeclimate(&results, &root, &rules);
670 assert_eq!(output[0]["severity"], "major");
671
672 let rules = RulesConfig {
674 unused_files: Severity::Warn,
675 ..RulesConfig::default()
676 };
677 let output = build_codeclimate(&results, &root, &rules);
678 assert_eq!(output[0]["severity"], "minor");
679 }
680
681 #[test]
682 fn codeclimate_unused_export_has_line_number() {
683 let root = PathBuf::from("/project");
684 let mut results = AnalysisResults::default();
685 results.unused_exports.push(UnusedExport {
686 path: root.join("src/utils.ts"),
687 export_name: "helperFn".to_string(),
688 is_type_only: false,
689 line: 10,
690 col: 4,
691 span_start: 120,
692 is_re_export: false,
693 });
694 let rules = RulesConfig::default();
695 let output = build_codeclimate(&results, &root, &rules);
696 let issue = &output[0];
697 assert_eq!(issue["location"]["lines"]["begin"], 10);
698 }
699
700 #[test]
701 fn codeclimate_unused_file_line_defaults_to_1() {
702 let root = PathBuf::from("/project");
703 let mut results = AnalysisResults::default();
704 results.unused_files.push(UnusedFile {
705 path: root.join("src/dead.ts"),
706 });
707 let rules = RulesConfig::default();
708 let output = build_codeclimate(&results, &root, &rules);
709 let issue = &output[0];
710 assert_eq!(issue["location"]["lines"]["begin"], 1);
711 }
712
713 #[test]
714 fn codeclimate_paths_are_relative() {
715 let root = PathBuf::from("/project");
716 let mut results = AnalysisResults::default();
717 results.unused_files.push(UnusedFile {
718 path: root.join("src/deep/nested/file.ts"),
719 });
720 let rules = RulesConfig::default();
721 let output = build_codeclimate(&results, &root, &rules);
722 let path = output[0]["location"]["path"].as_str().unwrap();
723 assert_eq!(path, "src/deep/nested/file.ts");
724 assert!(!path.starts_with("/project"));
725 }
726
727 #[test]
728 fn codeclimate_re_export_label_in_description() {
729 let root = PathBuf::from("/project");
730 let mut results = AnalysisResults::default();
731 results.unused_exports.push(UnusedExport {
732 path: root.join("src/index.ts"),
733 export_name: "reExported".to_string(),
734 is_type_only: false,
735 line: 1,
736 col: 0,
737 span_start: 0,
738 is_re_export: true,
739 });
740 let rules = RulesConfig::default();
741 let output = build_codeclimate(&results, &root, &rules);
742 let desc = output[0]["description"].as_str().unwrap();
743 assert!(desc.contains("Re-export"));
744 }
745
746 #[test]
747 fn codeclimate_unlisted_dep_one_issue_per_import_site() {
748 let root = PathBuf::from("/project");
749 let mut results = AnalysisResults::default();
750 results.unlisted_dependencies.push(UnlistedDependency {
751 package_name: "chalk".to_string(),
752 imported_from: vec![
753 ImportSite {
754 path: root.join("src/a.ts"),
755 line: 1,
756 col: 0,
757 },
758 ImportSite {
759 path: root.join("src/b.ts"),
760 line: 5,
761 col: 0,
762 },
763 ],
764 });
765 let rules = RulesConfig::default();
766 let output = build_codeclimate(&results, &root, &rules);
767 let arr = output.as_array().unwrap();
768 assert_eq!(arr.len(), 2);
769 assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
770 assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
771 }
772
773 #[test]
774 fn codeclimate_duplicate_export_one_issue_per_location() {
775 let root = PathBuf::from("/project");
776 let mut results = AnalysisResults::default();
777 results.duplicate_exports.push(DuplicateExport {
778 export_name: "Config".to_string(),
779 locations: vec![
780 DuplicateLocation {
781 path: root.join("src/a.ts"),
782 line: 10,
783 col: 0,
784 },
785 DuplicateLocation {
786 path: root.join("src/b.ts"),
787 line: 20,
788 col: 0,
789 },
790 DuplicateLocation {
791 path: root.join("src/c.ts"),
792 line: 30,
793 col: 0,
794 },
795 ],
796 });
797 let rules = RulesConfig::default();
798 let output = build_codeclimate(&results, &root, &rules);
799 let arr = output.as_array().unwrap();
800 assert_eq!(arr.len(), 3);
801 }
802
803 #[test]
804 fn codeclimate_circular_dep_emits_chain_in_description() {
805 let root = PathBuf::from("/project");
806 let mut results = AnalysisResults::default();
807 results.circular_dependencies.push(CircularDependency {
808 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
809 length: 2,
810 line: 3,
811 col: 0,
812 });
813 let rules = RulesConfig::default();
814 let output = build_codeclimate(&results, &root, &rules);
815 let desc = output[0]["description"].as_str().unwrap();
816 assert!(desc.contains("Circular dependency"));
817 assert!(desc.contains("src/a.ts"));
818 assert!(desc.contains("src/b.ts"));
819 }
820
821 #[test]
822 fn codeclimate_fingerprints_are_deterministic() {
823 let root = PathBuf::from("/project");
824 let results = sample_results(&root);
825 let rules = RulesConfig::default();
826 let output1 = build_codeclimate(&results, &root, &rules);
827 let output2 = build_codeclimate(&results, &root, &rules);
828
829 let fps1: Vec<&str> = output1
830 .as_array()
831 .unwrap()
832 .iter()
833 .map(|i| i["fingerprint"].as_str().unwrap())
834 .collect();
835 let fps2: Vec<&str> = output2
836 .as_array()
837 .unwrap()
838 .iter()
839 .map(|i| i["fingerprint"].as_str().unwrap())
840 .collect();
841 assert_eq!(fps1, fps2);
842 }
843
844 #[test]
845 fn codeclimate_fingerprints_are_unique() {
846 let root = PathBuf::from("/project");
847 let results = sample_results(&root);
848 let rules = RulesConfig::default();
849 let output = build_codeclimate(&results, &root, &rules);
850
851 let mut fps: Vec<&str> = output
852 .as_array()
853 .unwrap()
854 .iter()
855 .map(|i| i["fingerprint"].as_str().unwrap())
856 .collect();
857 let original_len = fps.len();
858 fps.sort_unstable();
859 fps.dedup();
860 assert_eq!(fps.len(), original_len, "fingerprints should be unique");
861 }
862
863 #[test]
864 fn codeclimate_type_only_dep_has_correct_check_name() {
865 let root = PathBuf::from("/project");
866 let mut results = AnalysisResults::default();
867 results.type_only_dependencies.push(TypeOnlyDependency {
868 package_name: "zod".to_string(),
869 path: root.join("package.json"),
870 line: 8,
871 });
872 let rules = RulesConfig::default();
873 let output = build_codeclimate(&results, &root, &rules);
874 assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
875 let desc = output[0]["description"].as_str().unwrap();
876 assert!(desc.contains("zod"));
877 assert!(desc.contains("type-only"));
878 }
879
880 #[test]
881 fn codeclimate_dep_with_zero_line_omits_line_number() {
882 let root = PathBuf::from("/project");
883 let mut results = AnalysisResults::default();
884 results.unused_dependencies.push(UnusedDependency {
885 package_name: "lodash".to_string(),
886 location: DependencyLocation::Dependencies,
887 path: root.join("package.json"),
888 line: 0,
889 });
890 let rules = RulesConfig::default();
891 let output = build_codeclimate(&results, &root, &rules);
892 assert_eq!(output[0]["location"]["lines"]["begin"], 1);
894 }
895
896 #[test]
899 fn fingerprint_hash_different_inputs_differ() {
900 let h1 = fingerprint_hash(&["a", "b"]);
901 let h2 = fingerprint_hash(&["a", "c"]);
902 assert_ne!(h1, h2);
903 }
904
905 #[test]
906 fn fingerprint_hash_order_matters() {
907 let h1 = fingerprint_hash(&["a", "b"]);
908 let h2 = fingerprint_hash(&["b", "a"]);
909 assert_ne!(h1, h2);
910 }
911
912 #[test]
913 fn fingerprint_hash_separator_prevents_collision() {
914 let h1 = fingerprint_hash(&["ab", "c"]);
916 let h2 = fingerprint_hash(&["a", "bc"]);
917 assert_ne!(h1, h2);
918 }
919
920 #[test]
921 fn fingerprint_hash_is_16_hex_chars() {
922 let h = fingerprint_hash(&["test"]);
923 assert_eq!(h.len(), 16);
924 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
925 }
926
927 #[test]
930 fn severity_error_maps_to_major() {
931 assert_eq!(severity_to_codeclimate(Severity::Error), "major");
932 }
933
934 #[test]
935 fn severity_warn_maps_to_minor() {
936 assert_eq!(severity_to_codeclimate(Severity::Warn), "minor");
937 }
938
939 #[test]
940 fn severity_off_maps_to_minor() {
941 assert_eq!(severity_to_codeclimate(Severity::Off), "minor");
942 }
943
944 #[test]
947 fn health_severity_zero_threshold_returns_minor() {
948 assert_eq!(health_severity(100, 0), "minor");
949 }
950
951 #[test]
952 fn health_severity_at_threshold_returns_minor() {
953 assert_eq!(health_severity(10, 10), "minor");
954 }
955
956 #[test]
957 fn health_severity_1_5x_threshold_returns_minor() {
958 assert_eq!(health_severity(15, 10), "minor");
959 }
960
961 #[test]
962 fn health_severity_above_1_5x_returns_major() {
963 assert_eq!(health_severity(16, 10), "major");
964 }
965
966 #[test]
967 fn health_severity_at_2_5x_returns_major() {
968 assert_eq!(health_severity(25, 10), "major");
969 }
970
971 #[test]
972 fn health_severity_above_2_5x_returns_critical() {
973 assert_eq!(health_severity(26, 10), "critical");
974 }
975}