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