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