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
788pub(super) fn print_grouped_health_codeclimate(
797 report: &HealthReport,
798 root: &Path,
799 resolver: &OwnershipResolver,
800) -> ExitCode {
801 let mut value = build_health_codeclimate(report, root);
802
803 if let Some(issues) = value.as_array_mut() {
804 for issue in issues {
805 let path = issue
806 .pointer("/location/path")
807 .and_then(|v| v.as_str())
808 .unwrap_or("");
809 let group = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
810 issue
811 .as_object_mut()
812 .expect("CodeClimate issue should be an object")
813 .insert("group".to_string(), serde_json::Value::String(group));
814 }
815 }
816
817 emit_json(&value, "CodeClimate")
818}
819
820#[must_use]
822#[expect(
823 clippy::cast_possible_truncation,
824 reason = "line numbers are bounded by source size"
825)]
826pub fn build_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> serde_json::Value {
827 let mut issues = Vec::new();
828
829 for (i, group) in report.clone_groups.iter().enumerate() {
830 let token_str = group.token_count.to_string();
833 let line_count_str = group.line_count.to_string();
834 let fragment_prefix: String = group
835 .instances
836 .first()
837 .map(|inst| inst.fragment.chars().take(64).collect())
838 .unwrap_or_default();
839
840 for instance in &group.instances {
841 let path = cc_path(&instance.file, root);
842 let start_str = instance.start_line.to_string();
843 let fp = fingerprint_hash(&[
844 "fallow/code-duplication",
845 &path,
846 &start_str,
847 &token_str,
848 &line_count_str,
849 &fragment_prefix,
850 ]);
851 issues.push(cc_issue(
852 "fallow/code-duplication",
853 &format!(
854 "Code clone group {} ({} lines, {} instances)",
855 i + 1,
856 group.line_count,
857 group.instances.len()
858 ),
859 "minor",
860 "Duplication",
861 &path,
862 Some(instance.start_line as u32),
863 &fp,
864 ));
865 }
866 }
867
868 serde_json::Value::Array(issues)
869}
870
871pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
873 let value = build_duplication_codeclimate(report, root);
874 emit_json(&value, "CodeClimate")
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880 use crate::report::test_helpers::sample_results;
881 use fallow_config::RulesConfig;
882 use fallow_core::results::*;
883 use std::path::PathBuf;
884
885 fn health_severity(value: u16, threshold: u16) -> &'static str {
888 if threshold == 0 {
889 return "minor";
890 }
891 let ratio = f64::from(value) / f64::from(threshold);
892 if ratio > 2.5 {
893 "critical"
894 } else if ratio > 1.5 {
895 "major"
896 } else {
897 "minor"
898 }
899 }
900
901 #[test]
902 fn codeclimate_empty_results_produces_empty_array() {
903 let root = PathBuf::from("/project");
904 let results = AnalysisResults::default();
905 let rules = RulesConfig::default();
906 let output = build_codeclimate(&results, &root, &rules);
907 let arr = output.as_array().unwrap();
908 assert!(arr.is_empty());
909 }
910
911 #[test]
912 fn codeclimate_produces_array_of_issues() {
913 let root = PathBuf::from("/project");
914 let results = sample_results(&root);
915 let rules = RulesConfig::default();
916 let output = build_codeclimate(&results, &root, &rules);
917 assert!(output.is_array());
918 let arr = output.as_array().unwrap();
919 assert!(!arr.is_empty());
921 }
922
923 #[test]
924 fn codeclimate_issue_has_required_fields() {
925 let root = PathBuf::from("/project");
926 let mut results = AnalysisResults::default();
927 results.unused_files.push(UnusedFile {
928 path: root.join("src/dead.ts"),
929 });
930 let rules = RulesConfig::default();
931 let output = build_codeclimate(&results, &root, &rules);
932 let issue = &output.as_array().unwrap()[0];
933
934 assert_eq!(issue["type"], "issue");
935 assert_eq!(issue["check_name"], "fallow/unused-file");
936 assert!(issue["description"].is_string());
937 assert!(issue["categories"].is_array());
938 assert!(issue["severity"].is_string());
939 assert!(issue["fingerprint"].is_string());
940 assert!(issue["location"].is_object());
941 assert!(issue["location"]["path"].is_string());
942 assert!(issue["location"]["lines"].is_object());
943 }
944
945 #[test]
946 fn codeclimate_unused_file_severity_follows_rules() {
947 let root = PathBuf::from("/project");
948 let mut results = AnalysisResults::default();
949 results.unused_files.push(UnusedFile {
950 path: root.join("src/dead.ts"),
951 });
952
953 let rules = RulesConfig::default();
955 let output = build_codeclimate(&results, &root, &rules);
956 assert_eq!(output[0]["severity"], "major");
957
958 let rules = RulesConfig {
960 unused_files: Severity::Warn,
961 ..RulesConfig::default()
962 };
963 let output = build_codeclimate(&results, &root, &rules);
964 assert_eq!(output[0]["severity"], "minor");
965 }
966
967 #[test]
968 fn codeclimate_unused_export_has_line_number() {
969 let root = PathBuf::from("/project");
970 let mut results = AnalysisResults::default();
971 results.unused_exports.push(UnusedExport {
972 path: root.join("src/utils.ts"),
973 export_name: "helperFn".to_string(),
974 is_type_only: false,
975 line: 10,
976 col: 4,
977 span_start: 120,
978 is_re_export: false,
979 });
980 let rules = RulesConfig::default();
981 let output = build_codeclimate(&results, &root, &rules);
982 let issue = &output[0];
983 assert_eq!(issue["location"]["lines"]["begin"], 10);
984 }
985
986 #[test]
987 fn codeclimate_unused_file_line_defaults_to_1() {
988 let root = PathBuf::from("/project");
989 let mut results = AnalysisResults::default();
990 results.unused_files.push(UnusedFile {
991 path: root.join("src/dead.ts"),
992 });
993 let rules = RulesConfig::default();
994 let output = build_codeclimate(&results, &root, &rules);
995 let issue = &output[0];
996 assert_eq!(issue["location"]["lines"]["begin"], 1);
997 }
998
999 #[test]
1000 fn codeclimate_paths_are_relative() {
1001 let root = PathBuf::from("/project");
1002 let mut results = AnalysisResults::default();
1003 results.unused_files.push(UnusedFile {
1004 path: root.join("src/deep/nested/file.ts"),
1005 });
1006 let rules = RulesConfig::default();
1007 let output = build_codeclimate(&results, &root, &rules);
1008 let path = output[0]["location"]["path"].as_str().unwrap();
1009 assert_eq!(path, "src/deep/nested/file.ts");
1010 assert!(!path.starts_with("/project"));
1011 }
1012
1013 #[test]
1014 fn codeclimate_re_export_label_in_description() {
1015 let root = PathBuf::from("/project");
1016 let mut results = AnalysisResults::default();
1017 results.unused_exports.push(UnusedExport {
1018 path: root.join("src/index.ts"),
1019 export_name: "reExported".to_string(),
1020 is_type_only: false,
1021 line: 1,
1022 col: 0,
1023 span_start: 0,
1024 is_re_export: true,
1025 });
1026 let rules = RulesConfig::default();
1027 let output = build_codeclimate(&results, &root, &rules);
1028 let desc = output[0]["description"].as_str().unwrap();
1029 assert!(desc.contains("Re-export"));
1030 }
1031
1032 #[test]
1033 fn codeclimate_unlisted_dep_one_issue_per_import_site() {
1034 let root = PathBuf::from("/project");
1035 let mut results = AnalysisResults::default();
1036 results.unlisted_dependencies.push(UnlistedDependency {
1037 package_name: "chalk".to_string(),
1038 imported_from: vec![
1039 ImportSite {
1040 path: root.join("src/a.ts"),
1041 line: 1,
1042 col: 0,
1043 },
1044 ImportSite {
1045 path: root.join("src/b.ts"),
1046 line: 5,
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(), 2);
1055 assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
1056 assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
1057 }
1058
1059 #[test]
1060 fn codeclimate_duplicate_export_one_issue_per_location() {
1061 let root = PathBuf::from("/project");
1062 let mut results = AnalysisResults::default();
1063 results.duplicate_exports.push(DuplicateExport {
1064 export_name: "Config".to_string(),
1065 locations: vec![
1066 DuplicateLocation {
1067 path: root.join("src/a.ts"),
1068 line: 10,
1069 col: 0,
1070 },
1071 DuplicateLocation {
1072 path: root.join("src/b.ts"),
1073 line: 20,
1074 col: 0,
1075 },
1076 DuplicateLocation {
1077 path: root.join("src/c.ts"),
1078 line: 30,
1079 col: 0,
1080 },
1081 ],
1082 });
1083 let rules = RulesConfig::default();
1084 let output = build_codeclimate(&results, &root, &rules);
1085 let arr = output.as_array().unwrap();
1086 assert_eq!(arr.len(), 3);
1087 }
1088
1089 #[test]
1090 fn codeclimate_circular_dep_emits_chain_in_description() {
1091 let root = PathBuf::from("/project");
1092 let mut results = AnalysisResults::default();
1093 results.circular_dependencies.push(CircularDependency {
1094 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1095 length: 2,
1096 line: 3,
1097 col: 0,
1098 is_cross_package: false,
1099 });
1100 let rules = RulesConfig::default();
1101 let output = build_codeclimate(&results, &root, &rules);
1102 let desc = output[0]["description"].as_str().unwrap();
1103 assert!(desc.contains("Circular dependency"));
1104 assert!(desc.contains("src/a.ts"));
1105 assert!(desc.contains("src/b.ts"));
1106 }
1107
1108 #[test]
1109 fn codeclimate_fingerprints_are_deterministic() {
1110 let root = PathBuf::from("/project");
1111 let results = sample_results(&root);
1112 let rules = RulesConfig::default();
1113 let output1 = build_codeclimate(&results, &root, &rules);
1114 let output2 = build_codeclimate(&results, &root, &rules);
1115
1116 let fps1: Vec<&str> = output1
1117 .as_array()
1118 .unwrap()
1119 .iter()
1120 .map(|i| i["fingerprint"].as_str().unwrap())
1121 .collect();
1122 let fps2: Vec<&str> = output2
1123 .as_array()
1124 .unwrap()
1125 .iter()
1126 .map(|i| i["fingerprint"].as_str().unwrap())
1127 .collect();
1128 assert_eq!(fps1, fps2);
1129 }
1130
1131 #[test]
1132 fn codeclimate_fingerprints_are_unique() {
1133 let root = PathBuf::from("/project");
1134 let results = sample_results(&root);
1135 let rules = RulesConfig::default();
1136 let output = build_codeclimate(&results, &root, &rules);
1137
1138 let mut fps: Vec<&str> = output
1139 .as_array()
1140 .unwrap()
1141 .iter()
1142 .map(|i| i["fingerprint"].as_str().unwrap())
1143 .collect();
1144 let original_len = fps.len();
1145 fps.sort_unstable();
1146 fps.dedup();
1147 assert_eq!(fps.len(), original_len, "fingerprints should be unique");
1148 }
1149
1150 #[test]
1151 fn codeclimate_type_only_dep_has_correct_check_name() {
1152 let root = PathBuf::from("/project");
1153 let mut results = AnalysisResults::default();
1154 results.type_only_dependencies.push(TypeOnlyDependency {
1155 package_name: "zod".to_string(),
1156 path: root.join("package.json"),
1157 line: 8,
1158 });
1159 let rules = RulesConfig::default();
1160 let output = build_codeclimate(&results, &root, &rules);
1161 assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
1162 let desc = output[0]["description"].as_str().unwrap();
1163 assert!(desc.contains("zod"));
1164 assert!(desc.contains("type-only"));
1165 }
1166
1167 #[test]
1168 fn codeclimate_dep_with_zero_line_omits_line_number() {
1169 let root = PathBuf::from("/project");
1170 let mut results = AnalysisResults::default();
1171 results.unused_dependencies.push(UnusedDependency {
1172 package_name: "lodash".to_string(),
1173 location: DependencyLocation::Dependencies,
1174 path: root.join("package.json"),
1175 line: 0,
1176 });
1177 let rules = RulesConfig::default();
1178 let output = build_codeclimate(&results, &root, &rules);
1179 assert_eq!(output[0]["location"]["lines"]["begin"], 1);
1181 }
1182
1183 #[test]
1186 fn fingerprint_hash_different_inputs_differ() {
1187 let h1 = fingerprint_hash(&["a", "b"]);
1188 let h2 = fingerprint_hash(&["a", "c"]);
1189 assert_ne!(h1, h2);
1190 }
1191
1192 #[test]
1193 fn fingerprint_hash_order_matters() {
1194 let h1 = fingerprint_hash(&["a", "b"]);
1195 let h2 = fingerprint_hash(&["b", "a"]);
1196 assert_ne!(h1, h2);
1197 }
1198
1199 #[test]
1200 fn fingerprint_hash_separator_prevents_collision() {
1201 let h1 = fingerprint_hash(&["ab", "c"]);
1203 let h2 = fingerprint_hash(&["a", "bc"]);
1204 assert_ne!(h1, h2);
1205 }
1206
1207 #[test]
1208 fn fingerprint_hash_is_16_hex_chars() {
1209 let h = fingerprint_hash(&["test"]);
1210 assert_eq!(h.len(), 16);
1211 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
1212 }
1213
1214 #[test]
1217 fn severity_error_maps_to_major() {
1218 assert_eq!(severity_to_codeclimate(Severity::Error), "major");
1219 }
1220
1221 #[test]
1222 fn severity_warn_maps_to_minor() {
1223 assert_eq!(severity_to_codeclimate(Severity::Warn), "minor");
1224 }
1225
1226 #[test]
1227 fn severity_off_maps_to_minor() {
1228 assert_eq!(severity_to_codeclimate(Severity::Off), "minor");
1229 }
1230
1231 #[test]
1234 fn health_severity_zero_threshold_returns_minor() {
1235 assert_eq!(health_severity(100, 0), "minor");
1236 }
1237
1238 #[test]
1239 fn health_severity_at_threshold_returns_minor() {
1240 assert_eq!(health_severity(10, 10), "minor");
1241 }
1242
1243 #[test]
1244 fn health_severity_1_5x_threshold_returns_minor() {
1245 assert_eq!(health_severity(15, 10), "minor");
1246 }
1247
1248 #[test]
1249 fn health_severity_above_1_5x_returns_major() {
1250 assert_eq!(health_severity(16, 10), "major");
1251 }
1252
1253 #[test]
1254 fn health_severity_at_2_5x_returns_major() {
1255 assert_eq!(health_severity(25, 10), "major");
1256 }
1257
1258 #[test]
1259 fn health_severity_above_2_5x_returns_critical() {
1260 assert_eq!(health_severity(26, 10), "critical");
1261 }
1262
1263 #[test]
1264 fn health_codeclimate_includes_coverage_gaps() {
1265 use crate::health_types::*;
1266
1267 let root = PathBuf::from("/project");
1268 let report = HealthReport {
1269 summary: HealthSummary {
1270 files_analyzed: 10,
1271 functions_analyzed: 50,
1272 ..Default::default()
1273 },
1274 coverage_gaps: Some(CoverageGaps {
1275 summary: CoverageGapSummary {
1276 runtime_files: 2,
1277 covered_files: 0,
1278 file_coverage_pct: 0.0,
1279 untested_files: 1,
1280 untested_exports: 1,
1281 },
1282 files: vec![UntestedFile {
1283 path: root.join("src/app.ts"),
1284 value_export_count: 2,
1285 }],
1286 exports: vec![UntestedExport {
1287 path: root.join("src/app.ts"),
1288 export_name: "loader".into(),
1289 line: 12,
1290 col: 4,
1291 }],
1292 }),
1293 ..Default::default()
1294 };
1295
1296 let output = build_health_codeclimate(&report, &root);
1297 let issues = output.as_array().unwrap();
1298 assert_eq!(issues.len(), 2);
1299 assert_eq!(issues[0]["check_name"], "fallow/untested-file");
1300 assert_eq!(issues[0]["categories"][0], "Coverage");
1301 assert_eq!(issues[0]["location"]["path"], "src/app.ts");
1302 assert_eq!(issues[1]["check_name"], "fallow/untested-export");
1303 assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
1304 assert!(
1305 issues[1]["description"]
1306 .as_str()
1307 .unwrap()
1308 .contains("loader")
1309 );
1310 }
1311
1312 #[test]
1313 fn health_codeclimate_crap_only_uses_crap_check_name() {
1314 use crate::health_types::{FindingSeverity, HealthFinding, HealthReport, HealthSummary};
1315 let root = PathBuf::from("/project");
1316 let report = HealthReport {
1317 findings: vec![HealthFinding {
1318 path: root.join("src/untested.ts"),
1319 name: "risky".to_string(),
1320 line: 7,
1321 col: 0,
1322 cyclomatic: 10,
1323 cognitive: 10,
1324 line_count: 20,
1325 param_count: 1,
1326 exceeded: crate::health_types::ExceededThreshold::Crap,
1327 severity: FindingSeverity::High,
1328 crap: Some(60.0),
1329 coverage_pct: Some(25.0),
1330 }],
1331 summary: HealthSummary {
1332 functions_analyzed: 10,
1333 functions_above_threshold: 1,
1334 ..Default::default()
1335 },
1336 ..Default::default()
1337 };
1338 let json = build_health_codeclimate(&report, &root);
1339 let issues = json.as_array().unwrap();
1340 assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
1341 assert_eq!(issues[0]["severity"], "major");
1342 let description = issues[0]["description"].as_str().unwrap();
1343 assert!(description.contains("CRAP score"), "desc: {description}");
1344 assert!(description.contains("coverage 25%"), "desc: {description}");
1345 }
1346}