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