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