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_unused_component_prop_issues(
1127 issues: &mut Vec<CodeClimateIssue>,
1128 findings: &[fallow_types::output_dead_code::UnusedComponentPropFinding],
1129 root: &Path,
1130 severity: Severity,
1131) {
1132 if findings.is_empty() {
1133 return;
1134 }
1135 for entry in findings {
1136 let level = finding_codeclimate(entry, severity);
1137 let p = &entry.prop;
1138 let path = cc_path(&p.path, root);
1139 let fp = codeclimate_fingerprint_hash(&[
1140 "fallow/unused-component-prop",
1141 &path,
1142 &p.line.to_string(),
1143 &p.prop_name,
1144 ]);
1145 let line = if p.line > 0 { Some(p.line) } else { None };
1146 let message = format!(
1147 "prop `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1148 p.prop_name, p.component_name
1149 );
1150 issues.push(dead_code_issue(
1151 entry.finding_id.as_deref(),
1152 &[],
1153 CodeClimateIssueInput {
1154 check_name: "fallow/unused-component-prop",
1155 description: &message,
1156 severity: level,
1157 category: "Bug Risk",
1158 path: &path,
1159 begin_line: line,
1160 fingerprint: &fp,
1161 },
1162 ));
1163 }
1164}
1165
1166fn push_unused_component_emit_issues(
1167 issues: &mut Vec<CodeClimateIssue>,
1168 findings: &[fallow_types::output_dead_code::UnusedComponentEmitFinding],
1169 root: &Path,
1170 severity: Severity,
1171) {
1172 if findings.is_empty() {
1173 return;
1174 }
1175 for entry in findings {
1176 let level = finding_codeclimate(entry, severity);
1177 let e = &entry.emit;
1178 let path = cc_path(&e.path, root);
1179 let fp = codeclimate_fingerprint_hash(&[
1180 "fallow/unused-component-emit",
1181 &path,
1182 &e.line.to_string(),
1183 &e.emit_name,
1184 ]);
1185 let line = if e.line > 0 { Some(e.line) } else { None };
1186 let message = format!(
1187 "emit `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1188 e.emit_name, e.component_name
1189 );
1190 issues.push(dead_code_issue(
1191 entry.finding_id.as_deref(),
1192 &[],
1193 CodeClimateIssueInput {
1194 check_name: "fallow/unused-component-emit",
1195 description: &message,
1196 severity: level,
1197 category: "Bug Risk",
1198 path: &path,
1199 begin_line: line,
1200 fingerprint: &fp,
1201 },
1202 ));
1203 }
1204}
1205
1206fn push_unused_svelte_event_issues(
1207 issues: &mut Vec<CodeClimateIssue>,
1208 findings: &[fallow_types::output_dead_code::UnusedSvelteEventFinding],
1209 root: &Path,
1210 severity: Severity,
1211) {
1212 if findings.is_empty() {
1213 return;
1214 }
1215 for entry in findings {
1216 let level = finding_codeclimate(entry, severity);
1217 let e = &entry.event;
1218 let path = cc_path(&e.path, root);
1219 let fp = codeclimate_fingerprint_hash(&[
1220 "fallow/unused-svelte-event",
1221 &path,
1222 &e.line.to_string(),
1223 &e.event_name,
1224 ]);
1225 let line = if e.line > 0 { Some(e.line) } else { None };
1226 let message = format!(
1227 "event `{}` is dispatched by component `{}` but listened to nowhere in the project (remove it or listen for it)",
1228 e.event_name, e.component_name
1229 );
1230 issues.push(dead_code_issue(
1231 entry.finding_id.as_deref(),
1232 &[],
1233 CodeClimateIssueInput {
1234 check_name: "fallow/unused-svelte-event",
1235 description: &message,
1236 severity: level,
1237 category: "Bug Risk",
1238 path: &path,
1239 begin_line: line,
1240 fingerprint: &fp,
1241 },
1242 ));
1243 }
1244}
1245
1246fn push_unused_component_input_issues(
1247 issues: &mut Vec<CodeClimateIssue>,
1248 findings: &[fallow_types::output_dead_code::UnusedComponentInputFinding],
1249 root: &Path,
1250 severity: Severity,
1251) {
1252 if findings.is_empty() {
1253 return;
1254 }
1255 for entry in findings {
1256 let level = finding_codeclimate(entry, severity);
1257 let i = &entry.input;
1258 let path = cc_path(&i.path, root);
1259 let fp = codeclimate_fingerprint_hash(&[
1260 "fallow/unused-component-input",
1261 &path,
1262 &i.line.to_string(),
1263 &i.input_name,
1264 ]);
1265 let line = if i.line > 0 { Some(i.line) } else { None };
1266 let message = format!(
1267 "input `{}` is declared but referenced nowhere in component `{}` (remove it or use it)",
1268 i.input_name, i.component_name
1269 );
1270 issues.push(dead_code_issue(
1271 entry.finding_id.as_deref(),
1272 &[],
1273 CodeClimateIssueInput {
1274 check_name: "fallow/unused-component-input",
1275 description: &message,
1276 severity: level,
1277 category: "Bug Risk",
1278 path: &path,
1279 begin_line: line,
1280 fingerprint: &fp,
1281 },
1282 ));
1283 }
1284}
1285
1286fn push_unused_component_output_issues(
1287 issues: &mut Vec<CodeClimateIssue>,
1288 findings: &[fallow_types::output_dead_code::UnusedComponentOutputFinding],
1289 root: &Path,
1290 severity: Severity,
1291) {
1292 if findings.is_empty() {
1293 return;
1294 }
1295 for entry in findings {
1296 let level = finding_codeclimate(entry, severity);
1297 let o = &entry.output;
1298 let path = cc_path(&o.path, root);
1299 let fp = codeclimate_fingerprint_hash(&[
1300 "fallow/unused-component-output",
1301 &path,
1302 &o.line.to_string(),
1303 &o.output_name,
1304 ]);
1305 let line = if o.line > 0 { Some(o.line) } else { None };
1306 let message = format!(
1307 "output `{}` is declared but emitted nowhere in component `{}` (remove it or emit it)",
1308 o.output_name, o.component_name
1309 );
1310 issues.push(dead_code_issue(
1311 entry.finding_id.as_deref(),
1312 &[],
1313 CodeClimateIssueInput {
1314 check_name: "fallow/unused-component-output",
1315 description: &message,
1316 severity: level,
1317 category: "Bug Risk",
1318 path: &path,
1319 begin_line: line,
1320 fingerprint: &fp,
1321 },
1322 ));
1323 }
1324}
1325
1326fn push_unused_server_action_issues(
1327 issues: &mut Vec<CodeClimateIssue>,
1328 findings: &[fallow_types::output_dead_code::UnusedServerActionFinding],
1329 root: &Path,
1330 severity: Severity,
1331) {
1332 if findings.is_empty() {
1333 return;
1334 }
1335 for entry in findings {
1336 let level = finding_codeclimate(entry, severity);
1337 let a = &entry.action;
1338 let path = cc_path(&a.path, root);
1339 let fp = codeclimate_fingerprint_hash(&[
1340 "fallow/unused-server-action",
1341 &path,
1342 &a.line.to_string(),
1343 &a.action_name,
1344 ]);
1345 let line = if a.line > 0 { Some(a.line) } else { None };
1346 let message = format!(
1347 "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)",
1348 a.action_name
1349 );
1350 issues.push(dead_code_issue(
1351 entry.finding_id.as_deref(),
1352 &[],
1353 CodeClimateIssueInput {
1354 check_name: "fallow/unused-server-action",
1355 description: &message,
1356 severity: level,
1357 category: "Bug Risk",
1358 path: &path,
1359 begin_line: line,
1360 fingerprint: &fp,
1361 },
1362 ));
1363 }
1364}
1365
1366fn push_unused_load_data_key_issues(
1367 issues: &mut Vec<CodeClimateIssue>,
1368 findings: &[fallow_types::output_dead_code::UnusedLoadDataKeyFinding],
1369 root: &Path,
1370 severity: Severity,
1371) {
1372 if findings.is_empty() {
1373 return;
1374 }
1375 for entry in findings {
1376 let level = finding_codeclimate(entry, severity);
1377 let k = &entry.key;
1378 let path = cc_path(&k.path, root);
1379 let fp = codeclimate_fingerprint_hash(&[
1380 "fallow/unused-load-data-key",
1381 &path,
1382 &k.line.to_string(),
1383 &k.key_name,
1384 ]);
1385 let line = if k.line > 0 { Some(k.line) } else { None };
1386 let message = format!(
1387 "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",
1388 k.key_name
1389 );
1390 issues.push(dead_code_issue(
1391 entry.finding_id.as_deref(),
1392 &[],
1393 CodeClimateIssueInput {
1394 check_name: "fallow/unused-load-data-key",
1395 description: &message,
1396 severity: level,
1397 category: "Bug Risk",
1398 path: &path,
1399 begin_line: line,
1400 fingerprint: &fp,
1401 },
1402 ));
1403 }
1404}
1405
1406fn push_route_collision_issues(
1407 issues: &mut Vec<CodeClimateIssue>,
1408 findings: &[fallow_types::output_dead_code::RouteCollisionFinding],
1409 root: &Path,
1410 severity: Severity,
1411) {
1412 if findings.is_empty() {
1413 return;
1414 }
1415 for entry in findings {
1416 let level = finding_codeclimate(entry, severity);
1417 let c = &entry.collision;
1418 let path = cc_path(&c.path, root);
1419 let fp = codeclimate_fingerprint_hash(&["fallow/route-collision", &path, &c.url]);
1420 let line = if c.line > 0 { Some(c.line) } else { None };
1421 let message = format!(
1422 "Route file resolves to `{}`, also owned by {} other file(s); Next.js fails the build because a URL can have only one owner",
1423 c.url,
1424 c.conflicting_paths.len()
1425 );
1426 issues.push(dead_code_issue(
1427 entry.finding_id.as_deref(),
1428 &[],
1429 CodeClimateIssueInput {
1430 check_name: "fallow/route-collision",
1431 description: &message,
1432 severity: level,
1433 category: "Bug Risk",
1434 path: &path,
1435 begin_line: line,
1436 fingerprint: &fp,
1437 },
1438 ));
1439 }
1440}
1441
1442fn push_dynamic_segment_name_conflict_issues(
1443 issues: &mut Vec<CodeClimateIssue>,
1444 findings: &[fallow_types::output_dead_code::DynamicSegmentNameConflictFinding],
1445 root: &Path,
1446 severity: Severity,
1447) {
1448 if findings.is_empty() {
1449 return;
1450 }
1451 for entry in findings {
1452 let level = finding_codeclimate(entry, severity);
1453 let c = &entry.conflict;
1454 let path = cc_path(&c.path, root);
1455 let fp = codeclimate_fingerprint_hash(&[
1456 "fallow/dynamic-segment-name-conflict",
1457 &path,
1458 &c.position,
1459 ]);
1460 let line = if c.line > 0 { Some(c.line) } else { None };
1461 let message = format!(
1462 "Dynamic segments at `{}` use different slug names ({}); Next.js requires one consistent name per dynamic path",
1463 c.position,
1464 c.conflicting_segments.join(", ")
1465 );
1466 issues.push(dead_code_issue(
1467 entry.finding_id.as_deref(),
1468 &[],
1469 CodeClimateIssueInput {
1470 check_name: "fallow/dynamic-segment-name-conflict",
1471 description: &message,
1472 severity: level,
1473 category: "Bug Risk",
1474 path: &path,
1475 begin_line: line,
1476 fingerprint: &fp,
1477 },
1478 ));
1479 }
1480}
1481
1482fn push_stale_suppression_issues(
1483 issues: &mut Vec<CodeClimateIssue>,
1484 suppressions: &[fallow_types::results::StaleSuppression],
1485 root: &Path,
1486 rules: &RulesConfig,
1487) {
1488 if suppressions.is_empty() {
1489 return;
1490 }
1491 for s in suppressions {
1492 let severity = if s.missing_reason {
1493 rules.require_suppression_reason
1494 } else {
1495 rules.stale_suppressions
1496 };
1497 let level = finding_codeclimate(s, severity);
1498 let path = cc_path(&s.path, root);
1499 let line_str = s.line.to_string();
1500 let check_name = if s.missing_reason {
1501 "fallow/missing-suppression-reason"
1502 } else {
1503 "fallow/stale-suppression"
1504 };
1505 let fp = codeclimate_fingerprint_hash(&[check_name, &path, &line_str]);
1506 issues.push(dead_code_issue(
1507 s.finding_id.as_deref(),
1508 &[],
1509 CodeClimateIssueInput {
1510 check_name,
1511 description: &s.display_message(),
1512 severity: level,
1513 category: "Bug Risk",
1514 path: &path,
1515 begin_line: Some(s.line),
1516 fingerprint: &fp,
1517 },
1518 ));
1519 }
1520}
1521
1522fn push_unused_catalog_entry_issues(
1523 issues: &mut Vec<CodeClimateIssue>,
1524 entries: &[fallow_types::output_dead_code::UnusedCatalogEntryFinding],
1525 root: &Path,
1526 severity: Severity,
1527) {
1528 if entries.is_empty() {
1529 return;
1530 }
1531 for entry in entries {
1532 let level = finding_codeclimate(entry, severity);
1533 let finding_id = entry.finding_id.as_deref();
1534 let entry = &entry.entry;
1535 let path = cc_path(&entry.path, root);
1536 let line_str = entry.line.to_string();
1537 let fp = codeclimate_fingerprint_hash(&[
1538 "fallow/unused-catalog-entry",
1539 &path,
1540 &line_str,
1541 &entry.catalog_name,
1542 &entry.entry_name,
1543 ]);
1544 let description = if entry.catalog_name == "default" {
1545 format!(
1546 "Catalog entry '{}' is not referenced by any workspace package",
1547 entry.entry_name
1548 )
1549 } else {
1550 format!(
1551 "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
1552 entry.entry_name, entry.catalog_name
1553 )
1554 };
1555 issues.push(dead_code_issue(
1556 finding_id,
1557 &[],
1558 CodeClimateIssueInput {
1559 check_name: "fallow/unused-catalog-entry",
1560 description: &description,
1561 severity: level,
1562 category: "Bug Risk",
1563 path: &path,
1564 begin_line: Some(entry.line),
1565 fingerprint: &fp,
1566 },
1567 ));
1568 }
1569}
1570
1571fn push_unresolved_catalog_reference_issues(
1572 issues: &mut Vec<CodeClimateIssue>,
1573 findings: &[fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding],
1574 root: &Path,
1575 severity: Severity,
1576) {
1577 if findings.is_empty() {
1578 return;
1579 }
1580 for finding in findings {
1581 let level = finding_codeclimate(finding, severity);
1582 let finding_id = finding.finding_id.as_deref();
1583 let finding = &finding.reference;
1584 let path = cc_path(&finding.path, root);
1585 let line_str = finding.line.to_string();
1586 let fp = codeclimate_fingerprint_hash(&[
1587 "fallow/unresolved-catalog-reference",
1588 &path,
1589 &line_str,
1590 &finding.catalog_name,
1591 &finding.entry_name,
1592 ]);
1593 let catalog_phrase = if finding.catalog_name == "default" {
1594 "the default catalog".to_string()
1595 } else {
1596 format!("catalog '{}'", finding.catalog_name)
1597 };
1598 let mut description = format!(
1599 "Package '{}' is referenced via `catalog:{}` but {} does not declare it; `pnpm install` will fail",
1600 finding.entry_name,
1601 if finding.catalog_name == "default" {
1602 ""
1603 } else {
1604 finding.catalog_name.as_str()
1605 },
1606 catalog_phrase,
1607 );
1608 if !finding.available_in_catalogs.is_empty() {
1609 use std::fmt::Write as _;
1610 let _ = write!(
1611 description,
1612 " (available in: {})",
1613 finding.available_in_catalogs.join(", ")
1614 );
1615 }
1616 issues.push(dead_code_issue(
1617 finding_id,
1618 &[],
1619 CodeClimateIssueInput {
1620 check_name: "fallow/unresolved-catalog-reference",
1621 description: &description,
1622 severity: level,
1623 category: "Bug Risk",
1624 path: &path,
1625 begin_line: Some(finding.line),
1626 fingerprint: &fp,
1627 },
1628 ));
1629 }
1630}
1631
1632fn push_empty_catalog_group_issues(
1633 issues: &mut Vec<CodeClimateIssue>,
1634 groups: &[fallow_types::output_dead_code::EmptyCatalogGroupFinding],
1635 root: &Path,
1636 severity: Severity,
1637) {
1638 if groups.is_empty() {
1639 return;
1640 }
1641 for group in groups {
1642 let level = finding_codeclimate(group, severity);
1643 let finding_id = group.finding_id.as_deref();
1644 let group = &group.group;
1645 let path = cc_path(&group.path, root);
1646 let line_str = group.line.to_string();
1647 let fp = codeclimate_fingerprint_hash(&[
1648 "fallow/empty-catalog-group",
1649 &path,
1650 &line_str,
1651 &group.catalog_name,
1652 ]);
1653 issues.push(dead_code_issue(
1654 finding_id,
1655 &[],
1656 CodeClimateIssueInput {
1657 check_name: "fallow/empty-catalog-group",
1658 description: &format!("Catalog group '{}' has no entries", group.catalog_name),
1659 severity: level,
1660 category: "Bug Risk",
1661 path: &path,
1662 begin_line: Some(group.line),
1663 fingerprint: &fp,
1664 },
1665 ));
1666 }
1667}
1668
1669fn push_unused_dependency_override_issues(
1670 issues: &mut Vec<CodeClimateIssue>,
1671 findings: &[fallow_types::output_dead_code::UnusedDependencyOverrideFinding],
1672 root: &Path,
1673 severity: Severity,
1674) {
1675 if findings.is_empty() {
1676 return;
1677 }
1678 for finding in findings {
1679 let level = finding_codeclimate(finding, severity);
1680 let finding_id = finding.finding_id.as_deref();
1681 let finding = &finding.entry;
1682 let path = cc_path(&finding.path, root);
1683 let line_str = finding.line.to_string();
1684 let fp = codeclimate_fingerprint_hash(&[
1685 "fallow/unused-dependency-override",
1686 &path,
1687 &line_str,
1688 finding.source.as_label(),
1689 &finding.raw_key,
1690 ]);
1691 let mut description = format!(
1692 "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in the lockfile",
1693 finding.raw_key, finding.version_range, finding.target_package,
1694 );
1695 if let Some(hint) = &finding.hint {
1696 use std::fmt::Write as _;
1697 let _ = write!(description, " ({hint})");
1698 }
1699 issues.push(dead_code_issue(
1700 finding_id,
1701 &[],
1702 CodeClimateIssueInput {
1703 check_name: "fallow/unused-dependency-override",
1704 description: &description,
1705 severity: level,
1706 category: "Bug Risk",
1707 path: &path,
1708 begin_line: Some(finding.line),
1709 fingerprint: &fp,
1710 },
1711 ));
1712 }
1713}
1714
1715fn push_misconfigured_dependency_override_issues(
1716 issues: &mut Vec<CodeClimateIssue>,
1717 findings: &[fallow_types::output_dead_code::MisconfiguredDependencyOverrideFinding],
1718 root: &Path,
1719 severity: Severity,
1720) {
1721 if findings.is_empty() {
1722 return;
1723 }
1724 for finding in findings {
1725 let level = finding_codeclimate(finding, severity);
1726 let finding_id = finding.finding_id.as_deref();
1727 let finding = &finding.entry;
1728 let path = cc_path(&finding.path, root);
1729 let line_str = finding.line.to_string();
1730 let fp = codeclimate_fingerprint_hash(&[
1731 "fallow/misconfigured-dependency-override",
1732 &path,
1733 &line_str,
1734 finding.source.as_label(),
1735 &finding.raw_key,
1736 ]);
1737 let description = format!(
1738 "Override `{}` -> `{}` is malformed: {}",
1739 finding.raw_key,
1740 finding.raw_value,
1741 finding.reason.describe(),
1742 );
1743 issues.push(dead_code_issue(
1744 finding_id,
1745 &[],
1746 CodeClimateIssueInput {
1747 check_name: "fallow/misconfigured-dependency-override",
1748 description: &description,
1749 severity: level,
1750 category: "Bug Risk",
1751 path: &path,
1752 begin_line: Some(finding.line),
1753 fingerprint: &fp,
1754 },
1755 ));
1756 }
1757}
1758
1759#[must_use]
1766pub fn build_codeclimate(
1767 results: &AnalysisResults,
1768 root: &Path,
1769 rules: &RulesConfig,
1770) -> Vec<CodeClimateIssue> {
1771 CodeClimateBuilder {
1772 issues: Vec::new(),
1773 results,
1774 root,
1775 rules,
1776 }
1777 .build()
1778}
1779
1780struct CodeClimateBuilder<'a> {
1781 issues: Vec<CodeClimateIssue>,
1782 results: &'a AnalysisResults,
1783 root: &'a Path,
1784 rules: &'a RulesConfig,
1785}
1786
1787impl CodeClimateBuilder<'_> {
1788 fn build(mut self) -> Vec<CodeClimateIssue> {
1789 self.push_file_and_export_issues();
1790 self.push_private_type_leak_issues();
1791 push_deprecated_export_issues(
1792 &mut self.issues,
1793 &self.results.deprecated_exports_in_use,
1794 self.root,
1795 self.rules.deprecated_exports_in_use,
1796 );
1797 self.push_package_dependency_issues();
1798 self.push_type_test_dependency_issues();
1799 self.push_member_issues();
1800 self.push_import_and_duplicate_issues();
1801 self.push_graph_issues();
1802 self.push_boundary_issues();
1803 self.push_suppression_and_catalog_issues();
1804 self.push_override_issues();
1805 self.issues
1806 }
1807
1808 fn push_file_and_export_issues(&mut self) {
1809 push_unused_file_issues(
1810 &mut self.issues,
1811 &self.results.unused_files,
1812 self.root,
1813 self.rules.unused_files,
1814 );
1815 push_unused_export_issues(UnusedExportIssuesInput {
1816 issues: &mut self.issues,
1817 exports: self.results.unused_exports.iter().map(|e| {
1818 (
1819 &e.export,
1820 e.reachability_caveats.as_slice(),
1821 e.effective_severity,
1822 e.finding_id.as_deref(),
1823 )
1824 }),
1825 root: self.root,
1826 rule_id: "fallow/unused-export",
1827 direct_label: "Export",
1828 re_export_label: "Re-export",
1829 severity: self.rules.unused_exports,
1830 });
1831 push_unused_export_issues(UnusedExportIssuesInput {
1832 issues: &mut self.issues,
1833 exports: self.results.unused_types.iter().map(|e| {
1834 (
1835 &e.export,
1836 e.reachability_caveats.as_slice(),
1837 e.effective_severity,
1838 e.finding_id.as_deref(),
1839 )
1840 }),
1841 root: self.root,
1842 rule_id: "fallow/unused-type",
1843 direct_label: "Type export",
1844 re_export_label: "Type re-export",
1845 severity: self.rules.unused_types,
1846 });
1847 }
1848
1849 fn push_private_type_leak_issues(&mut self) {
1850 push_private_type_leak_issues(
1851 &mut self.issues,
1852 &self.results.private_type_leaks,
1853 self.root,
1854 self.rules.private_type_leaks,
1855 );
1856 }
1857
1858 fn push_package_dependency_issues(&mut self) {
1859 push_dep_cc_issues(
1860 &mut self.issues,
1861 self.results.unused_dependencies.iter().map(|f| {
1862 (
1863 &f.dep,
1864 f.reachability_caveats.as_slice(),
1865 f.effective_severity,
1866 f.finding_id.as_deref(),
1867 )
1868 }),
1869 self.root,
1870 "fallow/unused-dependency",
1871 "dependencies",
1872 self.rules.unused_dependencies,
1873 );
1874 push_dep_cc_issues(
1875 &mut self.issues,
1876 self.results.unused_dev_dependencies.iter().map(|f| {
1877 (
1878 &f.dep,
1879 f.reachability_caveats.as_slice(),
1880 f.effective_severity,
1881 f.finding_id.as_deref(),
1882 )
1883 }),
1884 self.root,
1885 "fallow/unused-dev-dependency",
1886 "devDependencies",
1887 self.rules.unused_dev_dependencies,
1888 );
1889 push_dep_cc_issues(
1890 &mut self.issues,
1891 self.results.unused_optional_dependencies.iter().map(|f| {
1892 (
1893 &f.dep,
1894 f.reachability_caveats.as_slice(),
1895 f.effective_severity,
1896 f.finding_id.as_deref(),
1897 )
1898 }),
1899 self.root,
1900 "fallow/unused-optional-dependency",
1901 "optionalDependencies",
1902 self.rules.unused_optional_dependencies,
1903 );
1904 }
1905
1906 fn push_type_test_dependency_issues(&mut self) {
1907 push_type_only_dep_issues(
1908 &mut self.issues,
1909 &self.results.type_only_dependencies,
1910 self.root,
1911 self.rules.type_only_dependencies,
1912 );
1913 push_test_only_dep_issues(
1914 &mut self.issues,
1915 &self.results.test_only_dependencies,
1916 self.root,
1917 self.rules.test_only_dependencies,
1918 );
1919 push_dev_dep_in_prod_issues(
1920 &mut self.issues,
1921 &self.results.dev_dependencies_in_production,
1922 self.root,
1923 self.rules.dev_dependencies_in_production,
1924 );
1925 }
1926
1927 fn push_member_issues(&mut self) {
1928 push_unused_member_issues(
1929 &mut self.issues,
1930 self.results.unused_enum_members.iter().map(|m| {
1931 (
1932 &m.member,
1933 m.reachability_caveats.as_slice(),
1934 m.effective_severity,
1935 m.finding_id.as_deref(),
1936 )
1937 }),
1938 self.root,
1939 "fallow/unused-enum-member",
1940 "Enum",
1941 self.rules.unused_enum_members,
1942 );
1943 push_unused_member_issues(
1944 &mut self.issues,
1945 self.results.unused_class_members.iter().map(|m| {
1946 (
1947 &m.member,
1948 m.reachability_caveats.as_slice(),
1949 m.effective_severity,
1950 m.finding_id.as_deref(),
1951 )
1952 }),
1953 self.root,
1954 "fallow/unused-class-member",
1955 "Class",
1956 self.rules.unused_class_members,
1957 );
1958 push_unused_member_issues(
1959 &mut self.issues,
1960 self.results.unused_store_members.iter().map(|m| {
1961 (
1962 &m.member,
1963 m.reachability_caveats.as_slice(),
1964 m.effective_severity,
1965 m.finding_id.as_deref(),
1966 )
1967 }),
1968 self.root,
1969 "fallow/unused-store-member",
1970 "Store",
1971 self.rules.unused_store_members,
1972 );
1973 }
1974
1975 fn push_import_and_duplicate_issues(&mut self) {
1976 push_unresolved_import_issues(
1977 &mut self.issues,
1978 &self.results.unresolved_imports,
1979 self.root,
1980 self.rules.unresolved_imports,
1981 );
1982 push_unlisted_dep_issues(
1983 &mut self.issues,
1984 &self.results.unlisted_dependencies,
1985 self.root,
1986 self.rules.unlisted_dependencies,
1987 );
1988 push_duplicate_export_issues(
1989 &mut self.issues,
1990 &self.results.duplicate_exports,
1991 self.root,
1992 self.rules.duplicate_exports,
1993 );
1994 }
1995
1996 fn push_graph_issues(&mut self) {
1997 push_circular_dep_issues(
1998 &mut self.issues,
1999 &self.results.circular_dependencies,
2000 self.root,
2001 self.rules.circular_dependencies,
2002 );
2003 push_re_export_cycle_issues(
2004 &mut self.issues,
2005 &self.results.re_export_cycles,
2006 self.root,
2007 self.rules.re_export_cycle,
2008 );
2009 push_package_cycle_issues(
2010 &mut self.issues,
2011 &self.results.package_cycles,
2012 self.root,
2013 self.rules.package_cycle,
2014 );
2015 }
2016
2017 fn push_boundary_issues(&mut self) {
2018 self.push_architecture_boundary_issues();
2019 self.push_client_server_boundary_issues();
2020 self.push_component_boundary_issues();
2021 self.push_framework_route_issues();
2022 }
2023
2024 fn push_architecture_boundary_issues(&mut self) {
2025 push_boundary_violation_issues(
2026 &mut self.issues,
2027 &self.results.boundary_violations,
2028 self.root,
2029 self.rules.boundary_violation,
2030 );
2031 push_boundary_coverage_issues(
2032 &mut self.issues,
2033 &self.results.boundary_coverage_violations,
2034 self.root,
2035 self.rules.boundary_violation,
2036 );
2037 push_boundary_call_issues(
2038 &mut self.issues,
2039 &self.results.boundary_call_violations,
2040 self.root,
2041 self.rules.boundary_violation,
2042 );
2043 push_policy_violation_issues(&mut self.issues, &self.results.policy_violations, self.root);
2044 }
2045
2046 fn push_client_server_boundary_issues(&mut self) {
2047 push_invalid_client_export_issues(
2048 &mut self.issues,
2049 &self.results.invalid_client_exports,
2050 self.root,
2051 self.rules.invalid_client_export,
2052 );
2053 push_mixed_client_server_barrel_issues(
2054 &mut self.issues,
2055 &self.results.mixed_client_server_barrels,
2056 self.root,
2057 self.rules.mixed_client_server_barrel,
2058 );
2059 push_misplaced_directive_issues(
2060 &mut self.issues,
2061 &self.results.misplaced_directives,
2062 self.root,
2063 self.rules.misplaced_directive,
2064 );
2065 }
2066
2067 fn push_component_boundary_issues(&mut self) {
2068 push_unprovided_inject_issues(
2069 &mut self.issues,
2070 &self.results.unprovided_injects,
2071 self.root,
2072 self.rules.unprovided_injects,
2073 );
2074 push_unrendered_component_issues(
2075 &mut self.issues,
2076 &self.results.unrendered_components,
2077 self.root,
2078 self.rules.unrendered_components,
2079 );
2080 push_unused_component_prop_issues(
2081 &mut self.issues,
2082 &self.results.unused_component_props,
2083 self.root,
2084 self.rules.unused_component_props,
2085 );
2086 push_unused_component_emit_issues(
2087 &mut self.issues,
2088 &self.results.unused_component_emits,
2089 self.root,
2090 self.rules.unused_component_emits,
2091 );
2092 push_unused_component_input_issues(
2093 &mut self.issues,
2094 &self.results.unused_component_inputs,
2095 self.root,
2096 self.rules.unused_component_inputs,
2097 );
2098 push_unused_component_output_issues(
2099 &mut self.issues,
2100 &self.results.unused_component_outputs,
2101 self.root,
2102 self.rules.unused_component_outputs,
2103 );
2104 push_unused_svelte_event_issues(
2105 &mut self.issues,
2106 &self.results.unused_svelte_events,
2107 self.root,
2108 self.rules.unused_svelte_events,
2109 );
2110 }
2111
2112 fn push_framework_route_issues(&mut self) {
2113 push_unused_server_action_issues(
2114 &mut self.issues,
2115 &self.results.unused_server_actions,
2116 self.root,
2117 self.rules.unused_server_actions,
2118 );
2119 push_unused_load_data_key_issues(
2120 &mut self.issues,
2121 &self.results.unused_load_data_keys,
2122 self.root,
2123 self.rules.unused_load_data_keys,
2124 );
2125 push_route_collision_issues(
2126 &mut self.issues,
2127 &self.results.route_collisions,
2128 self.root,
2129 self.rules.route_collision,
2130 );
2131 push_dynamic_segment_name_conflict_issues(
2132 &mut self.issues,
2133 &self.results.dynamic_segment_name_conflicts,
2134 self.root,
2135 self.rules.dynamic_segment_name_conflict,
2136 );
2137 }
2138
2139 fn push_suppression_and_catalog_issues(&mut self) {
2140 push_stale_suppression_issues(
2141 &mut self.issues,
2142 &self.results.stale_suppressions,
2143 self.root,
2144 self.rules,
2145 );
2146 push_unused_catalog_entry_issues(
2147 &mut self.issues,
2148 &self.results.unused_catalog_entries,
2149 self.root,
2150 self.rules.unused_catalog_entries,
2151 );
2152 push_empty_catalog_group_issues(
2153 &mut self.issues,
2154 &self.results.empty_catalog_groups,
2155 self.root,
2156 self.rules.empty_catalog_groups,
2157 );
2158 push_unresolved_catalog_reference_issues(
2159 &mut self.issues,
2160 &self.results.unresolved_catalog_references,
2161 self.root,
2162 self.rules.unresolved_catalog_references,
2163 );
2164 }
2165
2166 fn push_override_issues(&mut self) {
2167 push_unused_dependency_override_issues(
2168 &mut self.issues,
2169 &self.results.unused_dependency_overrides,
2170 self.root,
2171 self.rules.unused_dependency_overrides,
2172 );
2173 push_misconfigured_dependency_override_issues(
2174 &mut self.issues,
2175 &self.results.misconfigured_dependency_overrides,
2176 self.root,
2177 self.rules.misconfigured_dependency_overrides,
2178 );
2179 }
2180}
2181
2182#[cfg(test)]
2183mod tests {
2184 use std::collections::BTreeSet;
2185
2186 use fallow_output::issue_output_contracts;
2187
2188 fn codeclimate_check_name_literals() -> BTreeSet<String> {
2189 let source = include_str!("dead_code_codeclimate.rs")
2190 .split("#[cfg(test)]")
2191 .next()
2192 .expect("source before tests");
2193 let mut literals = BTreeSet::new();
2194 let mut rest = source;
2195 while let Some(start) = rest.find("\"fallow/") {
2196 let after_quote = &rest[start + 1..];
2197 let Some(end) = after_quote.find('"') else {
2198 break;
2199 };
2200 literals.insert(after_quote[..end].to_owned());
2201 rest = &after_quote[end + 1..];
2202 }
2203 literals
2204 }
2205
2206 #[test]
2207 fn codeclimate_check_names_match_issue_contracts() {
2208 let from_emitter = codeclimate_check_name_literals();
2209 let from_contracts = issue_output_contracts()
2210 .flat_map(|contract| contract.codeclimate_check_names)
2211 .collect::<BTreeSet<_>>();
2212
2213 assert_eq!(from_emitter, from_contracts);
2214 }
2215
2216 mod caveats {
2217 use std::path::{Path, PathBuf};
2218
2219 use fallow_config::RulesConfig;
2220 use fallow_types::extract::MemberKind;
2221 use fallow_types::output_dead_code::{
2222 ReachabilityCaveat, UnusedClassMemberFinding, UnusedDependencyFinding,
2223 UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding,
2224 UnusedStoreMemberFinding,
2225 };
2226 use fallow_types::results::{
2227 AnalysisResults, DependencyLocation, UnusedDependency, UnusedExport, UnusedFile,
2228 UnusedMember,
2229 };
2230
2231 use crate::dead_code_codeclimate::build_codeclimate;
2232
2233 fn results_with(root: &Path, caveated: bool) -> AnalysisResults {
2235 let caveats = if caveated {
2236 vec![ReachabilityCaveat::IncompleteImportGraph]
2237 } else {
2238 Vec::new()
2239 };
2240 let mut results = AnalysisResults::default();
2241
2242 let mut file = UnusedFileFinding::with_actions(UnusedFile {
2243 path: root.join("src/lib.ts"),
2244 });
2245 file.reachability_caveats.clone_from(&caveats);
2246 results.unused_files.push(file);
2247
2248 let mut export = UnusedExportFinding::with_actions(UnusedExport {
2249 path: root.join("src/lib.ts"),
2250 export_name: "needed".to_owned(),
2251 is_type_only: false,
2252 line: 3,
2253 col: 0,
2254 span_start: 0,
2255 is_re_export: false,
2256 deprecated: false,
2257 deprecated_reason: None,
2258 });
2259 export.reachability_caveats.clone_from(&caveats);
2260 results.unused_exports.push(export);
2261
2262 let mut dep = UnusedDependencyFinding::with_actions(UnusedDependency {
2263 package_name: "left-pad".to_owned(),
2264 location: DependencyLocation::Dependencies,
2265 path: root.join("package.json"),
2266 line: 5,
2267 used_in_workspaces: Vec::new(),
2268 });
2269 dep.reachability_caveats.clone_from(&caveats);
2270 results.unused_dependencies.push(dep);
2271
2272 let member = |parent: &str, name: &str, kind| UnusedMember {
2273 path: root.join("src/lib.ts"),
2274 parent_name: parent.to_owned(),
2275 member_name: name.to_owned(),
2276 kind,
2277 line: 7,
2278 col: 2,
2279 };
2280
2281 let mut enum_member = UnusedEnumMemberFinding::with_actions(member(
2282 "Mode",
2283 "Legacy",
2284 MemberKind::EnumMember,
2285 ));
2286 enum_member.reachability_caveats.clone_from(&caveats);
2287 results.unused_enum_members.push(enum_member);
2288
2289 let mut class_member = UnusedClassMemberFinding::with_actions(member(
2290 "Widget",
2291 "render",
2292 MemberKind::ClassMethod,
2293 ));
2294 class_member.reachability_caveats.clone_from(&caveats);
2295 results.unused_class_members.push(class_member);
2296
2297 let mut store_member = UnusedStoreMemberFinding::with_actions(member(
2298 "useCart",
2299 "subtotal",
2300 MemberKind::StoreMember,
2301 ));
2302 store_member.reachability_caveats.clone_from(&caveats);
2303 results.unused_store_members.push(store_member);
2304
2305 results
2306 }
2307
2308 #[test]
2313 fn descriptions_name_the_caveat() {
2314 let root = PathBuf::from("/project");
2315
2316 let issues =
2317 build_codeclimate(&results_with(&root, true), &root, &RulesConfig::default());
2318
2319 let descriptions: Vec<&str> = issues
2320 .iter()
2321 .map(|issue| issue.description.as_str())
2322 .collect();
2323 assert!(
2324 descriptions
2325 .iter()
2326 .all(|description| description.ends_with(" (caveat: incomplete import graph)")),
2327 "every caveated finding hedges its description: {descriptions:?}"
2328 );
2329 assert!(
2330 descriptions.contains(
2331 &"File is not reachable from any entry point (caveat: incomplete import graph)"
2332 ),
2333 "{descriptions:?}"
2334 );
2335 }
2336
2337 #[test]
2341 fn a_clean_run_carries_no_caveat_text() {
2342 let root = PathBuf::from("/project");
2343
2344 let issues =
2345 build_codeclimate(&results_with(&root, false), &root, &RulesConfig::default());
2346
2347 assert!(
2348 issues
2349 .iter()
2350 .all(|issue| !issue.description.contains("caveat")),
2351 "{:?}",
2352 issues
2353 .iter()
2354 .map(|issue| issue.description.as_str())
2355 .collect::<Vec<_>>()
2356 );
2357 }
2358
2359 #[test]
2363 fn the_caveat_does_not_move_the_fingerprint() {
2364 let root = PathBuf::from("/project");
2365
2366 let clean =
2367 build_codeclimate(&results_with(&root, false), &root, &RulesConfig::default());
2368 let caveated =
2369 build_codeclimate(&results_with(&root, true), &root, &RulesConfig::default());
2370
2371 let fingerprints = |issues: &[fallow_output::CodeClimateIssue]| {
2372 issues
2373 .iter()
2374 .map(|issue| issue.fingerprint.clone())
2375 .collect::<Vec<_>>()
2376 };
2377 assert_eq!(fingerprints(&clean), fingerprints(&caveated));
2378 assert_ne!(
2379 clean[0].description, caveated[0].description,
2380 "the guard is only meaningful while the description actually changed"
2381 );
2382 }
2383 }
2384
2385 mod stable_fingerprints {
2386 use std::path::{Path, PathBuf};
2387
2388 use fallow_config::RulesConfig;
2389 use fallow_output::{CodeClimateIssue, codeclimate_fingerprint_hash};
2390 use fallow_types::identity::stamp_dead_code_finding_ids;
2391 use fallow_types::output_dead_code::{
2392 DuplicateExportFinding, UnlistedDependencyFinding, UnusedDependencyFinding,
2393 UnusedExportFinding,
2394 };
2395 use fallow_types::results::{
2396 AnalysisResults, DependencyLocation, DuplicateExport, DuplicateLocation, ImportSite,
2397 UnlistedDependency, UnusedDependency, UnusedExport,
2398 };
2399
2400 use crate::dead_code_codeclimate::build_codeclimate;
2401
2402 fn export(root: &Path, name: &str, line: u32) -> UnusedExportFinding {
2403 UnusedExportFinding::with_actions(UnusedExport {
2404 path: root.join("src/lib.ts"),
2405 export_name: name.to_owned(),
2406 is_type_only: false,
2407 line,
2408 col: 0,
2409 span_start: 0,
2410 is_re_export: false,
2411 deprecated: false,
2412 deprecated_reason: None,
2413 })
2414 }
2415
2416 fn dependency(manifest: PathBuf, line: u32) -> UnusedDependencyFinding {
2417 UnusedDependencyFinding::with_actions(UnusedDependency {
2418 package_name: "left-pad".to_owned(),
2419 location: DependencyLocation::Dependencies,
2420 path: manifest,
2421 line,
2422 used_in_workspaces: Vec::new(),
2423 })
2424 }
2425
2426 fn results(root: &Path, shift: u32) -> AnalysisResults {
2429 let mut results = AnalysisResults::default();
2430 results
2431 .unused_exports
2432 .push(export(root, "first", 3 + shift));
2433 results
2434 .unused_exports
2435 .push(export(root, "second", 9 + shift));
2436 results
2437 .unused_dependencies
2438 .push(dependency(root.join("package.json"), 5 + shift));
2439 results
2440 .unlisted_dependencies
2441 .push(UnlistedDependencyFinding::with_actions(
2442 UnlistedDependency {
2443 package_name: "chalk".to_owned(),
2444 imported_from: vec![
2445 ImportSite {
2446 path: root.join("src/cli.ts"),
2447 line: 2 + shift,
2448 col: 0,
2449 },
2450 ImportSite {
2451 path: root.join("src/cli.ts"),
2452 line: 8 + shift,
2453 col: 0,
2454 },
2455 ],
2456 },
2457 ));
2458 results
2459 .duplicate_exports
2460 .push(DuplicateExportFinding::with_actions(DuplicateExport {
2461 export_name: "Config".to_owned(),
2462 locations: vec![
2463 DuplicateLocation {
2464 path: root.join("src/a.ts"),
2465 line: 4 + shift,
2466 col: 0,
2467 },
2468 DuplicateLocation {
2469 path: root.join("src/b.ts"),
2470 line: 6 + shift,
2471 col: 0,
2472 },
2473 ],
2474 }));
2475 stamp_dead_code_finding_ids(&mut results, root);
2476 results
2477 }
2478
2479 fn fingerprints(issues: &[CodeClimateIssue]) -> Vec<String> {
2480 issues
2481 .iter()
2482 .map(|issue| issue.fingerprint.clone())
2483 .collect()
2484 }
2485
2486 #[test]
2489 fn a_line_shift_keeps_every_dead_code_fingerprint() {
2490 let root = PathBuf::from("/project");
2491 let rules = RulesConfig::default();
2492
2493 let before = build_codeclimate(&results(&root, 0), &root, &rules);
2494 let after = build_codeclimate(&results(&root, 40), &root, &rules);
2495
2496 assert_eq!(before.len(), 7);
2497 assert_eq!(fingerprints(&before), fingerprints(&after));
2498 assert_ne!(
2499 before[0].location.lines.begin, after[0].location.lines.begin,
2500 "the guard is only meaningful while the lines actually moved"
2501 );
2502 let mut unique = fingerprints(&before);
2503 unique.sort();
2504 unique.dedup();
2505 assert_eq!(
2506 unique.len(),
2507 before.len(),
2508 "every issue keeps its own fingerprint"
2509 );
2510 assert_eq!(
2511 before[0].legacy_fingerprint.as_deref(),
2512 Some(
2513 codeclimate_fingerprint_hash(&[
2514 "fallow/unused-export",
2515 "src/lib.ts",
2516 "3",
2517 "first"
2518 ])
2519 .as_str()
2520 ),
2521 "the line-based value stays available to match older review threads"
2522 );
2523 }
2524
2525 fn site(root: &Path, file: &str, line: u32) -> ImportSite {
2526 ImportSite {
2527 path: root.join(file),
2528 line,
2529 col: 0,
2530 }
2531 }
2532
2533 fn location(root: &Path, file: &str, line: u32) -> DuplicateLocation {
2534 DuplicateLocation {
2535 path: root.join(file),
2536 line,
2537 col: 0,
2538 }
2539 }
2540
2541 fn per_location(
2544 root: &Path,
2545 sites: Vec<ImportSite>,
2546 locations: Vec<DuplicateLocation>,
2547 ) -> Vec<(String, u32, String)> {
2548 let mut results = AnalysisResults::default();
2549 results
2550 .unlisted_dependencies
2551 .push(UnlistedDependencyFinding::with_actions(
2552 UnlistedDependency {
2553 package_name: "chalk".to_owned(),
2554 imported_from: sites,
2555 },
2556 ));
2557 results
2558 .duplicate_exports
2559 .push(DuplicateExportFinding::with_actions(DuplicateExport {
2560 export_name: "Config".to_owned(),
2561 locations,
2562 }));
2563 stamp_dead_code_finding_ids(&mut results, root);
2564 build_codeclimate(&results, root, &RulesConfig::default())
2565 .into_iter()
2566 .map(|issue| {
2567 (
2568 format!("{} {}", issue.check_name, issue.location.path),
2569 issue.location.lines.begin,
2570 issue.fingerprint,
2571 )
2572 })
2573 .collect()
2574 }
2575
2576 fn fingerprint_at(issues: &[(String, u32, String)], path: &str, line: u32) -> String {
2577 issues
2578 .iter()
2579 .find(|(p, l, _)| p == path && *l == line)
2580 .map_or_else(
2581 || panic!("no issue at {path}:{line}: {issues:?}"),
2582 |(_, _, fingerprint)| fingerprint.clone(),
2583 )
2584 }
2585
2586 #[test]
2591 fn a_sibling_location_does_not_move_the_other_fingerprints() {
2592 let root = PathBuf::from("/project");
2593 let base = per_location(
2594 &root,
2595 vec![site(&root, "src/b.ts", 4), site(&root, "src/c.ts", 7)],
2596 vec![
2597 location(&root, "src/b.ts", 4),
2598 location(&root, "src/c.ts", 7),
2599 ],
2600 );
2601 let added = per_location(
2602 &root,
2603 vec![
2604 site(&root, "src/a.ts", 1),
2605 site(&root, "src/b.ts", 4),
2606 site(&root, "src/b.ts", 9),
2607 site(&root, "src/c.ts", 7),
2608 ],
2609 vec![
2610 location(&root, "src/a.ts", 1),
2611 location(&root, "src/b.ts", 4),
2612 location(&root, "src/b.ts", 9),
2613 location(&root, "src/c.ts", 7),
2614 ],
2615 );
2616 let removed = per_location(
2617 &root,
2618 vec![site(&root, "src/c.ts", 7)],
2619 vec![
2620 location(&root, "src/c.ts", 7),
2621 location(&root, "src/d.ts", 2),
2622 ],
2623 );
2624
2625 for rule in ["fallow/unlisted-dependency", "fallow/duplicate-export"] {
2626 let pick = |issues: &[(String, u32, String)]| {
2627 issues
2628 .iter()
2629 .filter_map(|(key, line, fingerprint)| {
2630 key.strip_prefix(&format!("{rule} "))
2631 .map(|path| (path.to_owned(), *line, fingerprint.clone()))
2632 })
2633 .collect::<Vec<_>>()
2634 };
2635 let (base, added, removed) = (pick(&base), pick(&added), pick(&removed));
2636 assert_eq!(
2637 fingerprint_at(&base, "src/b.ts", 4),
2638 fingerprint_at(&added, "src/b.ts", 4)
2639 );
2640 assert_eq!(
2641 fingerprint_at(&base, "src/c.ts", 7),
2642 fingerprint_at(&added, "src/c.ts", 7)
2643 );
2644 assert_eq!(
2645 fingerprint_at(&base, "src/c.ts", 7),
2646 fingerprint_at(&removed, "src/c.ts", 7)
2647 );
2648 assert_ne!(
2649 fingerprint_at(&added, "src/b.ts", 4),
2650 fingerprint_at(&added, "src/b.ts", 9),
2651 "two locations with the same content still get two fingerprints"
2652 );
2653 }
2654 }
2655
2656 #[test]
2659 fn the_same_dependency_in_two_workspaces_gets_two_fingerprints() {
2660 let root = PathBuf::from("/project");
2661 let mut results = AnalysisResults::default();
2662 results
2663 .unused_dependencies
2664 .push(dependency(root.join("packages/a/package.json"), 5));
2665 results
2666 .unused_dependencies
2667 .push(dependency(root.join("packages/b/package.json"), 5));
2668 stamp_dead_code_finding_ids(&mut results, &root);
2669
2670 let issues = build_codeclimate(&results, &root, &RulesConfig::default());
2671
2672 assert_eq!(issues.len(), 2);
2673 assert_ne!(issues[0].fingerprint, issues[1].fingerprint);
2674 }
2675
2676 #[test]
2679 fn a_finding_without_an_id_keeps_the_legacy_fingerprint() {
2680 let root = PathBuf::from("/project");
2681 let mut results = AnalysisResults::default();
2682 results.unused_exports.push(export(&root, "first", 3));
2683
2684 let issues = build_codeclimate(&results, &root, &RulesConfig::default());
2685
2686 assert_eq!(
2687 issues[0].fingerprint,
2688 codeclimate_fingerprint_hash(&["fallow/unused-export", "src/lib.ts", "3", "first"])
2689 );
2690 }
2691 }
2692}