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