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