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
506fn push_unused_catalog_entry_issues(
507 issues: &mut Vec<serde_json::Value>,
508 entries: &[fallow_core::results::UnusedCatalogEntry],
509 root: &Path,
510 severity: Severity,
511) {
512 if entries.is_empty() {
513 return;
514 }
515 let level = severity_to_codeclimate(severity);
516 for entry in entries {
517 let path = cc_path(&entry.path, root);
518 let line_str = entry.line.to_string();
519 let fp = fingerprint_hash(&[
520 "fallow/unused-catalog-entry",
521 &path,
522 &line_str,
523 &entry.catalog_name,
524 &entry.entry_name,
525 ]);
526 let description = if entry.catalog_name == "default" {
527 format!(
528 "Catalog entry '{}' is not referenced by any workspace package",
529 entry.entry_name
530 )
531 } else {
532 format!(
533 "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
534 entry.entry_name, entry.catalog_name
535 )
536 };
537 issues.push(cc_issue(
538 "fallow/unused-catalog-entry",
539 &description,
540 level,
541 "Bug Risk",
542 &path,
543 Some(entry.line),
544 &fp,
545 ));
546 }
547}
548
549#[must_use]
551pub fn build_codeclimate(
552 results: &AnalysisResults,
553 root: &Path,
554 rules: &RulesConfig,
555) -> serde_json::Value {
556 let mut issues = Vec::new();
557
558 push_unused_file_issues(&mut issues, &results.unused_files, root, rules.unused_files);
559 push_unused_export_issues(
560 &mut issues,
561 &results.unused_exports,
562 root,
563 "fallow/unused-export",
564 "Export",
565 "Re-export",
566 rules.unused_exports,
567 );
568 push_unused_export_issues(
569 &mut issues,
570 &results.unused_types,
571 root,
572 "fallow/unused-type",
573 "Type export",
574 "Type re-export",
575 rules.unused_types,
576 );
577 push_private_type_leak_issues(
578 &mut issues,
579 &results.private_type_leaks,
580 root,
581 rules.private_type_leaks,
582 );
583 push_dep_cc_issues(
584 &mut issues,
585 &results.unused_dependencies,
586 root,
587 "fallow/unused-dependency",
588 "dependencies",
589 rules.unused_dependencies,
590 );
591 push_dep_cc_issues(
592 &mut issues,
593 &results.unused_dev_dependencies,
594 root,
595 "fallow/unused-dev-dependency",
596 "devDependencies",
597 rules.unused_dev_dependencies,
598 );
599 push_dep_cc_issues(
600 &mut issues,
601 &results.unused_optional_dependencies,
602 root,
603 "fallow/unused-optional-dependency",
604 "optionalDependencies",
605 rules.unused_optional_dependencies,
606 );
607 push_type_only_dep_issues(
608 &mut issues,
609 &results.type_only_dependencies,
610 root,
611 rules.type_only_dependencies,
612 );
613 push_test_only_dep_issues(
614 &mut issues,
615 &results.test_only_dependencies,
616 root,
617 rules.test_only_dependencies,
618 );
619 push_unused_member_issues(
620 &mut issues,
621 &results.unused_enum_members,
622 root,
623 "fallow/unused-enum-member",
624 "Enum",
625 rules.unused_enum_members,
626 );
627 push_unused_member_issues(
628 &mut issues,
629 &results.unused_class_members,
630 root,
631 "fallow/unused-class-member",
632 "Class",
633 rules.unused_class_members,
634 );
635 push_unresolved_import_issues(
636 &mut issues,
637 &results.unresolved_imports,
638 root,
639 rules.unresolved_imports,
640 );
641 push_unlisted_dep_issues(
642 &mut issues,
643 &results.unlisted_dependencies,
644 root,
645 rules.unlisted_dependencies,
646 );
647 push_duplicate_export_issues(
648 &mut issues,
649 &results.duplicate_exports,
650 root,
651 rules.duplicate_exports,
652 );
653 push_circular_dep_issues(
654 &mut issues,
655 &results.circular_dependencies,
656 root,
657 rules.circular_dependencies,
658 );
659 push_boundary_violation_issues(
660 &mut issues,
661 &results.boundary_violations,
662 root,
663 rules.boundary_violation,
664 );
665 push_stale_suppression_issues(
666 &mut issues,
667 &results.stale_suppressions,
668 root,
669 rules.stale_suppressions,
670 );
671 push_unused_catalog_entry_issues(
672 &mut issues,
673 &results.unused_catalog_entries,
674 root,
675 rules.unused_catalog_entries,
676 );
677
678 serde_json::Value::Array(issues)
679}
680
681pub(super) fn print_codeclimate(
683 results: &AnalysisResults,
684 root: &Path,
685 rules: &RulesConfig,
686) -> ExitCode {
687 let value = build_codeclimate(results, root, rules);
688 emit_json(&value, "CodeClimate")
689}
690
691pub(super) fn print_grouped_codeclimate(
697 results: &AnalysisResults,
698 root: &Path,
699 rules: &RulesConfig,
700 resolver: &OwnershipResolver,
701) -> ExitCode {
702 let mut value = build_codeclimate(results, root, rules);
703
704 if let Some(issues) = value.as_array_mut() {
705 for issue in issues {
706 let path = issue
707 .pointer("/location/path")
708 .and_then(|v| v.as_str())
709 .unwrap_or("");
710 let owner = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
711 issue
712 .as_object_mut()
713 .expect("CodeClimate issue should be an object")
714 .insert("owner".to_string(), serde_json::Value::String(owner));
715 }
716 }
717
718 emit_json(&value, "CodeClimate")
719}
720
721#[must_use]
723#[expect(
724 clippy::too_many_lines,
725 reason = "CRAP adds a fourth exceeded-threshold branch plus its description; splitting the dispatch table would fragment the mapping."
726)]
727pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> serde_json::Value {
728 let mut issues = Vec::new();
729
730 let cyc_t = report.summary.max_cyclomatic_threshold;
731 let cog_t = report.summary.max_cognitive_threshold;
732 let crap_t = report.summary.max_crap_threshold;
733
734 for finding in &report.findings {
735 let path = cc_path(&finding.path, root);
736 let description = match finding.exceeded {
737 ExceededThreshold::Both => format!(
738 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
739 finding.name, finding.cyclomatic, cyc_t, finding.cognitive, cog_t
740 ),
741 ExceededThreshold::Cyclomatic => format!(
742 "'{}' has cyclomatic complexity {} (threshold: {})",
743 finding.name, finding.cyclomatic, cyc_t
744 ),
745 ExceededThreshold::Cognitive => format!(
746 "'{}' has cognitive complexity {} (threshold: {})",
747 finding.name, finding.cognitive, cog_t
748 ),
749 ExceededThreshold::Crap
750 | ExceededThreshold::CyclomaticCrap
751 | ExceededThreshold::CognitiveCrap
752 | ExceededThreshold::All => {
753 let crap = finding.crap.unwrap_or(0.0);
754 let coverage = finding
755 .coverage_pct
756 .map(|pct| format!(", coverage {pct:.0}%"))
757 .unwrap_or_default();
758 format!(
759 "'{}' has CRAP score {crap:.1} (threshold: {crap_t:.1}, cyclomatic {}{coverage})",
760 finding.name, finding.cyclomatic,
761 )
762 }
763 };
764 let check_name = match finding.exceeded {
765 ExceededThreshold::Both => "fallow/high-complexity",
766 ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
767 ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
768 ExceededThreshold::Crap
769 | ExceededThreshold::CyclomaticCrap
770 | ExceededThreshold::CognitiveCrap
771 | ExceededThreshold::All => "fallow/high-crap-score",
772 };
773 let severity = match finding.severity {
775 crate::health_types::FindingSeverity::Critical => "critical",
776 crate::health_types::FindingSeverity::High => "major",
777 crate::health_types::FindingSeverity::Moderate => "minor",
778 };
779 let line_str = finding.line.to_string();
780 let fp = fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
781 issues.push(cc_issue(
782 check_name,
783 &description,
784 severity,
785 "Complexity",
786 &path,
787 Some(finding.line),
788 &fp,
789 ));
790 }
791
792 if let Some(ref production) = report.runtime_coverage {
800 for finding in &production.findings {
801 let path = cc_path(&finding.path, root);
802 let check_name = match finding.verdict {
803 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
804 "fallow/runtime-safe-to-delete"
805 }
806 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
807 "fallow/runtime-review-required"
808 }
809 crate::health_types::RuntimeCoverageVerdict::LowTraffic => {
810 "fallow/runtime-low-traffic"
811 }
812 crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
813 "fallow/runtime-coverage-unavailable"
814 }
815 crate::health_types::RuntimeCoverageVerdict::Active
816 | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
817 };
818 let invocations_hint = finding.invocations.map_or_else(
819 || "untracked".to_owned(),
820 |hits| format!("{hits} invocations"),
821 );
822 let description = format!(
823 "'{}' runtime coverage verdict: {} ({})",
824 finding.function,
825 finding.verdict.human_label(),
826 invocations_hint,
827 );
828 let severity = match finding.verdict {
833 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => "critical",
834 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "major",
835 _ => "minor",
836 };
837 let fp = fingerprint_hash(&[
838 check_name,
839 &path,
840 &finding.line.to_string(),
841 &finding.function,
842 ]);
843 issues.push(cc_issue(
844 check_name,
845 &description,
846 severity,
847 "Bug Risk",
853 &path,
854 Some(finding.line),
855 &fp,
856 ));
857 }
858 }
859
860 if let Some(ref gaps) = report.coverage_gaps {
861 for item in &gaps.files {
862 let path = cc_path(&item.path, root);
863 let description = format!(
864 "File is runtime-reachable but has no test dependency path ({} value export{})",
865 item.value_export_count,
866 if item.value_export_count == 1 {
867 ""
868 } else {
869 "s"
870 },
871 );
872 let fp = fingerprint_hash(&["fallow/untested-file", &path]);
873 issues.push(cc_issue(
874 "fallow/untested-file",
875 &description,
876 "minor",
877 "Coverage",
878 &path,
879 None,
880 &fp,
881 ));
882 }
883
884 for item in &gaps.exports {
885 let path = cc_path(&item.path, root);
886 let description = format!(
887 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
888 item.export_name
889 );
890 let line_str = item.line.to_string();
891 let fp = fingerprint_hash(&[
892 "fallow/untested-export",
893 &path,
894 &line_str,
895 &item.export_name,
896 ]);
897 issues.push(cc_issue(
898 "fallow/untested-export",
899 &description,
900 "minor",
901 "Coverage",
902 &path,
903 Some(item.line),
904 &fp,
905 ));
906 }
907 }
908
909 serde_json::Value::Array(issues)
910}
911
912pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
914 let value = build_health_codeclimate(report, root);
915 emit_json(&value, "CodeClimate")
916}
917
918pub(super) fn print_grouped_health_codeclimate(
927 report: &HealthReport,
928 root: &Path,
929 resolver: &OwnershipResolver,
930) -> ExitCode {
931 let mut value = build_health_codeclimate(report, root);
932
933 if let Some(issues) = value.as_array_mut() {
934 for issue in issues {
935 let path = issue
936 .pointer("/location/path")
937 .and_then(|v| v.as_str())
938 .unwrap_or("");
939 let group = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
940 issue
941 .as_object_mut()
942 .expect("CodeClimate issue should be an object")
943 .insert("group".to_string(), serde_json::Value::String(group));
944 }
945 }
946
947 emit_json(&value, "CodeClimate")
948}
949
950#[must_use]
952#[expect(
953 clippy::cast_possible_truncation,
954 reason = "line numbers are bounded by source size"
955)]
956pub fn build_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> serde_json::Value {
957 let mut issues = Vec::new();
958
959 for (i, group) in report.clone_groups.iter().enumerate() {
960 let token_str = group.token_count.to_string();
963 let line_count_str = group.line_count.to_string();
964 let fragment_prefix: String = group
965 .instances
966 .first()
967 .map(|inst| inst.fragment.chars().take(64).collect())
968 .unwrap_or_default();
969
970 for instance in &group.instances {
971 let path = cc_path(&instance.file, root);
972 let start_str = instance.start_line.to_string();
973 let fp = fingerprint_hash(&[
974 "fallow/code-duplication",
975 &path,
976 &start_str,
977 &token_str,
978 &line_count_str,
979 &fragment_prefix,
980 ]);
981 issues.push(cc_issue(
982 "fallow/code-duplication",
983 &format!(
984 "Code clone group {} ({} lines, {} instances)",
985 i + 1,
986 group.line_count,
987 group.instances.len()
988 ),
989 "minor",
990 "Duplication",
991 &path,
992 Some(instance.start_line as u32),
993 &fp,
994 ));
995 }
996 }
997
998 serde_json::Value::Array(issues)
999}
1000
1001pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
1003 let value = build_duplication_codeclimate(report, root);
1004 emit_json(&value, "CodeClimate")
1005}
1006
1007pub(super) fn print_grouped_duplication_codeclimate(
1016 report: &DuplicationReport,
1017 root: &Path,
1018 resolver: &OwnershipResolver,
1019) -> ExitCode {
1020 let mut value = build_duplication_codeclimate(report, root);
1021
1022 use rustc_hash::FxHashMap;
1025 let mut path_to_owner: FxHashMap<String, String> = FxHashMap::default();
1026 for group in &report.clone_groups {
1027 let owner = super::dupes_grouping::largest_owner(group, root, resolver);
1028 for instance in &group.instances {
1029 let path = cc_path(&instance.file, root);
1030 path_to_owner.insert(path, owner.clone());
1031 }
1032 }
1033
1034 if let Some(issues) = value.as_array_mut() {
1035 for issue in issues {
1036 let path = issue
1037 .pointer("/location/path")
1038 .and_then(|v| v.as_str())
1039 .unwrap_or("")
1040 .to_string();
1041 let group = path_to_owner
1042 .get(&path)
1043 .cloned()
1044 .unwrap_or_else(|| crate::codeowners::UNOWNED_LABEL.to_string());
1045 issue
1046 .as_object_mut()
1047 .expect("CodeClimate issue should be an object")
1048 .insert("group".to_string(), serde_json::Value::String(group));
1049 }
1050 }
1051
1052 emit_json(&value, "CodeClimate")
1053}
1054
1055#[cfg(test)]
1056mod tests {
1057 use super::*;
1058 use crate::report::test_helpers::sample_results;
1059 use fallow_config::RulesConfig;
1060 use fallow_core::results::*;
1061 use std::path::PathBuf;
1062
1063 fn health_severity(value: u16, threshold: u16) -> &'static str {
1066 if threshold == 0 {
1067 return "minor";
1068 }
1069 let ratio = f64::from(value) / f64::from(threshold);
1070 if ratio > 2.5 {
1071 "critical"
1072 } else if ratio > 1.5 {
1073 "major"
1074 } else {
1075 "minor"
1076 }
1077 }
1078
1079 #[test]
1080 fn codeclimate_empty_results_produces_empty_array() {
1081 let root = PathBuf::from("/project");
1082 let results = AnalysisResults::default();
1083 let rules = RulesConfig::default();
1084 let output = build_codeclimate(&results, &root, &rules);
1085 let arr = output.as_array().unwrap();
1086 assert!(arr.is_empty());
1087 }
1088
1089 #[test]
1090 fn codeclimate_produces_array_of_issues() {
1091 let root = PathBuf::from("/project");
1092 let results = sample_results(&root);
1093 let rules = RulesConfig::default();
1094 let output = build_codeclimate(&results, &root, &rules);
1095 assert!(output.is_array());
1096 let arr = output.as_array().unwrap();
1097 assert!(!arr.is_empty());
1099 }
1100
1101 #[test]
1102 fn codeclimate_issue_has_required_fields() {
1103 let root = PathBuf::from("/project");
1104 let mut results = AnalysisResults::default();
1105 results.unused_files.push(UnusedFile {
1106 path: root.join("src/dead.ts"),
1107 });
1108 let rules = RulesConfig::default();
1109 let output = build_codeclimate(&results, &root, &rules);
1110 let issue = &output.as_array().unwrap()[0];
1111
1112 assert_eq!(issue["type"], "issue");
1113 assert_eq!(issue["check_name"], "fallow/unused-file");
1114 assert!(issue["description"].is_string());
1115 assert!(issue["categories"].is_array());
1116 assert!(issue["severity"].is_string());
1117 assert!(issue["fingerprint"].is_string());
1118 assert!(issue["location"].is_object());
1119 assert!(issue["location"]["path"].is_string());
1120 assert!(issue["location"]["lines"].is_object());
1121 }
1122
1123 #[test]
1124 fn codeclimate_unused_file_severity_follows_rules() {
1125 let root = PathBuf::from("/project");
1126 let mut results = AnalysisResults::default();
1127 results.unused_files.push(UnusedFile {
1128 path: root.join("src/dead.ts"),
1129 });
1130
1131 let rules = RulesConfig::default();
1133 let output = build_codeclimate(&results, &root, &rules);
1134 assert_eq!(output[0]["severity"], "major");
1135
1136 let rules = RulesConfig {
1138 unused_files: Severity::Warn,
1139 ..RulesConfig::default()
1140 };
1141 let output = build_codeclimate(&results, &root, &rules);
1142 assert_eq!(output[0]["severity"], "minor");
1143 }
1144
1145 #[test]
1146 fn codeclimate_unused_export_has_line_number() {
1147 let root = PathBuf::from("/project");
1148 let mut results = AnalysisResults::default();
1149 results.unused_exports.push(UnusedExport {
1150 path: root.join("src/utils.ts"),
1151 export_name: "helperFn".to_string(),
1152 is_type_only: false,
1153 line: 10,
1154 col: 4,
1155 span_start: 120,
1156 is_re_export: false,
1157 });
1158 let rules = RulesConfig::default();
1159 let output = build_codeclimate(&results, &root, &rules);
1160 let issue = &output[0];
1161 assert_eq!(issue["location"]["lines"]["begin"], 10);
1162 }
1163
1164 #[test]
1165 fn codeclimate_unused_file_line_defaults_to_1() {
1166 let root = PathBuf::from("/project");
1167 let mut results = AnalysisResults::default();
1168 results.unused_files.push(UnusedFile {
1169 path: root.join("src/dead.ts"),
1170 });
1171 let rules = RulesConfig::default();
1172 let output = build_codeclimate(&results, &root, &rules);
1173 let issue = &output[0];
1174 assert_eq!(issue["location"]["lines"]["begin"], 1);
1175 }
1176
1177 #[test]
1178 fn codeclimate_paths_are_relative() {
1179 let root = PathBuf::from("/project");
1180 let mut results = AnalysisResults::default();
1181 results.unused_files.push(UnusedFile {
1182 path: root.join("src/deep/nested/file.ts"),
1183 });
1184 let rules = RulesConfig::default();
1185 let output = build_codeclimate(&results, &root, &rules);
1186 let path = output[0]["location"]["path"].as_str().unwrap();
1187 assert_eq!(path, "src/deep/nested/file.ts");
1188 assert!(!path.starts_with("/project"));
1189 }
1190
1191 #[test]
1192 fn codeclimate_re_export_label_in_description() {
1193 let root = PathBuf::from("/project");
1194 let mut results = AnalysisResults::default();
1195 results.unused_exports.push(UnusedExport {
1196 path: root.join("src/index.ts"),
1197 export_name: "reExported".to_string(),
1198 is_type_only: false,
1199 line: 1,
1200 col: 0,
1201 span_start: 0,
1202 is_re_export: true,
1203 });
1204 let rules = RulesConfig::default();
1205 let output = build_codeclimate(&results, &root, &rules);
1206 let desc = output[0]["description"].as_str().unwrap();
1207 assert!(desc.contains("Re-export"));
1208 }
1209
1210 #[test]
1211 fn codeclimate_unlisted_dep_one_issue_per_import_site() {
1212 let root = PathBuf::from("/project");
1213 let mut results = AnalysisResults::default();
1214 results.unlisted_dependencies.push(UnlistedDependency {
1215 package_name: "chalk".to_string(),
1216 imported_from: vec![
1217 ImportSite {
1218 path: root.join("src/a.ts"),
1219 line: 1,
1220 col: 0,
1221 },
1222 ImportSite {
1223 path: root.join("src/b.ts"),
1224 line: 5,
1225 col: 0,
1226 },
1227 ],
1228 });
1229 let rules = RulesConfig::default();
1230 let output = build_codeclimate(&results, &root, &rules);
1231 let arr = output.as_array().unwrap();
1232 assert_eq!(arr.len(), 2);
1233 assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
1234 assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
1235 }
1236
1237 #[test]
1238 fn codeclimate_duplicate_export_one_issue_per_location() {
1239 let root = PathBuf::from("/project");
1240 let mut results = AnalysisResults::default();
1241 results.duplicate_exports.push(DuplicateExport {
1242 export_name: "Config".to_string(),
1243 locations: vec![
1244 DuplicateLocation {
1245 path: root.join("src/a.ts"),
1246 line: 10,
1247 col: 0,
1248 },
1249 DuplicateLocation {
1250 path: root.join("src/b.ts"),
1251 line: 20,
1252 col: 0,
1253 },
1254 DuplicateLocation {
1255 path: root.join("src/c.ts"),
1256 line: 30,
1257 col: 0,
1258 },
1259 ],
1260 });
1261 let rules = RulesConfig::default();
1262 let output = build_codeclimate(&results, &root, &rules);
1263 let arr = output.as_array().unwrap();
1264 assert_eq!(arr.len(), 3);
1265 }
1266
1267 #[test]
1268 fn codeclimate_circular_dep_emits_chain_in_description() {
1269 let root = PathBuf::from("/project");
1270 let mut results = AnalysisResults::default();
1271 results.circular_dependencies.push(CircularDependency {
1272 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1273 length: 2,
1274 line: 3,
1275 col: 0,
1276 is_cross_package: false,
1277 });
1278 let rules = RulesConfig::default();
1279 let output = build_codeclimate(&results, &root, &rules);
1280 let desc = output[0]["description"].as_str().unwrap();
1281 assert!(desc.contains("Circular dependency"));
1282 assert!(desc.contains("src/a.ts"));
1283 assert!(desc.contains("src/b.ts"));
1284 }
1285
1286 #[test]
1287 fn codeclimate_fingerprints_are_deterministic() {
1288 let root = PathBuf::from("/project");
1289 let results = sample_results(&root);
1290 let rules = RulesConfig::default();
1291 let output1 = build_codeclimate(&results, &root, &rules);
1292 let output2 = build_codeclimate(&results, &root, &rules);
1293
1294 let fps1: Vec<&str> = output1
1295 .as_array()
1296 .unwrap()
1297 .iter()
1298 .map(|i| i["fingerprint"].as_str().unwrap())
1299 .collect();
1300 let fps2: Vec<&str> = output2
1301 .as_array()
1302 .unwrap()
1303 .iter()
1304 .map(|i| i["fingerprint"].as_str().unwrap())
1305 .collect();
1306 assert_eq!(fps1, fps2);
1307 }
1308
1309 #[test]
1310 fn codeclimate_fingerprints_are_unique() {
1311 let root = PathBuf::from("/project");
1312 let results = sample_results(&root);
1313 let rules = RulesConfig::default();
1314 let output = build_codeclimate(&results, &root, &rules);
1315
1316 let mut fps: Vec<&str> = output
1317 .as_array()
1318 .unwrap()
1319 .iter()
1320 .map(|i| i["fingerprint"].as_str().unwrap())
1321 .collect();
1322 let original_len = fps.len();
1323 fps.sort_unstable();
1324 fps.dedup();
1325 assert_eq!(fps.len(), original_len, "fingerprints should be unique");
1326 }
1327
1328 #[test]
1329 fn codeclimate_type_only_dep_has_correct_check_name() {
1330 let root = PathBuf::from("/project");
1331 let mut results = AnalysisResults::default();
1332 results.type_only_dependencies.push(TypeOnlyDependency {
1333 package_name: "zod".to_string(),
1334 path: root.join("package.json"),
1335 line: 8,
1336 });
1337 let rules = RulesConfig::default();
1338 let output = build_codeclimate(&results, &root, &rules);
1339 assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
1340 let desc = output[0]["description"].as_str().unwrap();
1341 assert!(desc.contains("zod"));
1342 assert!(desc.contains("type-only"));
1343 }
1344
1345 #[test]
1346 fn codeclimate_dep_with_zero_line_omits_line_number() {
1347 let root = PathBuf::from("/project");
1348 let mut results = AnalysisResults::default();
1349 results.unused_dependencies.push(UnusedDependency {
1350 package_name: "lodash".to_string(),
1351 location: DependencyLocation::Dependencies,
1352 path: root.join("package.json"),
1353 line: 0,
1354 used_in_workspaces: Vec::new(),
1355 });
1356 let rules = RulesConfig::default();
1357 let output = build_codeclimate(&results, &root, &rules);
1358 assert_eq!(output[0]["location"]["lines"]["begin"], 1);
1360 }
1361
1362 #[test]
1365 fn fingerprint_hash_different_inputs_differ() {
1366 let h1 = fingerprint_hash(&["a", "b"]);
1367 let h2 = fingerprint_hash(&["a", "c"]);
1368 assert_ne!(h1, h2);
1369 }
1370
1371 #[test]
1372 fn fingerprint_hash_order_matters() {
1373 let h1 = fingerprint_hash(&["a", "b"]);
1374 let h2 = fingerprint_hash(&["b", "a"]);
1375 assert_ne!(h1, h2);
1376 }
1377
1378 #[test]
1379 fn fingerprint_hash_separator_prevents_collision() {
1380 let h1 = fingerprint_hash(&["ab", "c"]);
1382 let h2 = fingerprint_hash(&["a", "bc"]);
1383 assert_ne!(h1, h2);
1384 }
1385
1386 #[test]
1387 fn fingerprint_hash_is_16_hex_chars() {
1388 let h = fingerprint_hash(&["test"]);
1389 assert_eq!(h.len(), 16);
1390 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
1391 }
1392
1393 #[test]
1396 fn severity_error_maps_to_major() {
1397 assert_eq!(severity_to_codeclimate(Severity::Error), "major");
1398 }
1399
1400 #[test]
1401 fn severity_warn_maps_to_minor() {
1402 assert_eq!(severity_to_codeclimate(Severity::Warn), "minor");
1403 }
1404
1405 #[test]
1406 #[should_panic(expected = "internal error: entered unreachable code")]
1407 fn severity_off_maps_to_minor() {
1408 let _ = severity_to_codeclimate(Severity::Off);
1409 }
1410
1411 #[test]
1414 fn health_severity_zero_threshold_returns_minor() {
1415 assert_eq!(health_severity(100, 0), "minor");
1416 }
1417
1418 #[test]
1419 fn health_severity_at_threshold_returns_minor() {
1420 assert_eq!(health_severity(10, 10), "minor");
1421 }
1422
1423 #[test]
1424 fn health_severity_1_5x_threshold_returns_minor() {
1425 assert_eq!(health_severity(15, 10), "minor");
1426 }
1427
1428 #[test]
1429 fn health_severity_above_1_5x_returns_major() {
1430 assert_eq!(health_severity(16, 10), "major");
1431 }
1432
1433 #[test]
1434 fn health_severity_at_2_5x_returns_major() {
1435 assert_eq!(health_severity(25, 10), "major");
1436 }
1437
1438 #[test]
1439 fn health_severity_above_2_5x_returns_critical() {
1440 assert_eq!(health_severity(26, 10), "critical");
1441 }
1442
1443 #[test]
1444 fn health_codeclimate_includes_coverage_gaps() {
1445 use crate::health_types::*;
1446
1447 let root = PathBuf::from("/project");
1448 let report = HealthReport {
1449 summary: HealthSummary {
1450 files_analyzed: 10,
1451 functions_analyzed: 50,
1452 ..Default::default()
1453 },
1454 coverage_gaps: Some(CoverageGaps {
1455 summary: CoverageGapSummary {
1456 runtime_files: 2,
1457 covered_files: 0,
1458 file_coverage_pct: 0.0,
1459 untested_files: 1,
1460 untested_exports: 1,
1461 },
1462 files: vec![UntestedFile {
1463 path: root.join("src/app.ts"),
1464 value_export_count: 2,
1465 }],
1466 exports: vec![UntestedExport {
1467 path: root.join("src/app.ts"),
1468 export_name: "loader".into(),
1469 line: 12,
1470 col: 4,
1471 }],
1472 }),
1473 ..Default::default()
1474 };
1475
1476 let output = build_health_codeclimate(&report, &root);
1477 let issues = output.as_array().unwrap();
1478 assert_eq!(issues.len(), 2);
1479 assert_eq!(issues[0]["check_name"], "fallow/untested-file");
1480 assert_eq!(issues[0]["categories"][0], "Coverage");
1481 assert_eq!(issues[0]["location"]["path"], "src/app.ts");
1482 assert_eq!(issues[1]["check_name"], "fallow/untested-export");
1483 assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
1484 assert!(
1485 issues[1]["description"]
1486 .as_str()
1487 .unwrap()
1488 .contains("loader")
1489 );
1490 }
1491
1492 #[test]
1493 fn health_codeclimate_crap_only_uses_crap_check_name() {
1494 use crate::health_types::{FindingSeverity, HealthFinding, HealthReport, HealthSummary};
1495 let root = PathBuf::from("/project");
1496 let report = HealthReport {
1497 findings: vec![HealthFinding {
1498 path: root.join("src/untested.ts"),
1499 name: "risky".to_string(),
1500 line: 7,
1501 col: 0,
1502 cyclomatic: 10,
1503 cognitive: 10,
1504 line_count: 20,
1505 param_count: 1,
1506 exceeded: crate::health_types::ExceededThreshold::Crap,
1507 severity: FindingSeverity::High,
1508 crap: Some(60.0),
1509 coverage_pct: Some(25.0),
1510 coverage_tier: None,
1511 }],
1512 summary: HealthSummary {
1513 functions_analyzed: 10,
1514 functions_above_threshold: 1,
1515 ..Default::default()
1516 },
1517 ..Default::default()
1518 };
1519 let json = build_health_codeclimate(&report, &root);
1520 let issues = json.as_array().unwrap();
1521 assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
1522 assert_eq!(issues[0]["severity"], "major");
1523 let description = issues[0]["description"].as_str().unwrap();
1524 assert!(description.contains("CRAP score"), "desc: {description}");
1525 assert!(description.contains("coverage 25%"), "desc: {description}");
1526 }
1527}