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::grouping::{self, OwnershipResolver};
9use super::{emit_json, normalize_uri, relative_path};
10use crate::health_types::{ExceededThreshold, HealthReport};
11
12const fn severity_to_codeclimate(s: Severity) -> &'static str {
14 match s {
15 Severity::Error => "major",
16 Severity::Warn | Severity::Off => "minor",
17 }
18}
19
20fn cc_path(path: &Path, root: &Path) -> String {
25 normalize_uri(&relative_path(path, root).display().to_string())
26}
27
28fn fingerprint_hash(parts: &[&str]) -> String {
33 let mut hash: u64 = 0xcbf2_9ce4_8422_2325; for part in parts {
35 for byte in part.bytes() {
36 hash ^= u64::from(byte);
37 hash = hash.wrapping_mul(0x0100_0000_01b3); }
39 hash ^= 0xff;
41 hash = hash.wrapping_mul(0x0100_0000_01b3);
42 }
43 format!("{hash:016x}")
44}
45
46fn cc_issue(
48 check_name: &str,
49 description: &str,
50 severity: &str,
51 category: &str,
52 path: &str,
53 begin_line: Option<u32>,
54 fingerprint: &str,
55) -> serde_json::Value {
56 let lines = begin_line.map_or_else(
57 || serde_json::json!({ "begin": 1 }),
58 |line| serde_json::json!({ "begin": line }),
59 );
60
61 serde_json::json!({
62 "type": "issue",
63 "check_name": check_name,
64 "description": description,
65 "categories": [category],
66 "severity": severity,
67 "fingerprint": fingerprint,
68 "location": {
69 "path": path,
70 "lines": lines
71 }
72 })
73}
74
75fn push_dep_cc_issues(
77 issues: &mut Vec<serde_json::Value>,
78 deps: &[fallow_core::results::UnusedDependency],
79 root: &Path,
80 rule_id: &str,
81 location_label: &str,
82 severity: Severity,
83) {
84 let level = severity_to_codeclimate(severity);
85 for dep in deps {
86 let path = cc_path(&dep.path, root);
87 let line = if dep.line > 0 { Some(dep.line) } else { None };
88 let fp = fingerprint_hash(&[rule_id, &dep.package_name]);
89 issues.push(cc_issue(
90 rule_id,
91 &format!(
92 "Package '{}' is in {location_label} but never imported",
93 dep.package_name
94 ),
95 level,
96 "Bug Risk",
97 &path,
98 line,
99 &fp,
100 ));
101 }
102}
103
104fn push_unused_file_issues(
105 issues: &mut Vec<serde_json::Value>,
106 files: &[fallow_core::results::UnusedFile],
107 root: &Path,
108 severity: Severity,
109) {
110 let level = severity_to_codeclimate(severity);
111 for file in files {
112 let path = cc_path(&file.path, root);
113 let fp = fingerprint_hash(&["fallow/unused-file", &path]);
114 issues.push(cc_issue(
115 "fallow/unused-file",
116 "File is not reachable from any entry point",
117 level,
118 "Bug Risk",
119 &path,
120 None,
121 &fp,
122 ));
123 }
124}
125
126fn push_unused_export_issues(
132 issues: &mut Vec<serde_json::Value>,
133 exports: &[fallow_core::results::UnusedExport],
134 root: &Path,
135 rule_id: &str,
136 direct_label: &str,
137 re_export_label: &str,
138 severity: Severity,
139) {
140 let level = severity_to_codeclimate(severity);
141 for export in exports {
142 let path = cc_path(&export.path, root);
143 let kind = if export.is_re_export {
144 re_export_label
145 } else {
146 direct_label
147 };
148 let line_str = export.line.to_string();
149 let fp = fingerprint_hash(&[rule_id, &path, &line_str, &export.export_name]);
150 issues.push(cc_issue(
151 rule_id,
152 &format!(
153 "{kind} '{}' is never imported by other modules",
154 export.export_name
155 ),
156 level,
157 "Bug Risk",
158 &path,
159 Some(export.line),
160 &fp,
161 ));
162 }
163}
164
165fn push_type_only_dep_issues(
166 issues: &mut Vec<serde_json::Value>,
167 deps: &[fallow_core::results::TypeOnlyDependency],
168 root: &Path,
169 severity: Severity,
170) {
171 let level = severity_to_codeclimate(severity);
172 for dep in deps {
173 let path = cc_path(&dep.path, root);
174 let line = if dep.line > 0 { Some(dep.line) } else { None };
175 let fp = fingerprint_hash(&["fallow/type-only-dependency", &dep.package_name]);
176 issues.push(cc_issue(
177 "fallow/type-only-dependency",
178 &format!(
179 "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
180 dep.package_name
181 ),
182 level,
183 "Bug Risk",
184 &path,
185 line,
186 &fp,
187 ));
188 }
189}
190
191fn push_test_only_dep_issues(
192 issues: &mut Vec<serde_json::Value>,
193 deps: &[fallow_core::results::TestOnlyDependency],
194 root: &Path,
195 severity: Severity,
196) {
197 let level = severity_to_codeclimate(severity);
198 for dep in deps {
199 let path = cc_path(&dep.path, root);
200 let line = if dep.line > 0 { Some(dep.line) } else { None };
201 let fp = fingerprint_hash(&["fallow/test-only-dependency", &dep.package_name]);
202 issues.push(cc_issue(
203 "fallow/test-only-dependency",
204 &format!(
205 "Package '{}' is only imported by test files (consider moving to devDependencies)",
206 dep.package_name
207 ),
208 level,
209 "Bug Risk",
210 &path,
211 line,
212 &fp,
213 ));
214 }
215}
216
217fn push_unused_member_issues(
222 issues: &mut Vec<serde_json::Value>,
223 members: &[fallow_core::results::UnusedMember],
224 root: &Path,
225 rule_id: &str,
226 entity_label: &str,
227 severity: Severity,
228) {
229 let level = severity_to_codeclimate(severity);
230 for member in members {
231 let path = cc_path(&member.path, root);
232 let line_str = member.line.to_string();
233 let fp = fingerprint_hash(&[
234 rule_id,
235 &path,
236 &line_str,
237 &member.parent_name,
238 &member.member_name,
239 ]);
240 issues.push(cc_issue(
241 rule_id,
242 &format!(
243 "{entity_label} member '{}.{}' is never referenced",
244 member.parent_name, member.member_name
245 ),
246 level,
247 "Bug Risk",
248 &path,
249 Some(member.line),
250 &fp,
251 ));
252 }
253}
254
255fn push_unresolved_import_issues(
256 issues: &mut Vec<serde_json::Value>,
257 imports: &[fallow_core::results::UnresolvedImport],
258 root: &Path,
259 severity: Severity,
260) {
261 let level = severity_to_codeclimate(severity);
262 for import in imports {
263 let path = cc_path(&import.path, root);
264 let line_str = import.line.to_string();
265 let fp = fingerprint_hash(&[
266 "fallow/unresolved-import",
267 &path,
268 &line_str,
269 &import.specifier,
270 ]);
271 issues.push(cc_issue(
272 "fallow/unresolved-import",
273 &format!("Import '{}' could not be resolved", import.specifier),
274 level,
275 "Bug Risk",
276 &path,
277 Some(import.line),
278 &fp,
279 ));
280 }
281}
282
283fn push_unlisted_dep_issues(
284 issues: &mut Vec<serde_json::Value>,
285 deps: &[fallow_core::results::UnlistedDependency],
286 root: &Path,
287 severity: Severity,
288) {
289 let level = severity_to_codeclimate(severity);
290 for dep in deps {
291 for site in &dep.imported_from {
292 let path = cc_path(&site.path, root);
293 let line_str = site.line.to_string();
294 let fp = fingerprint_hash(&[
295 "fallow/unlisted-dependency",
296 &path,
297 &line_str,
298 &dep.package_name,
299 ]);
300 issues.push(cc_issue(
301 "fallow/unlisted-dependency",
302 &format!(
303 "Package '{}' is imported but not listed in package.json",
304 dep.package_name
305 ),
306 level,
307 "Bug Risk",
308 &path,
309 Some(site.line),
310 &fp,
311 ));
312 }
313 }
314}
315
316fn push_duplicate_export_issues(
317 issues: &mut Vec<serde_json::Value>,
318 dups: &[fallow_core::results::DuplicateExport],
319 root: &Path,
320 severity: Severity,
321) {
322 let level = severity_to_codeclimate(severity);
323 for dup in dups {
324 for loc in &dup.locations {
325 let path = cc_path(&loc.path, root);
326 let line_str = loc.line.to_string();
327 let fp = fingerprint_hash(&[
328 "fallow/duplicate-export",
329 &path,
330 &line_str,
331 &dup.export_name,
332 ]);
333 issues.push(cc_issue(
334 "fallow/duplicate-export",
335 &format!("Export '{}' appears in multiple modules", dup.export_name),
336 level,
337 "Bug Risk",
338 &path,
339 Some(loc.line),
340 &fp,
341 ));
342 }
343 }
344}
345
346fn push_circular_dep_issues(
347 issues: &mut Vec<serde_json::Value>,
348 cycles: &[fallow_core::results::CircularDependency],
349 root: &Path,
350 severity: Severity,
351) {
352 let level = severity_to_codeclimate(severity);
353 for cycle in cycles {
354 let Some(first) = cycle.files.first() else {
355 continue;
356 };
357 let path = cc_path(first, root);
358 let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
359 let chain_str = chain.join(":");
360 let fp = fingerprint_hash(&["fallow/circular-dependency", &chain_str]);
361 let line = if cycle.line > 0 {
362 Some(cycle.line)
363 } else {
364 None
365 };
366 issues.push(cc_issue(
367 "fallow/circular-dependency",
368 &format!(
369 "Circular dependency{}: {}",
370 if cycle.is_cross_package {
371 " (cross-package)"
372 } else {
373 ""
374 },
375 chain.join(" \u{2192} ")
376 ),
377 level,
378 "Bug Risk",
379 &path,
380 line,
381 &fp,
382 ));
383 }
384}
385
386fn push_boundary_violation_issues(
387 issues: &mut Vec<serde_json::Value>,
388 violations: &[fallow_core::results::BoundaryViolation],
389 root: &Path,
390 severity: Severity,
391) {
392 let level = severity_to_codeclimate(severity);
393 for v in violations {
394 let path = cc_path(&v.from_path, root);
395 let to = cc_path(&v.to_path, root);
396 let fp = fingerprint_hash(&["fallow/boundary-violation", &path, &to]);
397 let line = if v.line > 0 { Some(v.line) } else { None };
398 issues.push(cc_issue(
399 "fallow/boundary-violation",
400 &format!(
401 "Boundary violation: {} -> {} ({} -> {})",
402 path, to, v.from_zone, v.to_zone
403 ),
404 level,
405 "Bug Risk",
406 &path,
407 line,
408 &fp,
409 ));
410 }
411}
412
413fn push_stale_suppression_issues(
414 issues: &mut Vec<serde_json::Value>,
415 suppressions: &[fallow_core::results::StaleSuppression],
416 root: &Path,
417 severity: Severity,
418) {
419 let level = severity_to_codeclimate(severity);
420 for s in suppressions {
421 let path = cc_path(&s.path, root);
422 let line_str = s.line.to_string();
423 let fp = fingerprint_hash(&["fallow/stale-suppression", &path, &line_str]);
424 issues.push(cc_issue(
425 "fallow/stale-suppression",
426 &s.description(),
427 level,
428 "Bug Risk",
429 &path,
430 Some(s.line),
431 &fp,
432 ));
433 }
434}
435
436#[must_use]
438pub fn build_codeclimate(
439 results: &AnalysisResults,
440 root: &Path,
441 rules: &RulesConfig,
442) -> serde_json::Value {
443 let mut issues = Vec::new();
444
445 push_unused_file_issues(&mut issues, &results.unused_files, root, rules.unused_files);
446 push_unused_export_issues(
447 &mut issues,
448 &results.unused_exports,
449 root,
450 "fallow/unused-export",
451 "Export",
452 "Re-export",
453 rules.unused_exports,
454 );
455 push_unused_export_issues(
456 &mut issues,
457 &results.unused_types,
458 root,
459 "fallow/unused-type",
460 "Type export",
461 "Type re-export",
462 rules.unused_types,
463 );
464 push_dep_cc_issues(
465 &mut issues,
466 &results.unused_dependencies,
467 root,
468 "fallow/unused-dependency",
469 "dependencies",
470 rules.unused_dependencies,
471 );
472 push_dep_cc_issues(
473 &mut issues,
474 &results.unused_dev_dependencies,
475 root,
476 "fallow/unused-dev-dependency",
477 "devDependencies",
478 rules.unused_dev_dependencies,
479 );
480 push_dep_cc_issues(
481 &mut issues,
482 &results.unused_optional_dependencies,
483 root,
484 "fallow/unused-optional-dependency",
485 "optionalDependencies",
486 rules.unused_optional_dependencies,
487 );
488 push_type_only_dep_issues(
489 &mut issues,
490 &results.type_only_dependencies,
491 root,
492 rules.type_only_dependencies,
493 );
494 push_test_only_dep_issues(
495 &mut issues,
496 &results.test_only_dependencies,
497 root,
498 rules.test_only_dependencies,
499 );
500 push_unused_member_issues(
501 &mut issues,
502 &results.unused_enum_members,
503 root,
504 "fallow/unused-enum-member",
505 "Enum",
506 rules.unused_enum_members,
507 );
508 push_unused_member_issues(
509 &mut issues,
510 &results.unused_class_members,
511 root,
512 "fallow/unused-class-member",
513 "Class",
514 rules.unused_class_members,
515 );
516 push_unresolved_import_issues(
517 &mut issues,
518 &results.unresolved_imports,
519 root,
520 rules.unresolved_imports,
521 );
522 push_unlisted_dep_issues(
523 &mut issues,
524 &results.unlisted_dependencies,
525 root,
526 rules.unlisted_dependencies,
527 );
528 push_duplicate_export_issues(
529 &mut issues,
530 &results.duplicate_exports,
531 root,
532 rules.duplicate_exports,
533 );
534 push_circular_dep_issues(
535 &mut issues,
536 &results.circular_dependencies,
537 root,
538 rules.circular_dependencies,
539 );
540 push_boundary_violation_issues(
541 &mut issues,
542 &results.boundary_violations,
543 root,
544 rules.boundary_violation,
545 );
546 push_stale_suppression_issues(
547 &mut issues,
548 &results.stale_suppressions,
549 root,
550 rules.stale_suppressions,
551 );
552
553 serde_json::Value::Array(issues)
554}
555
556pub(super) fn print_codeclimate(
558 results: &AnalysisResults,
559 root: &Path,
560 rules: &RulesConfig,
561) -> ExitCode {
562 let value = build_codeclimate(results, root, rules);
563 emit_json(&value, "CodeClimate")
564}
565
566pub(super) fn print_grouped_codeclimate(
572 results: &AnalysisResults,
573 root: &Path,
574 rules: &RulesConfig,
575 resolver: &OwnershipResolver,
576) -> ExitCode {
577 let mut value = build_codeclimate(results, root, rules);
578
579 if let Some(issues) = value.as_array_mut() {
580 for issue in issues {
581 let path = issue
582 .pointer("/location/path")
583 .and_then(|v| v.as_str())
584 .unwrap_or("");
585 let owner = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
586 issue
587 .as_object_mut()
588 .expect("CodeClimate issue should be an object")
589 .insert("owner".to_string(), serde_json::Value::String(owner));
590 }
591 }
592
593 emit_json(&value, "CodeClimate")
594}
595
596#[must_use]
598#[expect(
599 clippy::too_many_lines,
600 reason = "CRAP adds a fourth exceeded-threshold branch plus its description; splitting the dispatch table would fragment the mapping."
601)]
602pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> serde_json::Value {
603 let mut issues = Vec::new();
604
605 let cyc_t = report.summary.max_cyclomatic_threshold;
606 let cog_t = report.summary.max_cognitive_threshold;
607 let crap_t = report.summary.max_crap_threshold;
608
609 for finding in &report.findings {
610 let path = cc_path(&finding.path, root);
611 let description = match finding.exceeded {
612 ExceededThreshold::Both => format!(
613 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
614 finding.name, finding.cyclomatic, cyc_t, finding.cognitive, cog_t
615 ),
616 ExceededThreshold::Cyclomatic => format!(
617 "'{}' has cyclomatic complexity {} (threshold: {})",
618 finding.name, finding.cyclomatic, cyc_t
619 ),
620 ExceededThreshold::Cognitive => format!(
621 "'{}' has cognitive complexity {} (threshold: {})",
622 finding.name, finding.cognitive, cog_t
623 ),
624 ExceededThreshold::Crap
625 | ExceededThreshold::CyclomaticCrap
626 | ExceededThreshold::CognitiveCrap
627 | ExceededThreshold::All => {
628 let crap = finding.crap.unwrap_or(0.0);
629 let coverage = finding
630 .coverage_pct
631 .map(|pct| format!(", coverage {pct:.0}%"))
632 .unwrap_or_default();
633 format!(
634 "'{}' has CRAP score {crap:.1} (threshold: {crap_t:.1}, cyclomatic {}{coverage})",
635 finding.name, finding.cyclomatic,
636 )
637 }
638 };
639 let check_name = match finding.exceeded {
640 ExceededThreshold::Both => "fallow/high-complexity",
641 ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
642 ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
643 ExceededThreshold::Crap
644 | ExceededThreshold::CyclomaticCrap
645 | ExceededThreshold::CognitiveCrap
646 | ExceededThreshold::All => "fallow/high-crap-score",
647 };
648 let severity = match finding.severity {
650 crate::health_types::FindingSeverity::Critical => "critical",
651 crate::health_types::FindingSeverity::High => "major",
652 crate::health_types::FindingSeverity::Moderate => "minor",
653 };
654 let line_str = finding.line.to_string();
655 let fp = fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
656 issues.push(cc_issue(
657 check_name,
658 &description,
659 severity,
660 "Complexity",
661 &path,
662 Some(finding.line),
663 &fp,
664 ));
665 }
666
667 if let Some(ref production) = report.runtime_coverage {
668 for finding in &production.findings {
669 let path = cc_path(&finding.path, root);
670 let check_name = match finding.verdict {
671 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
672 "fallow/runtime-safe-to-delete"
673 }
674 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
675 "fallow/runtime-review-required"
676 }
677 crate::health_types::RuntimeCoverageVerdict::LowTraffic => {
678 "fallow/runtime-low-traffic"
679 }
680 crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
681 "fallow/runtime-coverage-unavailable"
682 }
683 crate::health_types::RuntimeCoverageVerdict::Active
684 | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
685 };
686 let invocations_hint = finding.invocations.map_or_else(
687 || "untracked".to_owned(),
688 |hits| format!("{hits} invocations"),
689 );
690 let description = format!(
691 "'{}' runtime coverage verdict: {} ({})",
692 finding.function,
693 finding.verdict.human_label(),
694 invocations_hint,
695 );
696 let severity = match finding.verdict {
701 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => "critical",
702 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "major",
703 _ => "minor",
704 };
705 let fp = fingerprint_hash(&[
706 check_name,
707 &path,
708 &finding.line.to_string(),
709 &finding.function,
710 ]);
711 issues.push(cc_issue(
712 check_name,
713 &description,
714 severity,
715 "Bug Risk",
721 &path,
722 Some(finding.line),
723 &fp,
724 ));
725 }
726 }
727
728 if let Some(ref gaps) = report.coverage_gaps {
729 for item in &gaps.files {
730 let path = cc_path(&item.path, root);
731 let description = format!(
732 "File is runtime-reachable but has no test dependency path ({} value export{})",
733 item.value_export_count,
734 if item.value_export_count == 1 {
735 ""
736 } else {
737 "s"
738 },
739 );
740 let fp = fingerprint_hash(&["fallow/untested-file", &path]);
741 issues.push(cc_issue(
742 "fallow/untested-file",
743 &description,
744 "minor",
745 "Coverage",
746 &path,
747 None,
748 &fp,
749 ));
750 }
751
752 for item in &gaps.exports {
753 let path = cc_path(&item.path, root);
754 let description = format!(
755 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
756 item.export_name
757 );
758 let line_str = item.line.to_string();
759 let fp = fingerprint_hash(&[
760 "fallow/untested-export",
761 &path,
762 &line_str,
763 &item.export_name,
764 ]);
765 issues.push(cc_issue(
766 "fallow/untested-export",
767 &description,
768 "minor",
769 "Coverage",
770 &path,
771 Some(item.line),
772 &fp,
773 ));
774 }
775 }
776
777 serde_json::Value::Array(issues)
778}
779
780pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
782 let value = build_health_codeclimate(report, root);
783 emit_json(&value, "CodeClimate")
784}
785
786pub(super) fn print_grouped_health_codeclimate(
795 report: &HealthReport,
796 root: &Path,
797 resolver: &OwnershipResolver,
798) -> ExitCode {
799 let mut value = build_health_codeclimate(report, root);
800
801 if let Some(issues) = value.as_array_mut() {
802 for issue in issues {
803 let path = issue
804 .pointer("/location/path")
805 .and_then(|v| v.as_str())
806 .unwrap_or("");
807 let group = grouping::resolve_owner(Path::new(path), Path::new(""), resolver);
808 issue
809 .as_object_mut()
810 .expect("CodeClimate issue should be an object")
811 .insert("group".to_string(), serde_json::Value::String(group));
812 }
813 }
814
815 emit_json(&value, "CodeClimate")
816}
817
818#[must_use]
820#[expect(
821 clippy::cast_possible_truncation,
822 reason = "line numbers are bounded by source size"
823)]
824pub fn build_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> serde_json::Value {
825 let mut issues = Vec::new();
826
827 for (i, group) in report.clone_groups.iter().enumerate() {
828 let token_str = group.token_count.to_string();
831 let line_count_str = group.line_count.to_string();
832 let fragment_prefix: String = group
833 .instances
834 .first()
835 .map(|inst| inst.fragment.chars().take(64).collect())
836 .unwrap_or_default();
837
838 for instance in &group.instances {
839 let path = cc_path(&instance.file, root);
840 let start_str = instance.start_line.to_string();
841 let fp = fingerprint_hash(&[
842 "fallow/code-duplication",
843 &path,
844 &start_str,
845 &token_str,
846 &line_count_str,
847 &fragment_prefix,
848 ]);
849 issues.push(cc_issue(
850 "fallow/code-duplication",
851 &format!(
852 "Code clone group {} ({} lines, {} instances)",
853 i + 1,
854 group.line_count,
855 group.instances.len()
856 ),
857 "minor",
858 "Duplication",
859 &path,
860 Some(instance.start_line as u32),
861 &fp,
862 ));
863 }
864 }
865
866 serde_json::Value::Array(issues)
867}
868
869pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
871 let value = build_duplication_codeclimate(report, root);
872 emit_json(&value, "CodeClimate")
873}
874
875pub(super) fn print_grouped_duplication_codeclimate(
884 report: &DuplicationReport,
885 root: &Path,
886 resolver: &OwnershipResolver,
887) -> ExitCode {
888 let mut value = build_duplication_codeclimate(report, root);
889
890 use rustc_hash::FxHashMap;
893 let mut path_to_owner: FxHashMap<String, String> = FxHashMap::default();
894 for group in &report.clone_groups {
895 let owner = super::dupes_grouping::largest_owner(group, root, resolver);
896 for instance in &group.instances {
897 let path = cc_path(&instance.file, root);
898 path_to_owner.insert(path, owner.clone());
899 }
900 }
901
902 if let Some(issues) = value.as_array_mut() {
903 for issue in issues {
904 let path = issue
905 .pointer("/location/path")
906 .and_then(|v| v.as_str())
907 .unwrap_or("")
908 .to_string();
909 let group = path_to_owner
910 .get(&path)
911 .cloned()
912 .unwrap_or_else(|| crate::codeowners::UNOWNED_LABEL.to_string());
913 issue
914 .as_object_mut()
915 .expect("CodeClimate issue should be an object")
916 .insert("group".to_string(), serde_json::Value::String(group));
917 }
918 }
919
920 emit_json(&value, "CodeClimate")
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926 use crate::report::test_helpers::sample_results;
927 use fallow_config::RulesConfig;
928 use fallow_core::results::*;
929 use std::path::PathBuf;
930
931 fn health_severity(value: u16, threshold: u16) -> &'static str {
934 if threshold == 0 {
935 return "minor";
936 }
937 let ratio = f64::from(value) / f64::from(threshold);
938 if ratio > 2.5 {
939 "critical"
940 } else if ratio > 1.5 {
941 "major"
942 } else {
943 "minor"
944 }
945 }
946
947 #[test]
948 fn codeclimate_empty_results_produces_empty_array() {
949 let root = PathBuf::from("/project");
950 let results = AnalysisResults::default();
951 let rules = RulesConfig::default();
952 let output = build_codeclimate(&results, &root, &rules);
953 let arr = output.as_array().unwrap();
954 assert!(arr.is_empty());
955 }
956
957 #[test]
958 fn codeclimate_produces_array_of_issues() {
959 let root = PathBuf::from("/project");
960 let results = sample_results(&root);
961 let rules = RulesConfig::default();
962 let output = build_codeclimate(&results, &root, &rules);
963 assert!(output.is_array());
964 let arr = output.as_array().unwrap();
965 assert!(!arr.is_empty());
967 }
968
969 #[test]
970 fn codeclimate_issue_has_required_fields() {
971 let root = PathBuf::from("/project");
972 let mut results = AnalysisResults::default();
973 results.unused_files.push(UnusedFile {
974 path: root.join("src/dead.ts"),
975 });
976 let rules = RulesConfig::default();
977 let output = build_codeclimate(&results, &root, &rules);
978 let issue = &output.as_array().unwrap()[0];
979
980 assert_eq!(issue["type"], "issue");
981 assert_eq!(issue["check_name"], "fallow/unused-file");
982 assert!(issue["description"].is_string());
983 assert!(issue["categories"].is_array());
984 assert!(issue["severity"].is_string());
985 assert!(issue["fingerprint"].is_string());
986 assert!(issue["location"].is_object());
987 assert!(issue["location"]["path"].is_string());
988 assert!(issue["location"]["lines"].is_object());
989 }
990
991 #[test]
992 fn codeclimate_unused_file_severity_follows_rules() {
993 let root = PathBuf::from("/project");
994 let mut results = AnalysisResults::default();
995 results.unused_files.push(UnusedFile {
996 path: root.join("src/dead.ts"),
997 });
998
999 let rules = RulesConfig::default();
1001 let output = build_codeclimate(&results, &root, &rules);
1002 assert_eq!(output[0]["severity"], "major");
1003
1004 let rules = RulesConfig {
1006 unused_files: Severity::Warn,
1007 ..RulesConfig::default()
1008 };
1009 let output = build_codeclimate(&results, &root, &rules);
1010 assert_eq!(output[0]["severity"], "minor");
1011 }
1012
1013 #[test]
1014 fn codeclimate_unused_export_has_line_number() {
1015 let root = PathBuf::from("/project");
1016 let mut results = AnalysisResults::default();
1017 results.unused_exports.push(UnusedExport {
1018 path: root.join("src/utils.ts"),
1019 export_name: "helperFn".to_string(),
1020 is_type_only: false,
1021 line: 10,
1022 col: 4,
1023 span_start: 120,
1024 is_re_export: false,
1025 });
1026 let rules = RulesConfig::default();
1027 let output = build_codeclimate(&results, &root, &rules);
1028 let issue = &output[0];
1029 assert_eq!(issue["location"]["lines"]["begin"], 10);
1030 }
1031
1032 #[test]
1033 fn codeclimate_unused_file_line_defaults_to_1() {
1034 let root = PathBuf::from("/project");
1035 let mut results = AnalysisResults::default();
1036 results.unused_files.push(UnusedFile {
1037 path: root.join("src/dead.ts"),
1038 });
1039 let rules = RulesConfig::default();
1040 let output = build_codeclimate(&results, &root, &rules);
1041 let issue = &output[0];
1042 assert_eq!(issue["location"]["lines"]["begin"], 1);
1043 }
1044
1045 #[test]
1046 fn codeclimate_paths_are_relative() {
1047 let root = PathBuf::from("/project");
1048 let mut results = AnalysisResults::default();
1049 results.unused_files.push(UnusedFile {
1050 path: root.join("src/deep/nested/file.ts"),
1051 });
1052 let rules = RulesConfig::default();
1053 let output = build_codeclimate(&results, &root, &rules);
1054 let path = output[0]["location"]["path"].as_str().unwrap();
1055 assert_eq!(path, "src/deep/nested/file.ts");
1056 assert!(!path.starts_with("/project"));
1057 }
1058
1059 #[test]
1060 fn codeclimate_re_export_label_in_description() {
1061 let root = PathBuf::from("/project");
1062 let mut results = AnalysisResults::default();
1063 results.unused_exports.push(UnusedExport {
1064 path: root.join("src/index.ts"),
1065 export_name: "reExported".to_string(),
1066 is_type_only: false,
1067 line: 1,
1068 col: 0,
1069 span_start: 0,
1070 is_re_export: true,
1071 });
1072 let rules = RulesConfig::default();
1073 let output = build_codeclimate(&results, &root, &rules);
1074 let desc = output[0]["description"].as_str().unwrap();
1075 assert!(desc.contains("Re-export"));
1076 }
1077
1078 #[test]
1079 fn codeclimate_unlisted_dep_one_issue_per_import_site() {
1080 let root = PathBuf::from("/project");
1081 let mut results = AnalysisResults::default();
1082 results.unlisted_dependencies.push(UnlistedDependency {
1083 package_name: "chalk".to_string(),
1084 imported_from: vec![
1085 ImportSite {
1086 path: root.join("src/a.ts"),
1087 line: 1,
1088 col: 0,
1089 },
1090 ImportSite {
1091 path: root.join("src/b.ts"),
1092 line: 5,
1093 col: 0,
1094 },
1095 ],
1096 });
1097 let rules = RulesConfig::default();
1098 let output = build_codeclimate(&results, &root, &rules);
1099 let arr = output.as_array().unwrap();
1100 assert_eq!(arr.len(), 2);
1101 assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
1102 assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
1103 }
1104
1105 #[test]
1106 fn codeclimate_duplicate_export_one_issue_per_location() {
1107 let root = PathBuf::from("/project");
1108 let mut results = AnalysisResults::default();
1109 results.duplicate_exports.push(DuplicateExport {
1110 export_name: "Config".to_string(),
1111 locations: vec![
1112 DuplicateLocation {
1113 path: root.join("src/a.ts"),
1114 line: 10,
1115 col: 0,
1116 },
1117 DuplicateLocation {
1118 path: root.join("src/b.ts"),
1119 line: 20,
1120 col: 0,
1121 },
1122 DuplicateLocation {
1123 path: root.join("src/c.ts"),
1124 line: 30,
1125 col: 0,
1126 },
1127 ],
1128 });
1129 let rules = RulesConfig::default();
1130 let output = build_codeclimate(&results, &root, &rules);
1131 let arr = output.as_array().unwrap();
1132 assert_eq!(arr.len(), 3);
1133 }
1134
1135 #[test]
1136 fn codeclimate_circular_dep_emits_chain_in_description() {
1137 let root = PathBuf::from("/project");
1138 let mut results = AnalysisResults::default();
1139 results.circular_dependencies.push(CircularDependency {
1140 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1141 length: 2,
1142 line: 3,
1143 col: 0,
1144 is_cross_package: false,
1145 });
1146 let rules = RulesConfig::default();
1147 let output = build_codeclimate(&results, &root, &rules);
1148 let desc = output[0]["description"].as_str().unwrap();
1149 assert!(desc.contains("Circular dependency"));
1150 assert!(desc.contains("src/a.ts"));
1151 assert!(desc.contains("src/b.ts"));
1152 }
1153
1154 #[test]
1155 fn codeclimate_fingerprints_are_deterministic() {
1156 let root = PathBuf::from("/project");
1157 let results = sample_results(&root);
1158 let rules = RulesConfig::default();
1159 let output1 = build_codeclimate(&results, &root, &rules);
1160 let output2 = build_codeclimate(&results, &root, &rules);
1161
1162 let fps1: Vec<&str> = output1
1163 .as_array()
1164 .unwrap()
1165 .iter()
1166 .map(|i| i["fingerprint"].as_str().unwrap())
1167 .collect();
1168 let fps2: Vec<&str> = output2
1169 .as_array()
1170 .unwrap()
1171 .iter()
1172 .map(|i| i["fingerprint"].as_str().unwrap())
1173 .collect();
1174 assert_eq!(fps1, fps2);
1175 }
1176
1177 #[test]
1178 fn codeclimate_fingerprints_are_unique() {
1179 let root = PathBuf::from("/project");
1180 let results = sample_results(&root);
1181 let rules = RulesConfig::default();
1182 let output = build_codeclimate(&results, &root, &rules);
1183
1184 let mut fps: Vec<&str> = output
1185 .as_array()
1186 .unwrap()
1187 .iter()
1188 .map(|i| i["fingerprint"].as_str().unwrap())
1189 .collect();
1190 let original_len = fps.len();
1191 fps.sort_unstable();
1192 fps.dedup();
1193 assert_eq!(fps.len(), original_len, "fingerprints should be unique");
1194 }
1195
1196 #[test]
1197 fn codeclimate_type_only_dep_has_correct_check_name() {
1198 let root = PathBuf::from("/project");
1199 let mut results = AnalysisResults::default();
1200 results.type_only_dependencies.push(TypeOnlyDependency {
1201 package_name: "zod".to_string(),
1202 path: root.join("package.json"),
1203 line: 8,
1204 });
1205 let rules = RulesConfig::default();
1206 let output = build_codeclimate(&results, &root, &rules);
1207 assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
1208 let desc = output[0]["description"].as_str().unwrap();
1209 assert!(desc.contains("zod"));
1210 assert!(desc.contains("type-only"));
1211 }
1212
1213 #[test]
1214 fn codeclimate_dep_with_zero_line_omits_line_number() {
1215 let root = PathBuf::from("/project");
1216 let mut results = AnalysisResults::default();
1217 results.unused_dependencies.push(UnusedDependency {
1218 package_name: "lodash".to_string(),
1219 location: DependencyLocation::Dependencies,
1220 path: root.join("package.json"),
1221 line: 0,
1222 });
1223 let rules = RulesConfig::default();
1224 let output = build_codeclimate(&results, &root, &rules);
1225 assert_eq!(output[0]["location"]["lines"]["begin"], 1);
1227 }
1228
1229 #[test]
1232 fn fingerprint_hash_different_inputs_differ() {
1233 let h1 = fingerprint_hash(&["a", "b"]);
1234 let h2 = fingerprint_hash(&["a", "c"]);
1235 assert_ne!(h1, h2);
1236 }
1237
1238 #[test]
1239 fn fingerprint_hash_order_matters() {
1240 let h1 = fingerprint_hash(&["a", "b"]);
1241 let h2 = fingerprint_hash(&["b", "a"]);
1242 assert_ne!(h1, h2);
1243 }
1244
1245 #[test]
1246 fn fingerprint_hash_separator_prevents_collision() {
1247 let h1 = fingerprint_hash(&["ab", "c"]);
1249 let h2 = fingerprint_hash(&["a", "bc"]);
1250 assert_ne!(h1, h2);
1251 }
1252
1253 #[test]
1254 fn fingerprint_hash_is_16_hex_chars() {
1255 let h = fingerprint_hash(&["test"]);
1256 assert_eq!(h.len(), 16);
1257 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
1258 }
1259
1260 #[test]
1263 fn severity_error_maps_to_major() {
1264 assert_eq!(severity_to_codeclimate(Severity::Error), "major");
1265 }
1266
1267 #[test]
1268 fn severity_warn_maps_to_minor() {
1269 assert_eq!(severity_to_codeclimate(Severity::Warn), "minor");
1270 }
1271
1272 #[test]
1273 fn severity_off_maps_to_minor() {
1274 assert_eq!(severity_to_codeclimate(Severity::Off), "minor");
1275 }
1276
1277 #[test]
1280 fn health_severity_zero_threshold_returns_minor() {
1281 assert_eq!(health_severity(100, 0), "minor");
1282 }
1283
1284 #[test]
1285 fn health_severity_at_threshold_returns_minor() {
1286 assert_eq!(health_severity(10, 10), "minor");
1287 }
1288
1289 #[test]
1290 fn health_severity_1_5x_threshold_returns_minor() {
1291 assert_eq!(health_severity(15, 10), "minor");
1292 }
1293
1294 #[test]
1295 fn health_severity_above_1_5x_returns_major() {
1296 assert_eq!(health_severity(16, 10), "major");
1297 }
1298
1299 #[test]
1300 fn health_severity_at_2_5x_returns_major() {
1301 assert_eq!(health_severity(25, 10), "major");
1302 }
1303
1304 #[test]
1305 fn health_severity_above_2_5x_returns_critical() {
1306 assert_eq!(health_severity(26, 10), "critical");
1307 }
1308
1309 #[test]
1310 fn health_codeclimate_includes_coverage_gaps() {
1311 use crate::health_types::*;
1312
1313 let root = PathBuf::from("/project");
1314 let report = HealthReport {
1315 summary: HealthSummary {
1316 files_analyzed: 10,
1317 functions_analyzed: 50,
1318 ..Default::default()
1319 },
1320 coverage_gaps: Some(CoverageGaps {
1321 summary: CoverageGapSummary {
1322 runtime_files: 2,
1323 covered_files: 0,
1324 file_coverage_pct: 0.0,
1325 untested_files: 1,
1326 untested_exports: 1,
1327 },
1328 files: vec![UntestedFile {
1329 path: root.join("src/app.ts"),
1330 value_export_count: 2,
1331 }],
1332 exports: vec![UntestedExport {
1333 path: root.join("src/app.ts"),
1334 export_name: "loader".into(),
1335 line: 12,
1336 col: 4,
1337 }],
1338 }),
1339 ..Default::default()
1340 };
1341
1342 let output = build_health_codeclimate(&report, &root);
1343 let issues = output.as_array().unwrap();
1344 assert_eq!(issues.len(), 2);
1345 assert_eq!(issues[0]["check_name"], "fallow/untested-file");
1346 assert_eq!(issues[0]["categories"][0], "Coverage");
1347 assert_eq!(issues[0]["location"]["path"], "src/app.ts");
1348 assert_eq!(issues[1]["check_name"], "fallow/untested-export");
1349 assert_eq!(issues[1]["location"]["lines"]["begin"], 12);
1350 assert!(
1351 issues[1]["description"]
1352 .as_str()
1353 .unwrap()
1354 .contains("loader")
1355 );
1356 }
1357
1358 #[test]
1359 fn health_codeclimate_crap_only_uses_crap_check_name() {
1360 use crate::health_types::{FindingSeverity, HealthFinding, HealthReport, HealthSummary};
1361 let root = PathBuf::from("/project");
1362 let report = HealthReport {
1363 findings: vec![HealthFinding {
1364 path: root.join("src/untested.ts"),
1365 name: "risky".to_string(),
1366 line: 7,
1367 col: 0,
1368 cyclomatic: 10,
1369 cognitive: 10,
1370 line_count: 20,
1371 param_count: 1,
1372 exceeded: crate::health_types::ExceededThreshold::Crap,
1373 severity: FindingSeverity::High,
1374 crap: Some(60.0),
1375 coverage_pct: Some(25.0),
1376 coverage_tier: None,
1377 }],
1378 summary: HealthSummary {
1379 functions_analyzed: 10,
1380 functions_above_threshold: 1,
1381 ..Default::default()
1382 },
1383 ..Default::default()
1384 };
1385 let json = build_health_codeclimate(&report, &root);
1386 let issues = json.as_array().unwrap();
1387 assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
1388 assert_eq!(issues[0]["severity"], "major");
1389 let description = issues[0]["description"].as_str().unwrap();
1390 assert!(description.contains("CRAP score"), "desc: {description}");
1391 assert!(description.contains("coverage 25%"), "desc: {description}");
1392 }
1393}