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