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_unused_dependency_override_issues(
605 issues: &mut Vec<serde_json::Value>,
606 findings: &[fallow_core::results::UnusedDependencyOverride],
607 root: &Path,
608 severity: Severity,
609) {
610 if findings.is_empty() {
611 return;
612 }
613 let level = severity_to_codeclimate(severity);
614 for finding in findings {
615 let path = cc_path(&finding.path, root);
616 let line_str = finding.line.to_string();
617 let fp = fingerprint_hash(&[
618 "fallow/unused-dependency-override",
619 &path,
620 &line_str,
621 finding.source.as_label(),
622 &finding.raw_key,
623 ]);
624 let mut description = format!(
625 "Override `{}` forces version `{}` but no workspace package depends on `{}`",
626 finding.raw_key, finding.version_range, finding.target_package,
627 );
628 if let Some(hint) = &finding.hint {
629 use std::fmt::Write as _;
630 let _ = write!(description, " ({hint})");
631 }
632 issues.push(cc_issue(
633 "fallow/unused-dependency-override",
634 &description,
635 level,
636 "Bug Risk",
637 &path,
638 Some(finding.line),
639 &fp,
640 ));
641 }
642}
643
644fn push_misconfigured_dependency_override_issues(
645 issues: &mut Vec<serde_json::Value>,
646 findings: &[fallow_core::results::MisconfiguredDependencyOverride],
647 root: &Path,
648 severity: Severity,
649) {
650 if findings.is_empty() {
651 return;
652 }
653 let level = severity_to_codeclimate(severity);
654 for finding in findings {
655 let path = cc_path(&finding.path, root);
656 let line_str = finding.line.to_string();
657 let fp = fingerprint_hash(&[
658 "fallow/misconfigured-dependency-override",
659 &path,
660 &line_str,
661 finding.source.as_label(),
662 &finding.raw_key,
663 ]);
664 let description = format!(
665 "Override `{}` -> `{}` is malformed: {}",
666 finding.raw_key,
667 finding.raw_value,
668 finding.reason.describe(),
669 );
670 issues.push(cc_issue(
671 "fallow/misconfigured-dependency-override",
672 &description,
673 level,
674 "Bug Risk",
675 &path,
676 Some(finding.line),
677 &fp,
678 ));
679 }
680}
681
682#[must_use]
684pub fn build_codeclimate(
685 results: &AnalysisResults,
686 root: &Path,
687 rules: &RulesConfig,
688) -> serde_json::Value {
689 let mut issues = Vec::new();
690
691 push_unused_file_issues(&mut issues, &results.unused_files, root, rules.unused_files);
692 push_unused_export_issues(
693 &mut issues,
694 &results.unused_exports,
695 root,
696 "fallow/unused-export",
697 "Export",
698 "Re-export",
699 rules.unused_exports,
700 );
701 push_unused_export_issues(
702 &mut issues,
703 &results.unused_types,
704 root,
705 "fallow/unused-type",
706 "Type export",
707 "Type re-export",
708 rules.unused_types,
709 );
710 push_private_type_leak_issues(
711 &mut issues,
712 &results.private_type_leaks,
713 root,
714 rules.private_type_leaks,
715 );
716 push_dep_cc_issues(
717 &mut issues,
718 &results.unused_dependencies,
719 root,
720 "fallow/unused-dependency",
721 "dependencies",
722 rules.unused_dependencies,
723 );
724 push_dep_cc_issues(
725 &mut issues,
726 &results.unused_dev_dependencies,
727 root,
728 "fallow/unused-dev-dependency",
729 "devDependencies",
730 rules.unused_dev_dependencies,
731 );
732 push_dep_cc_issues(
733 &mut issues,
734 &results.unused_optional_dependencies,
735 root,
736 "fallow/unused-optional-dependency",
737 "optionalDependencies",
738 rules.unused_optional_dependencies,
739 );
740 push_type_only_dep_issues(
741 &mut issues,
742 &results.type_only_dependencies,
743 root,
744 rules.type_only_dependencies,
745 );
746 push_test_only_dep_issues(
747 &mut issues,
748 &results.test_only_dependencies,
749 root,
750 rules.test_only_dependencies,
751 );
752 push_unused_member_issues(
753 &mut issues,
754 &results.unused_enum_members,
755 root,
756 "fallow/unused-enum-member",
757 "Enum",
758 rules.unused_enum_members,
759 );
760 push_unused_member_issues(
761 &mut issues,
762 &results.unused_class_members,
763 root,
764 "fallow/unused-class-member",
765 "Class",
766 rules.unused_class_members,
767 );
768 push_unresolved_import_issues(
769 &mut issues,
770 &results.unresolved_imports,
771 root,
772 rules.unresolved_imports,
773 );
774 push_unlisted_dep_issues(
775 &mut issues,
776 &results.unlisted_dependencies,
777 root,
778 rules.unlisted_dependencies,
779 );
780 push_duplicate_export_issues(
781 &mut issues,
782 &results.duplicate_exports,
783 root,
784 rules.duplicate_exports,
785 );
786 push_circular_dep_issues(
787 &mut issues,
788 &results.circular_dependencies,
789 root,
790 rules.circular_dependencies,
791 );
792 push_boundary_violation_issues(
793 &mut issues,
794 &results.boundary_violations,
795 root,
796 rules.boundary_violation,
797 );
798 push_stale_suppression_issues(
799 &mut issues,
800 &results.stale_suppressions,
801 root,
802 rules.stale_suppressions,
803 );
804 push_unused_catalog_entry_issues(
805 &mut issues,
806 &results.unused_catalog_entries,
807 root,
808 rules.unused_catalog_entries,
809 );
810 push_unresolved_catalog_reference_issues(
811 &mut issues,
812 &results.unresolved_catalog_references,
813 root,
814 rules.unresolved_catalog_references,
815 );
816 push_unused_dependency_override_issues(
817 &mut issues,
818 &results.unused_dependency_overrides,
819 root,
820 rules.unused_dependency_overrides,
821 );
822 push_misconfigured_dependency_override_issues(
823 &mut issues,
824 &results.misconfigured_dependency_overrides,
825 root,
826 rules.misconfigured_dependency_overrides,
827 );
828
829 serde_json::Value::Array(issues)
830}
831
832pub(super) fn print_codeclimate(
834 results: &AnalysisResults,
835 root: &Path,
836 rules: &RulesConfig,
837) -> ExitCode {
838 let value = build_codeclimate(results, root, rules);
839 emit_json(&value, "CodeClimate")
840}
841
842pub(super) fn print_grouped_codeclimate(
848 results: &AnalysisResults,
849 root: &Path,
850 rules: &RulesConfig,
851 resolver: &OwnershipResolver,
852) -> ExitCode {
853 let mut value = build_codeclimate(results, root, rules);
854
855 if let Some(issues) = value.as_array_mut() {
856 for issue in issues {
857 let path = issue
858 .pointer("/location/path")
859 .and_then(|v| v.as_str())
860 .unwrap_or("");
861 let owner = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
862 issue
863 .as_object_mut()
864 .expect("CodeClimate issue should be an object")
865 .insert("owner".to_string(), serde_json::Value::String(owner));
866 }
867 }
868
869 emit_json(&value, "CodeClimate")
870}
871
872#[must_use]
874#[expect(
875 clippy::too_many_lines,
876 reason = "CRAP adds a fourth exceeded-threshold branch plus its description; splitting the dispatch table would fragment the mapping."
877)]
878pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> serde_json::Value {
879 let mut issues = Vec::new();
880
881 let cyc_t = report.summary.max_cyclomatic_threshold;
882 let cog_t = report.summary.max_cognitive_threshold;
883 let crap_t = report.summary.max_crap_threshold;
884
885 for finding in &report.findings {
886 let path = cc_path(&finding.path, root);
887 let description = match finding.exceeded {
888 ExceededThreshold::Both => format!(
889 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
890 finding.name, finding.cyclomatic, cyc_t, finding.cognitive, cog_t
891 ),
892 ExceededThreshold::Cyclomatic => format!(
893 "'{}' has cyclomatic complexity {} (threshold: {})",
894 finding.name, finding.cyclomatic, cyc_t
895 ),
896 ExceededThreshold::Cognitive => format!(
897 "'{}' has cognitive complexity {} (threshold: {})",
898 finding.name, finding.cognitive, cog_t
899 ),
900 ExceededThreshold::Crap
901 | ExceededThreshold::CyclomaticCrap
902 | ExceededThreshold::CognitiveCrap
903 | ExceededThreshold::All => {
904 let crap = finding.crap.unwrap_or(0.0);
905 let coverage = finding
906 .coverage_pct
907 .map(|pct| format!(", coverage {pct:.0}%"))
908 .unwrap_or_default();
909 format!(
910 "'{}' has CRAP score {crap:.1} (threshold: {crap_t:.1}, cyclomatic {}{coverage})",
911 finding.name, finding.cyclomatic,
912 )
913 }
914 };
915 let check_name = match finding.exceeded {
916 ExceededThreshold::Both => "fallow/high-complexity",
917 ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
918 ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
919 ExceededThreshold::Crap
920 | ExceededThreshold::CyclomaticCrap
921 | ExceededThreshold::CognitiveCrap
922 | ExceededThreshold::All => "fallow/high-crap-score",
923 };
924 let severity = match finding.severity {
926 crate::health_types::FindingSeverity::Critical => "critical",
927 crate::health_types::FindingSeverity::High => "major",
928 crate::health_types::FindingSeverity::Moderate => "minor",
929 };
930 let line_str = finding.line.to_string();
931 let fp = fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
932 issues.push(cc_issue(
933 check_name,
934 &description,
935 severity,
936 "Complexity",
937 &path,
938 Some(finding.line),
939 &fp,
940 ));
941 }
942
943 if let Some(ref production) = report.runtime_coverage {
951 for finding in &production.findings {
952 let path = cc_path(&finding.path, root);
953 let check_name = match finding.verdict {
954 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
955 "fallow/runtime-safe-to-delete"
956 }
957 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
958 "fallow/runtime-review-required"
959 }
960 crate::health_types::RuntimeCoverageVerdict::LowTraffic => {
961 "fallow/runtime-low-traffic"
962 }
963 crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
964 "fallow/runtime-coverage-unavailable"
965 }
966 crate::health_types::RuntimeCoverageVerdict::Active
967 | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
968 };
969 let invocations_hint = finding.invocations.map_or_else(
970 || "untracked".to_owned(),
971 |hits| format!("{hits} invocations"),
972 );
973 let description = format!(
974 "'{}' runtime coverage verdict: {} ({})",
975 finding.function,
976 finding.verdict.human_label(),
977 invocations_hint,
978 );
979 let severity = match finding.verdict {
984 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => "critical",
985 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "major",
986 _ => "minor",
987 };
988 let fp = fingerprint_hash(&[
989 check_name,
990 &path,
991 &finding.line.to_string(),
992 &finding.function,
993 ]);
994 issues.push(cc_issue(
995 check_name,
996 &description,
997 severity,
998 "Bug Risk",
1004 &path,
1005 Some(finding.line),
1006 &fp,
1007 ));
1008 }
1009 }
1010
1011 if let Some(ref gaps) = report.coverage_gaps {
1012 for item in &gaps.files {
1013 let path = cc_path(&item.path, root);
1014 let description = format!(
1015 "File is runtime-reachable but has no test dependency path ({} value export{})",
1016 item.value_export_count,
1017 if item.value_export_count == 1 {
1018 ""
1019 } else {
1020 "s"
1021 },
1022 );
1023 let fp = fingerprint_hash(&["fallow/untested-file", &path]);
1024 issues.push(cc_issue(
1025 "fallow/untested-file",
1026 &description,
1027 "minor",
1028 "Coverage",
1029 &path,
1030 None,
1031 &fp,
1032 ));
1033 }
1034
1035 for item in &gaps.exports {
1036 let path = cc_path(&item.path, root);
1037 let description = format!(
1038 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
1039 item.export_name
1040 );
1041 let line_str = item.line.to_string();
1042 let fp = fingerprint_hash(&[
1043 "fallow/untested-export",
1044 &path,
1045 &line_str,
1046 &item.export_name,
1047 ]);
1048 issues.push(cc_issue(
1049 "fallow/untested-export",
1050 &description,
1051 "minor",
1052 "Coverage",
1053 &path,
1054 Some(item.line),
1055 &fp,
1056 ));
1057 }
1058 }
1059
1060 serde_json::Value::Array(issues)
1061}
1062
1063pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
1065 let value = build_health_codeclimate(report, root);
1066 emit_json(&value, "CodeClimate")
1067}
1068
1069pub(super) fn print_grouped_health_codeclimate(
1078 report: &HealthReport,
1079 root: &Path,
1080 resolver: &OwnershipResolver,
1081) -> ExitCode {
1082 let mut value = build_health_codeclimate(report, root);
1083
1084 if let Some(issues) = value.as_array_mut() {
1085 for issue in issues {
1086 let path = issue
1087 .pointer("/location/path")
1088 .and_then(|v| v.as_str())
1089 .unwrap_or("");
1090 let group = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
1091 issue
1092 .as_object_mut()
1093 .expect("CodeClimate issue should be an object")
1094 .insert("group".to_string(), serde_json::Value::String(group));
1095 }
1096 }
1097
1098 emit_json(&value, "CodeClimate")
1099}
1100
1101#[must_use]
1103#[expect(
1104 clippy::cast_possible_truncation,
1105 reason = "line numbers are bounded by source size"
1106)]
1107pub fn build_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> serde_json::Value {
1108 let mut issues = Vec::new();
1109
1110 for (i, group) in report.clone_groups.iter().enumerate() {
1111 let token_str = group.token_count.to_string();
1114 let line_count_str = group.line_count.to_string();
1115 let fragment_prefix: String = group
1116 .instances
1117 .first()
1118 .map(|inst| inst.fragment.chars().take(64).collect())
1119 .unwrap_or_default();
1120
1121 for instance in &group.instances {
1122 let path = cc_path(&instance.file, root);
1123 let start_str = instance.start_line.to_string();
1124 let fp = fingerprint_hash(&[
1125 "fallow/code-duplication",
1126 &path,
1127 &start_str,
1128 &token_str,
1129 &line_count_str,
1130 &fragment_prefix,
1131 ]);
1132 issues.push(cc_issue(
1133 "fallow/code-duplication",
1134 &format!(
1135 "Code clone group {} ({} lines, {} instances)",
1136 i + 1,
1137 group.line_count,
1138 group.instances.len()
1139 ),
1140 "minor",
1141 "Duplication",
1142 &path,
1143 Some(instance.start_line as u32),
1144 &fp,
1145 ));
1146 }
1147 }
1148
1149 serde_json::Value::Array(issues)
1150}
1151
1152pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
1154 let value = build_duplication_codeclimate(report, root);
1155 emit_json(&value, "CodeClimate")
1156}
1157
1158pub(super) fn print_grouped_duplication_codeclimate(
1167 report: &DuplicationReport,
1168 root: &Path,
1169 resolver: &OwnershipResolver,
1170) -> ExitCode {
1171 let mut value = build_duplication_codeclimate(report, root);
1172
1173 use rustc_hash::FxHashMap;
1176 let mut path_to_owner: FxHashMap<String, String> = FxHashMap::default();
1177 for group in &report.clone_groups {
1178 let owner = super::dupes_grouping::largest_owner(group, root, resolver);
1179 for instance in &group.instances {
1180 let path = cc_path(&instance.file, root);
1181 path_to_owner.insert(path, owner.clone());
1182 }
1183 }
1184
1185 if let Some(issues) = value.as_array_mut() {
1186 for issue in issues {
1187 let path = issue
1188 .pointer("/location/path")
1189 .and_then(|v| v.as_str())
1190 .unwrap_or("")
1191 .to_string();
1192 let group = path_to_owner
1193 .get(&path)
1194 .cloned()
1195 .unwrap_or_else(|| crate::codeowners::UNOWNED_LABEL.to_string());
1196 issue
1197 .as_object_mut()
1198 .expect("CodeClimate issue should be an object")
1199 .insert("group".to_string(), serde_json::Value::String(group));
1200 }
1201 }
1202
1203 emit_json(&value, "CodeClimate")
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208 use super::*;
1209 use crate::report::test_helpers::sample_results;
1210 use fallow_config::RulesConfig;
1211 use fallow_core::results::*;
1212 use std::path::PathBuf;
1213
1214 fn health_severity(value: u16, threshold: u16) -> &'static str {
1217 if threshold == 0 {
1218 return "minor";
1219 }
1220 let ratio = f64::from(value) / f64::from(threshold);
1221 if ratio > 2.5 {
1222 "critical"
1223 } else if ratio > 1.5 {
1224 "major"
1225 } else {
1226 "minor"
1227 }
1228 }
1229
1230 #[test]
1231 fn codeclimate_empty_results_produces_empty_array() {
1232 let root = PathBuf::from("/project");
1233 let results = AnalysisResults::default();
1234 let rules = RulesConfig::default();
1235 let output = build_codeclimate(&results, &root, &rules);
1236 let arr = output.as_array().unwrap();
1237 assert!(arr.is_empty());
1238 }
1239
1240 #[test]
1241 fn codeclimate_produces_array_of_issues() {
1242 let root = PathBuf::from("/project");
1243 let results = sample_results(&root);
1244 let rules = RulesConfig::default();
1245 let output = build_codeclimate(&results, &root, &rules);
1246 assert!(output.is_array());
1247 let arr = output.as_array().unwrap();
1248 assert!(!arr.is_empty());
1250 }
1251
1252 #[test]
1253 fn codeclimate_issue_has_required_fields() {
1254 let root = PathBuf::from("/project");
1255 let mut results = AnalysisResults::default();
1256 results.unused_files.push(UnusedFile {
1257 path: root.join("src/dead.ts"),
1258 });
1259 let rules = RulesConfig::default();
1260 let output = build_codeclimate(&results, &root, &rules);
1261 let issue = &output.as_array().unwrap()[0];
1262
1263 assert_eq!(issue["type"], "issue");
1264 assert_eq!(issue["check_name"], "fallow/unused-file");
1265 assert!(issue["description"].is_string());
1266 assert!(issue["categories"].is_array());
1267 assert!(issue["severity"].is_string());
1268 assert!(issue["fingerprint"].is_string());
1269 assert!(issue["location"].is_object());
1270 assert!(issue["location"]["path"].is_string());
1271 assert!(issue["location"]["lines"].is_object());
1272 }
1273
1274 #[test]
1275 fn codeclimate_unused_file_severity_follows_rules() {
1276 let root = PathBuf::from("/project");
1277 let mut results = AnalysisResults::default();
1278 results.unused_files.push(UnusedFile {
1279 path: root.join("src/dead.ts"),
1280 });
1281
1282 let rules = RulesConfig::default();
1284 let output = build_codeclimate(&results, &root, &rules);
1285 assert_eq!(output[0]["severity"], "major");
1286
1287 let rules = RulesConfig {
1289 unused_files: Severity::Warn,
1290 ..RulesConfig::default()
1291 };
1292 let output = build_codeclimate(&results, &root, &rules);
1293 assert_eq!(output[0]["severity"], "minor");
1294 }
1295
1296 #[test]
1297 fn codeclimate_unused_export_has_line_number() {
1298 let root = PathBuf::from("/project");
1299 let mut results = AnalysisResults::default();
1300 results.unused_exports.push(UnusedExport {
1301 path: root.join("src/utils.ts"),
1302 export_name: "helperFn".to_string(),
1303 is_type_only: false,
1304 line: 10,
1305 col: 4,
1306 span_start: 120,
1307 is_re_export: false,
1308 });
1309 let rules = RulesConfig::default();
1310 let output = build_codeclimate(&results, &root, &rules);
1311 let issue = &output[0];
1312 assert_eq!(issue["location"]["lines"]["begin"], 10);
1313 }
1314
1315 #[test]
1316 fn codeclimate_unused_file_line_defaults_to_1() {
1317 let root = PathBuf::from("/project");
1318 let mut results = AnalysisResults::default();
1319 results.unused_files.push(UnusedFile {
1320 path: root.join("src/dead.ts"),
1321 });
1322 let rules = RulesConfig::default();
1323 let output = build_codeclimate(&results, &root, &rules);
1324 let issue = &output[0];
1325 assert_eq!(issue["location"]["lines"]["begin"], 1);
1326 }
1327
1328 #[test]
1329 fn codeclimate_paths_are_relative() {
1330 let root = PathBuf::from("/project");
1331 let mut results = AnalysisResults::default();
1332 results.unused_files.push(UnusedFile {
1333 path: root.join("src/deep/nested/file.ts"),
1334 });
1335 let rules = RulesConfig::default();
1336 let output = build_codeclimate(&results, &root, &rules);
1337 let path = output[0]["location"]["path"].as_str().unwrap();
1338 assert_eq!(path, "src/deep/nested/file.ts");
1339 assert!(!path.starts_with("/project"));
1340 }
1341
1342 #[test]
1343 fn codeclimate_re_export_label_in_description() {
1344 let root = PathBuf::from("/project");
1345 let mut results = AnalysisResults::default();
1346 results.unused_exports.push(UnusedExport {
1347 path: root.join("src/index.ts"),
1348 export_name: "reExported".to_string(),
1349 is_type_only: false,
1350 line: 1,
1351 col: 0,
1352 span_start: 0,
1353 is_re_export: true,
1354 });
1355 let rules = RulesConfig::default();
1356 let output = build_codeclimate(&results, &root, &rules);
1357 let desc = output[0]["description"].as_str().unwrap();
1358 assert!(desc.contains("Re-export"));
1359 }
1360
1361 #[test]
1362 fn codeclimate_unlisted_dep_one_issue_per_import_site() {
1363 let root = PathBuf::from("/project");
1364 let mut results = AnalysisResults::default();
1365 results.unlisted_dependencies.push(UnlistedDependency {
1366 package_name: "chalk".to_string(),
1367 imported_from: vec![
1368 ImportSite {
1369 path: root.join("src/a.ts"),
1370 line: 1,
1371 col: 0,
1372 },
1373 ImportSite {
1374 path: root.join("src/b.ts"),
1375 line: 5,
1376 col: 0,
1377 },
1378 ],
1379 });
1380 let rules = RulesConfig::default();
1381 let output = build_codeclimate(&results, &root, &rules);
1382 let arr = output.as_array().unwrap();
1383 assert_eq!(arr.len(), 2);
1384 assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
1385 assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
1386 }
1387
1388 #[test]
1389 fn codeclimate_duplicate_export_one_issue_per_location() {
1390 let root = PathBuf::from("/project");
1391 let mut results = AnalysisResults::default();
1392 results.duplicate_exports.push(DuplicateExport {
1393 export_name: "Config".to_string(),
1394 locations: vec![
1395 DuplicateLocation {
1396 path: root.join("src/a.ts"),
1397 line: 10,
1398 col: 0,
1399 },
1400 DuplicateLocation {
1401 path: root.join("src/b.ts"),
1402 line: 20,
1403 col: 0,
1404 },
1405 DuplicateLocation {
1406 path: root.join("src/c.ts"),
1407 line: 30,
1408 col: 0,
1409 },
1410 ],
1411 });
1412 let rules = RulesConfig::default();
1413 let output = build_codeclimate(&results, &root, &rules);
1414 let arr = output.as_array().unwrap();
1415 assert_eq!(arr.len(), 3);
1416 }
1417
1418 #[test]
1419 fn codeclimate_circular_dep_emits_chain_in_description() {
1420 let root = PathBuf::from("/project");
1421 let mut results = AnalysisResults::default();
1422 results.circular_dependencies.push(CircularDependency {
1423 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1424 length: 2,
1425 line: 3,
1426 col: 0,
1427 is_cross_package: false,
1428 });
1429 let rules = RulesConfig::default();
1430 let output = build_codeclimate(&results, &root, &rules);
1431 let desc = output[0]["description"].as_str().unwrap();
1432 assert!(desc.contains("Circular dependency"));
1433 assert!(desc.contains("src/a.ts"));
1434 assert!(desc.contains("src/b.ts"));
1435 }
1436
1437 #[test]
1438 fn codeclimate_fingerprints_are_deterministic() {
1439 let root = PathBuf::from("/project");
1440 let results = sample_results(&root);
1441 let rules = RulesConfig::default();
1442 let output1 = build_codeclimate(&results, &root, &rules);
1443 let output2 = build_codeclimate(&results, &root, &rules);
1444
1445 let fps1: Vec<&str> = output1
1446 .as_array()
1447 .unwrap()
1448 .iter()
1449 .map(|i| i["fingerprint"].as_str().unwrap())
1450 .collect();
1451 let fps2: Vec<&str> = output2
1452 .as_array()
1453 .unwrap()
1454 .iter()
1455 .map(|i| i["fingerprint"].as_str().unwrap())
1456 .collect();
1457 assert_eq!(fps1, fps2);
1458 }
1459
1460 #[test]
1461 fn codeclimate_fingerprints_are_unique() {
1462 let root = PathBuf::from("/project");
1463 let results = sample_results(&root);
1464 let rules = RulesConfig::default();
1465 let output = build_codeclimate(&results, &root, &rules);
1466
1467 let mut fps: Vec<&str> = output
1468 .as_array()
1469 .unwrap()
1470 .iter()
1471 .map(|i| i["fingerprint"].as_str().unwrap())
1472 .collect();
1473 let original_len = fps.len();
1474 fps.sort_unstable();
1475 fps.dedup();
1476 assert_eq!(fps.len(), original_len, "fingerprints should be unique");
1477 }
1478
1479 #[test]
1480 fn codeclimate_type_only_dep_has_correct_check_name() {
1481 let root = PathBuf::from("/project");
1482 let mut results = AnalysisResults::default();
1483 results.type_only_dependencies.push(TypeOnlyDependency {
1484 package_name: "zod".to_string(),
1485 path: root.join("package.json"),
1486 line: 8,
1487 });
1488 let rules = RulesConfig::default();
1489 let output = build_codeclimate(&results, &root, &rules);
1490 assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
1491 let desc = output[0]["description"].as_str().unwrap();
1492 assert!(desc.contains("zod"));
1493 assert!(desc.contains("type-only"));
1494 }
1495
1496 #[test]
1497 fn codeclimate_dep_with_zero_line_omits_line_number() {
1498 let root = PathBuf::from("/project");
1499 let mut results = AnalysisResults::default();
1500 results.unused_dependencies.push(UnusedDependency {
1501 package_name: "lodash".to_string(),
1502 location: DependencyLocation::Dependencies,
1503 path: root.join("package.json"),
1504 line: 0,
1505 used_in_workspaces: Vec::new(),
1506 });
1507 let rules = RulesConfig::default();
1508 let output = build_codeclimate(&results, &root, &rules);
1509 assert_eq!(output[0]["location"]["lines"]["begin"], 1);
1511 }
1512
1513 #[test]
1516 fn fingerprint_hash_different_inputs_differ() {
1517 let h1 = fingerprint_hash(&["a", "b"]);
1518 let h2 = fingerprint_hash(&["a", "c"]);
1519 assert_ne!(h1, h2);
1520 }
1521
1522 #[test]
1523 fn fingerprint_hash_order_matters() {
1524 let h1 = fingerprint_hash(&["a", "b"]);
1525 let h2 = fingerprint_hash(&["b", "a"]);
1526 assert_ne!(h1, h2);
1527 }
1528
1529 #[test]
1530 fn fingerprint_hash_separator_prevents_collision() {
1531 let h1 = fingerprint_hash(&["ab", "c"]);
1533 let h2 = fingerprint_hash(&["a", "bc"]);
1534 assert_ne!(h1, h2);
1535 }
1536
1537 #[test]
1538 fn fingerprint_hash_is_16_hex_chars() {
1539 let h = fingerprint_hash(&["test"]);
1540 assert_eq!(h.len(), 16);
1541 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
1542 }
1543
1544 #[test]
1547 fn severity_error_maps_to_major() {
1548 assert_eq!(severity_to_codeclimate(Severity::Error), "major");
1549 }
1550
1551 #[test]
1552 fn severity_warn_maps_to_minor() {
1553 assert_eq!(severity_to_codeclimate(Severity::Warn), "minor");
1554 }
1555
1556 #[test]
1557 #[should_panic(expected = "internal error: entered unreachable code")]
1558 fn severity_off_maps_to_minor() {
1559 let _ = severity_to_codeclimate(Severity::Off);
1560 }
1561
1562 #[test]
1565 fn health_severity_zero_threshold_returns_minor() {
1566 assert_eq!(health_severity(100, 0), "minor");
1567 }
1568
1569 #[test]
1570 fn health_severity_at_threshold_returns_minor() {
1571 assert_eq!(health_severity(10, 10), "minor");
1572 }
1573
1574 #[test]
1575 fn health_severity_1_5x_threshold_returns_minor() {
1576 assert_eq!(health_severity(15, 10), "minor");
1577 }
1578
1579 #[test]
1580 fn health_severity_above_1_5x_returns_major() {
1581 assert_eq!(health_severity(16, 10), "major");
1582 }
1583
1584 #[test]
1585 fn health_severity_at_2_5x_returns_major() {
1586 assert_eq!(health_severity(25, 10), "major");
1587 }
1588
1589 #[test]
1590 fn health_severity_above_2_5x_returns_critical() {
1591 assert_eq!(health_severity(26, 10), "critical");
1592 }
1593
1594 #[test]
1595 fn health_codeclimate_includes_coverage_gaps() {
1596 use crate::health_types::*;
1597
1598 let root = PathBuf::from("/project");
1599 let report = HealthReport {
1600 summary: HealthSummary {
1601 files_analyzed: 10,
1602 functions_analyzed: 50,
1603 ..Default::default()
1604 },
1605 coverage_gaps: Some(CoverageGaps {
1606 summary: CoverageGapSummary {
1607 runtime_files: 2,
1608 covered_files: 0,
1609 file_coverage_pct: 0.0,
1610 untested_files: 1,
1611 untested_exports: 1,
1612 },
1613 files: vec![UntestedFile {
1614 path: root.join("src/app.ts"),
1615 value_export_count: 2,
1616 }],
1617 exports: vec![UntestedExport {
1618 path: root.join("src/app.ts"),
1619 export_name: "loader".into(),
1620 line: 12,
1621 col: 4,
1622 }],
1623 }),
1624 ..Default::default()
1625 };
1626
1627 let output = build_health_codeclimate(&report, &root);
1628 let issues = output.as_array().unwrap();
1629 assert_eq!(issues.len(), 2);
1630 assert_eq!(issues[0]["check_name"], "fallow/untested-file");
1631 assert_eq!(issues[0]["categories"][0], "Coverage");
1632 assert_eq!(issues[0]["location"]["path"], "src/app.ts");
1633 assert_eq!(issues[1]["check_name"], "fallow/untested-export");
1634 assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
1635 assert!(
1636 issues[1]["description"]
1637 .as_str()
1638 .unwrap()
1639 .contains("loader")
1640 );
1641 }
1642
1643 #[test]
1644 fn health_codeclimate_crap_only_uses_crap_check_name() {
1645 use crate::health_types::{FindingSeverity, HealthFinding, HealthReport, HealthSummary};
1646 let root = PathBuf::from("/project");
1647 let report = HealthReport {
1648 findings: vec![HealthFinding {
1649 path: root.join("src/untested.ts"),
1650 name: "risky".to_string(),
1651 line: 7,
1652 col: 0,
1653 cyclomatic: 10,
1654 cognitive: 10,
1655 line_count: 20,
1656 param_count: 1,
1657 exceeded: crate::health_types::ExceededThreshold::Crap,
1658 severity: FindingSeverity::High,
1659 crap: Some(60.0),
1660 coverage_pct: Some(25.0),
1661 coverage_tier: None,
1662 }],
1663 summary: HealthSummary {
1664 functions_analyzed: 10,
1665 functions_above_threshold: 1,
1666 ..Default::default()
1667 },
1668 ..Default::default()
1669 };
1670 let json = build_health_codeclimate(&report, &root);
1671 let issues = json.as_array().unwrap();
1672 assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
1673 assert_eq!(issues[0]["severity"], "major");
1674 let description = issues[0]["description"].as_str().unwrap();
1675 assert!(description.contains("CRAP score"), "desc: {description}");
1676 assert!(description.contains("coverage 25%"), "desc: {description}");
1677 }
1678}