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
104fn push_unused_file_issues(
105 issues: &mut Vec<serde_json::Value>,
106 files: &[fallow_core::results::UnusedFile],
107 root: &Path,
108 severity: Severity,
109) {
110 let level = severity_to_codeclimate(severity);
111 for file in files {
112 let path = cc_path(&file.path, root);
113 let fp = fingerprint_hash(&["fallow/unused-file", &path]);
114 issues.push(cc_issue(
115 "fallow/unused-file",
116 "File is not reachable from any entry point",
117 level,
118 "Bug Risk",
119 &path,
120 None,
121 &fp,
122 ));
123 }
124}
125
126fn push_unused_export_issues(
132 issues: &mut Vec<serde_json::Value>,
133 exports: &[fallow_core::results::UnusedExport],
134 root: &Path,
135 rule_id: &str,
136 direct_label: &str,
137 re_export_label: &str,
138 severity: Severity,
139) {
140 let level = severity_to_codeclimate(severity);
141 for export in exports {
142 let path = cc_path(&export.path, root);
143 let kind = if export.is_re_export {
144 re_export_label
145 } else {
146 direct_label
147 };
148 let line_str = export.line.to_string();
149 let fp = fingerprint_hash(&[rule_id, &path, &line_str, &export.export_name]);
150 issues.push(cc_issue(
151 rule_id,
152 &format!(
153 "{kind} '{}' is never imported by other modules",
154 export.export_name
155 ),
156 level,
157 "Bug Risk",
158 &path,
159 Some(export.line),
160 &fp,
161 ));
162 }
163}
164
165fn push_type_only_dep_issues(
166 issues: &mut Vec<serde_json::Value>,
167 deps: &[fallow_core::results::TypeOnlyDependency],
168 root: &Path,
169 severity: Severity,
170) {
171 let level = severity_to_codeclimate(severity);
172 for dep in deps {
173 let path = cc_path(&dep.path, root);
174 let line = if dep.line > 0 { Some(dep.line) } else { None };
175 let fp = fingerprint_hash(&["fallow/type-only-dependency", &dep.package_name]);
176 issues.push(cc_issue(
177 "fallow/type-only-dependency",
178 &format!(
179 "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
180 dep.package_name
181 ),
182 level,
183 "Bug Risk",
184 &path,
185 line,
186 &fp,
187 ));
188 }
189}
190
191fn push_test_only_dep_issues(
192 issues: &mut Vec<serde_json::Value>,
193 deps: &[fallow_core::results::TestOnlyDependency],
194 root: &Path,
195 severity: Severity,
196) {
197 let level = severity_to_codeclimate(severity);
198 for dep in deps {
199 let path = cc_path(&dep.path, root);
200 let line = if dep.line > 0 { Some(dep.line) } else { None };
201 let fp = fingerprint_hash(&["fallow/test-only-dependency", &dep.package_name]);
202 issues.push(cc_issue(
203 "fallow/test-only-dependency",
204 &format!(
205 "Package '{}' is only imported by test files (consider moving to devDependencies)",
206 dep.package_name
207 ),
208 level,
209 "Bug Risk",
210 &path,
211 line,
212 &fp,
213 ));
214 }
215}
216
217fn push_unused_member_issues(
222 issues: &mut Vec<serde_json::Value>,
223 members: &[fallow_core::results::UnusedMember],
224 root: &Path,
225 rule_id: &str,
226 entity_label: &str,
227 severity: Severity,
228) {
229 let level = severity_to_codeclimate(severity);
230 for member in members {
231 let path = cc_path(&member.path, root);
232 let line_str = member.line.to_string();
233 let fp = fingerprint_hash(&[
234 rule_id,
235 &path,
236 &line_str,
237 &member.parent_name,
238 &member.member_name,
239 ]);
240 issues.push(cc_issue(
241 rule_id,
242 &format!(
243 "{entity_label} member '{}.{}' is never referenced",
244 member.parent_name, member.member_name
245 ),
246 level,
247 "Bug Risk",
248 &path,
249 Some(member.line),
250 &fp,
251 ));
252 }
253}
254
255fn push_unresolved_import_issues(
256 issues: &mut Vec<serde_json::Value>,
257 imports: &[fallow_core::results::UnresolvedImport],
258 root: &Path,
259 severity: Severity,
260) {
261 let level = severity_to_codeclimate(severity);
262 for import in imports {
263 let path = cc_path(&import.path, root);
264 let line_str = import.line.to_string();
265 let fp = fingerprint_hash(&[
266 "fallow/unresolved-import",
267 &path,
268 &line_str,
269 &import.specifier,
270 ]);
271 issues.push(cc_issue(
272 "fallow/unresolved-import",
273 &format!("Import '{}' could not be resolved", import.specifier),
274 level,
275 "Bug Risk",
276 &path,
277 Some(import.line),
278 &fp,
279 ));
280 }
281}
282
283fn push_unlisted_dep_issues(
284 issues: &mut Vec<serde_json::Value>,
285 deps: &[fallow_core::results::UnlistedDependency],
286 root: &Path,
287 severity: Severity,
288) {
289 let level = severity_to_codeclimate(severity);
290 for dep in deps {
291 for site in &dep.imported_from {
292 let path = cc_path(&site.path, root);
293 let line_str = site.line.to_string();
294 let fp = fingerprint_hash(&[
295 "fallow/unlisted-dependency",
296 &path,
297 &line_str,
298 &dep.package_name,
299 ]);
300 issues.push(cc_issue(
301 "fallow/unlisted-dependency",
302 &format!(
303 "Package '{}' is imported but not listed in package.json",
304 dep.package_name
305 ),
306 level,
307 "Bug Risk",
308 &path,
309 Some(site.line),
310 &fp,
311 ));
312 }
313 }
314}
315
316fn push_duplicate_export_issues(
317 issues: &mut Vec<serde_json::Value>,
318 dups: &[fallow_core::results::DuplicateExport],
319 root: &Path,
320 severity: Severity,
321) {
322 let level = severity_to_codeclimate(severity);
323 for dup in dups {
324 for loc in &dup.locations {
325 let path = cc_path(&loc.path, root);
326 let line_str = loc.line.to_string();
327 let fp = fingerprint_hash(&[
328 "fallow/duplicate-export",
329 &path,
330 &line_str,
331 &dup.export_name,
332 ]);
333 issues.push(cc_issue(
334 "fallow/duplicate-export",
335 &format!("Export '{}' appears in multiple modules", dup.export_name),
336 level,
337 "Bug Risk",
338 &path,
339 Some(loc.line),
340 &fp,
341 ));
342 }
343 }
344}
345
346fn push_circular_dep_issues(
347 issues: &mut Vec<serde_json::Value>,
348 cycles: &[fallow_core::results::CircularDependency],
349 root: &Path,
350 severity: Severity,
351) {
352 let level = severity_to_codeclimate(severity);
353 for cycle in cycles {
354 let Some(first) = cycle.files.first() else {
355 continue;
356 };
357 let path = cc_path(first, root);
358 let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
359 let chain_str = chain.join(":");
360 let fp = fingerprint_hash(&["fallow/circular-dependency", &chain_str]);
361 let line = if cycle.line > 0 {
362 Some(cycle.line)
363 } else {
364 None
365 };
366 issues.push(cc_issue(
367 "fallow/circular-dependency",
368 &format!(
369 "Circular dependency{}: {}",
370 if cycle.is_cross_package {
371 " (cross-package)"
372 } else {
373 ""
374 },
375 chain.join(" \u{2192} ")
376 ),
377 level,
378 "Bug Risk",
379 &path,
380 line,
381 &fp,
382 ));
383 }
384}
385
386fn push_boundary_violation_issues(
387 issues: &mut Vec<serde_json::Value>,
388 violations: &[fallow_core::results::BoundaryViolation],
389 root: &Path,
390 severity: Severity,
391) {
392 let level = severity_to_codeclimate(severity);
393 for v in violations {
394 let path = cc_path(&v.from_path, root);
395 let to = cc_path(&v.to_path, root);
396 let fp = fingerprint_hash(&["fallow/boundary-violation", &path, &to]);
397 let line = if v.line > 0 { Some(v.line) } else { None };
398 issues.push(cc_issue(
399 "fallow/boundary-violation",
400 &format!(
401 "Boundary violation: {} -> {} ({} -> {})",
402 path, to, v.from_zone, v.to_zone
403 ),
404 level,
405 "Bug Risk",
406 &path,
407 line,
408 &fp,
409 ));
410 }
411}
412
413fn push_stale_suppression_issues(
414 issues: &mut Vec<serde_json::Value>,
415 suppressions: &[fallow_core::results::StaleSuppression],
416 root: &Path,
417 severity: Severity,
418) {
419 let level = severity_to_codeclimate(severity);
420 for s in suppressions {
421 let path = cc_path(&s.path, root);
422 let line_str = s.line.to_string();
423 let fp = fingerprint_hash(&["fallow/stale-suppression", &path, &line_str]);
424 issues.push(cc_issue(
425 "fallow/stale-suppression",
426 &s.description(),
427 level,
428 "Bug Risk",
429 &path,
430 Some(s.line),
431 &fp,
432 ));
433 }
434}
435
436#[must_use]
438pub fn build_codeclimate(
439 results: &AnalysisResults,
440 root: &Path,
441 rules: &RulesConfig,
442) -> serde_json::Value {
443 let mut issues = Vec::new();
444
445 push_unused_file_issues(&mut issues, &results.unused_files, root, rules.unused_files);
446 push_unused_export_issues(
447 &mut issues,
448 &results.unused_exports,
449 root,
450 "fallow/unused-export",
451 "Export",
452 "Re-export",
453 rules.unused_exports,
454 );
455 push_unused_export_issues(
456 &mut issues,
457 &results.unused_types,
458 root,
459 "fallow/unused-type",
460 "Type export",
461 "Type re-export",
462 rules.unused_types,
463 );
464 push_dep_cc_issues(
465 &mut issues,
466 &results.unused_dependencies,
467 root,
468 "fallow/unused-dependency",
469 "dependencies",
470 rules.unused_dependencies,
471 );
472 push_dep_cc_issues(
473 &mut issues,
474 &results.unused_dev_dependencies,
475 root,
476 "fallow/unused-dev-dependency",
477 "devDependencies",
478 rules.unused_dev_dependencies,
479 );
480 push_dep_cc_issues(
481 &mut issues,
482 &results.unused_optional_dependencies,
483 root,
484 "fallow/unused-optional-dependency",
485 "optionalDependencies",
486 rules.unused_optional_dependencies,
487 );
488 push_type_only_dep_issues(
489 &mut issues,
490 &results.type_only_dependencies,
491 root,
492 rules.type_only_dependencies,
493 );
494 push_test_only_dep_issues(
495 &mut issues,
496 &results.test_only_dependencies,
497 root,
498 rules.test_only_dependencies,
499 );
500 push_unused_member_issues(
501 &mut issues,
502 &results.unused_enum_members,
503 root,
504 "fallow/unused-enum-member",
505 "Enum",
506 rules.unused_enum_members,
507 );
508 push_unused_member_issues(
509 &mut issues,
510 &results.unused_class_members,
511 root,
512 "fallow/unused-class-member",
513 "Class",
514 rules.unused_class_members,
515 );
516 push_unresolved_import_issues(
517 &mut issues,
518 &results.unresolved_imports,
519 root,
520 rules.unresolved_imports,
521 );
522 push_unlisted_dep_issues(
523 &mut issues,
524 &results.unlisted_dependencies,
525 root,
526 rules.unlisted_dependencies,
527 );
528 push_duplicate_export_issues(
529 &mut issues,
530 &results.duplicate_exports,
531 root,
532 rules.duplicate_exports,
533 );
534 push_circular_dep_issues(
535 &mut issues,
536 &results.circular_dependencies,
537 root,
538 rules.circular_dependencies,
539 );
540 push_boundary_violation_issues(
541 &mut issues,
542 &results.boundary_violations,
543 root,
544 rules.boundary_violation,
545 );
546 push_stale_suppression_issues(
547 &mut issues,
548 &results.stale_suppressions,
549 root,
550 rules.stale_suppressions,
551 );
552
553 serde_json::Value::Array(issues)
554}
555
556pub(super) fn print_codeclimate(
558 results: &AnalysisResults,
559 root: &Path,
560 rules: &RulesConfig,
561) -> ExitCode {
562 let value = build_codeclimate(results, root, rules);
563 emit_json(&value, "CodeClimate")
564}
565
566pub(super) fn print_grouped_codeclimate(
572 results: &AnalysisResults,
573 root: &Path,
574 rules: &RulesConfig,
575 resolver: &OwnershipResolver,
576) -> ExitCode {
577 let mut value = build_codeclimate(results, root, rules);
578
579 if let Some(issues) = value.as_array_mut() {
580 for issue in issues {
581 let path = issue
582 .pointer("/location/path")
583 .and_then(|v| v.as_str())
584 .unwrap_or("");
585 let owner = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
586 issue
587 .as_object_mut()
588 .expect("CodeClimate issue should be an object")
589 .insert("owner".to_string(), serde_json::Value::String(owner));
590 }
591 }
592
593 emit_json(&value, "CodeClimate")
594}
595
596#[must_use]
598#[expect(
599 clippy::too_many_lines,
600 reason = "CRAP adds a fourth exceeded-threshold branch plus its description; splitting the dispatch table would fragment the mapping."
601)]
602pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> serde_json::Value {
603 let mut issues = Vec::new();
604
605 let cyc_t = report.summary.max_cyclomatic_threshold;
606 let cog_t = report.summary.max_cognitive_threshold;
607 let crap_t = report.summary.max_crap_threshold;
608
609 for finding in &report.findings {
610 let path = cc_path(&finding.path, root);
611 let description = match finding.exceeded {
612 ExceededThreshold::Both => format!(
613 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
614 finding.name, finding.cyclomatic, cyc_t, finding.cognitive, cog_t
615 ),
616 ExceededThreshold::Cyclomatic => format!(
617 "'{}' has cyclomatic complexity {} (threshold: {})",
618 finding.name, finding.cyclomatic, cyc_t
619 ),
620 ExceededThreshold::Cognitive => format!(
621 "'{}' has cognitive complexity {} (threshold: {})",
622 finding.name, finding.cognitive, cog_t
623 ),
624 ExceededThreshold::Crap
625 | ExceededThreshold::CyclomaticCrap
626 | ExceededThreshold::CognitiveCrap
627 | ExceededThreshold::All => {
628 let crap = finding.crap.unwrap_or(0.0);
629 let coverage = finding
630 .coverage_pct
631 .map(|pct| format!(", coverage {pct:.0}%"))
632 .unwrap_or_default();
633 format!(
634 "'{}' has CRAP score {crap:.1} (threshold: {crap_t:.1}, cyclomatic {}{coverage})",
635 finding.name, finding.cyclomatic,
636 )
637 }
638 };
639 let check_name = match finding.exceeded {
640 ExceededThreshold::Both => "fallow/high-complexity",
641 ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
642 ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
643 ExceededThreshold::Crap
644 | ExceededThreshold::CyclomaticCrap
645 | ExceededThreshold::CognitiveCrap
646 | ExceededThreshold::All => "fallow/high-crap-score",
647 };
648 let severity = match finding.severity {
650 crate::health_types::FindingSeverity::Critical => "critical",
651 crate::health_types::FindingSeverity::High => "major",
652 crate::health_types::FindingSeverity::Moderate => "minor",
653 };
654 let line_str = finding.line.to_string();
655 let fp = fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
656 issues.push(cc_issue(
657 check_name,
658 &description,
659 severity,
660 "Complexity",
661 &path,
662 Some(finding.line),
663 &fp,
664 ));
665 }
666
667 if let Some(ref production) = report.production_coverage {
668 for finding in &production.findings {
669 let path = cc_path(&finding.path, root);
670 let check_name = match finding.verdict {
671 crate::health_types::ProductionCoverageVerdict::SafeToDelete => {
672 "fallow/production-safe-to-delete"
673 }
674 crate::health_types::ProductionCoverageVerdict::ReviewRequired => {
675 "fallow/production-review-required"
676 }
677 crate::health_types::ProductionCoverageVerdict::LowTraffic => {
678 "fallow/production-low-traffic"
679 }
680 crate::health_types::ProductionCoverageVerdict::CoverageUnavailable => {
681 "fallow/production-coverage-unavailable"
682 }
683 crate::health_types::ProductionCoverageVerdict::Active
684 | crate::health_types::ProductionCoverageVerdict::Unknown => {
685 "fallow/production-coverage"
686 }
687 };
688 let invocations_hint = finding.invocations.map_or_else(
689 || "untracked".to_owned(),
690 |hits| format!("{hits} invocations"),
691 );
692 let description = format!(
693 "'{}' production coverage verdict: {} ({})",
694 finding.function,
695 finding.verdict.human_label(),
696 invocations_hint,
697 );
698 let severity = match finding.verdict {
703 crate::health_types::ProductionCoverageVerdict::SafeToDelete => "critical",
704 crate::health_types::ProductionCoverageVerdict::ReviewRequired => "major",
705 _ => "minor",
706 };
707 let fp = fingerprint_hash(&[
708 check_name,
709 &path,
710 &finding.line.to_string(),
711 &finding.function,
712 ]);
713 issues.push(cc_issue(
714 check_name,
715 &description,
716 severity,
717 "Bug Risk",
723 &path,
724 Some(finding.line),
725 &fp,
726 ));
727 }
728 }
729
730 if let Some(ref gaps) = report.coverage_gaps {
731 for item in &gaps.files {
732 let path = cc_path(&item.path, root);
733 let description = format!(
734 "File is runtime-reachable but has no test dependency path ({} value export{})",
735 item.value_export_count,
736 if item.value_export_count == 1 {
737 ""
738 } else {
739 "s"
740 },
741 );
742 let fp = fingerprint_hash(&["fallow/untested-file", &path]);
743 issues.push(cc_issue(
744 "fallow/untested-file",
745 &description,
746 "minor",
747 "Coverage",
748 &path,
749 None,
750 &fp,
751 ));
752 }
753
754 for item in &gaps.exports {
755 let path = cc_path(&item.path, root);
756 let description = format!(
757 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
758 item.export_name
759 );
760 let line_str = item.line.to_string();
761 let fp = fingerprint_hash(&[
762 "fallow/untested-export",
763 &path,
764 &line_str,
765 &item.export_name,
766 ]);
767 issues.push(cc_issue(
768 "fallow/untested-export",
769 &description,
770 "minor",
771 "Coverage",
772 &path,
773 Some(item.line),
774 &fp,
775 ));
776 }
777 }
778
779 serde_json::Value::Array(issues)
780}
781
782pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
784 let value = build_health_codeclimate(report, root);
785 emit_json(&value, "CodeClimate")
786}
787
788#[must_use]
790#[expect(
791 clippy::cast_possible_truncation,
792 reason = "line numbers are bounded by source size"
793)]
794pub fn build_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> serde_json::Value {
795 let mut issues = Vec::new();
796
797 for (i, group) in report.clone_groups.iter().enumerate() {
798 let token_str = group.token_count.to_string();
801 let line_count_str = group.line_count.to_string();
802 let fragment_prefix: String = group
803 .instances
804 .first()
805 .map(|inst| inst.fragment.chars().take(64).collect())
806 .unwrap_or_default();
807
808 for instance in &group.instances {
809 let path = cc_path(&instance.file, root);
810 let start_str = instance.start_line.to_string();
811 let fp = fingerprint_hash(&[
812 "fallow/code-duplication",
813 &path,
814 &start_str,
815 &token_str,
816 &line_count_str,
817 &fragment_prefix,
818 ]);
819 issues.push(cc_issue(
820 "fallow/code-duplication",
821 &format!(
822 "Code clone group {} ({} lines, {} instances)",
823 i + 1,
824 group.line_count,
825 group.instances.len()
826 ),
827 "minor",
828 "Duplication",
829 &path,
830 Some(instance.start_line as u32),
831 &fp,
832 ));
833 }
834 }
835
836 serde_json::Value::Array(issues)
837}
838
839pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
841 let value = build_duplication_codeclimate(report, root);
842 emit_json(&value, "CodeClimate")
843}
844
845#[cfg(test)]
846mod tests {
847 use super::*;
848 use crate::report::test_helpers::sample_results;
849 use fallow_config::RulesConfig;
850 use fallow_core::results::*;
851 use std::path::PathBuf;
852
853 fn health_severity(value: u16, threshold: u16) -> &'static str {
856 if threshold == 0 {
857 return "minor";
858 }
859 let ratio = f64::from(value) / f64::from(threshold);
860 if ratio > 2.5 {
861 "critical"
862 } else if ratio > 1.5 {
863 "major"
864 } else {
865 "minor"
866 }
867 }
868
869 #[test]
870 fn codeclimate_empty_results_produces_empty_array() {
871 let root = PathBuf::from("/project");
872 let results = AnalysisResults::default();
873 let rules = RulesConfig::default();
874 let output = build_codeclimate(&results, &root, &rules);
875 let arr = output.as_array().unwrap();
876 assert!(arr.is_empty());
877 }
878
879 #[test]
880 fn codeclimate_produces_array_of_issues() {
881 let root = PathBuf::from("/project");
882 let results = sample_results(&root);
883 let rules = RulesConfig::default();
884 let output = build_codeclimate(&results, &root, &rules);
885 assert!(output.is_array());
886 let arr = output.as_array().unwrap();
887 assert!(!arr.is_empty());
889 }
890
891 #[test]
892 fn codeclimate_issue_has_required_fields() {
893 let root = PathBuf::from("/project");
894 let mut results = AnalysisResults::default();
895 results.unused_files.push(UnusedFile {
896 path: root.join("src/dead.ts"),
897 });
898 let rules = RulesConfig::default();
899 let output = build_codeclimate(&results, &root, &rules);
900 let issue = &output.as_array().unwrap()[0];
901
902 assert_eq!(issue["type"], "issue");
903 assert_eq!(issue["check_name"], "fallow/unused-file");
904 assert!(issue["description"].is_string());
905 assert!(issue["categories"].is_array());
906 assert!(issue["severity"].is_string());
907 assert!(issue["fingerprint"].is_string());
908 assert!(issue["location"].is_object());
909 assert!(issue["location"]["path"].is_string());
910 assert!(issue["location"]["lines"].is_object());
911 }
912
913 #[test]
914 fn codeclimate_unused_file_severity_follows_rules() {
915 let root = PathBuf::from("/project");
916 let mut results = AnalysisResults::default();
917 results.unused_files.push(UnusedFile {
918 path: root.join("src/dead.ts"),
919 });
920
921 let rules = RulesConfig::default();
923 let output = build_codeclimate(&results, &root, &rules);
924 assert_eq!(output[0]["severity"], "major");
925
926 let rules = RulesConfig {
928 unused_files: Severity::Warn,
929 ..RulesConfig::default()
930 };
931 let output = build_codeclimate(&results, &root, &rules);
932 assert_eq!(output[0]["severity"], "minor");
933 }
934
935 #[test]
936 fn codeclimate_unused_export_has_line_number() {
937 let root = PathBuf::from("/project");
938 let mut results = AnalysisResults::default();
939 results.unused_exports.push(UnusedExport {
940 path: root.join("src/utils.ts"),
941 export_name: "helperFn".to_string(),
942 is_type_only: false,
943 line: 10,
944 col: 4,
945 span_start: 120,
946 is_re_export: false,
947 });
948 let rules = RulesConfig::default();
949 let output = build_codeclimate(&results, &root, &rules);
950 let issue = &output[0];
951 assert_eq!(issue["location"]["lines"]["begin"], 10);
952 }
953
954 #[test]
955 fn codeclimate_unused_file_line_defaults_to_1() {
956 let root = PathBuf::from("/project");
957 let mut results = AnalysisResults::default();
958 results.unused_files.push(UnusedFile {
959 path: root.join("src/dead.ts"),
960 });
961 let rules = RulesConfig::default();
962 let output = build_codeclimate(&results, &root, &rules);
963 let issue = &output[0];
964 assert_eq!(issue["location"]["lines"]["begin"], 1);
965 }
966
967 #[test]
968 fn codeclimate_paths_are_relative() {
969 let root = PathBuf::from("/project");
970 let mut results = AnalysisResults::default();
971 results.unused_files.push(UnusedFile {
972 path: root.join("src/deep/nested/file.ts"),
973 });
974 let rules = RulesConfig::default();
975 let output = build_codeclimate(&results, &root, &rules);
976 let path = output[0]["location"]["path"].as_str().unwrap();
977 assert_eq!(path, "src/deep/nested/file.ts");
978 assert!(!path.starts_with("/project"));
979 }
980
981 #[test]
982 fn codeclimate_re_export_label_in_description() {
983 let root = PathBuf::from("/project");
984 let mut results = AnalysisResults::default();
985 results.unused_exports.push(UnusedExport {
986 path: root.join("src/index.ts"),
987 export_name: "reExported".to_string(),
988 is_type_only: false,
989 line: 1,
990 col: 0,
991 span_start: 0,
992 is_re_export: true,
993 });
994 let rules = RulesConfig::default();
995 let output = build_codeclimate(&results, &root, &rules);
996 let desc = output[0]["description"].as_str().unwrap();
997 assert!(desc.contains("Re-export"));
998 }
999
1000 #[test]
1001 fn codeclimate_unlisted_dep_one_issue_per_import_site() {
1002 let root = PathBuf::from("/project");
1003 let mut results = AnalysisResults::default();
1004 results.unlisted_dependencies.push(UnlistedDependency {
1005 package_name: "chalk".to_string(),
1006 imported_from: vec![
1007 ImportSite {
1008 path: root.join("src/a.ts"),
1009 line: 1,
1010 col: 0,
1011 },
1012 ImportSite {
1013 path: root.join("src/b.ts"),
1014 line: 5,
1015 col: 0,
1016 },
1017 ],
1018 });
1019 let rules = RulesConfig::default();
1020 let output = build_codeclimate(&results, &root, &rules);
1021 let arr = output.as_array().unwrap();
1022 assert_eq!(arr.len(), 2);
1023 assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
1024 assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
1025 }
1026
1027 #[test]
1028 fn codeclimate_duplicate_export_one_issue_per_location() {
1029 let root = PathBuf::from("/project");
1030 let mut results = AnalysisResults::default();
1031 results.duplicate_exports.push(DuplicateExport {
1032 export_name: "Config".to_string(),
1033 locations: vec![
1034 DuplicateLocation {
1035 path: root.join("src/a.ts"),
1036 line: 10,
1037 col: 0,
1038 },
1039 DuplicateLocation {
1040 path: root.join("src/b.ts"),
1041 line: 20,
1042 col: 0,
1043 },
1044 DuplicateLocation {
1045 path: root.join("src/c.ts"),
1046 line: 30,
1047 col: 0,
1048 },
1049 ],
1050 });
1051 let rules = RulesConfig::default();
1052 let output = build_codeclimate(&results, &root, &rules);
1053 let arr = output.as_array().unwrap();
1054 assert_eq!(arr.len(), 3);
1055 }
1056
1057 #[test]
1058 fn codeclimate_circular_dep_emits_chain_in_description() {
1059 let root = PathBuf::from("/project");
1060 let mut results = AnalysisResults::default();
1061 results.circular_dependencies.push(CircularDependency {
1062 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1063 length: 2,
1064 line: 3,
1065 col: 0,
1066 is_cross_package: false,
1067 });
1068 let rules = RulesConfig::default();
1069 let output = build_codeclimate(&results, &root, &rules);
1070 let desc = output[0]["description"].as_str().unwrap();
1071 assert!(desc.contains("Circular dependency"));
1072 assert!(desc.contains("src/a.ts"));
1073 assert!(desc.contains("src/b.ts"));
1074 }
1075
1076 #[test]
1077 fn codeclimate_fingerprints_are_deterministic() {
1078 let root = PathBuf::from("/project");
1079 let results = sample_results(&root);
1080 let rules = RulesConfig::default();
1081 let output1 = build_codeclimate(&results, &root, &rules);
1082 let output2 = build_codeclimate(&results, &root, &rules);
1083
1084 let fps1: Vec<&str> = output1
1085 .as_array()
1086 .unwrap()
1087 .iter()
1088 .map(|i| i["fingerprint"].as_str().unwrap())
1089 .collect();
1090 let fps2: Vec<&str> = output2
1091 .as_array()
1092 .unwrap()
1093 .iter()
1094 .map(|i| i["fingerprint"].as_str().unwrap())
1095 .collect();
1096 assert_eq!(fps1, fps2);
1097 }
1098
1099 #[test]
1100 fn codeclimate_fingerprints_are_unique() {
1101 let root = PathBuf::from("/project");
1102 let results = sample_results(&root);
1103 let rules = RulesConfig::default();
1104 let output = build_codeclimate(&results, &root, &rules);
1105
1106 let mut fps: Vec<&str> = output
1107 .as_array()
1108 .unwrap()
1109 .iter()
1110 .map(|i| i["fingerprint"].as_str().unwrap())
1111 .collect();
1112 let original_len = fps.len();
1113 fps.sort_unstable();
1114 fps.dedup();
1115 assert_eq!(fps.len(), original_len, "fingerprints should be unique");
1116 }
1117
1118 #[test]
1119 fn codeclimate_type_only_dep_has_correct_check_name() {
1120 let root = PathBuf::from("/project");
1121 let mut results = AnalysisResults::default();
1122 results.type_only_dependencies.push(TypeOnlyDependency {
1123 package_name: "zod".to_string(),
1124 path: root.join("package.json"),
1125 line: 8,
1126 });
1127 let rules = RulesConfig::default();
1128 let output = build_codeclimate(&results, &root, &rules);
1129 assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
1130 let desc = output[0]["description"].as_str().unwrap();
1131 assert!(desc.contains("zod"));
1132 assert!(desc.contains("type-only"));
1133 }
1134
1135 #[test]
1136 fn codeclimate_dep_with_zero_line_omits_line_number() {
1137 let root = PathBuf::from("/project");
1138 let mut results = AnalysisResults::default();
1139 results.unused_dependencies.push(UnusedDependency {
1140 package_name: "lodash".to_string(),
1141 location: DependencyLocation::Dependencies,
1142 path: root.join("package.json"),
1143 line: 0,
1144 });
1145 let rules = RulesConfig::default();
1146 let output = build_codeclimate(&results, &root, &rules);
1147 assert_eq!(output[0]["location"]["lines"]["begin"], 1);
1149 }
1150
1151 #[test]
1154 fn fingerprint_hash_different_inputs_differ() {
1155 let h1 = fingerprint_hash(&["a", "b"]);
1156 let h2 = fingerprint_hash(&["a", "c"]);
1157 assert_ne!(h1, h2);
1158 }
1159
1160 #[test]
1161 fn fingerprint_hash_order_matters() {
1162 let h1 = fingerprint_hash(&["a", "b"]);
1163 let h2 = fingerprint_hash(&["b", "a"]);
1164 assert_ne!(h1, h2);
1165 }
1166
1167 #[test]
1168 fn fingerprint_hash_separator_prevents_collision() {
1169 let h1 = fingerprint_hash(&["ab", "c"]);
1171 let h2 = fingerprint_hash(&["a", "bc"]);
1172 assert_ne!(h1, h2);
1173 }
1174
1175 #[test]
1176 fn fingerprint_hash_is_16_hex_chars() {
1177 let h = fingerprint_hash(&["test"]);
1178 assert_eq!(h.len(), 16);
1179 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
1180 }
1181
1182 #[test]
1185 fn severity_error_maps_to_major() {
1186 assert_eq!(severity_to_codeclimate(Severity::Error), "major");
1187 }
1188
1189 #[test]
1190 fn severity_warn_maps_to_minor() {
1191 assert_eq!(severity_to_codeclimate(Severity::Warn), "minor");
1192 }
1193
1194 #[test]
1195 fn severity_off_maps_to_minor() {
1196 assert_eq!(severity_to_codeclimate(Severity::Off), "minor");
1197 }
1198
1199 #[test]
1202 fn health_severity_zero_threshold_returns_minor() {
1203 assert_eq!(health_severity(100, 0), "minor");
1204 }
1205
1206 #[test]
1207 fn health_severity_at_threshold_returns_minor() {
1208 assert_eq!(health_severity(10, 10), "minor");
1209 }
1210
1211 #[test]
1212 fn health_severity_1_5x_threshold_returns_minor() {
1213 assert_eq!(health_severity(15, 10), "minor");
1214 }
1215
1216 #[test]
1217 fn health_severity_above_1_5x_returns_major() {
1218 assert_eq!(health_severity(16, 10), "major");
1219 }
1220
1221 #[test]
1222 fn health_severity_at_2_5x_returns_major() {
1223 assert_eq!(health_severity(25, 10), "major");
1224 }
1225
1226 #[test]
1227 fn health_severity_above_2_5x_returns_critical() {
1228 assert_eq!(health_severity(26, 10), "critical");
1229 }
1230
1231 #[test]
1232 fn health_codeclimate_includes_coverage_gaps() {
1233 use crate::health_types::*;
1234
1235 let root = PathBuf::from("/project");
1236 let report = HealthReport {
1237 summary: HealthSummary {
1238 files_analyzed: 10,
1239 functions_analyzed: 50,
1240 ..Default::default()
1241 },
1242 coverage_gaps: Some(CoverageGaps {
1243 summary: CoverageGapSummary {
1244 runtime_files: 2,
1245 covered_files: 0,
1246 file_coverage_pct: 0.0,
1247 untested_files: 1,
1248 untested_exports: 1,
1249 },
1250 files: vec![UntestedFile {
1251 path: root.join("src/app.ts"),
1252 value_export_count: 2,
1253 }],
1254 exports: vec![UntestedExport {
1255 path: root.join("src/app.ts"),
1256 export_name: "loader".into(),
1257 line: 12,
1258 col: 4,
1259 }],
1260 }),
1261 ..Default::default()
1262 };
1263
1264 let output = build_health_codeclimate(&report, &root);
1265 let issues = output.as_array().unwrap();
1266 assert_eq!(issues.len(), 2);
1267 assert_eq!(issues[0]["check_name"], "fallow/untested-file");
1268 assert_eq!(issues[0]["categories"][0], "Coverage");
1269 assert_eq!(issues[0]["location"]["path"], "src/app.ts");
1270 assert_eq!(issues[1]["check_name"], "fallow/untested-export");
1271 assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
1272 assert!(
1273 issues[1]["description"]
1274 .as_str()
1275 .unwrap()
1276 .contains("loader")
1277 );
1278 }
1279
1280 #[test]
1281 fn health_codeclimate_crap_only_uses_crap_check_name() {
1282 use crate::health_types::{FindingSeverity, HealthFinding, HealthReport, HealthSummary};
1283 let root = PathBuf::from("/project");
1284 let report = HealthReport {
1285 findings: vec![HealthFinding {
1286 path: root.join("src/untested.ts"),
1287 name: "risky".to_string(),
1288 line: 7,
1289 col: 0,
1290 cyclomatic: 10,
1291 cognitive: 10,
1292 line_count: 20,
1293 param_count: 1,
1294 exceeded: crate::health_types::ExceededThreshold::Crap,
1295 severity: FindingSeverity::High,
1296 crap: Some(60.0),
1297 coverage_pct: Some(25.0),
1298 }],
1299 summary: HealthSummary {
1300 functions_analyzed: 10,
1301 functions_above_threshold: 1,
1302 ..Default::default()
1303 },
1304 ..Default::default()
1305 };
1306 let json = build_health_codeclimate(&report, &root);
1307 let issues = json.as_array().unwrap();
1308 assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
1309 assert_eq!(issues[0]["severity"], "major");
1310 let description = issues[0]["description"].as_str().unwrap();
1311 assert!(description.contains("CRAP score"), "desc: {description}");
1312 assert!(description.contains("coverage 25%"), "desc: {description}");
1313 }
1314}