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_invalid_client_export_issues(
877 issues: &mut Vec<CodeClimateIssue>,
878 findings: &[fallow_types::output_dead_code::InvalidClientExportFinding],
879 root: &Path,
880 severity: Severity,
881) {
882 if findings.is_empty() {
883 return;
884 }
885 let level = severity_to_codeclimate(severity);
886 for entry in findings {
887 let e = &entry.export;
888 let path = cc_path(&e.path, root);
889 let fp = fingerprint_hash(&["fallow/invalid-client-export", &path, &e.export_name]);
890 let line = if e.line > 0 { Some(e.line) } else { None };
891 let message = format!(
892 "Export `{}` is not allowed in a \"{}\" file (Next.js server-only / route-config name)",
893 e.export_name, e.directive
894 );
895 issues.push(cc_issue(
896 "fallow/invalid-client-export",
897 &message,
898 level,
899 "Bug Risk",
900 &path,
901 line,
902 &fp,
903 ));
904 }
905}
906
907fn push_mixed_client_server_barrel_issues(
908 issues: &mut Vec<CodeClimateIssue>,
909 findings: &[fallow_types::output_dead_code::MixedClientServerBarrelFinding],
910 root: &Path,
911 severity: Severity,
912) {
913 if findings.is_empty() {
914 return;
915 }
916 let level = severity_to_codeclimate(severity);
917 for entry in findings {
918 let b = &entry.barrel;
919 let path = cc_path(&b.path, root);
920 let fp = fingerprint_hash(&[
921 "fallow/mixed-client-server-barrel",
922 &path,
923 &b.client_origin,
924 &b.server_origin,
925 ]);
926 let line = if b.line > 0 { Some(b.line) } else { None };
927 let message = format!(
928 "Barrel re-exports both a \"use client\" module (`{}`) and a server-only module (`{}`); one import drags the other's directive across the boundary",
929 b.client_origin, b.server_origin
930 );
931 issues.push(cc_issue(
932 "fallow/mixed-client-server-barrel",
933 &message,
934 level,
935 "Bug Risk",
936 &path,
937 line,
938 &fp,
939 ));
940 }
941}
942
943fn push_misplaced_directive_issues(
944 issues: &mut Vec<CodeClimateIssue>,
945 findings: &[fallow_types::output_dead_code::MisplacedDirectiveFinding],
946 root: &Path,
947 severity: Severity,
948) {
949 if findings.is_empty() {
950 return;
951 }
952 let level = severity_to_codeclimate(severity);
953 for entry in findings {
954 let d = &entry.directive_site;
955 let path = cc_path(&d.path, root);
956 let fp = fingerprint_hash(&[
957 "fallow/misplaced-directive",
958 &path,
959 &d.line.to_string(),
960 &d.directive,
961 ]);
962 let line = if d.line > 0 { Some(d.line) } else { None };
963 let message = format!(
964 "Directive `\"{}\"` is not in the leading position, so the RSC bundler ignores it; move it to the top of the file",
965 d.directive
966 );
967 issues.push(cc_issue(
968 "fallow/misplaced-directive",
969 &message,
970 level,
971 "Bug Risk",
972 &path,
973 line,
974 &fp,
975 ));
976 }
977}
978
979fn push_unprovided_inject_issues(
980 issues: &mut Vec<CodeClimateIssue>,
981 findings: &[fallow_types::output_dead_code::UnprovidedInjectFinding],
982 root: &Path,
983 severity: Severity,
984) {
985 if findings.is_empty() {
986 return;
987 }
988 let level = severity_to_codeclimate(severity);
989 for entry in findings {
990 let i = &entry.inject;
991 let path = cc_path(&i.path, root);
992 let fp = fingerprint_hash(&[
993 "fallow/unprovided-inject",
994 &path,
995 &i.line.to_string(),
996 &i.key_name,
997 ]);
998 let line = if i.line > 0 { Some(i.line) } else { None };
999 let message = format!(
1000 "inject(`{}`) has no matching provide(`{}`) in this project; at runtime it returns undefined (provide the key or remove this inject)",
1001 i.key_name, i.key_name
1002 );
1003 issues.push(cc_issue(
1004 "fallow/unprovided-inject",
1005 &message,
1006 level,
1007 "Bug Risk",
1008 &path,
1009 line,
1010 &fp,
1011 ));
1012 }
1013}
1014
1015fn push_unrendered_component_issues(
1016 issues: &mut Vec<CodeClimateIssue>,
1017 findings: &[fallow_types::output_dead_code::UnrenderedComponentFinding],
1018 root: &Path,
1019 severity: Severity,
1020) {
1021 if findings.is_empty() {
1022 return;
1023 }
1024 let level = severity_to_codeclimate(severity);
1025 for entry in findings {
1026 let c = &entry.component;
1027 let path = cc_path(&c.path, root);
1028 let fp = fingerprint_hash(&[
1029 "fallow/unrendered-component",
1030 &path,
1031 &c.line.to_string(),
1032 &c.component_name,
1033 ]);
1034 let line = if c.line > 0 { Some(c.line) } else { None };
1035 let message = format!(
1036 "component `{}` is reachable but rendered nowhere in this project (render it somewhere or remove it)",
1037 c.component_name
1038 );
1039 issues.push(cc_issue(
1040 "fallow/unrendered-component",
1041 &message,
1042 level,
1043 "Bug Risk",
1044 &path,
1045 line,
1046 &fp,
1047 ));
1048 }
1049}
1050
1051fn push_unused_component_prop_issues(
1052 issues: &mut Vec<CodeClimateIssue>,
1053 findings: &[fallow_types::output_dead_code::UnusedComponentPropFinding],
1054 root: &Path,
1055 severity: Severity,
1056) {
1057 if findings.is_empty() {
1058 return;
1059 }
1060 let level = severity_to_codeclimate(severity);
1061 for entry in findings {
1062 let p = &entry.prop;
1063 let path = cc_path(&p.path, root);
1064 let fp = fingerprint_hash(&[
1065 "fallow/unused-component-prop",
1066 &path,
1067 &p.line.to_string(),
1068 &p.prop_name,
1069 ]);
1070 let line = if p.line > 0 { Some(p.line) } else { None };
1071 let message = format!(
1072 "prop `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1073 p.prop_name, p.component_name
1074 );
1075 issues.push(cc_issue(
1076 "fallow/unused-component-prop",
1077 &message,
1078 level,
1079 "Bug Risk",
1080 &path,
1081 line,
1082 &fp,
1083 ));
1084 }
1085}
1086
1087fn push_unused_component_emit_issues(
1088 issues: &mut Vec<CodeClimateIssue>,
1089 findings: &[fallow_types::output_dead_code::UnusedComponentEmitFinding],
1090 root: &Path,
1091 severity: Severity,
1092) {
1093 if findings.is_empty() {
1094 return;
1095 }
1096 let level = severity_to_codeclimate(severity);
1097 for entry in findings {
1098 let e = &entry.emit;
1099 let path = cc_path(&e.path, root);
1100 let fp = fingerprint_hash(&[
1101 "fallow/unused-component-emit",
1102 &path,
1103 &e.line.to_string(),
1104 &e.emit_name,
1105 ]);
1106 let line = if e.line > 0 { Some(e.line) } else { None };
1107 let message = format!(
1108 "emit `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1109 e.emit_name, e.component_name
1110 );
1111 issues.push(cc_issue(
1112 "fallow/unused-component-emit",
1113 &message,
1114 level,
1115 "Bug Risk",
1116 &path,
1117 line,
1118 &fp,
1119 ));
1120 }
1121}
1122
1123fn push_unused_svelte_event_issues(
1124 issues: &mut Vec<CodeClimateIssue>,
1125 findings: &[fallow_types::output_dead_code::UnusedSvelteEventFinding],
1126 root: &Path,
1127 severity: Severity,
1128) {
1129 if findings.is_empty() {
1130 return;
1131 }
1132 let level = severity_to_codeclimate(severity);
1133 for entry in findings {
1134 let e = &entry.event;
1135 let path = cc_path(&e.path, root);
1136 let fp = fingerprint_hash(&[
1137 "fallow/unused-svelte-event",
1138 &path,
1139 &e.line.to_string(),
1140 &e.event_name,
1141 ]);
1142 let line = if e.line > 0 { Some(e.line) } else { None };
1143 let message = format!(
1144 "event `{}` is dispatched by component `{}` but listened to nowhere in the project (remove it or listen for it)",
1145 e.event_name, e.component_name
1146 );
1147 issues.push(cc_issue(
1148 "fallow/unused-svelte-event",
1149 &message,
1150 level,
1151 "Bug Risk",
1152 &path,
1153 line,
1154 &fp,
1155 ));
1156 }
1157}
1158
1159fn push_unused_component_input_issues(
1160 issues: &mut Vec<CodeClimateIssue>,
1161 findings: &[fallow_types::output_dead_code::UnusedComponentInputFinding],
1162 root: &Path,
1163 severity: Severity,
1164) {
1165 if findings.is_empty() {
1166 return;
1167 }
1168 let level = severity_to_codeclimate(severity);
1169 for entry in findings {
1170 let i = &entry.input;
1171 let path = cc_path(&i.path, root);
1172 let fp = fingerprint_hash(&[
1173 "fallow/unused-component-input",
1174 &path,
1175 &i.line.to_string(),
1176 &i.input_name,
1177 ]);
1178 let line = if i.line > 0 { Some(i.line) } else { None };
1179 let message = format!(
1180 "input `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1181 i.input_name, i.component_name
1182 );
1183 issues.push(cc_issue(
1184 "fallow/unused-component-input",
1185 &message,
1186 level,
1187 "Bug Risk",
1188 &path,
1189 line,
1190 &fp,
1191 ));
1192 }
1193}
1194
1195fn push_unused_component_output_issues(
1196 issues: &mut Vec<CodeClimateIssue>,
1197 findings: &[fallow_types::output_dead_code::UnusedComponentOutputFinding],
1198 root: &Path,
1199 severity: Severity,
1200) {
1201 if findings.is_empty() {
1202 return;
1203 }
1204 let level = severity_to_codeclimate(severity);
1205 for entry in findings {
1206 let o = &entry.output;
1207 let path = cc_path(&o.path, root);
1208 let fp = fingerprint_hash(&[
1209 "fallow/unused-component-output",
1210 &path,
1211 &o.line.to_string(),
1212 &o.output_name,
1213 ]);
1214 let line = if o.line > 0 { Some(o.line) } else { None };
1215 let message = format!(
1216 "output `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1217 o.output_name, o.component_name
1218 );
1219 issues.push(cc_issue(
1220 "fallow/unused-component-output",
1221 &message,
1222 level,
1223 "Bug Risk",
1224 &path,
1225 line,
1226 &fp,
1227 ));
1228 }
1229}
1230
1231fn push_unused_server_action_issues(
1232 issues: &mut Vec<CodeClimateIssue>,
1233 findings: &[fallow_types::output_dead_code::UnusedServerActionFinding],
1234 root: &Path,
1235 severity: Severity,
1236) {
1237 if findings.is_empty() {
1238 return;
1239 }
1240 let level = severity_to_codeclimate(severity);
1241 for entry in findings {
1242 let a = &entry.action;
1243 let path = cc_path(&a.path, root);
1244 let fp = fingerprint_hash(&[
1245 "fallow/unused-server-action",
1246 &path,
1247 &a.line.to_string(),
1248 &a.action_name,
1249 ]);
1250 let line = if a.line > 0 { Some(a.line) } else { None };
1251 let message = format!(
1252 "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)",
1253 a.action_name
1254 );
1255 issues.push(cc_issue(
1256 "fallow/unused-server-action",
1257 &message,
1258 level,
1259 "Bug Risk",
1260 &path,
1261 line,
1262 &fp,
1263 ));
1264 }
1265}
1266
1267fn push_unused_load_data_key_issues(
1268 issues: &mut Vec<CodeClimateIssue>,
1269 findings: &[fallow_types::output_dead_code::UnusedLoadDataKeyFinding],
1270 root: &Path,
1271 severity: Severity,
1272) {
1273 if findings.is_empty() {
1274 return;
1275 }
1276 let level = severity_to_codeclimate(severity);
1277 for entry in findings {
1278 let k = &entry.key;
1279 let path = cc_path(&k.path, root);
1280 let fp = fingerprint_hash(&[
1281 "fallow/unused-load-data-key",
1282 &path,
1283 &k.line.to_string(),
1284 &k.key_name,
1285 ]);
1286 let line = if k.line > 0 { Some(k.line) } else { None };
1287 let message = format!(
1288 "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",
1289 k.key_name
1290 );
1291 issues.push(cc_issue(
1292 "fallow/unused-load-data-key",
1293 &message,
1294 level,
1295 "Bug Risk",
1296 &path,
1297 line,
1298 &fp,
1299 ));
1300 }
1301}
1302
1303fn push_route_collision_issues(
1304 issues: &mut Vec<CodeClimateIssue>,
1305 findings: &[fallow_types::output_dead_code::RouteCollisionFinding],
1306 root: &Path,
1307 severity: Severity,
1308) {
1309 if findings.is_empty() {
1310 return;
1311 }
1312 let level = severity_to_codeclimate(severity);
1313 for entry in findings {
1314 let c = &entry.collision;
1315 let path = cc_path(&c.path, root);
1316 let fp = fingerprint_hash(&["fallow/route-collision", &path, &c.url]);
1317 let line = if c.line > 0 { Some(c.line) } else { None };
1318 let message = format!(
1319 "Route file resolves to `{}`, also owned by {} other file(s); Next.js fails the build because a URL can have only one owner",
1320 c.url,
1321 c.conflicting_paths.len()
1322 );
1323 issues.push(cc_issue(
1324 "fallow/route-collision",
1325 &message,
1326 level,
1327 "Bug Risk",
1328 &path,
1329 line,
1330 &fp,
1331 ));
1332 }
1333}
1334
1335fn push_dynamic_segment_name_conflict_issues(
1336 issues: &mut Vec<CodeClimateIssue>,
1337 findings: &[fallow_types::output_dead_code::DynamicSegmentNameConflictFinding],
1338 root: &Path,
1339 severity: Severity,
1340) {
1341 if findings.is_empty() {
1342 return;
1343 }
1344 let level = severity_to_codeclimate(severity);
1345 for entry in findings {
1346 let c = &entry.conflict;
1347 let path = cc_path(&c.path, root);
1348 let fp = fingerprint_hash(&["fallow/dynamic-segment-name-conflict", &path, &c.position]);
1349 let line = if c.line > 0 { Some(c.line) } else { None };
1350 let message = format!(
1351 "Dynamic segments at `{}` use different slug names ({}); Next.js requires one consistent name per dynamic path",
1352 c.position,
1353 c.conflicting_segments.join(", ")
1354 );
1355 issues.push(cc_issue(
1356 "fallow/dynamic-segment-name-conflict",
1357 &message,
1358 level,
1359 "Bug Risk",
1360 &path,
1361 line,
1362 &fp,
1363 ));
1364 }
1365}
1366
1367fn push_stale_suppression_issues(
1368 issues: &mut Vec<CodeClimateIssue>,
1369 suppressions: &[fallow_core::results::StaleSuppression],
1370 root: &Path,
1371 severity: Severity,
1372) {
1373 if suppressions.is_empty() {
1374 return;
1375 }
1376 let level = severity_to_codeclimate(severity);
1377 for s in suppressions {
1378 let path = cc_path(&s.path, root);
1379 let line_str = s.line.to_string();
1380 let fp = fingerprint_hash(&["fallow/stale-suppression", &path, &line_str]);
1381 issues.push(cc_issue(
1382 "fallow/stale-suppression",
1383 &s.display_message(),
1384 level,
1385 "Bug Risk",
1386 &path,
1387 Some(s.line),
1388 &fp,
1389 ));
1390 }
1391}
1392
1393fn push_unused_catalog_entry_issues(
1394 issues: &mut Vec<CodeClimateIssue>,
1395 entries: &[fallow_core::results::UnusedCatalogEntryFinding],
1396 root: &Path,
1397 severity: Severity,
1398) {
1399 if entries.is_empty() {
1400 return;
1401 }
1402 let level = severity_to_codeclimate(severity);
1403 for entry in entries {
1404 let entry = &entry.entry;
1405 let path = cc_path(&entry.path, root);
1406 let line_str = entry.line.to_string();
1407 let fp = fingerprint_hash(&[
1408 "fallow/unused-catalog-entry",
1409 &path,
1410 &line_str,
1411 &entry.catalog_name,
1412 &entry.entry_name,
1413 ]);
1414 let description = if entry.catalog_name == "default" {
1415 format!(
1416 "Catalog entry '{}' is not referenced by any workspace package",
1417 entry.entry_name
1418 )
1419 } else {
1420 format!(
1421 "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
1422 entry.entry_name, entry.catalog_name
1423 )
1424 };
1425 issues.push(cc_issue(
1426 "fallow/unused-catalog-entry",
1427 &description,
1428 level,
1429 "Bug Risk",
1430 &path,
1431 Some(entry.line),
1432 &fp,
1433 ));
1434 }
1435}
1436
1437fn push_unresolved_catalog_reference_issues(
1438 issues: &mut Vec<CodeClimateIssue>,
1439 findings: &[fallow_core::results::UnresolvedCatalogReferenceFinding],
1440 root: &Path,
1441 severity: Severity,
1442) {
1443 if findings.is_empty() {
1444 return;
1445 }
1446 let level = severity_to_codeclimate(severity);
1447 for finding in findings {
1448 let finding = &finding.reference;
1449 let path = cc_path(&finding.path, root);
1450 let line_str = finding.line.to_string();
1451 let fp = fingerprint_hash(&[
1452 "fallow/unresolved-catalog-reference",
1453 &path,
1454 &line_str,
1455 &finding.catalog_name,
1456 &finding.entry_name,
1457 ]);
1458 let catalog_phrase = if finding.catalog_name == "default" {
1459 "the default catalog".to_string()
1460 } else {
1461 format!("catalog '{}'", finding.catalog_name)
1462 };
1463 let mut description = format!(
1464 "Package '{}' is referenced via `catalog:{}` but {} does not declare it; `pnpm install` will fail",
1465 finding.entry_name,
1466 if finding.catalog_name == "default" {
1467 ""
1468 } else {
1469 finding.catalog_name.as_str()
1470 },
1471 catalog_phrase,
1472 );
1473 if !finding.available_in_catalogs.is_empty() {
1474 use std::fmt::Write as _;
1475 let _ = write!(
1476 description,
1477 " (available in: {})",
1478 finding.available_in_catalogs.join(", ")
1479 );
1480 }
1481 issues.push(cc_issue(
1482 "fallow/unresolved-catalog-reference",
1483 &description,
1484 level,
1485 "Bug Risk",
1486 &path,
1487 Some(finding.line),
1488 &fp,
1489 ));
1490 }
1491}
1492
1493fn push_empty_catalog_group_issues(
1494 issues: &mut Vec<CodeClimateIssue>,
1495 groups: &[fallow_core::results::EmptyCatalogGroupFinding],
1496 root: &Path,
1497 severity: Severity,
1498) {
1499 if groups.is_empty() {
1500 return;
1501 }
1502 let level = severity_to_codeclimate(severity);
1503 for group in groups {
1504 let group = &group.group;
1505 let path = cc_path(&group.path, root);
1506 let line_str = group.line.to_string();
1507 let fp = fingerprint_hash(&[
1508 "fallow/empty-catalog-group",
1509 &path,
1510 &line_str,
1511 &group.catalog_name,
1512 ]);
1513 issues.push(cc_issue(
1514 "fallow/empty-catalog-group",
1515 &format!("Catalog group '{}' has no entries", group.catalog_name),
1516 level,
1517 "Bug Risk",
1518 &path,
1519 Some(group.line),
1520 &fp,
1521 ));
1522 }
1523}
1524
1525fn push_unused_dependency_override_issues(
1526 issues: &mut Vec<CodeClimateIssue>,
1527 findings: &[fallow_core::results::UnusedDependencyOverrideFinding],
1528 root: &Path,
1529 severity: Severity,
1530) {
1531 if findings.is_empty() {
1532 return;
1533 }
1534 let level = severity_to_codeclimate(severity);
1535 for finding in findings {
1536 let finding = &finding.entry;
1537 let path = cc_path(&finding.path, root);
1538 let line_str = finding.line.to_string();
1539 let fp = fingerprint_hash(&[
1540 "fallow/unused-dependency-override",
1541 &path,
1542 &line_str,
1543 finding.source.as_label(),
1544 &finding.raw_key,
1545 ]);
1546 let mut description = format!(
1547 "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
1548 finding.raw_key, finding.version_range, finding.target_package,
1549 );
1550 if let Some(hint) = &finding.hint {
1551 use std::fmt::Write as _;
1552 let _ = write!(description, " ({hint})");
1553 }
1554 issues.push(cc_issue(
1555 "fallow/unused-dependency-override",
1556 &description,
1557 level,
1558 "Bug Risk",
1559 &path,
1560 Some(finding.line),
1561 &fp,
1562 ));
1563 }
1564}
1565
1566fn push_misconfigured_dependency_override_issues(
1567 issues: &mut Vec<CodeClimateIssue>,
1568 findings: &[fallow_core::results::MisconfiguredDependencyOverrideFinding],
1569 root: &Path,
1570 severity: Severity,
1571) {
1572 if findings.is_empty() {
1573 return;
1574 }
1575 let level = severity_to_codeclimate(severity);
1576 for finding in findings {
1577 let finding = &finding.entry;
1578 let path = cc_path(&finding.path, root);
1579 let line_str = finding.line.to_string();
1580 let fp = fingerprint_hash(&[
1581 "fallow/misconfigured-dependency-override",
1582 &path,
1583 &line_str,
1584 finding.source.as_label(),
1585 &finding.raw_key,
1586 ]);
1587 let description = format!(
1588 "Override `{}` -> `{}` is malformed: {}",
1589 finding.raw_key,
1590 finding.raw_value,
1591 finding.reason.describe(),
1592 );
1593 issues.push(cc_issue(
1594 "fallow/misconfigured-dependency-override",
1595 &description,
1596 level,
1597 "Bug Risk",
1598 &path,
1599 Some(finding.line),
1600 &fp,
1601 ));
1602 }
1603}
1604
1605#[must_use]
1614#[expect(
1615 clippy::expect_used,
1616 reason = "CodeClimateIssue contains only infallibly serializable fields"
1617)]
1618pub fn issues_to_value(issues: &[CodeClimateIssue]) -> serde_json::Value {
1619 serde_json::to_value(issues).expect("CodeClimateIssue serializes infallibly")
1620}
1621
1622#[must_use]
1629pub fn build_codeclimate(
1630 results: &AnalysisResults,
1631 root: &Path,
1632 rules: &RulesConfig,
1633) -> Vec<CodeClimateIssue> {
1634 CodeClimateBuilder {
1635 issues: Vec::new(),
1636 results,
1637 root,
1638 rules,
1639 }
1640 .build()
1641}
1642
1643struct CodeClimateBuilder<'a> {
1644 issues: Vec<CodeClimateIssue>,
1645 results: &'a AnalysisResults,
1646 root: &'a Path,
1647 rules: &'a RulesConfig,
1648}
1649
1650impl CodeClimateBuilder<'_> {
1651 fn build(mut self) -> Vec<CodeClimateIssue> {
1652 self.push_file_and_export_issues();
1653 self.push_private_type_leak_issues();
1654 self.push_package_dependency_issues();
1655 self.push_type_test_dependency_issues();
1656 self.push_member_issues();
1657 self.push_import_and_duplicate_issues();
1658 self.push_graph_issues();
1659 self.push_boundary_issues();
1660 self.push_suppression_and_catalog_issues();
1661 self.push_override_issues();
1662 self.issues
1663 }
1664
1665 fn push_file_and_export_issues(&mut self) {
1666 push_unused_file_issues(
1667 &mut self.issues,
1668 &self.results.unused_files,
1669 self.root,
1670 self.rules.unused_files,
1671 );
1672 push_unused_export_issues(
1673 &mut self.issues,
1674 self.results.unused_exports.iter().map(|e| &e.export),
1675 self.root,
1676 "fallow/unused-export",
1677 "Export",
1678 "Re-export",
1679 self.rules.unused_exports,
1680 );
1681 push_unused_export_issues(
1682 &mut self.issues,
1683 self.results.unused_types.iter().map(|e| &e.export),
1684 self.root,
1685 "fallow/unused-type",
1686 "Type export",
1687 "Type re-export",
1688 self.rules.unused_types,
1689 );
1690 }
1691
1692 fn push_private_type_leak_issues(&mut self) {
1693 push_private_type_leak_issues(
1694 &mut self.issues,
1695 &self.results.private_type_leaks,
1696 self.root,
1697 self.rules.private_type_leaks,
1698 );
1699 }
1700
1701 fn push_package_dependency_issues(&mut self) {
1702 push_dep_cc_issues(
1703 &mut self.issues,
1704 self.results.unused_dependencies.iter().map(|f| &f.dep),
1705 self.root,
1706 "fallow/unused-dependency",
1707 "dependencies",
1708 self.rules.unused_dependencies,
1709 );
1710 push_dep_cc_issues(
1711 &mut self.issues,
1712 self.results.unused_dev_dependencies.iter().map(|f| &f.dep),
1713 self.root,
1714 "fallow/unused-dev-dependency",
1715 "devDependencies",
1716 self.rules.unused_dev_dependencies,
1717 );
1718 push_dep_cc_issues(
1719 &mut self.issues,
1720 self.results
1721 .unused_optional_dependencies
1722 .iter()
1723 .map(|f| &f.dep),
1724 self.root,
1725 "fallow/unused-optional-dependency",
1726 "optionalDependencies",
1727 self.rules.unused_optional_dependencies,
1728 );
1729 }
1730
1731 fn push_type_test_dependency_issues(&mut self) {
1732 push_type_only_dep_issues(
1733 &mut self.issues,
1734 &self.results.type_only_dependencies,
1735 self.root,
1736 self.rules.type_only_dependencies,
1737 );
1738 push_test_only_dep_issues(
1739 &mut self.issues,
1740 &self.results.test_only_dependencies,
1741 self.root,
1742 self.rules.test_only_dependencies,
1743 );
1744 }
1745
1746 fn push_member_issues(&mut self) {
1747 push_unused_member_issues(
1748 &mut self.issues,
1749 self.results.unused_enum_members.iter().map(|m| &m.member),
1750 self.root,
1751 "fallow/unused-enum-member",
1752 "Enum",
1753 self.rules.unused_enum_members,
1754 );
1755 push_unused_member_issues(
1756 &mut self.issues,
1757 self.results.unused_class_members.iter().map(|m| &m.member),
1758 self.root,
1759 "fallow/unused-class-member",
1760 "Class",
1761 self.rules.unused_class_members,
1762 );
1763 push_unused_member_issues(
1764 &mut self.issues,
1765 self.results.unused_store_members.iter().map(|m| &m.member),
1766 self.root,
1767 "fallow/unused-store-member",
1768 "Store",
1769 self.rules.unused_store_members,
1770 );
1771 }
1772
1773 fn push_import_and_duplicate_issues(&mut self) {
1774 push_unresolved_import_issues(
1775 &mut self.issues,
1776 &self.results.unresolved_imports,
1777 self.root,
1778 self.rules.unresolved_imports,
1779 );
1780 push_unlisted_dep_issues(
1781 &mut self.issues,
1782 &self.results.unlisted_dependencies,
1783 self.root,
1784 self.rules.unlisted_dependencies,
1785 );
1786 push_duplicate_export_issues(
1787 &mut self.issues,
1788 &self.results.duplicate_exports,
1789 self.root,
1790 self.rules.duplicate_exports,
1791 );
1792 }
1793
1794 fn push_graph_issues(&mut self) {
1795 push_circular_dep_issues(
1796 &mut self.issues,
1797 &self.results.circular_dependencies,
1798 self.root,
1799 self.rules.circular_dependencies,
1800 );
1801 push_re_export_cycle_issues(
1802 &mut self.issues,
1803 &self.results.re_export_cycles,
1804 self.root,
1805 self.rules.re_export_cycle,
1806 );
1807 }
1808
1809 fn push_boundary_issues(&mut self) {
1810 self.push_architecture_boundary_issues();
1811 self.push_client_server_boundary_issues();
1812 self.push_component_boundary_issues();
1813 self.push_framework_route_issues();
1814 }
1815
1816 fn push_architecture_boundary_issues(&mut self) {
1817 push_boundary_violation_issues(
1818 &mut self.issues,
1819 &self.results.boundary_violations,
1820 self.root,
1821 self.rules.boundary_violation,
1822 );
1823 push_boundary_coverage_issues(
1824 &mut self.issues,
1825 &self.results.boundary_coverage_violations,
1826 self.root,
1827 self.rules.boundary_violation,
1828 );
1829 push_boundary_call_issues(
1830 &mut self.issues,
1831 &self.results.boundary_call_violations,
1832 self.root,
1833 self.rules.boundary_violation,
1834 );
1835 push_policy_violation_issues(&mut self.issues, &self.results.policy_violations, self.root);
1836 }
1837
1838 fn push_client_server_boundary_issues(&mut self) {
1839 push_invalid_client_export_issues(
1840 &mut self.issues,
1841 &self.results.invalid_client_exports,
1842 self.root,
1843 self.rules.invalid_client_export,
1844 );
1845 push_mixed_client_server_barrel_issues(
1846 &mut self.issues,
1847 &self.results.mixed_client_server_barrels,
1848 self.root,
1849 self.rules.mixed_client_server_barrel,
1850 );
1851 push_misplaced_directive_issues(
1852 &mut self.issues,
1853 &self.results.misplaced_directives,
1854 self.root,
1855 self.rules.misplaced_directive,
1856 );
1857 }
1858
1859 fn push_component_boundary_issues(&mut self) {
1860 push_unprovided_inject_issues(
1861 &mut self.issues,
1862 &self.results.unprovided_injects,
1863 self.root,
1864 self.rules.unprovided_injects,
1865 );
1866 push_unrendered_component_issues(
1867 &mut self.issues,
1868 &self.results.unrendered_components,
1869 self.root,
1870 self.rules.unrendered_components,
1871 );
1872 push_unused_component_prop_issues(
1873 &mut self.issues,
1874 &self.results.unused_component_props,
1875 self.root,
1876 self.rules.unused_component_props,
1877 );
1878 push_unused_component_emit_issues(
1879 &mut self.issues,
1880 &self.results.unused_component_emits,
1881 self.root,
1882 self.rules.unused_component_emits,
1883 );
1884 push_unused_component_input_issues(
1885 &mut self.issues,
1886 &self.results.unused_component_inputs,
1887 self.root,
1888 self.rules.unused_component_inputs,
1889 );
1890 push_unused_component_output_issues(
1891 &mut self.issues,
1892 &self.results.unused_component_outputs,
1893 self.root,
1894 self.rules.unused_component_outputs,
1895 );
1896 push_unused_svelte_event_issues(
1897 &mut self.issues,
1898 &self.results.unused_svelte_events,
1899 self.root,
1900 self.rules.unused_svelte_events,
1901 );
1902 }
1903
1904 fn push_framework_route_issues(&mut self) {
1905 push_unused_server_action_issues(
1906 &mut self.issues,
1907 &self.results.unused_server_actions,
1908 self.root,
1909 self.rules.unused_server_actions,
1910 );
1911 push_unused_load_data_key_issues(
1912 &mut self.issues,
1913 &self.results.unused_load_data_keys,
1914 self.root,
1915 self.rules.unused_load_data_keys,
1916 );
1917 push_route_collision_issues(
1918 &mut self.issues,
1919 &self.results.route_collisions,
1920 self.root,
1921 self.rules.route_collision,
1922 );
1923 push_dynamic_segment_name_conflict_issues(
1924 &mut self.issues,
1925 &self.results.dynamic_segment_name_conflicts,
1926 self.root,
1927 self.rules.dynamic_segment_name_conflict,
1928 );
1929 }
1930
1931 fn push_suppression_and_catalog_issues(&mut self) {
1932 push_stale_suppression_issues(
1933 &mut self.issues,
1934 &self.results.stale_suppressions,
1935 self.root,
1936 self.rules.stale_suppressions,
1937 );
1938 push_unused_catalog_entry_issues(
1939 &mut self.issues,
1940 &self.results.unused_catalog_entries,
1941 self.root,
1942 self.rules.unused_catalog_entries,
1943 );
1944 push_empty_catalog_group_issues(
1945 &mut self.issues,
1946 &self.results.empty_catalog_groups,
1947 self.root,
1948 self.rules.empty_catalog_groups,
1949 );
1950 push_unresolved_catalog_reference_issues(
1951 &mut self.issues,
1952 &self.results.unresolved_catalog_references,
1953 self.root,
1954 self.rules.unresolved_catalog_references,
1955 );
1956 }
1957
1958 fn push_override_issues(&mut self) {
1959 push_unused_dependency_override_issues(
1960 &mut self.issues,
1961 &self.results.unused_dependency_overrides,
1962 self.root,
1963 self.rules.unused_dependency_overrides,
1964 );
1965 push_misconfigured_dependency_override_issues(
1966 &mut self.issues,
1967 &self.results.misconfigured_dependency_overrides,
1968 self.root,
1969 self.rules.misconfigured_dependency_overrides,
1970 );
1971 }
1972}
1973
1974pub(super) fn print_codeclimate(
1976 results: &AnalysisResults,
1977 root: &Path,
1978 rules: &RulesConfig,
1979) -> ExitCode {
1980 let issues = build_codeclimate(results, root, rules);
1981 let value = issues_to_value(&issues);
1982 emit_json(&value, "CodeClimate")
1983}
1984
1985#[expect(
1991 clippy::expect_used,
1992 reason = "grouped CodeClimate entries are JSON objects created by issues_to_value"
1993)]
1994pub(super) fn print_grouped_codeclimate(
1995 results: &AnalysisResults,
1996 root: &Path,
1997 rules: &RulesConfig,
1998 resolver: &OwnershipResolver,
1999) -> ExitCode {
2000 let issues = build_codeclimate(results, root, rules);
2001 let mut value = issues_to_value(&issues);
2002
2003 if let Some(items) = value.as_array_mut() {
2004 for issue in items {
2005 let path = issue
2006 .pointer("/location/path")
2007 .and_then(|v| v.as_str())
2008 .unwrap_or("");
2009 let owner = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
2010 issue
2011 .as_object_mut()
2012 .expect("CodeClimate issue should be an object")
2013 .insert("owner".to_string(), serde_json::Value::String(owner));
2014 }
2015 }
2016
2017 emit_json(&value, "CodeClimate")
2018}
2019
2020#[must_use]
2022pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> Vec<CodeClimateIssue> {
2023 let mut issues = Vec::new();
2024 let ctx = HealthCodeClimateContext {
2025 root,
2026 cyc_t: report.summary.max_cyclomatic_threshold,
2027 cog_t: report.summary.max_cognitive_threshold,
2028 crap_t: report.summary.max_crap_threshold,
2029 };
2030
2031 for finding in &report.findings {
2032 issues.push(ctx.complexity_issue(finding));
2033 }
2034
2035 if let Some(ref production) = report.runtime_coverage {
2036 for finding in &production.findings {
2037 issues.push(ctx.runtime_coverage_issue(finding));
2038 }
2039 }
2040
2041 if let Some(ref intelligence) = report.coverage_intelligence {
2042 for finding in &intelligence.findings {
2043 if let Some(issue) = ctx.coverage_intelligence_issue(finding) {
2044 issues.push(issue);
2045 }
2046 }
2047 }
2048
2049 if let Some(ref gaps) = report.coverage_gaps {
2050 for item in &gaps.files {
2051 issues.push(ctx.untested_file_issue(item));
2052 }
2053
2054 for item in &gaps.exports {
2055 issues.push(ctx.untested_export_issue(item));
2056 }
2057 }
2058
2059 issues
2060}
2061
2062pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
2064 let issues = build_health_codeclimate(report, root);
2065 let value = issues_to_value(&issues);
2066 emit_json(&value, "CodeClimate")
2067}
2068
2069#[expect(
2078 clippy::expect_used,
2079 reason = "grouped health CodeClimate entries are JSON objects created by issues_to_value"
2080)]
2081pub(super) fn print_grouped_health_codeclimate(
2082 report: &HealthReport,
2083 root: &Path,
2084 resolver: &OwnershipResolver,
2085) -> ExitCode {
2086 let issues = build_health_codeclimate(report, root);
2087 let mut value = issues_to_value(&issues);
2088
2089 if let Some(items) = value.as_array_mut() {
2090 for issue in items {
2091 let path = issue
2092 .pointer("/location/path")
2093 .and_then(|v| v.as_str())
2094 .unwrap_or("");
2095 let group = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
2096 issue
2097 .as_object_mut()
2098 .expect("CodeClimate issue should be an object")
2099 .insert("group".to_string(), serde_json::Value::String(group));
2100 }
2101 }
2102
2103 emit_json(&value, "CodeClimate")
2104}
2105
2106#[must_use]
2108#[expect(
2109 clippy::cast_possible_truncation,
2110 reason = "line numbers are bounded by source size"
2111)]
2112pub fn build_duplication_codeclimate(
2113 report: &DuplicationReport,
2114 root: &Path,
2115) -> Vec<CodeClimateIssue> {
2116 let mut issues = Vec::new();
2117
2118 for (i, group) in report.clone_groups.iter().enumerate() {
2119 let token_str = group.token_count.to_string();
2120 let line_count_str = group.line_count.to_string();
2121 let fragment_prefix: String = group
2122 .instances
2123 .first()
2124 .map(|inst| inst.fragment.chars().take(64).collect())
2125 .unwrap_or_default();
2126
2127 for instance in &group.instances {
2128 let path = cc_path(&instance.file, root);
2129 let start_str = instance.start_line.to_string();
2130 let fp = fingerprint_hash(&[
2131 "fallow/code-duplication",
2132 &path,
2133 &start_str,
2134 &token_str,
2135 &line_count_str,
2136 &fragment_prefix,
2137 ]);
2138 issues.push(cc_issue(
2139 "fallow/code-duplication",
2140 &format!(
2141 "Code clone group {} ({} lines, {} instances)",
2142 i + 1,
2143 group.line_count,
2144 group.instances.len()
2145 ),
2146 CodeClimateSeverity::Minor,
2147 "Duplication",
2148 &path,
2149 Some(instance.start_line as u32),
2150 &fp,
2151 ));
2152 }
2153 }
2154
2155 issues
2156}
2157
2158pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
2160 let issues = build_duplication_codeclimate(report, root);
2161 let value = issues_to_value(&issues);
2162 emit_json(&value, "CodeClimate")
2163}
2164
2165#[expect(
2174 clippy::expect_used,
2175 reason = "grouped duplication CodeClimate entries are JSON objects created by issues_to_value"
2176)]
2177pub(super) fn print_grouped_duplication_codeclimate(
2178 report: &DuplicationReport,
2179 root: &Path,
2180 resolver: &OwnershipResolver,
2181) -> ExitCode {
2182 let issues = build_duplication_codeclimate(report, root);
2183 let mut value = issues_to_value(&issues);
2184
2185 use rustc_hash::FxHashMap;
2186 let mut path_to_owner: FxHashMap<String, String> = FxHashMap::default();
2187 for group in &report.clone_groups {
2188 let owner = super::dupes_grouping::largest_owner(group, root, resolver);
2189 for instance in &group.instances {
2190 let path = cc_path(&instance.file, root);
2191 path_to_owner.insert(path, owner.clone());
2192 }
2193 }
2194
2195 if let Some(items) = value.as_array_mut() {
2196 for issue in items {
2197 let path = issue
2198 .pointer("/location/path")
2199 .and_then(|v| v.as_str())
2200 .unwrap_or("")
2201 .to_string();
2202 let group = path_to_owner
2203 .get(&path)
2204 .cloned()
2205 .unwrap_or_else(|| crate::codeowners::UNOWNED_LABEL.to_string());
2206 issue
2207 .as_object_mut()
2208 .expect("CodeClimate issue should be an object")
2209 .insert("group".to_string(), serde_json::Value::String(group));
2210 }
2211 }
2212
2213 emit_json(&value, "CodeClimate")
2214}
2215
2216#[cfg(test)]
2217mod tests {
2218 use super::*;
2219 use crate::report::test_helpers::sample_results;
2220 use fallow_config::RulesConfig;
2221 use fallow_core::results::*;
2222 use std::path::PathBuf;
2223
2224 fn health_severity(value: u16, threshold: u16) -> &'static str {
2227 if threshold == 0 {
2228 return "minor";
2229 }
2230 let ratio = f64::from(value) / f64::from(threshold);
2231 if ratio > 2.5 {
2232 "critical"
2233 } else if ratio > 1.5 {
2234 "major"
2235 } else {
2236 "minor"
2237 }
2238 }
2239
2240 #[test]
2241 fn codeclimate_empty_results_produces_empty_array() {
2242 let root = PathBuf::from("/project");
2243 let results = AnalysisResults::default();
2244 let rules = RulesConfig::default();
2245 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2246 let arr = output.as_array().unwrap();
2247 assert!(arr.is_empty());
2248 }
2249
2250 #[test]
2251 fn codeclimate_produces_array_of_issues() {
2252 let root = PathBuf::from("/project");
2253 let results = sample_results(&root);
2254 let rules = RulesConfig::default();
2255 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2256 assert!(output.is_array());
2257 let arr = output.as_array().unwrap();
2258 assert!(!arr.is_empty());
2259 }
2260
2261 #[test]
2262 fn codeclimate_issue_has_required_fields() {
2263 let root = PathBuf::from("/project");
2264 let mut results = AnalysisResults::default();
2265 results
2266 .unused_files
2267 .push(UnusedFileFinding::with_actions(UnusedFile {
2268 path: root.join("src/dead.ts"),
2269 }));
2270 let rules = RulesConfig::default();
2271 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2272 let issue = &output.as_array().unwrap()[0];
2273
2274 assert_eq!(issue["type"], "issue");
2275 assert_eq!(issue["check_name"], "fallow/unused-file");
2276 assert!(issue["description"].is_string());
2277 assert!(issue["categories"].is_array());
2278 assert!(issue["severity"].is_string());
2279 assert!(issue["fingerprint"].is_string());
2280 assert!(issue["location"].is_object());
2281 assert!(issue["location"]["path"].is_string());
2282 assert!(issue["location"]["lines"].is_object());
2283 }
2284
2285 #[test]
2286 fn codeclimate_unused_file_severity_follows_rules() {
2287 let root = PathBuf::from("/project");
2288 let mut results = AnalysisResults::default();
2289 results
2290 .unused_files
2291 .push(UnusedFileFinding::with_actions(UnusedFile {
2292 path: root.join("src/dead.ts"),
2293 }));
2294
2295 let rules = RulesConfig::default();
2296 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2297 assert_eq!(output[0]["severity"], "major");
2298
2299 let rules = RulesConfig {
2300 unused_files: Severity::Warn,
2301 ..RulesConfig::default()
2302 };
2303 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2304 assert_eq!(output[0]["severity"], "minor");
2305 }
2306
2307 #[test]
2308 fn codeclimate_unused_export_has_line_number() {
2309 let root = PathBuf::from("/project");
2310 let mut results = AnalysisResults::default();
2311 results
2312 .unused_exports
2313 .push(UnusedExportFinding::with_actions(UnusedExport {
2314 path: root.join("src/utils.ts"),
2315 export_name: "helperFn".to_string(),
2316 is_type_only: false,
2317 line: 10,
2318 col: 4,
2319 span_start: 120,
2320 is_re_export: false,
2321 }));
2322 let rules = RulesConfig::default();
2323 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2324 let issue = &output[0];
2325 assert_eq!(issue["location"]["lines"]["begin"], 10);
2326 }
2327
2328 #[test]
2329 fn codeclimate_unused_file_line_defaults_to_1() {
2330 let root = PathBuf::from("/project");
2331 let mut results = AnalysisResults::default();
2332 results
2333 .unused_files
2334 .push(UnusedFileFinding::with_actions(UnusedFile {
2335 path: root.join("src/dead.ts"),
2336 }));
2337 let rules = RulesConfig::default();
2338 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2339 let issue = &output[0];
2340 assert_eq!(issue["location"]["lines"]["begin"], 1);
2341 }
2342
2343 #[test]
2344 fn codeclimate_paths_are_relative() {
2345 let root = PathBuf::from("/project");
2346 let mut results = AnalysisResults::default();
2347 results
2348 .unused_files
2349 .push(UnusedFileFinding::with_actions(UnusedFile {
2350 path: root.join("src/deep/nested/file.ts"),
2351 }));
2352 let rules = RulesConfig::default();
2353 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2354 let path = output[0]["location"]["path"].as_str().unwrap();
2355 assert_eq!(path, "src/deep/nested/file.ts");
2356 assert!(!path.starts_with("/project"));
2357 }
2358
2359 #[test]
2360 fn codeclimate_re_export_label_in_description() {
2361 let root = PathBuf::from("/project");
2362 let mut results = AnalysisResults::default();
2363 results
2364 .unused_exports
2365 .push(UnusedExportFinding::with_actions(UnusedExport {
2366 path: root.join("src/index.ts"),
2367 export_name: "reExported".to_string(),
2368 is_type_only: false,
2369 line: 1,
2370 col: 0,
2371 span_start: 0,
2372 is_re_export: true,
2373 }));
2374 let rules = RulesConfig::default();
2375 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2376 let desc = output[0]["description"].as_str().unwrap();
2377 assert!(desc.contains("Re-export"));
2378 }
2379
2380 #[test]
2381 fn codeclimate_unlisted_dep_one_issue_per_import_site() {
2382 let root = PathBuf::from("/project");
2383 let mut results = AnalysisResults::default();
2384 results
2385 .unlisted_dependencies
2386 .push(UnlistedDependencyFinding::with_actions(
2387 UnlistedDependency {
2388 package_name: "chalk".to_string(),
2389 imported_from: vec![
2390 ImportSite {
2391 path: root.join("src/a.ts"),
2392 line: 1,
2393 col: 0,
2394 },
2395 ImportSite {
2396 path: root.join("src/b.ts"),
2397 line: 5,
2398 col: 0,
2399 },
2400 ],
2401 },
2402 ));
2403 let rules = RulesConfig::default();
2404 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2405 let arr = output.as_array().unwrap();
2406 assert_eq!(arr.len(), 2);
2407 assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
2408 assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
2409 }
2410
2411 #[test]
2412 fn codeclimate_duplicate_export_one_issue_per_location() {
2413 let root = PathBuf::from("/project");
2414 let mut results = AnalysisResults::default();
2415 results
2416 .duplicate_exports
2417 .push(DuplicateExportFinding::with_actions(DuplicateExport {
2418 export_name: "Config".to_string(),
2419 locations: vec![
2420 DuplicateLocation {
2421 path: root.join("src/a.ts"),
2422 line: 10,
2423 col: 0,
2424 },
2425 DuplicateLocation {
2426 path: root.join("src/b.ts"),
2427 line: 20,
2428 col: 0,
2429 },
2430 DuplicateLocation {
2431 path: root.join("src/c.ts"),
2432 line: 30,
2433 col: 0,
2434 },
2435 ],
2436 }));
2437 let rules = RulesConfig::default();
2438 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2439 let arr = output.as_array().unwrap();
2440 assert_eq!(arr.len(), 3);
2441 }
2442
2443 #[test]
2444 fn codeclimate_circular_dep_emits_chain_in_description() {
2445 let root = PathBuf::from("/project");
2446 let mut results = AnalysisResults::default();
2447 results
2448 .circular_dependencies
2449 .push(CircularDependencyFinding::with_actions(
2450 CircularDependency {
2451 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2452 length: 2,
2453 line: 3,
2454 col: 0,
2455 edges: Vec::new(),
2456 is_cross_package: false,
2457 },
2458 ));
2459 let rules = RulesConfig::default();
2460 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2461 let desc = output[0]["description"].as_str().unwrap();
2462 assert!(desc.contains("Circular dependency"));
2463 assert!(desc.contains("src/a.ts"));
2464 assert!(desc.contains("src/b.ts"));
2465 }
2466
2467 #[test]
2468 fn codeclimate_fingerprints_are_deterministic() {
2469 let root = PathBuf::from("/project");
2470 let results = sample_results(&root);
2471 let rules = RulesConfig::default();
2472 let output1 = issues_to_value(&build_codeclimate(&results, &root, &rules));
2473 let output2 = issues_to_value(&build_codeclimate(&results, &root, &rules));
2474
2475 let fps1: Vec<&str> = output1
2476 .as_array()
2477 .unwrap()
2478 .iter()
2479 .map(|i| i["fingerprint"].as_str().unwrap())
2480 .collect();
2481 let fps2: Vec<&str> = output2
2482 .as_array()
2483 .unwrap()
2484 .iter()
2485 .map(|i| i["fingerprint"].as_str().unwrap())
2486 .collect();
2487 assert_eq!(fps1, fps2);
2488 }
2489
2490 #[test]
2491 fn codeclimate_fingerprints_are_unique() {
2492 let root = PathBuf::from("/project");
2493 let results = sample_results(&root);
2494 let rules = RulesConfig::default();
2495 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2496
2497 let mut fps: Vec<&str> = output
2498 .as_array()
2499 .unwrap()
2500 .iter()
2501 .map(|i| i["fingerprint"].as_str().unwrap())
2502 .collect();
2503 let original_len = fps.len();
2504 fps.sort_unstable();
2505 fps.dedup();
2506 assert_eq!(fps.len(), original_len, "fingerprints should be unique");
2507 }
2508
2509 #[test]
2510 fn codeclimate_type_only_dep_has_correct_check_name() {
2511 let root = PathBuf::from("/project");
2512 let mut results = AnalysisResults::default();
2513 results
2514 .type_only_dependencies
2515 .push(TypeOnlyDependencyFinding::with_actions(
2516 TypeOnlyDependency {
2517 package_name: "zod".to_string(),
2518 path: root.join("package.json"),
2519 line: 8,
2520 },
2521 ));
2522 let rules = RulesConfig::default();
2523 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2524 assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
2525 let desc = output[0]["description"].as_str().unwrap();
2526 assert!(desc.contains("zod"));
2527 assert!(desc.contains("type-only"));
2528 }
2529
2530 #[test]
2531 fn codeclimate_dep_with_zero_line_omits_line_number() {
2532 let root = PathBuf::from("/project");
2533 let mut results = AnalysisResults::default();
2534 results
2535 .unused_dependencies
2536 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2537 package_name: "lodash".to_string(),
2538 location: DependencyLocation::Dependencies,
2539 path: root.join("package.json"),
2540 line: 0,
2541 used_in_workspaces: Vec::new(),
2542 }));
2543 let rules = RulesConfig::default();
2544 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2545 assert_eq!(output[0]["location"]["lines"]["begin"], 1);
2546 }
2547
2548 #[test]
2549 fn fingerprint_hash_different_inputs_differ() {
2550 let h1 = fingerprint_hash(&["a", "b"]);
2551 let h2 = fingerprint_hash(&["a", "c"]);
2552 assert_ne!(h1, h2);
2553 }
2554
2555 #[test]
2556 fn fingerprint_hash_order_matters() {
2557 let h1 = fingerprint_hash(&["a", "b"]);
2558 let h2 = fingerprint_hash(&["b", "a"]);
2559 assert_ne!(h1, h2);
2560 }
2561
2562 #[test]
2563 fn fingerprint_hash_separator_prevents_collision() {
2564 let h1 = fingerprint_hash(&["ab", "c"]);
2565 let h2 = fingerprint_hash(&["a", "bc"]);
2566 assert_ne!(h1, h2);
2567 }
2568
2569 #[test]
2570 fn fingerprint_hash_is_16_hex_chars() {
2571 let h = fingerprint_hash(&["test"]);
2572 assert_eq!(h.len(), 16);
2573 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
2574 }
2575
2576 #[test]
2577 fn severity_error_maps_to_major() {
2578 assert_eq!(
2579 severity_to_codeclimate(Severity::Error),
2580 CodeClimateSeverity::Major
2581 );
2582 }
2583
2584 #[test]
2585 fn severity_warn_maps_to_minor() {
2586 assert_eq!(
2587 severity_to_codeclimate(Severity::Warn),
2588 CodeClimateSeverity::Minor
2589 );
2590 }
2591
2592 #[test]
2593 #[should_panic(expected = "internal error: entered unreachable code")]
2594 fn severity_off_is_unreachable() {
2595 let _ = severity_to_codeclimate(Severity::Off);
2596 }
2597
2598 #[test]
2610 fn build_codeclimate_with_off_severity_and_empty_findings_does_not_panic() {
2611 let root = PathBuf::from("/project");
2612 let results = AnalysisResults::default();
2613 let rules = RulesConfig {
2614 unused_dependencies: Severity::Off,
2615 unused_dev_dependencies: Severity::Off,
2616 unused_optional_dependencies: Severity::Off,
2617 unused_exports: Severity::Off,
2618 unused_types: Severity::Off,
2619 unused_enum_members: Severity::Off,
2620 unused_class_members: Severity::Off,
2621 ..RulesConfig::default()
2622 };
2623 let issues = build_codeclimate(&results, &root, &rules);
2624 assert!(issues.is_empty());
2625 }
2626
2627 #[test]
2628 fn health_severity_zero_threshold_returns_minor() {
2629 assert_eq!(health_severity(100, 0), "minor");
2630 }
2631
2632 #[test]
2633 fn health_severity_at_threshold_returns_minor() {
2634 assert_eq!(health_severity(10, 10), "minor");
2635 }
2636
2637 #[test]
2638 fn health_severity_1_5x_threshold_returns_minor() {
2639 assert_eq!(health_severity(15, 10), "minor");
2640 }
2641
2642 #[test]
2643 fn health_severity_above_1_5x_returns_major() {
2644 assert_eq!(health_severity(16, 10), "major");
2645 }
2646
2647 #[test]
2648 fn health_severity_at_2_5x_returns_major() {
2649 assert_eq!(health_severity(25, 10), "major");
2650 }
2651
2652 #[test]
2653 fn health_severity_above_2_5x_returns_critical() {
2654 assert_eq!(health_severity(26, 10), "critical");
2655 }
2656
2657 #[test]
2658 fn health_codeclimate_includes_coverage_gaps() {
2659 use crate::health_types::*;
2660
2661 let root = PathBuf::from("/project");
2662 let report = HealthReport {
2663 summary: HealthSummary {
2664 files_analyzed: 10,
2665 functions_analyzed: 50,
2666 ..Default::default()
2667 },
2668 coverage_gaps: Some(CoverageGaps {
2669 summary: CoverageGapSummary {
2670 runtime_files: 2,
2671 covered_files: 0,
2672 file_coverage_pct: 0.0,
2673 untested_files: 1,
2674 untested_exports: 1,
2675 },
2676 files: vec![UntestedFileFinding::with_actions(
2677 UntestedFile {
2678 path: root.join("src/app.ts"),
2679 value_export_count: 2,
2680 },
2681 &root,
2682 )],
2683 exports: vec![UntestedExportFinding::with_actions(
2684 UntestedExport {
2685 path: root.join("src/app.ts"),
2686 export_name: "loader".into(),
2687 line: 12,
2688 col: 4,
2689 },
2690 &root,
2691 )],
2692 }),
2693 ..Default::default()
2694 };
2695
2696 let output = issues_to_value(&build_health_codeclimate(&report, &root));
2697 let issues = output.as_array().unwrap();
2698 assert_eq!(issues.len(), 2);
2699 assert_eq!(issues[0]["check_name"], "fallow/untested-file");
2700 assert_eq!(issues[0]["categories"][0], "Coverage");
2701 assert_eq!(issues[0]["location"]["path"], "src/app.ts");
2702 assert_eq!(issues[1]["check_name"], "fallow/untested-export");
2703 assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
2704 assert!(
2705 issues[1]["description"]
2706 .as_str()
2707 .unwrap()
2708 .contains("loader")
2709 );
2710 }
2711
2712 #[test]
2713 fn health_codeclimate_includes_coverage_intelligence_issue() {
2714 use crate::health_types::{
2715 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2716 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2717 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2718 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2719 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2720 HealthReport, HealthSummary,
2721 };
2722
2723 let root = PathBuf::from("/project");
2724 let report = HealthReport {
2725 summary: HealthSummary {
2726 files_analyzed: 10,
2727 functions_analyzed: 50,
2728 ..Default::default()
2729 },
2730 coverage_intelligence: Some(CoverageIntelligenceReport {
2731 schema_version: CoverageIntelligenceSchemaVersion::V1,
2732 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2733 summary: CoverageIntelligenceSummary {
2734 findings: 1,
2735 high_confidence_deletes: 1,
2736 ..Default::default()
2737 },
2738 findings: vec![CoverageIntelligenceFinding {
2739 id: "fallow:coverage-intel:abc123".to_owned(),
2740 path: root.join("src/dead.ts"),
2741 identity: Some("deadPath".to_owned()),
2742 line: 9,
2743 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2744 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2745 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2746 confidence: CoverageIntelligenceConfidence::High,
2747 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2748 evidence: CoverageIntelligenceEvidence {
2749 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2750 ..Default::default()
2751 },
2752 actions: vec![CoverageIntelligenceAction {
2753 kind: "delete-after-confirming-owner".to_owned(),
2754 description: "Confirm ownership".to_owned(),
2755 auto_fixable: false,
2756 }],
2757 }],
2758 }),
2759 ..Default::default()
2760 };
2761
2762 let output = issues_to_value(&build_health_codeclimate(&report, &root));
2763 let issues = output.as_array().unwrap();
2764 assert_eq!(issues.len(), 1);
2765 assert_eq!(
2766 issues[0]["check_name"],
2767 "fallow/coverage-intelligence-delete"
2768 );
2769 assert!(!issues[0]["fingerprint"].as_str().unwrap().is_empty());
2770 assert_eq!(issues[0]["location"]["path"], "src/dead.ts");
2771 assert!(
2772 issues[0]["description"]
2773 .as_str()
2774 .unwrap()
2775 .contains("deadPath")
2776 );
2777 }
2778
2779 #[test]
2780 fn health_codeclimate_skips_summary_only_coverage_intelligence() {
2781 use crate::health_types::{
2782 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2783 CoverageIntelligenceSummary, CoverageIntelligenceVerdict, HealthReport,
2784 };
2785
2786 let root = PathBuf::from("/project");
2787 let report = HealthReport {
2788 coverage_intelligence: Some(CoverageIntelligenceReport {
2789 schema_version: CoverageIntelligenceSchemaVersion::V1,
2790 verdict: CoverageIntelligenceVerdict::Clean,
2791 summary: CoverageIntelligenceSummary {
2792 skipped_ambiguous_matches: 2,
2793 ..Default::default()
2794 },
2795 findings: vec![],
2796 }),
2797 ..Default::default()
2798 };
2799
2800 let issues = build_health_codeclimate(&report, &root);
2801 assert!(issues.is_empty());
2802 }
2803
2804 #[test]
2805 fn health_codeclimate_crap_only_uses_crap_check_name() {
2806 use crate::health_types::{
2807 ComplexityViolation, FindingSeverity, HealthReport, HealthSummary,
2808 };
2809 let root = PathBuf::from("/project");
2810 let report = HealthReport {
2811 findings: vec![
2812 ComplexityViolation {
2813 path: root.join("src/untested.ts"),
2814 name: "risky".to_string(),
2815 line: 7,
2816 col: 0,
2817 cyclomatic: 10,
2818 cognitive: 10,
2819 line_count: 20,
2820 param_count: 1,
2821 react_hook_count: 0,
2822 react_jsx_max_depth: 0,
2823 react_prop_count: 0,
2824 react_hook_profile: None,
2825 exceeded: crate::health_types::ExceededThreshold::Crap,
2826 severity: FindingSeverity::High,
2827 crap: Some(60.0),
2828 coverage_pct: Some(25.0),
2829 coverage_tier: None,
2830 coverage_source: None,
2831 inherited_from: None,
2832 component_rollup: None,
2833 contributions: Vec::new(),
2834 effective_thresholds: None,
2835 threshold_source: None,
2836 }
2837 .into(),
2838 ],
2839 summary: HealthSummary {
2840 functions_analyzed: 10,
2841 functions_above_threshold: 1,
2842 ..Default::default()
2843 },
2844 ..Default::default()
2845 };
2846 let json = issues_to_value(&build_health_codeclimate(&report, &root));
2847 let issues = json.as_array().unwrap();
2848 assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
2849 assert_eq!(issues[0]["severity"], "major");
2850 let description = issues[0]["description"].as_str().unwrap();
2851 assert!(description.contains("CRAP score"), "desc: {description}");
2852 assert!(description.contains("coverage 25%"), "desc: {description}");
2853 }
2854}