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_stale_suppression_issues(
547 issues: &mut Vec<CodeClimateIssue>,
548 suppressions: &[fallow_core::results::StaleSuppression],
549 root: &Path,
550 severity: Severity,
551) {
552 if suppressions.is_empty() {
553 return;
554 }
555 let level = severity_to_codeclimate(severity);
556 for s in suppressions {
557 let path = cc_path(&s.path, root);
558 let line_str = s.line.to_string();
559 let fp = fingerprint_hash(&["fallow/stale-suppression", &path, &line_str]);
560 issues.push(cc_issue(
561 "fallow/stale-suppression",
562 &s.display_message(),
563 level,
564 "Bug Risk",
565 &path,
566 Some(s.line),
567 &fp,
568 ));
569 }
570}
571
572fn push_unused_catalog_entry_issues(
573 issues: &mut Vec<CodeClimateIssue>,
574 entries: &[fallow_core::results::UnusedCatalogEntryFinding],
575 root: &Path,
576 severity: Severity,
577) {
578 if entries.is_empty() {
579 return;
580 }
581 let level = severity_to_codeclimate(severity);
582 for entry in entries {
583 let entry = &entry.entry;
584 let path = cc_path(&entry.path, root);
585 let line_str = entry.line.to_string();
586 let fp = fingerprint_hash(&[
587 "fallow/unused-catalog-entry",
588 &path,
589 &line_str,
590 &entry.catalog_name,
591 &entry.entry_name,
592 ]);
593 let description = if entry.catalog_name == "default" {
594 format!(
595 "Catalog entry '{}' is not referenced by any workspace package",
596 entry.entry_name
597 )
598 } else {
599 format!(
600 "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
601 entry.entry_name, entry.catalog_name
602 )
603 };
604 issues.push(cc_issue(
605 "fallow/unused-catalog-entry",
606 &description,
607 level,
608 "Bug Risk",
609 &path,
610 Some(entry.line),
611 &fp,
612 ));
613 }
614}
615
616fn push_unresolved_catalog_reference_issues(
617 issues: &mut Vec<CodeClimateIssue>,
618 findings: &[fallow_core::results::UnresolvedCatalogReferenceFinding],
619 root: &Path,
620 severity: Severity,
621) {
622 if findings.is_empty() {
623 return;
624 }
625 let level = severity_to_codeclimate(severity);
626 for finding in findings {
627 let finding = &finding.reference;
628 let path = cc_path(&finding.path, root);
629 let line_str = finding.line.to_string();
630 let fp = fingerprint_hash(&[
631 "fallow/unresolved-catalog-reference",
632 &path,
633 &line_str,
634 &finding.catalog_name,
635 &finding.entry_name,
636 ]);
637 let catalog_phrase = if finding.catalog_name == "default" {
638 "the default catalog".to_string()
639 } else {
640 format!("catalog '{}'", finding.catalog_name)
641 };
642 let mut description = format!(
643 "Package '{}' is referenced via `catalog:{}` but {} does not declare it; `pnpm install` will fail",
644 finding.entry_name,
645 if finding.catalog_name == "default" {
646 ""
647 } else {
648 finding.catalog_name.as_str()
649 },
650 catalog_phrase,
651 );
652 if !finding.available_in_catalogs.is_empty() {
653 use std::fmt::Write as _;
654 let _ = write!(
655 description,
656 " (available in: {})",
657 finding.available_in_catalogs.join(", ")
658 );
659 }
660 issues.push(cc_issue(
661 "fallow/unresolved-catalog-reference",
662 &description,
663 level,
664 "Bug Risk",
665 &path,
666 Some(finding.line),
667 &fp,
668 ));
669 }
670}
671
672fn push_empty_catalog_group_issues(
673 issues: &mut Vec<CodeClimateIssue>,
674 groups: &[fallow_core::results::EmptyCatalogGroupFinding],
675 root: &Path,
676 severity: Severity,
677) {
678 if groups.is_empty() {
679 return;
680 }
681 let level = severity_to_codeclimate(severity);
682 for group in groups {
683 let group = &group.group;
684 let path = cc_path(&group.path, root);
685 let line_str = group.line.to_string();
686 let fp = fingerprint_hash(&[
687 "fallow/empty-catalog-group",
688 &path,
689 &line_str,
690 &group.catalog_name,
691 ]);
692 issues.push(cc_issue(
693 "fallow/empty-catalog-group",
694 &format!("Catalog group '{}' has no entries", group.catalog_name),
695 level,
696 "Bug Risk",
697 &path,
698 Some(group.line),
699 &fp,
700 ));
701 }
702}
703
704fn push_unused_dependency_override_issues(
705 issues: &mut Vec<CodeClimateIssue>,
706 findings: &[fallow_core::results::UnusedDependencyOverrideFinding],
707 root: &Path,
708 severity: Severity,
709) {
710 if findings.is_empty() {
711 return;
712 }
713 let level = severity_to_codeclimate(severity);
714 for finding in findings {
715 let finding = &finding.entry;
716 let path = cc_path(&finding.path, root);
717 let line_str = finding.line.to_string();
718 let fp = fingerprint_hash(&[
719 "fallow/unused-dependency-override",
720 &path,
721 &line_str,
722 finding.source.as_label(),
723 &finding.raw_key,
724 ]);
725 let mut description = format!(
726 "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
727 finding.raw_key, finding.version_range, finding.target_package,
728 );
729 if let Some(hint) = &finding.hint {
730 use std::fmt::Write as _;
731 let _ = write!(description, " ({hint})");
732 }
733 issues.push(cc_issue(
734 "fallow/unused-dependency-override",
735 &description,
736 level,
737 "Bug Risk",
738 &path,
739 Some(finding.line),
740 &fp,
741 ));
742 }
743}
744
745fn push_misconfigured_dependency_override_issues(
746 issues: &mut Vec<CodeClimateIssue>,
747 findings: &[fallow_core::results::MisconfiguredDependencyOverrideFinding],
748 root: &Path,
749 severity: Severity,
750) {
751 if findings.is_empty() {
752 return;
753 }
754 let level = severity_to_codeclimate(severity);
755 for finding in findings {
756 let finding = &finding.entry;
757 let path = cc_path(&finding.path, root);
758 let line_str = finding.line.to_string();
759 let fp = fingerprint_hash(&[
760 "fallow/misconfigured-dependency-override",
761 &path,
762 &line_str,
763 finding.source.as_label(),
764 &finding.raw_key,
765 ]);
766 let description = format!(
767 "Override `{}` -> `{}` is malformed: {}",
768 finding.raw_key,
769 finding.raw_value,
770 finding.reason.describe(),
771 );
772 issues.push(cc_issue(
773 "fallow/misconfigured-dependency-override",
774 &description,
775 level,
776 "Bug Risk",
777 &path,
778 Some(finding.line),
779 &fp,
780 ));
781 }
782}
783
784#[must_use]
793#[expect(
794 clippy::expect_used,
795 reason = "CodeClimateIssue contains only infallibly serializable fields"
796)]
797pub fn issues_to_value(issues: &[CodeClimateIssue]) -> serde_json::Value {
798 serde_json::to_value(issues).expect("CodeClimateIssue serializes infallibly")
799}
800
801#[must_use]
808#[expect(
809 clippy::too_many_lines,
810 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"
811)]
812pub fn build_codeclimate(
813 results: &AnalysisResults,
814 root: &Path,
815 rules: &RulesConfig,
816) -> Vec<CodeClimateIssue> {
817 let mut issues = Vec::new();
818
819 push_unused_file_issues(&mut issues, &results.unused_files, root, rules.unused_files);
820 push_unused_export_issues(
821 &mut issues,
822 results.unused_exports.iter().map(|e| &e.export),
823 root,
824 "fallow/unused-export",
825 "Export",
826 "Re-export",
827 rules.unused_exports,
828 );
829 push_unused_export_issues(
830 &mut issues,
831 results.unused_types.iter().map(|e| &e.export),
832 root,
833 "fallow/unused-type",
834 "Type export",
835 "Type re-export",
836 rules.unused_types,
837 );
838 push_private_type_leak_issues(
839 &mut issues,
840 &results.private_type_leaks,
841 root,
842 rules.private_type_leaks,
843 );
844 push_dep_cc_issues(
845 &mut issues,
846 results.unused_dependencies.iter().map(|f| &f.dep),
847 root,
848 "fallow/unused-dependency",
849 "dependencies",
850 rules.unused_dependencies,
851 );
852 push_dep_cc_issues(
853 &mut issues,
854 results.unused_dev_dependencies.iter().map(|f| &f.dep),
855 root,
856 "fallow/unused-dev-dependency",
857 "devDependencies",
858 rules.unused_dev_dependencies,
859 );
860 push_dep_cc_issues(
861 &mut issues,
862 results.unused_optional_dependencies.iter().map(|f| &f.dep),
863 root,
864 "fallow/unused-optional-dependency",
865 "optionalDependencies",
866 rules.unused_optional_dependencies,
867 );
868 push_type_only_dep_issues(
869 &mut issues,
870 &results.type_only_dependencies,
871 root,
872 rules.type_only_dependencies,
873 );
874 push_test_only_dep_issues(
875 &mut issues,
876 &results.test_only_dependencies,
877 root,
878 rules.test_only_dependencies,
879 );
880 push_unused_member_issues(
881 &mut issues,
882 results.unused_enum_members.iter().map(|m| &m.member),
883 root,
884 "fallow/unused-enum-member",
885 "Enum",
886 rules.unused_enum_members,
887 );
888 push_unused_member_issues(
889 &mut issues,
890 results.unused_class_members.iter().map(|m| &m.member),
891 root,
892 "fallow/unused-class-member",
893 "Class",
894 rules.unused_class_members,
895 );
896 push_unresolved_import_issues(
897 &mut issues,
898 &results.unresolved_imports,
899 root,
900 rules.unresolved_imports,
901 );
902 push_unlisted_dep_issues(
903 &mut issues,
904 &results.unlisted_dependencies,
905 root,
906 rules.unlisted_dependencies,
907 );
908 push_duplicate_export_issues(
909 &mut issues,
910 &results.duplicate_exports,
911 root,
912 rules.duplicate_exports,
913 );
914 push_circular_dep_issues(
915 &mut issues,
916 &results.circular_dependencies,
917 root,
918 rules.circular_dependencies,
919 );
920 push_re_export_cycle_issues(
921 &mut issues,
922 &results.re_export_cycles,
923 root,
924 rules.re_export_cycle,
925 );
926 push_boundary_violation_issues(
927 &mut issues,
928 &results.boundary_violations,
929 root,
930 rules.boundary_violation,
931 );
932 push_stale_suppression_issues(
933 &mut issues,
934 &results.stale_suppressions,
935 root,
936 rules.stale_suppressions,
937 );
938 push_unused_catalog_entry_issues(
939 &mut issues,
940 &results.unused_catalog_entries,
941 root,
942 rules.unused_catalog_entries,
943 );
944 push_empty_catalog_group_issues(
945 &mut issues,
946 &results.empty_catalog_groups,
947 root,
948 rules.empty_catalog_groups,
949 );
950 push_unresolved_catalog_reference_issues(
951 &mut issues,
952 &results.unresolved_catalog_references,
953 root,
954 rules.unresolved_catalog_references,
955 );
956 push_unused_dependency_override_issues(
957 &mut issues,
958 &results.unused_dependency_overrides,
959 root,
960 rules.unused_dependency_overrides,
961 );
962 push_misconfigured_dependency_override_issues(
963 &mut issues,
964 &results.misconfigured_dependency_overrides,
965 root,
966 rules.misconfigured_dependency_overrides,
967 );
968
969 issues
970}
971
972pub(super) fn print_codeclimate(
974 results: &AnalysisResults,
975 root: &Path,
976 rules: &RulesConfig,
977) -> ExitCode {
978 let issues = build_codeclimate(results, root, rules);
979 let value = issues_to_value(&issues);
980 emit_json(&value, "CodeClimate")
981}
982
983#[expect(
989 clippy::expect_used,
990 reason = "grouped CodeClimate entries are JSON objects created by issues_to_value"
991)]
992pub(super) fn print_grouped_codeclimate(
993 results: &AnalysisResults,
994 root: &Path,
995 rules: &RulesConfig,
996 resolver: &OwnershipResolver,
997) -> ExitCode {
998 let issues = build_codeclimate(results, root, rules);
999 let mut value = issues_to_value(&issues);
1000
1001 if let Some(items) = value.as_array_mut() {
1002 for issue in items {
1003 let path = issue
1004 .pointer("/location/path")
1005 .and_then(|v| v.as_str())
1006 .unwrap_or("");
1007 let owner = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
1008 issue
1009 .as_object_mut()
1010 .expect("CodeClimate issue should be an object")
1011 .insert("owner".to_string(), serde_json::Value::String(owner));
1012 }
1013 }
1014
1015 emit_json(&value, "CodeClimate")
1016}
1017
1018#[must_use]
1020#[expect(
1021 clippy::too_many_lines,
1022 reason = "CRAP adds a fourth exceeded-threshold branch plus its description; splitting the dispatch table would fragment the mapping."
1023)]
1024pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> Vec<CodeClimateIssue> {
1025 let mut issues = Vec::new();
1026
1027 let cyc_t = report.summary.max_cyclomatic_threshold;
1028 let cog_t = report.summary.max_cognitive_threshold;
1029 let crap_t = report.summary.max_crap_threshold;
1030
1031 for finding in &report.findings {
1032 let path = cc_path(&finding.path, root);
1033 let description = match finding.exceeded {
1034 ExceededThreshold::Both => format!(
1035 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
1036 finding.name, finding.cyclomatic, cyc_t, finding.cognitive, cog_t
1037 ),
1038 ExceededThreshold::Cyclomatic => format!(
1039 "'{}' has cyclomatic complexity {} (threshold: {})",
1040 finding.name, finding.cyclomatic, cyc_t
1041 ),
1042 ExceededThreshold::Cognitive => format!(
1043 "'{}' has cognitive complexity {} (threshold: {})",
1044 finding.name, finding.cognitive, cog_t
1045 ),
1046 ExceededThreshold::Crap
1047 | ExceededThreshold::CyclomaticCrap
1048 | ExceededThreshold::CognitiveCrap
1049 | ExceededThreshold::All => {
1050 let crap = finding.crap.unwrap_or(0.0);
1051 let coverage = finding
1052 .coverage_pct
1053 .map(|pct| format!(", coverage {pct:.0}%"))
1054 .unwrap_or_default();
1055 format!(
1056 "'{}' has CRAP score {crap:.1} (threshold: {crap_t:.1}, cyclomatic {}{coverage})",
1057 finding.name, finding.cyclomatic,
1058 )
1059 }
1060 };
1061 let check_name = match finding.exceeded {
1062 ExceededThreshold::Both => "fallow/high-complexity",
1063 ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
1064 ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
1065 ExceededThreshold::Crap
1066 | ExceededThreshold::CyclomaticCrap
1067 | ExceededThreshold::CognitiveCrap
1068 | ExceededThreshold::All => "fallow/high-crap-score",
1069 };
1070 let severity = match finding.severity {
1071 crate::health_types::FindingSeverity::Critical => CodeClimateSeverity::Critical,
1072 crate::health_types::FindingSeverity::High => CodeClimateSeverity::Major,
1073 crate::health_types::FindingSeverity::Moderate => CodeClimateSeverity::Minor,
1074 };
1075 let line_str = finding.line.to_string();
1076 let fp = fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
1077 issues.push(cc_issue(
1078 check_name,
1079 &description,
1080 severity,
1081 "Complexity",
1082 &path,
1083 Some(finding.line),
1084 &fp,
1085 ));
1086 }
1087
1088 if let Some(ref production) = report.runtime_coverage {
1089 for finding in &production.findings {
1090 let path = cc_path(&finding.path, root);
1091 let check_name = match finding.verdict {
1092 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
1093 "fallow/runtime-safe-to-delete"
1094 }
1095 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
1096 "fallow/runtime-review-required"
1097 }
1098 crate::health_types::RuntimeCoverageVerdict::LowTraffic => {
1099 "fallow/runtime-low-traffic"
1100 }
1101 crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
1102 "fallow/runtime-coverage-unavailable"
1103 }
1104 crate::health_types::RuntimeCoverageVerdict::Active
1105 | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
1106 };
1107 let invocations_hint = finding.invocations.map_or_else(
1108 || "untracked".to_owned(),
1109 |hits| format!("{hits} invocations"),
1110 );
1111 let description = format!(
1112 "'{}' runtime coverage verdict: {} ({})",
1113 finding.function,
1114 finding.verdict.human_label(),
1115 invocations_hint,
1116 );
1117 let severity = match finding.verdict {
1118 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
1119 CodeClimateSeverity::Critical
1120 }
1121 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
1122 CodeClimateSeverity::Major
1123 }
1124 _ => CodeClimateSeverity::Minor,
1125 };
1126 let fp = fingerprint_hash(&[
1127 check_name,
1128 &path,
1129 &finding.line.to_string(),
1130 &finding.function,
1131 ]);
1132 issues.push(cc_issue(
1133 check_name,
1134 &description,
1135 severity,
1136 "Bug Risk",
1137 &path,
1138 Some(finding.line),
1139 &fp,
1140 ));
1141 }
1142 }
1143
1144 if let Some(ref intelligence) = report.coverage_intelligence {
1145 for finding in &intelligence.findings {
1146 let path = cc_path(&finding.path, root);
1147 let check_name = coverage_intelligence_check_name(finding.recommendation);
1148 let identity = finding.identity.as_deref().unwrap_or("code");
1149 let description = format!(
1150 "'{}' coverage intelligence verdict: {} ({})",
1151 identity, finding.verdict, finding.recommendation,
1152 );
1153 let severity = match finding.verdict {
1154 crate::health_types::CoverageIntelligenceVerdict::RiskyChangeDetected
1155 | crate::health_types::CoverageIntelligenceVerdict::HighConfidenceDelete => {
1156 CodeClimateSeverity::Major
1157 }
1158 crate::health_types::CoverageIntelligenceVerdict::ReviewRequired
1159 | crate::health_types::CoverageIntelligenceVerdict::RefactorCarefully => {
1160 CodeClimateSeverity::Minor
1161 }
1162 crate::health_types::CoverageIntelligenceVerdict::Clean
1163 | crate::health_types::CoverageIntelligenceVerdict::Unknown => {
1164 continue;
1165 }
1166 };
1167 let fp = fingerprint_hash(&[
1168 check_name,
1169 &path,
1170 &finding.line.to_string(),
1171 identity,
1172 &finding.id,
1173 ]);
1174 issues.push(cc_issue(
1175 check_name,
1176 &description,
1177 severity,
1178 "Bug Risk",
1179 &path,
1180 Some(finding.line),
1181 &fp,
1182 ));
1183 }
1184 }
1185
1186 if let Some(ref gaps) = report.coverage_gaps {
1187 for item in &gaps.files {
1188 let path = cc_path(&item.file.path, root);
1189 let description = format!(
1190 "File is runtime-reachable but has no test dependency path ({} value export{})",
1191 item.file.value_export_count,
1192 if item.file.value_export_count == 1 {
1193 ""
1194 } else {
1195 "s"
1196 },
1197 );
1198 let fp = fingerprint_hash(&["fallow/untested-file", &path]);
1199 issues.push(cc_issue(
1200 "fallow/untested-file",
1201 &description,
1202 CodeClimateSeverity::Minor,
1203 "Coverage",
1204 &path,
1205 None,
1206 &fp,
1207 ));
1208 }
1209
1210 for item in &gaps.exports {
1211 let path = cc_path(&item.export.path, root);
1212 let description = format!(
1213 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
1214 item.export.export_name
1215 );
1216 let line_str = item.export.line.to_string();
1217 let fp = fingerprint_hash(&[
1218 "fallow/untested-export",
1219 &path,
1220 &line_str,
1221 &item.export.export_name,
1222 ]);
1223 issues.push(cc_issue(
1224 "fallow/untested-export",
1225 &description,
1226 CodeClimateSeverity::Minor,
1227 "Coverage",
1228 &path,
1229 Some(item.export.line),
1230 &fp,
1231 ));
1232 }
1233 }
1234
1235 issues
1236}
1237
1238pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
1240 let issues = build_health_codeclimate(report, root);
1241 let value = issues_to_value(&issues);
1242 emit_json(&value, "CodeClimate")
1243}
1244
1245#[expect(
1254 clippy::expect_used,
1255 reason = "grouped health CodeClimate entries are JSON objects created by issues_to_value"
1256)]
1257pub(super) fn print_grouped_health_codeclimate(
1258 report: &HealthReport,
1259 root: &Path,
1260 resolver: &OwnershipResolver,
1261) -> ExitCode {
1262 let issues = build_health_codeclimate(report, root);
1263 let mut value = issues_to_value(&issues);
1264
1265 if let Some(items) = value.as_array_mut() {
1266 for issue in items {
1267 let path = issue
1268 .pointer("/location/path")
1269 .and_then(|v| v.as_str())
1270 .unwrap_or("");
1271 let group = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
1272 issue
1273 .as_object_mut()
1274 .expect("CodeClimate issue should be an object")
1275 .insert("group".to_string(), serde_json::Value::String(group));
1276 }
1277 }
1278
1279 emit_json(&value, "CodeClimate")
1280}
1281
1282#[must_use]
1284#[expect(
1285 clippy::cast_possible_truncation,
1286 reason = "line numbers are bounded by source size"
1287)]
1288pub fn build_duplication_codeclimate(
1289 report: &DuplicationReport,
1290 root: &Path,
1291) -> Vec<CodeClimateIssue> {
1292 let mut issues = Vec::new();
1293
1294 for (i, group) in report.clone_groups.iter().enumerate() {
1295 let token_str = group.token_count.to_string();
1296 let line_count_str = group.line_count.to_string();
1297 let fragment_prefix: String = group
1298 .instances
1299 .first()
1300 .map(|inst| inst.fragment.chars().take(64).collect())
1301 .unwrap_or_default();
1302
1303 for instance in &group.instances {
1304 let path = cc_path(&instance.file, root);
1305 let start_str = instance.start_line.to_string();
1306 let fp = fingerprint_hash(&[
1307 "fallow/code-duplication",
1308 &path,
1309 &start_str,
1310 &token_str,
1311 &line_count_str,
1312 &fragment_prefix,
1313 ]);
1314 issues.push(cc_issue(
1315 "fallow/code-duplication",
1316 &format!(
1317 "Code clone group {} ({} lines, {} instances)",
1318 i + 1,
1319 group.line_count,
1320 group.instances.len()
1321 ),
1322 CodeClimateSeverity::Minor,
1323 "Duplication",
1324 &path,
1325 Some(instance.start_line as u32),
1326 &fp,
1327 ));
1328 }
1329 }
1330
1331 issues
1332}
1333
1334pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
1336 let issues = build_duplication_codeclimate(report, root);
1337 let value = issues_to_value(&issues);
1338 emit_json(&value, "CodeClimate")
1339}
1340
1341#[expect(
1350 clippy::expect_used,
1351 reason = "grouped duplication CodeClimate entries are JSON objects created by issues_to_value"
1352)]
1353pub(super) fn print_grouped_duplication_codeclimate(
1354 report: &DuplicationReport,
1355 root: &Path,
1356 resolver: &OwnershipResolver,
1357) -> ExitCode {
1358 let issues = build_duplication_codeclimate(report, root);
1359 let mut value = issues_to_value(&issues);
1360
1361 use rustc_hash::FxHashMap;
1362 let mut path_to_owner: FxHashMap<String, String> = FxHashMap::default();
1363 for group in &report.clone_groups {
1364 let owner = super::dupes_grouping::largest_owner(group, root, resolver);
1365 for instance in &group.instances {
1366 let path = cc_path(&instance.file, root);
1367 path_to_owner.insert(path, owner.clone());
1368 }
1369 }
1370
1371 if let Some(items) = value.as_array_mut() {
1372 for issue in items {
1373 let path = issue
1374 .pointer("/location/path")
1375 .and_then(|v| v.as_str())
1376 .unwrap_or("")
1377 .to_string();
1378 let group = path_to_owner
1379 .get(&path)
1380 .cloned()
1381 .unwrap_or_else(|| crate::codeowners::UNOWNED_LABEL.to_string());
1382 issue
1383 .as_object_mut()
1384 .expect("CodeClimate issue should be an object")
1385 .insert("group".to_string(), serde_json::Value::String(group));
1386 }
1387 }
1388
1389 emit_json(&value, "CodeClimate")
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394 use super::*;
1395 use crate::report::test_helpers::sample_results;
1396 use fallow_config::RulesConfig;
1397 use fallow_core::results::*;
1398 use std::path::PathBuf;
1399
1400 fn health_severity(value: u16, threshold: u16) -> &'static str {
1403 if threshold == 0 {
1404 return "minor";
1405 }
1406 let ratio = f64::from(value) / f64::from(threshold);
1407 if ratio > 2.5 {
1408 "critical"
1409 } else if ratio > 1.5 {
1410 "major"
1411 } else {
1412 "minor"
1413 }
1414 }
1415
1416 #[test]
1417 fn codeclimate_empty_results_produces_empty_array() {
1418 let root = PathBuf::from("/project");
1419 let results = AnalysisResults::default();
1420 let rules = RulesConfig::default();
1421 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1422 let arr = output.as_array().unwrap();
1423 assert!(arr.is_empty());
1424 }
1425
1426 #[test]
1427 fn codeclimate_produces_array_of_issues() {
1428 let root = PathBuf::from("/project");
1429 let results = sample_results(&root);
1430 let rules = RulesConfig::default();
1431 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1432 assert!(output.is_array());
1433 let arr = output.as_array().unwrap();
1434 assert!(!arr.is_empty());
1435 }
1436
1437 #[test]
1438 fn codeclimate_issue_has_required_fields() {
1439 let root = PathBuf::from("/project");
1440 let mut results = AnalysisResults::default();
1441 results
1442 .unused_files
1443 .push(UnusedFileFinding::with_actions(UnusedFile {
1444 path: root.join("src/dead.ts"),
1445 }));
1446 let rules = RulesConfig::default();
1447 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1448 let issue = &output.as_array().unwrap()[0];
1449
1450 assert_eq!(issue["type"], "issue");
1451 assert_eq!(issue["check_name"], "fallow/unused-file");
1452 assert!(issue["description"].is_string());
1453 assert!(issue["categories"].is_array());
1454 assert!(issue["severity"].is_string());
1455 assert!(issue["fingerprint"].is_string());
1456 assert!(issue["location"].is_object());
1457 assert!(issue["location"]["path"].is_string());
1458 assert!(issue["location"]["lines"].is_object());
1459 }
1460
1461 #[test]
1462 fn codeclimate_unused_file_severity_follows_rules() {
1463 let root = PathBuf::from("/project");
1464 let mut results = AnalysisResults::default();
1465 results
1466 .unused_files
1467 .push(UnusedFileFinding::with_actions(UnusedFile {
1468 path: root.join("src/dead.ts"),
1469 }));
1470
1471 let rules = RulesConfig::default();
1472 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1473 assert_eq!(output[0]["severity"], "major");
1474
1475 let rules = RulesConfig {
1476 unused_files: Severity::Warn,
1477 ..RulesConfig::default()
1478 };
1479 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1480 assert_eq!(output[0]["severity"], "minor");
1481 }
1482
1483 #[test]
1484 fn codeclimate_unused_export_has_line_number() {
1485 let root = PathBuf::from("/project");
1486 let mut results = AnalysisResults::default();
1487 results
1488 .unused_exports
1489 .push(UnusedExportFinding::with_actions(UnusedExport {
1490 path: root.join("src/utils.ts"),
1491 export_name: "helperFn".to_string(),
1492 is_type_only: false,
1493 line: 10,
1494 col: 4,
1495 span_start: 120,
1496 is_re_export: false,
1497 }));
1498 let rules = RulesConfig::default();
1499 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1500 let issue = &output[0];
1501 assert_eq!(issue["location"]["lines"]["begin"], 10);
1502 }
1503
1504 #[test]
1505 fn codeclimate_unused_file_line_defaults_to_1() {
1506 let root = PathBuf::from("/project");
1507 let mut results = AnalysisResults::default();
1508 results
1509 .unused_files
1510 .push(UnusedFileFinding::with_actions(UnusedFile {
1511 path: root.join("src/dead.ts"),
1512 }));
1513 let rules = RulesConfig::default();
1514 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1515 let issue = &output[0];
1516 assert_eq!(issue["location"]["lines"]["begin"], 1);
1517 }
1518
1519 #[test]
1520 fn codeclimate_paths_are_relative() {
1521 let root = PathBuf::from("/project");
1522 let mut results = AnalysisResults::default();
1523 results
1524 .unused_files
1525 .push(UnusedFileFinding::with_actions(UnusedFile {
1526 path: root.join("src/deep/nested/file.ts"),
1527 }));
1528 let rules = RulesConfig::default();
1529 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1530 let path = output[0]["location"]["path"].as_str().unwrap();
1531 assert_eq!(path, "src/deep/nested/file.ts");
1532 assert!(!path.starts_with("/project"));
1533 }
1534
1535 #[test]
1536 fn codeclimate_re_export_label_in_description() {
1537 let root = PathBuf::from("/project");
1538 let mut results = AnalysisResults::default();
1539 results
1540 .unused_exports
1541 .push(UnusedExportFinding::with_actions(UnusedExport {
1542 path: root.join("src/index.ts"),
1543 export_name: "reExported".to_string(),
1544 is_type_only: false,
1545 line: 1,
1546 col: 0,
1547 span_start: 0,
1548 is_re_export: true,
1549 }));
1550 let rules = RulesConfig::default();
1551 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1552 let desc = output[0]["description"].as_str().unwrap();
1553 assert!(desc.contains("Re-export"));
1554 }
1555
1556 #[test]
1557 fn codeclimate_unlisted_dep_one_issue_per_import_site() {
1558 let root = PathBuf::from("/project");
1559 let mut results = AnalysisResults::default();
1560 results
1561 .unlisted_dependencies
1562 .push(UnlistedDependencyFinding::with_actions(
1563 UnlistedDependency {
1564 package_name: "chalk".to_string(),
1565 imported_from: vec![
1566 ImportSite {
1567 path: root.join("src/a.ts"),
1568 line: 1,
1569 col: 0,
1570 },
1571 ImportSite {
1572 path: root.join("src/b.ts"),
1573 line: 5,
1574 col: 0,
1575 },
1576 ],
1577 },
1578 ));
1579 let rules = RulesConfig::default();
1580 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1581 let arr = output.as_array().unwrap();
1582 assert_eq!(arr.len(), 2);
1583 assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
1584 assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
1585 }
1586
1587 #[test]
1588 fn codeclimate_duplicate_export_one_issue_per_location() {
1589 let root = PathBuf::from("/project");
1590 let mut results = AnalysisResults::default();
1591 results
1592 .duplicate_exports
1593 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1594 export_name: "Config".to_string(),
1595 locations: vec![
1596 DuplicateLocation {
1597 path: root.join("src/a.ts"),
1598 line: 10,
1599 col: 0,
1600 },
1601 DuplicateLocation {
1602 path: root.join("src/b.ts"),
1603 line: 20,
1604 col: 0,
1605 },
1606 DuplicateLocation {
1607 path: root.join("src/c.ts"),
1608 line: 30,
1609 col: 0,
1610 },
1611 ],
1612 }));
1613 let rules = RulesConfig::default();
1614 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1615 let arr = output.as_array().unwrap();
1616 assert_eq!(arr.len(), 3);
1617 }
1618
1619 #[test]
1620 fn codeclimate_circular_dep_emits_chain_in_description() {
1621 let root = PathBuf::from("/project");
1622 let mut results = AnalysisResults::default();
1623 results
1624 .circular_dependencies
1625 .push(CircularDependencyFinding::with_actions(
1626 CircularDependency {
1627 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1628 length: 2,
1629 line: 3,
1630 col: 0,
1631 is_cross_package: false,
1632 },
1633 ));
1634 let rules = RulesConfig::default();
1635 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1636 let desc = output[0]["description"].as_str().unwrap();
1637 assert!(desc.contains("Circular dependency"));
1638 assert!(desc.contains("src/a.ts"));
1639 assert!(desc.contains("src/b.ts"));
1640 }
1641
1642 #[test]
1643 fn codeclimate_fingerprints_are_deterministic() {
1644 let root = PathBuf::from("/project");
1645 let results = sample_results(&root);
1646 let rules = RulesConfig::default();
1647 let output1 = issues_to_value(&build_codeclimate(&results, &root, &rules));
1648 let output2 = issues_to_value(&build_codeclimate(&results, &root, &rules));
1649
1650 let fps1: Vec<&str> = output1
1651 .as_array()
1652 .unwrap()
1653 .iter()
1654 .map(|i| i["fingerprint"].as_str().unwrap())
1655 .collect();
1656 let fps2: Vec<&str> = output2
1657 .as_array()
1658 .unwrap()
1659 .iter()
1660 .map(|i| i["fingerprint"].as_str().unwrap())
1661 .collect();
1662 assert_eq!(fps1, fps2);
1663 }
1664
1665 #[test]
1666 fn codeclimate_fingerprints_are_unique() {
1667 let root = PathBuf::from("/project");
1668 let results = sample_results(&root);
1669 let rules = RulesConfig::default();
1670 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1671
1672 let mut fps: Vec<&str> = output
1673 .as_array()
1674 .unwrap()
1675 .iter()
1676 .map(|i| i["fingerprint"].as_str().unwrap())
1677 .collect();
1678 let original_len = fps.len();
1679 fps.sort_unstable();
1680 fps.dedup();
1681 assert_eq!(fps.len(), original_len, "fingerprints should be unique");
1682 }
1683
1684 #[test]
1685 fn codeclimate_type_only_dep_has_correct_check_name() {
1686 let root = PathBuf::from("/project");
1687 let mut results = AnalysisResults::default();
1688 results
1689 .type_only_dependencies
1690 .push(TypeOnlyDependencyFinding::with_actions(
1691 TypeOnlyDependency {
1692 package_name: "zod".to_string(),
1693 path: root.join("package.json"),
1694 line: 8,
1695 },
1696 ));
1697 let rules = RulesConfig::default();
1698 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1699 assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
1700 let desc = output[0]["description"].as_str().unwrap();
1701 assert!(desc.contains("zod"));
1702 assert!(desc.contains("type-only"));
1703 }
1704
1705 #[test]
1706 fn codeclimate_dep_with_zero_line_omits_line_number() {
1707 let root = PathBuf::from("/project");
1708 let mut results = AnalysisResults::default();
1709 results
1710 .unused_dependencies
1711 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1712 package_name: "lodash".to_string(),
1713 location: DependencyLocation::Dependencies,
1714 path: root.join("package.json"),
1715 line: 0,
1716 used_in_workspaces: Vec::new(),
1717 }));
1718 let rules = RulesConfig::default();
1719 let output = issues_to_value(&build_codeclimate(&results, &root, &rules));
1720 assert_eq!(output[0]["location"]["lines"]["begin"], 1);
1721 }
1722
1723 #[test]
1724 fn fingerprint_hash_different_inputs_differ() {
1725 let h1 = fingerprint_hash(&["a", "b"]);
1726 let h2 = fingerprint_hash(&["a", "c"]);
1727 assert_ne!(h1, h2);
1728 }
1729
1730 #[test]
1731 fn fingerprint_hash_order_matters() {
1732 let h1 = fingerprint_hash(&["a", "b"]);
1733 let h2 = fingerprint_hash(&["b", "a"]);
1734 assert_ne!(h1, h2);
1735 }
1736
1737 #[test]
1738 fn fingerprint_hash_separator_prevents_collision() {
1739 let h1 = fingerprint_hash(&["ab", "c"]);
1740 let h2 = fingerprint_hash(&["a", "bc"]);
1741 assert_ne!(h1, h2);
1742 }
1743
1744 #[test]
1745 fn fingerprint_hash_is_16_hex_chars() {
1746 let h = fingerprint_hash(&["test"]);
1747 assert_eq!(h.len(), 16);
1748 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
1749 }
1750
1751 #[test]
1752 fn severity_error_maps_to_major() {
1753 assert_eq!(
1754 severity_to_codeclimate(Severity::Error),
1755 CodeClimateSeverity::Major
1756 );
1757 }
1758
1759 #[test]
1760 fn severity_warn_maps_to_minor() {
1761 assert_eq!(
1762 severity_to_codeclimate(Severity::Warn),
1763 CodeClimateSeverity::Minor
1764 );
1765 }
1766
1767 #[test]
1768 #[should_panic(expected = "internal error: entered unreachable code")]
1769 fn severity_off_is_unreachable() {
1770 let _ = severity_to_codeclimate(Severity::Off);
1771 }
1772
1773 #[test]
1785 fn build_codeclimate_with_off_severity_and_empty_findings_does_not_panic() {
1786 let root = PathBuf::from("/project");
1787 let results = AnalysisResults::default();
1788 let rules = RulesConfig {
1789 unused_dependencies: Severity::Off,
1790 unused_dev_dependencies: Severity::Off,
1791 unused_optional_dependencies: Severity::Off,
1792 unused_exports: Severity::Off,
1793 unused_types: Severity::Off,
1794 unused_enum_members: Severity::Off,
1795 unused_class_members: Severity::Off,
1796 ..RulesConfig::default()
1797 };
1798 let issues = build_codeclimate(&results, &root, &rules);
1799 assert!(issues.is_empty());
1800 }
1801
1802 #[test]
1803 fn health_severity_zero_threshold_returns_minor() {
1804 assert_eq!(health_severity(100, 0), "minor");
1805 }
1806
1807 #[test]
1808 fn health_severity_at_threshold_returns_minor() {
1809 assert_eq!(health_severity(10, 10), "minor");
1810 }
1811
1812 #[test]
1813 fn health_severity_1_5x_threshold_returns_minor() {
1814 assert_eq!(health_severity(15, 10), "minor");
1815 }
1816
1817 #[test]
1818 fn health_severity_above_1_5x_returns_major() {
1819 assert_eq!(health_severity(16, 10), "major");
1820 }
1821
1822 #[test]
1823 fn health_severity_at_2_5x_returns_major() {
1824 assert_eq!(health_severity(25, 10), "major");
1825 }
1826
1827 #[test]
1828 fn health_severity_above_2_5x_returns_critical() {
1829 assert_eq!(health_severity(26, 10), "critical");
1830 }
1831
1832 #[test]
1833 fn health_codeclimate_includes_coverage_gaps() {
1834 use crate::health_types::*;
1835
1836 let root = PathBuf::from("/project");
1837 let report = HealthReport {
1838 summary: HealthSummary {
1839 files_analyzed: 10,
1840 functions_analyzed: 50,
1841 ..Default::default()
1842 },
1843 coverage_gaps: Some(CoverageGaps {
1844 summary: CoverageGapSummary {
1845 runtime_files: 2,
1846 covered_files: 0,
1847 file_coverage_pct: 0.0,
1848 untested_files: 1,
1849 untested_exports: 1,
1850 },
1851 files: vec![UntestedFileFinding::with_actions(
1852 UntestedFile {
1853 path: root.join("src/app.ts"),
1854 value_export_count: 2,
1855 },
1856 &root,
1857 )],
1858 exports: vec![UntestedExportFinding::with_actions(
1859 UntestedExport {
1860 path: root.join("src/app.ts"),
1861 export_name: "loader".into(),
1862 line: 12,
1863 col: 4,
1864 },
1865 &root,
1866 )],
1867 }),
1868 ..Default::default()
1869 };
1870
1871 let output = issues_to_value(&build_health_codeclimate(&report, &root));
1872 let issues = output.as_array().unwrap();
1873 assert_eq!(issues.len(), 2);
1874 assert_eq!(issues[0]["check_name"], "fallow/untested-file");
1875 assert_eq!(issues[0]["categories"][0], "Coverage");
1876 assert_eq!(issues[0]["location"]["path"], "src/app.ts");
1877 assert_eq!(issues[1]["check_name"], "fallow/untested-export");
1878 assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
1879 assert!(
1880 issues[1]["description"]
1881 .as_str()
1882 .unwrap()
1883 .contains("loader")
1884 );
1885 }
1886
1887 #[test]
1888 fn health_codeclimate_includes_coverage_intelligence_issue() {
1889 use crate::health_types::{
1890 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
1891 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
1892 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
1893 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
1894 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
1895 HealthReport, HealthSummary,
1896 };
1897
1898 let root = PathBuf::from("/project");
1899 let report = HealthReport {
1900 summary: HealthSummary {
1901 files_analyzed: 10,
1902 functions_analyzed: 50,
1903 ..Default::default()
1904 },
1905 coverage_intelligence: Some(CoverageIntelligenceReport {
1906 schema_version: CoverageIntelligenceSchemaVersion::V1,
1907 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
1908 summary: CoverageIntelligenceSummary {
1909 findings: 1,
1910 high_confidence_deletes: 1,
1911 ..Default::default()
1912 },
1913 findings: vec![CoverageIntelligenceFinding {
1914 id: "fallow:coverage-intel:abc123".to_owned(),
1915 path: root.join("src/dead.ts"),
1916 identity: Some("deadPath".to_owned()),
1917 line: 9,
1918 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
1919 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
1920 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
1921 confidence: CoverageIntelligenceConfidence::High,
1922 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
1923 evidence: CoverageIntelligenceEvidence {
1924 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
1925 ..Default::default()
1926 },
1927 actions: vec![CoverageIntelligenceAction {
1928 kind: "delete-after-confirming-owner".to_owned(),
1929 description: "Confirm ownership".to_owned(),
1930 auto_fixable: false,
1931 }],
1932 }],
1933 }),
1934 ..Default::default()
1935 };
1936
1937 let output = issues_to_value(&build_health_codeclimate(&report, &root));
1938 let issues = output.as_array().unwrap();
1939 assert_eq!(issues.len(), 1);
1940 assert_eq!(
1941 issues[0]["check_name"],
1942 "fallow/coverage-intelligence-delete"
1943 );
1944 assert!(!issues[0]["fingerprint"].as_str().unwrap().is_empty());
1945 assert_eq!(issues[0]["location"]["path"], "src/dead.ts");
1946 assert!(
1947 issues[0]["description"]
1948 .as_str()
1949 .unwrap()
1950 .contains("deadPath")
1951 );
1952 }
1953
1954 #[test]
1955 fn health_codeclimate_skips_summary_only_coverage_intelligence() {
1956 use crate::health_types::{
1957 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
1958 CoverageIntelligenceSummary, CoverageIntelligenceVerdict, HealthReport,
1959 };
1960
1961 let root = PathBuf::from("/project");
1962 let report = HealthReport {
1963 coverage_intelligence: Some(CoverageIntelligenceReport {
1964 schema_version: CoverageIntelligenceSchemaVersion::V1,
1965 verdict: CoverageIntelligenceVerdict::Clean,
1966 summary: CoverageIntelligenceSummary {
1967 skipped_ambiguous_matches: 2,
1968 ..Default::default()
1969 },
1970 findings: vec![],
1971 }),
1972 ..Default::default()
1973 };
1974
1975 let issues = build_health_codeclimate(&report, &root);
1976 assert!(issues.is_empty());
1977 }
1978
1979 #[test]
1980 fn health_codeclimate_crap_only_uses_crap_check_name() {
1981 use crate::health_types::{
1982 ComplexityViolation, FindingSeverity, HealthReport, HealthSummary,
1983 };
1984 let root = PathBuf::from("/project");
1985 let report = HealthReport {
1986 findings: vec![
1987 ComplexityViolation {
1988 path: root.join("src/untested.ts"),
1989 name: "risky".to_string(),
1990 line: 7,
1991 col: 0,
1992 cyclomatic: 10,
1993 cognitive: 10,
1994 line_count: 20,
1995 param_count: 1,
1996 exceeded: crate::health_types::ExceededThreshold::Crap,
1997 severity: FindingSeverity::High,
1998 crap: Some(60.0),
1999 coverage_pct: Some(25.0),
2000 coverage_tier: None,
2001 coverage_source: None,
2002 inherited_from: None,
2003 component_rollup: None,
2004 }
2005 .into(),
2006 ],
2007 summary: HealthSummary {
2008 functions_analyzed: 10,
2009 functions_above_threshold: 1,
2010 ..Default::default()
2011 },
2012 ..Default::default()
2013 };
2014 let json = issues_to_value(&build_health_codeclimate(&report, &root));
2015 let issues = json.as_array().unwrap();
2016 assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
2017 assert_eq!(issues[0]["severity"], "major");
2018 let description = issues[0]["description"].as_str().unwrap();
2019 assert!(description.contains("CRAP score"), "desc: {description}");
2020 assert!(description.contains("coverage 25%"), "desc: {description}");
2021 }
2022}