1use std::path::Path;
2use std::process::ExitCode;
3
4use fallow_config::{RulesConfig, Severity};
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::AnalysisResults;
7
8use super::ci::{fingerprint, severity};
9use super::grouping::{self, OwnershipResolver};
10use super::{emit_json, normalize_uri, relative_path};
11use crate::health_types::{
12 ComplexityViolation, CoverageIntelligenceFinding, ExceededThreshold, HealthReport,
13 RuntimeCoverageFinding, UntestedExportFinding, UntestedFileFinding,
14};
15use crate::output_envelope::{
16 CodeClimateIssue, CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation,
17 CodeClimateSeverity,
18};
19
20fn severity_to_codeclimate(s: Severity) -> CodeClimateSeverity {
22 severity::codeclimate_severity(s)
23}
24
25fn cc_path(path: &Path, root: &Path) -> String {
30 normalize_uri(&relative_path(path, root).display().to_string())
31}
32
33fn fingerprint_hash(parts: &[&str]) -> String {
38 fingerprint::fingerprint_hash(parts)
39}
40
41fn cc_issue(
45 check_name: &str,
46 description: &str,
47 severity: CodeClimateSeverity,
48 category: &str,
49 path: &str,
50 begin_line: Option<u32>,
51 fingerprint: &str,
52) -> CodeClimateIssue {
53 CodeClimateIssue {
54 kind: CodeClimateIssueKind::Issue,
55 check_name: check_name.to_string(),
56 description: description.to_string(),
57 categories: vec![category.to_string()],
58 severity,
59 fingerprint: fingerprint.to_string(),
60 location: CodeClimateLocation {
61 path: path.to_string(),
62 lines: CodeClimateLines {
63 begin: begin_line.unwrap_or(1),
64 },
65 },
66 }
67}
68
69fn coverage_intelligence_check_name(
70 recommendation: crate::health_types::CoverageIntelligenceRecommendation,
71) -> &'static str {
72 match recommendation {
73 crate::health_types::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
74 "fallow/coverage-intelligence-risky-change"
75 }
76 crate::health_types::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
77 "fallow/coverage-intelligence-delete"
78 }
79 crate::health_types::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
80 "fallow/coverage-intelligence-review"
81 }
82 crate::health_types::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
83 "fallow/coverage-intelligence-refactor"
84 }
85 }
86}
87
88struct HealthCodeClimateContext<'a> {
89 root: &'a Path,
90 cyc_t: u16,
91 cog_t: u16,
92 crap_t: f64,
93}
94
95impl HealthCodeClimateContext<'_> {
96 fn complexity_issue(&self, finding: &ComplexityViolation) -> CodeClimateIssue {
97 let path = cc_path(&finding.path, self.root);
98 let check_name = complexity_check_name(finding);
99 let line_str = finding.line.to_string();
100 let fp = fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
101 cc_issue(
102 check_name,
103 &self.complexity_description(finding),
104 health_finding_severity(finding.severity),
105 "Complexity",
106 &path,
107 Some(finding.line),
108 &fp,
109 )
110 }
111
112 fn complexity_description(&self, finding: &ComplexityViolation) -> String {
113 match finding.exceeded {
114 ExceededThreshold::Both => format!(
115 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
116 finding.name, finding.cyclomatic, self.cyc_t, finding.cognitive, self.cog_t
117 ),
118 ExceededThreshold::Cyclomatic => format!(
119 "'{}' has cyclomatic complexity {} (threshold: {})",
120 finding.name, finding.cyclomatic, self.cyc_t
121 ),
122 ExceededThreshold::Cognitive => format!(
123 "'{}' has cognitive complexity {} (threshold: {})",
124 finding.name, finding.cognitive, self.cog_t
125 ),
126 ExceededThreshold::Crap
127 | ExceededThreshold::CyclomaticCrap
128 | ExceededThreshold::CognitiveCrap
129 | ExceededThreshold::All => {
130 let crap = finding.crap.unwrap_or(0.0);
131 let coverage = finding
132 .coverage_pct
133 .map(|pct| format!(", coverage {pct:.0}%"))
134 .unwrap_or_default();
135 format!(
136 "'{}' has CRAP score {crap:.1} (threshold: {:.1}, cyclomatic {}{coverage})",
137 finding.name, self.crap_t, finding.cyclomatic,
138 )
139 }
140 }
141 }
142
143 fn runtime_coverage_issue(&self, finding: &RuntimeCoverageFinding) -> CodeClimateIssue {
144 let path = cc_path(&finding.path, self.root);
145 let check_name = runtime_coverage_check_name(finding.verdict);
146 let invocations_hint = finding.invocations.map_or_else(
147 || "untracked".to_owned(),
148 |hits| format!("{hits} invocations"),
149 );
150 let description = format!(
151 "'{}' runtime coverage verdict: {} ({})",
152 finding.function,
153 finding.verdict.human_label(),
154 invocations_hint,
155 );
156 let fp = fingerprint_hash(&[
157 check_name,
158 &path,
159 &finding.line.to_string(),
160 &finding.function,
161 ]);
162 cc_issue(
163 check_name,
164 &description,
165 runtime_coverage_severity(finding.verdict),
166 "Bug Risk",
167 &path,
168 Some(finding.line),
169 &fp,
170 )
171 }
172
173 fn coverage_intelligence_issue(
174 &self,
175 finding: &CoverageIntelligenceFinding,
176 ) -> Option<CodeClimateIssue> {
177 let severity = coverage_intelligence_severity(finding.verdict)?;
178 let path = cc_path(&finding.path, self.root);
179 let check_name = coverage_intelligence_check_name(finding.recommendation);
180 let identity = finding.identity.as_deref().unwrap_or("code");
181 let description = format!(
182 "'{}' coverage intelligence verdict: {} ({})",
183 identity, finding.verdict, finding.recommendation,
184 );
185 let fp = fingerprint_hash(&[
186 check_name,
187 &path,
188 &finding.line.to_string(),
189 identity,
190 &finding.id,
191 ]);
192 Some(cc_issue(
193 check_name,
194 &description,
195 severity,
196 "Bug Risk",
197 &path,
198 Some(finding.line),
199 &fp,
200 ))
201 }
202
203 fn untested_file_issue(&self, item: &UntestedFileFinding) -> CodeClimateIssue {
204 let path = cc_path(&item.file.path, self.root);
205 let description = format!(
206 "File is runtime-reachable but has no test dependency path ({} value export{})",
207 item.file.value_export_count,
208 if item.file.value_export_count == 1 {
209 ""
210 } else {
211 "s"
212 },
213 );
214 let fp = fingerprint_hash(&["fallow/untested-file", &path]);
215 cc_issue(
216 "fallow/untested-file",
217 &description,
218 CodeClimateSeverity::Minor,
219 "Coverage",
220 &path,
221 None,
222 &fp,
223 )
224 }
225
226 fn untested_export_issue(&self, item: &UntestedExportFinding) -> CodeClimateIssue {
227 let path = cc_path(&item.export.path, self.root);
228 let description = format!(
229 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
230 item.export.export_name
231 );
232 let line_str = item.export.line.to_string();
233 let fp = fingerprint_hash(&[
234 "fallow/untested-export",
235 &path,
236 &line_str,
237 &item.export.export_name,
238 ]);
239 cc_issue(
240 "fallow/untested-export",
241 &description,
242 CodeClimateSeverity::Minor,
243 "Coverage",
244 &path,
245 Some(item.export.line),
246 &fp,
247 )
248 }
249}
250
251const fn complexity_check_name(finding: &ComplexityViolation) -> &'static str {
252 match finding.exceeded {
253 ExceededThreshold::Both => "fallow/high-complexity",
254 ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
255 ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
256 ExceededThreshold::Crap
257 | ExceededThreshold::CyclomaticCrap
258 | ExceededThreshold::CognitiveCrap
259 | ExceededThreshold::All => "fallow/high-crap-score",
260 }
261}
262
263const fn health_finding_severity(
264 severity: crate::health_types::FindingSeverity,
265) -> CodeClimateSeverity {
266 match severity {
267 crate::health_types::FindingSeverity::Critical => CodeClimateSeverity::Critical,
268 crate::health_types::FindingSeverity::High => CodeClimateSeverity::Major,
269 crate::health_types::FindingSeverity::Moderate => CodeClimateSeverity::Minor,
270 }
271}
272
273const fn runtime_coverage_check_name(
274 verdict: crate::health_types::RuntimeCoverageVerdict,
275) -> &'static str {
276 match verdict {
277 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
278 "fallow/runtime-safe-to-delete"
279 }
280 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
281 "fallow/runtime-review-required"
282 }
283 crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
284 crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
285 "fallow/runtime-coverage-unavailable"
286 }
287 crate::health_types::RuntimeCoverageVerdict::Active
288 | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
289 }
290}
291
292const fn runtime_coverage_severity(
293 verdict: crate::health_types::RuntimeCoverageVerdict,
294) -> CodeClimateSeverity {
295 match verdict {
296 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => CodeClimateSeverity::Critical,
297 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => CodeClimateSeverity::Major,
298 _ => CodeClimateSeverity::Minor,
299 }
300}
301
302const fn coverage_intelligence_severity(
303 verdict: crate::health_types::CoverageIntelligenceVerdict,
304) -> Option<CodeClimateSeverity> {
305 match verdict {
306 crate::health_types::CoverageIntelligenceVerdict::RiskyChangeDetected
307 | crate::health_types::CoverageIntelligenceVerdict::HighConfidenceDelete => {
308 Some(CodeClimateSeverity::Major)
309 }
310 crate::health_types::CoverageIntelligenceVerdict::ReviewRequired
311 | crate::health_types::CoverageIntelligenceVerdict::RefactorCarefully => {
312 Some(CodeClimateSeverity::Minor)
313 }
314 crate::health_types::CoverageIntelligenceVerdict::Clean
315 | crate::health_types::CoverageIntelligenceVerdict::Unknown => None,
316 }
317}
318
319fn push_dep_cc_issues<'a, I>(
321 issues: &mut Vec<CodeClimateIssue>,
322 deps: I,
323 root: &Path,
324 rule_id: &str,
325 location_label: &str,
326 severity: Severity,
327) where
328 I: IntoIterator<Item = &'a fallow_core::results::UnusedDependency>,
329{
330 for dep in deps {
331 let level = severity_to_codeclimate(severity);
332 let path = cc_path(&dep.path, root);
333 let line = if dep.line > 0 { Some(dep.line) } else { None };
334 let fp = fingerprint_hash(&[rule_id, &dep.package_name]);
335 let workspace_context = if dep.used_in_workspaces.is_empty() {
336 String::new()
337 } else {
338 let workspaces = dep
339 .used_in_workspaces
340 .iter()
341 .map(|path| cc_path(path, root))
342 .collect::<Vec<_>>()
343 .join(", ");
344 format!("; imported in other workspaces: {workspaces}")
345 };
346 issues.push(cc_issue(
347 rule_id,
348 &format!(
349 "Package '{}' is in {location_label} but never imported{workspace_context}",
350 dep.package_name
351 ),
352 level,
353 "Bug Risk",
354 &path,
355 line,
356 &fp,
357 ));
358 }
359}
360
361fn push_unused_file_issues(
362 issues: &mut Vec<CodeClimateIssue>,
363 files: &[fallow_types::output_dead_code::UnusedFileFinding],
364 root: &Path,
365 severity: Severity,
366) {
367 if files.is_empty() {
368 return;
369 }
370 let level = severity_to_codeclimate(severity);
371 for entry in files {
372 let path = cc_path(&entry.file.path, root);
373 let fp = fingerprint_hash(&["fallow/unused-file", &path]);
374 issues.push(cc_issue(
375 "fallow/unused-file",
376 "File is not reachable from any entry point",
377 level,
378 "Bug Risk",
379 &path,
380 None,
381 &fp,
382 ));
383 }
384}
385
386fn push_unused_export_issues<'a, I>(
392 issues: &mut Vec<CodeClimateIssue>,
393 exports: I,
394 root: &Path,
395 rule_id: &str,
396 direct_label: &str,
397 re_export_label: &str,
398 severity: Severity,
399) where
400 I: IntoIterator<Item = &'a fallow_core::results::UnusedExport>,
401{
402 for export in exports {
403 let level = severity_to_codeclimate(severity);
404 let path = cc_path(&export.path, root);
405 let kind = if export.is_re_export {
406 re_export_label
407 } else {
408 direct_label
409 };
410 let line_str = export.line.to_string();
411 let fp = fingerprint_hash(&[rule_id, &path, &line_str, &export.export_name]);
412 issues.push(cc_issue(
413 rule_id,
414 &format!(
415 "{kind} '{}' is never imported by other modules",
416 export.export_name
417 ),
418 level,
419 "Bug Risk",
420 &path,
421 Some(export.line),
422 &fp,
423 ));
424 }
425}
426
427fn push_private_type_leak_issues(
428 issues: &mut Vec<CodeClimateIssue>,
429 leaks: &[fallow_types::output_dead_code::PrivateTypeLeakFinding],
430 root: &Path,
431 severity: Severity,
432) {
433 if leaks.is_empty() {
434 return;
435 }
436 let level = severity_to_codeclimate(severity);
437 for entry in leaks {
438 let leak = &entry.leak;
439 let path = cc_path(&leak.path, root);
440 let line_str = leak.line.to_string();
441 let fp = fingerprint_hash(&[
442 "fallow/private-type-leak",
443 &path,
444 &line_str,
445 &leak.export_name,
446 &leak.type_name,
447 ]);
448 issues.push(cc_issue(
449 "fallow/private-type-leak",
450 &format!(
451 "Export '{}' references private type '{}'",
452 leak.export_name, leak.type_name
453 ),
454 level,
455 "Bug Risk",
456 &path,
457 Some(leak.line),
458 &fp,
459 ));
460 }
461}
462
463fn push_type_only_dep_issues(
464 issues: &mut Vec<CodeClimateIssue>,
465 deps: &[fallow_core::results::TypeOnlyDependencyFinding],
466 root: &Path,
467 severity: Severity,
468) {
469 if deps.is_empty() {
470 return;
471 }
472 let level = severity_to_codeclimate(severity);
473 for entry in deps {
474 let dep = &entry.dep;
475 let path = cc_path(&dep.path, root);
476 let line = if dep.line > 0 { Some(dep.line) } else { None };
477 let fp = fingerprint_hash(&["fallow/type-only-dependency", &dep.package_name]);
478 issues.push(cc_issue(
479 "fallow/type-only-dependency",
480 &format!(
481 "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
482 dep.package_name
483 ),
484 level,
485 "Bug Risk",
486 &path,
487 line,
488 &fp,
489 ));
490 }
491}
492
493fn push_test_only_dep_issues(
494 issues: &mut Vec<CodeClimateIssue>,
495 deps: &[fallow_core::results::TestOnlyDependencyFinding],
496 root: &Path,
497 severity: Severity,
498) {
499 if deps.is_empty() {
500 return;
501 }
502 let level = severity_to_codeclimate(severity);
503 for entry in deps {
504 let dep = &entry.dep;
505 let path = cc_path(&dep.path, root);
506 let line = if dep.line > 0 { Some(dep.line) } else { None };
507 let fp = fingerprint_hash(&["fallow/test-only-dependency", &dep.package_name]);
508 issues.push(cc_issue(
509 "fallow/test-only-dependency",
510 &format!(
511 "Package '{}' is only imported by test files (consider moving to devDependencies)",
512 dep.package_name
513 ),
514 level,
515 "Bug Risk",
516 &path,
517 line,
518 &fp,
519 ));
520 }
521}
522
523fn push_unused_member_issues<'a, I>(
528 issues: &mut Vec<CodeClimateIssue>,
529 members: I,
530 root: &Path,
531 rule_id: &str,
532 entity_label: &str,
533 severity: Severity,
534) where
535 I: IntoIterator<Item = &'a fallow_core::results::UnusedMember>,
536{
537 for member in members {
538 let level = severity_to_codeclimate(severity);
539 let path = cc_path(&member.path, root);
540 let line_str = member.line.to_string();
541 let fp = fingerprint_hash(&[
542 rule_id,
543 &path,
544 &line_str,
545 &member.parent_name,
546 &member.member_name,
547 ]);
548 issues.push(cc_issue(
549 rule_id,
550 &format!(
551 "{entity_label} member '{}.{}' is never referenced",
552 member.parent_name, member.member_name
553 ),
554 level,
555 "Bug Risk",
556 &path,
557 Some(member.line),
558 &fp,
559 ));
560 }
561}
562
563fn push_unresolved_import_issues(
564 issues: &mut Vec<CodeClimateIssue>,
565 imports: &[fallow_types::output_dead_code::UnresolvedImportFinding],
566 root: &Path,
567 severity: Severity,
568) {
569 if imports.is_empty() {
570 return;
571 }
572 let level = severity_to_codeclimate(severity);
573 for entry in imports {
574 let import = &entry.import;
575 let path = cc_path(&import.path, root);
576 let line_str = import.line.to_string();
577 let fp = fingerprint_hash(&[
578 "fallow/unresolved-import",
579 &path,
580 &line_str,
581 &import.specifier,
582 ]);
583 issues.push(cc_issue(
584 "fallow/unresolved-import",
585 &format!("Import '{}' could not be resolved", import.specifier),
586 level,
587 "Bug Risk",
588 &path,
589 Some(import.line),
590 &fp,
591 ));
592 }
593}
594
595fn push_unlisted_dep_issues(
596 issues: &mut Vec<CodeClimateIssue>,
597 deps: &[fallow_core::results::UnlistedDependencyFinding],
598 root: &Path,
599 severity: Severity,
600) {
601 if deps.is_empty() {
602 return;
603 }
604 let level = severity_to_codeclimate(severity);
605 for entry in deps {
606 let dep = &entry.dep;
607 for site in &dep.imported_from {
608 let path = cc_path(&site.path, root);
609 let line_str = site.line.to_string();
610 let fp = fingerprint_hash(&[
611 "fallow/unlisted-dependency",
612 &path,
613 &line_str,
614 &dep.package_name,
615 ]);
616 issues.push(cc_issue(
617 "fallow/unlisted-dependency",
618 &format!(
619 "Package '{}' is imported but not listed in package.json",
620 dep.package_name
621 ),
622 level,
623 "Bug Risk",
624 &path,
625 Some(site.line),
626 &fp,
627 ));
628 }
629 }
630}
631
632fn push_duplicate_export_issues(
633 issues: &mut Vec<CodeClimateIssue>,
634 dups: &[fallow_core::results::DuplicateExportFinding],
635 root: &Path,
636 severity: Severity,
637) {
638 if dups.is_empty() {
639 return;
640 }
641 let level = severity_to_codeclimate(severity);
642 for dup in dups {
643 let dup = &dup.export;
644 for loc in &dup.locations {
645 let path = cc_path(&loc.path, root);
646 let line_str = loc.line.to_string();
647 let fp = fingerprint_hash(&[
648 "fallow/duplicate-export",
649 &path,
650 &line_str,
651 &dup.export_name,
652 ]);
653 issues.push(cc_issue(
654 "fallow/duplicate-export",
655 &format!("Export '{}' appears in multiple modules", dup.export_name),
656 level,
657 "Bug Risk",
658 &path,
659 Some(loc.line),
660 &fp,
661 ));
662 }
663 }
664}
665
666fn push_circular_dep_issues(
667 issues: &mut Vec<CodeClimateIssue>,
668 cycles: &[fallow_types::output_dead_code::CircularDependencyFinding],
669 root: &Path,
670 severity: Severity,
671) {
672 if cycles.is_empty() {
673 return;
674 }
675 let level = severity_to_codeclimate(severity);
676 for entry in cycles {
677 let cycle = &entry.cycle;
678 let Some(first) = cycle.files.first() else {
679 continue;
680 };
681 let path = cc_path(first, root);
682 let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
683 let chain_str = chain.join(":");
684 let fp = fingerprint_hash(&["fallow/circular-dependency", &chain_str]);
685 let line = if cycle.line > 0 {
686 Some(cycle.line)
687 } else {
688 None
689 };
690 issues.push(cc_issue(
691 "fallow/circular-dependency",
692 &format!(
693 "Circular dependency{}: {}",
694 if cycle.is_cross_package {
695 " (cross-package)"
696 } else {
697 ""
698 },
699 chain.join(" \u{2192} ")
700 ),
701 level,
702 "Bug Risk",
703 &path,
704 line,
705 &fp,
706 ));
707 }
708}
709
710fn push_re_export_cycle_issues(
711 issues: &mut Vec<CodeClimateIssue>,
712 cycles: &[fallow_types::output_dead_code::ReExportCycleFinding],
713 root: &Path,
714 severity: Severity,
715) {
716 if cycles.is_empty() {
717 return;
718 }
719 let level = severity_to_codeclimate(severity);
720 for entry in cycles {
721 let cycle = &entry.cycle;
722 let Some(first) = cycle.files.first() else {
723 continue;
724 };
725 let path = cc_path(first, root);
726 let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
727 let chain_str = chain.join(":");
728 let kind_token = match cycle.kind {
729 fallow_core::results::ReExportCycleKind::SelfLoop => "self-loop",
730 fallow_core::results::ReExportCycleKind::MultiNode => "multi-node",
731 };
732 let kind_tag = match cycle.kind {
733 fallow_core::results::ReExportCycleKind::SelfLoop => " (self-loop)",
734 fallow_core::results::ReExportCycleKind::MultiNode => "",
735 };
736 let fp = fingerprint_hash(&["fallow/re-export-cycle", kind_token, &chain_str]);
737 issues.push(cc_issue(
738 "fallow/re-export-cycle",
739 &format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
740 level,
741 "Bug Risk",
742 &path,
743 None,
744 &fp,
745 ));
746 }
747}
748
749fn push_boundary_violation_issues(
750 issues: &mut Vec<CodeClimateIssue>,
751 violations: &[fallow_types::output_dead_code::BoundaryViolationFinding],
752 root: &Path,
753 severity: Severity,
754) {
755 if violations.is_empty() {
756 return;
757 }
758 let level = severity_to_codeclimate(severity);
759 for entry in violations {
760 let v = &entry.violation;
761 let path = cc_path(&v.from_path, root);
762 let to = cc_path(&v.to_path, root);
763 let fp = fingerprint_hash(&["fallow/boundary-violation", &path, &to]);
764 let line = if v.line > 0 { Some(v.line) } else { None };
765 issues.push(cc_issue(
766 "fallow/boundary-violation",
767 &format!(
768 "Boundary violation: {} -> {} ({} -> {})",
769 path, to, v.from_zone, v.to_zone
770 ),
771 level,
772 "Bug Risk",
773 &path,
774 line,
775 &fp,
776 ));
777 }
778}
779
780fn push_boundary_coverage_issues(
781 issues: &mut Vec<CodeClimateIssue>,
782 violations: &[fallow_types::output_dead_code::BoundaryCoverageViolationFinding],
783 root: &Path,
784 severity: Severity,
785) {
786 if violations.is_empty() {
787 return;
788 }
789 let level = severity_to_codeclimate(severity);
790 for entry in violations {
791 let v = &entry.violation;
792 let path = cc_path(&v.path, root);
793 let fp = fingerprint_hash(&["fallow/boundary-coverage", &path]);
794 let line = if v.line > 0 { Some(v.line) } else { None };
795 issues.push(cc_issue(
796 "fallow/boundary-coverage",
797 &format!("Boundary coverage: {path} matches no configured zone"),
798 level,
799 "Bug Risk",
800 &path,
801 line,
802 &fp,
803 ));
804 }
805}
806
807fn push_boundary_call_issues(
808 issues: &mut Vec<CodeClimateIssue>,
809 violations: &[fallow_types::output_dead_code::BoundaryCallViolationFinding],
810 root: &Path,
811 severity: Severity,
812) {
813 if violations.is_empty() {
814 return;
815 }
816 let level = severity_to_codeclimate(severity);
817 for entry in violations {
818 let v = &entry.violation;
819 let path = cc_path(&v.path, root);
820 let fp = fingerprint_hash(&["fallow/boundary-call-violation", &path, &v.callee]);
821 let line = if v.line > 0 { Some(v.line) } else { None };
822 issues.push(cc_issue(
823 "fallow/boundary-call-violation",
824 &format!(
825 "Boundary call: `{}` matches forbidden pattern `{}` in zone '{}'",
826 v.callee, v.pattern, v.zone
827 ),
828 level,
829 "Bug Risk",
830 &path,
831 line,
832 &fp,
833 ));
834 }
835}
836
837fn push_policy_violation_issues(
838 issues: &mut Vec<CodeClimateIssue>,
839 violations: &[fallow_types::output_dead_code::PolicyViolationFinding],
840 root: &Path,
841) {
842 use fallow_core::results::PolicyViolationSeverity;
843
844 for entry in violations {
845 let v = &entry.violation;
846 let path = cc_path(&v.path, root);
847 let rule = format!("{}/{}", v.pack, v.rule_id);
848 let fp = fingerprint_hash(&["fallow/policy-violation", &path, &rule, &v.matched]);
849 let line = if v.line > 0 { Some(v.line) } else { None };
850 let level = severity_to_codeclimate(match v.severity {
854 PolicyViolationSeverity::Error => Severity::Error,
855 PolicyViolationSeverity::Warn => Severity::Warn,
856 });
857 let message = match &v.message {
858 Some(message) => format!(
859 "Policy violation: `{}` is banned by `{rule}`. {message}",
860 v.matched
861 ),
862 None => format!("Policy violation: `{}` is banned by `{rule}`", v.matched),
863 };
864 issues.push(cc_issue(
865 "fallow/policy-violation",
866 &message,
867 level,
868 "Bug Risk",
869 &path,
870 line,
871 &fp,
872 ));
873 }
874}
875
876fn push_stale_suppression_issues(
877 issues: &mut Vec<CodeClimateIssue>,
878 suppressions: &[fallow_core::results::StaleSuppression],
879 root: &Path,
880 severity: Severity,
881) {
882 if suppressions.is_empty() {
883 return;
884 }
885 let level = severity_to_codeclimate(severity);
886 for s in suppressions {
887 let path = cc_path(&s.path, root);
888 let line_str = s.line.to_string();
889 let fp = fingerprint_hash(&["fallow/stale-suppression", &path, &line_str]);
890 issues.push(cc_issue(
891 "fallow/stale-suppression",
892 &s.display_message(),
893 level,
894 "Bug Risk",
895 &path,
896 Some(s.line),
897 &fp,
898 ));
899 }
900}
901
902fn push_unused_catalog_entry_issues(
903 issues: &mut Vec<CodeClimateIssue>,
904 entries: &[fallow_core::results::UnusedCatalogEntryFinding],
905 root: &Path,
906 severity: Severity,
907) {
908 if entries.is_empty() {
909 return;
910 }
911 let level = severity_to_codeclimate(severity);
912 for entry in entries {
913 let entry = &entry.entry;
914 let path = cc_path(&entry.path, root);
915 let line_str = entry.line.to_string();
916 let fp = fingerprint_hash(&[
917 "fallow/unused-catalog-entry",
918 &path,
919 &line_str,
920 &entry.catalog_name,
921 &entry.entry_name,
922 ]);
923 let description = if entry.catalog_name == "default" {
924 format!(
925 "Catalog entry '{}' is not referenced by any workspace package",
926 entry.entry_name
927 )
928 } else {
929 format!(
930 "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
931 entry.entry_name, entry.catalog_name
932 )
933 };
934 issues.push(cc_issue(
935 "fallow/unused-catalog-entry",
936 &description,
937 level,
938 "Bug Risk",
939 &path,
940 Some(entry.line),
941 &fp,
942 ));
943 }
944}
945
946fn push_unresolved_catalog_reference_issues(
947 issues: &mut Vec<CodeClimateIssue>,
948 findings: &[fallow_core::results::UnresolvedCatalogReferenceFinding],
949 root: &Path,
950 severity: Severity,
951) {
952 if findings.is_empty() {
953 return;
954 }
955 let level = severity_to_codeclimate(severity);
956 for finding in findings {
957 let finding = &finding.reference;
958 let path = cc_path(&finding.path, root);
959 let line_str = finding.line.to_string();
960 let fp = fingerprint_hash(&[
961 "fallow/unresolved-catalog-reference",
962 &path,
963 &line_str,
964 &finding.catalog_name,
965 &finding.entry_name,
966 ]);
967 let catalog_phrase = if finding.catalog_name == "default" {
968 "the default catalog".to_string()
969 } else {
970 format!("catalog '{}'", finding.catalog_name)
971 };
972 let mut description = format!(
973 "Package '{}' is referenced via `catalog:{}` but {} does not declare it; `pnpm install` will fail",
974 finding.entry_name,
975 if finding.catalog_name == "default" {
976 ""
977 } else {
978 finding.catalog_name.as_str()
979 },
980 catalog_phrase,
981 );
982 if !finding.available_in_catalogs.is_empty() {
983 use std::fmt::Write as _;
984 let _ = write!(
985 description,
986 " (available in: {})",
987 finding.available_in_catalogs.join(", ")
988 );
989 }
990 issues.push(cc_issue(
991 "fallow/unresolved-catalog-reference",
992 &description,
993 level,
994 "Bug Risk",
995 &path,
996 Some(finding.line),
997 &fp,
998 ));
999 }
1000}
1001
1002fn push_empty_catalog_group_issues(
1003 issues: &mut Vec<CodeClimateIssue>,
1004 groups: &[fallow_core::results::EmptyCatalogGroupFinding],
1005 root: &Path,
1006 severity: Severity,
1007) {
1008 if groups.is_empty() {
1009 return;
1010 }
1011 let level = severity_to_codeclimate(severity);
1012 for group in groups {
1013 let group = &group.group;
1014 let path = cc_path(&group.path, root);
1015 let line_str = group.line.to_string();
1016 let fp = fingerprint_hash(&[
1017 "fallow/empty-catalog-group",
1018 &path,
1019 &line_str,
1020 &group.catalog_name,
1021 ]);
1022 issues.push(cc_issue(
1023 "fallow/empty-catalog-group",
1024 &format!("Catalog group '{}' has no entries", group.catalog_name),
1025 level,
1026 "Bug Risk",
1027 &path,
1028 Some(group.line),
1029 &fp,
1030 ));
1031 }
1032}
1033
1034fn push_unused_dependency_override_issues(
1035 issues: &mut Vec<CodeClimateIssue>,
1036 findings: &[fallow_core::results::UnusedDependencyOverrideFinding],
1037 root: &Path,
1038 severity: Severity,
1039) {
1040 if findings.is_empty() {
1041 return;
1042 }
1043 let level = severity_to_codeclimate(severity);
1044 for finding in findings {
1045 let finding = &finding.entry;
1046 let path = cc_path(&finding.path, root);
1047 let line_str = finding.line.to_string();
1048 let fp = fingerprint_hash(&[
1049 "fallow/unused-dependency-override",
1050 &path,
1051 &line_str,
1052 finding.source.as_label(),
1053 &finding.raw_key,
1054 ]);
1055 let mut description = format!(
1056 "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
1057 finding.raw_key, finding.version_range, finding.target_package,
1058 );
1059 if let Some(hint) = &finding.hint {
1060 use std::fmt::Write as _;
1061 let _ = write!(description, " ({hint})");
1062 }
1063 issues.push(cc_issue(
1064 "fallow/unused-dependency-override",
1065 &description,
1066 level,
1067 "Bug Risk",
1068 &path,
1069 Some(finding.line),
1070 &fp,
1071 ));
1072 }
1073}
1074
1075fn push_misconfigured_dependency_override_issues(
1076 issues: &mut Vec<CodeClimateIssue>,
1077 findings: &[fallow_core::results::MisconfiguredDependencyOverrideFinding],
1078 root: &Path,
1079 severity: Severity,
1080) {
1081 if findings.is_empty() {
1082 return;
1083 }
1084 let level = severity_to_codeclimate(severity);
1085 for finding in findings {
1086 let finding = &finding.entry;
1087 let path = cc_path(&finding.path, root);
1088 let line_str = finding.line.to_string();
1089 let fp = fingerprint_hash(&[
1090 "fallow/misconfigured-dependency-override",
1091 &path,
1092 &line_str,
1093 finding.source.as_label(),
1094 &finding.raw_key,
1095 ]);
1096 let description = format!(
1097 "Override `{}` -> `{}` is malformed: {}",
1098 finding.raw_key,
1099 finding.raw_value,
1100 finding.reason.describe(),
1101 );
1102 issues.push(cc_issue(
1103 "fallow/misconfigured-dependency-override",
1104 &description,
1105 level,
1106 "Bug Risk",
1107 &path,
1108 Some(finding.line),
1109 &fp,
1110 ));
1111 }
1112}
1113
1114#[must_use]
1123#[expect(
1124 clippy::expect_used,
1125 reason = "CodeClimateIssue contains only infallibly serializable fields"
1126)]
1127pub fn issues_to_value(issues: &[CodeClimateIssue]) -> serde_json::Value {
1128 serde_json::to_value(issues).expect("CodeClimateIssue serializes infallibly")
1129}
1130
1131#[must_use]
1138pub fn build_codeclimate(
1139 results: &AnalysisResults,
1140 root: &Path,
1141 rules: &RulesConfig,
1142) -> Vec<CodeClimateIssue> {
1143 CodeClimateBuilder {
1144 issues: Vec::new(),
1145 results,
1146 root,
1147 rules,
1148 }
1149 .build()
1150}
1151
1152struct CodeClimateBuilder<'a> {
1153 issues: Vec<CodeClimateIssue>,
1154 results: &'a AnalysisResults,
1155 root: &'a Path,
1156 rules: &'a RulesConfig,
1157}
1158
1159impl CodeClimateBuilder<'_> {
1160 fn build(mut self) -> Vec<CodeClimateIssue> {
1161 self.push_file_and_export_issues();
1162 self.push_private_type_leak_issues();
1163 self.push_package_dependency_issues();
1164 self.push_type_test_dependency_issues();
1165 self.push_member_issues();
1166 self.push_import_and_duplicate_issues();
1167 self.push_graph_issues();
1168 self.push_boundary_issues();
1169 self.push_suppression_and_catalog_issues();
1170 self.push_override_issues();
1171 self.issues
1172 }
1173
1174 fn push_file_and_export_issues(&mut self) {
1175 push_unused_file_issues(
1176 &mut self.issues,
1177 &self.results.unused_files,
1178 self.root,
1179 self.rules.unused_files,
1180 );
1181 push_unused_export_issues(
1182 &mut self.issues,
1183 self.results.unused_exports.iter().map(|e| &e.export),
1184 self.root,
1185 "fallow/unused-export",
1186 "Export",
1187 "Re-export",
1188 self.rules.unused_exports,
1189 );
1190 push_unused_export_issues(
1191 &mut self.issues,
1192 self.results.unused_types.iter().map(|e| &e.export),
1193 self.root,
1194 "fallow/unused-type",
1195 "Type export",
1196 "Type re-export",
1197 self.rules.unused_types,
1198 );
1199 }
1200
1201 fn push_private_type_leak_issues(&mut self) {
1202 push_private_type_leak_issues(
1203 &mut self.issues,
1204 &self.results.private_type_leaks,
1205 self.root,
1206 self.rules.private_type_leaks,
1207 );
1208 }
1209
1210 fn push_package_dependency_issues(&mut self) {
1211 push_dep_cc_issues(
1212 &mut self.issues,
1213 self.results.unused_dependencies.iter().map(|f| &f.dep),
1214 self.root,
1215 "fallow/unused-dependency",
1216 "dependencies",
1217 self.rules.unused_dependencies,
1218 );
1219 push_dep_cc_issues(
1220 &mut self.issues,
1221 self.results.unused_dev_dependencies.iter().map(|f| &f.dep),
1222 self.root,
1223 "fallow/unused-dev-dependency",
1224 "devDependencies",
1225 self.rules.unused_dev_dependencies,
1226 );
1227 push_dep_cc_issues(
1228 &mut self.issues,
1229 self.results
1230 .unused_optional_dependencies
1231 .iter()
1232 .map(|f| &f.dep),
1233 self.root,
1234 "fallow/unused-optional-dependency",
1235 "optionalDependencies",
1236 self.rules.unused_optional_dependencies,
1237 );
1238 }
1239
1240 fn push_type_test_dependency_issues(&mut self) {
1241 push_type_only_dep_issues(
1242 &mut self.issues,
1243 &self.results.type_only_dependencies,
1244 self.root,
1245 self.rules.type_only_dependencies,
1246 );
1247 push_test_only_dep_issues(
1248 &mut self.issues,
1249 &self.results.test_only_dependencies,
1250 self.root,
1251 self.rules.test_only_dependencies,
1252 );
1253 }
1254
1255 fn push_member_issues(&mut self) {
1256 push_unused_member_issues(
1257 &mut self.issues,
1258 self.results.unused_enum_members.iter().map(|m| &m.member),
1259 self.root,
1260 "fallow/unused-enum-member",
1261 "Enum",
1262 self.rules.unused_enum_members,
1263 );
1264 push_unused_member_issues(
1265 &mut self.issues,
1266 self.results.unused_class_members.iter().map(|m| &m.member),
1267 self.root,
1268 "fallow/unused-class-member",
1269 "Class",
1270 self.rules.unused_class_members,
1271 );
1272 }
1273
1274 fn push_import_and_duplicate_issues(&mut self) {
1275 push_unresolved_import_issues(
1276 &mut self.issues,
1277 &self.results.unresolved_imports,
1278 self.root,
1279 self.rules.unresolved_imports,
1280 );
1281 push_unlisted_dep_issues(
1282 &mut self.issues,
1283 &self.results.unlisted_dependencies,
1284 self.root,
1285 self.rules.unlisted_dependencies,
1286 );
1287 push_duplicate_export_issues(
1288 &mut self.issues,
1289 &self.results.duplicate_exports,
1290 self.root,
1291 self.rules.duplicate_exports,
1292 );
1293 }
1294
1295 fn push_graph_issues(&mut self) {
1296 push_circular_dep_issues(
1297 &mut self.issues,
1298 &self.results.circular_dependencies,
1299 self.root,
1300 self.rules.circular_dependencies,
1301 );
1302 push_re_export_cycle_issues(
1303 &mut self.issues,
1304 &self.results.re_export_cycles,
1305 self.root,
1306 self.rules.re_export_cycle,
1307 );
1308 }
1309
1310 fn push_boundary_issues(&mut self) {
1311 push_boundary_violation_issues(
1312 &mut self.issues,
1313 &self.results.boundary_violations,
1314 self.root,
1315 self.rules.boundary_violation,
1316 );
1317 push_boundary_coverage_issues(
1318 &mut self.issues,
1319 &self.results.boundary_coverage_violations,
1320 self.root,
1321 self.rules.boundary_violation,
1322 );
1323 push_boundary_call_issues(
1324 &mut self.issues,
1325 &self.results.boundary_call_violations,
1326 self.root,
1327 self.rules.boundary_violation,
1328 );
1329 push_policy_violation_issues(&mut self.issues, &self.results.policy_violations, self.root);
1330 }
1331
1332 fn push_suppression_and_catalog_issues(&mut self) {
1333 push_stale_suppression_issues(
1334 &mut self.issues,
1335 &self.results.stale_suppressions,
1336 self.root,
1337 self.rules.stale_suppressions,
1338 );
1339 push_unused_catalog_entry_issues(
1340 &mut self.issues,
1341 &self.results.unused_catalog_entries,
1342 self.root,
1343 self.rules.unused_catalog_entries,
1344 );
1345 push_empty_catalog_group_issues(
1346 &mut self.issues,
1347 &self.results.empty_catalog_groups,
1348 self.root,
1349 self.rules.empty_catalog_groups,
1350 );
1351 push_unresolved_catalog_reference_issues(
1352 &mut self.issues,
1353 &self.results.unresolved_catalog_references,
1354 self.root,
1355 self.rules.unresolved_catalog_references,
1356 );
1357 }
1358
1359 fn push_override_issues(&mut self) {
1360 push_unused_dependency_override_issues(
1361 &mut self.issues,
1362 &self.results.unused_dependency_overrides,
1363 self.root,
1364 self.rules.unused_dependency_overrides,
1365 );
1366 push_misconfigured_dependency_override_issues(
1367 &mut self.issues,
1368 &self.results.misconfigured_dependency_overrides,
1369 self.root,
1370 self.rules.misconfigured_dependency_overrides,
1371 );
1372 }
1373}
1374
1375pub(super) fn print_codeclimate(
1377 results: &AnalysisResults,
1378 root: &Path,
1379 rules: &RulesConfig,
1380) -> ExitCode {
1381 let issues = build_codeclimate(results, root, rules);
1382 let value = issues_to_value(&issues);
1383 emit_json(&value, "CodeClimate")
1384}
1385
1386#[expect(
1392 clippy::expect_used,
1393 reason = "grouped CodeClimate entries are JSON objects created by issues_to_value"
1394)]
1395pub(super) fn print_grouped_codeclimate(
1396 results: &AnalysisResults,
1397 root: &Path,
1398 rules: &RulesConfig,
1399 resolver: &OwnershipResolver,
1400) -> ExitCode {
1401 let issues = build_codeclimate(results, root, rules);
1402 let mut value = issues_to_value(&issues);
1403
1404 if let Some(items) = value.as_array_mut() {
1405 for issue in items {
1406 let path = issue
1407 .pointer("/location/path")
1408 .and_then(|v| v.as_str())
1409 .unwrap_or("");
1410 let owner = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
1411 issue
1412 .as_object_mut()
1413 .expect("CodeClimate issue should be an object")
1414 .insert("owner".to_string(), serde_json::Value::String(owner));
1415 }
1416 }
1417
1418 emit_json(&value, "CodeClimate")
1419}
1420
1421#[must_use]
1423pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> Vec<CodeClimateIssue> {
1424 let mut issues = Vec::new();
1425 let ctx = HealthCodeClimateContext {
1426 root,
1427 cyc_t: report.summary.max_cyclomatic_threshold,
1428 cog_t: report.summary.max_cognitive_threshold,
1429 crap_t: report.summary.max_crap_threshold,
1430 };
1431
1432 for finding in &report.findings {
1433 issues.push(ctx.complexity_issue(finding));
1434 }
1435
1436 if let Some(ref production) = report.runtime_coverage {
1437 for finding in &production.findings {
1438 issues.push(ctx.runtime_coverage_issue(finding));
1439 }
1440 }
1441
1442 if let Some(ref intelligence) = report.coverage_intelligence {
1443 for finding in &intelligence.findings {
1444 if let Some(issue) = ctx.coverage_intelligence_issue(finding) {
1445 issues.push(issue);
1446 }
1447 }
1448 }
1449
1450 if let Some(ref gaps) = report.coverage_gaps {
1451 for item in &gaps.files {
1452 issues.push(ctx.untested_file_issue(item));
1453 }
1454
1455 for item in &gaps.exports {
1456 issues.push(ctx.untested_export_issue(item));
1457 }
1458 }
1459
1460 issues
1461}
1462
1463pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
1465 let issues = build_health_codeclimate(report, root);
1466 let value = issues_to_value(&issues);
1467 emit_json(&value, "CodeClimate")
1468}
1469
1470#[expect(
1479 clippy::expect_used,
1480 reason = "grouped health CodeClimate entries are JSON objects created by issues_to_value"
1481)]
1482pub(super) fn print_grouped_health_codeclimate(
1483 report: &HealthReport,
1484 root: &Path,
1485 resolver: &OwnershipResolver,
1486) -> ExitCode {
1487 let issues = build_health_codeclimate(report, root);
1488 let mut value = issues_to_value(&issues);
1489
1490 if let Some(items) = value.as_array_mut() {
1491 for issue in items {
1492 let path = issue
1493 .pointer("/location/path")
1494 .and_then(|v| v.as_str())
1495 .unwrap_or("");
1496 let group = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
1497 issue
1498 .as_object_mut()
1499 .expect("CodeClimate issue should be an object")
1500 .insert("group".to_string(), serde_json::Value::String(group));
1501 }
1502 }
1503
1504 emit_json(&value, "CodeClimate")
1505}
1506
1507#[must_use]
1509#[expect(
1510 clippy::cast_possible_truncation,
1511 reason = "line numbers are bounded by source size"
1512)]
1513pub fn build_duplication_codeclimate(
1514 report: &DuplicationReport,
1515 root: &Path,
1516) -> Vec<CodeClimateIssue> {
1517 let mut issues = Vec::new();
1518
1519 for (i, group) in report.clone_groups.iter().enumerate() {
1520 let token_str = group.token_count.to_string();
1521 let line_count_str = group.line_count.to_string();
1522 let fragment_prefix: String = group
1523 .instances
1524 .first()
1525 .map(|inst| inst.fragment.chars().take(64).collect())
1526 .unwrap_or_default();
1527
1528 for instance in &group.instances {
1529 let path = cc_path(&instance.file, root);
1530 let start_str = instance.start_line.to_string();
1531 let fp = fingerprint_hash(&[
1532 "fallow/code-duplication",
1533 &path,
1534 &start_str,
1535 &token_str,
1536 &line_count_str,
1537 &fragment_prefix,
1538 ]);
1539 issues.push(cc_issue(
1540 "fallow/code-duplication",
1541 &format!(
1542 "Code clone group {} ({} lines, {} instances)",
1543 i + 1,
1544 group.line_count,
1545 group.instances.len()
1546 ),
1547 CodeClimateSeverity::Minor,
1548 "Duplication",
1549 &path,
1550 Some(instance.start_line as u32),
1551 &fp,
1552 ));
1553 }
1554 }
1555
1556 issues
1557}
1558
1559pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
1561 let issues = build_duplication_codeclimate(report, root);
1562 let value = issues_to_value(&issues);
1563 emit_json(&value, "CodeClimate")
1564}
1565
1566#[expect(
1575 clippy::expect_used,
1576 reason = "grouped duplication CodeClimate entries are JSON objects created by issues_to_value"
1577)]
1578pub(super) fn print_grouped_duplication_codeclimate(
1579 report: &DuplicationReport,
1580 root: &Path,
1581 resolver: &OwnershipResolver,
1582) -> ExitCode {
1583 let issues = build_duplication_codeclimate(report, root);
1584 let mut value = issues_to_value(&issues);
1585
1586 use rustc_hash::FxHashMap;
1587 let mut path_to_owner: FxHashMap<String, String> = FxHashMap::default();
1588 for group in &report.clone_groups {
1589 let owner = super::dupes_grouping::largest_owner(group, root, resolver);
1590 for instance in &group.instances {
1591 let path = cc_path(&instance.file, root);
1592 path_to_owner.insert(path, owner.clone());
1593 }
1594 }
1595
1596 if let Some(items) = value.as_array_mut() {
1597 for issue in items {
1598 let path = issue
1599 .pointer("/location/path")
1600 .and_then(|v| v.as_str())
1601 .unwrap_or("")
1602 .to_string();
1603 let group = path_to_owner
1604 .get(&path)
1605 .cloned()
1606 .unwrap_or_else(|| crate::codeowners::UNOWNED_LABEL.to_string());
1607 issue
1608 .as_object_mut()
1609 .expect("CodeClimate issue should be an object")
1610 .insert("group".to_string(), serde_json::Value::String(group));
1611 }
1612 }
1613
1614 emit_json(&value, "CodeClimate")
1615}
1616
1617#[cfg(test)]
1618mod tests {
1619 use super::*;
1620 use crate::report::test_helpers::sample_results;
1621 use fallow_config::RulesConfig;
1622 use fallow_core::results::*;
1623 use std::path::PathBuf;
1624
1625 fn health_severity(value: u16, threshold: u16) -> &'static str {
1628 if threshold == 0 {
1629 return "minor";
1630 }
1631 let ratio = f64::from(value) / f64::from(threshold);
1632 if ratio > 2.5 {
1633 "critical"
1634 } else if ratio > 1.5 {
1635 "major"
1636 } else {
1637 "minor"
1638 }
1639 }
1640
1641 #[test]
1642 fn codeclimate_empty_results_produces_empty_array() {
1643 let root = PathBuf::from("/project");
1644 let results = AnalysisResults::default();
1645 let rules = RulesConfig::default();
1646 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1647 let arr = output.as_array().unwrap();
1648 assert!(arr.is_empty());
1649 }
1650
1651 #[test]
1652 fn codeclimate_produces_array_of_issues() {
1653 let root = PathBuf::from("/project");
1654 let results = sample_results(&root);
1655 let rules = RulesConfig::default();
1656 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1657 assert!(output.is_array());
1658 let arr = output.as_array().unwrap();
1659 assert!(!arr.is_empty());
1660 }
1661
1662 #[test]
1663 fn codeclimate_issue_has_required_fields() {
1664 let root = PathBuf::from("/project");
1665 let mut results = AnalysisResults::default();
1666 results
1667 .unused_files
1668 .push(UnusedFileFinding::with_actions(UnusedFile {
1669 path: root.join("src/dead.ts"),
1670 }));
1671 let rules = RulesConfig::default();
1672 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1673 let issue = &output.as_array().unwrap()[0];
1674
1675 assert_eq!(issue["type"], "issue");
1676 assert_eq!(issue["check_name"], "fallow/unused-file");
1677 assert!(issue["description"].is_string());
1678 assert!(issue["categories"].is_array());
1679 assert!(issue["severity"].is_string());
1680 assert!(issue["fingerprint"].is_string());
1681 assert!(issue["location"].is_object());
1682 assert!(issue["location"]["path"].is_string());
1683 assert!(issue["location"]["lines"].is_object());
1684 }
1685
1686 #[test]
1687 fn codeclimate_unused_file_severity_follows_rules() {
1688 let root = PathBuf::from("/project");
1689 let mut results = AnalysisResults::default();
1690 results
1691 .unused_files
1692 .push(UnusedFileFinding::with_actions(UnusedFile {
1693 path: root.join("src/dead.ts"),
1694 }));
1695
1696 let rules = RulesConfig::default();
1697 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1698 assert_eq!(output[0]["severity"], "major");
1699
1700 let rules = RulesConfig {
1701 unused_files: Severity::Warn,
1702 ..RulesConfig::default()
1703 };
1704 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1705 assert_eq!(output[0]["severity"], "minor");
1706 }
1707
1708 #[test]
1709 fn codeclimate_unused_export_has_line_number() {
1710 let root = PathBuf::from("/project");
1711 let mut results = AnalysisResults::default();
1712 results
1713 .unused_exports
1714 .push(UnusedExportFinding::with_actions(UnusedExport {
1715 path: root.join("src/utils.ts"),
1716 export_name: "helperFn".to_string(),
1717 is_type_only: false,
1718 line: 10,
1719 col: 4,
1720 span_start: 120,
1721 is_re_export: false,
1722 }));
1723 let rules = RulesConfig::default();
1724 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1725 let issue = &output[0];
1726 assert_eq!(issue["location"]["lines"]["begin"], 10);
1727 }
1728
1729 #[test]
1730 fn codeclimate_unused_file_line_defaults_to_1() {
1731 let root = PathBuf::from("/project");
1732 let mut results = AnalysisResults::default();
1733 results
1734 .unused_files
1735 .push(UnusedFileFinding::with_actions(UnusedFile {
1736 path: root.join("src/dead.ts"),
1737 }));
1738 let rules = RulesConfig::default();
1739 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1740 let issue = &output[0];
1741 assert_eq!(issue["location"]["lines"]["begin"], 1);
1742 }
1743
1744 #[test]
1745 fn codeclimate_paths_are_relative() {
1746 let root = PathBuf::from("/project");
1747 let mut results = AnalysisResults::default();
1748 results
1749 .unused_files
1750 .push(UnusedFileFinding::with_actions(UnusedFile {
1751 path: root.join("src/deep/nested/file.ts"),
1752 }));
1753 let rules = RulesConfig::default();
1754 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1755 let path = output[0]["location"]["path"].as_str().unwrap();
1756 assert_eq!(path, "src/deep/nested/file.ts");
1757 assert!(!path.starts_with("/project"));
1758 }
1759
1760 #[test]
1761 fn codeclimate_re_export_label_in_description() {
1762 let root = PathBuf::from("/project");
1763 let mut results = AnalysisResults::default();
1764 results
1765 .unused_exports
1766 .push(UnusedExportFinding::with_actions(UnusedExport {
1767 path: root.join("src/index.ts"),
1768 export_name: "reExported".to_string(),
1769 is_type_only: false,
1770 line: 1,
1771 col: 0,
1772 span_start: 0,
1773 is_re_export: true,
1774 }));
1775 let rules = RulesConfig::default();
1776 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1777 let desc = output[0]["description"].as_str().unwrap();
1778 assert!(desc.contains("Re-export"));
1779 }
1780
1781 #[test]
1782 fn codeclimate_unlisted_dep_one_issue_per_import_site() {
1783 let root = PathBuf::from("/project");
1784 let mut results = AnalysisResults::default();
1785 results
1786 .unlisted_dependencies
1787 .push(UnlistedDependencyFinding::with_actions(
1788 UnlistedDependency {
1789 package_name: "chalk".to_string(),
1790 imported_from: vec![
1791 ImportSite {
1792 path: root.join("src/a.ts"),
1793 line: 1,
1794 col: 0,
1795 },
1796 ImportSite {
1797 path: root.join("src/b.ts"),
1798 line: 5,
1799 col: 0,
1800 },
1801 ],
1802 },
1803 ));
1804 let rules = RulesConfig::default();
1805 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1806 let arr = output.as_array().unwrap();
1807 assert_eq!(arr.len(), 2);
1808 assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
1809 assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
1810 }
1811
1812 #[test]
1813 fn codeclimate_duplicate_export_one_issue_per_location() {
1814 let root = PathBuf::from("/project");
1815 let mut results = AnalysisResults::default();
1816 results
1817 .duplicate_exports
1818 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1819 export_name: "Config".to_string(),
1820 locations: vec![
1821 DuplicateLocation {
1822 path: root.join("src/a.ts"),
1823 line: 10,
1824 col: 0,
1825 },
1826 DuplicateLocation {
1827 path: root.join("src/b.ts"),
1828 line: 20,
1829 col: 0,
1830 },
1831 DuplicateLocation {
1832 path: root.join("src/c.ts"),
1833 line: 30,
1834 col: 0,
1835 },
1836 ],
1837 }));
1838 let rules = RulesConfig::default();
1839 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1840 let arr = output.as_array().unwrap();
1841 assert_eq!(arr.len(), 3);
1842 }
1843
1844 #[test]
1845 fn codeclimate_circular_dep_emits_chain_in_description() {
1846 let root = PathBuf::from("/project");
1847 let mut results = AnalysisResults::default();
1848 results
1849 .circular_dependencies
1850 .push(CircularDependencyFinding::with_actions(
1851 CircularDependency {
1852 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1853 length: 2,
1854 line: 3,
1855 col: 0,
1856 edges: Vec::new(),
1857 is_cross_package: false,
1858 },
1859 ));
1860 let rules = RulesConfig::default();
1861 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1862 let desc = output[0]["description"].as_str().unwrap();
1863 assert!(desc.contains("Circular dependency"));
1864 assert!(desc.contains("src/a.ts"));
1865 assert!(desc.contains("src/b.ts"));
1866 }
1867
1868 #[test]
1869 fn codeclimate_fingerprints_are_deterministic() {
1870 let root = PathBuf::from("/project");
1871 let results = sample_results(&root);
1872 let rules = RulesConfig::default();
1873 let output1 = issues_to_value(&build_codeclimate(&results, &root, &rules));
1874 let output2 = issues_to_value(&build_codeclimate(&results, &root, &rules));
1875
1876 let fps1: Vec<&str> = output1
1877 .as_array()
1878 .unwrap()
1879 .iter()
1880 .map(|i| i["fingerprint"].as_str().unwrap())
1881 .collect();
1882 let fps2: Vec<&str> = output2
1883 .as_array()
1884 .unwrap()
1885 .iter()
1886 .map(|i| i["fingerprint"].as_str().unwrap())
1887 .collect();
1888 assert_eq!(fps1, fps2);
1889 }
1890
1891 #[test]
1892 fn codeclimate_fingerprints_are_unique() {
1893 let root = PathBuf::from("/project");
1894 let results = sample_results(&root);
1895 let rules = RulesConfig::default();
1896 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1897
1898 let mut fps: Vec<&str> = output
1899 .as_array()
1900 .unwrap()
1901 .iter()
1902 .map(|i| i["fingerprint"].as_str().unwrap())
1903 .collect();
1904 let original_len = fps.len();
1905 fps.sort_unstable();
1906 fps.dedup();
1907 assert_eq!(fps.len(), original_len, "fingerprints should be unique");
1908 }
1909
1910 #[test]
1911 fn codeclimate_type_only_dep_has_correct_check_name() {
1912 let root = PathBuf::from("/project");
1913 let mut results = AnalysisResults::default();
1914 results
1915 .type_only_dependencies
1916 .push(TypeOnlyDependencyFinding::with_actions(
1917 TypeOnlyDependency {
1918 package_name: "zod".to_string(),
1919 path: root.join("package.json"),
1920 line: 8,
1921 },
1922 ));
1923 let rules = RulesConfig::default();
1924 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1925 assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
1926 let desc = output[0]["description"].as_str().unwrap();
1927 assert!(desc.contains("zod"));
1928 assert!(desc.contains("type-only"));
1929 }
1930
1931 #[test]
1932 fn codeclimate_dep_with_zero_line_omits_line_number() {
1933 let root = PathBuf::from("/project");
1934 let mut results = AnalysisResults::default();
1935 results
1936 .unused_dependencies
1937 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1938 package_name: "lodash".to_string(),
1939 location: DependencyLocation::Dependencies,
1940 path: root.join("package.json"),
1941 line: 0,
1942 used_in_workspaces: Vec::new(),
1943 }));
1944 let rules = RulesConfig::default();
1945 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1946 assert_eq!(output[0]["location"]["lines"]["begin"], 1);
1947 }
1948
1949 #[test]
1950 fn fingerprint_hash_different_inputs_differ() {
1951 let h1 = fingerprint_hash(&["a", "b"]);
1952 let h2 = fingerprint_hash(&["a", "c"]);
1953 assert_ne!(h1, h2);
1954 }
1955
1956 #[test]
1957 fn fingerprint_hash_order_matters() {
1958 let h1 = fingerprint_hash(&["a", "b"]);
1959 let h2 = fingerprint_hash(&["b", "a"]);
1960 assert_ne!(h1, h2);
1961 }
1962
1963 #[test]
1964 fn fingerprint_hash_separator_prevents_collision() {
1965 let h1 = fingerprint_hash(&["ab", "c"]);
1966 let h2 = fingerprint_hash(&["a", "bc"]);
1967 assert_ne!(h1, h2);
1968 }
1969
1970 #[test]
1971 fn fingerprint_hash_is_16_hex_chars() {
1972 let h = fingerprint_hash(&["test"]);
1973 assert_eq!(h.len(), 16);
1974 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
1975 }
1976
1977 #[test]
1978 fn severity_error_maps_to_major() {
1979 assert_eq!(
1980 severity_to_codeclimate(Severity::Error),
1981 CodeClimateSeverity::Major
1982 );
1983 }
1984
1985 #[test]
1986 fn severity_warn_maps_to_minor() {
1987 assert_eq!(
1988 severity_to_codeclimate(Severity::Warn),
1989 CodeClimateSeverity::Minor
1990 );
1991 }
1992
1993 #[test]
1994 #[should_panic(expected = "internal error: entered unreachable code")]
1995 fn severity_off_is_unreachable() {
1996 let _ = severity_to_codeclimate(Severity::Off);
1997 }
1998
1999 #[test]
2011 fn build_codeclimate_with_off_severity_and_empty_findings_does_not_panic() {
2012 let root = PathBuf::from("/project");
2013 let results = AnalysisResults::default();
2014 let rules = RulesConfig {
2015 unused_dependencies: Severity::Off,
2016 unused_dev_dependencies: Severity::Off,
2017 unused_optional_dependencies: Severity::Off,
2018 unused_exports: Severity::Off,
2019 unused_types: Severity::Off,
2020 unused_enum_members: Severity::Off,
2021 unused_class_members: Severity::Off,
2022 ..RulesConfig::default()
2023 };
2024 let issues = build_codeclimate(&results, &root, &rules);
2025 assert!(issues.is_empty());
2026 }
2027
2028 #[test]
2029 fn health_severity_zero_threshold_returns_minor() {
2030 assert_eq!(health_severity(100, 0), "minor");
2031 }
2032
2033 #[test]
2034 fn health_severity_at_threshold_returns_minor() {
2035 assert_eq!(health_severity(10, 10), "minor");
2036 }
2037
2038 #[test]
2039 fn health_severity_1_5x_threshold_returns_minor() {
2040 assert_eq!(health_severity(15, 10), "minor");
2041 }
2042
2043 #[test]
2044 fn health_severity_above_1_5x_returns_major() {
2045 assert_eq!(health_severity(16, 10), "major");
2046 }
2047
2048 #[test]
2049 fn health_severity_at_2_5x_returns_major() {
2050 assert_eq!(health_severity(25, 10), "major");
2051 }
2052
2053 #[test]
2054 fn health_severity_above_2_5x_returns_critical() {
2055 assert_eq!(health_severity(26, 10), "critical");
2056 }
2057
2058 #[test]
2059 fn health_codeclimate_includes_coverage_gaps() {
2060 use crate::health_types::*;
2061
2062 let root = PathBuf::from("/project");
2063 let report = HealthReport {
2064 summary: HealthSummary {
2065 files_analyzed: 10,
2066 functions_analyzed: 50,
2067 ..Default::default()
2068 },
2069 coverage_gaps: Some(CoverageGaps {
2070 summary: CoverageGapSummary {
2071 runtime_files: 2,
2072 covered_files: 0,
2073 file_coverage_pct: 0.0,
2074 untested_files: 1,
2075 untested_exports: 1,
2076 },
2077 files: vec![UntestedFileFinding::with_actions(
2078 UntestedFile {
2079 path: root.join("src/app.ts"),
2080 value_export_count: 2,
2081 },
2082 &root,
2083 )],
2084 exports: vec![UntestedExportFinding::with_actions(
2085 UntestedExport {
2086 path: root.join("src/app.ts"),
2087 export_name: "loader".into(),
2088 line: 12,
2089 col: 4,
2090 },
2091 &root,
2092 )],
2093 }),
2094 ..Default::default()
2095 };
2096
2097 let output = issues_to_value(&build_health_codeclimate(&report, &root));
2098 let issues = output.as_array().unwrap();
2099 assert_eq!(issues.len(), 2);
2100 assert_eq!(issues[0]["check_name"], "fallow/untested-file");
2101 assert_eq!(issues[0]["categories"][0], "Coverage");
2102 assert_eq!(issues[0]["location"]["path"], "src/app.ts");
2103 assert_eq!(issues[1]["check_name"], "fallow/untested-export");
2104 assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
2105 assert!(
2106 issues[1]["description"]
2107 .as_str()
2108 .unwrap()
2109 .contains("loader")
2110 );
2111 }
2112
2113 #[test]
2114 fn health_codeclimate_includes_coverage_intelligence_issue() {
2115 use crate::health_types::{
2116 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2117 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2118 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2119 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2120 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2121 HealthReport, HealthSummary,
2122 };
2123
2124 let root = PathBuf::from("/project");
2125 let report = HealthReport {
2126 summary: HealthSummary {
2127 files_analyzed: 10,
2128 functions_analyzed: 50,
2129 ..Default::default()
2130 },
2131 coverage_intelligence: Some(CoverageIntelligenceReport {
2132 schema_version: CoverageIntelligenceSchemaVersion::V1,
2133 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2134 summary: CoverageIntelligenceSummary {
2135 findings: 1,
2136 high_confidence_deletes: 1,
2137 ..Default::default()
2138 },
2139 findings: vec![CoverageIntelligenceFinding {
2140 id: "fallow:coverage-intel:abc123".to_owned(),
2141 path: root.join("src/dead.ts"),
2142 identity: Some("deadPath".to_owned()),
2143 line: 9,
2144 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2145 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2146 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2147 confidence: CoverageIntelligenceConfidence::High,
2148 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2149 evidence: CoverageIntelligenceEvidence {
2150 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2151 ..Default::default()
2152 },
2153 actions: vec![CoverageIntelligenceAction {
2154 kind: "delete-after-confirming-owner".to_owned(),
2155 description: "Confirm ownership".to_owned(),
2156 auto_fixable: false,
2157 }],
2158 }],
2159 }),
2160 ..Default::default()
2161 };
2162
2163 let output = issues_to_value(&build_health_codeclimate(&report, &root));
2164 let issues = output.as_array().unwrap();
2165 assert_eq!(issues.len(), 1);
2166 assert_eq!(
2167 issues[0]["check_name"],
2168 "fallow/coverage-intelligence-delete"
2169 );
2170 assert!(!issues[0]["fingerprint"].as_str().unwrap().is_empty());
2171 assert_eq!(issues[0]["location"]["path"], "src/dead.ts");
2172 assert!(
2173 issues[0]["description"]
2174 .as_str()
2175 .unwrap()
2176 .contains("deadPath")
2177 );
2178 }
2179
2180 #[test]
2181 fn health_codeclimate_skips_summary_only_coverage_intelligence() {
2182 use crate::health_types::{
2183 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2184 CoverageIntelligenceSummary, CoverageIntelligenceVerdict, HealthReport,
2185 };
2186
2187 let root = PathBuf::from("/project");
2188 let report = HealthReport {
2189 coverage_intelligence: Some(CoverageIntelligenceReport {
2190 schema_version: CoverageIntelligenceSchemaVersion::V1,
2191 verdict: CoverageIntelligenceVerdict::Clean,
2192 summary: CoverageIntelligenceSummary {
2193 skipped_ambiguous_matches: 2,
2194 ..Default::default()
2195 },
2196 findings: vec![],
2197 }),
2198 ..Default::default()
2199 };
2200
2201 let issues = build_health_codeclimate(&report, &root);
2202 assert!(issues.is_empty());
2203 }
2204
2205 #[test]
2206 fn health_codeclimate_crap_only_uses_crap_check_name() {
2207 use crate::health_types::{
2208 ComplexityViolation, FindingSeverity, HealthReport, HealthSummary,
2209 };
2210 let root = PathBuf::from("/project");
2211 let report = HealthReport {
2212 findings: vec![
2213 ComplexityViolation {
2214 path: root.join("src/untested.ts"),
2215 name: "risky".to_string(),
2216 line: 7,
2217 col: 0,
2218 cyclomatic: 10,
2219 cognitive: 10,
2220 line_count: 20,
2221 param_count: 1,
2222 exceeded: crate::health_types::ExceededThreshold::Crap,
2223 severity: FindingSeverity::High,
2224 crap: Some(60.0),
2225 coverage_pct: Some(25.0),
2226 coverage_tier: None,
2227 coverage_source: None,
2228 inherited_from: None,
2229 component_rollup: None,
2230 contributions: Vec::new(),
2231 }
2232 .into(),
2233 ],
2234 summary: HealthSummary {
2235 functions_analyzed: 10,
2236 functions_above_threshold: 1,
2237 ..Default::default()
2238 },
2239 ..Default::default()
2240 };
2241 let json = issues_to_value(&build_health_codeclimate(&report, &root));
2242 let issues = json.as_array().unwrap();
2243 assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
2244 assert_eq!(issues[0]["severity"], "major");
2245 let description = issues[0]["description"].as_str().unwrap();
2246 assert!(description.contains("CRAP score"), "desc: {description}");
2247 assert!(description.contains("coverage 25%"), "desc: {description}");
2248 }
2249}