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