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