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