1use std::path::Path;
4
5use fallow_config::{RulesConfig, Severity};
6use fallow_output::{
7 CodeClimateIssue, CodeClimateIssueInput, CodeClimateSeverity, build_codeclimate_issue,
8 codeclimate_fingerprint_hash, normalize_uri,
9};
10use fallow_types::identity::dead_code_finding_id;
11use fallow_types::output_dead_code::{
12 EffectiveSeverity, GatedFinding, ReachabilityCaveat, caveat_suffix,
13};
14use fallow_types::results::AnalysisResults;
15
16fn severity_to_codeclimate(s: Severity) -> CodeClimateSeverity {
17 match s {
18 Severity::Error => CodeClimateSeverity::Major,
19 Severity::Warn => CodeClimateSeverity::Minor,
20 Severity::Off => unreachable!(),
21 }
22}
23
24fn gate_codeclimate(effective: Option<EffectiveSeverity>, rule: Severity) -> CodeClimateSeverity {
31 match effective {
32 Some(EffectiveSeverity::Error) => CodeClimateSeverity::Major,
33 Some(EffectiveSeverity::Warn) => CodeClimateSeverity::Minor,
34 None => match rule {
35 Severity::Off => CodeClimateSeverity::Minor,
36 Severity::Error | Severity::Warn => severity_to_codeclimate(rule),
37 },
38 }
39}
40
41fn finding_codeclimate(finding: &impl GatedFinding, rule: Severity) -> CodeClimateSeverity {
42 gate_codeclimate(finding.effective_severity(), rule)
43}
44
45fn cc_path(path: &Path, root: &Path) -> String {
46 normalize_uri(
47 &path
48 .strip_prefix(root)
49 .unwrap_or(path)
50 .display()
51 .to_string(),
52 )
53}
54
55fn cc_caveat_suffix(caveats: &[ReachabilityCaveat]) -> String {
70 caveat_suffix(caveats).unwrap_or_default()
71}
72
73fn dead_code_issue(
92 finding_id: Option<&str>,
93 discriminator: &[&str],
94 input: CodeClimateIssueInput<'_>,
95) -> CodeClimateIssue {
96 let mut issue = build_codeclimate_issue(input);
97 if let Some(id) = finding_id {
98 let mut parts = Vec::with_capacity(discriminator.len() + 1);
99 parts.push(id);
100 parts.extend_from_slice(discriminator);
101 let stable = codeclimate_fingerprint_hash(&parts);
102 if stable != issue.fingerprint {
103 issue.legacy_fingerprint = Some(std::mem::replace(&mut issue.fingerprint, stable));
104 }
105 }
106 issue
107}
108
109fn location_discriminators(locations: &[(String, u32, u32)]) -> Vec<Vec<String>> {
120 locations
121 .iter()
122 .enumerate()
123 .map(|(index, (path, line, col))| {
124 let earlier = locations
125 .iter()
126 .enumerate()
127 .filter(|(other, (other_path, other_line, other_col))| {
128 other_path == path && (other_line, other_col, *other) < (line, col, index)
129 })
130 .count();
131 if earlier == 0 {
132 vec![path.clone()]
133 } else {
134 vec![path.clone(), format!("~{earlier}")]
135 }
136 })
137 .collect()
138}
139
140fn push_dep_cc_issues<'a, I>(
142 issues: &mut Vec<CodeClimateIssue>,
143 deps: I,
144 root: &Path,
145 rule_id: &str,
146 location_label: &str,
147 severity: Severity,
148) where
149 I: IntoIterator<
150 Item = (
151 &'a fallow_types::results::UnusedDependency,
152 &'a [ReachabilityCaveat],
153 Option<EffectiveSeverity>,
154 Option<&'a str>,
155 ),
156 >,
157{
158 for (dep, caveats, effective, finding_id) in deps {
159 let level = gate_codeclimate(effective, severity);
160 let path = cc_path(&dep.path, root);
161 let line = if dep.line > 0 { Some(dep.line) } else { None };
162 let fp = codeclimate_fingerprint_hash(&[rule_id, &dep.package_name]);
163 let workspace_context = if dep.used_in_workspaces.is_empty() {
164 String::new()
165 } else {
166 let workspaces = dep
167 .used_in_workspaces
168 .iter()
169 .map(|path| cc_path(path, root))
170 .collect::<Vec<_>>()
171 .join(", ");
172 format!("; imported in other workspaces: {workspaces}")
173 };
174 issues.push(dead_code_issue(
175 finding_id,
176 &[],
177 CodeClimateIssueInput {
178 check_name: rule_id,
179 description: &format!(
180 "Package '{}' is in {location_label} but never imported{workspace_context}{}",
181 dep.package_name,
182 cc_caveat_suffix(caveats)
183 ),
184 severity: level,
185 category: "Bug Risk",
186 path: &path,
187 begin_line: line,
188 fingerprint: &fp,
189 },
190 ));
191 }
192}
193
194fn push_unused_file_issues(
195 issues: &mut Vec<CodeClimateIssue>,
196 files: &[fallow_types::output_dead_code::UnusedFileFinding],
197 root: &Path,
198 severity: Severity,
199) {
200 if files.is_empty() {
201 return;
202 }
203 for entry in files {
204 let level = finding_codeclimate(entry, severity);
205 let path = cc_path(&entry.file.path, root);
206 let fp = codeclimate_fingerprint_hash(&["fallow/unused-file", &path]);
207 issues.push(dead_code_issue(
208 entry.finding_id.as_deref(),
209 &[],
210 CodeClimateIssueInput {
211 check_name: "fallow/unused-file",
212 description: &format!(
213 "File is not reachable from any entry point{}",
214 cc_caveat_suffix(&entry.reachability_caveats)
215 ),
216 severity: level,
217 category: "Bug Risk",
218 path: &path,
219 begin_line: None,
220 fingerprint: &fp,
221 },
222 ));
223 }
224}
225
226struct UnusedExportIssuesInput<'a, I> {
232 issues: &'a mut Vec<CodeClimateIssue>,
233 exports: I,
234 root: &'a Path,
235 rule_id: &'a str,
236 direct_label: &'a str,
237 re_export_label: &'a str,
238 severity: Severity,
239}
240
241fn push_unused_export_issues<'a, I>(input: UnusedExportIssuesInput<'a, I>)
242where
243 I: IntoIterator<
244 Item = (
245 &'a fallow_types::results::UnusedExport,
246 &'a [ReachabilityCaveat],
247 Option<EffectiveSeverity>,
248 Option<&'a str>,
249 ),
250 >,
251{
252 for (export, caveats, effective, finding_id) in input.exports {
253 let level = gate_codeclimate(effective, input.severity);
254 let path = cc_path(&export.path, input.root);
255 let kind = if export.is_re_export {
256 input.re_export_label
257 } else {
258 input.direct_label
259 };
260 let line_str = export.line.to_string();
261 let fp =
262 codeclimate_fingerprint_hash(&[input.rule_id, &path, &line_str, &export.export_name]);
263 input.issues.push(dead_code_issue(
264 finding_id,
265 &[],
266 CodeClimateIssueInput {
267 check_name: input.rule_id,
268 description: &format!(
269 "{kind} '{}' is never imported by other modules{}",
270 export.export_name,
271 cc_caveat_suffix(caveats)
272 ),
273 severity: level,
274 category: "Bug Risk",
275 path: &path,
276 begin_line: Some(export.line),
277 fingerprint: &fp,
278 },
279 ));
280 }
281}
282
283fn push_private_type_leak_issues(
284 issues: &mut Vec<CodeClimateIssue>,
285 leaks: &[fallow_types::output_dead_code::PrivateTypeLeakFinding],
286 root: &Path,
287 severity: Severity,
288) {
289 if leaks.is_empty() {
290 return;
291 }
292 for entry in leaks {
293 let level = finding_codeclimate(entry, severity);
294 let leak = &entry.leak;
295 let path = cc_path(&leak.path, root);
296 let line_str = leak.line.to_string();
297 let fp = codeclimate_fingerprint_hash(&[
298 "fallow/private-type-leak",
299 &path,
300 &line_str,
301 &leak.export_name,
302 &leak.type_name,
303 ]);
304 issues.push(dead_code_issue(
305 entry.finding_id.as_deref(),
306 &[],
307 CodeClimateIssueInput {
308 check_name: "fallow/private-type-leak",
309 description: &format!(
310 "Export '{}' references private type '{}'",
311 leak.export_name, leak.type_name
312 ),
313 severity: level,
314 category: "Bug Risk",
315 path: &path,
316 begin_line: Some(leak.line),
317 fingerprint: &fp,
318 },
319 ));
320 }
321}
322
323fn push_deprecated_export_issues(
324 issues: &mut Vec<CodeClimateIssue>,
325 findings: &[fallow_types::output_dead_code::DeprecatedExportInUseFinding],
326 root: &Path,
327 severity: Severity,
328) {
329 for entry in findings {
330 let level = finding_codeclimate(entry, severity);
331 let export = &entry.export;
332 let path = cc_path(&export.path, root);
333 let line_str = export.line.to_string();
334 let fp = codeclimate_fingerprint_hash(&[
335 "fallow/deprecated-export-in-use",
336 &path,
337 &line_str,
338 &export.export_name,
339 ]);
340 issues.push(dead_code_issue(
341 entry.finding_id.as_deref(),
342 &[],
343 CodeClimateIssueInput {
344 check_name: "fallow/deprecated-export-in-use",
345 description: &export.description(),
346 severity: level,
347 category: "Compatibility",
348 path: &path,
349 begin_line: Some(export.line),
350 fingerprint: &fp,
351 },
352 ));
353 }
354}
355
356fn push_type_only_dep_issues(
357 issues: &mut Vec<CodeClimateIssue>,
358 deps: &[fallow_types::output_dead_code::TypeOnlyDependencyFinding],
359 root: &Path,
360 severity: Severity,
361) {
362 if deps.is_empty() {
363 return;
364 }
365 for entry in deps {
366 let level = finding_codeclimate(entry, severity);
367 let dep = &entry.dep;
368 let path = cc_path(&dep.path, root);
369 let line = if dep.line > 0 { Some(dep.line) } else { None };
370 let fp = codeclimate_fingerprint_hash(&["fallow/type-only-dependency", &dep.package_name]);
371 issues.push(dead_code_issue(
372 entry.finding_id.as_deref(),
373 &[],
374 CodeClimateIssueInput {
375 check_name: "fallow/type-only-dependency",
376 description: &format!(
377 "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
378 dep.package_name
379 ),
380 severity: level,
381 category: "Bug Risk",
382 path: &path,
383 begin_line: line,
384 fingerprint: &fp,
385 },
386 ));
387 }
388}
389
390fn push_test_only_dep_issues(
391 issues: &mut Vec<CodeClimateIssue>,
392 deps: &[fallow_types::output_dead_code::TestOnlyDependencyFinding],
393 root: &Path,
394 severity: Severity,
395) {
396 if deps.is_empty() {
397 return;
398 }
399 for entry in deps {
400 let level = finding_codeclimate(entry, severity);
401 let dep = &entry.dep;
402 let path = cc_path(&dep.path, root);
403 let line = if dep.line > 0 { Some(dep.line) } else { None };
404 let fp = codeclimate_fingerprint_hash(&["fallow/test-only-dependency", &dep.package_name]);
405 issues.push(dead_code_issue(
406 entry.finding_id.as_deref(),
407 &[],
408 CodeClimateIssueInput {
409 check_name: "fallow/test-only-dependency",
410 description: &format!(
411 "Package '{}' is only imported by test files (consider moving to devDependencies)",
412 dep.package_name
413 ),
414 severity: level,
415 category: "Bug Risk",
416 path: &path,
417 begin_line: line,
418 fingerprint: &fp,
419 },
420 ));
421 }
422}
423
424fn push_dev_dep_in_prod_issues(
425 issues: &mut Vec<CodeClimateIssue>,
426 deps: &[fallow_types::output_dead_code::DevDependencyInProductionFinding],
427 root: &Path,
428 severity: Severity,
429) {
430 if deps.is_empty() {
431 return;
432 }
433 for entry in deps {
434 let level = finding_codeclimate(entry, severity);
435 let dep = &entry.dep;
436 let path = cc_path(&dep.path, root);
437 let line = if dep.line > 0 { Some(dep.line) } else { None };
438 let fp = codeclimate_fingerprint_hash(&[
439 "fallow/dev-dependency-in-production",
440 &dep.package_name,
441 ]);
442 issues.push(dead_code_issue(
443 entry.finding_id.as_deref(),
444 &[],
445 CodeClimateIssueInput {
446 check_name: "fallow/dev-dependency-in-production",
447 description: &format!(
448 "devDependency '{}' is imported by production code at runtime (consider moving to dependencies)",
449 dep.package_name
450 ),
451 severity: level,
452 category: "Bug Risk",
453 path: &path,
454 begin_line: line,
455 fingerprint: &fp,
456 },
457 ));
458 }
459}
460
461fn push_unused_member_issues<'a, I>(
466 issues: &mut Vec<CodeClimateIssue>,
467 members: I,
468 root: &Path,
469 rule_id: &str,
470 entity_label: &str,
471 severity: Severity,
472) where
473 I: IntoIterator<
474 Item = (
475 &'a fallow_types::results::UnusedMember,
476 &'a [ReachabilityCaveat],
477 Option<EffectiveSeverity>,
478 Option<&'a str>,
479 ),
480 >,
481{
482 for (member, caveats, effective, finding_id) in members {
483 let level = gate_codeclimate(effective, severity);
484 let path = cc_path(&member.path, root);
485 let line_str = member.line.to_string();
486 let fp = codeclimate_fingerprint_hash(&[
487 rule_id,
488 &path,
489 &line_str,
490 &member.parent_name,
491 &member.member_name,
492 ]);
493 issues.push(dead_code_issue(
494 finding_id,
495 &[],
496 CodeClimateIssueInput {
497 check_name: rule_id,
498 description: &format!(
499 "{entity_label} member '{}.{}' is never referenced{}",
500 member.parent_name,
501 member.member_name,
502 cc_caveat_suffix(caveats)
503 ),
504 severity: level,
505 category: "Bug Risk",
506 path: &path,
507 begin_line: Some(member.line),
508 fingerprint: &fp,
509 },
510 ));
511 }
512}
513
514fn push_unresolved_import_issues(
515 issues: &mut Vec<CodeClimateIssue>,
516 imports: &[fallow_types::output_dead_code::UnresolvedImportFinding],
517 root: &Path,
518 severity: Severity,
519) {
520 if imports.is_empty() {
521 return;
522 }
523 for entry in imports {
524 let level = finding_codeclimate(entry, severity);
525 let import = &entry.import;
526 let path = cc_path(&import.path, root);
527 let line_str = import.line.to_string();
528 let fp = codeclimate_fingerprint_hash(&[
529 "fallow/unresolved-import",
530 &path,
531 &line_str,
532 &import.specifier,
533 ]);
534 issues.push(dead_code_issue(
535 entry.finding_id.as_deref(),
536 &[],
537 CodeClimateIssueInput {
538 check_name: "fallow/unresolved-import",
539 description: &format!("Import '{}' could not be resolved", import.specifier),
540 severity: level,
541 category: "Bug Risk",
542 path: &path,
543 begin_line: Some(import.line),
544 fingerprint: &fp,
545 },
546 ));
547 }
548}
549
550fn push_unlisted_dep_issues(
551 issues: &mut Vec<CodeClimateIssue>,
552 deps: &[fallow_types::output_dead_code::UnlistedDependencyFinding],
553 root: &Path,
554 severity: Severity,
555) {
556 if deps.is_empty() {
557 return;
558 }
559 for entry in deps {
560 let level = finding_codeclimate(entry, severity);
561 let dep = &entry.dep;
562 let sites: Vec<(String, u32, u32)> = dep
563 .imported_from
564 .iter()
565 .map(|site| (cc_path(&site.path, root), site.line, site.col))
566 .collect();
567 let discriminators = location_discriminators(&sites);
568 for ((path, line, _), discriminator) in sites.iter().zip(&discriminators) {
569 let line_str = line.to_string();
570 let discriminator: Vec<&str> = discriminator.iter().map(String::as_str).collect();
571 let fp = codeclimate_fingerprint_hash(&[
572 "fallow/unlisted-dependency",
573 path,
574 &line_str,
575 &dep.package_name,
576 ]);
577 issues.push(dead_code_issue(
578 entry.finding_id.as_deref(),
579 &discriminator,
580 CodeClimateIssueInput {
581 check_name: "fallow/unlisted-dependency",
582 description: &format!(
583 "Package '{}' is imported but not listed in package.json",
584 dep.package_name
585 ),
586 severity: level,
587 category: "Bug Risk",
588 path,
589 begin_line: Some(*line),
590 fingerprint: &fp,
591 },
592 ));
593 }
594 }
595}
596
597fn push_duplicate_export_issues(
598 issues: &mut Vec<CodeClimateIssue>,
599 dups: &[fallow_types::output_dead_code::DuplicateExportFinding],
600 root: &Path,
601 severity: Severity,
602) {
603 if dups.is_empty() {
604 return;
605 }
606 for dup in dups {
607 let level = finding_codeclimate(dup, severity);
608 let location_identity = dup
615 .finding_id
616 .is_some()
617 .then(|| dead_code_finding_id("duplicate-export", &[&dup.export.export_name]));
618 let finding_id = location_identity.as_deref();
619 let dup = &dup.export;
620 let locations: Vec<(String, u32, u32)> = dup
621 .locations
622 .iter()
623 .map(|loc| (cc_path(&loc.path, root), loc.line, loc.col))
624 .collect();
625 let discriminators = location_discriminators(&locations);
626 for ((path, line, _), discriminator) in locations.iter().zip(&discriminators) {
627 let line_str = line.to_string();
628 let discriminator: Vec<&str> = discriminator.iter().map(String::as_str).collect();
629 let fp = codeclimate_fingerprint_hash(&[
630 "fallow/duplicate-export",
631 path,
632 &line_str,
633 &dup.export_name,
634 ]);
635 issues.push(dead_code_issue(
636 finding_id,
637 &discriminator,
638 CodeClimateIssueInput {
639 check_name: "fallow/duplicate-export",
640 description: &format!(
641 "Export '{}' appears in multiple modules",
642 dup.export_name
643 ),
644 severity: level,
645 category: "Bug Risk",
646 path,
647 begin_line: Some(*line),
648 fingerprint: &fp,
649 },
650 ));
651 }
652 }
653}
654
655fn push_circular_dep_issues(
656 issues: &mut Vec<CodeClimateIssue>,
657 cycles: &[fallow_types::output_dead_code::CircularDependencyFinding],
658 root: &Path,
659 severity: Severity,
660) {
661 if cycles.is_empty() {
662 return;
663 }
664 for entry in cycles {
665 let level = finding_codeclimate(entry, severity);
666 let cycle = &entry.cycle;
667 let Some(first) = cycle.files.first() else {
668 continue;
669 };
670 let path = cc_path(first, root);
671 let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
672 let chain_str = chain.join(":");
673 let fp = codeclimate_fingerprint_hash(&["fallow/circular-dependency", &chain_str]);
674 let line = if cycle.line > 0 {
675 Some(cycle.line)
676 } else {
677 None
678 };
679 issues.push(dead_code_issue(
680 entry.finding_id.as_deref(),
681 &[],
682 CodeClimateIssueInput {
683 check_name: "fallow/circular-dependency",
684 description: &format!(
685 "Circular dependency{}: {}",
686 if cycle.is_cross_package {
687 " (cross-package)"
688 } else {
689 ""
690 },
691 chain.join(" \u{2192} ")
692 ),
693 severity: level,
694 category: "Bug Risk",
695 path: &path,
696 begin_line: line,
697 fingerprint: &fp,
698 },
699 ));
700 }
701}
702
703fn push_re_export_cycle_issues(
704 issues: &mut Vec<CodeClimateIssue>,
705 cycles: &[fallow_types::output_dead_code::ReExportCycleFinding],
706 root: &Path,
707 severity: Severity,
708) {
709 if cycles.is_empty() {
710 return;
711 }
712 for entry in cycles {
713 let level = finding_codeclimate(entry, severity);
714 let cycle = &entry.cycle;
715 let Some(first) = cycle.files.first() else {
716 continue;
717 };
718 let path = cc_path(first, root);
719 let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
720 let chain_str = chain.join(":");
721 let kind_token = match cycle.kind {
722 fallow_types::results::ReExportCycleKind::SelfLoop => "self-loop",
723 fallow_types::results::ReExportCycleKind::MultiNode => "multi-node",
724 };
725 let kind_tag = match cycle.kind {
726 fallow_types::results::ReExportCycleKind::SelfLoop => " (self-loop)",
727 fallow_types::results::ReExportCycleKind::MultiNode => "",
728 };
729 let fp = codeclimate_fingerprint_hash(&["fallow/re-export-cycle", kind_token, &chain_str]);
730 issues.push(dead_code_issue(
731 entry.finding_id.as_deref(),
732 &[],
733 CodeClimateIssueInput {
734 check_name: "fallow/re-export-cycle",
735 description: &format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
736 severity: level,
737 category: "Bug Risk",
738 path: &path,
739 begin_line: None,
740 fingerprint: &fp,
741 },
742 ));
743 }
744}
745
746fn push_package_cycle_issues(
747 issues: &mut Vec<CodeClimateIssue>,
748 cycles: &[fallow_types::output_dead_code::PackageCycleFinding],
749 root: &Path,
750 severity: Severity,
751) {
752 for entry in cycles {
753 let level = finding_codeclimate(entry, severity);
754 let cycle = &entry.cycle;
755 let Some(anchor) = cycle.edges.first() else {
756 continue;
757 };
758 let path = cc_path(&anchor.path, root);
759 let packages = cycle.packages.join(":");
760 let fp = codeclimate_fingerprint_hash(&["fallow/package-cycle", &packages]);
761 let note = if cycle.group_truncated {
762 format!(
763 " ({})",
764 fallow_types::results::PackageCycle::GROUP_TRUNCATED_NOTE
765 )
766 } else {
767 String::new()
768 };
769 issues.push(build_codeclimate_issue(CodeClimateIssueInput {
770 check_name: "fallow/package-cycle",
771 description: &format!("Package cycle: {}{note}", cycle.chain(" \u{2192} ")),
772 severity: level,
773 category: "Bug Risk",
774 path: &path,
775 begin_line: (anchor.line > 0).then_some(anchor.line),
776 fingerprint: &fp,
777 }));
778 }
779}
780
781fn push_boundary_violation_issues(
782 issues: &mut Vec<CodeClimateIssue>,
783 violations: &[fallow_types::output_dead_code::BoundaryViolationFinding],
784 root: &Path,
785 severity: Severity,
786) {
787 if violations.is_empty() {
788 return;
789 }
790 for entry in violations {
791 let level = finding_codeclimate(entry, severity);
792 let v = &entry.violation;
793 let path = cc_path(&v.from_path, root);
794 let to = cc_path(&v.to_path, root);
795 let fp = codeclimate_fingerprint_hash(&["fallow/boundary-violation", &path, &to]);
796 let line = if v.line > 0 { Some(v.line) } else { None };
797 let via = v
798 .via_path
799 .as_ref()
800 .map_or_else(String::new, |via| format!(", via {}", cc_path(via, root)));
801 issues.push(dead_code_issue(
802 entry.finding_id.as_deref(),
803 &[],
804 CodeClimateIssueInput {
805 check_name: "fallow/boundary-violation",
806 description: &format!(
807 "Boundary violation: {} -> {} ({} -> {}{})",
808 path, to, v.from_zone, v.to_zone, via
809 ),
810 severity: level,
811 category: "Bug Risk",
812 path: &path,
813 begin_line: line,
814 fingerprint: &fp,
815 },
816 ));
817 }
818}
819
820fn push_boundary_coverage_issues(
821 issues: &mut Vec<CodeClimateIssue>,
822 violations: &[fallow_types::output_dead_code::BoundaryCoverageViolationFinding],
823 root: &Path,
824 severity: Severity,
825) {
826 if violations.is_empty() {
827 return;
828 }
829 for entry in violations {
830 let level = finding_codeclimate(entry, severity);
831 let v = &entry.violation;
832 let path = cc_path(&v.path, root);
833 let fp = codeclimate_fingerprint_hash(&["fallow/boundary-coverage", &path]);
834 let line = if v.line > 0 { Some(v.line) } else { None };
835 issues.push(dead_code_issue(
836 entry.finding_id.as_deref(),
837 &[],
838 CodeClimateIssueInput {
839 check_name: "fallow/boundary-coverage",
840 description: &format!("Boundary coverage: {path} matches no configured zone"),
841 severity: level,
842 category: "Bug Risk",
843 path: &path,
844 begin_line: line,
845 fingerprint: &fp,
846 },
847 ));
848 }
849}
850
851fn push_boundary_call_issues(
852 issues: &mut Vec<CodeClimateIssue>,
853 violations: &[fallow_types::output_dead_code::BoundaryCallViolationFinding],
854 root: &Path,
855 severity: Severity,
856) {
857 if violations.is_empty() {
858 return;
859 }
860 for entry in violations {
861 let level = finding_codeclimate(entry, severity);
862 let v = &entry.violation;
863 let path = cc_path(&v.path, root);
864 let fp =
865 codeclimate_fingerprint_hash(&["fallow/boundary-call-violation", &path, &v.callee]);
866 let line = if v.line > 0 { Some(v.line) } else { None };
867 issues.push(dead_code_issue(
868 entry.finding_id.as_deref(),
869 &[],
870 CodeClimateIssueInput {
871 check_name: "fallow/boundary-call-violation",
872 description: &format!(
873 "Boundary call: `{}` matches forbidden pattern `{}` in zone '{}'",
874 v.callee, v.pattern, v.zone
875 ),
876 severity: level,
877 category: "Bug Risk",
878 path: &path,
879 begin_line: line,
880 fingerprint: &fp,
881 },
882 ));
883 }
884}
885
886fn push_policy_violation_issues(
887 issues: &mut Vec<CodeClimateIssue>,
888 violations: &[fallow_types::output_dead_code::PolicyViolationFinding],
889 root: &Path,
890) {
891 use fallow_types::results::PolicyViolationSeverity;
892
893 for entry in violations {
894 let v = &entry.violation;
895 let path = cc_path(&v.path, root);
896 let rule = format!("{}/{}", v.pack, v.rule_id);
897 let fp =
898 codeclimate_fingerprint_hash(&["fallow/policy-violation", &path, &rule, &v.matched]);
899 let line = if v.line > 0 { Some(v.line) } else { None };
900 let level = severity_to_codeclimate(match v.severity {
904 PolicyViolationSeverity::Error => Severity::Error,
905 PolicyViolationSeverity::Warn => Severity::Warn,
906 });
907 let message = match &v.message {
908 Some(message) => format!(
909 "Policy violation: `{}` is banned by `{rule}`. {message}",
910 v.matched
911 ),
912 None => format!("Policy violation: `{}` is banned by `{rule}`", v.matched),
913 };
914 issues.push(dead_code_issue(
915 entry.finding_id.as_deref(),
916 &[],
917 CodeClimateIssueInput {
918 check_name: "fallow/policy-violation",
919 description: &message,
920 severity: level,
921 category: "Bug Risk",
922 path: &path,
923 begin_line: line,
924 fingerprint: &fp,
925 },
926 ));
927 }
928}
929
930fn push_invalid_client_export_issues(
931 issues: &mut Vec<CodeClimateIssue>,
932 findings: &[fallow_types::output_dead_code::InvalidClientExportFinding],
933 root: &Path,
934 severity: Severity,
935) {
936 if findings.is_empty() {
937 return;
938 }
939 for entry in findings {
940 let level = finding_codeclimate(entry, severity);
941 let e = &entry.export;
942 let path = cc_path(&e.path, root);
943 let fp =
944 codeclimate_fingerprint_hash(&["fallow/invalid-client-export", &path, &e.export_name]);
945 let line = if e.line > 0 { Some(e.line) } else { None };
946 let message = format!(
947 "Export `{}` is not allowed in a \"{}\" file (Next.js server-only / route-config name)",
948 e.export_name, e.directive
949 );
950 issues.push(dead_code_issue(
951 entry.finding_id.as_deref(),
952 &[],
953 CodeClimateIssueInput {
954 check_name: "fallow/invalid-client-export",
955 description: &message,
956 severity: level,
957 category: "Bug Risk",
958 path: &path,
959 begin_line: line,
960 fingerprint: &fp,
961 },
962 ));
963 }
964}
965
966fn push_mixed_client_server_barrel_issues(
967 issues: &mut Vec<CodeClimateIssue>,
968 findings: &[fallow_types::output_dead_code::MixedClientServerBarrelFinding],
969 root: &Path,
970 severity: Severity,
971) {
972 if findings.is_empty() {
973 return;
974 }
975 for entry in findings {
976 let level = finding_codeclimate(entry, severity);
977 let b = &entry.barrel;
978 let path = cc_path(&b.path, root);
979 let fp = codeclimate_fingerprint_hash(&[
980 "fallow/mixed-client-server-barrel",
981 &path,
982 &b.client_origin,
983 &b.server_origin,
984 ]);
985 let line = if b.line > 0 { Some(b.line) } else { None };
986 let message = format!(
987 "Barrel re-exports both a \"use client\" module (`{}`) and a server-only module (`{}`); one import drags the other's directive across the boundary",
988 b.client_origin, b.server_origin
989 );
990 issues.push(dead_code_issue(
991 entry.finding_id.as_deref(),
992 &[],
993 CodeClimateIssueInput {
994 check_name: "fallow/mixed-client-server-barrel",
995 description: &message,
996 severity: level,
997 category: "Bug Risk",
998 path: &path,
999 begin_line: line,
1000 fingerprint: &fp,
1001 },
1002 ));
1003 }
1004}
1005
1006fn push_misplaced_directive_issues(
1007 issues: &mut Vec<CodeClimateIssue>,
1008 findings: &[fallow_types::output_dead_code::MisplacedDirectiveFinding],
1009 root: &Path,
1010 severity: Severity,
1011) {
1012 if findings.is_empty() {
1013 return;
1014 }
1015 for entry in findings {
1016 let level = finding_codeclimate(entry, severity);
1017 let d = &entry.directive_site;
1018 let path = cc_path(&d.path, root);
1019 let fp = codeclimate_fingerprint_hash(&[
1020 "fallow/misplaced-directive",
1021 &path,
1022 &d.line.to_string(),
1023 &d.directive,
1024 ]);
1025 let line = if d.line > 0 { Some(d.line) } else { None };
1026 let message = format!(
1027 "Directive `\"{}\"` is not in the leading position, so the RSC bundler ignores it; move it to the top of the file",
1028 d.directive
1029 );
1030 issues.push(dead_code_issue(
1031 entry.finding_id.as_deref(),
1032 &[],
1033 CodeClimateIssueInput {
1034 check_name: "fallow/misplaced-directive",
1035 description: &message,
1036 severity: level,
1037 category: "Bug Risk",
1038 path: &path,
1039 begin_line: line,
1040 fingerprint: &fp,
1041 },
1042 ));
1043 }
1044}
1045
1046fn push_unprovided_inject_issues(
1047 issues: &mut Vec<CodeClimateIssue>,
1048 findings: &[fallow_types::output_dead_code::UnprovidedInjectFinding],
1049 root: &Path,
1050 severity: Severity,
1051) {
1052 if findings.is_empty() {
1053 return;
1054 }
1055 for entry in findings {
1056 let level = finding_codeclimate(entry, severity);
1057 let i = &entry.inject;
1058 let path = cc_path(&i.path, root);
1059 let fp = codeclimate_fingerprint_hash(&[
1060 "fallow/unprovided-inject",
1061 &path,
1062 &i.line.to_string(),
1063 &i.key_name,
1064 ]);
1065 let line = if i.line > 0 { Some(i.line) } else { None };
1066 let message = format!(
1067 "inject(`{}`) has no matching provide(`{}`) in this project; at runtime it returns undefined (provide the key or remove this inject)",
1068 i.key_name, i.key_name
1069 );
1070 issues.push(dead_code_issue(
1071 entry.finding_id.as_deref(),
1072 &[],
1073 CodeClimateIssueInput {
1074 check_name: "fallow/unprovided-inject",
1075 description: &message,
1076 severity: level,
1077 category: "Bug Risk",
1078 path: &path,
1079 begin_line: line,
1080 fingerprint: &fp,
1081 },
1082 ));
1083 }
1084}
1085
1086fn push_unrendered_component_issues(
1087 issues: &mut Vec<CodeClimateIssue>,
1088 findings: &[fallow_types::output_dead_code::UnrenderedComponentFinding],
1089 root: &Path,
1090 severity: Severity,
1091) {
1092 if findings.is_empty() {
1093 return;
1094 }
1095 for entry in findings {
1096 let level = finding_codeclimate(entry, severity);
1097 let c = &entry.component;
1098 let path = cc_path(&c.path, root);
1099 let fp = codeclimate_fingerprint_hash(&[
1100 "fallow/unrendered-component",
1101 &path,
1102 &c.line.to_string(),
1103 &c.component_name,
1104 ]);
1105 let line = if c.line > 0 { Some(c.line) } else { None };
1106 let message = format!(
1107 "component `{}` is reachable but rendered nowhere in this project (render it somewhere or remove it)",
1108 c.component_name
1109 );
1110 issues.push(dead_code_issue(
1111 entry.finding_id.as_deref(),
1112 &[],
1113 CodeClimateIssueInput {
1114 check_name: "fallow/unrendered-component",
1115 description: &message,
1116 severity: level,
1117 category: "Bug Risk",
1118 path: &path,
1119 begin_line: line,
1120 fingerprint: &fp,
1121 },
1122 ));
1123 }
1124}
1125
1126fn push_absent_component_prop_issues(
1127 issues: &mut Vec<CodeClimateIssue>,
1128 findings: &[fallow_types::output_dead_code::AbsentComponentPropFinding],
1129 root: &Path,
1130 severity: Severity,
1131) {
1132 for finding in findings {
1133 let prop = &finding.prop;
1134 let path = cc_path(&prop.path, root);
1135 let fingerprint = codeclimate_fingerprint_hash(&[
1136 "fallow/absent-component-prop",
1137 &path,
1138 &prop.component_name,
1139 &prop.prop_name,
1140 ]);
1141 let description = format!(
1142 "Optional prop '{}.{}' is absent from inspected callers. {}",
1143 prop.component_name, prop.prop_name, prop.explanation
1144 );
1145 issues.push(dead_code_issue(
1146 finding.finding_id.as_deref(),
1147 &[],
1148 CodeClimateIssueInput {
1149 check_name: "fallow/absent-component-prop",
1150 description: &description,
1151 severity: finding_codeclimate(finding, severity),
1152 category: "Clarity",
1153 path: &path,
1154 begin_line: Some(prop.line),
1155 fingerprint: &fingerprint,
1156 },
1157 ));
1158 }
1159}
1160
1161fn push_unused_component_prop_issues(
1162 issues: &mut Vec<CodeClimateIssue>,
1163 findings: &[fallow_types::output_dead_code::UnusedComponentPropFinding],
1164 root: &Path,
1165 severity: Severity,
1166) {
1167 if findings.is_empty() {
1168 return;
1169 }
1170 for entry in findings {
1171 let level = finding_codeclimate(entry, severity);
1172 let p = &entry.prop;
1173 let path = cc_path(&p.path, root);
1174 let fp = codeclimate_fingerprint_hash(&[
1175 "fallow/unused-component-prop",
1176 &path,
1177 &p.line.to_string(),
1178 &p.prop_name,
1179 ]);
1180 let line = if p.line > 0 { Some(p.line) } else { None };
1181 let message = format!(
1182 "prop `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1183 p.prop_name, p.component_name
1184 );
1185 issues.push(dead_code_issue(
1186 entry.finding_id.as_deref(),
1187 &[],
1188 CodeClimateIssueInput {
1189 check_name: "fallow/unused-component-prop",
1190 description: &message,
1191 severity: level,
1192 category: "Bug Risk",
1193 path: &path,
1194 begin_line: line,
1195 fingerprint: &fp,
1196 },
1197 ));
1198 }
1199}
1200
1201fn push_unused_component_emit_issues(
1202 issues: &mut Vec<CodeClimateIssue>,
1203 findings: &[fallow_types::output_dead_code::UnusedComponentEmitFinding],
1204 root: &Path,
1205 severity: Severity,
1206) {
1207 if findings.is_empty() {
1208 return;
1209 }
1210 for entry in findings {
1211 let level = finding_codeclimate(entry, severity);
1212 let e = &entry.emit;
1213 let path = cc_path(&e.path, root);
1214 let fp = codeclimate_fingerprint_hash(&[
1215 "fallow/unused-component-emit",
1216 &path,
1217 &e.line.to_string(),
1218 &e.emit_name,
1219 ]);
1220 let line = if e.line > 0 { Some(e.line) } else { None };
1221 let message = format!(
1222 "emit `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1223 e.emit_name, e.component_name
1224 );
1225 issues.push(dead_code_issue(
1226 entry.finding_id.as_deref(),
1227 &[],
1228 CodeClimateIssueInput {
1229 check_name: "fallow/unused-component-emit",
1230 description: &message,
1231 severity: level,
1232 category: "Bug Risk",
1233 path: &path,
1234 begin_line: line,
1235 fingerprint: &fp,
1236 },
1237 ));
1238 }
1239}
1240
1241fn push_unused_effect_layer_issues(
1242 issues: &mut Vec<CodeClimateIssue>,
1243 findings: &[fallow_types::output_dead_code::UnusedEffectLayerFinding],
1244 root: &Path,
1245 severity: Severity,
1246) {
1247 for entry in findings {
1248 let level = finding_codeclimate(entry, severity);
1249 let f = &entry.layer;
1250 let path = cc_path(&f.path, root);
1251 let fp = codeclimate_fingerprint_hash(&[
1252 "fallow/unused-effect-layer",
1253 &path,
1254 &f.line.to_string(),
1255 &f.name,
1256 ]);
1257 let line = if f.line > 0 { Some(f.line) } else { None };
1258 let message = format!(
1259 "layer `{}` is composed only into layers that nothing uses ({}); compose it into a live layer or remove it",
1260 f.name,
1261 f.dead_consumers.join(", ")
1262 );
1263 issues.push(dead_code_issue(
1264 entry.finding_id.as_deref(),
1265 &[],
1266 CodeClimateIssueInput {
1267 check_name: "fallow/unused-effect-layer",
1268 description: &message,
1269 severity: level,
1270 category: "Bug Risk",
1271 path: &path,
1272 begin_line: line,
1273 fingerprint: &fp,
1274 },
1275 ));
1276 }
1277}
1278
1279fn push_unused_tagged_error_issues(
1280 issues: &mut Vec<CodeClimateIssue>,
1281 findings: &[fallow_types::output_dead_code::UnusedTaggedErrorFinding],
1282 root: &Path,
1283 severity: Severity,
1284) {
1285 for entry in findings {
1286 let level = finding_codeclimate(entry, severity);
1287 let f = &entry.error;
1288 let path = cc_path(&f.path, root);
1289 let fp = codeclimate_fingerprint_hash(&[
1290 "fallow/unused-tagged-error",
1291 &path,
1292 &f.line.to_string(),
1293 &f.name,
1294 ]);
1295 let line = if f.line > 0 { Some(f.line) } else { None };
1296 let message = format!(
1297 "tagged error `{}` (tag `{}`) is never constructed or failed with; fail with it or remove it",
1298 f.name, f.tag
1299 );
1300 issues.push(dead_code_issue(
1301 entry.finding_id.as_deref(),
1302 &[],
1303 CodeClimateIssueInput {
1304 check_name: "fallow/unused-tagged-error",
1305 description: &message,
1306 severity: level,
1307 category: "Bug Risk",
1308 path: &path,
1309 begin_line: line,
1310 fingerprint: &fp,
1311 },
1312 ));
1313 }
1314}
1315
1316fn push_unproduced_catch_tag_issues(
1317 issues: &mut Vec<CodeClimateIssue>,
1318 findings: &[fallow_types::output_dead_code::UnproducedCatchTagFinding],
1319 root: &Path,
1320 severity: Severity,
1321) {
1322 for entry in findings {
1323 let level = finding_codeclimate(entry, severity);
1324 let f = &entry.catch_tag;
1325 let path = cc_path(&f.path, root);
1326 let fp = codeclimate_fingerprint_hash(&[
1327 "fallow/unproduced-catch-tag",
1328 &path,
1329 &f.line.to_string(),
1330 &f.tag,
1331 ]);
1332 let line = if f.line > 0 { Some(f.line) } else { None };
1333 let message = format!(
1334 "catchTag handler for `{}` never runs: no code produces {}",
1335 f.tag,
1336 f.candidate_classes.join(", ")
1337 );
1338 issues.push(dead_code_issue(
1339 entry.finding_id.as_deref(),
1340 &[],
1341 CodeClimateIssueInput {
1342 check_name: "fallow/unproduced-catch-tag",
1343 description: &message,
1344 severity: level,
1345 category: "Bug Risk",
1346 path: &path,
1347 begin_line: line,
1348 fingerprint: &fp,
1349 },
1350 ));
1351 }
1352}
1353
1354fn push_unused_svelte_event_issues(
1355 issues: &mut Vec<CodeClimateIssue>,
1356 findings: &[fallow_types::output_dead_code::UnusedSvelteEventFinding],
1357 root: &Path,
1358 severity: Severity,
1359) {
1360 if findings.is_empty() {
1361 return;
1362 }
1363 for entry in findings {
1364 let level = finding_codeclimate(entry, severity);
1365 let e = &entry.event;
1366 let path = cc_path(&e.path, root);
1367 let fp = codeclimate_fingerprint_hash(&[
1368 "fallow/unused-svelte-event",
1369 &path,
1370 &e.line.to_string(),
1371 &e.event_name,
1372 ]);
1373 let line = if e.line > 0 { Some(e.line) } else { None };
1374 let message = format!(
1375 "event `{}` is dispatched by component `{}` but listened to nowhere in the project (remove it or listen for it)",
1376 e.event_name, e.component_name
1377 );
1378 issues.push(dead_code_issue(
1379 entry.finding_id.as_deref(),
1380 &[],
1381 CodeClimateIssueInput {
1382 check_name: "fallow/unused-svelte-event",
1383 description: &message,
1384 severity: level,
1385 category: "Bug Risk",
1386 path: &path,
1387 begin_line: line,
1388 fingerprint: &fp,
1389 },
1390 ));
1391 }
1392}
1393
1394fn push_unused_component_input_issues(
1395 issues: &mut Vec<CodeClimateIssue>,
1396 findings: &[fallow_types::output_dead_code::UnusedComponentInputFinding],
1397 root: &Path,
1398 severity: Severity,
1399) {
1400 if findings.is_empty() {
1401 return;
1402 }
1403 for entry in findings {
1404 let level = finding_codeclimate(entry, severity);
1405 let i = &entry.input;
1406 let path = cc_path(&i.path, root);
1407 let fp = codeclimate_fingerprint_hash(&[
1408 "fallow/unused-component-input",
1409 &path,
1410 &i.line.to_string(),
1411 &i.input_name,
1412 ]);
1413 let line = if i.line > 0 { Some(i.line) } else { None };
1414 let message = format!(
1415 "input `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1416 i.input_name, i.component_name
1417 );
1418 issues.push(dead_code_issue(
1419 entry.finding_id.as_deref(),
1420 &[],
1421 CodeClimateIssueInput {
1422 check_name: "fallow/unused-component-input",
1423 description: &message,
1424 severity: level,
1425 category: "Bug Risk",
1426 path: &path,
1427 begin_line: line,
1428 fingerprint: &fp,
1429 },
1430 ));
1431 }
1432}
1433
1434fn push_unused_component_output_issues(
1435 issues: &mut Vec<CodeClimateIssue>,
1436 findings: &[fallow_types::output_dead_code::UnusedComponentOutputFinding],
1437 root: &Path,
1438 severity: Severity,
1439) {
1440 if findings.is_empty() {
1441 return;
1442 }
1443 for entry in findings {
1444 let level = finding_codeclimate(entry, severity);
1445 let o = &entry.output;
1446 let path = cc_path(&o.path, root);
1447 let fp = codeclimate_fingerprint_hash(&[
1448 "fallow/unused-component-output",
1449 &path,
1450 &o.line.to_string(),
1451 &o.output_name,
1452 ]);
1453 let line = if o.line > 0 { Some(o.line) } else { None };
1454 let message = format!(
1455 "output `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1456 o.output_name, o.component_name
1457 );
1458 issues.push(dead_code_issue(
1459 entry.finding_id.as_deref(),
1460 &[],
1461 CodeClimateIssueInput {
1462 check_name: "fallow/unused-component-output",
1463 description: &message,
1464 severity: level,
1465 category: "Bug Risk",
1466 path: &path,
1467 begin_line: line,
1468 fingerprint: &fp,
1469 },
1470 ));
1471 }
1472}
1473
1474fn push_unused_server_action_issues(
1475 issues: &mut Vec<CodeClimateIssue>,
1476 findings: &[fallow_types::output_dead_code::UnusedServerActionFinding],
1477 root: &Path,
1478 severity: Severity,
1479) {
1480 if findings.is_empty() {
1481 return;
1482 }
1483 for entry in findings {
1484 let level = finding_codeclimate(entry, severity);
1485 let a = &entry.action;
1486 let path = cc_path(&a.path, root);
1487 let fp = codeclimate_fingerprint_hash(&[
1488 "fallow/unused-server-action",
1489 &path,
1490 &a.line.to_string(),
1491 &a.action_name,
1492 ]);
1493 let line = if a.line > 0 { Some(a.line) } else { None };
1494 let message = format!(
1495 "server action `{}` is exported from a \"use server\" file but no code in this project references it (wire it to a consumer or remove it)",
1496 a.action_name
1497 );
1498 issues.push(dead_code_issue(
1499 entry.finding_id.as_deref(),
1500 &[],
1501 CodeClimateIssueInput {
1502 check_name: "fallow/unused-server-action",
1503 description: &message,
1504 severity: level,
1505 category: "Bug Risk",
1506 path: &path,
1507 begin_line: line,
1508 fingerprint: &fp,
1509 },
1510 ));
1511 }
1512}
1513
1514fn push_unused_load_data_key_issues(
1515 issues: &mut Vec<CodeClimateIssue>,
1516 findings: &[fallow_types::output_dead_code::UnusedLoadDataKeyFinding],
1517 root: &Path,
1518 severity: Severity,
1519) {
1520 if findings.is_empty() {
1521 return;
1522 }
1523 for entry in findings {
1524 let level = finding_codeclimate(entry, severity);
1525 let k = &entry.key;
1526 let path = cc_path(&k.path, root);
1527 let fp = codeclimate_fingerprint_hash(&[
1528 "fallow/unused-load-data-key",
1529 &path,
1530 &k.line.to_string(),
1531 &k.key_name,
1532 ]);
1533 let line = if k.line > 0 { Some(k.line) } else { None };
1534 let message = format!(
1535 "load() return key `{}` is read by no consumer (sibling +page.svelte data.<key> or project-wide page.data.<key>); delete the key or wire a consumer",
1536 k.key_name
1537 );
1538 issues.push(dead_code_issue(
1539 entry.finding_id.as_deref(),
1540 &[],
1541 CodeClimateIssueInput {
1542 check_name: "fallow/unused-load-data-key",
1543 description: &message,
1544 severity: level,
1545 category: "Bug Risk",
1546 path: &path,
1547 begin_line: line,
1548 fingerprint: &fp,
1549 },
1550 ));
1551 }
1552}
1553
1554fn push_route_collision_issues(
1555 issues: &mut Vec<CodeClimateIssue>,
1556 findings: &[fallow_types::output_dead_code::RouteCollisionFinding],
1557 root: &Path,
1558 severity: Severity,
1559) {
1560 if findings.is_empty() {
1561 return;
1562 }
1563 for entry in findings {
1564 let level = finding_codeclimate(entry, severity);
1565 let c = &entry.collision;
1566 let path = cc_path(&c.path, root);
1567 let fp = codeclimate_fingerprint_hash(&["fallow/route-collision", &path, &c.url]);
1568 let line = if c.line > 0 { Some(c.line) } else { None };
1569 let message = format!(
1570 "Route file resolves to `{}`, also owned by {} other file(s); Next.js fails the build because a URL can have only one owner",
1571 c.url,
1572 c.conflicting_paths.len()
1573 );
1574 issues.push(dead_code_issue(
1575 entry.finding_id.as_deref(),
1576 &[],
1577 CodeClimateIssueInput {
1578 check_name: "fallow/route-collision",
1579 description: &message,
1580 severity: level,
1581 category: "Bug Risk",
1582 path: &path,
1583 begin_line: line,
1584 fingerprint: &fp,
1585 },
1586 ));
1587 }
1588}
1589
1590fn push_dynamic_segment_name_conflict_issues(
1591 issues: &mut Vec<CodeClimateIssue>,
1592 findings: &[fallow_types::output_dead_code::DynamicSegmentNameConflictFinding],
1593 root: &Path,
1594 severity: Severity,
1595) {
1596 if findings.is_empty() {
1597 return;
1598 }
1599 for entry in findings {
1600 let level = finding_codeclimate(entry, severity);
1601 let c = &entry.conflict;
1602 let path = cc_path(&c.path, root);
1603 let fp = codeclimate_fingerprint_hash(&[
1604 "fallow/dynamic-segment-name-conflict",
1605 &path,
1606 &c.position,
1607 ]);
1608 let line = if c.line > 0 { Some(c.line) } else { None };
1609 let message = format!(
1610 "Dynamic segments at `{}` use different slug names ({}); Next.js requires one consistent name per dynamic path",
1611 c.position,
1612 c.conflicting_segments.join(", ")
1613 );
1614 issues.push(dead_code_issue(
1615 entry.finding_id.as_deref(),
1616 &[],
1617 CodeClimateIssueInput {
1618 check_name: "fallow/dynamic-segment-name-conflict",
1619 description: &message,
1620 severity: level,
1621 category: "Bug Risk",
1622 path: &path,
1623 begin_line: line,
1624 fingerprint: &fp,
1625 },
1626 ));
1627 }
1628}
1629
1630fn push_stale_suppression_issues(
1631 issues: &mut Vec<CodeClimateIssue>,
1632 suppressions: &[fallow_types::results::StaleSuppression],
1633 root: &Path,
1634 rules: &RulesConfig,
1635) {
1636 if suppressions.is_empty() {
1637 return;
1638 }
1639 for s in suppressions {
1640 let severity = if s.missing_reason {
1641 rules.require_suppression_reason
1642 } else {
1643 rules.stale_suppressions
1644 };
1645 let level = finding_codeclimate(s, severity);
1646 let path = cc_path(&s.path, root);
1647 let line_str = s.line.to_string();
1648 let check_name = if s.missing_reason {
1649 "fallow/missing-suppression-reason"
1650 } else {
1651 "fallow/stale-suppression"
1652 };
1653 let fp = codeclimate_fingerprint_hash(&[check_name, &path, &line_str]);
1654 issues.push(dead_code_issue(
1655 s.finding_id.as_deref(),
1656 &[],
1657 CodeClimateIssueInput {
1658 check_name,
1659 description: &s.display_message(),
1660 severity: level,
1661 category: "Bug Risk",
1662 path: &path,
1663 begin_line: Some(s.line),
1664 fingerprint: &fp,
1665 },
1666 ));
1667 }
1668}
1669
1670fn push_unused_catalog_entry_issues(
1671 issues: &mut Vec<CodeClimateIssue>,
1672 entries: &[fallow_types::output_dead_code::UnusedCatalogEntryFinding],
1673 root: &Path,
1674 severity: Severity,
1675) {
1676 if entries.is_empty() {
1677 return;
1678 }
1679 for entry in entries {
1680 let level = finding_codeclimate(entry, severity);
1681 let finding_id = entry.finding_id.as_deref();
1682 let entry = &entry.entry;
1683 let path = cc_path(&entry.path, root);
1684 let line_str = entry.line.to_string();
1685 let fp = codeclimate_fingerprint_hash(&[
1686 "fallow/unused-catalog-entry",
1687 &path,
1688 &line_str,
1689 &entry.catalog_name,
1690 &entry.entry_name,
1691 ]);
1692 let description = if entry.catalog_name == "default" {
1693 format!(
1694 "Catalog entry '{}' is not referenced by any workspace package",
1695 entry.entry_name
1696 )
1697 } else {
1698 format!(
1699 "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
1700 entry.entry_name, entry.catalog_name
1701 )
1702 };
1703 issues.push(dead_code_issue(
1704 finding_id,
1705 &[],
1706 CodeClimateIssueInput {
1707 check_name: "fallow/unused-catalog-entry",
1708 description: &description,
1709 severity: level,
1710 category: "Bug Risk",
1711 path: &path,
1712 begin_line: Some(entry.line),
1713 fingerprint: &fp,
1714 },
1715 ));
1716 }
1717}
1718
1719fn push_unresolved_catalog_reference_issues(
1720 issues: &mut Vec<CodeClimateIssue>,
1721 findings: &[fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding],
1722 root: &Path,
1723 severity: Severity,
1724) {
1725 if findings.is_empty() {
1726 return;
1727 }
1728 for finding in findings {
1729 let level = finding_codeclimate(finding, severity);
1730 let finding_id = finding.finding_id.as_deref();
1731 let finding = &finding.reference;
1732 let path = cc_path(&finding.path, root);
1733 let line_str = finding.line.to_string();
1734 let fp = codeclimate_fingerprint_hash(&[
1735 "fallow/unresolved-catalog-reference",
1736 &path,
1737 &line_str,
1738 &finding.catalog_name,
1739 &finding.entry_name,
1740 ]);
1741 let catalog_phrase = if finding.catalog_name == "default" {
1742 "the default catalog".to_string()
1743 } else {
1744 format!("catalog '{}'", finding.catalog_name)
1745 };
1746 let mut description = format!(
1747 "Package '{}' is referenced via `catalog:{}` but {} does not declare it; `pnpm install` will fail",
1748 finding.entry_name,
1749 if finding.catalog_name == "default" {
1750 ""
1751 } else {
1752 finding.catalog_name.as_str()
1753 },
1754 catalog_phrase,
1755 );
1756 if !finding.available_in_catalogs.is_empty() {
1757 use std::fmt::Write as _;
1758 let _ = write!(
1759 description,
1760 " (available in: {})",
1761 finding.available_in_catalogs.join(", ")
1762 );
1763 }
1764 issues.push(dead_code_issue(
1765 finding_id,
1766 &[],
1767 CodeClimateIssueInput {
1768 check_name: "fallow/unresolved-catalog-reference",
1769 description: &description,
1770 severity: level,
1771 category: "Bug Risk",
1772 path: &path,
1773 begin_line: Some(finding.line),
1774 fingerprint: &fp,
1775 },
1776 ));
1777 }
1778}
1779
1780fn push_empty_catalog_group_issues(
1781 issues: &mut Vec<CodeClimateIssue>,
1782 groups: &[fallow_types::output_dead_code::EmptyCatalogGroupFinding],
1783 root: &Path,
1784 severity: Severity,
1785) {
1786 if groups.is_empty() {
1787 return;
1788 }
1789 for group in groups {
1790 let level = finding_codeclimate(group, severity);
1791 let finding_id = group.finding_id.as_deref();
1792 let group = &group.group;
1793 let path = cc_path(&group.path, root);
1794 let line_str = group.line.to_string();
1795 let fp = codeclimate_fingerprint_hash(&[
1796 "fallow/empty-catalog-group",
1797 &path,
1798 &line_str,
1799 &group.catalog_name,
1800 ]);
1801 issues.push(dead_code_issue(
1802 finding_id,
1803 &[],
1804 CodeClimateIssueInput {
1805 check_name: "fallow/empty-catalog-group",
1806 description: &format!("Catalog group '{}' has no entries", group.catalog_name),
1807 severity: level,
1808 category: "Bug Risk",
1809 path: &path,
1810 begin_line: Some(group.line),
1811 fingerprint: &fp,
1812 },
1813 ));
1814 }
1815}
1816
1817fn push_unused_dependency_override_issues(
1818 issues: &mut Vec<CodeClimateIssue>,
1819 findings: &[fallow_types::output_dead_code::UnusedDependencyOverrideFinding],
1820 root: &Path,
1821 severity: Severity,
1822) {
1823 if findings.is_empty() {
1824 return;
1825 }
1826 for finding in findings {
1827 let level = finding_codeclimate(finding, severity);
1828 let finding_id = finding.finding_id.as_deref();
1829 let finding = &finding.entry;
1830 let path = cc_path(&finding.path, root);
1831 let line_str = finding.line.to_string();
1832 let fp = codeclimate_fingerprint_hash(&[
1833 "fallow/unused-dependency-override",
1834 &path,
1835 &line_str,
1836 finding.source.as_label(),
1837 &finding.raw_key,
1838 ]);
1839 let mut description = format!(
1840 "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in the lockfile",
1841 finding.raw_key, finding.version_range, finding.target_package,
1842 );
1843 if let Some(hint) = &finding.hint {
1844 use std::fmt::Write as _;
1845 let _ = write!(description, " ({hint})");
1846 }
1847 issues.push(dead_code_issue(
1848 finding_id,
1849 &[],
1850 CodeClimateIssueInput {
1851 check_name: "fallow/unused-dependency-override",
1852 description: &description,
1853 severity: level,
1854 category: "Bug Risk",
1855 path: &path,
1856 begin_line: Some(finding.line),
1857 fingerprint: &fp,
1858 },
1859 ));
1860 }
1861}
1862
1863fn push_misconfigured_dependency_override_issues(
1864 issues: &mut Vec<CodeClimateIssue>,
1865 findings: &[fallow_types::output_dead_code::MisconfiguredDependencyOverrideFinding],
1866 root: &Path,
1867 severity: Severity,
1868) {
1869 if findings.is_empty() {
1870 return;
1871 }
1872 for finding in findings {
1873 let level = finding_codeclimate(finding, severity);
1874 let finding_id = finding.finding_id.as_deref();
1875 let finding = &finding.entry;
1876 let path = cc_path(&finding.path, root);
1877 let line_str = finding.line.to_string();
1878 let fp = codeclimate_fingerprint_hash(&[
1879 "fallow/misconfigured-dependency-override",
1880 &path,
1881 &line_str,
1882 finding.source.as_label(),
1883 &finding.raw_key,
1884 ]);
1885 let description = format!(
1886 "Override `{}` -> `{}` is malformed: {}",
1887 finding.raw_key,
1888 finding.raw_value,
1889 finding.reason.describe(),
1890 );
1891 issues.push(dead_code_issue(
1892 finding_id,
1893 &[],
1894 CodeClimateIssueInput {
1895 check_name: "fallow/misconfigured-dependency-override",
1896 description: &description,
1897 severity: level,
1898 category: "Bug Risk",
1899 path: &path,
1900 begin_line: Some(finding.line),
1901 fingerprint: &fp,
1902 },
1903 ));
1904 }
1905}
1906
1907#[must_use]
1914pub fn build_codeclimate(
1915 results: &AnalysisResults,
1916 root: &Path,
1917 rules: &RulesConfig,
1918) -> Vec<CodeClimateIssue> {
1919 CodeClimateBuilder {
1920 issues: Vec::new(),
1921 results,
1922 root,
1923 rules,
1924 }
1925 .build()
1926}
1927
1928struct CodeClimateBuilder<'a> {
1929 issues: Vec<CodeClimateIssue>,
1930 results: &'a AnalysisResults,
1931 root: &'a Path,
1932 rules: &'a RulesConfig,
1933}
1934
1935impl CodeClimateBuilder<'_> {
1936 fn build(mut self) -> Vec<CodeClimateIssue> {
1937 self.push_file_and_export_issues();
1938 self.push_private_type_leak_issues();
1939 push_deprecated_export_issues(
1940 &mut self.issues,
1941 &self.results.deprecated_exports_in_use,
1942 self.root,
1943 self.rules.deprecated_exports_in_use,
1944 );
1945 self.push_package_dependency_issues();
1946 self.push_type_test_dependency_issues();
1947 self.push_member_issues();
1948 self.push_import_and_duplicate_issues();
1949 self.push_graph_issues();
1950 self.push_boundary_issues();
1951 self.push_suppression_and_catalog_issues();
1952 self.push_override_issues();
1953 self.push_prop_drilling_issues();
1954 self.issues
1955 }
1956
1957 fn push_prop_drilling_issues(&mut self) {
1958 for finding in &self.results.prop_drilling_chains {
1959 if finding.effective_severity != Some(EffectiveSeverity::Error) {
1960 continue;
1961 }
1962 let Some(source) = finding.chain.hops.first() else {
1963 continue;
1964 };
1965 let path = cc_path(&source.file, self.root);
1966 let fingerprint = codeclimate_fingerprint_hash(&[
1967 "fallow/prop-drilling",
1968 &path,
1969 &source.component,
1970 &finding.chain.prop,
1971 ]);
1972 self.issues.push(dead_code_issue(
1973 finding.finding_id.as_deref(),
1974 &[],
1975 CodeClimateIssueInput {
1976 check_name: "fallow/prop-drilling",
1977 description: &format!("Prop '{}' is forwarded unchanged through {} components. Review composition, colocation or context, or suppress the source prop declaration.", finding.chain.prop, finding.chain.depth),
1978 severity: CodeClimateSeverity::Major,
1979 category: "Complexity",
1980 path: &path,
1981 begin_line: Some(source.line),
1982 fingerprint: &fingerprint,
1983 },
1984 ));
1985 }
1986 }
1987
1988 fn push_file_and_export_issues(&mut self) {
1989 push_unused_file_issues(
1990 &mut self.issues,
1991 &self.results.unused_files,
1992 self.root,
1993 self.rules.unused_files,
1994 );
1995 push_unused_export_issues(UnusedExportIssuesInput {
1996 issues: &mut self.issues,
1997 exports: self.results.unused_exports.iter().map(|e| {
1998 (
1999 &e.export,
2000 e.reachability_caveats.as_slice(),
2001 e.effective_severity,
2002 e.finding_id.as_deref(),
2003 )
2004 }),
2005 root: self.root,
2006 rule_id: "fallow/unused-export",
2007 direct_label: "Export",
2008 re_export_label: "Re-export",
2009 severity: self.rules.unused_exports,
2010 });
2011 push_unused_export_issues(UnusedExportIssuesInput {
2012 issues: &mut self.issues,
2013 exports: self.results.unused_types.iter().map(|e| {
2014 (
2015 &e.export,
2016 e.reachability_caveats.as_slice(),
2017 e.effective_severity,
2018 e.finding_id.as_deref(),
2019 )
2020 }),
2021 root: self.root,
2022 rule_id: "fallow/unused-type",
2023 direct_label: "Type export",
2024 re_export_label: "Type re-export",
2025 severity: self.rules.unused_types,
2026 });
2027 }
2028
2029 fn push_private_type_leak_issues(&mut self) {
2030 push_private_type_leak_issues(
2031 &mut self.issues,
2032 &self.results.private_type_leaks,
2033 self.root,
2034 self.rules.private_type_leaks,
2035 );
2036 }
2037
2038 fn push_package_dependency_issues(&mut self) {
2039 push_dep_cc_issues(
2040 &mut self.issues,
2041 self.results.unused_dependencies.iter().map(|f| {
2042 (
2043 &f.dep,
2044 f.reachability_caveats.as_slice(),
2045 f.effective_severity,
2046 f.finding_id.as_deref(),
2047 )
2048 }),
2049 self.root,
2050 "fallow/unused-dependency",
2051 "dependencies",
2052 self.rules.unused_dependencies,
2053 );
2054 push_dep_cc_issues(
2055 &mut self.issues,
2056 self.results.unused_dev_dependencies.iter().map(|f| {
2057 (
2058 &f.dep,
2059 f.reachability_caveats.as_slice(),
2060 f.effective_severity,
2061 f.finding_id.as_deref(),
2062 )
2063 }),
2064 self.root,
2065 "fallow/unused-dev-dependency",
2066 "devDependencies",
2067 self.rules.unused_dev_dependencies,
2068 );
2069 push_dep_cc_issues(
2070 &mut self.issues,
2071 self.results.unused_optional_dependencies.iter().map(|f| {
2072 (
2073 &f.dep,
2074 f.reachability_caveats.as_slice(),
2075 f.effective_severity,
2076 f.finding_id.as_deref(),
2077 )
2078 }),
2079 self.root,
2080 "fallow/unused-optional-dependency",
2081 "optionalDependencies",
2082 self.rules.unused_optional_dependencies,
2083 );
2084 }
2085
2086 fn push_type_test_dependency_issues(&mut self) {
2087 push_type_only_dep_issues(
2088 &mut self.issues,
2089 &self.results.type_only_dependencies,
2090 self.root,
2091 self.rules.type_only_dependencies,
2092 );
2093 push_test_only_dep_issues(
2094 &mut self.issues,
2095 &self.results.test_only_dependencies,
2096 self.root,
2097 self.rules.test_only_dependencies,
2098 );
2099 push_dev_dep_in_prod_issues(
2100 &mut self.issues,
2101 &self.results.dev_dependencies_in_production,
2102 self.root,
2103 self.rules.dev_dependencies_in_production,
2104 );
2105 }
2106
2107 fn push_member_issues(&mut self) {
2108 push_unused_member_issues(
2109 &mut self.issues,
2110 self.results.unused_enum_members.iter().map(|m| {
2111 (
2112 &m.member,
2113 m.reachability_caveats.as_slice(),
2114 m.effective_severity,
2115 m.finding_id.as_deref(),
2116 )
2117 }),
2118 self.root,
2119 "fallow/unused-enum-member",
2120 "Enum",
2121 self.rules.unused_enum_members,
2122 );
2123 push_unused_member_issues(
2124 &mut self.issues,
2125 self.results.unused_class_members.iter().map(|m| {
2126 (
2127 &m.member,
2128 m.reachability_caveats.as_slice(),
2129 m.effective_severity,
2130 m.finding_id.as_deref(),
2131 )
2132 }),
2133 self.root,
2134 "fallow/unused-class-member",
2135 "Class",
2136 self.rules.unused_class_members,
2137 );
2138 push_unused_member_issues(
2139 &mut self.issues,
2140 self.results.unused_store_members.iter().map(|m| {
2141 (
2142 &m.member,
2143 m.reachability_caveats.as_slice(),
2144 m.effective_severity,
2145 m.finding_id.as_deref(),
2146 )
2147 }),
2148 self.root,
2149 "fallow/unused-store-member",
2150 "Store",
2151 self.rules.unused_store_members,
2152 );
2153 }
2154
2155 fn push_import_and_duplicate_issues(&mut self) {
2156 push_unresolved_import_issues(
2157 &mut self.issues,
2158 &self.results.unresolved_imports,
2159 self.root,
2160 self.rules.unresolved_imports,
2161 );
2162 push_unlisted_dep_issues(
2163 &mut self.issues,
2164 &self.results.unlisted_dependencies,
2165 self.root,
2166 self.rules.unlisted_dependencies,
2167 );
2168 push_duplicate_export_issues(
2169 &mut self.issues,
2170 &self.results.duplicate_exports,
2171 self.root,
2172 self.rules.duplicate_exports,
2173 );
2174 }
2175
2176 fn push_graph_issues(&mut self) {
2177 push_circular_dep_issues(
2178 &mut self.issues,
2179 &self.results.circular_dependencies,
2180 self.root,
2181 self.rules.circular_dependencies,
2182 );
2183 push_re_export_cycle_issues(
2184 &mut self.issues,
2185 &self.results.re_export_cycles,
2186 self.root,
2187 self.rules.re_export_cycle,
2188 );
2189 push_package_cycle_issues(
2190 &mut self.issues,
2191 &self.results.package_cycles,
2192 self.root,
2193 self.rules.package_cycle,
2194 );
2195 }
2196
2197 fn push_boundary_issues(&mut self) {
2198 self.push_architecture_boundary_issues();
2199 self.push_client_server_boundary_issues();
2200 self.push_component_boundary_issues();
2201 self.push_framework_route_issues();
2202 }
2203
2204 fn push_architecture_boundary_issues(&mut self) {
2205 push_boundary_violation_issues(
2206 &mut self.issues,
2207 &self.results.boundary_violations,
2208 self.root,
2209 self.rules.boundary_violation,
2210 );
2211 push_boundary_coverage_issues(
2212 &mut self.issues,
2213 &self.results.boundary_coverage_violations,
2214 self.root,
2215 self.rules.boundary_violation,
2216 );
2217 push_boundary_call_issues(
2218 &mut self.issues,
2219 &self.results.boundary_call_violations,
2220 self.root,
2221 self.rules.boundary_violation,
2222 );
2223 push_policy_violation_issues(&mut self.issues, &self.results.policy_violations, self.root);
2224 }
2225
2226 fn push_client_server_boundary_issues(&mut self) {
2227 push_invalid_client_export_issues(
2228 &mut self.issues,
2229 &self.results.invalid_client_exports,
2230 self.root,
2231 self.rules.invalid_client_export,
2232 );
2233 push_mixed_client_server_barrel_issues(
2234 &mut self.issues,
2235 &self.results.mixed_client_server_barrels,
2236 self.root,
2237 self.rules.mixed_client_server_barrel,
2238 );
2239 push_misplaced_directive_issues(
2240 &mut self.issues,
2241 &self.results.misplaced_directives,
2242 self.root,
2243 self.rules.misplaced_directive,
2244 );
2245 }
2246
2247 fn push_component_boundary_issues(&mut self) {
2248 push_unprovided_inject_issues(
2249 &mut self.issues,
2250 &self.results.unprovided_injects,
2251 self.root,
2252 self.rules.unprovided_injects,
2253 );
2254 push_unrendered_component_issues(
2255 &mut self.issues,
2256 &self.results.unrendered_components,
2257 self.root,
2258 self.rules.unrendered_components,
2259 );
2260 push_absent_component_prop_issues(
2261 &mut self.issues,
2262 &self.results.absent_component_props,
2263 self.root,
2264 self.rules.absent_component_props,
2265 );
2266 push_unused_component_prop_issues(
2267 &mut self.issues,
2268 &self.results.unused_component_props,
2269 self.root,
2270 self.rules.unused_component_props,
2271 );
2272 push_unused_component_emit_issues(
2273 &mut self.issues,
2274 &self.results.unused_component_emits,
2275 self.root,
2276 self.rules.unused_component_emits,
2277 );
2278 push_unused_component_input_issues(
2279 &mut self.issues,
2280 &self.results.unused_component_inputs,
2281 self.root,
2282 self.rules.unused_component_inputs,
2283 );
2284 push_unused_component_output_issues(
2285 &mut self.issues,
2286 &self.results.unused_component_outputs,
2287 self.root,
2288 self.rules.unused_component_outputs,
2289 );
2290 push_unused_svelte_event_issues(
2291 &mut self.issues,
2292 &self.results.unused_svelte_events,
2293 self.root,
2294 self.rules.unused_svelte_events,
2295 );
2296 push_unused_effect_layer_issues(
2297 &mut self.issues,
2298 &self.results.unused_effect_layers,
2299 self.root,
2300 self.rules.unused_effect_layers,
2301 );
2302 push_unused_tagged_error_issues(
2303 &mut self.issues,
2304 &self.results.unused_tagged_errors,
2305 self.root,
2306 self.rules.unused_tagged_errors,
2307 );
2308 push_unproduced_catch_tag_issues(
2309 &mut self.issues,
2310 &self.results.unproduced_catch_tags,
2311 self.root,
2312 self.rules.unproduced_catch_tags,
2313 );
2314 }
2315
2316 fn push_framework_route_issues(&mut self) {
2317 push_unused_server_action_issues(
2318 &mut self.issues,
2319 &self.results.unused_server_actions,
2320 self.root,
2321 self.rules.unused_server_actions,
2322 );
2323 push_unused_load_data_key_issues(
2324 &mut self.issues,
2325 &self.results.unused_load_data_keys,
2326 self.root,
2327 self.rules.unused_load_data_keys,
2328 );
2329 push_route_collision_issues(
2330 &mut self.issues,
2331 &self.results.route_collisions,
2332 self.root,
2333 self.rules.route_collision,
2334 );
2335 push_dynamic_segment_name_conflict_issues(
2336 &mut self.issues,
2337 &self.results.dynamic_segment_name_conflicts,
2338 self.root,
2339 self.rules.dynamic_segment_name_conflict,
2340 );
2341 }
2342
2343 fn push_suppression_and_catalog_issues(&mut self) {
2344 push_stale_suppression_issues(
2345 &mut self.issues,
2346 &self.results.stale_suppressions,
2347 self.root,
2348 self.rules,
2349 );
2350 push_unused_catalog_entry_issues(
2351 &mut self.issues,
2352 &self.results.unused_catalog_entries,
2353 self.root,
2354 self.rules.unused_catalog_entries,
2355 );
2356 push_empty_catalog_group_issues(
2357 &mut self.issues,
2358 &self.results.empty_catalog_groups,
2359 self.root,
2360 self.rules.empty_catalog_groups,
2361 );
2362 push_unresolved_catalog_reference_issues(
2363 &mut self.issues,
2364 &self.results.unresolved_catalog_references,
2365 self.root,
2366 self.rules.unresolved_catalog_references,
2367 );
2368 }
2369
2370 fn push_override_issues(&mut self) {
2371 push_unused_dependency_override_issues(
2372 &mut self.issues,
2373 &self.results.unused_dependency_overrides,
2374 self.root,
2375 self.rules.unused_dependency_overrides,
2376 );
2377 push_misconfigured_dependency_override_issues(
2378 &mut self.issues,
2379 &self.results.misconfigured_dependency_overrides,
2380 self.root,
2381 self.rules.misconfigured_dependency_overrides,
2382 );
2383 }
2384}
2385
2386#[cfg(test)]
2387mod tests {
2388 use std::collections::BTreeSet;
2389
2390 use fallow_output::issue_output_contracts;
2391
2392 fn codeclimate_check_name_literals() -> BTreeSet<String> {
2393 let source = include_str!("dead_code_codeclimate.rs")
2394 .split("#[cfg(test)]")
2395 .next()
2396 .expect("source before tests");
2397 let mut literals = BTreeSet::new();
2398 let mut rest = source;
2399 while let Some(start) = rest.find("\"fallow/") {
2400 let after_quote = &rest[start + 1..];
2401 let Some(end) = after_quote.find('"') else {
2402 break;
2403 };
2404 literals.insert(after_quote[..end].to_owned());
2405 rest = &after_quote[end + 1..];
2406 }
2407 literals
2408 }
2409
2410 #[test]
2411 fn codeclimate_check_names_match_issue_contracts() {
2412 let from_emitter = codeclimate_check_name_literals();
2413 let from_contracts = issue_output_contracts()
2414 .flat_map(|contract| contract.codeclimate_check_names)
2415 .collect::<BTreeSet<_>>();
2416
2417 assert_eq!(from_emitter, from_contracts);
2418 }
2419
2420 mod caveats {
2421 use std::path::{Path, PathBuf};
2422
2423 use fallow_config::RulesConfig;
2424 use fallow_types::extract::MemberKind;
2425 use fallow_types::output_dead_code::{
2426 ReachabilityCaveat, UnusedClassMemberFinding, UnusedDependencyFinding,
2427 UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding,
2428 UnusedStoreMemberFinding,
2429 };
2430 use fallow_types::results::{
2431 AnalysisResults, DependencyLocation, UnusedDependency, UnusedExport, UnusedFile,
2432 UnusedMember,
2433 };
2434
2435 use crate::dead_code_codeclimate::build_codeclimate;
2436
2437 fn results_with(root: &Path, caveated: bool) -> AnalysisResults {
2439 let caveats = if caveated {
2440 vec![ReachabilityCaveat::IncompleteImportGraph]
2441 } else {
2442 Vec::new()
2443 };
2444 let mut results = AnalysisResults::default();
2445
2446 let mut file = UnusedFileFinding::with_actions(UnusedFile {
2447 path: root.join("src/lib.ts"),
2448 });
2449 file.reachability_caveats.clone_from(&caveats);
2450 results.unused_files.push(file);
2451
2452 let mut export = UnusedExportFinding::with_actions(UnusedExport {
2453 path: root.join("src/lib.ts"),
2454 export_name: "needed".to_owned(),
2455 is_type_only: false,
2456 line: 3,
2457 col: 0,
2458 span_start: 0,
2459 is_re_export: false,
2460 deprecated: false,
2461 deprecated_reason: None,
2462 });
2463 export.reachability_caveats.clone_from(&caveats);
2464 results.unused_exports.push(export);
2465
2466 let mut dep = UnusedDependencyFinding::with_actions(UnusedDependency {
2467 package_name: "left-pad".to_owned(),
2468 location: DependencyLocation::Dependencies,
2469 path: root.join("package.json"),
2470 line: 5,
2471 used_in_workspaces: Vec::new(),
2472 declared_and_imported_in: Vec::new(),
2473 });
2474 dep.reachability_caveats.clone_from(&caveats);
2475 results.unused_dependencies.push(dep);
2476
2477 let member = |parent: &str, name: &str, kind| UnusedMember {
2478 path: root.join("src/lib.ts"),
2479 parent_name: parent.to_owned(),
2480 member_name: name.to_owned(),
2481 kind,
2482 line: 7,
2483 col: 2,
2484 };
2485
2486 let mut enum_member = UnusedEnumMemberFinding::with_actions(member(
2487 "Mode",
2488 "Legacy",
2489 MemberKind::EnumMember,
2490 ));
2491 enum_member.reachability_caveats.clone_from(&caveats);
2492 results.unused_enum_members.push(enum_member);
2493
2494 let mut class_member = UnusedClassMemberFinding::with_actions(member(
2495 "Widget",
2496 "render",
2497 MemberKind::ClassMethod,
2498 ));
2499 class_member.reachability_caveats.clone_from(&caveats);
2500 results.unused_class_members.push(class_member);
2501
2502 let mut store_member = UnusedStoreMemberFinding::with_actions(member(
2503 "useCart",
2504 "subtotal",
2505 MemberKind::StoreMember,
2506 ));
2507 store_member.reachability_caveats.clone_from(&caveats);
2508 results.unused_store_members.push(store_member);
2509
2510 results
2511 }
2512
2513 #[test]
2518 fn descriptions_name_the_caveat() {
2519 let root = PathBuf::from("/project");
2520
2521 let issues =
2522 build_codeclimate(&results_with(&root, true), &root, &RulesConfig::default());
2523
2524 let descriptions: Vec<&str> = issues
2525 .iter()
2526 .map(|issue| issue.description.as_str())
2527 .collect();
2528 assert!(
2529 descriptions
2530 .iter()
2531 .all(|description| description.ends_with(" (caveat: incomplete import graph)")),
2532 "every caveated finding hedges its description: {descriptions:?}"
2533 );
2534 assert!(
2535 descriptions.contains(
2536 &"File is not reachable from any entry point (caveat: incomplete import graph)"
2537 ),
2538 "{descriptions:?}"
2539 );
2540 }
2541
2542 #[test]
2546 fn a_clean_run_carries_no_caveat_text() {
2547 let root = PathBuf::from("/project");
2548
2549 let issues =
2550 build_codeclimate(&results_with(&root, false), &root, &RulesConfig::default());
2551
2552 assert!(
2553 issues
2554 .iter()
2555 .all(|issue| !issue.description.contains("caveat")),
2556 "{:?}",
2557 issues
2558 .iter()
2559 .map(|issue| issue.description.as_str())
2560 .collect::<Vec<_>>()
2561 );
2562 }
2563
2564 #[test]
2568 fn the_caveat_does_not_move_the_fingerprint() {
2569 let root = PathBuf::from("/project");
2570
2571 let clean =
2572 build_codeclimate(&results_with(&root, false), &root, &RulesConfig::default());
2573 let caveated =
2574 build_codeclimate(&results_with(&root, true), &root, &RulesConfig::default());
2575
2576 let fingerprints = |issues: &[fallow_output::CodeClimateIssue]| {
2577 issues
2578 .iter()
2579 .map(|issue| issue.fingerprint.clone())
2580 .collect::<Vec<_>>()
2581 };
2582 assert_eq!(fingerprints(&clean), fingerprints(&caveated));
2583 assert_ne!(
2584 clean[0].description, caveated[0].description,
2585 "the guard is only meaningful while the description actually changed"
2586 );
2587 }
2588 }
2589
2590 mod stable_fingerprints {
2591 use std::path::{Path, PathBuf};
2592
2593 use fallow_config::RulesConfig;
2594 use fallow_output::{CodeClimateIssue, codeclimate_fingerprint_hash};
2595 use fallow_types::identity::stamp_dead_code_finding_ids;
2596 use fallow_types::output_dead_code::{
2597 DuplicateExportFinding, UnlistedDependencyFinding, UnusedDependencyFinding,
2598 UnusedExportFinding,
2599 };
2600 use fallow_types::results::{
2601 AnalysisResults, DependencyLocation, DuplicateExport, DuplicateLocation, ImportSite,
2602 UnlistedDependency, UnusedDependency, UnusedExport,
2603 };
2604
2605 use crate::dead_code_codeclimate::build_codeclimate;
2606
2607 fn export(root: &Path, name: &str, line: u32) -> UnusedExportFinding {
2608 UnusedExportFinding::with_actions(UnusedExport {
2609 path: root.join("src/lib.ts"),
2610 export_name: name.to_owned(),
2611 is_type_only: false,
2612 line,
2613 col: 0,
2614 span_start: 0,
2615 is_re_export: false,
2616 deprecated: false,
2617 deprecated_reason: None,
2618 })
2619 }
2620
2621 fn dependency(manifest: PathBuf, line: u32) -> UnusedDependencyFinding {
2622 UnusedDependencyFinding::with_actions(UnusedDependency {
2623 package_name: "left-pad".to_owned(),
2624 location: DependencyLocation::Dependencies,
2625 path: manifest,
2626 line,
2627 used_in_workspaces: Vec::new(),
2628 declared_and_imported_in: Vec::new(),
2629 })
2630 }
2631
2632 fn results(root: &Path, shift: u32) -> AnalysisResults {
2635 let mut results = AnalysisResults::default();
2636 results
2637 .unused_exports
2638 .push(export(root, "first", 3 + shift));
2639 results
2640 .unused_exports
2641 .push(export(root, "second", 9 + shift));
2642 results
2643 .unused_dependencies
2644 .push(dependency(root.join("package.json"), 5 + shift));
2645 results
2646 .unlisted_dependencies
2647 .push(UnlistedDependencyFinding::with_actions(
2648 UnlistedDependency {
2649 package_name: "chalk".to_owned(),
2650 imported_from: vec![
2651 ImportSite {
2652 path: root.join("src/cli.ts"),
2653 line: 2 + shift,
2654 col: 0,
2655 },
2656 ImportSite {
2657 path: root.join("src/cli.ts"),
2658 line: 8 + shift,
2659 col: 0,
2660 },
2661 ],
2662 },
2663 ));
2664 results
2665 .duplicate_exports
2666 .push(DuplicateExportFinding::with_actions(DuplicateExport {
2667 export_name: "Config".to_owned(),
2668 locations: vec![
2669 DuplicateLocation {
2670 path: root.join("src/a.ts"),
2671 line: 4 + shift,
2672 col: 0,
2673 },
2674 DuplicateLocation {
2675 path: root.join("src/b.ts"),
2676 line: 6 + shift,
2677 col: 0,
2678 },
2679 ],
2680 }));
2681 stamp_dead_code_finding_ids(&mut results, root);
2682 results
2683 }
2684
2685 fn fingerprints(issues: &[CodeClimateIssue]) -> Vec<String> {
2686 issues
2687 .iter()
2688 .map(|issue| issue.fingerprint.clone())
2689 .collect()
2690 }
2691
2692 #[test]
2695 fn a_line_shift_keeps_every_dead_code_fingerprint() {
2696 let root = PathBuf::from("/project");
2697 let rules = RulesConfig::default();
2698
2699 let before = build_codeclimate(&results(&root, 0), &root, &rules);
2700 let after = build_codeclimate(&results(&root, 40), &root, &rules);
2701
2702 assert_eq!(before.len(), 7);
2703 assert_eq!(fingerprints(&before), fingerprints(&after));
2704 assert_ne!(
2705 before[0].location.lines.begin, after[0].location.lines.begin,
2706 "the guard is only meaningful while the lines actually moved"
2707 );
2708 let mut unique = fingerprints(&before);
2709 unique.sort();
2710 unique.dedup();
2711 assert_eq!(
2712 unique.len(),
2713 before.len(),
2714 "every issue keeps its own fingerprint"
2715 );
2716 assert_eq!(
2717 before[0].legacy_fingerprint.as_deref(),
2718 Some(
2719 codeclimate_fingerprint_hash(&[
2720 "fallow/unused-export",
2721 "src/lib.ts",
2722 "3",
2723 "first"
2724 ])
2725 .as_str()
2726 ),
2727 "the line-based value stays available to match older review threads"
2728 );
2729 }
2730
2731 fn site(root: &Path, file: &str, line: u32) -> ImportSite {
2732 ImportSite {
2733 path: root.join(file),
2734 line,
2735 col: 0,
2736 }
2737 }
2738
2739 fn location(root: &Path, file: &str, line: u32) -> DuplicateLocation {
2740 DuplicateLocation {
2741 path: root.join(file),
2742 line,
2743 col: 0,
2744 }
2745 }
2746
2747 fn per_location(
2750 root: &Path,
2751 sites: Vec<ImportSite>,
2752 locations: Vec<DuplicateLocation>,
2753 ) -> Vec<(String, u32, String)> {
2754 let mut results = AnalysisResults::default();
2755 results
2756 .unlisted_dependencies
2757 .push(UnlistedDependencyFinding::with_actions(
2758 UnlistedDependency {
2759 package_name: "chalk".to_owned(),
2760 imported_from: sites,
2761 },
2762 ));
2763 results
2764 .duplicate_exports
2765 .push(DuplicateExportFinding::with_actions(DuplicateExport {
2766 export_name: "Config".to_owned(),
2767 locations,
2768 }));
2769 stamp_dead_code_finding_ids(&mut results, root);
2770 build_codeclimate(&results, root, &RulesConfig::default())
2771 .into_iter()
2772 .map(|issue| {
2773 (
2774 format!("{} {}", issue.check_name, issue.location.path),
2775 issue.location.lines.begin,
2776 issue.fingerprint,
2777 )
2778 })
2779 .collect()
2780 }
2781
2782 fn fingerprint_at(issues: &[(String, u32, String)], path: &str, line: u32) -> String {
2783 issues
2784 .iter()
2785 .find(|(p, l, _)| p == path && *l == line)
2786 .map_or_else(
2787 || panic!("no issue at {path}:{line}: {issues:?}"),
2788 |(_, _, fingerprint)| fingerprint.clone(),
2789 )
2790 }
2791
2792 #[test]
2797 fn a_sibling_location_does_not_move_the_other_fingerprints() {
2798 let root = PathBuf::from("/project");
2799 let base = per_location(
2800 &root,
2801 vec![site(&root, "src/b.ts", 4), site(&root, "src/c.ts", 7)],
2802 vec![
2803 location(&root, "src/b.ts", 4),
2804 location(&root, "src/c.ts", 7),
2805 ],
2806 );
2807 let added = per_location(
2808 &root,
2809 vec![
2810 site(&root, "src/a.ts", 1),
2811 site(&root, "src/b.ts", 4),
2812 site(&root, "src/b.ts", 9),
2813 site(&root, "src/c.ts", 7),
2814 ],
2815 vec![
2816 location(&root, "src/a.ts", 1),
2817 location(&root, "src/b.ts", 4),
2818 location(&root, "src/b.ts", 9),
2819 location(&root, "src/c.ts", 7),
2820 ],
2821 );
2822 let removed = per_location(
2823 &root,
2824 vec![site(&root, "src/c.ts", 7)],
2825 vec![
2826 location(&root, "src/c.ts", 7),
2827 location(&root, "src/d.ts", 2),
2828 ],
2829 );
2830
2831 for rule in ["fallow/unlisted-dependency", "fallow/duplicate-export"] {
2832 let pick = |issues: &[(String, u32, String)]| {
2833 issues
2834 .iter()
2835 .filter_map(|(key, line, fingerprint)| {
2836 key.strip_prefix(&format!("{rule} "))
2837 .map(|path| (path.to_owned(), *line, fingerprint.clone()))
2838 })
2839 .collect::<Vec<_>>()
2840 };
2841 let (base, added, removed) = (pick(&base), pick(&added), pick(&removed));
2842 assert_eq!(
2843 fingerprint_at(&base, "src/b.ts", 4),
2844 fingerprint_at(&added, "src/b.ts", 4)
2845 );
2846 assert_eq!(
2847 fingerprint_at(&base, "src/c.ts", 7),
2848 fingerprint_at(&added, "src/c.ts", 7)
2849 );
2850 assert_eq!(
2851 fingerprint_at(&base, "src/c.ts", 7),
2852 fingerprint_at(&removed, "src/c.ts", 7)
2853 );
2854 assert_ne!(
2855 fingerprint_at(&added, "src/b.ts", 4),
2856 fingerprint_at(&added, "src/b.ts", 9),
2857 "two locations with the same content still get two fingerprints"
2858 );
2859 }
2860 }
2861
2862 #[test]
2865 fn the_same_dependency_in_two_workspaces_gets_two_fingerprints() {
2866 let root = PathBuf::from("/project");
2867 let mut results = AnalysisResults::default();
2868 results
2869 .unused_dependencies
2870 .push(dependency(root.join("packages/a/package.json"), 5));
2871 results
2872 .unused_dependencies
2873 .push(dependency(root.join("packages/b/package.json"), 5));
2874 stamp_dead_code_finding_ids(&mut results, &root);
2875
2876 let issues = build_codeclimate(&results, &root, &RulesConfig::default());
2877
2878 assert_eq!(issues.len(), 2);
2879 assert_ne!(issues[0].fingerprint, issues[1].fingerprint);
2880 }
2881
2882 #[test]
2885 fn a_finding_without_an_id_keeps_the_legacy_fingerprint() {
2886 let root = PathBuf::from("/project");
2887 let mut results = AnalysisResults::default();
2888 results.unused_exports.push(export(&root, "first", 3));
2889
2890 let issues = build_codeclimate(&results, &root, &RulesConfig::default());
2891
2892 assert_eq!(
2893 issues[0].fingerprint,
2894 codeclimate_fingerprint_hash(&["fallow/unused-export", "src/lib.ts", "3", "first"])
2895 );
2896 }
2897 }
2898}