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