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