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
386struct UnusedExportIssuesInput<'a, I> {
392 issues: &'a mut Vec<CodeClimateIssue>,
393 exports: I,
394 root: &'a Path,
395 rule_id: &'a str,
396 direct_label: &'a str,
397 re_export_label: &'a str,
398 severity: Severity,
399}
400
401fn push_unused_export_issues<'a, I>(input: UnusedExportIssuesInput<'a, I>)
402where
403 I: IntoIterator<Item = &'a fallow_core::results::UnusedExport>,
404{
405 for export in input.exports {
406 let level = severity_to_codeclimate(input.severity);
407 let path = cc_path(&export.path, input.root);
408 let kind = if export.is_re_export {
409 input.re_export_label
410 } else {
411 input.direct_label
412 };
413 let line_str = export.line.to_string();
414 let fp = fingerprint_hash(&[input.rule_id, &path, &line_str, &export.export_name]);
415 input.issues.push(cc_issue(
416 input.rule_id,
417 &format!(
418 "{kind} '{}' is never imported by other modules",
419 export.export_name
420 ),
421 level,
422 "Bug Risk",
423 &path,
424 Some(export.line),
425 &fp,
426 ));
427 }
428}
429
430fn push_private_type_leak_issues(
431 issues: &mut Vec<CodeClimateIssue>,
432 leaks: &[fallow_types::output_dead_code::PrivateTypeLeakFinding],
433 root: &Path,
434 severity: Severity,
435) {
436 if leaks.is_empty() {
437 return;
438 }
439 let level = severity_to_codeclimate(severity);
440 for entry in leaks {
441 let leak = &entry.leak;
442 let path = cc_path(&leak.path, root);
443 let line_str = leak.line.to_string();
444 let fp = fingerprint_hash(&[
445 "fallow/private-type-leak",
446 &path,
447 &line_str,
448 &leak.export_name,
449 &leak.type_name,
450 ]);
451 issues.push(cc_issue(
452 "fallow/private-type-leak",
453 &format!(
454 "Export '{}' references private type '{}'",
455 leak.export_name, leak.type_name
456 ),
457 level,
458 "Bug Risk",
459 &path,
460 Some(leak.line),
461 &fp,
462 ));
463 }
464}
465
466fn push_type_only_dep_issues(
467 issues: &mut Vec<CodeClimateIssue>,
468 deps: &[fallow_core::results::TypeOnlyDependencyFinding],
469 root: &Path,
470 severity: Severity,
471) {
472 if deps.is_empty() {
473 return;
474 }
475 let level = severity_to_codeclimate(severity);
476 for entry in deps {
477 let dep = &entry.dep;
478 let path = cc_path(&dep.path, root);
479 let line = if dep.line > 0 { Some(dep.line) } else { None };
480 let fp = fingerprint_hash(&["fallow/type-only-dependency", &dep.package_name]);
481 issues.push(cc_issue(
482 "fallow/type-only-dependency",
483 &format!(
484 "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
485 dep.package_name
486 ),
487 level,
488 "Bug Risk",
489 &path,
490 line,
491 &fp,
492 ));
493 }
494}
495
496fn push_test_only_dep_issues(
497 issues: &mut Vec<CodeClimateIssue>,
498 deps: &[fallow_core::results::TestOnlyDependencyFinding],
499 root: &Path,
500 severity: Severity,
501) {
502 if deps.is_empty() {
503 return;
504 }
505 let level = severity_to_codeclimate(severity);
506 for entry in deps {
507 let dep = &entry.dep;
508 let path = cc_path(&dep.path, root);
509 let line = if dep.line > 0 { Some(dep.line) } else { None };
510 let fp = fingerprint_hash(&["fallow/test-only-dependency", &dep.package_name]);
511 issues.push(cc_issue(
512 "fallow/test-only-dependency",
513 &format!(
514 "Package '{}' is only imported by test files (consider moving to devDependencies)",
515 dep.package_name
516 ),
517 level,
518 "Bug Risk",
519 &path,
520 line,
521 &fp,
522 ));
523 }
524}
525
526fn push_unused_member_issues<'a, I>(
531 issues: &mut Vec<CodeClimateIssue>,
532 members: I,
533 root: &Path,
534 rule_id: &str,
535 entity_label: &str,
536 severity: Severity,
537) where
538 I: IntoIterator<Item = &'a fallow_core::results::UnusedMember>,
539{
540 for member in members {
541 let level = severity_to_codeclimate(severity);
542 let path = cc_path(&member.path, root);
543 let line_str = member.line.to_string();
544 let fp = fingerprint_hash(&[
545 rule_id,
546 &path,
547 &line_str,
548 &member.parent_name,
549 &member.member_name,
550 ]);
551 issues.push(cc_issue(
552 rule_id,
553 &format!(
554 "{entity_label} member '{}.{}' is never referenced",
555 member.parent_name, member.member_name
556 ),
557 level,
558 "Bug Risk",
559 &path,
560 Some(member.line),
561 &fp,
562 ));
563 }
564}
565
566fn push_unresolved_import_issues(
567 issues: &mut Vec<CodeClimateIssue>,
568 imports: &[fallow_types::output_dead_code::UnresolvedImportFinding],
569 root: &Path,
570 severity: Severity,
571) {
572 if imports.is_empty() {
573 return;
574 }
575 let level = severity_to_codeclimate(severity);
576 for entry in imports {
577 let import = &entry.import;
578 let path = cc_path(&import.path, root);
579 let line_str = import.line.to_string();
580 let fp = fingerprint_hash(&[
581 "fallow/unresolved-import",
582 &path,
583 &line_str,
584 &import.specifier,
585 ]);
586 issues.push(cc_issue(
587 "fallow/unresolved-import",
588 &format!("Import '{}' could not be resolved", import.specifier),
589 level,
590 "Bug Risk",
591 &path,
592 Some(import.line),
593 &fp,
594 ));
595 }
596}
597
598fn push_unlisted_dep_issues(
599 issues: &mut Vec<CodeClimateIssue>,
600 deps: &[fallow_core::results::UnlistedDependencyFinding],
601 root: &Path,
602 severity: Severity,
603) {
604 if deps.is_empty() {
605 return;
606 }
607 let level = severity_to_codeclimate(severity);
608 for entry in deps {
609 let dep = &entry.dep;
610 for site in &dep.imported_from {
611 let path = cc_path(&site.path, root);
612 let line_str = site.line.to_string();
613 let fp = fingerprint_hash(&[
614 "fallow/unlisted-dependency",
615 &path,
616 &line_str,
617 &dep.package_name,
618 ]);
619 issues.push(cc_issue(
620 "fallow/unlisted-dependency",
621 &format!(
622 "Package '{}' is imported but not listed in package.json",
623 dep.package_name
624 ),
625 level,
626 "Bug Risk",
627 &path,
628 Some(site.line),
629 &fp,
630 ));
631 }
632 }
633}
634
635fn push_duplicate_export_issues(
636 issues: &mut Vec<CodeClimateIssue>,
637 dups: &[fallow_core::results::DuplicateExportFinding],
638 root: &Path,
639 severity: Severity,
640) {
641 if dups.is_empty() {
642 return;
643 }
644 let level = severity_to_codeclimate(severity);
645 for dup in dups {
646 let dup = &dup.export;
647 for loc in &dup.locations {
648 let path = cc_path(&loc.path, root);
649 let line_str = loc.line.to_string();
650 let fp = fingerprint_hash(&[
651 "fallow/duplicate-export",
652 &path,
653 &line_str,
654 &dup.export_name,
655 ]);
656 issues.push(cc_issue(
657 "fallow/duplicate-export",
658 &format!("Export '{}' appears in multiple modules", dup.export_name),
659 level,
660 "Bug Risk",
661 &path,
662 Some(loc.line),
663 &fp,
664 ));
665 }
666 }
667}
668
669fn push_circular_dep_issues(
670 issues: &mut Vec<CodeClimateIssue>,
671 cycles: &[fallow_types::output_dead_code::CircularDependencyFinding],
672 root: &Path,
673 severity: Severity,
674) {
675 if cycles.is_empty() {
676 return;
677 }
678 let level = severity_to_codeclimate(severity);
679 for entry in cycles {
680 let cycle = &entry.cycle;
681 let Some(first) = cycle.files.first() else {
682 continue;
683 };
684 let path = cc_path(first, root);
685 let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
686 let chain_str = chain.join(":");
687 let fp = fingerprint_hash(&["fallow/circular-dependency", &chain_str]);
688 let line = if cycle.line > 0 {
689 Some(cycle.line)
690 } else {
691 None
692 };
693 issues.push(cc_issue(
694 "fallow/circular-dependency",
695 &format!(
696 "Circular dependency{}: {}",
697 if cycle.is_cross_package {
698 " (cross-package)"
699 } else {
700 ""
701 },
702 chain.join(" \u{2192} ")
703 ),
704 level,
705 "Bug Risk",
706 &path,
707 line,
708 &fp,
709 ));
710 }
711}
712
713fn push_re_export_cycle_issues(
714 issues: &mut Vec<CodeClimateIssue>,
715 cycles: &[fallow_types::output_dead_code::ReExportCycleFinding],
716 root: &Path,
717 severity: Severity,
718) {
719 if cycles.is_empty() {
720 return;
721 }
722 let level = severity_to_codeclimate(severity);
723 for entry in cycles {
724 let cycle = &entry.cycle;
725 let Some(first) = cycle.files.first() else {
726 continue;
727 };
728 let path = cc_path(first, root);
729 let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
730 let chain_str = chain.join(":");
731 let kind_token = match cycle.kind {
732 fallow_core::results::ReExportCycleKind::SelfLoop => "self-loop",
733 fallow_core::results::ReExportCycleKind::MultiNode => "multi-node",
734 };
735 let kind_tag = match cycle.kind {
736 fallow_core::results::ReExportCycleKind::SelfLoop => " (self-loop)",
737 fallow_core::results::ReExportCycleKind::MultiNode => "",
738 };
739 let fp = fingerprint_hash(&["fallow/re-export-cycle", kind_token, &chain_str]);
740 issues.push(cc_issue(
741 "fallow/re-export-cycle",
742 &format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
743 level,
744 "Bug Risk",
745 &path,
746 None,
747 &fp,
748 ));
749 }
750}
751
752fn push_boundary_violation_issues(
753 issues: &mut Vec<CodeClimateIssue>,
754 violations: &[fallow_types::output_dead_code::BoundaryViolationFinding],
755 root: &Path,
756 severity: Severity,
757) {
758 if violations.is_empty() {
759 return;
760 }
761 let level = severity_to_codeclimate(severity);
762 for entry in violations {
763 let v = &entry.violation;
764 let path = cc_path(&v.from_path, root);
765 let to = cc_path(&v.to_path, root);
766 let fp = fingerprint_hash(&["fallow/boundary-violation", &path, &to]);
767 let line = if v.line > 0 { Some(v.line) } else { None };
768 issues.push(cc_issue(
769 "fallow/boundary-violation",
770 &format!(
771 "Boundary violation: {} -> {} ({} -> {})",
772 path, to, v.from_zone, v.to_zone
773 ),
774 level,
775 "Bug Risk",
776 &path,
777 line,
778 &fp,
779 ));
780 }
781}
782
783fn push_boundary_coverage_issues(
784 issues: &mut Vec<CodeClimateIssue>,
785 violations: &[fallow_types::output_dead_code::BoundaryCoverageViolationFinding],
786 root: &Path,
787 severity: Severity,
788) {
789 if violations.is_empty() {
790 return;
791 }
792 let level = severity_to_codeclimate(severity);
793 for entry in violations {
794 let v = &entry.violation;
795 let path = cc_path(&v.path, root);
796 let fp = fingerprint_hash(&["fallow/boundary-coverage", &path]);
797 let line = if v.line > 0 { Some(v.line) } else { None };
798 issues.push(cc_issue(
799 "fallow/boundary-coverage",
800 &format!("Boundary coverage: {path} matches no configured zone"),
801 level,
802 "Bug Risk",
803 &path,
804 line,
805 &fp,
806 ));
807 }
808}
809
810fn push_boundary_call_issues(
811 issues: &mut Vec<CodeClimateIssue>,
812 violations: &[fallow_types::output_dead_code::BoundaryCallViolationFinding],
813 root: &Path,
814 severity: Severity,
815) {
816 if violations.is_empty() {
817 return;
818 }
819 let level = severity_to_codeclimate(severity);
820 for entry in violations {
821 let v = &entry.violation;
822 let path = cc_path(&v.path, root);
823 let fp = fingerprint_hash(&["fallow/boundary-call-violation", &path, &v.callee]);
824 let line = if v.line > 0 { Some(v.line) } else { None };
825 issues.push(cc_issue(
826 "fallow/boundary-call-violation",
827 &format!(
828 "Boundary call: `{}` matches forbidden pattern `{}` in zone '{}'",
829 v.callee, v.pattern, v.zone
830 ),
831 level,
832 "Bug Risk",
833 &path,
834 line,
835 &fp,
836 ));
837 }
838}
839
840fn push_policy_violation_issues(
841 issues: &mut Vec<CodeClimateIssue>,
842 violations: &[fallow_types::output_dead_code::PolicyViolationFinding],
843 root: &Path,
844) {
845 use fallow_core::results::PolicyViolationSeverity;
846
847 for entry in violations {
848 let v = &entry.violation;
849 let path = cc_path(&v.path, root);
850 let rule = format!("{}/{}", v.pack, v.rule_id);
851 let fp = fingerprint_hash(&["fallow/policy-violation", &path, &rule, &v.matched]);
852 let line = if v.line > 0 { Some(v.line) } else { None };
853 let level = severity_to_codeclimate(match v.severity {
857 PolicyViolationSeverity::Error => Severity::Error,
858 PolicyViolationSeverity::Warn => Severity::Warn,
859 });
860 let message = match &v.message {
861 Some(message) => format!(
862 "Policy violation: `{}` is banned by `{rule}`. {message}",
863 v.matched
864 ),
865 None => format!("Policy violation: `{}` is banned by `{rule}`", v.matched),
866 };
867 issues.push(cc_issue(
868 "fallow/policy-violation",
869 &message,
870 level,
871 "Bug Risk",
872 &path,
873 line,
874 &fp,
875 ));
876 }
877}
878
879fn push_invalid_client_export_issues(
880 issues: &mut Vec<CodeClimateIssue>,
881 findings: &[fallow_types::output_dead_code::InvalidClientExportFinding],
882 root: &Path,
883 severity: Severity,
884) {
885 if findings.is_empty() {
886 return;
887 }
888 let level = severity_to_codeclimate(severity);
889 for entry in findings {
890 let e = &entry.export;
891 let path = cc_path(&e.path, root);
892 let fp = fingerprint_hash(&["fallow/invalid-client-export", &path, &e.export_name]);
893 let line = if e.line > 0 { Some(e.line) } else { None };
894 let message = format!(
895 "Export `{}` is not allowed in a \"{}\" file (Next.js server-only / route-config name)",
896 e.export_name, e.directive
897 );
898 issues.push(cc_issue(
899 "fallow/invalid-client-export",
900 &message,
901 level,
902 "Bug Risk",
903 &path,
904 line,
905 &fp,
906 ));
907 }
908}
909
910fn push_mixed_client_server_barrel_issues(
911 issues: &mut Vec<CodeClimateIssue>,
912 findings: &[fallow_types::output_dead_code::MixedClientServerBarrelFinding],
913 root: &Path,
914 severity: Severity,
915) {
916 if findings.is_empty() {
917 return;
918 }
919 let level = severity_to_codeclimate(severity);
920 for entry in findings {
921 let b = &entry.barrel;
922 let path = cc_path(&b.path, root);
923 let fp = fingerprint_hash(&[
924 "fallow/mixed-client-server-barrel",
925 &path,
926 &b.client_origin,
927 &b.server_origin,
928 ]);
929 let line = if b.line > 0 { Some(b.line) } else { None };
930 let message = format!(
931 "Barrel re-exports both a \"use client\" module (`{}`) and a server-only module (`{}`); one import drags the other's directive across the boundary",
932 b.client_origin, b.server_origin
933 );
934 issues.push(cc_issue(
935 "fallow/mixed-client-server-barrel",
936 &message,
937 level,
938 "Bug Risk",
939 &path,
940 line,
941 &fp,
942 ));
943 }
944}
945
946fn push_misplaced_directive_issues(
947 issues: &mut Vec<CodeClimateIssue>,
948 findings: &[fallow_types::output_dead_code::MisplacedDirectiveFinding],
949 root: &Path,
950 severity: Severity,
951) {
952 if findings.is_empty() {
953 return;
954 }
955 let level = severity_to_codeclimate(severity);
956 for entry in findings {
957 let d = &entry.directive_site;
958 let path = cc_path(&d.path, root);
959 let fp = fingerprint_hash(&[
960 "fallow/misplaced-directive",
961 &path,
962 &d.line.to_string(),
963 &d.directive,
964 ]);
965 let line = if d.line > 0 { Some(d.line) } else { None };
966 let message = format!(
967 "Directive `\"{}\"` is not in the leading position, so the RSC bundler ignores it; move it to the top of the file",
968 d.directive
969 );
970 issues.push(cc_issue(
971 "fallow/misplaced-directive",
972 &message,
973 level,
974 "Bug Risk",
975 &path,
976 line,
977 &fp,
978 ));
979 }
980}
981
982fn push_unprovided_inject_issues(
983 issues: &mut Vec<CodeClimateIssue>,
984 findings: &[fallow_types::output_dead_code::UnprovidedInjectFinding],
985 root: &Path,
986 severity: Severity,
987) {
988 if findings.is_empty() {
989 return;
990 }
991 let level = severity_to_codeclimate(severity);
992 for entry in findings {
993 let i = &entry.inject;
994 let path = cc_path(&i.path, root);
995 let fp = fingerprint_hash(&[
996 "fallow/unprovided-inject",
997 &path,
998 &i.line.to_string(),
999 &i.key_name,
1000 ]);
1001 let line = if i.line > 0 { Some(i.line) } else { None };
1002 let message = format!(
1003 "inject(`{}`) has no matching provide(`{}`) in this project; at runtime it returns undefined (provide the key or remove this inject)",
1004 i.key_name, i.key_name
1005 );
1006 issues.push(cc_issue(
1007 "fallow/unprovided-inject",
1008 &message,
1009 level,
1010 "Bug Risk",
1011 &path,
1012 line,
1013 &fp,
1014 ));
1015 }
1016}
1017
1018fn push_unrendered_component_issues(
1019 issues: &mut Vec<CodeClimateIssue>,
1020 findings: &[fallow_types::output_dead_code::UnrenderedComponentFinding],
1021 root: &Path,
1022 severity: Severity,
1023) {
1024 if findings.is_empty() {
1025 return;
1026 }
1027 let level = severity_to_codeclimate(severity);
1028 for entry in findings {
1029 let c = &entry.component;
1030 let path = cc_path(&c.path, root);
1031 let fp = fingerprint_hash(&[
1032 "fallow/unrendered-component",
1033 &path,
1034 &c.line.to_string(),
1035 &c.component_name,
1036 ]);
1037 let line = if c.line > 0 { Some(c.line) } else { None };
1038 let message = format!(
1039 "component `{}` is reachable but rendered nowhere in this project (render it somewhere or remove it)",
1040 c.component_name
1041 );
1042 issues.push(cc_issue(
1043 "fallow/unrendered-component",
1044 &message,
1045 level,
1046 "Bug Risk",
1047 &path,
1048 line,
1049 &fp,
1050 ));
1051 }
1052}
1053
1054fn push_unused_component_prop_issues(
1055 issues: &mut Vec<CodeClimateIssue>,
1056 findings: &[fallow_types::output_dead_code::UnusedComponentPropFinding],
1057 root: &Path,
1058 severity: Severity,
1059) {
1060 if findings.is_empty() {
1061 return;
1062 }
1063 let level = severity_to_codeclimate(severity);
1064 for entry in findings {
1065 let p = &entry.prop;
1066 let path = cc_path(&p.path, root);
1067 let fp = fingerprint_hash(&[
1068 "fallow/unused-component-prop",
1069 &path,
1070 &p.line.to_string(),
1071 &p.prop_name,
1072 ]);
1073 let line = if p.line > 0 { Some(p.line) } else { None };
1074 let message = format!(
1075 "prop `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1076 p.prop_name, p.component_name
1077 );
1078 issues.push(cc_issue(
1079 "fallow/unused-component-prop",
1080 &message,
1081 level,
1082 "Bug Risk",
1083 &path,
1084 line,
1085 &fp,
1086 ));
1087 }
1088}
1089
1090fn push_unused_component_emit_issues(
1091 issues: &mut Vec<CodeClimateIssue>,
1092 findings: &[fallow_types::output_dead_code::UnusedComponentEmitFinding],
1093 root: &Path,
1094 severity: Severity,
1095) {
1096 if findings.is_empty() {
1097 return;
1098 }
1099 let level = severity_to_codeclimate(severity);
1100 for entry in findings {
1101 let e = &entry.emit;
1102 let path = cc_path(&e.path, root);
1103 let fp = fingerprint_hash(&[
1104 "fallow/unused-component-emit",
1105 &path,
1106 &e.line.to_string(),
1107 &e.emit_name,
1108 ]);
1109 let line = if e.line > 0 { Some(e.line) } else { None };
1110 let message = format!(
1111 "emit `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1112 e.emit_name, e.component_name
1113 );
1114 issues.push(cc_issue(
1115 "fallow/unused-component-emit",
1116 &message,
1117 level,
1118 "Bug Risk",
1119 &path,
1120 line,
1121 &fp,
1122 ));
1123 }
1124}
1125
1126fn push_unused_svelte_event_issues(
1127 issues: &mut Vec<CodeClimateIssue>,
1128 findings: &[fallow_types::output_dead_code::UnusedSvelteEventFinding],
1129 root: &Path,
1130 severity: Severity,
1131) {
1132 if findings.is_empty() {
1133 return;
1134 }
1135 let level = severity_to_codeclimate(severity);
1136 for entry in findings {
1137 let e = &entry.event;
1138 let path = cc_path(&e.path, root);
1139 let fp = fingerprint_hash(&[
1140 "fallow/unused-svelte-event",
1141 &path,
1142 &e.line.to_string(),
1143 &e.event_name,
1144 ]);
1145 let line = if e.line > 0 { Some(e.line) } else { None };
1146 let message = format!(
1147 "event `{}` is dispatched by component `{}` but listened to nowhere in the project (remove it or listen for it)",
1148 e.event_name, e.component_name
1149 );
1150 issues.push(cc_issue(
1151 "fallow/unused-svelte-event",
1152 &message,
1153 level,
1154 "Bug Risk",
1155 &path,
1156 line,
1157 &fp,
1158 ));
1159 }
1160}
1161
1162fn push_unused_component_input_issues(
1163 issues: &mut Vec<CodeClimateIssue>,
1164 findings: &[fallow_types::output_dead_code::UnusedComponentInputFinding],
1165 root: &Path,
1166 severity: Severity,
1167) {
1168 if findings.is_empty() {
1169 return;
1170 }
1171 let level = severity_to_codeclimate(severity);
1172 for entry in findings {
1173 let i = &entry.input;
1174 let path = cc_path(&i.path, root);
1175 let fp = fingerprint_hash(&[
1176 "fallow/unused-component-input",
1177 &path,
1178 &i.line.to_string(),
1179 &i.input_name,
1180 ]);
1181 let line = if i.line > 0 { Some(i.line) } else { None };
1182 let message = format!(
1183 "input `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1184 i.input_name, i.component_name
1185 );
1186 issues.push(cc_issue(
1187 "fallow/unused-component-input",
1188 &message,
1189 level,
1190 "Bug Risk",
1191 &path,
1192 line,
1193 &fp,
1194 ));
1195 }
1196}
1197
1198fn push_unused_component_output_issues(
1199 issues: &mut Vec<CodeClimateIssue>,
1200 findings: &[fallow_types::output_dead_code::UnusedComponentOutputFinding],
1201 root: &Path,
1202 severity: Severity,
1203) {
1204 if findings.is_empty() {
1205 return;
1206 }
1207 let level = severity_to_codeclimate(severity);
1208 for entry in findings {
1209 let o = &entry.output;
1210 let path = cc_path(&o.path, root);
1211 let fp = fingerprint_hash(&[
1212 "fallow/unused-component-output",
1213 &path,
1214 &o.line.to_string(),
1215 &o.output_name,
1216 ]);
1217 let line = if o.line > 0 { Some(o.line) } else { None };
1218 let message = format!(
1219 "output `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1220 o.output_name, o.component_name
1221 );
1222 issues.push(cc_issue(
1223 "fallow/unused-component-output",
1224 &message,
1225 level,
1226 "Bug Risk",
1227 &path,
1228 line,
1229 &fp,
1230 ));
1231 }
1232}
1233
1234fn push_unused_server_action_issues(
1235 issues: &mut Vec<CodeClimateIssue>,
1236 findings: &[fallow_types::output_dead_code::UnusedServerActionFinding],
1237 root: &Path,
1238 severity: Severity,
1239) {
1240 if findings.is_empty() {
1241 return;
1242 }
1243 let level = severity_to_codeclimate(severity);
1244 for entry in findings {
1245 let a = &entry.action;
1246 let path = cc_path(&a.path, root);
1247 let fp = fingerprint_hash(&[
1248 "fallow/unused-server-action",
1249 &path,
1250 &a.line.to_string(),
1251 &a.action_name,
1252 ]);
1253 let line = if a.line > 0 { Some(a.line) } else { None };
1254 let message = format!(
1255 "server action `{}` is exported from a \"use server\" file but no code in this project references it (wire it to a consumer or remove it)",
1256 a.action_name
1257 );
1258 issues.push(cc_issue(
1259 "fallow/unused-server-action",
1260 &message,
1261 level,
1262 "Bug Risk",
1263 &path,
1264 line,
1265 &fp,
1266 ));
1267 }
1268}
1269
1270fn push_unused_load_data_key_issues(
1271 issues: &mut Vec<CodeClimateIssue>,
1272 findings: &[fallow_types::output_dead_code::UnusedLoadDataKeyFinding],
1273 root: &Path,
1274 severity: Severity,
1275) {
1276 if findings.is_empty() {
1277 return;
1278 }
1279 let level = severity_to_codeclimate(severity);
1280 for entry in findings {
1281 let k = &entry.key;
1282 let path = cc_path(&k.path, root);
1283 let fp = fingerprint_hash(&[
1284 "fallow/unused-load-data-key",
1285 &path,
1286 &k.line.to_string(),
1287 &k.key_name,
1288 ]);
1289 let line = if k.line > 0 { Some(k.line) } else { None };
1290 let message = format!(
1291 "load() return key `{}` is read by no consumer (sibling +page.svelte data.<key> or project-wide page.data.<key>); delete the key or wire a consumer",
1292 k.key_name
1293 );
1294 issues.push(cc_issue(
1295 "fallow/unused-load-data-key",
1296 &message,
1297 level,
1298 "Bug Risk",
1299 &path,
1300 line,
1301 &fp,
1302 ));
1303 }
1304}
1305
1306fn push_route_collision_issues(
1307 issues: &mut Vec<CodeClimateIssue>,
1308 findings: &[fallow_types::output_dead_code::RouteCollisionFinding],
1309 root: &Path,
1310 severity: Severity,
1311) {
1312 if findings.is_empty() {
1313 return;
1314 }
1315 let level = severity_to_codeclimate(severity);
1316 for entry in findings {
1317 let c = &entry.collision;
1318 let path = cc_path(&c.path, root);
1319 let fp = fingerprint_hash(&["fallow/route-collision", &path, &c.url]);
1320 let line = if c.line > 0 { Some(c.line) } else { None };
1321 let message = format!(
1322 "Route file resolves to `{}`, also owned by {} other file(s); Next.js fails the build because a URL can have only one owner",
1323 c.url,
1324 c.conflicting_paths.len()
1325 );
1326 issues.push(cc_issue(
1327 "fallow/route-collision",
1328 &message,
1329 level,
1330 "Bug Risk",
1331 &path,
1332 line,
1333 &fp,
1334 ));
1335 }
1336}
1337
1338fn push_dynamic_segment_name_conflict_issues(
1339 issues: &mut Vec<CodeClimateIssue>,
1340 findings: &[fallow_types::output_dead_code::DynamicSegmentNameConflictFinding],
1341 root: &Path,
1342 severity: Severity,
1343) {
1344 if findings.is_empty() {
1345 return;
1346 }
1347 let level = severity_to_codeclimate(severity);
1348 for entry in findings {
1349 let c = &entry.conflict;
1350 let path = cc_path(&c.path, root);
1351 let fp = fingerprint_hash(&["fallow/dynamic-segment-name-conflict", &path, &c.position]);
1352 let line = if c.line > 0 { Some(c.line) } else { None };
1353 let message = format!(
1354 "Dynamic segments at `{}` use different slug names ({}); Next.js requires one consistent name per dynamic path",
1355 c.position,
1356 c.conflicting_segments.join(", ")
1357 );
1358 issues.push(cc_issue(
1359 "fallow/dynamic-segment-name-conflict",
1360 &message,
1361 level,
1362 "Bug Risk",
1363 &path,
1364 line,
1365 &fp,
1366 ));
1367 }
1368}
1369
1370fn push_stale_suppression_issues(
1371 issues: &mut Vec<CodeClimateIssue>,
1372 suppressions: &[fallow_core::results::StaleSuppression],
1373 root: &Path,
1374 rules: &RulesConfig,
1375) {
1376 if suppressions.is_empty() {
1377 return;
1378 }
1379 for s in suppressions {
1380 let severity = if s.missing_reason {
1381 rules.require_suppression_reason
1382 } else {
1383 rules.stale_suppressions
1384 };
1385 let level = severity_to_codeclimate(severity);
1386 let path = cc_path(&s.path, root);
1387 let line_str = s.line.to_string();
1388 let check_name = if s.missing_reason {
1389 "fallow/missing-suppression-reason"
1390 } else {
1391 "fallow/stale-suppression"
1392 };
1393 let fp = fingerprint_hash(&[check_name, &path, &line_str]);
1394 issues.push(cc_issue(
1395 check_name,
1396 &s.display_message(),
1397 level,
1398 "Bug Risk",
1399 &path,
1400 Some(s.line),
1401 &fp,
1402 ));
1403 }
1404}
1405
1406fn push_unused_catalog_entry_issues(
1407 issues: &mut Vec<CodeClimateIssue>,
1408 entries: &[fallow_core::results::UnusedCatalogEntryFinding],
1409 root: &Path,
1410 severity: Severity,
1411) {
1412 if entries.is_empty() {
1413 return;
1414 }
1415 let level = severity_to_codeclimate(severity);
1416 for entry in entries {
1417 let entry = &entry.entry;
1418 let path = cc_path(&entry.path, root);
1419 let line_str = entry.line.to_string();
1420 let fp = fingerprint_hash(&[
1421 "fallow/unused-catalog-entry",
1422 &path,
1423 &line_str,
1424 &entry.catalog_name,
1425 &entry.entry_name,
1426 ]);
1427 let description = if entry.catalog_name == "default" {
1428 format!(
1429 "Catalog entry '{}' is not referenced by any workspace package",
1430 entry.entry_name
1431 )
1432 } else {
1433 format!(
1434 "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
1435 entry.entry_name, entry.catalog_name
1436 )
1437 };
1438 issues.push(cc_issue(
1439 "fallow/unused-catalog-entry",
1440 &description,
1441 level,
1442 "Bug Risk",
1443 &path,
1444 Some(entry.line),
1445 &fp,
1446 ));
1447 }
1448}
1449
1450fn push_unresolved_catalog_reference_issues(
1451 issues: &mut Vec<CodeClimateIssue>,
1452 findings: &[fallow_core::results::UnresolvedCatalogReferenceFinding],
1453 root: &Path,
1454 severity: Severity,
1455) {
1456 if findings.is_empty() {
1457 return;
1458 }
1459 let level = severity_to_codeclimate(severity);
1460 for finding in findings {
1461 let finding = &finding.reference;
1462 let path = cc_path(&finding.path, root);
1463 let line_str = finding.line.to_string();
1464 let fp = fingerprint_hash(&[
1465 "fallow/unresolved-catalog-reference",
1466 &path,
1467 &line_str,
1468 &finding.catalog_name,
1469 &finding.entry_name,
1470 ]);
1471 let catalog_phrase = if finding.catalog_name == "default" {
1472 "the default catalog".to_string()
1473 } else {
1474 format!("catalog '{}'", finding.catalog_name)
1475 };
1476 let mut description = format!(
1477 "Package '{}' is referenced via `catalog:{}` but {} does not declare it; `pnpm install` will fail",
1478 finding.entry_name,
1479 if finding.catalog_name == "default" {
1480 ""
1481 } else {
1482 finding.catalog_name.as_str()
1483 },
1484 catalog_phrase,
1485 );
1486 if !finding.available_in_catalogs.is_empty() {
1487 use std::fmt::Write as _;
1488 let _ = write!(
1489 description,
1490 " (available in: {})",
1491 finding.available_in_catalogs.join(", ")
1492 );
1493 }
1494 issues.push(cc_issue(
1495 "fallow/unresolved-catalog-reference",
1496 &description,
1497 level,
1498 "Bug Risk",
1499 &path,
1500 Some(finding.line),
1501 &fp,
1502 ));
1503 }
1504}
1505
1506fn push_empty_catalog_group_issues(
1507 issues: &mut Vec<CodeClimateIssue>,
1508 groups: &[fallow_core::results::EmptyCatalogGroupFinding],
1509 root: &Path,
1510 severity: Severity,
1511) {
1512 if groups.is_empty() {
1513 return;
1514 }
1515 let level = severity_to_codeclimate(severity);
1516 for group in groups {
1517 let group = &group.group;
1518 let path = cc_path(&group.path, root);
1519 let line_str = group.line.to_string();
1520 let fp = fingerprint_hash(&[
1521 "fallow/empty-catalog-group",
1522 &path,
1523 &line_str,
1524 &group.catalog_name,
1525 ]);
1526 issues.push(cc_issue(
1527 "fallow/empty-catalog-group",
1528 &format!("Catalog group '{}' has no entries", group.catalog_name),
1529 level,
1530 "Bug Risk",
1531 &path,
1532 Some(group.line),
1533 &fp,
1534 ));
1535 }
1536}
1537
1538fn push_unused_dependency_override_issues(
1539 issues: &mut Vec<CodeClimateIssue>,
1540 findings: &[fallow_core::results::UnusedDependencyOverrideFinding],
1541 root: &Path,
1542 severity: Severity,
1543) {
1544 if findings.is_empty() {
1545 return;
1546 }
1547 let level = severity_to_codeclimate(severity);
1548 for finding in findings {
1549 let finding = &finding.entry;
1550 let path = cc_path(&finding.path, root);
1551 let line_str = finding.line.to_string();
1552 let fp = fingerprint_hash(&[
1553 "fallow/unused-dependency-override",
1554 &path,
1555 &line_str,
1556 finding.source.as_label(),
1557 &finding.raw_key,
1558 ]);
1559 let mut description = format!(
1560 "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
1561 finding.raw_key, finding.version_range, finding.target_package,
1562 );
1563 if let Some(hint) = &finding.hint {
1564 use std::fmt::Write as _;
1565 let _ = write!(description, " ({hint})");
1566 }
1567 issues.push(cc_issue(
1568 "fallow/unused-dependency-override",
1569 &description,
1570 level,
1571 "Bug Risk",
1572 &path,
1573 Some(finding.line),
1574 &fp,
1575 ));
1576 }
1577}
1578
1579fn push_misconfigured_dependency_override_issues(
1580 issues: &mut Vec<CodeClimateIssue>,
1581 findings: &[fallow_core::results::MisconfiguredDependencyOverrideFinding],
1582 root: &Path,
1583 severity: Severity,
1584) {
1585 if findings.is_empty() {
1586 return;
1587 }
1588 let level = severity_to_codeclimate(severity);
1589 for finding in findings {
1590 let finding = &finding.entry;
1591 let path = cc_path(&finding.path, root);
1592 let line_str = finding.line.to_string();
1593 let fp = fingerprint_hash(&[
1594 "fallow/misconfigured-dependency-override",
1595 &path,
1596 &line_str,
1597 finding.source.as_label(),
1598 &finding.raw_key,
1599 ]);
1600 let description = format!(
1601 "Override `{}` -> `{}` is malformed: {}",
1602 finding.raw_key,
1603 finding.raw_value,
1604 finding.reason.describe(),
1605 );
1606 issues.push(cc_issue(
1607 "fallow/misconfigured-dependency-override",
1608 &description,
1609 level,
1610 "Bug Risk",
1611 &path,
1612 Some(finding.line),
1613 &fp,
1614 ));
1615 }
1616}
1617
1618#[must_use]
1627#[expect(
1628 clippy::expect_used,
1629 reason = "CodeClimateIssue contains only infallibly serializable fields"
1630)]
1631pub fn issues_to_value(issues: &[CodeClimateIssue]) -> serde_json::Value {
1632 serde_json::to_value(issues).expect("CodeClimateIssue serializes infallibly")
1633}
1634
1635#[must_use]
1642pub fn build_codeclimate(
1643 results: &AnalysisResults,
1644 root: &Path,
1645 rules: &RulesConfig,
1646) -> Vec<CodeClimateIssue> {
1647 CodeClimateBuilder {
1648 issues: Vec::new(),
1649 results,
1650 root,
1651 rules,
1652 }
1653 .build()
1654}
1655
1656struct CodeClimateBuilder<'a> {
1657 issues: Vec<CodeClimateIssue>,
1658 results: &'a AnalysisResults,
1659 root: &'a Path,
1660 rules: &'a RulesConfig,
1661}
1662
1663impl CodeClimateBuilder<'_> {
1664 fn build(mut self) -> Vec<CodeClimateIssue> {
1665 self.push_file_and_export_issues();
1666 self.push_private_type_leak_issues();
1667 self.push_package_dependency_issues();
1668 self.push_type_test_dependency_issues();
1669 self.push_member_issues();
1670 self.push_import_and_duplicate_issues();
1671 self.push_graph_issues();
1672 self.push_boundary_issues();
1673 self.push_suppression_and_catalog_issues();
1674 self.push_override_issues();
1675 self.issues
1676 }
1677
1678 fn push_file_and_export_issues(&mut self) {
1679 push_unused_file_issues(
1680 &mut self.issues,
1681 &self.results.unused_files,
1682 self.root,
1683 self.rules.unused_files,
1684 );
1685 push_unused_export_issues(UnusedExportIssuesInput {
1686 issues: &mut self.issues,
1687 exports: self.results.unused_exports.iter().map(|e| &e.export),
1688 root: self.root,
1689 rule_id: "fallow/unused-export",
1690 direct_label: "Export",
1691 re_export_label: "Re-export",
1692 severity: self.rules.unused_exports,
1693 });
1694 push_unused_export_issues(UnusedExportIssuesInput {
1695 issues: &mut self.issues,
1696 exports: self.results.unused_types.iter().map(|e| &e.export),
1697 root: self.root,
1698 rule_id: "fallow/unused-type",
1699 direct_label: "Type export",
1700 re_export_label: "Type re-export",
1701 severity: self.rules.unused_types,
1702 });
1703 }
1704
1705 fn push_private_type_leak_issues(&mut self) {
1706 push_private_type_leak_issues(
1707 &mut self.issues,
1708 &self.results.private_type_leaks,
1709 self.root,
1710 self.rules.private_type_leaks,
1711 );
1712 }
1713
1714 fn push_package_dependency_issues(&mut self) {
1715 push_dep_cc_issues(
1716 &mut self.issues,
1717 self.results.unused_dependencies.iter().map(|f| &f.dep),
1718 self.root,
1719 "fallow/unused-dependency",
1720 "dependencies",
1721 self.rules.unused_dependencies,
1722 );
1723 push_dep_cc_issues(
1724 &mut self.issues,
1725 self.results.unused_dev_dependencies.iter().map(|f| &f.dep),
1726 self.root,
1727 "fallow/unused-dev-dependency",
1728 "devDependencies",
1729 self.rules.unused_dev_dependencies,
1730 );
1731 push_dep_cc_issues(
1732 &mut self.issues,
1733 self.results
1734 .unused_optional_dependencies
1735 .iter()
1736 .map(|f| &f.dep),
1737 self.root,
1738 "fallow/unused-optional-dependency",
1739 "optionalDependencies",
1740 self.rules.unused_optional_dependencies,
1741 );
1742 }
1743
1744 fn push_type_test_dependency_issues(&mut self) {
1745 push_type_only_dep_issues(
1746 &mut self.issues,
1747 &self.results.type_only_dependencies,
1748 self.root,
1749 self.rules.type_only_dependencies,
1750 );
1751 push_test_only_dep_issues(
1752 &mut self.issues,
1753 &self.results.test_only_dependencies,
1754 self.root,
1755 self.rules.test_only_dependencies,
1756 );
1757 }
1758
1759 fn push_member_issues(&mut self) {
1760 push_unused_member_issues(
1761 &mut self.issues,
1762 self.results.unused_enum_members.iter().map(|m| &m.member),
1763 self.root,
1764 "fallow/unused-enum-member",
1765 "Enum",
1766 self.rules.unused_enum_members,
1767 );
1768 push_unused_member_issues(
1769 &mut self.issues,
1770 self.results.unused_class_members.iter().map(|m| &m.member),
1771 self.root,
1772 "fallow/unused-class-member",
1773 "Class",
1774 self.rules.unused_class_members,
1775 );
1776 push_unused_member_issues(
1777 &mut self.issues,
1778 self.results.unused_store_members.iter().map(|m| &m.member),
1779 self.root,
1780 "fallow/unused-store-member",
1781 "Store",
1782 self.rules.unused_store_members,
1783 );
1784 }
1785
1786 fn push_import_and_duplicate_issues(&mut self) {
1787 push_unresolved_import_issues(
1788 &mut self.issues,
1789 &self.results.unresolved_imports,
1790 self.root,
1791 self.rules.unresolved_imports,
1792 );
1793 push_unlisted_dep_issues(
1794 &mut self.issues,
1795 &self.results.unlisted_dependencies,
1796 self.root,
1797 self.rules.unlisted_dependencies,
1798 );
1799 push_duplicate_export_issues(
1800 &mut self.issues,
1801 &self.results.duplicate_exports,
1802 self.root,
1803 self.rules.duplicate_exports,
1804 );
1805 }
1806
1807 fn push_graph_issues(&mut self) {
1808 push_circular_dep_issues(
1809 &mut self.issues,
1810 &self.results.circular_dependencies,
1811 self.root,
1812 self.rules.circular_dependencies,
1813 );
1814 push_re_export_cycle_issues(
1815 &mut self.issues,
1816 &self.results.re_export_cycles,
1817 self.root,
1818 self.rules.re_export_cycle,
1819 );
1820 }
1821
1822 fn push_boundary_issues(&mut self) {
1823 self.push_architecture_boundary_issues();
1824 self.push_client_server_boundary_issues();
1825 self.push_component_boundary_issues();
1826 self.push_framework_route_issues();
1827 }
1828
1829 fn push_architecture_boundary_issues(&mut self) {
1830 push_boundary_violation_issues(
1831 &mut self.issues,
1832 &self.results.boundary_violations,
1833 self.root,
1834 self.rules.boundary_violation,
1835 );
1836 push_boundary_coverage_issues(
1837 &mut self.issues,
1838 &self.results.boundary_coverage_violations,
1839 self.root,
1840 self.rules.boundary_violation,
1841 );
1842 push_boundary_call_issues(
1843 &mut self.issues,
1844 &self.results.boundary_call_violations,
1845 self.root,
1846 self.rules.boundary_violation,
1847 );
1848 push_policy_violation_issues(&mut self.issues, &self.results.policy_violations, self.root);
1849 }
1850
1851 fn push_client_server_boundary_issues(&mut self) {
1852 push_invalid_client_export_issues(
1853 &mut self.issues,
1854 &self.results.invalid_client_exports,
1855 self.root,
1856 self.rules.invalid_client_export,
1857 );
1858 push_mixed_client_server_barrel_issues(
1859 &mut self.issues,
1860 &self.results.mixed_client_server_barrels,
1861 self.root,
1862 self.rules.mixed_client_server_barrel,
1863 );
1864 push_misplaced_directive_issues(
1865 &mut self.issues,
1866 &self.results.misplaced_directives,
1867 self.root,
1868 self.rules.misplaced_directive,
1869 );
1870 }
1871
1872 fn push_component_boundary_issues(&mut self) {
1873 push_unprovided_inject_issues(
1874 &mut self.issues,
1875 &self.results.unprovided_injects,
1876 self.root,
1877 self.rules.unprovided_injects,
1878 );
1879 push_unrendered_component_issues(
1880 &mut self.issues,
1881 &self.results.unrendered_components,
1882 self.root,
1883 self.rules.unrendered_components,
1884 );
1885 push_unused_component_prop_issues(
1886 &mut self.issues,
1887 &self.results.unused_component_props,
1888 self.root,
1889 self.rules.unused_component_props,
1890 );
1891 push_unused_component_emit_issues(
1892 &mut self.issues,
1893 &self.results.unused_component_emits,
1894 self.root,
1895 self.rules.unused_component_emits,
1896 );
1897 push_unused_component_input_issues(
1898 &mut self.issues,
1899 &self.results.unused_component_inputs,
1900 self.root,
1901 self.rules.unused_component_inputs,
1902 );
1903 push_unused_component_output_issues(
1904 &mut self.issues,
1905 &self.results.unused_component_outputs,
1906 self.root,
1907 self.rules.unused_component_outputs,
1908 );
1909 push_unused_svelte_event_issues(
1910 &mut self.issues,
1911 &self.results.unused_svelte_events,
1912 self.root,
1913 self.rules.unused_svelte_events,
1914 );
1915 }
1916
1917 fn push_framework_route_issues(&mut self) {
1918 push_unused_server_action_issues(
1919 &mut self.issues,
1920 &self.results.unused_server_actions,
1921 self.root,
1922 self.rules.unused_server_actions,
1923 );
1924 push_unused_load_data_key_issues(
1925 &mut self.issues,
1926 &self.results.unused_load_data_keys,
1927 self.root,
1928 self.rules.unused_load_data_keys,
1929 );
1930 push_route_collision_issues(
1931 &mut self.issues,
1932 &self.results.route_collisions,
1933 self.root,
1934 self.rules.route_collision,
1935 );
1936 push_dynamic_segment_name_conflict_issues(
1937 &mut self.issues,
1938 &self.results.dynamic_segment_name_conflicts,
1939 self.root,
1940 self.rules.dynamic_segment_name_conflict,
1941 );
1942 }
1943
1944 fn push_suppression_and_catalog_issues(&mut self) {
1945 push_stale_suppression_issues(
1946 &mut self.issues,
1947 &self.results.stale_suppressions,
1948 self.root,
1949 self.rules,
1950 );
1951 push_unused_catalog_entry_issues(
1952 &mut self.issues,
1953 &self.results.unused_catalog_entries,
1954 self.root,
1955 self.rules.unused_catalog_entries,
1956 );
1957 push_empty_catalog_group_issues(
1958 &mut self.issues,
1959 &self.results.empty_catalog_groups,
1960 self.root,
1961 self.rules.empty_catalog_groups,
1962 );
1963 push_unresolved_catalog_reference_issues(
1964 &mut self.issues,
1965 &self.results.unresolved_catalog_references,
1966 self.root,
1967 self.rules.unresolved_catalog_references,
1968 );
1969 }
1970
1971 fn push_override_issues(&mut self) {
1972 push_unused_dependency_override_issues(
1973 &mut self.issues,
1974 &self.results.unused_dependency_overrides,
1975 self.root,
1976 self.rules.unused_dependency_overrides,
1977 );
1978 push_misconfigured_dependency_override_issues(
1979 &mut self.issues,
1980 &self.results.misconfigured_dependency_overrides,
1981 self.root,
1982 self.rules.misconfigured_dependency_overrides,
1983 );
1984 }
1985}
1986
1987pub(super) fn print_codeclimate(
1989 results: &AnalysisResults,
1990 root: &Path,
1991 rules: &RulesConfig,
1992) -> ExitCode {
1993 let issues = build_codeclimate(results, root, rules);
1994 let value = issues_to_value(&issues);
1995 emit_json(&value, "CodeClimate")
1996}
1997
1998#[expect(
2004 clippy::expect_used,
2005 reason = "grouped CodeClimate entries are JSON objects created by issues_to_value"
2006)]
2007pub(super) fn print_grouped_codeclimate(
2008 results: &AnalysisResults,
2009 root: &Path,
2010 rules: &RulesConfig,
2011 resolver: &OwnershipResolver,
2012) -> ExitCode {
2013 let issues = build_codeclimate(results, root, rules);
2014 let mut value = issues_to_value(&issues);
2015
2016 if let Some(items) = value.as_array_mut() {
2017 for issue in items {
2018 let path = issue
2019 .pointer("/location/path")
2020 .and_then(|v| v.as_str())
2021 .unwrap_or("");
2022 let owner = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
2023 issue
2024 .as_object_mut()
2025 .expect("CodeClimate issue should be an object")
2026 .insert("owner".to_string(), serde_json::Value::String(owner));
2027 }
2028 }
2029
2030 emit_json(&value, "CodeClimate")
2031}
2032
2033#[must_use]
2035pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> Vec<CodeClimateIssue> {
2036 let mut issues = Vec::new();
2037 let ctx = HealthCodeClimateContext {
2038 root,
2039 cyc_t: report.summary.max_cyclomatic_threshold,
2040 cog_t: report.summary.max_cognitive_threshold,
2041 crap_t: report.summary.max_crap_threshold,
2042 };
2043
2044 for finding in &report.findings {
2045 issues.push(ctx.complexity_issue(finding));
2046 }
2047
2048 if let Some(ref production) = report.runtime_coverage {
2049 for finding in &production.findings {
2050 issues.push(ctx.runtime_coverage_issue(finding));
2051 }
2052 }
2053
2054 if let Some(ref intelligence) = report.coverage_intelligence {
2055 for finding in &intelligence.findings {
2056 if let Some(issue) = ctx.coverage_intelligence_issue(finding) {
2057 issues.push(issue);
2058 }
2059 }
2060 }
2061
2062 if let Some(ref gaps) = report.coverage_gaps {
2063 for item in &gaps.files {
2064 issues.push(ctx.untested_file_issue(item));
2065 }
2066
2067 for item in &gaps.exports {
2068 issues.push(ctx.untested_export_issue(item));
2069 }
2070 }
2071
2072 issues
2073}
2074
2075pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
2077 let issues = build_health_codeclimate(report, root);
2078 let value = issues_to_value(&issues);
2079 emit_json(&value, "CodeClimate")
2080}
2081
2082#[expect(
2091 clippy::expect_used,
2092 reason = "grouped health CodeClimate entries are JSON objects created by issues_to_value"
2093)]
2094pub(super) fn print_grouped_health_codeclimate(
2095 report: &HealthReport,
2096 root: &Path,
2097 resolver: &OwnershipResolver,
2098) -> ExitCode {
2099 let issues = build_health_codeclimate(report, root);
2100 let mut value = issues_to_value(&issues);
2101
2102 if let Some(items) = value.as_array_mut() {
2103 for issue in items {
2104 let path = issue
2105 .pointer("/location/path")
2106 .and_then(|v| v.as_str())
2107 .unwrap_or("");
2108 let group = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
2109 issue
2110 .as_object_mut()
2111 .expect("CodeClimate issue should be an object")
2112 .insert("group".to_string(), serde_json::Value::String(group));
2113 }
2114 }
2115
2116 emit_json(&value, "CodeClimate")
2117}
2118
2119#[must_use]
2121#[expect(
2122 clippy::cast_possible_truncation,
2123 reason = "line numbers are bounded by source size"
2124)]
2125pub fn build_duplication_codeclimate(
2126 report: &DuplicationReport,
2127 root: &Path,
2128) -> Vec<CodeClimateIssue> {
2129 let mut issues = Vec::new();
2130
2131 for (i, group) in report.clone_groups.iter().enumerate() {
2132 let token_str = group.token_count.to_string();
2133 let line_count_str = group.line_count.to_string();
2134 let fragment_prefix: String = group
2135 .instances
2136 .first()
2137 .map(|inst| inst.fragment.chars().take(64).collect())
2138 .unwrap_or_default();
2139
2140 for instance in &group.instances {
2141 let path = cc_path(&instance.file, root);
2142 let start_str = instance.start_line.to_string();
2143 let fp = fingerprint_hash(&[
2144 "fallow/code-duplication",
2145 &path,
2146 &start_str,
2147 &token_str,
2148 &line_count_str,
2149 &fragment_prefix,
2150 ]);
2151 issues.push(cc_issue(
2152 "fallow/code-duplication",
2153 &format!(
2154 "Code clone group {} ({} lines, {} instances)",
2155 i + 1,
2156 group.line_count,
2157 group.instances.len()
2158 ),
2159 CodeClimateSeverity::Minor,
2160 "Duplication",
2161 &path,
2162 Some(instance.start_line as u32),
2163 &fp,
2164 ));
2165 }
2166 }
2167
2168 issues
2169}
2170
2171pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
2173 let issues = build_duplication_codeclimate(report, root);
2174 let value = issues_to_value(&issues);
2175 emit_json(&value, "CodeClimate")
2176}
2177
2178#[expect(
2187 clippy::expect_used,
2188 reason = "grouped duplication CodeClimate entries are JSON objects created by issues_to_value"
2189)]
2190pub(super) fn print_grouped_duplication_codeclimate(
2191 report: &DuplicationReport,
2192 root: &Path,
2193 resolver: &OwnershipResolver,
2194) -> ExitCode {
2195 let issues = build_duplication_codeclimate(report, root);
2196 let mut value = issues_to_value(&issues);
2197
2198 use rustc_hash::FxHashMap;
2199 let mut path_to_owner: FxHashMap<String, String> = FxHashMap::default();
2200 for group in &report.clone_groups {
2201 let owner = super::dupes_grouping::largest_owner(group, root, resolver);
2202 for instance in &group.instances {
2203 let path = cc_path(&instance.file, root);
2204 path_to_owner.insert(path, owner.clone());
2205 }
2206 }
2207
2208 if let Some(items) = value.as_array_mut() {
2209 for issue in items {
2210 let path = issue
2211 .pointer("/location/path")
2212 .and_then(|v| v.as_str())
2213 .unwrap_or("")
2214 .to_string();
2215 let group = path_to_owner
2216 .get(&path)
2217 .cloned()
2218 .unwrap_or_else(|| crate::codeowners::UNOWNED_LABEL.to_string());
2219 issue
2220 .as_object_mut()
2221 .expect("CodeClimate issue should be an object")
2222 .insert("group".to_string(), serde_json::Value::String(group));
2223 }
2224 }
2225
2226 emit_json(&value, "CodeClimate")
2227}
2228
2229#[cfg(test)]
2230mod tests {
2231 use super::*;
2232 use crate::report::test_helpers::sample_results;
2233 use fallow_config::RulesConfig;
2234 use fallow_core::results::*;
2235 use std::path::PathBuf;
2236
2237 fn health_severity(value: u16, threshold: u16) -> &'static str {
2240 if threshold == 0 {
2241 return "minor";
2242 }
2243 let ratio = f64::from(value) / f64::from(threshold);
2244 if ratio > 2.5 {
2245 "critical"
2246 } else if ratio > 1.5 {
2247 "major"
2248 } else {
2249 "minor"
2250 }
2251 }
2252
2253 #[test]
2254 fn codeclimate_empty_results_produces_empty_array() {
2255 let root = PathBuf::from("/project");
2256 let results = AnalysisResults::default();
2257 let rules = RulesConfig::default();
2258 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2259 let arr = output.as_array().unwrap();
2260 assert!(arr.is_empty());
2261 }
2262
2263 #[test]
2264 fn codeclimate_produces_array_of_issues() {
2265 let root = PathBuf::from("/project");
2266 let results = sample_results(&root);
2267 let rules = RulesConfig::default();
2268 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2269 assert!(output.is_array());
2270 let arr = output.as_array().unwrap();
2271 assert!(!arr.is_empty());
2272 }
2273
2274 #[test]
2275 fn codeclimate_missing_suppression_reason_uses_reason_rule_severity() {
2276 let root = PathBuf::from("/project");
2277 let mut results = AnalysisResults::default();
2278 results.stale_suppressions.push(StaleSuppression {
2279 path: root.join("src/file.ts"),
2280 line: 1,
2281 col: 0,
2282 origin: SuppressionOrigin::Comment {
2283 issue_kind: Some("unused-exports".to_string()),
2284 reason: None,
2285 is_file_level: false,
2286 kind_known: true,
2287 },
2288 missing_reason: true,
2289 actions: StaleSuppression::actions_for(true),
2290 });
2291 let rules = RulesConfig {
2292 stale_suppressions: Severity::Off,
2293 require_suppression_reason: Severity::Error,
2294 ..Default::default()
2295 };
2296
2297 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2298
2299 assert_eq!(output[0]["check_name"], "fallow/missing-suppression-reason");
2300 assert_eq!(output[0]["severity"], "major");
2301 }
2302
2303 #[test]
2304 fn codeclimate_stale_and_missing_suppression_have_distinct_identities() {
2305 let root = PathBuf::from("/project");
2306 let mut results = AnalysisResults::default();
2307 let origin = SuppressionOrigin::Comment {
2308 issue_kind: Some("unused-exports".to_string()),
2309 reason: None,
2310 is_file_level: false,
2311 kind_known: true,
2312 };
2313 results.stale_suppressions.push(StaleSuppression {
2314 path: root.join("src/file.ts"),
2315 line: 1,
2316 col: 0,
2317 origin: origin.clone(),
2318 missing_reason: false,
2319 actions: StaleSuppression::actions_for(false),
2320 });
2321 results.stale_suppressions.push(StaleSuppression {
2322 path: root.join("src/file.ts"),
2323 line: 1,
2324 col: 0,
2325 origin,
2326 missing_reason: true,
2327 actions: StaleSuppression::actions_for(true),
2328 });
2329 let rules = RulesConfig {
2330 stale_suppressions: Severity::Warn,
2331 require_suppression_reason: Severity::Error,
2332 ..Default::default()
2333 };
2334
2335 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2336
2337 assert_eq!(output[0]["check_name"], "fallow/stale-suppression");
2338 assert_eq!(output[1]["check_name"], "fallow/missing-suppression-reason");
2339 assert_ne!(output[0]["fingerprint"], output[1]["fingerprint"]);
2340 }
2341
2342 #[test]
2343 fn codeclimate_issue_has_required_fields() {
2344 let root = PathBuf::from("/project");
2345 let mut results = AnalysisResults::default();
2346 results
2347 .unused_files
2348 .push(UnusedFileFinding::with_actions(UnusedFile {
2349 path: root.join("src/dead.ts"),
2350 }));
2351 let rules = RulesConfig::default();
2352 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2353 let issue = &output.as_array().unwrap()[0];
2354
2355 assert_eq!(issue["type"], "issue");
2356 assert_eq!(issue["check_name"], "fallow/unused-file");
2357 assert!(issue["description"].is_string());
2358 assert!(issue["categories"].is_array());
2359 assert!(issue["severity"].is_string());
2360 assert!(issue["fingerprint"].is_string());
2361 assert!(issue["location"].is_object());
2362 assert!(issue["location"]["path"].is_string());
2363 assert!(issue["location"]["lines"].is_object());
2364 }
2365
2366 #[test]
2367 fn codeclimate_unused_file_severity_follows_rules() {
2368 let root = PathBuf::from("/project");
2369 let mut results = AnalysisResults::default();
2370 results
2371 .unused_files
2372 .push(UnusedFileFinding::with_actions(UnusedFile {
2373 path: root.join("src/dead.ts"),
2374 }));
2375
2376 let rules = RulesConfig::default();
2377 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2378 assert_eq!(output[0]["severity"], "major");
2379
2380 let rules = RulesConfig {
2381 unused_files: Severity::Warn,
2382 ..RulesConfig::default()
2383 };
2384 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2385 assert_eq!(output[0]["severity"], "minor");
2386 }
2387
2388 #[test]
2389 fn codeclimate_unused_export_has_line_number() {
2390 let root = PathBuf::from("/project");
2391 let mut results = AnalysisResults::default();
2392 results
2393 .unused_exports
2394 .push(UnusedExportFinding::with_actions(UnusedExport {
2395 path: root.join("src/utils.ts"),
2396 export_name: "helperFn".to_string(),
2397 is_type_only: false,
2398 line: 10,
2399 col: 4,
2400 span_start: 120,
2401 is_re_export: false,
2402 }));
2403 let rules = RulesConfig::default();
2404 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2405 let issue = &output[0];
2406 assert_eq!(issue["location"]["lines"]["begin"], 10);
2407 }
2408
2409 #[test]
2410 fn codeclimate_unused_file_line_defaults_to_1() {
2411 let root = PathBuf::from("/project");
2412 let mut results = AnalysisResults::default();
2413 results
2414 .unused_files
2415 .push(UnusedFileFinding::with_actions(UnusedFile {
2416 path: root.join("src/dead.ts"),
2417 }));
2418 let rules = RulesConfig::default();
2419 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2420 let issue = &output[0];
2421 assert_eq!(issue["location"]["lines"]["begin"], 1);
2422 }
2423
2424 #[test]
2425 fn codeclimate_paths_are_relative() {
2426 let root = PathBuf::from("/project");
2427 let mut results = AnalysisResults::default();
2428 results
2429 .unused_files
2430 .push(UnusedFileFinding::with_actions(UnusedFile {
2431 path: root.join("src/deep/nested/file.ts"),
2432 }));
2433 let rules = RulesConfig::default();
2434 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2435 let path = output[0]["location"]["path"].as_str().unwrap();
2436 assert_eq!(path, "src/deep/nested/file.ts");
2437 assert!(!path.starts_with("/project"));
2438 }
2439
2440 #[test]
2441 fn codeclimate_re_export_label_in_description() {
2442 let root = PathBuf::from("/project");
2443 let mut results = AnalysisResults::default();
2444 results
2445 .unused_exports
2446 .push(UnusedExportFinding::with_actions(UnusedExport {
2447 path: root.join("src/index.ts"),
2448 export_name: "reExported".to_string(),
2449 is_type_only: false,
2450 line: 1,
2451 col: 0,
2452 span_start: 0,
2453 is_re_export: true,
2454 }));
2455 let rules = RulesConfig::default();
2456 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2457 let desc = output[0]["description"].as_str().unwrap();
2458 assert!(desc.contains("Re-export"));
2459 }
2460
2461 #[test]
2462 fn codeclimate_unlisted_dep_one_issue_per_import_site() {
2463 let root = PathBuf::from("/project");
2464 let mut results = AnalysisResults::default();
2465 results
2466 .unlisted_dependencies
2467 .push(UnlistedDependencyFinding::with_actions(
2468 UnlistedDependency {
2469 package_name: "chalk".to_string(),
2470 imported_from: vec![
2471 ImportSite {
2472 path: root.join("src/a.ts"),
2473 line: 1,
2474 col: 0,
2475 },
2476 ImportSite {
2477 path: root.join("src/b.ts"),
2478 line: 5,
2479 col: 0,
2480 },
2481 ],
2482 },
2483 ));
2484 let rules = RulesConfig::default();
2485 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2486 let arr = output.as_array().unwrap();
2487 assert_eq!(arr.len(), 2);
2488 assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
2489 assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
2490 }
2491
2492 #[test]
2493 fn codeclimate_duplicate_export_one_issue_per_location() {
2494 let root = PathBuf::from("/project");
2495 let mut results = AnalysisResults::default();
2496 results
2497 .duplicate_exports
2498 .push(DuplicateExportFinding::with_actions(DuplicateExport {
2499 export_name: "Config".to_string(),
2500 locations: vec![
2501 DuplicateLocation {
2502 path: root.join("src/a.ts"),
2503 line: 10,
2504 col: 0,
2505 },
2506 DuplicateLocation {
2507 path: root.join("src/b.ts"),
2508 line: 20,
2509 col: 0,
2510 },
2511 DuplicateLocation {
2512 path: root.join("src/c.ts"),
2513 line: 30,
2514 col: 0,
2515 },
2516 ],
2517 }));
2518 let rules = RulesConfig::default();
2519 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2520 let arr = output.as_array().unwrap();
2521 assert_eq!(arr.len(), 3);
2522 }
2523
2524 #[test]
2525 fn codeclimate_circular_dep_emits_chain_in_description() {
2526 let root = PathBuf::from("/project");
2527 let mut results = AnalysisResults::default();
2528 results
2529 .circular_dependencies
2530 .push(CircularDependencyFinding::with_actions(
2531 CircularDependency {
2532 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2533 length: 2,
2534 line: 3,
2535 col: 0,
2536 edges: Vec::new(),
2537 is_cross_package: false,
2538 },
2539 ));
2540 let rules = RulesConfig::default();
2541 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2542 let desc = output[0]["description"].as_str().unwrap();
2543 assert!(desc.contains("Circular dependency"));
2544 assert!(desc.contains("src/a.ts"));
2545 assert!(desc.contains("src/b.ts"));
2546 }
2547
2548 #[test]
2549 fn codeclimate_fingerprints_are_deterministic() {
2550 let root = PathBuf::from("/project");
2551 let results = sample_results(&root);
2552 let rules = RulesConfig::default();
2553 let output1 = issues_to_value(&build_codeclimate(&results, &root, &rules));
2554 let output2 = issues_to_value(&build_codeclimate(&results, &root, &rules));
2555
2556 let fps1: Vec<&str> = output1
2557 .as_array()
2558 .unwrap()
2559 .iter()
2560 .map(|i| i["fingerprint"].as_str().unwrap())
2561 .collect();
2562 let fps2: Vec<&str> = output2
2563 .as_array()
2564 .unwrap()
2565 .iter()
2566 .map(|i| i["fingerprint"].as_str().unwrap())
2567 .collect();
2568 assert_eq!(fps1, fps2);
2569 }
2570
2571 #[test]
2572 fn codeclimate_fingerprints_are_unique() {
2573 let root = PathBuf::from("/project");
2574 let results = sample_results(&root);
2575 let rules = RulesConfig::default();
2576 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2577
2578 let mut fps: Vec<&str> = output
2579 .as_array()
2580 .unwrap()
2581 .iter()
2582 .map(|i| i["fingerprint"].as_str().unwrap())
2583 .collect();
2584 let original_len = fps.len();
2585 fps.sort_unstable();
2586 fps.dedup();
2587 assert_eq!(fps.len(), original_len, "fingerprints should be unique");
2588 }
2589
2590 #[test]
2591 fn codeclimate_type_only_dep_has_correct_check_name() {
2592 let root = PathBuf::from("/project");
2593 let mut results = AnalysisResults::default();
2594 results
2595 .type_only_dependencies
2596 .push(TypeOnlyDependencyFinding::with_actions(
2597 TypeOnlyDependency {
2598 package_name: "zod".to_string(),
2599 path: root.join("package.json"),
2600 line: 8,
2601 },
2602 ));
2603 let rules = RulesConfig::default();
2604 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2605 assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
2606 let desc = output[0]["description"].as_str().unwrap();
2607 assert!(desc.contains("zod"));
2608 assert!(desc.contains("type-only"));
2609 }
2610
2611 #[test]
2612 fn codeclimate_dep_with_zero_line_omits_line_number() {
2613 let root = PathBuf::from("/project");
2614 let mut results = AnalysisResults::default();
2615 results
2616 .unused_dependencies
2617 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2618 package_name: "lodash".to_string(),
2619 location: DependencyLocation::Dependencies,
2620 path: root.join("package.json"),
2621 line: 0,
2622 used_in_workspaces: Vec::new(),
2623 }));
2624 let rules = RulesConfig::default();
2625 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2626 assert_eq!(output[0]["location"]["lines"]["begin"], 1);
2627 }
2628
2629 #[test]
2630 fn fingerprint_hash_different_inputs_differ() {
2631 let h1 = fingerprint_hash(&["a", "b"]);
2632 let h2 = fingerprint_hash(&["a", "c"]);
2633 assert_ne!(h1, h2);
2634 }
2635
2636 #[test]
2637 fn fingerprint_hash_order_matters() {
2638 let h1 = fingerprint_hash(&["a", "b"]);
2639 let h2 = fingerprint_hash(&["b", "a"]);
2640 assert_ne!(h1, h2);
2641 }
2642
2643 #[test]
2644 fn fingerprint_hash_separator_prevents_collision() {
2645 let h1 = fingerprint_hash(&["ab", "c"]);
2646 let h2 = fingerprint_hash(&["a", "bc"]);
2647 assert_ne!(h1, h2);
2648 }
2649
2650 #[test]
2651 fn fingerprint_hash_is_16_hex_chars() {
2652 let h = fingerprint_hash(&["test"]);
2653 assert_eq!(h.len(), 16);
2654 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
2655 }
2656
2657 #[test]
2658 fn severity_error_maps_to_major() {
2659 assert_eq!(
2660 severity_to_codeclimate(Severity::Error),
2661 CodeClimateSeverity::Major
2662 );
2663 }
2664
2665 #[test]
2666 fn severity_warn_maps_to_minor() {
2667 assert_eq!(
2668 severity_to_codeclimate(Severity::Warn),
2669 CodeClimateSeverity::Minor
2670 );
2671 }
2672
2673 #[test]
2674 #[should_panic(expected = "internal error: entered unreachable code")]
2675 fn severity_off_is_unreachable() {
2676 let _ = severity_to_codeclimate(Severity::Off);
2677 }
2678
2679 #[test]
2691 fn build_codeclimate_with_off_severity_and_empty_findings_does_not_panic() {
2692 let root = PathBuf::from("/project");
2693 let results = AnalysisResults::default();
2694 let rules = RulesConfig {
2695 unused_dependencies: Severity::Off,
2696 unused_dev_dependencies: Severity::Off,
2697 unused_optional_dependencies: Severity::Off,
2698 unused_exports: Severity::Off,
2699 unused_types: Severity::Off,
2700 unused_enum_members: Severity::Off,
2701 unused_class_members: Severity::Off,
2702 ..RulesConfig::default()
2703 };
2704 let issues = build_codeclimate(&results, &root, &rules);
2705 assert!(issues.is_empty());
2706 }
2707
2708 #[test]
2709 fn health_severity_zero_threshold_returns_minor() {
2710 assert_eq!(health_severity(100, 0), "minor");
2711 }
2712
2713 #[test]
2714 fn health_severity_at_threshold_returns_minor() {
2715 assert_eq!(health_severity(10, 10), "minor");
2716 }
2717
2718 #[test]
2719 fn health_severity_1_5x_threshold_returns_minor() {
2720 assert_eq!(health_severity(15, 10), "minor");
2721 }
2722
2723 #[test]
2724 fn health_severity_above_1_5x_returns_major() {
2725 assert_eq!(health_severity(16, 10), "major");
2726 }
2727
2728 #[test]
2729 fn health_severity_at_2_5x_returns_major() {
2730 assert_eq!(health_severity(25, 10), "major");
2731 }
2732
2733 #[test]
2734 fn health_severity_above_2_5x_returns_critical() {
2735 assert_eq!(health_severity(26, 10), "critical");
2736 }
2737
2738 #[test]
2739 fn health_codeclimate_includes_coverage_gaps() {
2740 use crate::health_types::*;
2741
2742 let root = PathBuf::from("/project");
2743 let report = HealthReport {
2744 summary: HealthSummary {
2745 files_analyzed: 10,
2746 functions_analyzed: 50,
2747 ..Default::default()
2748 },
2749 coverage_gaps: Some(CoverageGaps {
2750 summary: CoverageGapSummary {
2751 runtime_files: 2,
2752 covered_files: 0,
2753 file_coverage_pct: 0.0,
2754 untested_files: 1,
2755 untested_exports: 1,
2756 },
2757 files: vec![UntestedFileFinding::with_actions(
2758 UntestedFile {
2759 path: root.join("src/app.ts"),
2760 value_export_count: 2,
2761 },
2762 &root,
2763 )],
2764 exports: vec![UntestedExportFinding::with_actions(
2765 UntestedExport {
2766 path: root.join("src/app.ts"),
2767 export_name: "loader".into(),
2768 line: 12,
2769 col: 4,
2770 },
2771 &root,
2772 )],
2773 }),
2774 ..Default::default()
2775 };
2776
2777 let output = issues_to_value(&build_health_codeclimate(&report, &root));
2778 let issues = output.as_array().unwrap();
2779 assert_eq!(issues.len(), 2);
2780 assert_eq!(issues[0]["check_name"], "fallow/untested-file");
2781 assert_eq!(issues[0]["categories"][0], "Coverage");
2782 assert_eq!(issues[0]["location"]["path"], "src/app.ts");
2783 assert_eq!(issues[1]["check_name"], "fallow/untested-export");
2784 assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
2785 assert!(
2786 issues[1]["description"]
2787 .as_str()
2788 .unwrap()
2789 .contains("loader")
2790 );
2791 }
2792
2793 #[test]
2794 fn health_codeclimate_includes_coverage_intelligence_issue() {
2795 use crate::health_types::{
2796 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2797 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2798 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2799 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2800 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2801 HealthReport, HealthSummary,
2802 };
2803
2804 let root = PathBuf::from("/project");
2805 let report = HealthReport {
2806 summary: HealthSummary {
2807 files_analyzed: 10,
2808 functions_analyzed: 50,
2809 ..Default::default()
2810 },
2811 coverage_intelligence: Some(CoverageIntelligenceReport {
2812 schema_version: CoverageIntelligenceSchemaVersion::V1,
2813 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2814 summary: CoverageIntelligenceSummary {
2815 findings: 1,
2816 high_confidence_deletes: 1,
2817 ..Default::default()
2818 },
2819 findings: vec![CoverageIntelligenceFinding {
2820 id: "fallow:coverage-intel:abc123".to_owned(),
2821 path: root.join("src/dead.ts"),
2822 identity: Some("deadPath".to_owned()),
2823 line: 9,
2824 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2825 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2826 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2827 confidence: CoverageIntelligenceConfidence::High,
2828 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2829 evidence: CoverageIntelligenceEvidence {
2830 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2831 ..Default::default()
2832 },
2833 actions: vec![CoverageIntelligenceAction {
2834 kind: "delete-after-confirming-owner".to_owned(),
2835 description: "Confirm ownership".to_owned(),
2836 auto_fixable: false,
2837 }],
2838 }],
2839 }),
2840 ..Default::default()
2841 };
2842
2843 let output = issues_to_value(&build_health_codeclimate(&report, &root));
2844 let issues = output.as_array().unwrap();
2845 assert_eq!(issues.len(), 1);
2846 assert_eq!(
2847 issues[0]["check_name"],
2848 "fallow/coverage-intelligence-delete"
2849 );
2850 assert!(!issues[0]["fingerprint"].as_str().unwrap().is_empty());
2851 assert_eq!(issues[0]["location"]["path"], "src/dead.ts");
2852 assert!(
2853 issues[0]["description"]
2854 .as_str()
2855 .unwrap()
2856 .contains("deadPath")
2857 );
2858 }
2859
2860 #[test]
2861 fn health_codeclimate_skips_summary_only_coverage_intelligence() {
2862 use crate::health_types::{
2863 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2864 CoverageIntelligenceSummary, CoverageIntelligenceVerdict, HealthReport,
2865 };
2866
2867 let root = PathBuf::from("/project");
2868 let report = HealthReport {
2869 coverage_intelligence: Some(CoverageIntelligenceReport {
2870 schema_version: CoverageIntelligenceSchemaVersion::V1,
2871 verdict: CoverageIntelligenceVerdict::Clean,
2872 summary: CoverageIntelligenceSummary {
2873 skipped_ambiguous_matches: 2,
2874 ..Default::default()
2875 },
2876 findings: vec![],
2877 }),
2878 ..Default::default()
2879 };
2880
2881 let issues = build_health_codeclimate(&report, &root);
2882 assert!(issues.is_empty());
2883 }
2884
2885 #[test]
2886 fn health_codeclimate_crap_only_uses_crap_check_name() {
2887 use crate::health_types::{
2888 ComplexityViolation, FindingSeverity, HealthReport, HealthSummary,
2889 };
2890 let root = PathBuf::from("/project");
2891 let report = HealthReport {
2892 findings: vec![
2893 ComplexityViolation {
2894 path: root.join("src/untested.ts"),
2895 name: "risky".to_string(),
2896 line: 7,
2897 col: 0,
2898 cyclomatic: 10,
2899 cognitive: 10,
2900 line_count: 20,
2901 param_count: 1,
2902 react_hook_count: 0,
2903 react_jsx_max_depth: 0,
2904 react_prop_count: 0,
2905 react_hook_profile: None,
2906 exceeded: crate::health_types::ExceededThreshold::Crap,
2907 severity: FindingSeverity::High,
2908 crap: Some(60.0),
2909 coverage_pct: Some(25.0),
2910 coverage_tier: None,
2911 coverage_source: None,
2912 inherited_from: None,
2913 component_rollup: None,
2914 contributions: Vec::new(),
2915 effective_thresholds: None,
2916 threshold_source: None,
2917 }
2918 .into(),
2919 ],
2920 summary: HealthSummary {
2921 functions_analyzed: 10,
2922 functions_above_threshold: 1,
2923 ..Default::default()
2924 },
2925 ..Default::default()
2926 };
2927 let json = issues_to_value(&build_health_codeclimate(&report, &root));
2928 let issues = json.as_array().unwrap();
2929 assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
2930 assert_eq!(issues[0]["severity"], "major");
2931 let description = issues[0]["description"].as_str().unwrap();
2932 assert!(description.contains("CRAP score"), "desc: {description}");
2933 assert!(description.contains("coverage 25%"), "desc: {description}");
2934 }
2935}