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
41#[derive(Clone, Copy)]
47struct CcIssue<'a> {
48 check_name: &'a str,
49 description: &'a str,
50 severity: CodeClimateSeverity,
51 category: &'a str,
52 path: &'a str,
53 begin_line: Option<u32>,
54 fingerprint: &'a str,
55}
56
57fn cc_issue(issue: CcIssue<'_>) -> CodeClimateIssue {
58 let CcIssue {
59 check_name,
60 description,
61 severity,
62 category,
63 path,
64 begin_line,
65 fingerprint,
66 } = issue;
67 CodeClimateIssue {
68 kind: CodeClimateIssueKind::Issue,
69 check_name: check_name.to_string(),
70 description: description.to_string(),
71 categories: vec![category.to_string()],
72 severity,
73 fingerprint: fingerprint.to_string(),
74 location: CodeClimateLocation {
75 path: path.to_string(),
76 lines: CodeClimateLines {
77 begin: begin_line.unwrap_or(1),
78 },
79 },
80 }
81}
82
83fn coverage_intelligence_check_name(
84 recommendation: crate::health_types::CoverageIntelligenceRecommendation,
85) -> &'static str {
86 match recommendation {
87 crate::health_types::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
88 "fallow/coverage-intelligence-risky-change"
89 }
90 crate::health_types::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
91 "fallow/coverage-intelligence-delete"
92 }
93 crate::health_types::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
94 "fallow/coverage-intelligence-review"
95 }
96 crate::health_types::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
97 "fallow/coverage-intelligence-refactor"
98 }
99 }
100}
101
102struct HealthCodeClimateContext<'a> {
103 root: &'a Path,
104 cyc_t: u16,
105 cog_t: u16,
106 crap_t: f64,
107}
108
109impl HealthCodeClimateContext<'_> {
110 fn complexity_issue(&self, finding: &ComplexityViolation) -> CodeClimateIssue {
111 let path = cc_path(&finding.path, self.root);
112 let check_name = complexity_check_name(finding);
113 let line_str = finding.line.to_string();
114 let fp = fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
115 cc_issue(CcIssue {
116 check_name,
117 description: &self.complexity_description(finding),
118 severity: health_finding_severity(finding.severity),
119 category: "Complexity",
120 path: &path,
121 begin_line: Some(finding.line),
122 fingerprint: &fp,
123 })
124 }
125
126 fn complexity_description(&self, finding: &ComplexityViolation) -> String {
127 match finding.exceeded {
128 ExceededThreshold::Both => format!(
129 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
130 finding.name, finding.cyclomatic, self.cyc_t, finding.cognitive, self.cog_t
131 ),
132 ExceededThreshold::Cyclomatic => format!(
133 "'{}' has cyclomatic complexity {} (threshold: {})",
134 finding.name, finding.cyclomatic, self.cyc_t
135 ),
136 ExceededThreshold::Cognitive => format!(
137 "'{}' has cognitive complexity {} (threshold: {})",
138 finding.name, finding.cognitive, self.cog_t
139 ),
140 ExceededThreshold::Crap
141 | ExceededThreshold::CyclomaticCrap
142 | ExceededThreshold::CognitiveCrap
143 | ExceededThreshold::All => {
144 let crap = finding.crap.unwrap_or(0.0);
145 let coverage = finding
146 .coverage_pct
147 .map(|pct| format!(", coverage {pct:.0}%"))
148 .unwrap_or_default();
149 format!(
150 "'{}' has CRAP score {crap:.1} (threshold: {:.1}, cyclomatic {}{coverage})",
151 finding.name, self.crap_t, finding.cyclomatic,
152 )
153 }
154 }
155 }
156
157 fn runtime_coverage_issue(&self, finding: &RuntimeCoverageFinding) -> CodeClimateIssue {
158 let path = cc_path(&finding.path, self.root);
159 let check_name = runtime_coverage_check_name(finding.verdict);
160 let invocations_hint = finding.invocations.map_or_else(
161 || "untracked".to_owned(),
162 |hits| format!("{hits} invocations"),
163 );
164 let description = format!(
165 "'{}' runtime coverage verdict: {} ({})",
166 finding.function,
167 finding.verdict.human_label(),
168 invocations_hint,
169 );
170 let fp = fingerprint_hash(&[
171 check_name,
172 &path,
173 &finding.line.to_string(),
174 &finding.function,
175 ]);
176 cc_issue(CcIssue {
177 check_name,
178 description: &description,
179 severity: runtime_coverage_severity(finding.verdict),
180 category: "Bug Risk",
181 path: &path,
182 begin_line: Some(finding.line),
183 fingerprint: &fp,
184 })
185 }
186
187 fn coverage_intelligence_issue(
188 &self,
189 finding: &CoverageIntelligenceFinding,
190 ) -> Option<CodeClimateIssue> {
191 let severity = coverage_intelligence_severity(finding.verdict)?;
192 let path = cc_path(&finding.path, self.root);
193 let check_name = coverage_intelligence_check_name(finding.recommendation);
194 let identity = finding.identity.as_deref().unwrap_or("code");
195 let description = format!(
196 "'{}' coverage intelligence verdict: {} ({})",
197 identity, finding.verdict, finding.recommendation,
198 );
199 let fp = fingerprint_hash(&[
200 check_name,
201 &path,
202 &finding.line.to_string(),
203 identity,
204 &finding.id,
205 ]);
206 Some(cc_issue(CcIssue {
207 check_name,
208 description: &description,
209 severity,
210 category: "Bug Risk",
211 path: &path,
212 begin_line: Some(finding.line),
213 fingerprint: &fp,
214 }))
215 }
216
217 fn untested_file_issue(&self, item: &UntestedFileFinding) -> CodeClimateIssue {
218 let path = cc_path(&item.file.path, self.root);
219 let description = format!(
220 "File is runtime-reachable but has no test dependency path ({} value export{})",
221 item.file.value_export_count,
222 if item.file.value_export_count == 1 {
223 ""
224 } else {
225 "s"
226 },
227 );
228 let fp = fingerprint_hash(&["fallow/untested-file", &path]);
229 cc_issue(CcIssue {
230 check_name: "fallow/untested-file",
231 description: &description,
232 severity: CodeClimateSeverity::Minor,
233 category: "Coverage",
234 path: &path,
235 begin_line: None,
236 fingerprint: &fp,
237 })
238 }
239
240 fn untested_export_issue(&self, item: &UntestedExportFinding) -> CodeClimateIssue {
241 let path = cc_path(&item.export.path, self.root);
242 let description = format!(
243 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
244 item.export.export_name
245 );
246 let line_str = item.export.line.to_string();
247 let fp = fingerprint_hash(&[
248 "fallow/untested-export",
249 &path,
250 &line_str,
251 &item.export.export_name,
252 ]);
253 cc_issue(CcIssue {
254 check_name: "fallow/untested-export",
255 description: &description,
256 severity: CodeClimateSeverity::Minor,
257 category: "Coverage",
258 path: &path,
259 begin_line: Some(item.export.line),
260 fingerprint: &fp,
261 })
262 }
263}
264
265const fn complexity_check_name(finding: &ComplexityViolation) -> &'static str {
266 match finding.exceeded {
267 ExceededThreshold::Both => "fallow/high-complexity",
268 ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
269 ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
270 ExceededThreshold::Crap
271 | ExceededThreshold::CyclomaticCrap
272 | ExceededThreshold::CognitiveCrap
273 | ExceededThreshold::All => "fallow/high-crap-score",
274 }
275}
276
277const fn health_finding_severity(
278 severity: crate::health_types::FindingSeverity,
279) -> CodeClimateSeverity {
280 match severity {
281 crate::health_types::FindingSeverity::Critical => CodeClimateSeverity::Critical,
282 crate::health_types::FindingSeverity::High => CodeClimateSeverity::Major,
283 crate::health_types::FindingSeverity::Moderate => CodeClimateSeverity::Minor,
284 }
285}
286
287const fn runtime_coverage_check_name(
288 verdict: crate::health_types::RuntimeCoverageVerdict,
289) -> &'static str {
290 match verdict {
291 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
292 "fallow/runtime-safe-to-delete"
293 }
294 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
295 "fallow/runtime-review-required"
296 }
297 crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
298 crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
299 "fallow/runtime-coverage-unavailable"
300 }
301 crate::health_types::RuntimeCoverageVerdict::Active
302 | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
303 }
304}
305
306const fn runtime_coverage_severity(
307 verdict: crate::health_types::RuntimeCoverageVerdict,
308) -> CodeClimateSeverity {
309 match verdict {
310 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => CodeClimateSeverity::Critical,
311 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => CodeClimateSeverity::Major,
312 _ => CodeClimateSeverity::Minor,
313 }
314}
315
316const fn coverage_intelligence_severity(
317 verdict: crate::health_types::CoverageIntelligenceVerdict,
318) -> Option<CodeClimateSeverity> {
319 match verdict {
320 crate::health_types::CoverageIntelligenceVerdict::RiskyChangeDetected
321 | crate::health_types::CoverageIntelligenceVerdict::HighConfidenceDelete => {
322 Some(CodeClimateSeverity::Major)
323 }
324 crate::health_types::CoverageIntelligenceVerdict::ReviewRequired
325 | crate::health_types::CoverageIntelligenceVerdict::RefactorCarefully => {
326 Some(CodeClimateSeverity::Minor)
327 }
328 crate::health_types::CoverageIntelligenceVerdict::Clean
329 | crate::health_types::CoverageIntelligenceVerdict::Unknown => None,
330 }
331}
332
333fn push_dep_cc_issues<'a, I>(
335 issues: &mut Vec<CodeClimateIssue>,
336 deps: I,
337 root: &Path,
338 rule_id: &str,
339 location_label: &str,
340 severity: Severity,
341) where
342 I: IntoIterator<Item = &'a fallow_core::results::UnusedDependency>,
343{
344 for dep in deps {
345 let level = severity_to_codeclimate(severity);
346 let path = cc_path(&dep.path, root);
347 let line = if dep.line > 0 { Some(dep.line) } else { None };
348 let fp = fingerprint_hash(&[rule_id, &dep.package_name]);
349 let workspace_context = if dep.used_in_workspaces.is_empty() {
350 String::new()
351 } else {
352 let workspaces = dep
353 .used_in_workspaces
354 .iter()
355 .map(|path| cc_path(path, root))
356 .collect::<Vec<_>>()
357 .join(", ");
358 format!("; imported in other workspaces: {workspaces}")
359 };
360 issues.push(cc_issue(CcIssue {
361 check_name: rule_id,
362 description: &format!(
363 "Package '{}' is in {location_label} but never imported{workspace_context}",
364 dep.package_name
365 ),
366 severity: level,
367 category: "Bug Risk",
368 path: &path,
369 begin_line: line,
370 fingerprint: &fp,
371 }));
372 }
373}
374
375fn push_unused_file_issues(
376 issues: &mut Vec<CodeClimateIssue>,
377 files: &[fallow_types::output_dead_code::UnusedFileFinding],
378 root: &Path,
379 severity: Severity,
380) {
381 if files.is_empty() {
382 return;
383 }
384 let level = severity_to_codeclimate(severity);
385 for entry in files {
386 let path = cc_path(&entry.file.path, root);
387 let fp = fingerprint_hash(&["fallow/unused-file", &path]);
388 issues.push(cc_issue(CcIssue {
389 check_name: "fallow/unused-file",
390 description: "File is not reachable from any entry point",
391 severity: level,
392 category: "Bug Risk",
393 path: &path,
394 begin_line: None,
395 fingerprint: &fp,
396 }));
397 }
398}
399
400struct UnusedExportIssuesInput<'a, I> {
406 issues: &'a mut Vec<CodeClimateIssue>,
407 exports: I,
408 root: &'a Path,
409 rule_id: &'a str,
410 direct_label: &'a str,
411 re_export_label: &'a str,
412 severity: Severity,
413}
414
415fn push_unused_export_issues<'a, I>(input: UnusedExportIssuesInput<'a, I>)
416where
417 I: IntoIterator<Item = &'a fallow_core::results::UnusedExport>,
418{
419 for export in input.exports {
420 let level = severity_to_codeclimate(input.severity);
421 let path = cc_path(&export.path, input.root);
422 let kind = if export.is_re_export {
423 input.re_export_label
424 } else {
425 input.direct_label
426 };
427 let line_str = export.line.to_string();
428 let fp = fingerprint_hash(&[input.rule_id, &path, &line_str, &export.export_name]);
429 input.issues.push(cc_issue(CcIssue {
430 check_name: input.rule_id,
431 description: &format!(
432 "{kind} '{}' is never imported by other modules",
433 export.export_name
434 ),
435 severity: level,
436 category: "Bug Risk",
437 path: &path,
438 begin_line: Some(export.line),
439 fingerprint: &fp,
440 }));
441 }
442}
443
444fn push_private_type_leak_issues(
445 issues: &mut Vec<CodeClimateIssue>,
446 leaks: &[fallow_types::output_dead_code::PrivateTypeLeakFinding],
447 root: &Path,
448 severity: Severity,
449) {
450 if leaks.is_empty() {
451 return;
452 }
453 let level = severity_to_codeclimate(severity);
454 for entry in leaks {
455 let leak = &entry.leak;
456 let path = cc_path(&leak.path, root);
457 let line_str = leak.line.to_string();
458 let fp = fingerprint_hash(&[
459 "fallow/private-type-leak",
460 &path,
461 &line_str,
462 &leak.export_name,
463 &leak.type_name,
464 ]);
465 issues.push(cc_issue(CcIssue {
466 check_name: "fallow/private-type-leak",
467 description: &format!(
468 "Export '{}' references private type '{}'",
469 leak.export_name, leak.type_name
470 ),
471 severity: level,
472 category: "Bug Risk",
473 path: &path,
474 begin_line: Some(leak.line),
475 fingerprint: &fp,
476 }));
477 }
478}
479
480fn push_type_only_dep_issues(
481 issues: &mut Vec<CodeClimateIssue>,
482 deps: &[fallow_core::results::TypeOnlyDependencyFinding],
483 root: &Path,
484 severity: Severity,
485) {
486 if deps.is_empty() {
487 return;
488 }
489 let level = severity_to_codeclimate(severity);
490 for entry in deps {
491 let dep = &entry.dep;
492 let path = cc_path(&dep.path, root);
493 let line = if dep.line > 0 { Some(dep.line) } else { None };
494 let fp = fingerprint_hash(&["fallow/type-only-dependency", &dep.package_name]);
495 issues.push(cc_issue(CcIssue {
496 check_name: "fallow/type-only-dependency",
497 description: &format!(
498 "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
499 dep.package_name
500 ),
501 severity: level,
502 category: "Bug Risk",
503 path: &path,
504 begin_line: line,
505 fingerprint: &fp,
506 }));
507 }
508}
509
510fn push_test_only_dep_issues(
511 issues: &mut Vec<CodeClimateIssue>,
512 deps: &[fallow_core::results::TestOnlyDependencyFinding],
513 root: &Path,
514 severity: Severity,
515) {
516 if deps.is_empty() {
517 return;
518 }
519 let level = severity_to_codeclimate(severity);
520 for entry in deps {
521 let dep = &entry.dep;
522 let path = cc_path(&dep.path, root);
523 let line = if dep.line > 0 { Some(dep.line) } else { None };
524 let fp = fingerprint_hash(&["fallow/test-only-dependency", &dep.package_name]);
525 issues.push(cc_issue(CcIssue {
526 check_name: "fallow/test-only-dependency",
527 description: &format!(
528 "Package '{}' is only imported by test files (consider moving to devDependencies)",
529 dep.package_name
530 ),
531 severity: level,
532 category: "Bug Risk",
533 path: &path,
534 begin_line: line,
535 fingerprint: &fp,
536 }));
537 }
538}
539
540fn push_unused_member_issues<'a, I>(
545 issues: &mut Vec<CodeClimateIssue>,
546 members: I,
547 root: &Path,
548 rule_id: &str,
549 entity_label: &str,
550 severity: Severity,
551) where
552 I: IntoIterator<Item = &'a fallow_core::results::UnusedMember>,
553{
554 for member in members {
555 let level = severity_to_codeclimate(severity);
556 let path = cc_path(&member.path, root);
557 let line_str = member.line.to_string();
558 let fp = fingerprint_hash(&[
559 rule_id,
560 &path,
561 &line_str,
562 &member.parent_name,
563 &member.member_name,
564 ]);
565 issues.push(cc_issue(CcIssue {
566 check_name: rule_id,
567 description: &format!(
568 "{entity_label} member '{}.{}' is never referenced",
569 member.parent_name, member.member_name
570 ),
571 severity: level,
572 category: "Bug Risk",
573 path: &path,
574 begin_line: Some(member.line),
575 fingerprint: &fp,
576 }));
577 }
578}
579
580fn push_unresolved_import_issues(
581 issues: &mut Vec<CodeClimateIssue>,
582 imports: &[fallow_types::output_dead_code::UnresolvedImportFinding],
583 root: &Path,
584 severity: Severity,
585) {
586 if imports.is_empty() {
587 return;
588 }
589 let level = severity_to_codeclimate(severity);
590 for entry in imports {
591 let import = &entry.import;
592 let path = cc_path(&import.path, root);
593 let line_str = import.line.to_string();
594 let fp = fingerprint_hash(&[
595 "fallow/unresolved-import",
596 &path,
597 &line_str,
598 &import.specifier,
599 ]);
600 issues.push(cc_issue(CcIssue {
601 check_name: "fallow/unresolved-import",
602 description: &format!("Import '{}' could not be resolved", import.specifier),
603 severity: level,
604 category: "Bug Risk",
605 path: &path,
606 begin_line: Some(import.line),
607 fingerprint: &fp,
608 }));
609 }
610}
611
612fn push_unlisted_dep_issues(
613 issues: &mut Vec<CodeClimateIssue>,
614 deps: &[fallow_core::results::UnlistedDependencyFinding],
615 root: &Path,
616 severity: Severity,
617) {
618 if deps.is_empty() {
619 return;
620 }
621 let level = severity_to_codeclimate(severity);
622 for entry in deps {
623 let dep = &entry.dep;
624 for site in &dep.imported_from {
625 let path = cc_path(&site.path, root);
626 let line_str = site.line.to_string();
627 let fp = fingerprint_hash(&[
628 "fallow/unlisted-dependency",
629 &path,
630 &line_str,
631 &dep.package_name,
632 ]);
633 issues.push(cc_issue(CcIssue {
634 check_name: "fallow/unlisted-dependency",
635 description: &format!(
636 "Package '{}' is imported but not listed in package.json",
637 dep.package_name
638 ),
639 severity: level,
640 category: "Bug Risk",
641 path: &path,
642 begin_line: Some(site.line),
643 fingerprint: &fp,
644 }));
645 }
646 }
647}
648
649fn push_duplicate_export_issues(
650 issues: &mut Vec<CodeClimateIssue>,
651 dups: &[fallow_core::results::DuplicateExportFinding],
652 root: &Path,
653 severity: Severity,
654) {
655 if dups.is_empty() {
656 return;
657 }
658 let level = severity_to_codeclimate(severity);
659 for dup in dups {
660 let dup = &dup.export;
661 for loc in &dup.locations {
662 let path = cc_path(&loc.path, root);
663 let line_str = loc.line.to_string();
664 let fp = fingerprint_hash(&[
665 "fallow/duplicate-export",
666 &path,
667 &line_str,
668 &dup.export_name,
669 ]);
670 issues.push(cc_issue(CcIssue {
671 check_name: "fallow/duplicate-export",
672 description: &format!("Export '{}' appears in multiple modules", dup.export_name),
673 severity: level,
674 category: "Bug Risk",
675 path: &path,
676 begin_line: Some(loc.line),
677 fingerprint: &fp,
678 }));
679 }
680 }
681}
682
683fn push_circular_dep_issues(
684 issues: &mut Vec<CodeClimateIssue>,
685 cycles: &[fallow_types::output_dead_code::CircularDependencyFinding],
686 root: &Path,
687 severity: Severity,
688) {
689 if cycles.is_empty() {
690 return;
691 }
692 let level = severity_to_codeclimate(severity);
693 for entry in cycles {
694 let cycle = &entry.cycle;
695 let Some(first) = cycle.files.first() else {
696 continue;
697 };
698 let path = cc_path(first, root);
699 let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
700 let chain_str = chain.join(":");
701 let fp = fingerprint_hash(&["fallow/circular-dependency", &chain_str]);
702 let line = if cycle.line > 0 {
703 Some(cycle.line)
704 } else {
705 None
706 };
707 issues.push(cc_issue(CcIssue {
708 check_name: "fallow/circular-dependency",
709 description: &format!(
710 "Circular dependency{}: {}",
711 if cycle.is_cross_package {
712 " (cross-package)"
713 } else {
714 ""
715 },
716 chain.join(" \u{2192} ")
717 ),
718 severity: level,
719 category: "Bug Risk",
720 path: &path,
721 begin_line: line,
722 fingerprint: &fp,
723 }));
724 }
725}
726
727fn push_re_export_cycle_issues(
728 issues: &mut Vec<CodeClimateIssue>,
729 cycles: &[fallow_types::output_dead_code::ReExportCycleFinding],
730 root: &Path,
731 severity: Severity,
732) {
733 if cycles.is_empty() {
734 return;
735 }
736 let level = severity_to_codeclimate(severity);
737 for entry in cycles {
738 let cycle = &entry.cycle;
739 let Some(first) = cycle.files.first() else {
740 continue;
741 };
742 let path = cc_path(first, root);
743 let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
744 let chain_str = chain.join(":");
745 let kind_token = match cycle.kind {
746 fallow_core::results::ReExportCycleKind::SelfLoop => "self-loop",
747 fallow_core::results::ReExportCycleKind::MultiNode => "multi-node",
748 };
749 let kind_tag = match cycle.kind {
750 fallow_core::results::ReExportCycleKind::SelfLoop => " (self-loop)",
751 fallow_core::results::ReExportCycleKind::MultiNode => "",
752 };
753 let fp = fingerprint_hash(&["fallow/re-export-cycle", kind_token, &chain_str]);
754 issues.push(cc_issue(CcIssue {
755 check_name: "fallow/re-export-cycle",
756 description: &format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
757 severity: level,
758 category: "Bug Risk",
759 path: &path,
760 begin_line: None,
761 fingerprint: &fp,
762 }));
763 }
764}
765
766fn push_boundary_violation_issues(
767 issues: &mut Vec<CodeClimateIssue>,
768 violations: &[fallow_types::output_dead_code::BoundaryViolationFinding],
769 root: &Path,
770 severity: Severity,
771) {
772 if violations.is_empty() {
773 return;
774 }
775 let level = severity_to_codeclimate(severity);
776 for entry in violations {
777 let v = &entry.violation;
778 let path = cc_path(&v.from_path, root);
779 let to = cc_path(&v.to_path, root);
780 let fp = fingerprint_hash(&["fallow/boundary-violation", &path, &to]);
781 let line = if v.line > 0 { Some(v.line) } else { None };
782 issues.push(cc_issue(CcIssue {
783 check_name: "fallow/boundary-violation",
784 description: &format!(
785 "Boundary violation: {} -> {} ({} -> {})",
786 path, to, v.from_zone, v.to_zone
787 ),
788 severity: level,
789 category: "Bug Risk",
790 path: &path,
791 begin_line: line,
792 fingerprint: &fp,
793 }));
794 }
795}
796
797fn push_boundary_coverage_issues(
798 issues: &mut Vec<CodeClimateIssue>,
799 violations: &[fallow_types::output_dead_code::BoundaryCoverageViolationFinding],
800 root: &Path,
801 severity: Severity,
802) {
803 if violations.is_empty() {
804 return;
805 }
806 let level = severity_to_codeclimate(severity);
807 for entry in violations {
808 let v = &entry.violation;
809 let path = cc_path(&v.path, root);
810 let fp = fingerprint_hash(&["fallow/boundary-coverage", &path]);
811 let line = if v.line > 0 { Some(v.line) } else { None };
812 issues.push(cc_issue(CcIssue {
813 check_name: "fallow/boundary-coverage",
814 description: &format!("Boundary coverage: {path} matches no configured zone"),
815 severity: level,
816 category: "Bug Risk",
817 path: &path,
818 begin_line: line,
819 fingerprint: &fp,
820 }));
821 }
822}
823
824fn push_boundary_call_issues(
825 issues: &mut Vec<CodeClimateIssue>,
826 violations: &[fallow_types::output_dead_code::BoundaryCallViolationFinding],
827 root: &Path,
828 severity: Severity,
829) {
830 if violations.is_empty() {
831 return;
832 }
833 let level = severity_to_codeclimate(severity);
834 for entry in violations {
835 let v = &entry.violation;
836 let path = cc_path(&v.path, root);
837 let fp = fingerprint_hash(&["fallow/boundary-call-violation", &path, &v.callee]);
838 let line = if v.line > 0 { Some(v.line) } else { None };
839 issues.push(cc_issue(CcIssue {
840 check_name: "fallow/boundary-call-violation",
841 description: &format!(
842 "Boundary call: `{}` matches forbidden pattern `{}` in zone '{}'",
843 v.callee, v.pattern, v.zone
844 ),
845 severity: level,
846 category: "Bug Risk",
847 path: &path,
848 begin_line: line,
849 fingerprint: &fp,
850 }));
851 }
852}
853
854fn push_policy_violation_issues(
855 issues: &mut Vec<CodeClimateIssue>,
856 violations: &[fallow_types::output_dead_code::PolicyViolationFinding],
857 root: &Path,
858) {
859 use fallow_core::results::PolicyViolationSeverity;
860
861 for entry in violations {
862 let v = &entry.violation;
863 let path = cc_path(&v.path, root);
864 let rule = format!("{}/{}", v.pack, v.rule_id);
865 let fp = fingerprint_hash(&["fallow/policy-violation", &path, &rule, &v.matched]);
866 let line = if v.line > 0 { Some(v.line) } else { None };
867 let level = severity_to_codeclimate(match v.severity {
871 PolicyViolationSeverity::Error => Severity::Error,
872 PolicyViolationSeverity::Warn => Severity::Warn,
873 });
874 let message = match &v.message {
875 Some(message) => format!(
876 "Policy violation: `{}` is banned by `{rule}`. {message}",
877 v.matched
878 ),
879 None => format!("Policy violation: `{}` is banned by `{rule}`", v.matched),
880 };
881 issues.push(cc_issue(CcIssue {
882 check_name: "fallow/policy-violation",
883 description: &message,
884 severity: level,
885 category: "Bug Risk",
886 path: &path,
887 begin_line: line,
888 fingerprint: &fp,
889 }));
890 }
891}
892
893fn push_invalid_client_export_issues(
894 issues: &mut Vec<CodeClimateIssue>,
895 findings: &[fallow_types::output_dead_code::InvalidClientExportFinding],
896 root: &Path,
897 severity: Severity,
898) {
899 if findings.is_empty() {
900 return;
901 }
902 let level = severity_to_codeclimate(severity);
903 for entry in findings {
904 let e = &entry.export;
905 let path = cc_path(&e.path, root);
906 let fp = fingerprint_hash(&["fallow/invalid-client-export", &path, &e.export_name]);
907 let line = if e.line > 0 { Some(e.line) } else { None };
908 let message = format!(
909 "Export `{}` is not allowed in a \"{}\" file (Next.js server-only / route-config name)",
910 e.export_name, e.directive
911 );
912 issues.push(cc_issue(CcIssue {
913 check_name: "fallow/invalid-client-export",
914 description: &message,
915 severity: level,
916 category: "Bug Risk",
917 path: &path,
918 begin_line: line,
919 fingerprint: &fp,
920 }));
921 }
922}
923
924fn push_mixed_client_server_barrel_issues(
925 issues: &mut Vec<CodeClimateIssue>,
926 findings: &[fallow_types::output_dead_code::MixedClientServerBarrelFinding],
927 root: &Path,
928 severity: Severity,
929) {
930 if findings.is_empty() {
931 return;
932 }
933 let level = severity_to_codeclimate(severity);
934 for entry in findings {
935 let b = &entry.barrel;
936 let path = cc_path(&b.path, root);
937 let fp = fingerprint_hash(&[
938 "fallow/mixed-client-server-barrel",
939 &path,
940 &b.client_origin,
941 &b.server_origin,
942 ]);
943 let line = if b.line > 0 { Some(b.line) } else { None };
944 let message = format!(
945 "Barrel re-exports both a \"use client\" module (`{}`) and a server-only module (`{}`); one import drags the other's directive across the boundary",
946 b.client_origin, b.server_origin
947 );
948 issues.push(cc_issue(CcIssue {
949 check_name: "fallow/mixed-client-server-barrel",
950 description: &message,
951 severity: level,
952 category: "Bug Risk",
953 path: &path,
954 begin_line: line,
955 fingerprint: &fp,
956 }));
957 }
958}
959
960fn push_misplaced_directive_issues(
961 issues: &mut Vec<CodeClimateIssue>,
962 findings: &[fallow_types::output_dead_code::MisplacedDirectiveFinding],
963 root: &Path,
964 severity: Severity,
965) {
966 if findings.is_empty() {
967 return;
968 }
969 let level = severity_to_codeclimate(severity);
970 for entry in findings {
971 let d = &entry.directive_site;
972 let path = cc_path(&d.path, root);
973 let fp = fingerprint_hash(&[
974 "fallow/misplaced-directive",
975 &path,
976 &d.line.to_string(),
977 &d.directive,
978 ]);
979 let line = if d.line > 0 { Some(d.line) } else { None };
980 let message = format!(
981 "Directive `\"{}\"` is not in the leading position, so the RSC bundler ignores it; move it to the top of the file",
982 d.directive
983 );
984 issues.push(cc_issue(CcIssue {
985 check_name: "fallow/misplaced-directive",
986 description: &message,
987 severity: level,
988 category: "Bug Risk",
989 path: &path,
990 begin_line: line,
991 fingerprint: &fp,
992 }));
993 }
994}
995
996fn push_unprovided_inject_issues(
997 issues: &mut Vec<CodeClimateIssue>,
998 findings: &[fallow_types::output_dead_code::UnprovidedInjectFinding],
999 root: &Path,
1000 severity: Severity,
1001) {
1002 if findings.is_empty() {
1003 return;
1004 }
1005 let level = severity_to_codeclimate(severity);
1006 for entry in findings {
1007 let i = &entry.inject;
1008 let path = cc_path(&i.path, root);
1009 let fp = fingerprint_hash(&[
1010 "fallow/unprovided-inject",
1011 &path,
1012 &i.line.to_string(),
1013 &i.key_name,
1014 ]);
1015 let line = if i.line > 0 { Some(i.line) } else { None };
1016 let message = format!(
1017 "inject(`{}`) has no matching provide(`{}`) in this project; at runtime it returns undefined (provide the key or remove this inject)",
1018 i.key_name, i.key_name
1019 );
1020 issues.push(cc_issue(CcIssue {
1021 check_name: "fallow/unprovided-inject",
1022 description: &message,
1023 severity: level,
1024 category: "Bug Risk",
1025 path: &path,
1026 begin_line: line,
1027 fingerprint: &fp,
1028 }));
1029 }
1030}
1031
1032fn push_unrendered_component_issues(
1033 issues: &mut Vec<CodeClimateIssue>,
1034 findings: &[fallow_types::output_dead_code::UnrenderedComponentFinding],
1035 root: &Path,
1036 severity: Severity,
1037) {
1038 if findings.is_empty() {
1039 return;
1040 }
1041 let level = severity_to_codeclimate(severity);
1042 for entry in findings {
1043 let c = &entry.component;
1044 let path = cc_path(&c.path, root);
1045 let fp = fingerprint_hash(&[
1046 "fallow/unrendered-component",
1047 &path,
1048 &c.line.to_string(),
1049 &c.component_name,
1050 ]);
1051 let line = if c.line > 0 { Some(c.line) } else { None };
1052 let message = format!(
1053 "component `{}` is reachable but rendered nowhere in this project (render it somewhere or remove it)",
1054 c.component_name
1055 );
1056 issues.push(cc_issue(CcIssue {
1057 check_name: "fallow/unrendered-component",
1058 description: &message,
1059 severity: level,
1060 category: "Bug Risk",
1061 path: &path,
1062 begin_line: line,
1063 fingerprint: &fp,
1064 }));
1065 }
1066}
1067
1068fn push_unused_component_prop_issues(
1069 issues: &mut Vec<CodeClimateIssue>,
1070 findings: &[fallow_types::output_dead_code::UnusedComponentPropFinding],
1071 root: &Path,
1072 severity: Severity,
1073) {
1074 if findings.is_empty() {
1075 return;
1076 }
1077 let level = severity_to_codeclimate(severity);
1078 for entry in findings {
1079 let p = &entry.prop;
1080 let path = cc_path(&p.path, root);
1081 let fp = fingerprint_hash(&[
1082 "fallow/unused-component-prop",
1083 &path,
1084 &p.line.to_string(),
1085 &p.prop_name,
1086 ]);
1087 let line = if p.line > 0 { Some(p.line) } else { None };
1088 let message = format!(
1089 "prop `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1090 p.prop_name, p.component_name
1091 );
1092 issues.push(cc_issue(CcIssue {
1093 check_name: "fallow/unused-component-prop",
1094 description: &message,
1095 severity: level,
1096 category: "Bug Risk",
1097 path: &path,
1098 begin_line: line,
1099 fingerprint: &fp,
1100 }));
1101 }
1102}
1103
1104fn push_unused_component_emit_issues(
1105 issues: &mut Vec<CodeClimateIssue>,
1106 findings: &[fallow_types::output_dead_code::UnusedComponentEmitFinding],
1107 root: &Path,
1108 severity: Severity,
1109) {
1110 if findings.is_empty() {
1111 return;
1112 }
1113 let level = severity_to_codeclimate(severity);
1114 for entry in findings {
1115 let e = &entry.emit;
1116 let path = cc_path(&e.path, root);
1117 let fp = fingerprint_hash(&[
1118 "fallow/unused-component-emit",
1119 &path,
1120 &e.line.to_string(),
1121 &e.emit_name,
1122 ]);
1123 let line = if e.line > 0 { Some(e.line) } else { None };
1124 let message = format!(
1125 "emit `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1126 e.emit_name, e.component_name
1127 );
1128 issues.push(cc_issue(CcIssue {
1129 check_name: "fallow/unused-component-emit",
1130 description: &message,
1131 severity: level,
1132 category: "Bug Risk",
1133 path: &path,
1134 begin_line: line,
1135 fingerprint: &fp,
1136 }));
1137 }
1138}
1139
1140fn push_unused_svelte_event_issues(
1141 issues: &mut Vec<CodeClimateIssue>,
1142 findings: &[fallow_types::output_dead_code::UnusedSvelteEventFinding],
1143 root: &Path,
1144 severity: Severity,
1145) {
1146 if findings.is_empty() {
1147 return;
1148 }
1149 let level = severity_to_codeclimate(severity);
1150 for entry in findings {
1151 let e = &entry.event;
1152 let path = cc_path(&e.path, root);
1153 let fp = fingerprint_hash(&[
1154 "fallow/unused-svelte-event",
1155 &path,
1156 &e.line.to_string(),
1157 &e.event_name,
1158 ]);
1159 let line = if e.line > 0 { Some(e.line) } else { None };
1160 let message = format!(
1161 "event `{}` is dispatched by component `{}` but listened to nowhere in the project (remove it or listen for it)",
1162 e.event_name, e.component_name
1163 );
1164 issues.push(cc_issue(CcIssue {
1165 check_name: "fallow/unused-svelte-event",
1166 description: &message,
1167 severity: level,
1168 category: "Bug Risk",
1169 path: &path,
1170 begin_line: line,
1171 fingerprint: &fp,
1172 }));
1173 }
1174}
1175
1176fn push_unused_component_input_issues(
1177 issues: &mut Vec<CodeClimateIssue>,
1178 findings: &[fallow_types::output_dead_code::UnusedComponentInputFinding],
1179 root: &Path,
1180 severity: Severity,
1181) {
1182 if findings.is_empty() {
1183 return;
1184 }
1185 let level = severity_to_codeclimate(severity);
1186 for entry in findings {
1187 let i = &entry.input;
1188 let path = cc_path(&i.path, root);
1189 let fp = fingerprint_hash(&[
1190 "fallow/unused-component-input",
1191 &path,
1192 &i.line.to_string(),
1193 &i.input_name,
1194 ]);
1195 let line = if i.line > 0 { Some(i.line) } else { None };
1196 let message = format!(
1197 "input `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1198 i.input_name, i.component_name
1199 );
1200 issues.push(cc_issue(CcIssue {
1201 check_name: "fallow/unused-component-input",
1202 description: &message,
1203 severity: level,
1204 category: "Bug Risk",
1205 path: &path,
1206 begin_line: line,
1207 fingerprint: &fp,
1208 }));
1209 }
1210}
1211
1212fn push_unused_component_output_issues(
1213 issues: &mut Vec<CodeClimateIssue>,
1214 findings: &[fallow_types::output_dead_code::UnusedComponentOutputFinding],
1215 root: &Path,
1216 severity: Severity,
1217) {
1218 if findings.is_empty() {
1219 return;
1220 }
1221 let level = severity_to_codeclimate(severity);
1222 for entry in findings {
1223 let o = &entry.output;
1224 let path = cc_path(&o.path, root);
1225 let fp = fingerprint_hash(&[
1226 "fallow/unused-component-output",
1227 &path,
1228 &o.line.to_string(),
1229 &o.output_name,
1230 ]);
1231 let line = if o.line > 0 { Some(o.line) } else { None };
1232 let message = format!(
1233 "output `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1234 o.output_name, o.component_name
1235 );
1236 issues.push(cc_issue(CcIssue {
1237 check_name: "fallow/unused-component-output",
1238 description: &message,
1239 severity: level,
1240 category: "Bug Risk",
1241 path: &path,
1242 begin_line: line,
1243 fingerprint: &fp,
1244 }));
1245 }
1246}
1247
1248fn push_unused_server_action_issues(
1249 issues: &mut Vec<CodeClimateIssue>,
1250 findings: &[fallow_types::output_dead_code::UnusedServerActionFinding],
1251 root: &Path,
1252 severity: Severity,
1253) {
1254 if findings.is_empty() {
1255 return;
1256 }
1257 let level = severity_to_codeclimate(severity);
1258 for entry in findings {
1259 let a = &entry.action;
1260 let path = cc_path(&a.path, root);
1261 let fp = fingerprint_hash(&[
1262 "fallow/unused-server-action",
1263 &path,
1264 &a.line.to_string(),
1265 &a.action_name,
1266 ]);
1267 let line = if a.line > 0 { Some(a.line) } else { None };
1268 let message = format!(
1269 "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)",
1270 a.action_name
1271 );
1272 issues.push(cc_issue(CcIssue {
1273 check_name: "fallow/unused-server-action",
1274 description: &message,
1275 severity: level,
1276 category: "Bug Risk",
1277 path: &path,
1278 begin_line: line,
1279 fingerprint: &fp,
1280 }));
1281 }
1282}
1283
1284fn push_unused_load_data_key_issues(
1285 issues: &mut Vec<CodeClimateIssue>,
1286 findings: &[fallow_types::output_dead_code::UnusedLoadDataKeyFinding],
1287 root: &Path,
1288 severity: Severity,
1289) {
1290 if findings.is_empty() {
1291 return;
1292 }
1293 let level = severity_to_codeclimate(severity);
1294 for entry in findings {
1295 let k = &entry.key;
1296 let path = cc_path(&k.path, root);
1297 let fp = fingerprint_hash(&[
1298 "fallow/unused-load-data-key",
1299 &path,
1300 &k.line.to_string(),
1301 &k.key_name,
1302 ]);
1303 let line = if k.line > 0 { Some(k.line) } else { None };
1304 let message = format!(
1305 "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",
1306 k.key_name
1307 );
1308 issues.push(cc_issue(CcIssue {
1309 check_name: "fallow/unused-load-data-key",
1310 description: &message,
1311 severity: level,
1312 category: "Bug Risk",
1313 path: &path,
1314 begin_line: line,
1315 fingerprint: &fp,
1316 }));
1317 }
1318}
1319
1320fn push_route_collision_issues(
1321 issues: &mut Vec<CodeClimateIssue>,
1322 findings: &[fallow_types::output_dead_code::RouteCollisionFinding],
1323 root: &Path,
1324 severity: Severity,
1325) {
1326 if findings.is_empty() {
1327 return;
1328 }
1329 let level = severity_to_codeclimate(severity);
1330 for entry in findings {
1331 let c = &entry.collision;
1332 let path = cc_path(&c.path, root);
1333 let fp = fingerprint_hash(&["fallow/route-collision", &path, &c.url]);
1334 let line = if c.line > 0 { Some(c.line) } else { None };
1335 let message = format!(
1336 "Route file resolves to `{}`, also owned by {} other file(s); Next.js fails the build because a URL can have only one owner",
1337 c.url,
1338 c.conflicting_paths.len()
1339 );
1340 issues.push(cc_issue(CcIssue {
1341 check_name: "fallow/route-collision",
1342 description: &message,
1343 severity: level,
1344 category: "Bug Risk",
1345 path: &path,
1346 begin_line: line,
1347 fingerprint: &fp,
1348 }));
1349 }
1350}
1351
1352fn push_dynamic_segment_name_conflict_issues(
1353 issues: &mut Vec<CodeClimateIssue>,
1354 findings: &[fallow_types::output_dead_code::DynamicSegmentNameConflictFinding],
1355 root: &Path,
1356 severity: Severity,
1357) {
1358 if findings.is_empty() {
1359 return;
1360 }
1361 let level = severity_to_codeclimate(severity);
1362 for entry in findings {
1363 let c = &entry.conflict;
1364 let path = cc_path(&c.path, root);
1365 let fp = fingerprint_hash(&["fallow/dynamic-segment-name-conflict", &path, &c.position]);
1366 let line = if c.line > 0 { Some(c.line) } else { None };
1367 let message = format!(
1368 "Dynamic segments at `{}` use different slug names ({}); Next.js requires one consistent name per dynamic path",
1369 c.position,
1370 c.conflicting_segments.join(", ")
1371 );
1372 issues.push(cc_issue(CcIssue {
1373 check_name: "fallow/dynamic-segment-name-conflict",
1374 description: &message,
1375 severity: level,
1376 category: "Bug Risk",
1377 path: &path,
1378 begin_line: line,
1379 fingerprint: &fp,
1380 }));
1381 }
1382}
1383
1384fn push_stale_suppression_issues(
1385 issues: &mut Vec<CodeClimateIssue>,
1386 suppressions: &[fallow_core::results::StaleSuppression],
1387 root: &Path,
1388 rules: &RulesConfig,
1389) {
1390 if suppressions.is_empty() {
1391 return;
1392 }
1393 for s in suppressions {
1394 let severity = if s.missing_reason {
1395 rules.require_suppression_reason
1396 } else {
1397 rules.stale_suppressions
1398 };
1399 let level = severity_to_codeclimate(severity);
1400 let path = cc_path(&s.path, root);
1401 let line_str = s.line.to_string();
1402 let check_name = if s.missing_reason {
1403 "fallow/missing-suppression-reason"
1404 } else {
1405 "fallow/stale-suppression"
1406 };
1407 let fp = fingerprint_hash(&[check_name, &path, &line_str]);
1408 issues.push(cc_issue(CcIssue {
1409 check_name,
1410 description: &s.display_message(),
1411 severity: level,
1412 category: "Bug Risk",
1413 path: &path,
1414 begin_line: Some(s.line),
1415 fingerprint: &fp,
1416 }));
1417 }
1418}
1419
1420fn push_unused_catalog_entry_issues(
1421 issues: &mut Vec<CodeClimateIssue>,
1422 entries: &[fallow_core::results::UnusedCatalogEntryFinding],
1423 root: &Path,
1424 severity: Severity,
1425) {
1426 if entries.is_empty() {
1427 return;
1428 }
1429 let level = severity_to_codeclimate(severity);
1430 for entry in entries {
1431 let entry = &entry.entry;
1432 let path = cc_path(&entry.path, root);
1433 let line_str = entry.line.to_string();
1434 let fp = fingerprint_hash(&[
1435 "fallow/unused-catalog-entry",
1436 &path,
1437 &line_str,
1438 &entry.catalog_name,
1439 &entry.entry_name,
1440 ]);
1441 let description = if entry.catalog_name == "default" {
1442 format!(
1443 "Catalog entry '{}' is not referenced by any workspace package",
1444 entry.entry_name
1445 )
1446 } else {
1447 format!(
1448 "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
1449 entry.entry_name, entry.catalog_name
1450 )
1451 };
1452 issues.push(cc_issue(CcIssue {
1453 check_name: "fallow/unused-catalog-entry",
1454 description: &description,
1455 severity: level,
1456 category: "Bug Risk",
1457 path: &path,
1458 begin_line: Some(entry.line),
1459 fingerprint: &fp,
1460 }));
1461 }
1462}
1463
1464fn push_unresolved_catalog_reference_issues(
1465 issues: &mut Vec<CodeClimateIssue>,
1466 findings: &[fallow_core::results::UnresolvedCatalogReferenceFinding],
1467 root: &Path,
1468 severity: Severity,
1469) {
1470 if findings.is_empty() {
1471 return;
1472 }
1473 let level = severity_to_codeclimate(severity);
1474 for finding in findings {
1475 let finding = &finding.reference;
1476 let path = cc_path(&finding.path, root);
1477 let line_str = finding.line.to_string();
1478 let fp = fingerprint_hash(&[
1479 "fallow/unresolved-catalog-reference",
1480 &path,
1481 &line_str,
1482 &finding.catalog_name,
1483 &finding.entry_name,
1484 ]);
1485 let catalog_phrase = if finding.catalog_name == "default" {
1486 "the default catalog".to_string()
1487 } else {
1488 format!("catalog '{}'", finding.catalog_name)
1489 };
1490 let mut description = format!(
1491 "Package '{}' is referenced via `catalog:{}` but {} does not declare it; `pnpm install` will fail",
1492 finding.entry_name,
1493 if finding.catalog_name == "default" {
1494 ""
1495 } else {
1496 finding.catalog_name.as_str()
1497 },
1498 catalog_phrase,
1499 );
1500 if !finding.available_in_catalogs.is_empty() {
1501 use std::fmt::Write as _;
1502 let _ = write!(
1503 description,
1504 " (available in: {})",
1505 finding.available_in_catalogs.join(", ")
1506 );
1507 }
1508 issues.push(cc_issue(CcIssue {
1509 check_name: "fallow/unresolved-catalog-reference",
1510 description: &description,
1511 severity: level,
1512 category: "Bug Risk",
1513 path: &path,
1514 begin_line: Some(finding.line),
1515 fingerprint: &fp,
1516 }));
1517 }
1518}
1519
1520fn push_empty_catalog_group_issues(
1521 issues: &mut Vec<CodeClimateIssue>,
1522 groups: &[fallow_core::results::EmptyCatalogGroupFinding],
1523 root: &Path,
1524 severity: Severity,
1525) {
1526 if groups.is_empty() {
1527 return;
1528 }
1529 let level = severity_to_codeclimate(severity);
1530 for group in groups {
1531 let group = &group.group;
1532 let path = cc_path(&group.path, root);
1533 let line_str = group.line.to_string();
1534 let fp = fingerprint_hash(&[
1535 "fallow/empty-catalog-group",
1536 &path,
1537 &line_str,
1538 &group.catalog_name,
1539 ]);
1540 issues.push(cc_issue(CcIssue {
1541 check_name: "fallow/empty-catalog-group",
1542 description: &format!("Catalog group '{}' has no entries", group.catalog_name),
1543 severity: level,
1544 category: "Bug Risk",
1545 path: &path,
1546 begin_line: Some(group.line),
1547 fingerprint: &fp,
1548 }));
1549 }
1550}
1551
1552fn push_unused_dependency_override_issues(
1553 issues: &mut Vec<CodeClimateIssue>,
1554 findings: &[fallow_core::results::UnusedDependencyOverrideFinding],
1555 root: &Path,
1556 severity: Severity,
1557) {
1558 if findings.is_empty() {
1559 return;
1560 }
1561 let level = severity_to_codeclimate(severity);
1562 for finding in findings {
1563 let finding = &finding.entry;
1564 let path = cc_path(&finding.path, root);
1565 let line_str = finding.line.to_string();
1566 let fp = fingerprint_hash(&[
1567 "fallow/unused-dependency-override",
1568 &path,
1569 &line_str,
1570 finding.source.as_label(),
1571 &finding.raw_key,
1572 ]);
1573 let mut description = format!(
1574 "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
1575 finding.raw_key, finding.version_range, finding.target_package,
1576 );
1577 if let Some(hint) = &finding.hint {
1578 use std::fmt::Write as _;
1579 let _ = write!(description, " ({hint})");
1580 }
1581 issues.push(cc_issue(CcIssue {
1582 check_name: "fallow/unused-dependency-override",
1583 description: &description,
1584 severity: level,
1585 category: "Bug Risk",
1586 path: &path,
1587 begin_line: Some(finding.line),
1588 fingerprint: &fp,
1589 }));
1590 }
1591}
1592
1593fn push_misconfigured_dependency_override_issues(
1594 issues: &mut Vec<CodeClimateIssue>,
1595 findings: &[fallow_core::results::MisconfiguredDependencyOverrideFinding],
1596 root: &Path,
1597 severity: Severity,
1598) {
1599 if findings.is_empty() {
1600 return;
1601 }
1602 let level = severity_to_codeclimate(severity);
1603 for finding in findings {
1604 let finding = &finding.entry;
1605 let path = cc_path(&finding.path, root);
1606 let line_str = finding.line.to_string();
1607 let fp = fingerprint_hash(&[
1608 "fallow/misconfigured-dependency-override",
1609 &path,
1610 &line_str,
1611 finding.source.as_label(),
1612 &finding.raw_key,
1613 ]);
1614 let description = format!(
1615 "Override `{}` -> `{}` is malformed: {}",
1616 finding.raw_key,
1617 finding.raw_value,
1618 finding.reason.describe(),
1619 );
1620 issues.push(cc_issue(CcIssue {
1621 check_name: "fallow/misconfigured-dependency-override",
1622 description: &description,
1623 severity: level,
1624 category: "Bug Risk",
1625 path: &path,
1626 begin_line: Some(finding.line),
1627 fingerprint: &fp,
1628 }));
1629 }
1630}
1631
1632#[must_use]
1641#[expect(
1642 clippy::expect_used,
1643 reason = "CodeClimateIssue contains only infallibly serializable fields"
1644)]
1645pub fn issues_to_value(issues: &[CodeClimateIssue]) -> serde_json::Value {
1646 serde_json::to_value(issues).expect("CodeClimateIssue serializes infallibly")
1647}
1648
1649#[must_use]
1656pub fn build_codeclimate(
1657 results: &AnalysisResults,
1658 root: &Path,
1659 rules: &RulesConfig,
1660) -> Vec<CodeClimateIssue> {
1661 CodeClimateBuilder {
1662 issues: Vec::new(),
1663 results,
1664 root,
1665 rules,
1666 }
1667 .build()
1668}
1669
1670struct CodeClimateBuilder<'a> {
1671 issues: Vec<CodeClimateIssue>,
1672 results: &'a AnalysisResults,
1673 root: &'a Path,
1674 rules: &'a RulesConfig,
1675}
1676
1677impl CodeClimateBuilder<'_> {
1678 fn build(mut self) -> Vec<CodeClimateIssue> {
1679 self.push_file_and_export_issues();
1680 self.push_private_type_leak_issues();
1681 self.push_package_dependency_issues();
1682 self.push_type_test_dependency_issues();
1683 self.push_member_issues();
1684 self.push_import_and_duplicate_issues();
1685 self.push_graph_issues();
1686 self.push_boundary_issues();
1687 self.push_suppression_and_catalog_issues();
1688 self.push_override_issues();
1689 self.issues
1690 }
1691
1692 fn push_file_and_export_issues(&mut self) {
1693 push_unused_file_issues(
1694 &mut self.issues,
1695 &self.results.unused_files,
1696 self.root,
1697 self.rules.unused_files,
1698 );
1699 push_unused_export_issues(UnusedExportIssuesInput {
1700 issues: &mut self.issues,
1701 exports: self.results.unused_exports.iter().map(|e| &e.export),
1702 root: self.root,
1703 rule_id: "fallow/unused-export",
1704 direct_label: "Export",
1705 re_export_label: "Re-export",
1706 severity: self.rules.unused_exports,
1707 });
1708 push_unused_export_issues(UnusedExportIssuesInput {
1709 issues: &mut self.issues,
1710 exports: self.results.unused_types.iter().map(|e| &e.export),
1711 root: self.root,
1712 rule_id: "fallow/unused-type",
1713 direct_label: "Type export",
1714 re_export_label: "Type re-export",
1715 severity: self.rules.unused_types,
1716 });
1717 }
1718
1719 fn push_private_type_leak_issues(&mut self) {
1720 push_private_type_leak_issues(
1721 &mut self.issues,
1722 &self.results.private_type_leaks,
1723 self.root,
1724 self.rules.private_type_leaks,
1725 );
1726 }
1727
1728 fn push_package_dependency_issues(&mut self) {
1729 push_dep_cc_issues(
1730 &mut self.issues,
1731 self.results.unused_dependencies.iter().map(|f| &f.dep),
1732 self.root,
1733 "fallow/unused-dependency",
1734 "dependencies",
1735 self.rules.unused_dependencies,
1736 );
1737 push_dep_cc_issues(
1738 &mut self.issues,
1739 self.results.unused_dev_dependencies.iter().map(|f| &f.dep),
1740 self.root,
1741 "fallow/unused-dev-dependency",
1742 "devDependencies",
1743 self.rules.unused_dev_dependencies,
1744 );
1745 push_dep_cc_issues(
1746 &mut self.issues,
1747 self.results
1748 .unused_optional_dependencies
1749 .iter()
1750 .map(|f| &f.dep),
1751 self.root,
1752 "fallow/unused-optional-dependency",
1753 "optionalDependencies",
1754 self.rules.unused_optional_dependencies,
1755 );
1756 }
1757
1758 fn push_type_test_dependency_issues(&mut self) {
1759 push_type_only_dep_issues(
1760 &mut self.issues,
1761 &self.results.type_only_dependencies,
1762 self.root,
1763 self.rules.type_only_dependencies,
1764 );
1765 push_test_only_dep_issues(
1766 &mut self.issues,
1767 &self.results.test_only_dependencies,
1768 self.root,
1769 self.rules.test_only_dependencies,
1770 );
1771 }
1772
1773 fn push_member_issues(&mut self) {
1774 push_unused_member_issues(
1775 &mut self.issues,
1776 self.results.unused_enum_members.iter().map(|m| &m.member),
1777 self.root,
1778 "fallow/unused-enum-member",
1779 "Enum",
1780 self.rules.unused_enum_members,
1781 );
1782 push_unused_member_issues(
1783 &mut self.issues,
1784 self.results.unused_class_members.iter().map(|m| &m.member),
1785 self.root,
1786 "fallow/unused-class-member",
1787 "Class",
1788 self.rules.unused_class_members,
1789 );
1790 push_unused_member_issues(
1791 &mut self.issues,
1792 self.results.unused_store_members.iter().map(|m| &m.member),
1793 self.root,
1794 "fallow/unused-store-member",
1795 "Store",
1796 self.rules.unused_store_members,
1797 );
1798 }
1799
1800 fn push_import_and_duplicate_issues(&mut self) {
1801 push_unresolved_import_issues(
1802 &mut self.issues,
1803 &self.results.unresolved_imports,
1804 self.root,
1805 self.rules.unresolved_imports,
1806 );
1807 push_unlisted_dep_issues(
1808 &mut self.issues,
1809 &self.results.unlisted_dependencies,
1810 self.root,
1811 self.rules.unlisted_dependencies,
1812 );
1813 push_duplicate_export_issues(
1814 &mut self.issues,
1815 &self.results.duplicate_exports,
1816 self.root,
1817 self.rules.duplicate_exports,
1818 );
1819 }
1820
1821 fn push_graph_issues(&mut self) {
1822 push_circular_dep_issues(
1823 &mut self.issues,
1824 &self.results.circular_dependencies,
1825 self.root,
1826 self.rules.circular_dependencies,
1827 );
1828 push_re_export_cycle_issues(
1829 &mut self.issues,
1830 &self.results.re_export_cycles,
1831 self.root,
1832 self.rules.re_export_cycle,
1833 );
1834 }
1835
1836 fn push_boundary_issues(&mut self) {
1837 self.push_architecture_boundary_issues();
1838 self.push_client_server_boundary_issues();
1839 self.push_component_boundary_issues();
1840 self.push_framework_route_issues();
1841 }
1842
1843 fn push_architecture_boundary_issues(&mut self) {
1844 push_boundary_violation_issues(
1845 &mut self.issues,
1846 &self.results.boundary_violations,
1847 self.root,
1848 self.rules.boundary_violation,
1849 );
1850 push_boundary_coverage_issues(
1851 &mut self.issues,
1852 &self.results.boundary_coverage_violations,
1853 self.root,
1854 self.rules.boundary_violation,
1855 );
1856 push_boundary_call_issues(
1857 &mut self.issues,
1858 &self.results.boundary_call_violations,
1859 self.root,
1860 self.rules.boundary_violation,
1861 );
1862 push_policy_violation_issues(&mut self.issues, &self.results.policy_violations, self.root);
1863 }
1864
1865 fn push_client_server_boundary_issues(&mut self) {
1866 push_invalid_client_export_issues(
1867 &mut self.issues,
1868 &self.results.invalid_client_exports,
1869 self.root,
1870 self.rules.invalid_client_export,
1871 );
1872 push_mixed_client_server_barrel_issues(
1873 &mut self.issues,
1874 &self.results.mixed_client_server_barrels,
1875 self.root,
1876 self.rules.mixed_client_server_barrel,
1877 );
1878 push_misplaced_directive_issues(
1879 &mut self.issues,
1880 &self.results.misplaced_directives,
1881 self.root,
1882 self.rules.misplaced_directive,
1883 );
1884 }
1885
1886 fn push_component_boundary_issues(&mut self) {
1887 push_unprovided_inject_issues(
1888 &mut self.issues,
1889 &self.results.unprovided_injects,
1890 self.root,
1891 self.rules.unprovided_injects,
1892 );
1893 push_unrendered_component_issues(
1894 &mut self.issues,
1895 &self.results.unrendered_components,
1896 self.root,
1897 self.rules.unrendered_components,
1898 );
1899 push_unused_component_prop_issues(
1900 &mut self.issues,
1901 &self.results.unused_component_props,
1902 self.root,
1903 self.rules.unused_component_props,
1904 );
1905 push_unused_component_emit_issues(
1906 &mut self.issues,
1907 &self.results.unused_component_emits,
1908 self.root,
1909 self.rules.unused_component_emits,
1910 );
1911 push_unused_component_input_issues(
1912 &mut self.issues,
1913 &self.results.unused_component_inputs,
1914 self.root,
1915 self.rules.unused_component_inputs,
1916 );
1917 push_unused_component_output_issues(
1918 &mut self.issues,
1919 &self.results.unused_component_outputs,
1920 self.root,
1921 self.rules.unused_component_outputs,
1922 );
1923 push_unused_svelte_event_issues(
1924 &mut self.issues,
1925 &self.results.unused_svelte_events,
1926 self.root,
1927 self.rules.unused_svelte_events,
1928 );
1929 }
1930
1931 fn push_framework_route_issues(&mut self) {
1932 push_unused_server_action_issues(
1933 &mut self.issues,
1934 &self.results.unused_server_actions,
1935 self.root,
1936 self.rules.unused_server_actions,
1937 );
1938 push_unused_load_data_key_issues(
1939 &mut self.issues,
1940 &self.results.unused_load_data_keys,
1941 self.root,
1942 self.rules.unused_load_data_keys,
1943 );
1944 push_route_collision_issues(
1945 &mut self.issues,
1946 &self.results.route_collisions,
1947 self.root,
1948 self.rules.route_collision,
1949 );
1950 push_dynamic_segment_name_conflict_issues(
1951 &mut self.issues,
1952 &self.results.dynamic_segment_name_conflicts,
1953 self.root,
1954 self.rules.dynamic_segment_name_conflict,
1955 );
1956 }
1957
1958 fn push_suppression_and_catalog_issues(&mut self) {
1959 push_stale_suppression_issues(
1960 &mut self.issues,
1961 &self.results.stale_suppressions,
1962 self.root,
1963 self.rules,
1964 );
1965 push_unused_catalog_entry_issues(
1966 &mut self.issues,
1967 &self.results.unused_catalog_entries,
1968 self.root,
1969 self.rules.unused_catalog_entries,
1970 );
1971 push_empty_catalog_group_issues(
1972 &mut self.issues,
1973 &self.results.empty_catalog_groups,
1974 self.root,
1975 self.rules.empty_catalog_groups,
1976 );
1977 push_unresolved_catalog_reference_issues(
1978 &mut self.issues,
1979 &self.results.unresolved_catalog_references,
1980 self.root,
1981 self.rules.unresolved_catalog_references,
1982 );
1983 }
1984
1985 fn push_override_issues(&mut self) {
1986 push_unused_dependency_override_issues(
1987 &mut self.issues,
1988 &self.results.unused_dependency_overrides,
1989 self.root,
1990 self.rules.unused_dependency_overrides,
1991 );
1992 push_misconfigured_dependency_override_issues(
1993 &mut self.issues,
1994 &self.results.misconfigured_dependency_overrides,
1995 self.root,
1996 self.rules.misconfigured_dependency_overrides,
1997 );
1998 }
1999}
2000
2001pub(super) fn print_codeclimate(
2003 results: &AnalysisResults,
2004 root: &Path,
2005 rules: &RulesConfig,
2006) -> ExitCode {
2007 let issues = build_codeclimate(results, root, rules);
2008 let value = issues_to_value(&issues);
2009 emit_json(&value, "CodeClimate")
2010}
2011
2012#[expect(
2018 clippy::expect_used,
2019 reason = "grouped CodeClimate entries are JSON objects created by issues_to_value"
2020)]
2021pub(super) fn print_grouped_codeclimate(
2022 results: &AnalysisResults,
2023 root: &Path,
2024 rules: &RulesConfig,
2025 resolver: &OwnershipResolver,
2026) -> ExitCode {
2027 let issues = build_codeclimate(results, root, rules);
2028 let mut value = issues_to_value(&issues);
2029
2030 if let Some(items) = value.as_array_mut() {
2031 for issue in items {
2032 let path = issue
2033 .pointer("/location/path")
2034 .and_then(|v| v.as_str())
2035 .unwrap_or("");
2036 let owner = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
2037 issue
2038 .as_object_mut()
2039 .expect("CodeClimate issue should be an object")
2040 .insert("owner".to_string(), serde_json::Value::String(owner));
2041 }
2042 }
2043
2044 emit_json(&value, "CodeClimate")
2045}
2046
2047#[must_use]
2049pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> Vec<CodeClimateIssue> {
2050 let mut issues = Vec::new();
2051 let ctx = HealthCodeClimateContext {
2052 root,
2053 cyc_t: report.summary.max_cyclomatic_threshold,
2054 cog_t: report.summary.max_cognitive_threshold,
2055 crap_t: report.summary.max_crap_threshold,
2056 };
2057
2058 for finding in &report.findings {
2059 issues.push(ctx.complexity_issue(finding));
2060 }
2061
2062 if let Some(ref production) = report.runtime_coverage {
2063 for finding in &production.findings {
2064 issues.push(ctx.runtime_coverage_issue(finding));
2065 }
2066 }
2067
2068 if let Some(ref intelligence) = report.coverage_intelligence {
2069 for finding in &intelligence.findings {
2070 if let Some(issue) = ctx.coverage_intelligence_issue(finding) {
2071 issues.push(issue);
2072 }
2073 }
2074 }
2075
2076 if let Some(ref gaps) = report.coverage_gaps {
2077 for item in &gaps.files {
2078 issues.push(ctx.untested_file_issue(item));
2079 }
2080
2081 for item in &gaps.exports {
2082 issues.push(ctx.untested_export_issue(item));
2083 }
2084 }
2085
2086 issues
2087}
2088
2089pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
2091 let issues = build_health_codeclimate(report, root);
2092 let value = issues_to_value(&issues);
2093 emit_json(&value, "CodeClimate")
2094}
2095
2096#[expect(
2105 clippy::expect_used,
2106 reason = "grouped health CodeClimate entries are JSON objects created by issues_to_value"
2107)]
2108pub(super) fn print_grouped_health_codeclimate(
2109 report: &HealthReport,
2110 root: &Path,
2111 resolver: &OwnershipResolver,
2112) -> ExitCode {
2113 let issues = build_health_codeclimate(report, root);
2114 let mut value = issues_to_value(&issues);
2115
2116 if let Some(items) = value.as_array_mut() {
2117 for issue in items {
2118 let path = issue
2119 .pointer("/location/path")
2120 .and_then(|v| v.as_str())
2121 .unwrap_or("");
2122 let group = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
2123 issue
2124 .as_object_mut()
2125 .expect("CodeClimate issue should be an object")
2126 .insert("group".to_string(), serde_json::Value::String(group));
2127 }
2128 }
2129
2130 emit_json(&value, "CodeClimate")
2131}
2132
2133#[must_use]
2135#[expect(
2136 clippy::cast_possible_truncation,
2137 reason = "line numbers are bounded by source size"
2138)]
2139pub fn build_duplication_codeclimate(
2140 report: &DuplicationReport,
2141 root: &Path,
2142) -> Vec<CodeClimateIssue> {
2143 let mut issues = Vec::new();
2144
2145 for (i, group) in report.clone_groups.iter().enumerate() {
2146 let token_str = group.token_count.to_string();
2147 let line_count_str = group.line_count.to_string();
2148 let fragment_prefix: String = group
2149 .instances
2150 .first()
2151 .map(|inst| inst.fragment.chars().take(64).collect())
2152 .unwrap_or_default();
2153
2154 for instance in &group.instances {
2155 let path = cc_path(&instance.file, root);
2156 let start_str = instance.start_line.to_string();
2157 let fp = fingerprint_hash(&[
2158 "fallow/code-duplication",
2159 &path,
2160 &start_str,
2161 &token_str,
2162 &line_count_str,
2163 &fragment_prefix,
2164 ]);
2165 issues.push(cc_issue(CcIssue {
2166 check_name: "fallow/code-duplication",
2167 description: &format!(
2168 "Code clone group {} ({} lines, {} instances)",
2169 i + 1,
2170 group.line_count,
2171 group.instances.len()
2172 ),
2173 severity: CodeClimateSeverity::Minor,
2174 category: "Duplication",
2175 path: &path,
2176 begin_line: Some(instance.start_line as u32),
2177 fingerprint: &fp,
2178 }));
2179 }
2180 }
2181
2182 issues
2183}
2184
2185pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
2187 let issues = build_duplication_codeclimate(report, root);
2188 let value = issues_to_value(&issues);
2189 emit_json(&value, "CodeClimate")
2190}
2191
2192#[expect(
2201 clippy::expect_used,
2202 reason = "grouped duplication CodeClimate entries are JSON objects created by issues_to_value"
2203)]
2204pub(super) fn print_grouped_duplication_codeclimate(
2205 report: &DuplicationReport,
2206 root: &Path,
2207 resolver: &OwnershipResolver,
2208) -> ExitCode {
2209 let issues = build_duplication_codeclimate(report, root);
2210 let mut value = issues_to_value(&issues);
2211
2212 use rustc_hash::FxHashMap;
2213 let mut path_to_owner: FxHashMap<String, String> = FxHashMap::default();
2214 for group in &report.clone_groups {
2215 let owner = super::dupes_grouping::largest_owner(group, root, resolver);
2216 for instance in &group.instances {
2217 let path = cc_path(&instance.file, root);
2218 path_to_owner.insert(path, owner.clone());
2219 }
2220 }
2221
2222 if let Some(items) = value.as_array_mut() {
2223 for issue in items {
2224 let path = issue
2225 .pointer("/location/path")
2226 .and_then(|v| v.as_str())
2227 .unwrap_or("")
2228 .to_string();
2229 let group = path_to_owner
2230 .get(&path)
2231 .cloned()
2232 .unwrap_or_else(|| crate::codeowners::UNOWNED_LABEL.to_string());
2233 issue
2234 .as_object_mut()
2235 .expect("CodeClimate issue should be an object")
2236 .insert("group".to_string(), serde_json::Value::String(group));
2237 }
2238 }
2239
2240 emit_json(&value, "CodeClimate")
2241}
2242
2243#[cfg(test)]
2244mod tests {
2245 use super::*;
2246 use crate::report::test_helpers::sample_results;
2247 use fallow_config::RulesConfig;
2248 use fallow_core::results::*;
2249 use std::path::PathBuf;
2250
2251 fn health_severity(value: u16, threshold: u16) -> &'static str {
2254 if threshold == 0 {
2255 return "minor";
2256 }
2257 let ratio = f64::from(value) / f64::from(threshold);
2258 if ratio > 2.5 {
2259 "critical"
2260 } else if ratio > 1.5 {
2261 "major"
2262 } else {
2263 "minor"
2264 }
2265 }
2266
2267 #[test]
2268 fn codeclimate_empty_results_produces_empty_array() {
2269 let root = PathBuf::from("/project");
2270 let results = AnalysisResults::default();
2271 let rules = RulesConfig::default();
2272 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2273 let arr = output.as_array().unwrap();
2274 assert!(arr.is_empty());
2275 }
2276
2277 #[test]
2278 fn codeclimate_produces_array_of_issues() {
2279 let root = PathBuf::from("/project");
2280 let results = sample_results(&root);
2281 let rules = RulesConfig::default();
2282 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2283 assert!(output.is_array());
2284 let arr = output.as_array().unwrap();
2285 assert!(!arr.is_empty());
2286 }
2287
2288 #[test]
2289 fn codeclimate_missing_suppression_reason_uses_reason_rule_severity() {
2290 let root = PathBuf::from("/project");
2291 let mut results = AnalysisResults::default();
2292 results.stale_suppressions.push(StaleSuppression {
2293 path: root.join("src/file.ts"),
2294 line: 1,
2295 col: 0,
2296 origin: SuppressionOrigin::Comment {
2297 issue_kind: Some("unused-exports".to_string()),
2298 reason: None,
2299 is_file_level: false,
2300 kind_known: true,
2301 },
2302 missing_reason: true,
2303 actions: StaleSuppression::actions_for(true),
2304 });
2305 let rules = RulesConfig {
2306 stale_suppressions: Severity::Off,
2307 require_suppression_reason: Severity::Error,
2308 ..Default::default()
2309 };
2310
2311 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2312
2313 assert_eq!(output[0]["check_name"], "fallow/missing-suppression-reason");
2314 assert_eq!(output[0]["severity"], "major");
2315 }
2316
2317 #[test]
2318 fn codeclimate_stale_and_missing_suppression_have_distinct_identities() {
2319 let root = PathBuf::from("/project");
2320 let mut results = AnalysisResults::default();
2321 let origin = SuppressionOrigin::Comment {
2322 issue_kind: Some("unused-exports".to_string()),
2323 reason: None,
2324 is_file_level: false,
2325 kind_known: true,
2326 };
2327 results.stale_suppressions.push(StaleSuppression {
2328 path: root.join("src/file.ts"),
2329 line: 1,
2330 col: 0,
2331 origin: origin.clone(),
2332 missing_reason: false,
2333 actions: StaleSuppression::actions_for(false),
2334 });
2335 results.stale_suppressions.push(StaleSuppression {
2336 path: root.join("src/file.ts"),
2337 line: 1,
2338 col: 0,
2339 origin,
2340 missing_reason: true,
2341 actions: StaleSuppression::actions_for(true),
2342 });
2343 let rules = RulesConfig {
2344 stale_suppressions: Severity::Warn,
2345 require_suppression_reason: Severity::Error,
2346 ..Default::default()
2347 };
2348
2349 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2350
2351 assert_eq!(output[0]["check_name"], "fallow/stale-suppression");
2352 assert_eq!(output[1]["check_name"], "fallow/missing-suppression-reason");
2353 assert_ne!(output[0]["fingerprint"], output[1]["fingerprint"]);
2354 }
2355
2356 #[test]
2357 fn codeclimate_issue_has_required_fields() {
2358 let root = PathBuf::from("/project");
2359 let mut results = AnalysisResults::default();
2360 results
2361 .unused_files
2362 .push(UnusedFileFinding::with_actions(UnusedFile {
2363 path: root.join("src/dead.ts"),
2364 }));
2365 let rules = RulesConfig::default();
2366 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2367 let issue = &output.as_array().unwrap()[0];
2368
2369 assert_eq!(issue["type"], "issue");
2370 assert_eq!(issue["check_name"], "fallow/unused-file");
2371 assert!(issue["description"].is_string());
2372 assert!(issue["categories"].is_array());
2373 assert!(issue["severity"].is_string());
2374 assert!(issue["fingerprint"].is_string());
2375 assert!(issue["location"].is_object());
2376 assert!(issue["location"]["path"].is_string());
2377 assert!(issue["location"]["lines"].is_object());
2378 }
2379
2380 #[test]
2381 fn codeclimate_unused_file_severity_follows_rules() {
2382 let root = PathBuf::from("/project");
2383 let mut results = AnalysisResults::default();
2384 results
2385 .unused_files
2386 .push(UnusedFileFinding::with_actions(UnusedFile {
2387 path: root.join("src/dead.ts"),
2388 }));
2389
2390 let rules = RulesConfig::default();
2391 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2392 assert_eq!(output[0]["severity"], "major");
2393
2394 let rules = RulesConfig {
2395 unused_files: Severity::Warn,
2396 ..RulesConfig::default()
2397 };
2398 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2399 assert_eq!(output[0]["severity"], "minor");
2400 }
2401
2402 #[test]
2403 fn codeclimate_unused_export_has_line_number() {
2404 let root = PathBuf::from("/project");
2405 let mut results = AnalysisResults::default();
2406 results
2407 .unused_exports
2408 .push(UnusedExportFinding::with_actions(UnusedExport {
2409 path: root.join("src/utils.ts"),
2410 export_name: "helperFn".to_string(),
2411 is_type_only: false,
2412 line: 10,
2413 col: 4,
2414 span_start: 120,
2415 is_re_export: false,
2416 }));
2417 let rules = RulesConfig::default();
2418 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2419 let issue = &output[0];
2420 assert_eq!(issue["location"]["lines"]["begin"], 10);
2421 }
2422
2423 #[test]
2424 fn codeclimate_unused_file_line_defaults_to_1() {
2425 let root = PathBuf::from("/project");
2426 let mut results = AnalysisResults::default();
2427 results
2428 .unused_files
2429 .push(UnusedFileFinding::with_actions(UnusedFile {
2430 path: root.join("src/dead.ts"),
2431 }));
2432 let rules = RulesConfig::default();
2433 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2434 let issue = &output[0];
2435 assert_eq!(issue["location"]["lines"]["begin"], 1);
2436 }
2437
2438 #[test]
2439 fn codeclimate_paths_are_relative() {
2440 let root = PathBuf::from("/project");
2441 let mut results = AnalysisResults::default();
2442 results
2443 .unused_files
2444 .push(UnusedFileFinding::with_actions(UnusedFile {
2445 path: root.join("src/deep/nested/file.ts"),
2446 }));
2447 let rules = RulesConfig::default();
2448 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2449 let path = output[0]["location"]["path"].as_str().unwrap();
2450 assert_eq!(path, "src/deep/nested/file.ts");
2451 assert!(!path.starts_with("/project"));
2452 }
2453
2454 #[test]
2455 fn codeclimate_re_export_label_in_description() {
2456 let root = PathBuf::from("/project");
2457 let mut results = AnalysisResults::default();
2458 results
2459 .unused_exports
2460 .push(UnusedExportFinding::with_actions(UnusedExport {
2461 path: root.join("src/index.ts"),
2462 export_name: "reExported".to_string(),
2463 is_type_only: false,
2464 line: 1,
2465 col: 0,
2466 span_start: 0,
2467 is_re_export: true,
2468 }));
2469 let rules = RulesConfig::default();
2470 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2471 let desc = output[0]["description"].as_str().unwrap();
2472 assert!(desc.contains("Re-export"));
2473 }
2474
2475 #[test]
2476 fn codeclimate_unlisted_dep_one_issue_per_import_site() {
2477 let root = PathBuf::from("/project");
2478 let mut results = AnalysisResults::default();
2479 results
2480 .unlisted_dependencies
2481 .push(UnlistedDependencyFinding::with_actions(
2482 UnlistedDependency {
2483 package_name: "chalk".to_string(),
2484 imported_from: vec![
2485 ImportSite {
2486 path: root.join("src/a.ts"),
2487 line: 1,
2488 col: 0,
2489 },
2490 ImportSite {
2491 path: root.join("src/b.ts"),
2492 line: 5,
2493 col: 0,
2494 },
2495 ],
2496 },
2497 ));
2498 let rules = RulesConfig::default();
2499 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2500 let arr = output.as_array().unwrap();
2501 assert_eq!(arr.len(), 2);
2502 assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
2503 assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
2504 }
2505
2506 #[test]
2507 fn codeclimate_duplicate_export_one_issue_per_location() {
2508 let root = PathBuf::from("/project");
2509 let mut results = AnalysisResults::default();
2510 results
2511 .duplicate_exports
2512 .push(DuplicateExportFinding::with_actions(DuplicateExport {
2513 export_name: "Config".to_string(),
2514 locations: vec![
2515 DuplicateLocation {
2516 path: root.join("src/a.ts"),
2517 line: 10,
2518 col: 0,
2519 },
2520 DuplicateLocation {
2521 path: root.join("src/b.ts"),
2522 line: 20,
2523 col: 0,
2524 },
2525 DuplicateLocation {
2526 path: root.join("src/c.ts"),
2527 line: 30,
2528 col: 0,
2529 },
2530 ],
2531 }));
2532 let rules = RulesConfig::default();
2533 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2534 let arr = output.as_array().unwrap();
2535 assert_eq!(arr.len(), 3);
2536 }
2537
2538 #[test]
2539 fn codeclimate_circular_dep_emits_chain_in_description() {
2540 let root = PathBuf::from("/project");
2541 let mut results = AnalysisResults::default();
2542 results
2543 .circular_dependencies
2544 .push(CircularDependencyFinding::with_actions(
2545 CircularDependency {
2546 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2547 length: 2,
2548 line: 3,
2549 col: 0,
2550 edges: Vec::new(),
2551 is_cross_package: false,
2552 },
2553 ));
2554 let rules = RulesConfig::default();
2555 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2556 let desc = output[0]["description"].as_str().unwrap();
2557 assert!(desc.contains("Circular dependency"));
2558 assert!(desc.contains("src/a.ts"));
2559 assert!(desc.contains("src/b.ts"));
2560 }
2561
2562 #[test]
2563 fn codeclimate_fingerprints_are_deterministic() {
2564 let root = PathBuf::from("/project");
2565 let results = sample_results(&root);
2566 let rules = RulesConfig::default();
2567 let output1 = issues_to_value(&build_codeclimate(&results, &root, &rules));
2568 let output2 = issues_to_value(&build_codeclimate(&results, &root, &rules));
2569
2570 let fps1: Vec<&str> = output1
2571 .as_array()
2572 .unwrap()
2573 .iter()
2574 .map(|i| i["fingerprint"].as_str().unwrap())
2575 .collect();
2576 let fps2: Vec<&str> = output2
2577 .as_array()
2578 .unwrap()
2579 .iter()
2580 .map(|i| i["fingerprint"].as_str().unwrap())
2581 .collect();
2582 assert_eq!(fps1, fps2);
2583 }
2584
2585 #[test]
2586 fn codeclimate_fingerprints_are_unique() {
2587 let root = PathBuf::from("/project");
2588 let results = sample_results(&root);
2589 let rules = RulesConfig::default();
2590 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2591
2592 let mut fps: Vec<&str> = output
2593 .as_array()
2594 .unwrap()
2595 .iter()
2596 .map(|i| i["fingerprint"].as_str().unwrap())
2597 .collect();
2598 let original_len = fps.len();
2599 fps.sort_unstable();
2600 fps.dedup();
2601 assert_eq!(fps.len(), original_len, "fingerprints should be unique");
2602 }
2603
2604 #[test]
2605 fn codeclimate_type_only_dep_has_correct_check_name() {
2606 let root = PathBuf::from("/project");
2607 let mut results = AnalysisResults::default();
2608 results
2609 .type_only_dependencies
2610 .push(TypeOnlyDependencyFinding::with_actions(
2611 TypeOnlyDependency {
2612 package_name: "zod".to_string(),
2613 path: root.join("package.json"),
2614 line: 8,
2615 },
2616 ));
2617 let rules = RulesConfig::default();
2618 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2619 assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
2620 let desc = output[0]["description"].as_str().unwrap();
2621 assert!(desc.contains("zod"));
2622 assert!(desc.contains("type-only"));
2623 }
2624
2625 #[test]
2626 fn codeclimate_dep_with_zero_line_omits_line_number() {
2627 let root = PathBuf::from("/project");
2628 let mut results = AnalysisResults::default();
2629 results
2630 .unused_dependencies
2631 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2632 package_name: "lodash".to_string(),
2633 location: DependencyLocation::Dependencies,
2634 path: root.join("package.json"),
2635 line: 0,
2636 used_in_workspaces: Vec::new(),
2637 }));
2638 let rules = RulesConfig::default();
2639 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
2640 assert_eq!(output[0]["location"]["lines"]["begin"], 1);
2641 }
2642
2643 #[test]
2644 fn fingerprint_hash_different_inputs_differ() {
2645 let h1 = fingerprint_hash(&["a", "b"]);
2646 let h2 = fingerprint_hash(&["a", "c"]);
2647 assert_ne!(h1, h2);
2648 }
2649
2650 #[test]
2651 fn fingerprint_hash_order_matters() {
2652 let h1 = fingerprint_hash(&["a", "b"]);
2653 let h2 = fingerprint_hash(&["b", "a"]);
2654 assert_ne!(h1, h2);
2655 }
2656
2657 #[test]
2658 fn fingerprint_hash_separator_prevents_collision() {
2659 let h1 = fingerprint_hash(&["ab", "c"]);
2660 let h2 = fingerprint_hash(&["a", "bc"]);
2661 assert_ne!(h1, h2);
2662 }
2663
2664 #[test]
2665 fn fingerprint_hash_is_16_hex_chars() {
2666 let h = fingerprint_hash(&["test"]);
2667 assert_eq!(h.len(), 16);
2668 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
2669 }
2670
2671 #[test]
2672 fn severity_error_maps_to_major() {
2673 assert_eq!(
2674 severity_to_codeclimate(Severity::Error),
2675 CodeClimateSeverity::Major
2676 );
2677 }
2678
2679 #[test]
2680 fn severity_warn_maps_to_minor() {
2681 assert_eq!(
2682 severity_to_codeclimate(Severity::Warn),
2683 CodeClimateSeverity::Minor
2684 );
2685 }
2686
2687 #[test]
2688 #[should_panic(expected = "internal error: entered unreachable code")]
2689 fn severity_off_is_unreachable() {
2690 let _ = severity_to_codeclimate(Severity::Off);
2691 }
2692
2693 #[test]
2705 fn build_codeclimate_with_off_severity_and_empty_findings_does_not_panic() {
2706 let root = PathBuf::from("/project");
2707 let results = AnalysisResults::default();
2708 let rules = RulesConfig {
2709 unused_dependencies: Severity::Off,
2710 unused_dev_dependencies: Severity::Off,
2711 unused_optional_dependencies: Severity::Off,
2712 unused_exports: Severity::Off,
2713 unused_types: Severity::Off,
2714 unused_enum_members: Severity::Off,
2715 unused_class_members: Severity::Off,
2716 ..RulesConfig::default()
2717 };
2718 let issues = build_codeclimate(&results, &root, &rules);
2719 assert!(issues.is_empty());
2720 }
2721
2722 #[test]
2723 fn health_severity_zero_threshold_returns_minor() {
2724 assert_eq!(health_severity(100, 0), "minor");
2725 }
2726
2727 #[test]
2728 fn health_severity_at_threshold_returns_minor() {
2729 assert_eq!(health_severity(10, 10), "minor");
2730 }
2731
2732 #[test]
2733 fn health_severity_1_5x_threshold_returns_minor() {
2734 assert_eq!(health_severity(15, 10), "minor");
2735 }
2736
2737 #[test]
2738 fn health_severity_above_1_5x_returns_major() {
2739 assert_eq!(health_severity(16, 10), "major");
2740 }
2741
2742 #[test]
2743 fn health_severity_at_2_5x_returns_major() {
2744 assert_eq!(health_severity(25, 10), "major");
2745 }
2746
2747 #[test]
2748 fn health_severity_above_2_5x_returns_critical() {
2749 assert_eq!(health_severity(26, 10), "critical");
2750 }
2751
2752 #[test]
2753 fn health_codeclimate_includes_coverage_gaps() {
2754 use crate::health_types::*;
2755
2756 let root = PathBuf::from("/project");
2757 let report = HealthReport {
2758 summary: HealthSummary {
2759 files_analyzed: 10,
2760 functions_analyzed: 50,
2761 ..Default::default()
2762 },
2763 coverage_gaps: Some(CoverageGaps {
2764 summary: CoverageGapSummary {
2765 runtime_files: 2,
2766 covered_files: 0,
2767 file_coverage_pct: 0.0,
2768 untested_files: 1,
2769 untested_exports: 1,
2770 },
2771 files: vec![UntestedFileFinding::with_actions(
2772 UntestedFile {
2773 path: root.join("src/app.ts"),
2774 value_export_count: 2,
2775 },
2776 &root,
2777 )],
2778 exports: vec![UntestedExportFinding::with_actions(
2779 UntestedExport {
2780 path: root.join("src/app.ts"),
2781 export_name: "loader".into(),
2782 line: 12,
2783 col: 4,
2784 },
2785 &root,
2786 )],
2787 }),
2788 ..Default::default()
2789 };
2790
2791 let output = issues_to_value(&build_health_codeclimate(&report, &root));
2792 let issues = output.as_array().unwrap();
2793 assert_eq!(issues.len(), 2);
2794 assert_eq!(issues[0]["check_name"], "fallow/untested-file");
2795 assert_eq!(issues[0]["categories"][0], "Coverage");
2796 assert_eq!(issues[0]["location"]["path"], "src/app.ts");
2797 assert_eq!(issues[1]["check_name"], "fallow/untested-export");
2798 assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
2799 assert!(
2800 issues[1]["description"]
2801 .as_str()
2802 .unwrap()
2803 .contains("loader")
2804 );
2805 }
2806
2807 #[test]
2808 fn health_codeclimate_includes_coverage_intelligence_issue() {
2809 use crate::health_types::{
2810 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2811 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2812 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2813 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2814 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2815 HealthReport, HealthSummary,
2816 };
2817
2818 let root = PathBuf::from("/project");
2819 let report = HealthReport {
2820 summary: HealthSummary {
2821 files_analyzed: 10,
2822 functions_analyzed: 50,
2823 ..Default::default()
2824 },
2825 coverage_intelligence: Some(CoverageIntelligenceReport {
2826 schema_version: CoverageIntelligenceSchemaVersion::V1,
2827 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2828 summary: CoverageIntelligenceSummary {
2829 findings: 1,
2830 high_confidence_deletes: 1,
2831 ..Default::default()
2832 },
2833 findings: vec![CoverageIntelligenceFinding {
2834 id: "fallow:coverage-intel:abc123".to_owned(),
2835 path: root.join("src/dead.ts"),
2836 identity: Some("deadPath".to_owned()),
2837 line: 9,
2838 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2839 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2840 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2841 confidence: CoverageIntelligenceConfidence::High,
2842 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2843 evidence: CoverageIntelligenceEvidence {
2844 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2845 ..Default::default()
2846 },
2847 actions: vec![CoverageIntelligenceAction {
2848 kind: "delete-after-confirming-owner".to_owned(),
2849 description: "Confirm ownership".to_owned(),
2850 auto_fixable: false,
2851 }],
2852 }],
2853 }),
2854 ..Default::default()
2855 };
2856
2857 let output = issues_to_value(&build_health_codeclimate(&report, &root));
2858 let issues = output.as_array().unwrap();
2859 assert_eq!(issues.len(), 1);
2860 assert_eq!(
2861 issues[0]["check_name"],
2862 "fallow/coverage-intelligence-delete"
2863 );
2864 assert!(!issues[0]["fingerprint"].as_str().unwrap().is_empty());
2865 assert_eq!(issues[0]["location"]["path"], "src/dead.ts");
2866 assert!(
2867 issues[0]["description"]
2868 .as_str()
2869 .unwrap()
2870 .contains("deadPath")
2871 );
2872 }
2873
2874 #[test]
2875 fn health_codeclimate_skips_summary_only_coverage_intelligence() {
2876 use crate::health_types::{
2877 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2878 CoverageIntelligenceSummary, CoverageIntelligenceVerdict, HealthReport,
2879 };
2880
2881 let root = PathBuf::from("/project");
2882 let report = HealthReport {
2883 coverage_intelligence: Some(CoverageIntelligenceReport {
2884 schema_version: CoverageIntelligenceSchemaVersion::V1,
2885 verdict: CoverageIntelligenceVerdict::Clean,
2886 summary: CoverageIntelligenceSummary {
2887 skipped_ambiguous_matches: 2,
2888 ..Default::default()
2889 },
2890 findings: vec![],
2891 }),
2892 ..Default::default()
2893 };
2894
2895 let issues = build_health_codeclimate(&report, &root);
2896 assert!(issues.is_empty());
2897 }
2898
2899 #[test]
2900 fn health_codeclimate_crap_only_uses_crap_check_name() {
2901 use crate::health_types::{
2902 ComplexityViolation, FindingSeverity, HealthReport, HealthSummary,
2903 };
2904 let root = PathBuf::from("/project");
2905 let report = HealthReport {
2906 findings: vec![
2907 ComplexityViolation {
2908 path: root.join("src/untested.ts"),
2909 name: "risky".to_string(),
2910 line: 7,
2911 col: 0,
2912 cyclomatic: 10,
2913 cognitive: 10,
2914 line_count: 20,
2915 param_count: 1,
2916 react_hook_count: 0,
2917 react_jsx_max_depth: 0,
2918 react_prop_count: 0,
2919 react_hook_profile: None,
2920 exceeded: crate::health_types::ExceededThreshold::Crap,
2921 severity: FindingSeverity::High,
2922 crap: Some(60.0),
2923 coverage_pct: Some(25.0),
2924 coverage_tier: None,
2925 coverage_source: None,
2926 inherited_from: None,
2927 component_rollup: None,
2928 contributions: Vec::new(),
2929 effective_thresholds: None,
2930 threshold_source: None,
2931 }
2932 .into(),
2933 ],
2934 summary: HealthSummary {
2935 functions_analyzed: 10,
2936 functions_above_threshold: 1,
2937 ..Default::default()
2938 },
2939 ..Default::default()
2940 };
2941 let json = issues_to_value(&build_health_codeclimate(&report, &root));
2942 let issues = json.as_array().unwrap();
2943 assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
2944 assert_eq!(issues[0]["severity"], "major");
2945 let description = issues[0]["description"].as_str().unwrap();
2946 assert!(description.contains("CRAP score"), "desc: {description}");
2947 assert!(description.contains("coverage 25%"), "desc: {description}");
2948 }
2949
2950 #[test]
2955 fn coverage_intelligence_check_name_review_before_changing() {
2956 use crate::health_types::CoverageIntelligenceRecommendation;
2957 assert_eq!(
2958 coverage_intelligence_check_name(
2959 CoverageIntelligenceRecommendation::ReviewBeforeChanging
2960 ),
2961 "fallow/coverage-intelligence-review"
2962 );
2963 }
2964
2965 #[test]
2966 fn coverage_intelligence_check_name_refactor_carefully() {
2967 use crate::health_types::CoverageIntelligenceRecommendation;
2968 assert_eq!(
2969 coverage_intelligence_check_name(
2970 CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior
2971 ),
2972 "fallow/coverage-intelligence-refactor"
2973 );
2974 }
2975
2976 #[test]
2981 fn complexity_description_both_threshold_exceeded() {
2982 use crate::health_types::{ComplexityViolation, ExceededThreshold, FindingSeverity};
2983 let root = PathBuf::from("/project");
2984 let report = crate::health_types::HealthReport {
2985 findings: vec![
2986 ComplexityViolation {
2987 path: root.join("src/fn.ts"),
2988 name: "bothFn".to_string(),
2989 line: 1,
2990 col: 0,
2991 cyclomatic: 25,
2992 cognitive: 20,
2993 line_count: 50,
2994 param_count: 2,
2995 react_hook_count: 0,
2996 react_jsx_max_depth: 0,
2997 react_prop_count: 0,
2998 react_hook_profile: None,
2999 exceeded: ExceededThreshold::Both,
3000 severity: FindingSeverity::High,
3001 crap: None,
3002 coverage_pct: None,
3003 coverage_tier: None,
3004 coverage_source: None,
3005 inherited_from: None,
3006 component_rollup: None,
3007 contributions: vec![],
3008 effective_thresholds: None,
3009 threshold_source: None,
3010 }
3011 .into(),
3012 ],
3013 ..Default::default()
3014 };
3015 let json = issues_to_value(&build_health_codeclimate(&report, &root));
3016 let issues = json.as_array().unwrap();
3017 assert_eq!(issues[0]["check_name"], "fallow/high-complexity");
3018 let desc = issues[0]["description"].as_str().unwrap();
3019 assert!(desc.contains("cyclomatic complexity"), "desc: {desc}");
3020 assert!(desc.contains("cognitive complexity"), "desc: {desc}");
3021 }
3022
3023 #[test]
3024 fn complexity_description_cyclomatic_only() {
3025 use crate::health_types::{ComplexityViolation, ExceededThreshold, FindingSeverity};
3026 let root = PathBuf::from("/project");
3027 let report = crate::health_types::HealthReport {
3028 findings: vec![
3029 ComplexityViolation {
3030 path: root.join("src/fn.ts"),
3031 name: "cycFn".to_string(),
3032 line: 1,
3033 col: 0,
3034 cyclomatic: 25,
3035 cognitive: 5,
3036 line_count: 30,
3037 param_count: 1,
3038 react_hook_count: 0,
3039 react_jsx_max_depth: 0,
3040 react_prop_count: 0,
3041 react_hook_profile: None,
3042 exceeded: ExceededThreshold::Cyclomatic,
3043 severity: FindingSeverity::High,
3044 crap: None,
3045 coverage_pct: None,
3046 coverage_tier: None,
3047 coverage_source: None,
3048 inherited_from: None,
3049 component_rollup: None,
3050 contributions: vec![],
3051 effective_thresholds: None,
3052 threshold_source: None,
3053 }
3054 .into(),
3055 ],
3056 ..Default::default()
3057 };
3058 let json = issues_to_value(&build_health_codeclimate(&report, &root));
3059 let issues = json.as_array().unwrap();
3060 assert_eq!(issues[0]["check_name"], "fallow/high-cyclomatic-complexity");
3061 let desc = issues[0]["description"].as_str().unwrap();
3062 assert!(desc.contains("cyclomatic complexity"), "desc: {desc}");
3063 assert!(
3064 !desc.contains("cognitive"),
3065 "desc should not mention cognitive: {desc}"
3066 );
3067 }
3068
3069 #[test]
3070 fn complexity_description_cognitive_only() {
3071 use crate::health_types::{ComplexityViolation, ExceededThreshold, FindingSeverity};
3072 let root = PathBuf::from("/project");
3073 let report = crate::health_types::HealthReport {
3074 findings: vec![
3075 ComplexityViolation {
3076 path: root.join("src/fn.ts"),
3077 name: "cogFn".to_string(),
3078 line: 1,
3079 col: 0,
3080 cyclomatic: 5,
3081 cognitive: 30,
3082 line_count: 30,
3083 param_count: 1,
3084 react_hook_count: 0,
3085 react_jsx_max_depth: 0,
3086 react_prop_count: 0,
3087 react_hook_profile: None,
3088 exceeded: ExceededThreshold::Cognitive,
3089 severity: FindingSeverity::High,
3090 crap: None,
3091 coverage_pct: None,
3092 coverage_tier: None,
3093 coverage_source: None,
3094 inherited_from: None,
3095 component_rollup: None,
3096 contributions: vec![],
3097 effective_thresholds: None,
3098 threshold_source: None,
3099 }
3100 .into(),
3101 ],
3102 ..Default::default()
3103 };
3104 let json = issues_to_value(&build_health_codeclimate(&report, &root));
3105 let issues = json.as_array().unwrap();
3106 assert_eq!(issues[0]["check_name"], "fallow/high-cognitive-complexity");
3107 let desc = issues[0]["description"].as_str().unwrap();
3108 assert!(desc.contains("cognitive complexity"), "desc: {desc}");
3109 assert!(!desc.contains("cyclomatic"), "desc: {desc}");
3110 }
3111
3112 #[test]
3113 fn complexity_description_all_threshold_crap_no_coverage() {
3114 use crate::health_types::{ComplexityViolation, ExceededThreshold, FindingSeverity};
3115 let root = PathBuf::from("/project");
3116 let report = crate::health_types::HealthReport {
3117 findings: vec![
3118 ComplexityViolation {
3119 path: root.join("src/fn.ts"),
3120 name: "allFn".to_string(),
3121 line: 1,
3122 col: 0,
3123 cyclomatic: 30,
3124 cognitive: 20,
3125 line_count: 80,
3126 param_count: 2,
3127 react_hook_count: 0,
3128 react_jsx_max_depth: 0,
3129 react_prop_count: 0,
3130 react_hook_profile: None,
3131 exceeded: ExceededThreshold::All,
3132 severity: FindingSeverity::Critical,
3133 crap: Some(110.0),
3134 coverage_pct: None,
3135 coverage_tier: None,
3136 coverage_source: None,
3137 inherited_from: None,
3138 component_rollup: None,
3139 contributions: vec![],
3140 effective_thresholds: None,
3141 threshold_source: None,
3142 }
3143 .into(),
3144 ],
3145 ..Default::default()
3146 };
3147 let json = issues_to_value(&build_health_codeclimate(&report, &root));
3148 let issues = json.as_array().unwrap();
3149 assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
3150 let desc = issues[0]["description"].as_str().unwrap();
3151 assert!(desc.contains("CRAP score"), "desc: {desc}");
3152 assert!(
3153 !desc.contains("coverage"),
3154 "no coverage pct should appear: {desc}"
3155 );
3156 }
3157
3158 #[test]
3163 fn coverage_intelligence_issue_none_identity_uses_code_label() {
3164 use crate::health_types::{
3165 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
3166 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
3167 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
3168 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
3169 CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
3170 };
3171 let root = PathBuf::from("/project");
3172 let report = crate::health_types::HealthReport {
3173 coverage_intelligence: Some(CoverageIntelligenceReport {
3174 schema_version: CoverageIntelligenceSchemaVersion::V1,
3175 verdict: CoverageIntelligenceVerdict::ReviewRequired,
3176 summary: CoverageIntelligenceSummary {
3177 findings: 1,
3178 ..Default::default()
3179 },
3180 findings: vec![CoverageIntelligenceFinding {
3181 id: "fallow:coverage-intel:xyz".to_owned(),
3182 path: root.join("src/util.ts"),
3183 identity: None,
3184 line: 3,
3185 verdict: CoverageIntelligenceVerdict::ReviewRequired,
3186 signals: vec![],
3187 recommendation: CoverageIntelligenceRecommendation::ReviewBeforeChanging,
3188 confidence: CoverageIntelligenceConfidence::Low,
3189 related_ids: vec![],
3190 evidence: CoverageIntelligenceEvidence {
3191 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
3192 ..Default::default()
3193 },
3194 actions: vec![CoverageIntelligenceAction {
3195 kind: "review".to_owned(),
3196 description: "Review before changing".to_owned(),
3197 auto_fixable: false,
3198 }],
3199 }],
3200 }),
3201 ..Default::default()
3202 };
3203 let json = issues_to_value(&build_health_codeclimate(&report, &root));
3204 let issues = json.as_array().unwrap();
3205 assert_eq!(issues.len(), 1);
3206 assert_eq!(
3207 issues[0]["check_name"],
3208 "fallow/coverage-intelligence-review"
3209 );
3210 let desc = issues[0]["description"].as_str().unwrap();
3211 assert!(
3212 desc.contains("'code'"),
3213 "description should say 'code': {desc}"
3214 );
3215 }
3216
3217 #[test]
3222 fn coverage_intelligence_clean_verdict_emits_no_issue() {
3223 use crate::health_types::{
3224 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
3225 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
3226 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
3227 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
3228 CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
3229 };
3230 let root = PathBuf::from("/project");
3231 let report = crate::health_types::HealthReport {
3232 coverage_intelligence: Some(CoverageIntelligenceReport {
3233 schema_version: CoverageIntelligenceSchemaVersion::V1,
3234 verdict: CoverageIntelligenceVerdict::Clean,
3235 summary: CoverageIntelligenceSummary {
3236 findings: 1,
3237 ..Default::default()
3238 },
3239 findings: vec![CoverageIntelligenceFinding {
3240 id: "fallow:coverage-intel:clean".to_owned(),
3241 path: root.join("src/util.ts"),
3242 identity: Some("cleanFn".to_owned()),
3243 line: 3,
3244 verdict: CoverageIntelligenceVerdict::Clean,
3245 signals: vec![],
3246 recommendation: CoverageIntelligenceRecommendation::ReviewBeforeChanging,
3247 confidence: CoverageIntelligenceConfidence::Low,
3248 related_ids: vec![],
3249 evidence: CoverageIntelligenceEvidence {
3250 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
3251 ..Default::default()
3252 },
3253 actions: vec![CoverageIntelligenceAction {
3254 kind: "review".to_owned(),
3255 description: "Review before changing".to_owned(),
3256 auto_fixable: false,
3257 }],
3258 }],
3259 }),
3260 ..Default::default()
3261 };
3262 let issues = build_health_codeclimate(&report, &root);
3263 assert!(
3264 issues.is_empty(),
3265 "Clean verdict should not produce an issue"
3266 );
3267 }
3268
3269 #[test]
3274 fn untested_file_singular_export_count() {
3275 use crate::health_types::{
3276 CoverageGapSummary, CoverageGaps, UntestedFile, UntestedFileFinding,
3277 };
3278 let root = PathBuf::from("/project");
3279 let report = crate::health_types::HealthReport {
3280 coverage_gaps: Some(CoverageGaps {
3281 summary: CoverageGapSummary {
3282 runtime_files: 1,
3283 covered_files: 0,
3284 file_coverage_pct: 0.0,
3285 untested_files: 1,
3286 untested_exports: 0,
3287 },
3288 files: vec![UntestedFileFinding::with_actions(
3289 UntestedFile {
3290 path: root.join("src/single.ts"),
3291 value_export_count: 1,
3292 },
3293 &root,
3294 )],
3295 exports: vec![],
3296 }),
3297 ..Default::default()
3298 };
3299 let json = issues_to_value(&build_health_codeclimate(&report, &root));
3300 let issues = json.as_array().unwrap();
3301 assert_eq!(issues.len(), 1);
3302 let desc = issues[0]["description"].as_str().unwrap();
3303 assert!(desc.contains("1 value export)"), "singular: {desc}");
3304 assert!(
3305 !desc.contains("exports"),
3306 "singular should not say 'exports': {desc}"
3307 );
3308 }
3309
3310 #[test]
3311 fn untested_file_plural_export_count() {
3312 use crate::health_types::{
3313 CoverageGapSummary, CoverageGaps, UntestedFile, UntestedFileFinding,
3314 };
3315 let root = PathBuf::from("/project");
3316 let report = crate::health_types::HealthReport {
3317 coverage_gaps: Some(CoverageGaps {
3318 summary: CoverageGapSummary {
3319 runtime_files: 1,
3320 covered_files: 0,
3321 file_coverage_pct: 0.0,
3322 untested_files: 1,
3323 untested_exports: 0,
3324 },
3325 files: vec![UntestedFileFinding::with_actions(
3326 UntestedFile {
3327 path: root.join("src/multi.ts"),
3328 value_export_count: 3,
3329 },
3330 &root,
3331 )],
3332 exports: vec![],
3333 }),
3334 ..Default::default()
3335 };
3336 let json = issues_to_value(&build_health_codeclimate(&report, &root));
3337 let issues = json.as_array().unwrap();
3338 let desc = issues[0]["description"].as_str().unwrap();
3339 assert!(desc.contains("3 value exports)"), "plural: {desc}");
3340 }
3341
3342 #[test]
3347 fn health_finding_severity_critical_maps_to_critical() {
3348 use crate::health_types::FindingSeverity;
3349 assert_eq!(
3350 health_finding_severity(FindingSeverity::Critical),
3351 CodeClimateSeverity::Critical
3352 );
3353 }
3354
3355 #[test]
3356 fn health_finding_severity_moderate_maps_to_minor() {
3357 use crate::health_types::FindingSeverity;
3358 assert_eq!(
3359 health_finding_severity(FindingSeverity::Moderate),
3360 CodeClimateSeverity::Minor
3361 );
3362 }
3363
3364 #[test]
3369 fn runtime_coverage_check_name_safe_to_delete() {
3370 use crate::health_types::RuntimeCoverageVerdict;
3371 assert_eq!(
3372 runtime_coverage_check_name(RuntimeCoverageVerdict::SafeToDelete),
3373 "fallow/runtime-safe-to-delete"
3374 );
3375 }
3376
3377 #[test]
3378 fn runtime_coverage_check_name_low_traffic() {
3379 use crate::health_types::RuntimeCoverageVerdict;
3380 assert_eq!(
3381 runtime_coverage_check_name(RuntimeCoverageVerdict::LowTraffic),
3382 "fallow/runtime-low-traffic"
3383 );
3384 }
3385
3386 #[test]
3387 fn runtime_coverage_check_name_coverage_unavailable() {
3388 use crate::health_types::RuntimeCoverageVerdict;
3389 assert_eq!(
3390 runtime_coverage_check_name(RuntimeCoverageVerdict::CoverageUnavailable),
3391 "fallow/runtime-coverage-unavailable"
3392 );
3393 }
3394
3395 #[test]
3396 fn runtime_coverage_check_name_active_and_unknown_use_generic() {
3397 use crate::health_types::RuntimeCoverageVerdict;
3398 assert_eq!(
3399 runtime_coverage_check_name(RuntimeCoverageVerdict::Active),
3400 "fallow/runtime-coverage"
3401 );
3402 assert_eq!(
3403 runtime_coverage_check_name(RuntimeCoverageVerdict::Unknown),
3404 "fallow/runtime-coverage"
3405 );
3406 }
3407
3408 #[test]
3413 fn runtime_coverage_severity_safe_to_delete_is_critical() {
3414 use crate::health_types::RuntimeCoverageVerdict;
3415 assert_eq!(
3416 runtime_coverage_severity(RuntimeCoverageVerdict::SafeToDelete),
3417 CodeClimateSeverity::Critical
3418 );
3419 }
3420
3421 #[test]
3422 fn runtime_coverage_severity_review_required_is_major() {
3423 use crate::health_types::RuntimeCoverageVerdict;
3424 assert_eq!(
3425 runtime_coverage_severity(RuntimeCoverageVerdict::ReviewRequired),
3426 CodeClimateSeverity::Major
3427 );
3428 }
3429
3430 #[test]
3431 fn runtime_coverage_severity_other_verdicts_are_minor() {
3432 use crate::health_types::RuntimeCoverageVerdict;
3433 assert_eq!(
3434 runtime_coverage_severity(RuntimeCoverageVerdict::LowTraffic),
3435 CodeClimateSeverity::Minor
3436 );
3437 assert_eq!(
3438 runtime_coverage_severity(RuntimeCoverageVerdict::Active),
3439 CodeClimateSeverity::Minor
3440 );
3441 }
3442
3443 #[test]
3448 fn coverage_intelligence_severity_review_required_is_minor() {
3449 use crate::health_types::CoverageIntelligenceVerdict;
3450 assert_eq!(
3451 coverage_intelligence_severity(CoverageIntelligenceVerdict::ReviewRequired),
3452 Some(CodeClimateSeverity::Minor)
3453 );
3454 }
3455
3456 #[test]
3457 fn coverage_intelligence_severity_refactor_carefully_is_minor() {
3458 use crate::health_types::CoverageIntelligenceVerdict;
3459 assert_eq!(
3460 coverage_intelligence_severity(CoverageIntelligenceVerdict::RefactorCarefully),
3461 Some(CodeClimateSeverity::Minor)
3462 );
3463 }
3464
3465 #[test]
3466 fn coverage_intelligence_severity_clean_is_none() {
3467 use crate::health_types::CoverageIntelligenceVerdict;
3468 assert_eq!(
3469 coverage_intelligence_severity(CoverageIntelligenceVerdict::Clean),
3470 None
3471 );
3472 }
3473
3474 #[test]
3475 fn coverage_intelligence_severity_unknown_is_none() {
3476 use crate::health_types::CoverageIntelligenceVerdict;
3477 assert_eq!(
3478 coverage_intelligence_severity(CoverageIntelligenceVerdict::Unknown),
3479 None
3480 );
3481 }
3482
3483 #[test]
3488 fn unused_dep_with_workspace_context_includes_workspace_in_description() {
3489 let root = PathBuf::from("/project");
3490 let mut results = AnalysisResults::default();
3491 results
3492 .unused_dependencies
3493 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
3494 package_name: "shared-lib".to_string(),
3495 location: DependencyLocation::Dependencies,
3496 path: root.join("package.json"),
3497 line: 10,
3498 used_in_workspaces: vec![root.join("packages/app")],
3499 }));
3500 let rules = RulesConfig::default();
3501 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3502 let desc = output[0]["description"].as_str().unwrap();
3503 assert!(
3504 desc.contains("imported in other workspaces"),
3505 "desc: {desc}"
3506 );
3507 assert!(desc.contains("packages/app"), "desc: {desc}");
3508 }
3509
3510 #[test]
3515 fn private_type_leak_emits_correct_check_name_and_description() {
3516 use fallow_config::Severity;
3517 use fallow_types::output_dead_code::PrivateTypeLeakFinding;
3518 let root = PathBuf::from("/project");
3519 let mut results = AnalysisResults::default();
3520 results
3521 .private_type_leaks
3522 .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
3523 path: root.join("src/api.ts"),
3524 export_name: "ApiResponse".to_string(),
3525 type_name: "InternalState".to_string(),
3526 line: 7,
3527 col: 0,
3528 span_start: 0,
3529 }));
3530 let rules = RulesConfig {
3532 private_type_leaks: Severity::Error,
3533 ..RulesConfig::default()
3534 };
3535 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3536 let arr = output.as_array().unwrap();
3537 assert_eq!(arr.len(), 1);
3538 assert_eq!(arr[0]["check_name"], "fallow/private-type-leak");
3539 let desc = arr[0]["description"].as_str().unwrap();
3540 assert!(desc.contains("ApiResponse"), "desc: {desc}");
3541 assert!(desc.contains("InternalState"), "desc: {desc}");
3542 assert_eq!(arr[0]["location"]["lines"]["begin"], 7);
3543 }
3544
3545 #[test]
3550 fn type_only_dep_zero_line_defaults_to_1() {
3551 let root = PathBuf::from("/project");
3552 let mut results = AnalysisResults::default();
3553 results
3554 .type_only_dependencies
3555 .push(TypeOnlyDependencyFinding::with_actions(
3556 TypeOnlyDependency {
3557 package_name: "ts-types".to_string(),
3558 path: root.join("package.json"),
3559 line: 0,
3560 },
3561 ));
3562 let rules = RulesConfig::default();
3563 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3564 assert_eq!(output[0]["location"]["lines"]["begin"], 1);
3565 }
3566
3567 #[test]
3568 fn test_only_dep_zero_line_defaults_to_1() {
3569 let root = PathBuf::from("/project");
3570 let mut results = AnalysisResults::default();
3571 results
3572 .test_only_dependencies
3573 .push(TestOnlyDependencyFinding::with_actions(
3574 TestOnlyDependency {
3575 package_name: "vitest".to_string(),
3576 path: root.join("package.json"),
3577 line: 0,
3578 },
3579 ));
3580 let rules = RulesConfig::default();
3581 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3582 assert_eq!(output[0]["check_name"], "fallow/test-only-dependency");
3583 assert_eq!(output[0]["location"]["lines"]["begin"], 1);
3584 }
3585
3586 #[test]
3591 fn re_export_cycle_self_loop_description_contains_self_loop_tag() {
3592 use fallow_types::output_dead_code::ReExportCycleFinding;
3593 let root = PathBuf::from("/project");
3594 let mut results = AnalysisResults::default();
3595 results
3596 .re_export_cycles
3597 .push(ReExportCycleFinding::with_actions(ReExportCycle {
3598 files: vec![root.join("src/index.ts")],
3599 kind: ReExportCycleKind::SelfLoop,
3600 }));
3601 let rules = RulesConfig::default();
3602 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3603 assert_eq!(output[0]["check_name"], "fallow/re-export-cycle");
3604 let desc = output[0]["description"].as_str().unwrap();
3605 assert!(desc.contains("(self-loop)"), "desc: {desc}");
3606 }
3607
3608 #[test]
3609 fn re_export_cycle_multi_node_description_has_no_kind_tag() {
3610 use fallow_types::output_dead_code::ReExportCycleFinding;
3611 let root = PathBuf::from("/project");
3612 let mut results = AnalysisResults::default();
3613 results
3614 .re_export_cycles
3615 .push(ReExportCycleFinding::with_actions(ReExportCycle {
3616 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
3617 kind: ReExportCycleKind::MultiNode,
3618 }));
3619 let rules = RulesConfig::default();
3620 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3621 let desc = output[0]["description"].as_str().unwrap();
3622 assert!(desc.starts_with("Re-export cycle: "), "desc: {desc}");
3623 assert!(!desc.contains("(self-loop)"), "desc: {desc}");
3624 assert!(!desc.contains("(multi-node)"), "desc: {desc}");
3625 assert!(desc.contains("src/a.ts"), "desc: {desc}");
3626 }
3627
3628 #[test]
3633 fn boundary_coverage_violation_emits_correct_check_name() {
3634 use fallow_types::output_dead_code::BoundaryCoverageViolationFinding;
3635 let root = PathBuf::from("/project");
3636 let mut results = AnalysisResults::default();
3637 results
3638 .boundary_coverage_violations
3639 .push(BoundaryCoverageViolationFinding::with_actions(
3640 BoundaryCoverageViolation {
3641 path: root.join("src/orphan.ts"),
3642 line: 0,
3643 col: 0,
3644 },
3645 ));
3646 let rules = RulesConfig::default();
3647 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3648 assert_eq!(output[0]["check_name"], "fallow/boundary-coverage");
3649 let desc = output[0]["description"].as_str().unwrap();
3650 assert!(desc.contains("no configured zone"), "desc: {desc}");
3651 }
3652
3653 #[test]
3654 fn boundary_call_violation_emits_pattern_in_description() {
3655 use fallow_types::output_dead_code::BoundaryCallViolationFinding;
3656 let root = PathBuf::from("/project");
3657 let mut results = AnalysisResults::default();
3658 results
3659 .boundary_call_violations
3660 .push(BoundaryCallViolationFinding::with_actions(
3661 BoundaryCallViolation {
3662 path: root.join("src/ui/App.ts"),
3663 line: 5,
3664 col: 0,
3665 zone: "ui".to_string(),
3666 callee: "fs.readFile".to_string(),
3667 pattern: "fs.*".to_string(),
3668 },
3669 ));
3670 let rules = RulesConfig::default();
3671 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3672 assert_eq!(output[0]["check_name"], "fallow/boundary-call-violation");
3673 let desc = output[0]["description"].as_str().unwrap();
3674 assert!(desc.contains("fs.readFile"), "desc: {desc}");
3675 assert!(desc.contains("fs.*"), "desc: {desc}");
3676 assert!(desc.contains("'ui'"), "desc: {desc}");
3677 }
3678
3679 #[test]
3684 fn policy_violation_error_severity_maps_to_major() {
3685 use fallow_types::output_dead_code::PolicyViolationFinding;
3686 let root = PathBuf::from("/project");
3687 let mut results = AnalysisResults::default();
3688 results
3689 .policy_violations
3690 .push(PolicyViolationFinding::with_actions(PolicyViolation {
3691 path: root.join("src/service.ts"),
3692 line: 2,
3693 col: 0,
3694 pack: "security".to_string(),
3695 rule_id: "no-eval".to_string(),
3696 kind: PolicyRuleKind::BannedCall,
3697 matched: "eval".to_string(),
3698 severity: PolicyViolationSeverity::Error,
3699 message: None,
3700 }));
3701 let rules = RulesConfig::default();
3702 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3703 assert_eq!(output[0]["check_name"], "fallow/policy-violation");
3704 assert_eq!(output[0]["severity"], "major");
3705 let desc = output[0]["description"].as_str().unwrap();
3706 assert!(desc.contains("eval"), "desc: {desc}");
3707 assert!(desc.contains("security/no-eval"), "desc: {desc}");
3708 }
3709
3710 #[test]
3711 fn policy_violation_warn_severity_maps_to_minor() {
3712 use fallow_types::output_dead_code::PolicyViolationFinding;
3713 let root = PathBuf::from("/project");
3714 let mut results = AnalysisResults::default();
3715 results
3716 .policy_violations
3717 .push(PolicyViolationFinding::with_actions(PolicyViolation {
3718 path: root.join("src/service.ts"),
3719 line: 2,
3720 col: 0,
3721 pack: "style".to_string(),
3722 rule_id: "no-console".to_string(),
3723 kind: PolicyRuleKind::BannedCall,
3724 matched: "console.log".to_string(),
3725 severity: PolicyViolationSeverity::Warn,
3726 message: Some("Use the project logger instead".to_string()),
3727 }));
3728 let rules = RulesConfig::default();
3729 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3730 assert_eq!(output[0]["severity"], "minor");
3731 let desc = output[0]["description"].as_str().unwrap();
3732 assert!(
3733 desc.contains("Use the project logger instead"),
3734 "desc: {desc}"
3735 );
3736 }
3737
3738 #[test]
3743 fn invalid_client_export_emits_correct_check_name_and_directive() {
3744 use fallow_types::output_dead_code::InvalidClientExportFinding;
3745 let root = PathBuf::from("/project");
3746 let mut results = AnalysisResults::default();
3747 results
3748 .invalid_client_exports
3749 .push(InvalidClientExportFinding::with_actions(
3750 InvalidClientExport {
3751 path: root.join("src/page.ts"),
3752 export_name: "metadata".to_string(),
3753 directive: "use client".to_string(),
3754 line: 3,
3755 col: 0,
3756 },
3757 ));
3758 let rules = RulesConfig::default();
3759 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3760 assert_eq!(output[0]["check_name"], "fallow/invalid-client-export");
3761 let desc = output[0]["description"].as_str().unwrap();
3762 assert!(desc.contains("metadata"), "desc: {desc}");
3763 assert!(desc.contains("use client"), "desc: {desc}");
3764 }
3765
3766 #[test]
3767 fn mixed_client_server_barrel_emits_correct_check_name_and_origins() {
3768 use fallow_types::output_dead_code::MixedClientServerBarrelFinding;
3769 let root = PathBuf::from("/project");
3770 let mut results = AnalysisResults::default();
3771 results
3772 .mixed_client_server_barrels
3773 .push(MixedClientServerBarrelFinding::with_actions(
3774 MixedClientServerBarrel {
3775 path: root.join("src/index.ts"),
3776 client_origin: "./client-comp".to_string(),
3777 server_origin: "./server-only".to_string(),
3778 line: 1,
3779 col: 0,
3780 },
3781 ));
3782 let rules = RulesConfig::default();
3783 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3784 assert_eq!(output[0]["check_name"], "fallow/mixed-client-server-barrel");
3785 let desc = output[0]["description"].as_str().unwrap();
3786 assert!(desc.contains("./client-comp"), "desc: {desc}");
3787 assert!(desc.contains("./server-only"), "desc: {desc}");
3788 }
3789
3790 #[test]
3795 fn misplaced_directive_emits_correct_check_name_and_guidance() {
3796 use fallow_types::output_dead_code::MisplacedDirectiveFinding;
3797 let root = PathBuf::from("/project");
3798 let mut results = AnalysisResults::default();
3799 results
3800 .misplaced_directives
3801 .push(MisplacedDirectiveFinding::with_actions(
3802 MisplacedDirective {
3803 path: root.join("src/page.ts"),
3804 directive: "use client".to_string(),
3805 line: 5,
3806 col: 0,
3807 },
3808 ));
3809 let rules = RulesConfig::default();
3810 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3811 assert_eq!(output[0]["check_name"], "fallow/misplaced-directive");
3812 let desc = output[0]["description"].as_str().unwrap();
3813 assert!(desc.contains("use client"), "desc: {desc}");
3814 assert!(desc.contains("leading position"), "desc: {desc}");
3815 }
3816
3817 #[test]
3822 fn unrendered_component_emits_correct_check_name_and_description() {
3823 use fallow_types::output_dead_code::UnrenderedComponentFinding;
3824 let root = PathBuf::from("/project");
3825 let mut results = AnalysisResults::default();
3826 results
3827 .unrendered_components
3828 .push(UnrenderedComponentFinding::with_actions(
3829 UnrenderedComponent {
3830 path: root.join("src/Dead.vue"),
3831 component_name: "Dead".to_string(),
3832 framework: "vue".to_string(),
3833 reachable_via: None,
3834 line: 1,
3835 col: 0,
3836 },
3837 ));
3838 let rules = RulesConfig::default();
3839 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3840 assert_eq!(output[0]["check_name"], "fallow/unrendered-component");
3841 let desc = output[0]["description"].as_str().unwrap();
3842 assert!(desc.contains("`Dead`"), "desc: {desc}");
3843 assert!(desc.contains("rendered nowhere"), "desc: {desc}");
3844 }
3845
3846 #[test]
3851 fn unused_component_prop_emits_correct_check_name_and_description() {
3852 use fallow_types::output_dead_code::UnusedComponentPropFinding;
3853 let root = PathBuf::from("/project");
3854 let mut results = AnalysisResults::default();
3855 results
3856 .unused_component_props
3857 .push(UnusedComponentPropFinding::with_actions(
3858 UnusedComponentProp {
3859 path: root.join("src/Card.vue"),
3860 component_name: "Card".to_string(),
3861 prop_name: "color".to_string(),
3862 line: 3,
3863 col: 0,
3864 },
3865 ));
3866 let rules = RulesConfig::default();
3867 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3868 assert_eq!(output[0]["check_name"], "fallow/unused-component-prop");
3869 let desc = output[0]["description"].as_str().unwrap();
3870 assert!(desc.contains("`color`"), "desc: {desc}");
3871 assert!(desc.contains("Card"), "desc: {desc}");
3872 }
3873
3874 #[test]
3879 fn unused_component_emit_emits_correct_check_name_and_description() {
3880 use fallow_types::output_dead_code::UnusedComponentEmitFinding;
3881 let root = PathBuf::from("/project");
3882 let mut results = AnalysisResults::default();
3883 results
3884 .unused_component_emits
3885 .push(UnusedComponentEmitFinding::with_actions(
3886 UnusedComponentEmit {
3887 path: root.join("src/Button.vue"),
3888 component_name: "Button".to_string(),
3889 emit_name: "close".to_string(),
3890 line: 4,
3891 col: 0,
3892 },
3893 ));
3894 let rules = RulesConfig::default();
3895 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3896 assert_eq!(output[0]["check_name"], "fallow/unused-component-emit");
3897 let desc = output[0]["description"].as_str().unwrap();
3898 assert!(desc.contains("`close`"), "desc: {desc}");
3899 assert!(desc.contains("Button"), "desc: {desc}");
3900 }
3901
3902 #[test]
3907 fn unused_svelte_event_emits_correct_check_name_and_description() {
3908 use fallow_types::output_dead_code::UnusedSvelteEventFinding;
3909 let root = PathBuf::from("/project");
3910 let mut results = AnalysisResults::default();
3911 results
3912 .unused_svelte_events
3913 .push(UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
3914 path: root.join("src/Child.svelte"),
3915 component_name: "Child".to_string(),
3916 event_name: "submit".to_string(),
3917 line: 2,
3918 col: 0,
3919 }));
3920 let rules = RulesConfig::default();
3921 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3922 assert_eq!(output[0]["check_name"], "fallow/unused-svelte-event");
3923 let desc = output[0]["description"].as_str().unwrap();
3924 assert!(desc.contains("`submit`"), "desc: {desc}");
3925 assert!(desc.contains("listened to nowhere"), "desc: {desc}");
3926 }
3927
3928 #[test]
3933 fn unused_component_input_emits_correct_check_name_and_description() {
3934 use fallow_types::output_dead_code::UnusedComponentInputFinding;
3935 let root = PathBuf::from("/project");
3936 let mut results = AnalysisResults::default();
3937 results
3938 .unused_component_inputs
3939 .push(UnusedComponentInputFinding::with_actions(
3940 UnusedComponentInput {
3941 path: root.join("src/card.component.ts"),
3942 component_name: "CardComponent".to_string(),
3943 input_name: "label".to_string(),
3944 line: 5,
3945 col: 0,
3946 },
3947 ));
3948 let rules = RulesConfig::default();
3949 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3950 assert_eq!(output[0]["check_name"], "fallow/unused-component-input");
3951 let desc = output[0]["description"].as_str().unwrap();
3952 assert!(desc.contains("`label`"), "desc: {desc}");
3953 assert!(desc.contains("CardComponent"), "desc: {desc}");
3954 }
3955
3956 #[test]
3957 fn unused_component_output_emits_correct_check_name_and_description() {
3958 use fallow_types::output_dead_code::UnusedComponentOutputFinding;
3959 let root = PathBuf::from("/project");
3960 let mut results = AnalysisResults::default();
3961 results
3962 .unused_component_outputs
3963 .push(UnusedComponentOutputFinding::with_actions(
3964 UnusedComponentOutput {
3965 path: root.join("src/toggle.component.ts"),
3966 component_name: "ToggleComponent".to_string(),
3967 output_name: "changed".to_string(),
3968 line: 6,
3969 col: 0,
3970 },
3971 ));
3972 let rules = RulesConfig::default();
3973 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
3974 assert_eq!(output[0]["check_name"], "fallow/unused-component-output");
3975 let desc = output[0]["description"].as_str().unwrap();
3976 assert!(desc.contains("`changed`"), "desc: {desc}");
3977 assert!(desc.contains("ToggleComponent"), "desc: {desc}");
3978 }
3979
3980 #[test]
3985 fn unused_server_action_emits_correct_check_name_and_description() {
3986 use fallow_types::output_dead_code::UnusedServerActionFinding;
3987 let root = PathBuf::from("/project");
3988 let mut results = AnalysisResults::default();
3989 results
3990 .unused_server_actions
3991 .push(UnusedServerActionFinding::with_actions(
3992 UnusedServerAction {
3993 path: root.join("src/app/actions.ts"),
3994 action_name: "deleteUser".to_string(),
3995 line: 8,
3996 col: 0,
3997 },
3998 ));
3999 let rules = RulesConfig::default();
4000 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
4001 assert_eq!(output[0]["check_name"], "fallow/unused-server-action");
4002 let desc = output[0]["description"].as_str().unwrap();
4003 assert!(desc.contains("`deleteUser`"), "desc: {desc}");
4004 assert!(desc.contains("\"use server\""), "desc: {desc}");
4005 }
4006
4007 #[test]
4012 fn unused_load_data_key_emits_correct_check_name_and_description() {
4013 use fallow_types::output_dead_code::UnusedLoadDataKeyFinding;
4014 let root = PathBuf::from("/project");
4015 let mut results = AnalysisResults::default();
4016 results
4017 .unused_load_data_keys
4018 .push(UnusedLoadDataKeyFinding::with_actions(UnusedLoadDataKey {
4019 path: root.join("src/routes/+page.ts"),
4020 key_name: "postCount".to_string(),
4021 line: 7,
4022 col: 0,
4023 route_dir: None,
4024 }));
4025 let rules = RulesConfig::default();
4026 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
4027 assert_eq!(output[0]["check_name"], "fallow/unused-load-data-key");
4028 let desc = output[0]["description"].as_str().unwrap();
4029 assert!(desc.contains("`postCount`"), "desc: {desc}");
4030 assert!(desc.contains("no consumer"), "desc: {desc}");
4031 }
4032
4033 #[test]
4038 fn route_collision_emits_correct_check_name_and_url_in_description() {
4039 use fallow_types::output_dead_code::RouteCollisionFinding;
4040 let root = PathBuf::from("/project");
4041 let mut results = AnalysisResults::default();
4042 results
4043 .route_collisions
4044 .push(RouteCollisionFinding::with_actions(RouteCollision {
4045 path: root.join("src/app/about/page.tsx"),
4046 url: "/about".to_string(),
4047 conflicting_paths: vec![root.join("src/app/(marketing)/about/page.tsx")],
4048 line: 1,
4049 col: 0,
4050 }));
4051 let rules = RulesConfig::default();
4052 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
4053 assert_eq!(output[0]["check_name"], "fallow/route-collision");
4054 let desc = output[0]["description"].as_str().unwrap();
4055 assert!(desc.contains("`/about`"), "desc: {desc}");
4056 assert!(desc.contains("1 other file"), "desc: {desc}");
4057 }
4058
4059 #[test]
4060 fn dynamic_segment_name_conflict_emits_correct_check_name_and_position() {
4061 use fallow_types::output_dead_code::DynamicSegmentNameConflictFinding;
4062 let root = PathBuf::from("/project");
4063 let mut results = AnalysisResults::default();
4064 results.dynamic_segment_name_conflicts.push(
4065 DynamicSegmentNameConflictFinding::with_actions(DynamicSegmentNameConflict {
4066 path: root.join("src/app/shop/[id]/page.tsx"),
4067 position: "/shop".to_string(),
4068 conflicting_segments: vec!["[id]".to_string(), "[slug]".to_string()],
4069 conflicting_paths: vec![root.join("src/app/shop/[slug]/page.tsx")],
4070 line: 1,
4071 col: 0,
4072 }),
4073 );
4074 let rules = RulesConfig::default();
4075 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
4076 assert_eq!(
4077 output[0]["check_name"],
4078 "fallow/dynamic-segment-name-conflict"
4079 );
4080 let desc = output[0]["description"].as_str().unwrap();
4081 assert!(desc.contains("`/shop`"), "desc: {desc}");
4082 assert!(desc.contains("[id]"), "desc: {desc}");
4083 assert!(desc.contains("[slug]"), "desc: {desc}");
4084 }
4085
4086 #[test]
4091 fn unresolved_catalog_reference_default_catalog_description() {
4092 use fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding;
4093 let root = PathBuf::from("/project");
4094 let mut results = AnalysisResults::default();
4095 results.unresolved_catalog_references.push(
4096 UnresolvedCatalogReferenceFinding::with_actions(UnresolvedCatalogReference {
4097 entry_name: "react".to_string(),
4098 catalog_name: "default".to_string(),
4099 path: root.join("packages/app/package.json"),
4100 line: 5,
4101 available_in_catalogs: vec![],
4102 }),
4103 );
4104 let rules = RulesConfig::default();
4105 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
4106 assert_eq!(
4107 output[0]["check_name"],
4108 "fallow/unresolved-catalog-reference"
4109 );
4110 let desc = output[0]["description"].as_str().unwrap();
4111 assert!(desc.contains("the default catalog"), "desc: {desc}");
4112 assert!(desc.contains("react"), "desc: {desc}");
4113 assert!(desc.contains("catalog:"), "desc: {desc}");
4114 }
4115
4116 #[test]
4117 fn unresolved_catalog_reference_named_catalog_description() {
4118 use fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding;
4119 let root = PathBuf::from("/project");
4120 let mut results = AnalysisResults::default();
4121 results.unresolved_catalog_references.push(
4122 UnresolvedCatalogReferenceFinding::with_actions(UnresolvedCatalogReference {
4123 entry_name: "lodash".to_string(),
4124 catalog_name: "react17".to_string(),
4125 path: root.join("packages/legacy/package.json"),
4126 line: 3,
4127 available_in_catalogs: vec!["default".to_string()],
4128 }),
4129 );
4130 let rules = RulesConfig::default();
4131 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
4132 let desc = output[0]["description"].as_str().unwrap();
4133 assert!(desc.contains("catalog 'react17'"), "desc: {desc}");
4134 assert!(desc.contains("available in"), "desc: {desc}");
4135 assert!(desc.contains("default"), "desc: {desc}");
4136 }
4137
4138 #[test]
4143 fn unused_dependency_override_with_hint_includes_hint_in_description() {
4144 use fallow_types::output_dead_code::UnusedDependencyOverrideFinding;
4145 let root = PathBuf::from("/project");
4146 let mut results = AnalysisResults::default();
4147 results
4148 .unused_dependency_overrides
4149 .push(UnusedDependencyOverrideFinding::with_actions(
4150 UnusedDependencyOverride {
4151 raw_key: "react".to_string(),
4152 target_package: "react".to_string(),
4153 parent_package: None,
4154 version_constraint: None,
4155 version_range: "^18.0.0".to_string(),
4156 source: DependencyOverrideSource::PnpmPackageJson,
4157 path: root.join("package.json"),
4158 line: 12,
4159 hint: Some("verify lockfile before removing".to_string()),
4160 },
4161 ));
4162 let rules = RulesConfig::default();
4163 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
4164 assert_eq!(output[0]["check_name"], "fallow/unused-dependency-override");
4165 let desc = output[0]["description"].as_str().unwrap();
4166 assert!(desc.contains("react"), "desc: {desc}");
4167 assert!(desc.contains("verify lockfile"), "desc: {desc}");
4168 }
4169
4170 #[test]
4175 fn health_codeclimate_runtime_safe_to_delete_maps_to_critical_severity() {
4176 use crate::health_types::{
4177 RuntimeCoverageConfidence, RuntimeCoverageDataSource, RuntimeCoverageEvidence,
4178 RuntimeCoverageFinding, RuntimeCoverageReport, RuntimeCoverageReportVerdict,
4179 RuntimeCoverageSchemaVersion, RuntimeCoverageSummary, RuntimeCoverageVerdict,
4180 };
4181 let root = PathBuf::from("/project");
4182 let report = crate::health_types::HealthReport {
4183 runtime_coverage: Some(RuntimeCoverageReport {
4184 schema_version: RuntimeCoverageSchemaVersion::V1,
4185 verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
4186 signals: vec![],
4187 summary: RuntimeCoverageSummary {
4188 data_source: RuntimeCoverageDataSource::Local,
4189 last_received_at: None,
4190 functions_tracked: 10,
4191 functions_hit: 8,
4192 functions_unhit: 2,
4193 functions_untracked: 0,
4194 coverage_percent: 80.0,
4195 trace_count: 1000,
4196 period_days: 7,
4197 deployments_seen: 1,
4198 capture_quality: None,
4199 },
4200 findings: vec![RuntimeCoverageFinding {
4201 id: "fallow:prod:aabbccdd".to_string(),
4202 stable_id: None,
4203 source_hash: None,
4204 path: root.join("src/legacy.ts"),
4205 function: "legacyHelper".to_string(),
4206 line: 12,
4207 verdict: RuntimeCoverageVerdict::SafeToDelete,
4208 invocations: Some(0),
4209 confidence: RuntimeCoverageConfidence::High,
4210 evidence: RuntimeCoverageEvidence {
4211 static_status: "unused".to_string(),
4212 test_coverage: "not_covered".to_string(),
4213 v8_tracking: "tracked".to_string(),
4214 untracked_reason: None,
4215 observation_days: 30,
4216 deployments_observed: 3,
4217 },
4218 actions: vec![],
4219 }],
4220 hot_paths: vec![],
4221 blast_radius: vec![],
4222 importance: vec![],
4223 watermark: None,
4224 warnings: vec![],
4225 }),
4226 ..Default::default()
4227 };
4228 let json = issues_to_value(&build_health_codeclimate(&report, &root));
4229 let issues = json.as_array().unwrap();
4230 assert_eq!(issues.len(), 1);
4231 assert_eq!(issues[0]["check_name"], "fallow/runtime-safe-to-delete");
4232 assert_eq!(issues[0]["severity"], "critical");
4233 let desc = issues[0]["description"].as_str().unwrap();
4234 assert!(desc.contains("legacyHelper"), "desc: {desc}");
4235 assert!(desc.contains("safe to delete"), "desc: {desc}");
4236 assert!(desc.contains("0 invocations"), "desc: {desc}");
4237 assert_eq!(issues[0]["location"]["path"], "src/legacy.ts");
4238 assert_eq!(issues[0]["location"]["lines"]["begin"], 12);
4239 }
4240
4241 #[test]
4242 fn health_codeclimate_runtime_finding_with_none_invocations_shows_untracked() {
4243 use crate::health_types::{
4244 RuntimeCoverageConfidence, RuntimeCoverageDataSource, RuntimeCoverageEvidence,
4245 RuntimeCoverageFinding, RuntimeCoverageReport, RuntimeCoverageReportVerdict,
4246 RuntimeCoverageSchemaVersion, RuntimeCoverageSummary, RuntimeCoverageVerdict,
4247 };
4248 let root = PathBuf::from("/project");
4249 let report = crate::health_types::HealthReport {
4250 runtime_coverage: Some(RuntimeCoverageReport {
4251 schema_version: RuntimeCoverageSchemaVersion::V1,
4252 verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
4253 signals: vec![],
4254 summary: RuntimeCoverageSummary {
4255 data_source: RuntimeCoverageDataSource::Local,
4256 last_received_at: None,
4257 functions_tracked: 5,
4258 functions_hit: 4,
4259 functions_unhit: 0,
4260 functions_untracked: 1,
4261 coverage_percent: 80.0,
4262 trace_count: 500,
4263 period_days: 7,
4264 deployments_seen: 1,
4265 capture_quality: None,
4266 },
4267 findings: vec![RuntimeCoverageFinding {
4268 id: "fallow:prod:11223344".to_string(),
4269 stable_id: None,
4270 source_hash: None,
4271 path: root.join("src/worker.ts"),
4272 function: "workerFn".to_string(),
4273 line: 5,
4274 verdict: RuntimeCoverageVerdict::CoverageUnavailable,
4275 invocations: None,
4276 confidence: RuntimeCoverageConfidence::Low,
4277 evidence: RuntimeCoverageEvidence {
4278 static_status: "used".to_string(),
4279 test_coverage: "not_covered".to_string(),
4280 v8_tracking: "untracked".to_string(),
4281 untracked_reason: Some("worker_thread".to_string()),
4282 observation_days: 7,
4283 deployments_observed: 1,
4284 },
4285 actions: vec![],
4286 }],
4287 hot_paths: vec![],
4288 blast_radius: vec![],
4289 importance: vec![],
4290 watermark: None,
4291 warnings: vec![],
4292 }),
4293 ..Default::default()
4294 };
4295 let json = issues_to_value(&build_health_codeclimate(&report, &root));
4296 let issues = json.as_array().unwrap();
4297 let desc = issues[0]["description"].as_str().unwrap();
4298 assert!(desc.contains("untracked"), "desc: {desc}");
4299 }
4300
4301 #[test]
4306 fn duplication_codeclimate_one_issue_per_instance() {
4307 use fallow_core::duplicates::{
4308 CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
4309 };
4310 let root = PathBuf::from("/project");
4311 let report = DuplicationReport {
4312 clone_groups: vec![CloneGroup {
4313 instances: vec![
4314 CloneInstance {
4315 file: root.join("src/a.ts"),
4316 start_line: 10,
4317 end_line: 20,
4318 start_col: 0,
4319 end_col: 0,
4320 fragment: "const x = 1;\nconst y = 2;".to_string(),
4321 },
4322 CloneInstance {
4323 file: root.join("src/b.ts"),
4324 start_line: 30,
4325 end_line: 40,
4326 start_col: 0,
4327 end_col: 0,
4328 fragment: "const x = 1;\nconst y = 2;".to_string(),
4329 },
4330 ],
4331 token_count: 20,
4332 line_count: 10,
4333 }],
4334 clone_families: vec![],
4335 mirrored_directories: vec![],
4336 stats: DuplicationStats::default(),
4337 };
4338 let output = issues_to_value(&build_duplication_codeclimate(&report, &root));
4339 let issues = output.as_array().unwrap();
4340 assert_eq!(issues.len(), 2, "one issue per clone instance");
4341 assert_eq!(issues[0]["check_name"], "fallow/code-duplication");
4342 assert_eq!(issues[1]["check_name"], "fallow/code-duplication");
4343 assert_eq!(issues[0]["location"]["path"].as_str().unwrap(), "src/a.ts");
4344 assert_eq!(issues[1]["location"]["path"].as_str().unwrap(), "src/b.ts");
4345 assert_eq!(issues[0]["location"]["lines"]["begin"], 10);
4346 assert_eq!(issues[1]["location"]["lines"]["begin"], 30);
4347 assert_eq!(issues[0]["categories"][0], "Duplication");
4348 }
4349
4350 #[test]
4351 fn duplication_codeclimate_description_includes_group_number_and_line_count() {
4352 use fallow_core::duplicates::{
4353 CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
4354 };
4355 let root = PathBuf::from("/project");
4356 let report = DuplicationReport {
4357 clone_groups: vec![
4358 CloneGroup {
4359 instances: vec![CloneInstance {
4360 file: root.join("src/a.ts"),
4361 start_line: 1,
4362 end_line: 10,
4363 start_col: 0,
4364 end_col: 0,
4365 fragment: "x".to_string(),
4366 }],
4367 token_count: 5,
4368 line_count: 8,
4369 },
4370 CloneGroup {
4371 instances: vec![CloneInstance {
4372 file: root.join("src/b.ts"),
4373 start_line: 5,
4374 end_line: 12,
4375 start_col: 0,
4376 end_col: 0,
4377 fragment: "y".to_string(),
4378 }],
4379 token_count: 3,
4380 line_count: 6,
4381 },
4382 ],
4383 clone_families: vec![],
4384 mirrored_directories: vec![],
4385 stats: DuplicationStats::default(),
4386 };
4387 let output = issues_to_value(&build_duplication_codeclimate(&report, &root));
4388 let issues = output.as_array().unwrap();
4389 assert_eq!(issues.len(), 2);
4390 let desc0 = issues[0]["description"].as_str().unwrap();
4391 assert!(desc0.contains("group 1"), "first group: {desc0}");
4392 assert!(desc0.contains("8 lines"), "first group line count: {desc0}");
4393 let desc1 = issues[1]["description"].as_str().unwrap();
4394 assert!(desc1.contains("group 2"), "second group: {desc1}");
4395 assert!(
4396 desc1.contains("6 lines"),
4397 "second group line count: {desc1}"
4398 );
4399 }
4400
4401 #[test]
4402 fn duplication_codeclimate_empty_report_produces_empty_array() {
4403 use fallow_core::duplicates::{DuplicationReport, DuplicationStats};
4404 let root = PathBuf::from("/project");
4405 let report = DuplicationReport {
4406 clone_groups: vec![],
4407 clone_families: vec![],
4408 mirrored_directories: vec![],
4409 stats: DuplicationStats::default(),
4410 };
4411 let output = issues_to_value(&build_duplication_codeclimate(&report, &root));
4412 assert!(output.as_array().unwrap().is_empty());
4413 }
4414
4415 #[test]
4416 fn duplication_codeclimate_fingerprints_are_unique_across_instances() {
4417 use fallow_core::duplicates::{
4418 CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
4419 };
4420 let root = PathBuf::from("/project");
4421 let report = DuplicationReport {
4422 clone_groups: vec![CloneGroup {
4423 instances: vec![
4424 CloneInstance {
4425 file: root.join("src/x.ts"),
4426 start_line: 1,
4427 end_line: 5,
4428 start_col: 0,
4429 end_col: 0,
4430 fragment: "hello".to_string(),
4431 },
4432 CloneInstance {
4433 file: root.join("src/y.ts"),
4434 start_line: 10,
4435 end_line: 14,
4436 start_col: 0,
4437 end_col: 0,
4438 fragment: "hello".to_string(),
4439 },
4440 ],
4441 token_count: 5,
4442 line_count: 4,
4443 }],
4444 clone_families: vec![],
4445 mirrored_directories: vec![],
4446 stats: DuplicationStats::default(),
4447 };
4448 let output = issues_to_value(&build_duplication_codeclimate(&report, &root));
4449 let issues = output.as_array().unwrap();
4450 let fp0 = issues[0]["fingerprint"].as_str().unwrap();
4451 let fp1 = issues[1]["fingerprint"].as_str().unwrap();
4452 assert_ne!(
4453 fp0, fp1,
4454 "different file+line instances must have distinct fingerprints"
4455 );
4456 }
4457
4458 #[test]
4463 fn circular_dep_cross_package_description_contains_label() {
4464 use fallow_types::output_dead_code::CircularDependencyFinding;
4465 let root = PathBuf::from("/project");
4466 let mut results = AnalysisResults::default();
4467 results
4468 .circular_dependencies
4469 .push(CircularDependencyFinding::with_actions(
4470 CircularDependency {
4471 files: vec![
4472 root.join("packages/a/src/index.ts"),
4473 root.join("packages/b/src/index.ts"),
4474 ],
4475 length: 2,
4476 line: 0,
4477 col: 0,
4478 edges: Vec::new(),
4479 is_cross_package: true,
4480 },
4481 ));
4482 let rules = RulesConfig::default();
4483 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
4484 let desc = output[0]["description"].as_str().unwrap();
4485 assert!(desc.contains("(cross-package)"), "desc: {desc}");
4486 }
4487
4488 #[test]
4493 fn unused_type_re_export_uses_type_re_export_label() {
4494 use fallow_types::output_dead_code::UnusedTypeFinding;
4495 let root = PathBuf::from("/project");
4496 let mut results = AnalysisResults::default();
4497 results
4498 .unused_types
4499 .push(UnusedTypeFinding::with_actions(UnusedExport {
4500 path: root.join("src/index.ts"),
4501 export_name: "ReExportedType".to_string(),
4502 is_type_only: true,
4503 line: 2,
4504 col: 0,
4505 span_start: 0,
4506 is_re_export: true,
4507 }));
4508 let rules = RulesConfig::default();
4509 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
4510 assert_eq!(output[0]["check_name"], "fallow/unused-type");
4511 let desc = output[0]["description"].as_str().unwrap();
4512 assert!(desc.contains("Type re-export"), "desc: {desc}");
4513 }
4514}