1use serde::{Deserialize, Serialize};
31use std::path::Path;
32
33use crate::envelope::AuditIntroduced;
34use crate::output::{
35 AddToConfigAction, AddToConfigKind, AddToConfigValue, FixAction, FixActionType,
36 IgnoreExportsRule, IssueAction, SuppressFileAction, SuppressFileKind, SuppressLineAction,
37 SuppressLineKind, SuppressLineScope,
38};
39use crate::results::{
40 BoundaryCallViolation, BoundaryCoverageViolation, BoundaryViolation, CircularDependency,
41 DependencyOverrideSource, DeprecatedExportInUse, DevDependencyInProduction, DuplicateExport,
42 DuplicatePropShape, DynamicSegmentNameConflict, EmptyCatalogGroup, InvalidClientExport,
43 MisconfiguredDependencyOverride, MisplacedDirective, MixedClientServerBarrel, PolicyViolation,
44 PrivateTypeLeak, PropDrillingChain, ReExportCycle, ReExportCycleKind, RouteCollision,
45 TestOnlyDependency, ThinWrapper, TypeOnlyDependency, UnlistedDependency, UnprovidedInject,
46 UnrenderedComponent, UnresolvedCatalogReference, UnresolvedImport, UnusedCatalogEntry,
47 UnusedComponentEmit, UnusedComponentInput, UnusedComponentOutput, UnusedComponentProp,
48 UnusedDependency, UnusedDependencyOverride, UnusedExport, UnusedFile, UnusedLoadDataKey,
49 UnusedMember, UnusedServerAction, UnusedSvelteEvent,
50};
51use crate::semantic::{
52 SemanticCandidateDecision, SemanticCandidateDecisionKind, SemanticCompleteness,
53};
54
55pub const NAMESPACE_BARREL_HINT: &str = "If every location is the sole `index.*` of its directory, this is likely an intentional namespace-barrel API. Prefer adding these files to `ignoreExports` over removing exports.";
59
60const IGNORE_EXPORTS_VALUE_SCHEMA: &str =
64 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreExports";
65
66const IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreCatalogReferences/items";
69
70const IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencyOverrides/items";
74
75const PNPM_WORKSPACE_FILE: &str = "pnpm-workspace.yaml";
76
77fn manual_framework_fix(kind: FixActionType, description: &str, note: &str) -> IssueAction {
78 IssueAction::Fix(FixAction {
79 kind,
80 auto_fixable: false,
81 description: description.to_string(),
82 note: Some(note.to_string()),
83 available_in_catalogs: None,
84 suggested_target: None,
85 })
86}
87
88fn suppress_line(comment: &str) -> IssueAction {
89 IssueAction::SuppressLine(SuppressLineAction {
90 kind: SuppressLineKind::SuppressLine,
91 auto_fixable: false,
92 description: "Suppress with an inline comment above the line".to_string(),
93 comment: comment.to_string(),
94 scope: None,
95 })
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
120#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
121#[serde(rename_all = "kebab-case")]
122pub enum ReachabilityCaveat {
123 IncompleteFileAnalysis,
138 IncompleteImportGraph,
180}
181
182impl ReachabilityCaveat {
183 #[must_use]
185 pub const fn token(self) -> &'static str {
186 match self {
187 Self::IncompleteFileAnalysis => "incomplete-file-analysis",
188 Self::IncompleteImportGraph => "incomplete-import-graph",
189 }
190 }
191
192 #[must_use]
201 pub const fn message(self) -> &'static str {
202 match self {
203 Self::IncompleteFileAnalysis => {
204 "low: this file was not fully analyzed, so its extracted exports and imports may be incomplete; see workspace_diagnostics[]"
205 }
206 Self::IncompleteImportGraph => {
207 "low: a module this run did not fully read may hold an import that would credit this; see workspace_diagnostics[]"
208 }
209 }
210 }
211
212 #[must_use]
215 pub const fn short_label(self) -> &'static str {
216 match self {
217 Self::IncompleteFileAnalysis => "incomplete file analysis",
218 Self::IncompleteImportGraph => "incomplete import graph",
219 }
220 }
221}
222
223#[must_use]
226pub fn caveat_labels(caveats: &[ReachabilityCaveat]) -> Option<String> {
227 if caveats.is_empty() {
228 return None;
229 }
230 let labels: Vec<&str> = caveats
231 .iter()
232 .map(|caveat| ReachabilityCaveat::short_label(*caveat))
233 .collect();
234 Some(labels.join(", "))
235}
236
237#[must_use]
241pub fn caveat_suffix(caveats: &[ReachabilityCaveat]) -> Option<String> {
242 caveat_labels(caveats).map(|labels| format!("{CAVEAT_SUFFIX_MARKER}{labels})"))
243}
244
245pub const CAVEAT_SUFFIX_MARKER: &str = " (caveat: ";
252
253#[must_use]
260pub fn description_carries_caveat(description: &str) -> bool {
261 description.contains(CAVEAT_SUFFIX_MARKER)
262}
263
264#[must_use]
274pub fn caveat_label_for_token(token: &str) -> String {
275 match token {
276 "incomplete-file-analysis" => {
277 ReachabilityCaveat::short_label(ReachabilityCaveat::IncompleteFileAnalysis).to_owned()
278 }
279 "incomplete-import-graph" => {
280 ReachabilityCaveat::short_label(ReachabilityCaveat::IncompleteImportGraph).to_owned()
281 }
282 other => other.replace('-', " "),
283 }
284}
285
286#[must_use]
290pub fn caveat_labels_for_tokens<'a>(tokens: impl IntoIterator<Item = &'a str>) -> Option<String> {
291 let labels: Vec<String> = tokens.into_iter().map(caveat_label_for_token).collect();
292 if labels.is_empty() {
293 return None;
294 }
295 Some(labels.join(", "))
296}
297
298#[must_use]
301pub fn caveat_suffix_for_tokens<'a>(tokens: impl IntoIterator<Item = &'a str>) -> Option<String> {
302 caveat_labels_for_tokens(tokens).map(|labels| format!("{CAVEAT_SUFFIX_MARKER}{labels})"))
303}
304
305pub const INCOMPLETE_EVIDENCE_NOTE: &str = "Evidence is incomplete: a file this run did not fully analyze may hold the reference that \
309 credits this finding, so this mutation is not applied automatically. Resolve the files named \
310 in workspace_diagnostics[] and re-run, or confirm and remove it by hand.";
311
312pub trait MutationEvidence {
330 fn reachability_caveats(&self) -> &[ReachabilityCaveat];
333
334 fn may_auto_apply_mutation(&self) -> bool {
338 self.reachability_caveats().is_empty()
339 }
340}
341
342pub trait CaveatedFinding: MutationEvidence {
349 fn set_reachability_caveats(&mut self, caveats: Vec<ReachabilityCaveat>);
355}
356
357fn withhold_caveated_mutations(actions: &mut [IssueAction], caveats: &[ReachabilityCaveat]) {
362 if caveats.is_empty() {
363 return;
364 }
365 for action in actions {
366 let IssueAction::Fix(fix) = action else {
367 continue;
368 };
369 fix.auto_fixable = false;
370 fix.note = Some(match fix.note.take() {
371 Some(existing) => format!("{existing}. {INCOMPLETE_EVIDENCE_NOTE}"),
372 None => INCOMPLETE_EVIDENCE_NOTE.to_string(),
373 });
374 }
375}
376
377macro_rules! impl_caveated_finding {
381 ($($finding:ty),+ $(,)?) => {
382 $(
383 impl MutationEvidence for $finding {
384 fn reachability_caveats(&self) -> &[ReachabilityCaveat] {
385 &self.reachability_caveats
386 }
387 }
388
389 impl CaveatedFinding for $finding {
390 fn set_reachability_caveats(&mut self, caveats: Vec<ReachabilityCaveat>) {
391 withhold_caveated_mutations(&mut self.actions, &caveats);
392 self.reachability_caveats = caveats;
393 }
394 }
395 )+
396 };
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize)]
404#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
405pub struct UnusedFileFinding {
406 #[serde(flatten)]
408 pub file: UnusedFile,
409 pub actions: Vec<IssueAction>,
412 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub introduced: Option<AuditIntroduced>,
416 #[serde(
421 default,
422 skip_serializing_if = "Option::is_none",
423 deserialize_with = "deserialize_effective_severity"
424 )]
425 pub effective_severity: Option<EffectiveSeverity>,
426 #[serde(default, skip_serializing_if = "Vec::is_empty")]
432 pub reachability_caveats: Vec<ReachabilityCaveat>,
433}
434
435impl UnusedFileFinding {
436 #[must_use]
440 pub fn with_actions(file: UnusedFile) -> Self {
441 let actions = vec![
442 IssueAction::Fix(FixAction {
443 kind: FixActionType::DeleteFile,
444 auto_fixable: false,
445 description: "Delete this file".to_string(),
446 note: Some(
447 "File deletion may remove runtime functionality not visible to static analysis"
448 .to_string(),
449 ),
450 available_in_catalogs: None,
451 suggested_target: None,
452 }),
453 IssueAction::SuppressFile(SuppressFileAction {
454 kind: SuppressFileKind::SuppressFile,
455 auto_fixable: false,
456 description: "Suppress with a file-level comment at the top of the file"
457 .to_string(),
458 comment: "// fallow-ignore-file unused-file".to_string(),
459 }),
460 ];
461 Self {
462 file,
463 actions,
464 introduced: None,
465 effective_severity: None,
466 reachability_caveats: Vec::new(),
467 }
468 }
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize)]
475#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
476pub struct PrivateTypeLeakFinding {
477 #[serde(flatten)]
479 pub leak: PrivateTypeLeak,
480 pub actions: Vec<IssueAction>,
483 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub introduced: Option<AuditIntroduced>,
487 #[serde(
492 default,
493 skip_serializing_if = "Option::is_none",
494 deserialize_with = "deserialize_effective_severity"
495 )]
496 pub effective_severity: Option<EffectiveSeverity>,
497}
498
499impl PrivateTypeLeakFinding {
500 #[must_use]
502 pub fn with_actions(leak: PrivateTypeLeak) -> Self {
503 let actions = vec![
504 IssueAction::Fix(FixAction {
505 kind: FixActionType::ExportType,
506 auto_fixable: false,
507 description: "Export the referenced private type by name".to_string(),
508 note: Some(
509 "Keep the type exported while it is part of a public signature".to_string(),
510 ),
511 available_in_catalogs: None,
512 suggested_target: None,
513 }),
514 IssueAction::SuppressLine(SuppressLineAction {
515 kind: SuppressLineKind::SuppressLine,
516 auto_fixable: false,
517 description: "Suppress with an inline comment above the line".to_string(),
518 comment: "// fallow-ignore-next-line private-type-leak".to_string(),
519 scope: None,
520 }),
521 ];
522 Self {
523 leak,
524 actions,
525 introduced: None,
526 effective_severity: None,
527 }
528 }
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize)]
535#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
536pub struct DeprecatedExportInUseFinding {
537 #[serde(flatten)]
539 pub export: DeprecatedExportInUse,
540 pub actions: Vec<IssueAction>,
543 #[serde(default, skip_serializing_if = "Option::is_none")]
546 pub introduced: Option<AuditIntroduced>,
547 #[serde(
552 default,
553 skip_serializing_if = "Option::is_none",
554 deserialize_with = "deserialize_effective_severity"
555 )]
556 pub effective_severity: Option<EffectiveSeverity>,
557}
558
559impl DeprecatedExportInUseFinding {
560 #[must_use]
562 pub fn with_actions(export: DeprecatedExportInUse) -> Self {
563 let trace_hint = format!(
564 "For the full consumer list, run `fallow dead-code --trace <path>:{}` with the `path` of this finding.",
565 export.export_name
566 );
567 let note = if export.public_api {
568 format!(
569 "This export is public API. External consumers are not visible, so do not remove it on this evidence alone. {trace_hint}"
570 )
571 } else {
572 format!(
573 "Move each consumer to the replacement that the deprecation message names, then remove the export. {trace_hint}"
574 )
575 };
576 let actions = vec![
577 IssueAction::Fix(FixAction {
578 kind: FixActionType::MigrateDeprecatedExport,
579 auto_fixable: false,
580 description: "Move the consumers off the deprecated export".to_string(),
581 note: Some(note),
582 available_in_catalogs: None,
583 suggested_target: None,
584 }),
585 suppress_line("// fallow-ignore-next-line deprecated-export-in-use"),
586 ];
587 Self {
588 export,
589 actions,
590 introduced: None,
591 effective_severity: None,
592 }
593 }
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize)]
601#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
602pub struct UnresolvedImportFinding {
603 #[serde(flatten)]
605 pub import: UnresolvedImport,
606 pub actions: Vec<IssueAction>,
609 #[serde(default, skip_serializing_if = "Option::is_none")]
612 pub introduced: Option<AuditIntroduced>,
613 #[serde(
618 default,
619 skip_serializing_if = "Option::is_none",
620 deserialize_with = "deserialize_effective_severity"
621 )]
622 pub effective_severity: Option<EffectiveSeverity>,
623}
624
625impl UnresolvedImportFinding {
626 #[must_use]
628 pub fn with_actions(import: UnresolvedImport) -> Self {
629 let actions = vec![
630 IssueAction::Fix(FixAction {
631 kind: FixActionType::ResolveImport,
632 auto_fixable: false,
633 description: "Fix the import specifier or install the missing module".to_string(),
634 note: Some(
635 "Verify the module path and check tsconfig paths configuration".to_string(),
636 ),
637 available_in_catalogs: None,
638 suggested_target: None,
639 }),
640 IssueAction::AddToConfig(AddToConfigAction {
641 kind: AddToConfigKind::AddToConfig,
642 auto_fixable: false,
643 description: format!(
644 "Add \"{}\" to ignoreUnresolvedImports in fallow config",
645 import.specifier
646 ),
647 config_key: "ignoreUnresolvedImports".to_string(),
648 value: AddToConfigValue::Scalar(import.specifier.clone()),
649 value_schema: Some(
650 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
651 .to_string(),
652 ),
653 }),
654 IssueAction::SuppressLine(SuppressLineAction {
655 kind: SuppressLineKind::SuppressLine,
656 auto_fixable: false,
657 description: "Suppress with an inline comment above the line".to_string(),
658 comment: "// fallow-ignore-next-line unresolved-import".to_string(),
659 scope: None,
660 }),
661 ];
662 Self {
663 import,
664 actions,
665 introduced: None,
666 effective_severity: None,
667 }
668 }
669}
670
671#[derive(Debug, Clone, Serialize, Deserialize)]
676#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
677pub struct CircularDependencyFinding {
678 #[serde(flatten)]
680 pub cycle: CircularDependency,
681 pub actions: Vec<IssueAction>,
684 #[serde(default, skip_serializing_if = "Option::is_none")]
687 pub introduced: Option<AuditIntroduced>,
688 #[serde(
693 default,
694 skip_serializing_if = "Option::is_none",
695 deserialize_with = "deserialize_effective_severity"
696 )]
697 pub effective_severity: Option<EffectiveSeverity>,
698}
699
700impl CircularDependencyFinding {
701 #[must_use]
703 pub fn with_actions(cycle: CircularDependency) -> Self {
704 let actions = vec![
705 IssueAction::Fix(FixAction {
706 kind: FixActionType::RefactorCycle,
707 auto_fixable: false,
708 description: "Extract shared logic into a separate module to break the cycle"
709 .to_string(),
710 note: Some(
711 "Circular imports can cause initialization issues and make code harder to reason about"
712 .to_string(),
713 ),
714 available_in_catalogs: None,
715 suggested_target: None,
716 }),
717 IssueAction::SuppressLine(SuppressLineAction {
718 kind: SuppressLineKind::SuppressLine,
719 auto_fixable: false,
720 description: "Suppress with an inline comment above the line".to_string(),
721 comment: "// fallow-ignore-next-line circular-dependency".to_string(),
722 scope: None,
723 }),
724 ];
725 Self {
726 cycle,
727 actions,
728 introduced: None,
729 effective_severity: None,
730 }
731 }
732}
733
734#[derive(Debug, Clone, Serialize, Deserialize)]
742#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
743pub struct ReExportCycleFinding {
744 #[serde(flatten)]
746 pub cycle: ReExportCycle,
747 pub actions: Vec<IssueAction>,
750 #[serde(default, skip_serializing_if = "Option::is_none")]
753 pub introduced: Option<AuditIntroduced>,
754 #[serde(
759 default,
760 skip_serializing_if = "Option::is_none",
761 deserialize_with = "deserialize_effective_severity"
762 )]
763 pub effective_severity: Option<EffectiveSeverity>,
764}
765
766impl ReExportCycleFinding {
767 #[must_use]
774 pub fn with_actions(cycle: ReExportCycle) -> Self {
775 let suppress_description = match cycle.kind {
781 ReExportCycleKind::SelfLoop => {
782 "Suppress with a file-level comment at the top of this file. \
783 The cycle is a self-loop, so the suppression covers the entire finding."
784 .to_string()
785 }
786 ReExportCycleKind::MultiNode => {
787 "Suppress with a file-level comment at the top of this file. \
788 One suppression on any member breaks the cycle for every member \
789 (see the sibling `files` array)."
790 .to_string()
791 }
792 };
793 let actions = vec![
794 IssueAction::Fix(FixAction {
795 kind: FixActionType::RefactorReExportCycle,
796 auto_fixable: false,
797 description: "Remove one `export * from` (or `export { ... } from`) \
798 statement on any one member to break the cycle"
799 .to_string(),
800 note: Some(
801 "Re-export cycles are structurally a no-op: chain propagation through \
802 the loop never reaches a terminating module, so imports from any member \
803 may silently come up empty."
804 .to_string(),
805 ),
806 available_in_catalogs: None,
807 suggested_target: None,
808 }),
809 IssueAction::SuppressFile(SuppressFileAction {
810 kind: SuppressFileKind::SuppressFile,
811 auto_fixable: false,
812 description: suppress_description,
813 comment: "// fallow-ignore-file re-export-cycle".to_string(),
814 }),
815 ];
816 Self {
817 cycle,
818 actions,
819 introduced: None,
820 effective_severity: None,
821 }
822 }
823}
824
825#[derive(Debug, Clone, Serialize, Deserialize)]
830#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
831pub struct BoundaryViolationFinding {
832 #[serde(flatten)]
834 pub violation: BoundaryViolation,
835 pub actions: Vec<IssueAction>,
838 #[serde(default, skip_serializing_if = "Option::is_none")]
841 pub introduced: Option<AuditIntroduced>,
842 #[serde(
847 default,
848 skip_serializing_if = "Option::is_none",
849 deserialize_with = "deserialize_effective_severity"
850 )]
851 pub effective_severity: Option<EffectiveSeverity>,
852}
853
854impl BoundaryViolationFinding {
855 #[must_use]
857 pub fn with_actions(violation: BoundaryViolation) -> Self {
858 let actions = vec![
859 IssueAction::Fix(FixAction {
860 kind: FixActionType::RefactorBoundary,
861 auto_fixable: false,
862 description: "Move the import through an allowed zone or restructure the dependency"
863 .to_string(),
864 note: Some(
865 "This import crosses an architecture boundary that is not permitted by the configured rules"
866 .to_string(),
867 ),
868 available_in_catalogs: None,
869 suggested_target: None,
870 }),
871 IssueAction::SuppressLine(SuppressLineAction {
872 kind: SuppressLineKind::SuppressLine,
873 auto_fixable: false,
874 description: "Suppress with an inline comment above the line".to_string(),
875 comment: "// fallow-ignore-next-line boundary-violation".to_string(),
876 scope: None,
877 }),
878 ];
879 Self {
880 violation,
881 actions,
882 introduced: None,
883 effective_severity: None,
884 }
885 }
886}
887
888#[derive(Debug, Clone, Serialize, Deserialize)]
892#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
893pub struct BoundaryCoverageViolationFinding {
894 #[serde(flatten)]
896 pub violation: BoundaryCoverageViolation,
897 pub actions: Vec<IssueAction>,
899 #[serde(default, skip_serializing_if = "Option::is_none")]
902 pub introduced: Option<AuditIntroduced>,
903 #[serde(
908 default,
909 skip_serializing_if = "Option::is_none",
910 deserialize_with = "deserialize_effective_severity"
911 )]
912 pub effective_severity: Option<EffectiveSeverity>,
913}
914
915impl BoundaryCoverageViolationFinding {
916 #[must_use]
918 pub fn with_actions(violation: BoundaryCoverageViolation) -> Self {
919 let path = violation.path.to_string_lossy().replace('\\', "/");
920 let actions = vec![
921 IssueAction::Fix(FixAction {
922 kind: FixActionType::RefactorBoundary,
923 auto_fixable: false,
924 description: "Add this file to a boundary zone pattern or move it under an existing zone"
925 .to_string(),
926 note: Some(
927 "Boundary coverage is enabled, so every analyzed source file must match a zone unless allow-listed"
928 .to_string(),
929 ),
930 available_in_catalogs: None,
931 suggested_target: None,
932 }),
933 IssueAction::AddToConfig(AddToConfigAction {
934 kind: AddToConfigKind::AddToConfig,
935 auto_fixable: false,
936 description: format!(
937 "Add \"{path}\" to boundaries.coverage.allowUnmatched in fallow config"
938 ),
939 config_key: "boundaries.coverage.allowUnmatched".to_string(),
940 value: AddToConfigValue::Scalar(path),
941 value_schema: Some(
942 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/boundaries/properties/coverage/properties/allowUnmatched/items"
943 .to_string(),
944 ),
945 }),
946 IssueAction::SuppressFile(SuppressFileAction {
947 kind: SuppressFileKind::SuppressFile,
948 auto_fixable: false,
949 description: "Suppress with a file-level comment at the top of the file"
950 .to_string(),
951 comment: "// fallow-ignore-file boundary-violation".to_string(),
952 }),
953 ];
954 Self {
955 violation,
956 actions,
957 introduced: None,
958 effective_severity: None,
959 }
960 }
961}
962
963#[derive(Debug, Clone, Serialize, Deserialize)]
967#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
968pub struct BoundaryCallViolationFinding {
969 #[serde(flatten)]
971 pub violation: BoundaryCallViolation,
972 pub actions: Vec<IssueAction>,
974 #[serde(default, skip_serializing_if = "Option::is_none")]
977 pub introduced: Option<AuditIntroduced>,
978 #[serde(
983 default,
984 skip_serializing_if = "Option::is_none",
985 deserialize_with = "deserialize_effective_severity"
986 )]
987 pub effective_severity: Option<EffectiveSeverity>,
988}
989
990impl BoundaryCallViolationFinding {
991 #[must_use]
993 pub fn with_actions(violation: BoundaryCallViolation) -> Self {
994 let actions = vec![
995 IssueAction::Fix(FixAction {
996 kind: FixActionType::RefactorBoundary,
997 auto_fixable: false,
998 description: format!(
999 "Move the `{}` call out of zone '{}' or behind an allowed abstraction",
1000 violation.callee, violation.zone,
1001 ),
1002 note: Some(format!(
1003 "`boundaries.calls.forbidden` bans callees matching `{}` from zone '{}'. The check is syntactic: it applies only to files classified into a zone and does not follow aliased or re-bound callees",
1004 violation.pattern, violation.zone,
1005 )),
1006 available_in_catalogs: None,
1007 suggested_target: None,
1008 }),
1009 IssueAction::SuppressLine(SuppressLineAction {
1010 kind: SuppressLineKind::SuppressLine,
1011 auto_fixable: false,
1012 description: "Suppress with an inline comment above the line".to_string(),
1013 comment: "// fallow-ignore-next-line boundary-violation".to_string(),
1014 scope: None,
1015 }),
1016 IssueAction::SuppressFile(SuppressFileAction {
1017 kind: SuppressFileKind::SuppressFile,
1018 auto_fixable: false,
1019 description: "Suppress with a file-level comment at the top of the file"
1020 .to_string(),
1021 comment: "// fallow-ignore-file boundary-violation".to_string(),
1022 }),
1023 ];
1024 Self {
1025 violation,
1026 actions,
1027 introduced: None,
1028 effective_severity: None,
1029 }
1030 }
1031}
1032
1033#[derive(Debug, Clone, Serialize, Deserialize)]
1037#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1038pub struct PolicyViolationFinding {
1039 #[serde(flatten)]
1041 pub violation: PolicyViolation,
1042 pub actions: Vec<IssueAction>,
1044 #[serde(default, skip_serializing_if = "Option::is_none")]
1047 pub introduced: Option<AuditIntroduced>,
1048}
1049
1050impl PolicyViolationFinding {
1051 #[must_use]
1053 pub fn with_actions(violation: PolicyViolation) -> Self {
1054 let what = match violation.kind {
1055 crate::results::PolicyRuleKind::BannedCall => "call",
1056 crate::results::PolicyRuleKind::BannedImport => "import",
1057 crate::results::PolicyRuleKind::BannedEffect => "effect",
1058 crate::results::PolicyRuleKind::BannedExport => "export",
1059 };
1060 let description = match &violation.message {
1061 Some(message) => format!("Replace the `{}` {what}: {message}", violation.matched),
1062 None => format!("Replace the `{}` {what}", violation.matched),
1063 };
1064 let suppress_token = format!("policy-violation:{}/{}", violation.pack, violation.rule_id);
1065 let actions = vec![
1066 IssueAction::Fix(FixAction {
1067 kind: FixActionType::ResolvePolicyViolation,
1068 auto_fixable: false,
1069 description,
1070 note: Some(format!(
1071 "Rule `{}/{}` from the configured rule packs bans this {what}. The check is syntactic: it does not follow aliased or re-bound callees, and import matching uses the raw specifier",
1072 violation.pack, violation.rule_id,
1073 )),
1074 available_in_catalogs: None,
1075 suggested_target: None,
1076 }),
1077 IssueAction::SuppressLine(SuppressLineAction {
1078 kind: SuppressLineKind::SuppressLine,
1079 auto_fixable: false,
1080 description: "Suppress this rule-pack rule with an inline comment above the line"
1081 .to_string(),
1082 comment: format!("// fallow-ignore-next-line {suppress_token}"),
1083 scope: None,
1084 }),
1085 IssueAction::SuppressFile(SuppressFileAction {
1086 kind: SuppressFileKind::SuppressFile,
1087 auto_fixable: false,
1088 description:
1089 "Suppress this rule-pack rule with a file-level comment at the top of the file"
1090 .to_string(),
1091 comment: format!("// fallow-ignore-file {suppress_token}"),
1092 }),
1093 ];
1094 Self {
1095 violation,
1096 actions,
1097 introduced: None,
1098 }
1099 }
1100}
1101
1102#[derive(Debug, Clone, Serialize, Deserialize)]
1107#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1108pub struct UnusedExportFinding {
1109 #[serde(flatten)]
1111 pub export: UnusedExport,
1112 pub actions: Vec<IssueAction>,
1115 #[serde(default, skip_serializing_if = "Option::is_none")]
1117 pub semantic: Option<SemanticCandidateDecision>,
1118 #[serde(default, skip_serializing_if = "Option::is_none")]
1121 pub introduced: Option<AuditIntroduced>,
1122 #[serde(
1127 default,
1128 skip_serializing_if = "Option::is_none",
1129 deserialize_with = "deserialize_effective_severity"
1130 )]
1131 pub effective_severity: Option<EffectiveSeverity>,
1132 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1137 pub reachability_caveats: Vec<ReachabilityCaveat>,
1138}
1139
1140impl UnusedExportFinding {
1141 #[must_use]
1145 pub fn with_actions(export: UnusedExport) -> Self {
1146 let note = if export.is_re_export {
1147 Some(
1148 "This finding originates from a re-export; verify it is not part of your public API before removing"
1149 .to_string(),
1150 )
1151 } else {
1152 None
1153 };
1154 let actions = vec![
1155 IssueAction::Fix(FixAction {
1156 kind: FixActionType::RemoveExport,
1157 auto_fixable: true,
1158 description: "Remove the unused export from the public API".to_string(),
1159 note,
1160 available_in_catalogs: None,
1161 suggested_target: None,
1162 }),
1163 IssueAction::SuppressLine(SuppressLineAction {
1164 kind: SuppressLineKind::SuppressLine,
1165 auto_fixable: false,
1166 description: "Suppress with an inline comment above the line".to_string(),
1167 comment: "// fallow-ignore-next-line unused-export".to_string(),
1168 scope: None,
1169 }),
1170 ];
1171 Self {
1172 export,
1173 actions,
1174 semantic: None,
1175 introduced: None,
1176 effective_severity: None,
1177 reachability_caveats: Vec::new(),
1178 }
1179 }
1180
1181 pub fn set_semantic_decision(&mut self, decision: SemanticCandidateDecision) {
1184 set_export_semantic_action(&mut self.actions, &decision, &self.reachability_caveats);
1185 self.semantic = Some(decision);
1186 }
1187}
1188
1189#[derive(Debug, Clone, Serialize, Deserialize)]
1194#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1195pub struct UnusedTypeFinding {
1196 #[serde(flatten)]
1198 pub export: UnusedExport,
1199 pub actions: Vec<IssueAction>,
1202 #[serde(default, skip_serializing_if = "Option::is_none")]
1204 pub semantic: Option<SemanticCandidateDecision>,
1205 #[serde(default, skip_serializing_if = "Option::is_none")]
1208 pub introduced: Option<AuditIntroduced>,
1209 #[serde(
1214 default,
1215 skip_serializing_if = "Option::is_none",
1216 deserialize_with = "deserialize_effective_severity"
1217 )]
1218 pub effective_severity: Option<EffectiveSeverity>,
1219 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1225 pub reachability_caveats: Vec<ReachabilityCaveat>,
1226}
1227
1228impl UnusedTypeFinding {
1229 #[must_use]
1232 pub fn with_actions(export: UnusedExport) -> Self {
1233 let note = if export.is_re_export {
1234 Some(
1235 "This finding originates from a re-export; verify it is not part of your public API before removing"
1236 .to_string(),
1237 )
1238 } else {
1239 None
1240 };
1241 let actions = vec![
1242 IssueAction::Fix(FixAction {
1243 kind: FixActionType::RemoveExport,
1244 auto_fixable: true,
1245 description:
1246 "Remove the `export` (or `export type`) keyword from the type declaration"
1247 .to_string(),
1248 note,
1249 available_in_catalogs: None,
1250 suggested_target: None,
1251 }),
1252 IssueAction::SuppressLine(SuppressLineAction {
1253 kind: SuppressLineKind::SuppressLine,
1254 auto_fixable: false,
1255 description: "Suppress with an inline comment above the line".to_string(),
1256 comment: "// fallow-ignore-next-line unused-type".to_string(),
1257 scope: None,
1258 }),
1259 ];
1260 Self {
1261 export,
1262 actions,
1263 semantic: None,
1264 introduced: None,
1265 effective_severity: None,
1266 reachability_caveats: Vec::new(),
1267 }
1268 }
1269
1270 pub fn set_semantic_decision(&mut self, decision: SemanticCandidateDecision) {
1273 set_export_semantic_action(&mut self.actions, &decision, &self.reachability_caveats);
1274 self.semantic = Some(decision);
1275 }
1276}
1277
1278fn set_export_semantic_action(
1283 actions: &mut [IssueAction],
1284 decision: &SemanticCandidateDecision,
1285 caveats: &[ReachabilityCaveat],
1286) {
1287 let complete_negative = decision.decision
1288 == SemanticCandidateDecisionKind::ConfirmedNoStaticReferences
1289 && decision.status == SemanticCompleteness::Complete;
1290 let Some(IssueAction::Fix(action)) = actions.first_mut() else {
1291 return;
1292 };
1293 action.auto_fixable = complete_negative && caveats.is_empty();
1294 if !complete_negative {
1295 action.note = Some(
1296 "Type-aware analysis retained this candidate because complete negative evidence was not available"
1297 .to_string(),
1298 );
1299 }
1300 if !caveats.is_empty() {
1301 action.note = Some(INCOMPLETE_EVIDENCE_NOTE.to_string());
1302 }
1303}
1304
1305#[derive(Debug, Clone, Serialize, Deserialize)]
1311#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1312pub struct InvalidClientExportFinding {
1313 #[serde(flatten)]
1315 pub export: InvalidClientExport,
1316 pub actions: Vec<IssueAction>,
1319 #[serde(default, skip_serializing_if = "Option::is_none")]
1322 pub introduced: Option<AuditIntroduced>,
1323 #[serde(
1328 default,
1329 skip_serializing_if = "Option::is_none",
1330 deserialize_with = "deserialize_effective_severity"
1331 )]
1332 pub effective_severity: Option<EffectiveSeverity>,
1333}
1334
1335impl InvalidClientExportFinding {
1336 #[must_use]
1341 pub fn with_actions(export: InvalidClientExport) -> Self {
1342 let actions = vec![
1343 IssueAction::Fix(FixAction {
1344 kind: FixActionType::MoveToServerModule,
1345 auto_fixable: false,
1346 description: "Move the server-only export to a non-client module and import it from there"
1347 .to_string(),
1348 note: Some(
1349 "A \"use client\" file cannot export a Next.js server-only or route-config name; Next.js rejects it at build time"
1350 .to_string(),
1351 ),
1352 available_in_catalogs: None,
1353 suggested_target: None,
1354 }),
1355 IssueAction::SuppressLine(SuppressLineAction {
1356 kind: SuppressLineKind::SuppressLine,
1357 auto_fixable: false,
1358 description: "Suppress with an inline comment above the line".to_string(),
1359 comment: "// fallow-ignore-next-line invalid-client-export".to_string(),
1360 scope: None,
1361 }),
1362 ];
1363 Self {
1364 export,
1365 actions,
1366 introduced: None,
1367 effective_severity: None,
1368 }
1369 }
1370}
1371
1372#[derive(Debug, Clone, Serialize, Deserialize)]
1378#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1379pub struct MixedClientServerBarrelFinding {
1380 #[serde(flatten)]
1382 pub barrel: MixedClientServerBarrel,
1383 pub actions: Vec<IssueAction>,
1386 #[serde(default, skip_serializing_if = "Option::is_none")]
1389 pub introduced: Option<AuditIntroduced>,
1390 #[serde(
1395 default,
1396 skip_serializing_if = "Option::is_none",
1397 deserialize_with = "deserialize_effective_severity"
1398 )]
1399 pub effective_severity: Option<EffectiveSeverity>,
1400}
1401
1402impl MixedClientServerBarrelFinding {
1403 #[must_use]
1408 pub fn with_actions(barrel: MixedClientServerBarrel) -> Self {
1409 let actions = vec![
1410 IssueAction::Fix(FixAction {
1411 kind: FixActionType::SplitMixedBarrel,
1412 auto_fixable: false,
1413 description: "Split the barrel so client and server-only modules are re-exported from separate files"
1414 .to_string(),
1415 note: Some(
1416 "Importing one name from this barrel drags the other's directive across the client/server boundary"
1417 .to_string(),
1418 ),
1419 available_in_catalogs: None,
1420 suggested_target: None,
1421 }),
1422 IssueAction::SuppressLine(SuppressLineAction {
1423 kind: SuppressLineKind::SuppressLine,
1424 auto_fixable: false,
1425 description: "Suppress with an inline comment above the line".to_string(),
1426 comment: "// fallow-ignore-next-line mixed-client-server-barrel".to_string(),
1427 scope: None,
1428 }),
1429 ];
1430 Self {
1431 barrel,
1432 actions,
1433 introduced: None,
1434 effective_severity: None,
1435 }
1436 }
1437}
1438
1439#[derive(Debug, Clone, Serialize, Deserialize)]
1445#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1446pub struct MisplacedDirectiveFinding {
1447 #[serde(flatten)]
1449 pub directive_site: MisplacedDirective,
1450 pub actions: Vec<IssueAction>,
1453 #[serde(default, skip_serializing_if = "Option::is_none")]
1456 pub introduced: Option<AuditIntroduced>,
1457 #[serde(
1462 default,
1463 skip_serializing_if = "Option::is_none",
1464 deserialize_with = "deserialize_effective_severity"
1465 )]
1466 pub effective_severity: Option<EffectiveSeverity>,
1467}
1468
1469impl MisplacedDirectiveFinding {
1470 #[must_use]
1475 pub fn with_actions(directive_site: MisplacedDirective) -> Self {
1476 let actions = vec![
1477 IssueAction::Fix(FixAction {
1478 kind: FixActionType::HoistDirective,
1479 auto_fixable: false,
1480 description: "Move the directive to the very top of the file, above all imports and statements"
1481 .to_string(),
1482 note: Some(
1483 "An RSC bundler honors the directive only in the leading prologue; here it precedes other statements and is silently ignored"
1484 .to_string(),
1485 ),
1486 available_in_catalogs: None,
1487 suggested_target: None,
1488 }),
1489 IssueAction::SuppressLine(SuppressLineAction {
1490 kind: SuppressLineKind::SuppressLine,
1491 auto_fixable: false,
1492 description: "Suppress with an inline comment above the line".to_string(),
1493 comment: "// fallow-ignore-next-line misplaced-directive".to_string(),
1494 scope: None,
1495 }),
1496 ];
1497 Self {
1498 directive_site,
1499 actions,
1500 introduced: None,
1501 effective_severity: None,
1502 }
1503 }
1504}
1505
1506#[derive(Debug, Clone, Serialize, Deserialize)]
1511#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1512pub struct UnprovidedInjectFinding {
1513 #[serde(flatten)]
1515 pub inject: UnprovidedInject,
1516 pub actions: Vec<IssueAction>,
1519 #[serde(default, skip_serializing_if = "Option::is_none")]
1522 pub introduced: Option<AuditIntroduced>,
1523 #[serde(
1528 default,
1529 skip_serializing_if = "Option::is_none",
1530 deserialize_with = "deserialize_effective_severity"
1531 )]
1532 pub effective_severity: Option<EffectiveSeverity>,
1533}
1534
1535impl UnprovidedInjectFinding {
1536 #[must_use]
1539 pub fn with_actions(inject: UnprovidedInject) -> Self {
1540 let actions = vec![
1541 manual_framework_fix(
1542 FixActionType::ProvideInject,
1543 "Provide this injected key, or remove the inject / getContext call",
1544 "Manual review required: dependency-injection keys can be provided by framework wiring, tests, or package consumers outside this project.",
1545 ),
1546 suppress_line("// fallow-ignore-next-line unprovided-inject"),
1547 ];
1548 Self {
1549 inject,
1550 actions,
1551 introduced: None,
1552 effective_severity: None,
1553 }
1554 }
1555}
1556
1557#[derive(Debug, Clone, Serialize, Deserialize)]
1562#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1563pub struct UnusedServerActionFinding {
1564 #[serde(flatten)]
1566 pub action: UnusedServerAction,
1567 pub actions: Vec<IssueAction>,
1570 #[serde(default, skip_serializing_if = "Option::is_none")]
1573 pub introduced: Option<AuditIntroduced>,
1574 #[serde(
1579 default,
1580 skip_serializing_if = "Option::is_none",
1581 deserialize_with = "deserialize_effective_severity"
1582 )]
1583 pub effective_severity: Option<EffectiveSeverity>,
1584}
1585
1586impl UnusedServerActionFinding {
1587 #[must_use]
1590 pub fn with_actions(action: UnusedServerAction) -> Self {
1591 let actions = vec![
1592 manual_framework_fix(
1593 FixActionType::WireServerAction,
1594 "Wire the server action to a caller or form action, or remove it",
1595 "Manual review required: server actions may still be POST-able by action id or invoked reflectively outside the static project graph.",
1596 ),
1597 suppress_line("// fallow-ignore-next-line unused-server-action"),
1598 ];
1599 Self {
1600 action,
1601 actions,
1602 introduced: None,
1603 effective_severity: None,
1604 }
1605 }
1606}
1607
1608#[derive(Debug, Clone, Serialize, Deserialize)]
1613#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1614pub struct UnusedLoadDataKeyFinding {
1615 #[serde(flatten)]
1617 pub key: UnusedLoadDataKey,
1618 pub actions: Vec<IssueAction>,
1621 #[serde(default, skip_serializing_if = "Option::is_none")]
1624 pub introduced: Option<AuditIntroduced>,
1625 #[serde(
1630 default,
1631 skip_serializing_if = "Option::is_none",
1632 deserialize_with = "deserialize_effective_severity"
1633 )]
1634 pub effective_severity: Option<EffectiveSeverity>,
1635}
1636
1637impl UnusedLoadDataKeyFinding {
1638 #[must_use]
1641 pub fn with_actions(key: UnusedLoadDataKey) -> Self {
1642 let actions = vec![
1643 manual_framework_fix(
1644 FixActionType::UseLoadData,
1645 "Read this load data key from the route UI, or remove it from the load return",
1646 "Manual review required: load functions can perform real server or database work, so verify side effects before deleting the producer.",
1647 ),
1648 suppress_line("// fallow-ignore-next-line unused-load-data-key"),
1649 ];
1650 Self {
1651 key,
1652 actions,
1653 introduced: None,
1654 effective_severity: None,
1655 }
1656 }
1657}
1658
1659#[derive(Debug, Clone, Serialize, Deserialize)]
1664#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1665pub struct UnrenderedComponentFinding {
1666 #[serde(flatten)]
1668 pub component: UnrenderedComponent,
1669 pub actions: Vec<IssueAction>,
1672 #[serde(default, skip_serializing_if = "Option::is_none")]
1675 pub introduced: Option<AuditIntroduced>,
1676 #[serde(
1681 default,
1682 skip_serializing_if = "Option::is_none",
1683 deserialize_with = "deserialize_effective_severity"
1684 )]
1685 pub effective_severity: Option<EffectiveSeverity>,
1686}
1687
1688impl UnrenderedComponentFinding {
1689 #[must_use]
1692 pub fn with_actions(component: UnrenderedComponent) -> Self {
1693 let actions = vec![
1694 manual_framework_fix(
1695 FixActionType::RenderComponent,
1696 "Render the reachable component from project code, or remove it",
1697 "Manual review required: exported library components and dynamic render registries can be intentionally reachable without static template usage.",
1698 ),
1699 suppress_line("// fallow-ignore-next-line unrendered-component"),
1700 ];
1701 Self {
1702 component,
1703 actions,
1704 introduced: None,
1705 effective_severity: None,
1706 }
1707 }
1708}
1709
1710#[derive(Debug, Clone, Serialize, Deserialize)]
1715#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1716pub struct UnusedComponentPropFinding {
1717 #[serde(flatten)]
1719 pub prop: UnusedComponentProp,
1720 pub actions: Vec<IssueAction>,
1723 #[serde(default, skip_serializing_if = "Option::is_none")]
1726 pub introduced: Option<AuditIntroduced>,
1727 #[serde(
1732 default,
1733 skip_serializing_if = "Option::is_none",
1734 deserialize_with = "deserialize_effective_severity"
1735 )]
1736 pub effective_severity: Option<EffectiveSeverity>,
1737}
1738
1739impl UnusedComponentPropFinding {
1740 #[must_use]
1743 pub fn with_actions(prop: UnusedComponentProp) -> Self {
1744 let actions = vec![
1745 manual_framework_fix(
1746 FixActionType::UseComponentProp,
1747 "Use the declared prop in the component, or remove it from the component API",
1748 "Manual review required: public component APIs can intentionally keep stable props for external consumers.",
1749 ),
1750 suppress_line("// fallow-ignore-next-line unused-component-prop"),
1751 ];
1752 Self {
1753 prop,
1754 actions,
1755 introduced: None,
1756 effective_severity: None,
1757 }
1758 }
1759}
1760
1761#[derive(Debug, Clone, Serialize, Deserialize)]
1766#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1767pub struct UnusedComponentEmitFinding {
1768 #[serde(flatten)]
1770 pub emit: UnusedComponentEmit,
1771 pub actions: Vec<IssueAction>,
1774 #[serde(default, skip_serializing_if = "Option::is_none")]
1777 pub introduced: Option<AuditIntroduced>,
1778 #[serde(
1783 default,
1784 skip_serializing_if = "Option::is_none",
1785 deserialize_with = "deserialize_effective_severity"
1786 )]
1787 pub effective_severity: Option<EffectiveSeverity>,
1788}
1789
1790impl UnusedComponentEmitFinding {
1791 #[must_use]
1794 pub fn with_actions(emit: UnusedComponentEmit) -> Self {
1795 let actions = vec![
1796 manual_framework_fix(
1797 FixActionType::EmitComponentEvent,
1798 "Emit the declared event from the component, or remove it from the component API",
1799 "Manual review required: public component APIs can intentionally keep stable events for external listeners.",
1800 ),
1801 suppress_line("// fallow-ignore-next-line unused-component-emit"),
1802 ];
1803 Self {
1804 emit,
1805 actions,
1806 introduced: None,
1807 effective_severity: None,
1808 }
1809 }
1810}
1811
1812#[derive(Debug, Clone, Serialize, Deserialize)]
1818#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1819pub struct UnusedSvelteEventFinding {
1820 #[serde(flatten)]
1822 pub event: UnusedSvelteEvent,
1823 pub actions: Vec<IssueAction>,
1826 #[serde(default, skip_serializing_if = "Option::is_none")]
1829 pub introduced: Option<AuditIntroduced>,
1830 #[serde(
1835 default,
1836 skip_serializing_if = "Option::is_none",
1837 deserialize_with = "deserialize_effective_severity"
1838 )]
1839 pub effective_severity: Option<EffectiveSeverity>,
1840}
1841
1842impl UnusedSvelteEventFinding {
1843 #[must_use]
1846 pub fn with_actions(event: UnusedSvelteEvent) -> Self {
1847 let actions = vec![
1848 manual_framework_fix(
1849 FixActionType::WireSvelteEvent,
1850 "Add or forward a listener for this custom event, or remove the dispatch",
1851 "Manual review required: public Svelte component APIs can intentionally dispatch events for package consumers outside this project.",
1852 ),
1853 suppress_line("// fallow-ignore-next-line unused-svelte-event"),
1854 ];
1855 Self {
1856 event,
1857 actions,
1858 introduced: None,
1859 effective_severity: None,
1860 }
1861 }
1862}
1863
1864#[derive(Debug, Clone, Serialize, Deserialize)]
1870#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1871pub struct PropDrillingChainFinding {
1872 #[serde(flatten)]
1874 pub chain: PropDrillingChain,
1875 pub actions: Vec<IssueAction>,
1878 #[serde(default, skip_serializing_if = "Option::is_none")]
1881 pub introduced: Option<AuditIntroduced>,
1882 #[serde(
1888 default,
1889 skip_serializing_if = "Option::is_none",
1890 deserialize_with = "deserialize_effective_severity"
1891 )]
1892 pub effective_severity: Option<EffectiveSeverity>,
1893}
1894
1895impl PropDrillingChainFinding {
1896 #[must_use]
1901 pub fn with_actions(chain: PropDrillingChain) -> Self {
1902 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1903 kind: SuppressLineKind::SuppressLine,
1904 auto_fixable: false,
1905 description: "Suppress with an inline comment above the source prop declaration"
1906 .to_string(),
1907 comment: "// fallow-ignore-next-line prop-drilling".to_string(),
1908 scope: None,
1909 })];
1910 Self {
1911 chain,
1912 actions,
1913 introduced: None,
1914 effective_severity: None,
1915 }
1916 }
1917}
1918
1919#[derive(Debug, Clone, Serialize, Deserialize)]
1925#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1926pub struct ThinWrapperFinding {
1927 #[serde(flatten)]
1929 pub wrapper: ThinWrapper,
1930 pub actions: Vec<IssueAction>,
1933 #[serde(default, skip_serializing_if = "Option::is_none")]
1936 pub introduced: Option<AuditIntroduced>,
1937 #[serde(
1943 default,
1944 skip_serializing_if = "Option::is_none",
1945 deserialize_with = "deserialize_effective_severity"
1946 )]
1947 pub effective_severity: Option<EffectiveSeverity>,
1948}
1949
1950impl ThinWrapperFinding {
1951 #[must_use]
1955 pub fn with_actions(wrapper: ThinWrapper) -> Self {
1956 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1957 kind: SuppressLineKind::SuppressLine,
1958 auto_fixable: false,
1959 description: "Suppress with an inline comment above the component definition"
1960 .to_string(),
1961 comment: "// fallow-ignore-next-line thin-wrapper".to_string(),
1962 scope: None,
1963 })];
1964 Self {
1965 wrapper,
1966 actions,
1967 introduced: None,
1968 effective_severity: None,
1969 }
1970 }
1971}
1972
1973#[derive(Debug, Clone, Serialize, Deserialize)]
1981#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1982pub struct DuplicatePropShapeFinding {
1983 #[serde(flatten)]
1985 pub shape: DuplicatePropShape,
1986 pub actions: Vec<IssueAction>,
1989 #[serde(default, skip_serializing_if = "Option::is_none")]
1992 pub introduced: Option<AuditIntroduced>,
1993 #[serde(
1999 default,
2000 skip_serializing_if = "Option::is_none",
2001 deserialize_with = "deserialize_effective_severity"
2002 )]
2003 pub effective_severity: Option<EffectiveSeverity>,
2004}
2005
2006impl DuplicatePropShapeFinding {
2007 #[must_use]
2014 pub fn with_actions(shape: DuplicatePropShape) -> Self {
2015 let actions = vec![
2016 IssueAction::SuppressLine(SuppressLineAction {
2017 kind: SuppressLineKind::SuppressLine,
2018 auto_fixable: false,
2019 description: "Three or more components share this exact prop shape. Extract one \
2020 shared `Props` type (or a base component) that every member reuses, \
2021 or keep them separate if a per-variant divergence is planned. \
2022 Suppress one member with an inline comment above the component \
2023 definition."
2024 .to_string(),
2025 comment: "// fallow-ignore-next-line duplicate-prop-shape".to_string(),
2026 scope: None,
2027 }),
2028 IssueAction::SuppressFile(SuppressFileAction {
2029 kind: SuppressFileKind::SuppressFile,
2030 auto_fixable: false,
2031 description: "Escape hatch: a file-level suppress silences this member but it \
2032 still appears in its siblings' `sharing_components` (the group is \
2033 real regardless of suppression)."
2034 .to_string(),
2035 comment: "// fallow-ignore-file duplicate-prop-shape".to_string(),
2036 }),
2037 ];
2038 Self {
2039 shape,
2040 actions,
2041 introduced: None,
2042 effective_severity: None,
2043 }
2044 }
2045}
2046
2047#[derive(Debug, Clone, Serialize, Deserialize)]
2052#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2053pub struct UnusedComponentInputFinding {
2054 #[serde(flatten)]
2056 pub input: UnusedComponentInput,
2057 pub actions: Vec<IssueAction>,
2060 #[serde(default, skip_serializing_if = "Option::is_none")]
2063 pub introduced: Option<AuditIntroduced>,
2064 #[serde(
2069 default,
2070 skip_serializing_if = "Option::is_none",
2071 deserialize_with = "deserialize_effective_severity"
2072 )]
2073 pub effective_severity: Option<EffectiveSeverity>,
2074}
2075
2076impl UnusedComponentInputFinding {
2077 #[must_use]
2081 pub fn with_actions(input: UnusedComponentInput) -> Self {
2082 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
2083 kind: SuppressLineKind::SuppressLine,
2084 auto_fixable: false,
2085 description: "Suppress with an inline comment above the line".to_string(),
2086 comment: "// fallow-ignore-next-line unused-component-input".to_string(),
2087 scope: None,
2088 })];
2089 Self {
2090 input,
2091 actions,
2092 introduced: None,
2093 effective_severity: None,
2094 }
2095 }
2096}
2097
2098#[derive(Debug, Clone, Serialize, Deserialize)]
2103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2104pub struct UnusedComponentOutputFinding {
2105 #[serde(flatten)]
2107 pub output: UnusedComponentOutput,
2108 pub actions: Vec<IssueAction>,
2111 #[serde(default, skip_serializing_if = "Option::is_none")]
2114 pub introduced: Option<AuditIntroduced>,
2115 #[serde(
2120 default,
2121 skip_serializing_if = "Option::is_none",
2122 deserialize_with = "deserialize_effective_severity"
2123 )]
2124 pub effective_severity: Option<EffectiveSeverity>,
2125}
2126
2127impl UnusedComponentOutputFinding {
2128 #[must_use]
2132 pub fn with_actions(output: UnusedComponentOutput) -> Self {
2133 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
2134 kind: SuppressLineKind::SuppressLine,
2135 auto_fixable: false,
2136 description: "Suppress with an inline comment above the line".to_string(),
2137 comment: "// fallow-ignore-next-line unused-component-output".to_string(),
2138 scope: None,
2139 })];
2140 Self {
2141 output,
2142 actions,
2143 introduced: None,
2144 effective_severity: None,
2145 }
2146 }
2147}
2148
2149#[derive(Debug, Clone, Serialize, Deserialize)]
2155#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2156pub struct RouteCollisionFinding {
2157 #[serde(flatten)]
2159 pub collision: RouteCollision,
2160 pub actions: Vec<IssueAction>,
2163 #[serde(default, skip_serializing_if = "Option::is_none")]
2166 pub introduced: Option<AuditIntroduced>,
2167 #[serde(
2172 default,
2173 skip_serializing_if = "Option::is_none",
2174 deserialize_with = "deserialize_effective_severity"
2175 )]
2176 pub effective_severity: Option<EffectiveSeverity>,
2177}
2178
2179impl RouteCollisionFinding {
2180 #[must_use]
2184 pub fn with_actions(collision: RouteCollision) -> Self {
2185 let actions = vec![
2186 IssueAction::Fix(FixAction {
2187 kind: FixActionType::ResolveRouteCollision,
2188 auto_fixable: false,
2189 description: "Two or more files resolve to the same URL. Move or merge one so \
2190 each URL has a single owner. Route groups `(name)` and parallel \
2191 slots `@name` are the only legal same-URL shapes."
2192 .to_string(),
2193 note: Some(
2194 "Next.js fails the build with \"You cannot have two parallel pages that \
2195 resolve to the same path\". See the sibling `conflicting_paths` array for \
2196 the other files that own this URL."
2197 .to_string(),
2198 ),
2199 available_in_catalogs: None,
2200 suggested_target: None,
2201 }),
2202 IssueAction::SuppressFile(SuppressFileAction {
2203 kind: SuppressFileKind::SuppressFile,
2204 auto_fixable: false,
2205 description: "Escape hatch only: a file-level suppress silences the finding but \
2206 does NOT make `next build` pass. Prefer moving or merging a file."
2207 .to_string(),
2208 comment: "// fallow-ignore-file route-collision".to_string(),
2209 }),
2210 ];
2211 Self {
2212 collision,
2213 actions,
2214 introduced: None,
2215 effective_severity: None,
2216 }
2217 }
2218}
2219
2220#[derive(Debug, Clone, Serialize, Deserialize)]
2225#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2226pub struct DynamicSegmentNameConflictFinding {
2227 #[serde(flatten)]
2229 pub conflict: DynamicSegmentNameConflict,
2230 pub actions: Vec<IssueAction>,
2233 #[serde(default, skip_serializing_if = "Option::is_none")]
2236 pub introduced: Option<AuditIntroduced>,
2237 #[serde(
2242 default,
2243 skip_serializing_if = "Option::is_none",
2244 deserialize_with = "deserialize_effective_severity"
2245 )]
2246 pub effective_severity: Option<EffectiveSeverity>,
2247}
2248
2249impl DynamicSegmentNameConflictFinding {
2250 #[must_use]
2253 pub fn with_actions(conflict: DynamicSegmentNameConflict) -> Self {
2254 let actions = vec![
2255 IssueAction::Fix(FixAction {
2256 kind: FixActionType::ResolveDynamicSegmentNameConflict,
2257 auto_fixable: false,
2258 description: "Sibling dynamic segments at the same position use different param \
2259 names. Rename them to one consistent slug name (e.g. pick `[id]` \
2260 or `[slug]` for both)."
2261 .to_string(),
2262 note: Some(
2263 "Next.js throws \"You cannot use different slug names for the same dynamic \
2264 path\" at dev / runtime when the position is hit; `next build` does not \
2265 catch it. See the sibling `conflicting_segments` array."
2266 .to_string(),
2267 ),
2268 available_in_catalogs: None,
2269 suggested_target: None,
2270 }),
2271 IssueAction::SuppressFile(SuppressFileAction {
2272 kind: SuppressFileKind::SuppressFile,
2273 auto_fixable: false,
2274 description: "Escape hatch only: a file-level suppress silences the finding but \
2275 does NOT stop Next.js from throwing at dev / runtime. Prefer \
2276 renaming the segments."
2277 .to_string(),
2278 comment: "// fallow-ignore-file dynamic-segment-name-conflict".to_string(),
2279 }),
2280 ];
2281 Self {
2282 conflict,
2283 actions,
2284 introduced: None,
2285 effective_severity: None,
2286 }
2287 }
2288}
2289
2290#[derive(Debug, Clone, Serialize, Deserialize)]
2293#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2294pub struct UnusedEnumMemberFinding {
2295 #[serde(flatten)]
2297 pub member: UnusedMember,
2298 pub actions: Vec<IssueAction>,
2301 #[serde(default, skip_serializing_if = "Option::is_none")]
2304 pub introduced: Option<AuditIntroduced>,
2305 #[serde(
2310 default,
2311 skip_serializing_if = "Option::is_none",
2312 deserialize_with = "deserialize_effective_severity"
2313 )]
2314 pub effective_severity: Option<EffectiveSeverity>,
2315 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2322 pub reachability_caveats: Vec<ReachabilityCaveat>,
2323}
2324
2325impl UnusedEnumMemberFinding {
2326 #[must_use]
2328 pub fn with_actions(member: UnusedMember) -> Self {
2329 let actions = vec![
2330 IssueAction::Fix(FixAction {
2331 kind: FixActionType::RemoveEnumMember,
2332 auto_fixable: true,
2333 description: "Remove this enum member".to_string(),
2334 note: None,
2335 available_in_catalogs: None,
2336 suggested_target: None,
2337 }),
2338 IssueAction::SuppressLine(SuppressLineAction {
2339 kind: SuppressLineKind::SuppressLine,
2340 auto_fixable: false,
2341 description: "Suppress with an inline comment above the line".to_string(),
2342 comment: "// fallow-ignore-next-line unused-enum-member".to_string(),
2343 scope: None,
2344 }),
2345 ];
2346 Self {
2347 member,
2348 actions,
2349 introduced: None,
2350 effective_severity: None,
2351 reachability_caveats: Vec::new(),
2352 }
2353 }
2354}
2355
2356#[derive(Debug, Clone, Serialize, Deserialize)]
2361#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2362pub struct UnusedClassMemberFinding {
2363 #[serde(flatten)]
2365 pub member: UnusedMember,
2366 pub actions: Vec<IssueAction>,
2369 #[serde(default, skip_serializing_if = "Option::is_none")]
2371 pub semantic: Option<SemanticCandidateDecision>,
2372 #[serde(skip)]
2376 #[cfg_attr(feature = "schema", schemars(skip))]
2377 pub semantic_only_candidate: bool,
2378 #[serde(default, skip_serializing_if = "Option::is_none")]
2381 pub introduced: Option<AuditIntroduced>,
2382 #[serde(
2387 default,
2388 skip_serializing_if = "Option::is_none",
2389 deserialize_with = "deserialize_effective_severity"
2390 )]
2391 pub effective_severity: Option<EffectiveSeverity>,
2392 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2400 pub reachability_caveats: Vec<ReachabilityCaveat>,
2401}
2402
2403impl UnusedClassMemberFinding {
2404 #[must_use]
2409 pub fn with_actions(member: UnusedMember) -> Self {
2410 let actions = vec![
2411 IssueAction::Fix(FixAction {
2412 kind: FixActionType::RemoveClassMember,
2413 auto_fixable: false,
2414 description: "Remove this class member".to_string(),
2415 note: Some(
2416 "Class member may be used via dependency injection or decorators".to_string(),
2417 ),
2418 available_in_catalogs: None,
2419 suggested_target: None,
2420 }),
2421 IssueAction::SuppressLine(SuppressLineAction {
2422 kind: SuppressLineKind::SuppressLine,
2423 auto_fixable: false,
2424 description: "Suppress with an inline comment above the line".to_string(),
2425 comment: "// fallow-ignore-next-line unused-class-member".to_string(),
2426 scope: None,
2427 }),
2428 ];
2429 Self {
2430 member,
2431 actions,
2432 semantic: None,
2433 semantic_only_candidate: false,
2434 introduced: None,
2435 effective_severity: None,
2436 reachability_caveats: Vec::new(),
2437 }
2438 }
2439
2440 #[must_use]
2443 pub const fn semantic_only_candidate(mut self) -> Self {
2444 self.semantic_only_candidate = true;
2445 self
2446 }
2447
2448 pub fn set_semantic_decision(&mut self, decision: SemanticCandidateDecision) {
2461 let evidence_complete = self.reachability_caveats.is_empty();
2462 if let Some(IssueAction::Fix(action)) = self.actions.first_mut() {
2463 action.auto_fixable = decision.closed_world_eligible && evidence_complete;
2464 action.note = Some(if evidence_complete {
2465 decision.explanation.clone()
2466 } else {
2467 INCOMPLETE_EVIDENCE_NOTE.to_string()
2468 });
2469 }
2470 self.semantic = Some(decision);
2471 }
2472}
2473
2474#[derive(Debug, Clone, Serialize, Deserialize)]
2483#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2484pub struct UnusedStoreMemberFinding {
2485 #[serde(flatten)]
2487 pub member: UnusedMember,
2488 pub actions: Vec<IssueAction>,
2491 #[serde(default, skip_serializing_if = "Option::is_none")]
2494 pub introduced: Option<AuditIntroduced>,
2495 #[serde(
2500 default,
2501 skip_serializing_if = "Option::is_none",
2502 deserialize_with = "deserialize_effective_severity"
2503 )]
2504 pub effective_severity: Option<EffectiveSeverity>,
2505 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2514 pub reachability_caveats: Vec<ReachabilityCaveat>,
2515}
2516
2517impl UnusedStoreMemberFinding {
2518 #[must_use]
2522 pub fn with_actions(member: UnusedMember) -> Self {
2523 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
2524 kind: SuppressLineKind::SuppressLine,
2525 auto_fixable: false,
2526 description: "Suppress with an inline comment above the line".to_string(),
2527 comment: "// fallow-ignore-next-line unused-store-member".to_string(),
2528 scope: None,
2529 })];
2530 Self {
2531 member,
2532 actions,
2533 introduced: None,
2534 effective_severity: None,
2535 reachability_caveats: Vec::new(),
2536 }
2537 }
2538}
2539
2540fn build_unused_dependency_actions(
2551 dep: &UnusedDependency,
2552 package_json_location: &str,
2553 suppress_issue_kind: &str,
2554) -> Vec<IssueAction> {
2555 let mut actions = Vec::with_capacity(2);
2556 let cross_workspace = !dep.used_in_workspaces.is_empty();
2557 actions.push(if cross_workspace {
2558 IssueAction::Fix(FixAction {
2559 kind: FixActionType::MoveDependency,
2560 auto_fixable: false,
2561 description: "Move this dependency to the workspace package.json that imports it"
2562 .to_string(),
2563 note: Some(
2564 "fallow fix will not remove dependencies that are imported by another workspace"
2565 .to_string(),
2566 ),
2567 available_in_catalogs: None,
2568 suggested_target: None,
2569 })
2570 } else {
2571 IssueAction::Fix(FixAction {
2572 kind: FixActionType::RemoveDependency,
2573 auto_fixable: true,
2574 description: format!("Remove from {package_json_location} in package.json"),
2575 note: None,
2576 available_in_catalogs: None,
2577 suggested_target: None,
2578 })
2579 });
2580 actions.push(build_ignore_dependencies_suppress_action(
2581 &dep.package_name,
2582 suppress_issue_kind,
2583 ));
2584 actions
2585}
2586
2587fn build_ignore_dependencies_suppress_action(
2595 package_name: &str,
2596 _suppress_issue_kind: &str,
2597) -> IssueAction {
2598 IssueAction::AddToConfig(AddToConfigAction {
2599 kind: AddToConfigKind::AddToConfig,
2600 auto_fixable: false,
2601 description: format!("Add \"{package_name}\" to ignoreDependencies in fallow config"),
2602 config_key: "ignoreDependencies".to_string(),
2603 value: AddToConfigValue::Scalar(package_name.to_string()),
2604 value_schema: Some(
2605 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items"
2606 .to_string(),
2607 ),
2608 })
2609}
2610
2611#[derive(Debug, Clone, Serialize, Deserialize)]
2617#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2618pub struct UnusedDependencyFinding {
2619 #[serde(flatten)]
2621 pub dep: UnusedDependency,
2622 pub actions: Vec<IssueAction>,
2625 #[serde(default, skip_serializing_if = "Option::is_none")]
2628 pub introduced: Option<AuditIntroduced>,
2629 #[serde(
2634 default,
2635 skip_serializing_if = "Option::is_none",
2636 deserialize_with = "deserialize_effective_severity"
2637 )]
2638 pub effective_severity: Option<EffectiveSeverity>,
2639 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2646 pub reachability_caveats: Vec<ReachabilityCaveat>,
2647}
2648
2649impl UnusedDependencyFinding {
2650 #[must_use]
2653 pub fn with_actions(dep: UnusedDependency) -> Self {
2654 let actions = build_unused_dependency_actions(&dep, "dependencies", "unused-dependency");
2655 Self {
2656 dep,
2657 actions,
2658 introduced: None,
2659 effective_severity: None,
2660 reachability_caveats: Vec::new(),
2661 }
2662 }
2663}
2664
2665#[derive(Debug, Clone, Serialize, Deserialize)]
2671#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2672pub struct UnusedDevDependencyFinding {
2673 #[serde(flatten)]
2675 pub dep: UnusedDependency,
2676 pub actions: Vec<IssueAction>,
2679 #[serde(default, skip_serializing_if = "Option::is_none")]
2682 pub introduced: Option<AuditIntroduced>,
2683 #[serde(
2688 default,
2689 skip_serializing_if = "Option::is_none",
2690 deserialize_with = "deserialize_effective_severity"
2691 )]
2692 pub effective_severity: Option<EffectiveSeverity>,
2693 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2700 pub reachability_caveats: Vec<ReachabilityCaveat>,
2701}
2702
2703impl UnusedDevDependencyFinding {
2704 #[must_use]
2706 pub fn with_actions(dep: UnusedDependency) -> Self {
2707 let actions =
2708 build_unused_dependency_actions(&dep, "devDependencies", "unused-dev-dependency");
2709 Self {
2710 dep,
2711 actions,
2712 introduced: None,
2713 effective_severity: None,
2714 reachability_caveats: Vec::new(),
2715 }
2716 }
2717}
2718
2719#[derive(Debug, Clone, Serialize, Deserialize)]
2725#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2726pub struct UnusedOptionalDependencyFinding {
2727 #[serde(flatten)]
2729 pub dep: UnusedDependency,
2730 pub actions: Vec<IssueAction>,
2733 #[serde(default, skip_serializing_if = "Option::is_none")]
2736 pub introduced: Option<AuditIntroduced>,
2737 #[serde(
2742 default,
2743 skip_serializing_if = "Option::is_none",
2744 deserialize_with = "deserialize_effective_severity"
2745 )]
2746 pub effective_severity: Option<EffectiveSeverity>,
2747 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2754 pub reachability_caveats: Vec<ReachabilityCaveat>,
2755}
2756
2757impl UnusedOptionalDependencyFinding {
2758 #[must_use]
2760 pub fn with_actions(dep: UnusedDependency) -> Self {
2761 let actions =
2762 build_unused_dependency_actions(&dep, "optionalDependencies", "unused-dependency");
2763 Self {
2764 dep,
2765 actions,
2766 introduced: None,
2767 effective_severity: None,
2768 reachability_caveats: Vec::new(),
2769 }
2770 }
2771}
2772
2773#[derive(Debug, Clone, Serialize, Deserialize)]
2777#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2778pub struct UnlistedDependencyFinding {
2779 #[serde(flatten)]
2781 pub dep: UnlistedDependency,
2782 pub actions: Vec<IssueAction>,
2785 #[serde(default, skip_serializing_if = "Option::is_none")]
2788 pub introduced: Option<AuditIntroduced>,
2789 #[serde(
2794 default,
2795 skip_serializing_if = "Option::is_none",
2796 deserialize_with = "deserialize_effective_severity"
2797 )]
2798 pub effective_severity: Option<EffectiveSeverity>,
2799}
2800
2801impl UnlistedDependencyFinding {
2802 #[must_use]
2804 pub fn with_actions(dep: UnlistedDependency) -> Self {
2805 let actions = vec![
2806 IssueAction::Fix(FixAction {
2807 kind: FixActionType::InstallDependency,
2808 auto_fixable: false,
2809 description: "Add this package to dependencies in package.json".to_string(),
2810 note: Some(
2811 "Verify this package should be a direct dependency before adding".to_string(),
2812 ),
2813 available_in_catalogs: None,
2814 suggested_target: None,
2815 }),
2816 build_ignore_dependencies_suppress_action(&dep.package_name, "unlisted-dependency"),
2817 ];
2818 Self {
2819 dep,
2820 actions,
2821 introduced: None,
2822 effective_severity: None,
2823 }
2824 }
2825}
2826
2827#[derive(Debug, Clone, Serialize, Deserialize)]
2831#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2832pub struct TypeOnlyDependencyFinding {
2833 #[serde(flatten)]
2835 pub dep: TypeOnlyDependency,
2836 pub actions: Vec<IssueAction>,
2839 #[serde(default, skip_serializing_if = "Option::is_none")]
2842 pub introduced: Option<AuditIntroduced>,
2843 #[serde(
2848 default,
2849 skip_serializing_if = "Option::is_none",
2850 deserialize_with = "deserialize_effective_severity"
2851 )]
2852 pub effective_severity: Option<EffectiveSeverity>,
2853}
2854
2855impl TypeOnlyDependencyFinding {
2856 #[must_use]
2858 pub fn with_actions(dep: TypeOnlyDependency) -> Self {
2859 let actions = vec![
2860 IssueAction::Fix(FixAction {
2861 kind: FixActionType::MoveToDev,
2862 auto_fixable: false,
2863 description: "Move to devDependencies (only type imports are used)".to_string(),
2864 note: Some(
2865 "Type imports are erased at runtime so this dependency is not needed in production"
2866 .to_string(),
2867 ),
2868 available_in_catalogs: None,
2869 suggested_target: None,
2870 }),
2871 build_ignore_dependencies_suppress_action(&dep.package_name, "type-only-dependency"),
2872 ];
2873 Self {
2874 dep,
2875 actions,
2876 introduced: None,
2877 effective_severity: None,
2878 }
2879 }
2880}
2881
2882#[derive(Debug, Clone, Serialize, Deserialize)]
2886#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2887pub struct TestOnlyDependencyFinding {
2888 #[serde(flatten)]
2890 pub dep: TestOnlyDependency,
2891 pub actions: Vec<IssueAction>,
2894 #[serde(default, skip_serializing_if = "Option::is_none")]
2897 pub introduced: Option<AuditIntroduced>,
2898 #[serde(
2903 default,
2904 skip_serializing_if = "Option::is_none",
2905 deserialize_with = "deserialize_effective_severity"
2906 )]
2907 pub effective_severity: Option<EffectiveSeverity>,
2908}
2909
2910impl TestOnlyDependencyFinding {
2911 #[must_use]
2913 pub fn with_actions(dep: TestOnlyDependency) -> Self {
2914 let actions = vec![
2915 IssueAction::Fix(FixAction {
2916 kind: FixActionType::MoveToDev,
2917 auto_fixable: false,
2918 description: "Move to devDependencies (only test files import this)".to_string(),
2919 note: Some(
2920 "Only test files import this package so it does not need to be a production dependency"
2921 .to_string(),
2922 ),
2923 available_in_catalogs: None,
2924 suggested_target: None,
2925 }),
2926 build_ignore_dependencies_suppress_action(&dep.package_name, "test-only-dependency"),
2927 ];
2928 Self {
2929 dep,
2930 actions,
2931 introduced: None,
2932 effective_severity: None,
2933 }
2934 }
2935}
2936
2937#[derive(Debug, Clone, Serialize, Deserialize)]
2942#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2943pub struct DevDependencyInProductionFinding {
2944 #[serde(flatten)]
2946 pub dep: DevDependencyInProduction,
2947 pub actions: Vec<IssueAction>,
2950 #[serde(default, skip_serializing_if = "Option::is_none")]
2953 pub introduced: Option<AuditIntroduced>,
2954 #[serde(
2959 default,
2960 skip_serializing_if = "Option::is_none",
2961 deserialize_with = "deserialize_effective_severity"
2962 )]
2963 pub effective_severity: Option<EffectiveSeverity>,
2964}
2965
2966impl DevDependencyInProductionFinding {
2967 #[must_use]
2969 pub fn with_actions(dep: DevDependencyInProduction) -> Self {
2970 let actions = vec![
2971 IssueAction::Fix(FixAction {
2972 kind: FixActionType::MoveToProd,
2973 auto_fixable: false,
2974 description:
2975 "Move to dependencies if the deployment installs them (production code imports this)"
2976 .to_string(),
2977 note: Some(
2978 "A production-only install (`pnpm install --prod`) omits devDependencies, so an import resolved at runtime breaks. A build that inlines the package into its output resolves nothing at runtime, and moving it there can instead make the deployment require an install it did not need"
2979 .to_string(),
2980 ),
2981 available_in_catalogs: None,
2982 suggested_target: None,
2983 }),
2984 build_ignore_dependencies_suppress_action(
2985 &dep.package_name,
2986 "dev-dependency-in-production",
2987 ),
2988 ];
2989 Self {
2990 dep,
2991 actions,
2992 introduced: None,
2993 effective_severity: None,
2994 }
2995 }
2996}
2997
2998#[derive(Debug, Clone, Serialize, Deserialize)]
3019#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3020pub struct DuplicateExportFinding {
3021 #[serde(flatten)]
3023 pub export: DuplicateExport,
3024 pub actions: Vec<IssueAction>,
3027 #[serde(default, skip_serializing_if = "Option::is_none")]
3030 pub introduced: Option<AuditIntroduced>,
3031 #[serde(
3036 default,
3037 skip_serializing_if = "Option::is_none",
3038 deserialize_with = "deserialize_effective_severity"
3039 )]
3040 pub effective_severity: Option<EffectiveSeverity>,
3041}
3042
3043impl DuplicateExportFinding {
3044 #[must_use]
3053 pub fn with_actions(export: DuplicateExport) -> Self {
3054 let mut actions: Vec<IssueAction> = Vec::with_capacity(3);
3055
3056 if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
3057 actions.push(IssueAction::AddToConfig(AddToConfigAction {
3058 kind: AddToConfigKind::AddToConfig,
3059 auto_fixable: false,
3060 description: "Add an ignoreExports rule so these files are excluded from duplicate-export grouping (use when this duplication is an intentional namespace-barrel API).".to_string(),
3061 config_key: "ignoreExports".to_string(),
3062 value: AddToConfigValue::ExportsRules(rules),
3063 value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
3064 }));
3065 }
3066
3067 actions.push(IssueAction::Fix(FixAction {
3068 kind: FixActionType::RemoveDuplicate,
3069 auto_fixable: false,
3070 description: "Keep one canonical export location and remove the others".to_string(),
3071 note: Some(NAMESPACE_BARREL_HINT.to_string()),
3072 available_in_catalogs: None,
3073 suggested_target: None,
3074 }));
3075
3076 actions.push(IssueAction::SuppressLine(SuppressLineAction {
3077 kind: SuppressLineKind::SuppressLine,
3078 auto_fixable: false,
3079 description: "Suppress with an inline comment above the line".to_string(),
3080 comment: "// fallow-ignore-next-line duplicate-export".to_string(),
3081 scope: Some(SuppressLineScope::PerLocation),
3082 }));
3083
3084 Self {
3085 export,
3086 actions,
3087 introduced: None,
3088 effective_severity: None,
3089 }
3090 }
3091
3092 pub fn set_config_fixable(&mut self, fixable: bool) {
3098 if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
3099 action.auto_fixable = fixable;
3100 }
3101 }
3102}
3103
3104fn build_duplicate_exports_ignore_rules(
3108 export: &DuplicateExport,
3109) -> Option<Vec<IgnoreExportsRule>> {
3110 let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
3111 for loc in &export.locations {
3112 let path = loc.path.to_string_lossy().replace('\\', "/");
3120 if path.is_empty() {
3121 continue;
3122 }
3123 if entries.iter().any(|existing| existing.file == path) {
3124 continue;
3125 }
3126 entries.push(IgnoreExportsRule {
3127 file: path,
3128 exports: vec!["*".to_string()],
3129 });
3130 }
3131 if entries.is_empty() {
3132 None
3133 } else {
3134 Some(entries)
3135 }
3136}
3137
3138#[derive(Debug, Clone, Serialize, Deserialize)]
3142#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3143pub struct UnusedCatalogEntryFinding {
3144 #[serde(flatten)]
3146 pub entry: UnusedCatalogEntry,
3147 pub actions: Vec<IssueAction>,
3149 #[serde(default, skip_serializing_if = "Option::is_none")]
3152 pub introduced: Option<AuditIntroduced>,
3153 #[serde(
3158 default,
3159 skip_serializing_if = "Option::is_none",
3160 deserialize_with = "deserialize_effective_severity"
3161 )]
3162 pub effective_severity: Option<EffectiveSeverity>,
3163}
3164
3165impl UnusedCatalogEntryFinding {
3166 #[must_use]
3171 pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
3172 let is_pnpm_source = is_pnpm_catalog_source(&entry.path);
3173 let auto_fixable = entry.hardcoded_consumers.is_empty() && is_pnpm_source;
3174 let note = if is_pnpm_source {
3175 Some(
3176 "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing"
3177 .to_string(),
3178 )
3179 } else {
3180 Some(
3181 "fallow fix only edits pnpm-workspace.yaml catalog entries. Edit Bun package.json catalogs manually."
3182 .to_string(),
3183 )
3184 };
3185 let mut actions = vec![IssueAction::Fix(FixAction {
3186 kind: FixActionType::RemoveCatalogEntry,
3187 auto_fixable,
3188 description: if is_pnpm_source {
3189 "Remove the entry from pnpm-workspace.yaml".to_string()
3190 } else {
3191 "Remove the entry from the catalog source file manually".to_string()
3192 },
3193 note,
3194 available_in_catalogs: None,
3195 suggested_target: None,
3196 })];
3197 if is_pnpm_source {
3198 actions.push(IssueAction::SuppressLine(SuppressLineAction {
3199 kind: SuppressLineKind::SuppressLine,
3200 auto_fixable: false,
3201 description: "Suppress with a YAML comment above the line".to_string(),
3202 comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
3203 scope: None,
3204 }));
3205 }
3206 Self {
3207 entry,
3208 actions,
3209 introduced: None,
3210 effective_severity: None,
3211 }
3212 }
3213}
3214
3215#[derive(Debug, Clone, Serialize, Deserialize)]
3219#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3220pub struct EmptyCatalogGroupFinding {
3221 #[serde(flatten)]
3223 pub group: EmptyCatalogGroup,
3224 pub actions: Vec<IssueAction>,
3226 #[serde(default, skip_serializing_if = "Option::is_none")]
3229 pub introduced: Option<AuditIntroduced>,
3230 #[serde(
3235 default,
3236 skip_serializing_if = "Option::is_none",
3237 deserialize_with = "deserialize_effective_severity"
3238 )]
3239 pub effective_severity: Option<EffectiveSeverity>,
3240}
3241
3242impl EmptyCatalogGroupFinding {
3243 #[must_use]
3245 pub fn with_actions(group: EmptyCatalogGroup) -> Self {
3246 let auto_fixable = is_pnpm_catalog_source(&group.path);
3247 let mut actions = vec![IssueAction::Fix(FixAction {
3248 kind: FixActionType::RemoveEmptyCatalogGroup,
3249 auto_fixable,
3250 description: if auto_fixable {
3251 "Remove the empty named catalog group from pnpm-workspace.yaml".to_string()
3252 } else {
3253 "Remove the empty named catalog group from the catalog source file manually"
3254 .to_string()
3255 },
3256 note: Some(if auto_fixable {
3257 "Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
3258 .to_string()
3259 } else {
3260 "fallow fix only edits pnpm-workspace.yaml catalog groups. Edit Bun package.json catalogs manually."
3261 .to_string()
3262 }),
3263 available_in_catalogs: None,
3264 suggested_target: None,
3265 })];
3266 if auto_fixable {
3267 actions.push(IssueAction::SuppressLine(SuppressLineAction {
3268 kind: SuppressLineKind::SuppressLine,
3269 auto_fixable: false,
3270 description: "Suppress with a YAML comment above the line".to_string(),
3271 comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
3272 scope: None,
3273 }));
3274 }
3275 Self {
3276 group,
3277 actions,
3278 introduced: None,
3279 effective_severity: None,
3280 }
3281 }
3282}
3283
3284fn is_pnpm_catalog_source(path: &Path) -> bool {
3285 path == Path::new(PNPM_WORKSPACE_FILE)
3286}
3287
3288#[derive(Debug, Clone, Serialize, Deserialize)]
3296#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3297pub struct UnresolvedCatalogReferenceFinding {
3298 #[serde(flatten)]
3300 pub reference: UnresolvedCatalogReference,
3301 pub actions: Vec<IssueAction>,
3304 #[serde(default, skip_serializing_if = "Option::is_none")]
3307 pub introduced: Option<AuditIntroduced>,
3308 #[serde(
3313 default,
3314 skip_serializing_if = "Option::is_none",
3315 deserialize_with = "deserialize_effective_severity"
3316 )]
3317 pub effective_severity: Option<EffectiveSeverity>,
3318}
3319
3320impl UnresolvedCatalogReferenceFinding {
3321 #[must_use]
3325 pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
3326 let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
3331 let primary = catalog_reference_primary_action(&reference);
3332 let fallback = remove_catalog_reference_action();
3333 let suppress = suppress_catalog_reference_action(&reference, consumer_path);
3334
3335 Self {
3336 reference,
3337 actions: vec![primary, fallback, suppress],
3338 introduced: None,
3339 effective_severity: None,
3340 }
3341 }
3342}
3343
3344fn catalog_reference_primary_action(reference: &UnresolvedCatalogReference) -> IssueAction {
3345 if reference.available_in_catalogs.is_empty() {
3346 return IssueAction::Fix(FixAction {
3347 kind: FixActionType::AddCatalogEntry,
3348 auto_fixable: false,
3349 description: format!(
3350 "Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
3351 reference.entry_name, reference.catalog_name
3352 ),
3353 note: Some(
3354 "Pin a version that satisfies the consumer's import; no other catalog declares this package today"
3355 .to_string(),
3356 ),
3357 available_in_catalogs: None,
3358 suggested_target: None,
3359 });
3360 }
3361
3362 let available = reference.available_in_catalogs.clone();
3363 let suggested_target = (available.len() == 1).then(|| available[0].clone());
3364 IssueAction::Fix(FixAction {
3365 kind: FixActionType::UpdateCatalogReference,
3366 auto_fixable: false,
3367 description: format!(
3368 "Switch the reference from `catalog:{}` to a catalog that declares `{}`",
3369 reference.catalog_name, reference.entry_name
3370 ),
3371 note: None,
3372 available_in_catalogs: Some(available),
3373 suggested_target,
3374 })
3375}
3376
3377fn remove_catalog_reference_action() -> IssueAction {
3378 IssueAction::Fix(FixAction {
3379 kind: FixActionType::RemoveCatalogReference,
3380 auto_fixable: false,
3381 description: "Remove the catalog reference and pin a hardcoded version in package.json"
3382 .to_string(),
3383 note: Some(
3384 "Use only when neither another catalog declares the package nor the named catalog should grow to include it"
3385 .to_string(),
3386 ),
3387 available_in_catalogs: None,
3388 suggested_target: None,
3389 })
3390}
3391
3392fn suppress_catalog_reference_action(
3393 reference: &UnresolvedCatalogReference,
3394 consumer_path: String,
3395) -> IssueAction {
3396 let mut suppress_value = serde_json::Map::new();
3397 suppress_value.insert(
3398 "package".to_string(),
3399 serde_json::Value::String(reference.entry_name.clone()),
3400 );
3401 suppress_value.insert(
3402 "catalog".to_string(),
3403 serde_json::Value::String(reference.catalog_name.clone()),
3404 );
3405 suppress_value.insert(
3406 "consumer".to_string(),
3407 serde_json::Value::String(consumer_path),
3408 );
3409 IssueAction::AddToConfig(AddToConfigAction {
3410 kind: AddToConfigKind::AddToConfig,
3411 auto_fixable: false,
3412 description: "Suppress this reference via ignoreCatalogReferences in fallow config (use when the catalog edit is intentionally landing in a separate PR or the package is a placeholder).".to_string(),
3413 config_key: "ignoreCatalogReferences".to_string(),
3414 value: AddToConfigValue::RuleObject(suppress_value),
3415 value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
3416 })
3417}
3418
3419#[derive(Debug, Clone, Serialize, Deserialize)]
3424#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3425pub struct UnusedDependencyOverrideFinding {
3426 #[serde(flatten)]
3428 pub entry: UnusedDependencyOverride,
3429 pub actions: Vec<IssueAction>,
3431 #[serde(default, skip_serializing_if = "Option::is_none")]
3434 pub introduced: Option<AuditIntroduced>,
3435 #[serde(
3440 default,
3441 skip_serializing_if = "Option::is_none",
3442 deserialize_with = "deserialize_effective_severity"
3443 )]
3444 pub effective_severity: Option<EffectiveSeverity>,
3445}
3446
3447impl UnusedDependencyOverrideFinding {
3448 #[must_use]
3450 pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
3451 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
3452 actions.push(IssueAction::Fix(FixAction {
3453 kind: FixActionType::RemoveDependencyOverride,
3454 auto_fixable: false,
3455 description: "Remove the package-manager override entry from its declaration source"
3456 .to_string(),
3457 note: Some(
3458 "Conservative static check; verify against the active package manager's frozen-lockfile install before removing in case the override targets a transitive dependency (CVE-fix pattern)"
3459 .to_string(),
3460 ),
3461 available_in_catalogs: None,
3462 suggested_target: None,
3463 }));
3464
3465 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
3466 Some(&entry.target_package),
3467 &entry.raw_key,
3468 entry.source,
3469 ) {
3470 actions.push(suppress);
3471 }
3472
3473 Self {
3474 entry,
3475 actions,
3476 introduced: None,
3477 effective_severity: None,
3478 }
3479 }
3480}
3481
3482#[derive(Debug, Clone, Serialize, Deserialize)]
3488#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3489pub struct MisconfiguredDependencyOverrideFinding {
3490 #[serde(flatten)]
3492 pub entry: MisconfiguredDependencyOverride,
3493 pub actions: Vec<IssueAction>,
3495 #[serde(default, skip_serializing_if = "Option::is_none")]
3498 pub introduced: Option<AuditIntroduced>,
3499 #[serde(
3504 default,
3505 skip_serializing_if = "Option::is_none",
3506 deserialize_with = "deserialize_effective_severity"
3507 )]
3508 pub effective_severity: Option<EffectiveSeverity>,
3509}
3510
3511impl MisconfiguredDependencyOverrideFinding {
3512 #[must_use]
3517 pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
3518 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
3519 actions.push(IssueAction::Fix(FixAction {
3520 kind: FixActionType::FixDependencyOverride,
3521 auto_fixable: false,
3522 description:
3523 "Fix the package-manager override key or value: invalid entries are rejected or ignored"
3524 .to_string(),
3525 note: Some(
3526 "Common shapes: bare `pkg`, scoped `@scope/pkg`, version-selector `pkg@<2`, parent-chain `parent>child`. Valid values include semver ranges, `-` (removal), `$ref` (self-ref), and `npm:alias@^1`."
3527 .to_string(),
3528 ),
3529 available_in_catalogs: None,
3530 suggested_target: None,
3531 }));
3532
3533 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
3534 entry.target_package.as_deref(),
3535 &entry.raw_key,
3536 entry.source,
3537 ) {
3538 actions.push(suppress);
3539 }
3540
3541 Self {
3542 entry,
3543 actions,
3544 introduced: None,
3545 effective_severity: None,
3546 }
3547 }
3548}
3549
3550fn build_ignore_dependency_overrides_suppress(
3555 target_package: Option<&str>,
3556 raw_key: &str,
3557 source: DependencyOverrideSource,
3558) -> Option<IssueAction> {
3559 let package = target_package
3560 .filter(|s| !s.is_empty())
3561 .or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
3562 .to_string();
3563 let mut value = serde_json::Map::new();
3564 value.insert("package".to_string(), serde_json::Value::String(package));
3565 value.insert(
3566 "source".to_string(),
3567 serde_json::Value::String(source.as_label().to_string()),
3568 );
3569 Some(IssueAction::AddToConfig(AddToConfigAction {
3570 kind: AddToConfigKind::AddToConfig,
3571 auto_fixable: false,
3572 description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
3573 config_key: "ignoreDependencyOverrides".to_string(),
3574 value: AddToConfigValue::RuleObject(value),
3575 value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
3576 }))
3577}
3578
3579impl_caveated_finding!(
3586 UnusedFileFinding,
3587 UnusedExportFinding,
3588 UnusedTypeFinding,
3589 UnusedEnumMemberFinding,
3590 UnusedClassMemberFinding,
3591 UnusedStoreMemberFinding,
3592 UnusedDependencyFinding,
3593 UnusedDevDependencyFinding,
3594 UnusedOptionalDependencyFinding,
3595);
3596
3597#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3616#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3617#[serde(rename_all = "lowercase")]
3618pub enum EffectiveSeverity {
3619 Error,
3621 Warn,
3623}
3624
3625pub fn deserialize_effective_severity<'de, D>(
3635 deserializer: D,
3636) -> Result<Option<EffectiveSeverity>, D::Error>
3637where
3638 D: serde::Deserializer<'de>,
3639{
3640 #[derive(Deserialize)]
3641 #[serde(untagged)]
3642 enum Tolerant {
3643 Known(EffectiveSeverity),
3644 Unknown(serde::de::IgnoredAny),
3645 }
3646 Ok(match Option::<Tolerant>::deserialize(deserializer)? {
3647 Some(Tolerant::Known(severity)) => Some(severity),
3648 Some(Tolerant::Unknown(_)) | None => None,
3649 })
3650}
3651
3652pub trait GatedFinding {
3658 fn effective_severity(&self) -> Option<EffectiveSeverity>;
3660
3661 fn set_effective_severity(&mut self, severity: Option<EffectiveSeverity>);
3663}
3664
3665macro_rules! impl_gated_finding {
3667 ($($finding:ty),+ $(,)?) => {
3668 $(
3669 impl GatedFinding for $finding {
3670 fn effective_severity(&self) -> Option<EffectiveSeverity> {
3671 self.effective_severity
3672 }
3673
3674 fn set_effective_severity(&mut self, severity: Option<EffectiveSeverity>) {
3675 self.effective_severity = severity;
3676 }
3677 }
3678 )+
3679 };
3680}
3681
3682impl_gated_finding!(
3683 UnusedFileFinding,
3684 PrivateTypeLeakFinding,
3685 DeprecatedExportInUseFinding,
3686 UnresolvedImportFinding,
3687 CircularDependencyFinding,
3688 ReExportCycleFinding,
3689 BoundaryViolationFinding,
3690 BoundaryCoverageViolationFinding,
3691 BoundaryCallViolationFinding,
3692 UnusedExportFinding,
3693 UnusedTypeFinding,
3694 InvalidClientExportFinding,
3695 MixedClientServerBarrelFinding,
3696 MisplacedDirectiveFinding,
3697 UnprovidedInjectFinding,
3698 UnusedServerActionFinding,
3699 UnusedLoadDataKeyFinding,
3700 UnrenderedComponentFinding,
3701 UnusedComponentPropFinding,
3702 UnusedComponentEmitFinding,
3703 UnusedSvelteEventFinding,
3704 UnusedComponentInputFinding,
3705 UnusedComponentOutputFinding,
3706 RouteCollisionFinding,
3707 DynamicSegmentNameConflictFinding,
3708 UnusedEnumMemberFinding,
3709 UnusedClassMemberFinding,
3710 UnusedStoreMemberFinding,
3711 UnusedDependencyFinding,
3712 UnusedDevDependencyFinding,
3713 UnusedOptionalDependencyFinding,
3714 UnlistedDependencyFinding,
3715 TypeOnlyDependencyFinding,
3716 TestOnlyDependencyFinding,
3717 DevDependencyInProductionFinding,
3718 DuplicateExportFinding,
3719 UnusedCatalogEntryFinding,
3720 EmptyCatalogGroupFinding,
3721 UnresolvedCatalogReferenceFinding,
3722 UnusedDependencyOverrideFinding,
3723 MisconfiguredDependencyOverrideFinding,
3724 PropDrillingChainFinding,
3725 ThinWrapperFinding,
3726 DuplicatePropShapeFinding,
3727 crate::results::StaleSuppression,
3728);
3729
3730#[cfg(test)]
3740mod caveat_tokens {
3741 use super::*;
3742
3743 #[test]
3747 fn token_labels_match_the_typed_labels() {
3748 let typed = [
3749 ReachabilityCaveat::IncompleteFileAnalysis,
3750 ReachabilityCaveat::IncompleteImportGraph,
3751 ];
3752 let tokens: Vec<&str> = typed.iter().map(|c| c.token()).collect();
3753
3754 assert_eq!(
3755 caveat_labels_for_tokens(tokens.iter().copied()),
3756 caveat_labels(&typed)
3757 );
3758 assert_eq!(
3759 caveat_suffix_for_tokens(tokens.iter().copied()),
3760 caveat_suffix(&typed)
3761 );
3762 }
3763
3764 #[test]
3765 fn no_tokens_means_nothing_to_say() {
3766 assert_eq!(caveat_labels_for_tokens(std::iter::empty()), None);
3767 assert_eq!(caveat_suffix_for_tokens(std::iter::empty()), None);
3768 }
3769
3770 #[test]
3775 fn a_rendered_suffix_is_recognised_by_the_marker() {
3776 for caveats in [
3777 &[ReachabilityCaveat::IncompleteImportGraph][..],
3778 &[
3779 ReachabilityCaveat::IncompleteFileAnalysis,
3780 ReachabilityCaveat::IncompleteImportGraph,
3781 ][..],
3782 ] {
3783 let suffix = caveat_suffix(caveats).expect("a caveat renders a suffix");
3784 assert!(
3785 description_carries_caveat(&format!("Something is never referenced{suffix}")),
3786 "the marker must match what caveat_suffix writes: {suffix}"
3787 );
3788 }
3789 assert!(
3790 description_carries_caveat(&format!(
3791 "Something is never referenced{}",
3792 caveat_suffix_for_tokens(["some-future-cause"]).expect("token suffix")
3793 )),
3794 "the token-side renderer writes the same marker"
3795 );
3796 assert!(
3797 !description_carries_caveat("Class member 'Widget.helper' is never referenced"),
3798 "a clean description must not read as caveated"
3799 );
3800 }
3801
3802 #[test]
3809 fn no_caveat_message_names_a_single_cause() {
3810 for caveat in [
3811 ReachabilityCaveat::IncompleteFileAnalysis,
3812 ReachabilityCaveat::IncompleteImportGraph,
3813 ] {
3814 let message = caveat.message();
3815 assert!(
3816 !message.contains("parse cleanly") && !message.contains("parse error"),
3817 "{} names the parse cause alone, but a size-skipped or unreadable \
3818 file reaches the same caveat: {message}",
3819 caveat.token()
3820 );
3821 assert!(
3822 message.contains("workspace_diagnostics"),
3823 "{} must point at the list that names the actual files: {message}",
3824 caveat.token()
3825 );
3826 }
3827 }
3828
3829 #[test]
3833 fn an_unrecognised_token_still_renders_as_a_caveat() {
3834 let suffix = caveat_suffix_for_tokens(["some-future-cause"])
3835 .expect("an unknown token is still a caveat");
3836
3837 assert_eq!(suffix, " (caveat: some future cause)");
3838 }
3839}
3840
3841#[cfg(test)]
3854mod mutation_gate {
3855 use super::*;
3856 use crate::extract::MemberKind;
3857 use crate::results::DependencyLocation;
3858 use std::path::PathBuf;
3859
3860 const BOTH: [ReachabilityCaveat; 2] = [
3861 ReachabilityCaveat::IncompleteFileAnalysis,
3862 ReachabilityCaveat::IncompleteImportGraph,
3863 ];
3864
3865 fn export(name: &str) -> UnusedExport {
3866 UnusedExport {
3867 path: PathBuf::from("/p/src/mod.ts"),
3868 export_name: name.to_string(),
3869 is_type_only: false,
3870 line: 1,
3871 col: 0,
3872 span_start: 0,
3873 is_re_export: false,
3874 deprecated: false,
3875 deprecated_reason: None,
3876 }
3877 }
3878
3879 fn member(name: &str) -> UnusedMember {
3880 UnusedMember {
3881 path: PathBuf::from("/p/src/mod.ts"),
3882 parent_name: "Color".to_string(),
3883 member_name: name.to_string(),
3884 kind: MemberKind::EnumMember,
3885 line: 2,
3886 col: 2,
3887 }
3888 }
3889
3890 fn class_member(name: &str) -> UnusedMember {
3891 UnusedMember {
3892 parent_name: "Widget".to_string(),
3893 kind: MemberKind::ClassMethod,
3894 ..member(name)
3895 }
3896 }
3897
3898 fn store_member(name: &str) -> UnusedMember {
3899 UnusedMember {
3900 parent_name: "useCounterStore".to_string(),
3901 kind: MemberKind::StoreMember,
3902 ..member(name)
3903 }
3904 }
3905
3906 fn dependency(name: &str) -> UnusedDependency {
3907 UnusedDependency {
3908 package_name: name.to_string(),
3909 location: DependencyLocation::Dependencies,
3910 path: PathBuf::from("/p/package.json"),
3911 line: 5,
3912 used_in_workspaces: Vec::new(),
3913 }
3914 }
3915
3916 type GatedPair = (&'static str, Box<dyn Gated>, Box<dyn Gated>);
3919
3920 fn every_finding_type() -> Vec<GatedPair> {
3923 fn pair<T: Gated + Clone + 'static>(name: &'static str, clean: T) -> GatedPair {
3924 let mut caveated = clean.clone();
3925 caveated.stamp(BOTH.to_vec());
3926 (name, Box::new(clean), Box::new(caveated))
3927 }
3928 vec![
3929 pair(
3930 "unused_files",
3931 UnusedFileFinding::with_actions(UnusedFile {
3932 path: PathBuf::from("/p/src/orphan.ts"),
3933 }),
3934 ),
3935 pair(
3936 "unused_exports",
3937 UnusedExportFinding::with_actions(export("helper")),
3938 ),
3939 pair(
3940 "unused_types",
3941 UnusedTypeFinding::with_actions(export("Shape")),
3942 ),
3943 pair(
3944 "unused_enum_members",
3945 UnusedEnumMemberFinding::with_actions(member("Blue")),
3946 ),
3947 pair(
3948 "unused_class_members",
3949 UnusedClassMemberFinding::with_actions(class_member("legacyMethod")),
3950 ),
3951 pair(
3952 "unused_store_members",
3953 UnusedStoreMemberFinding::with_actions(store_member("onlyUsedInBigFile")),
3954 ),
3955 pair(
3956 "unused_dependencies",
3957 UnusedDependencyFinding::with_actions(dependency("lodash")),
3958 ),
3959 pair(
3960 "unused_dev_dependencies",
3961 UnusedDevDependencyFinding::with_actions(dependency("vitest")),
3962 ),
3963 pair(
3964 "unused_optional_dependencies",
3965 UnusedOptionalDependencyFinding::with_actions(dependency("fsevents")),
3966 ),
3967 ]
3968 }
3969
3970 trait Gated {
3973 fn actions(&self) -> &[IssueAction];
3974 fn gate_allows_mutation(&self) -> bool;
3975 fn stamp(&mut self, caveats: Vec<ReachabilityCaveat>);
3976 }
3977
3978 impl<T: MutationEvidence + CaveatedFinding + HasActions> Gated for T {
3979 fn actions(&self) -> &[IssueAction] {
3980 HasActions::actions(self)
3981 }
3982 fn gate_allows_mutation(&self) -> bool {
3983 self.may_auto_apply_mutation()
3984 }
3985 fn stamp(&mut self, caveats: Vec<ReachabilityCaveat>) {
3986 self.set_reachability_caveats(caveats);
3987 }
3988 }
3989
3990 trait HasActions {
3991 fn actions(&self) -> &[IssueAction];
3992 }
3993
3994 macro_rules! has_actions {
3995 ($($ty:ty),+ $(,)?) => { $( impl HasActions for $ty {
3996 fn actions(&self) -> &[IssueAction] { &self.actions }
3997 } )+ };
3998 }
3999 has_actions!(
4000 UnusedFileFinding,
4001 UnusedExportFinding,
4002 UnusedTypeFinding,
4003 UnusedEnumMemberFinding,
4004 UnusedClassMemberFinding,
4005 UnusedStoreMemberFinding,
4006 UnusedDependencyFinding,
4007 UnusedDevDependencyFinding,
4008 UnusedOptionalDependencyFinding,
4009 );
4010
4011 #[test]
4015 fn every_auto_fixable_dead_code_mutation_is_gated() {
4016 for (name, _clean, caveated) in every_finding_type() {
4017 assert!(
4018 !caveated.gate_allows_mutation(),
4019 "{name}: a stamped finding must fail the gate"
4020 );
4021 for action in caveated.actions() {
4022 assert!(
4023 !action.is_auto_fixable(),
4024 "{name}: a caveated finding still advertises an auto-fixable action, so an \
4025 agent following the documented actions contract would plan a removal \
4026 `fallow fix` refuses"
4027 );
4028 }
4029 }
4030 }
4031
4032 #[test]
4036 fn an_uncaveated_finding_keeps_its_auto_fix() {
4037 let auto_fixable_types = [
4038 "unused_exports",
4039 "unused_types",
4040 "unused_enum_members",
4041 "unused_dependencies",
4042 "unused_dev_dependencies",
4043 "unused_optional_dependencies",
4044 ];
4045 for (name, clean, _caveated) in every_finding_type() {
4046 assert!(
4047 clean.gate_allows_mutation(),
4048 "{name}: a finding with no caveat must pass the gate"
4049 );
4050 if auto_fixable_types.contains(&name) {
4051 assert!(
4052 clean.actions().iter().any(IssueAction::is_auto_fixable),
4053 "{name}: the gate must not withhold a mutation the run has the evidence for"
4054 );
4055 }
4056 }
4057 }
4058
4059 #[test]
4063 fn the_gate_downgrades_a_mutation_without_removing_it() {
4064 let clean = UnusedExportFinding::with_actions(export("helper"));
4065 let mut caveated = clean.clone();
4066 caveated.set_reachability_caveats(BOTH.to_vec());
4067
4068 assert_eq!(caveated.actions.len(), clean.actions.len());
4069 let IssueAction::Fix(fix) = &caveated.actions[0] else {
4070 panic!("position 0 stays the fix action");
4071 };
4072 assert!(!fix.auto_fixable);
4073 assert_eq!(
4074 fix.note.as_deref(),
4075 Some(INCOMPLETE_EVIDENCE_NOTE),
4076 "the withheld action says why in its own note, not only in a sibling array"
4077 );
4078 }
4079
4080 #[test]
4084 fn a_gated_mutation_keeps_the_note_it_already_had() {
4085 let mut re_export = export("helper");
4086 re_export.is_re_export = true;
4087 let mut finding = UnusedExportFinding::with_actions(re_export);
4088 finding.set_reachability_caveats(vec![ReachabilityCaveat::IncompleteImportGraph]);
4089
4090 let IssueAction::Fix(fix) = &finding.actions[0] else {
4091 panic!("position 0 stays the fix action");
4092 };
4093 let note = fix.note.as_deref().expect("note present");
4094 assert!(note.contains("public API"), "the original note survives");
4095 assert!(
4096 note.contains("Evidence is incomplete"),
4097 "the caveat is added"
4098 );
4099 }
4100
4101 #[test]
4120 fn a_store_member_exposes_no_mutation_at_all() {
4121 let store = UnusedStoreMemberFinding::with_actions(member("total"));
4122 assert!(
4123 !store.actions.iter().any(IssueAction::is_auto_fixable),
4124 "a store member must expose no automatically applicable mutation"
4125 );
4126 assert!(
4127 !store
4128 .actions
4129 .iter()
4130 .any(|action| matches!(action, IssueAction::Fix(_))),
4131 "and no fix action at all"
4132 );
4133
4134 let class = UnusedClassMemberFinding::with_actions(class_member("helper"));
4135 assert!(
4136 !class.actions.iter().any(IssueAction::is_auto_fixable),
4137 "a class member's syntactic removal stays withheld until semantic evidence opens it"
4138 );
4139 }
4140
4141 #[test]
4145 fn a_complete_semantic_verdict_cannot_reopen_a_caveated_mutation() {
4146 use crate::semantic::{
4147 SemanticCandidateDecision, SemanticCandidateDecisionKind, SemanticCompleteness,
4148 SemanticNamespace, SemanticSymbol,
4149 };
4150
4151 let complete_negative = || SemanticCandidateDecision {
4152 query_id: 0,
4153 subject: SemanticSymbol {
4154 path: PathBuf::from("/p/src/mod.ts"),
4155 namespace: SemanticNamespace::Value,
4156 declaration_kind: "function".to_string(),
4157 exported_name: "helper".to_string(),
4158 local_name: "helper".to_string(),
4159 owner: None,
4160 line: 1,
4161 col: 0,
4162 },
4163 decision: SemanticCandidateDecisionKind::ConfirmedNoStaticReferences,
4164 status: SemanticCompleteness::Complete,
4165 owning_projects: Vec::new(),
4166 evidence: Vec::new(),
4167 contract: None,
4168 framework_contract: None,
4169 closed_world_eligible: false,
4170 edit_guard: None,
4171 reason_code: None,
4172 explanation: String::new(),
4173 actions: Vec::new(),
4174 total_evidence_count: 0,
4175 truncated: false,
4176 omissions: Vec::new(),
4177 };
4178
4179 let mut clean = UnusedExportFinding::with_actions(export("helper"));
4180 clean.set_semantic_decision(complete_negative());
4181 assert!(
4182 clean.actions.iter().any(IssueAction::is_auto_fixable),
4183 "a complete negative verdict on a clean run still enables the fix"
4184 );
4185
4186 let mut caveated = UnusedExportFinding::with_actions(export("helper"));
4187 caveated.set_reachability_caveats(vec![ReachabilityCaveat::IncompleteImportGraph]);
4188 caveated.set_semantic_decision(complete_negative());
4189 assert!(
4190 !caveated.actions.iter().any(IssueAction::is_auto_fixable),
4191 "the semantic pass must ask the gate too"
4192 );
4193 }
4194
4195 #[test]
4202 fn a_complete_semantic_verdict_cannot_reopen_a_caveated_class_member() {
4203 use crate::semantic::{
4204 SemanticCandidateDecision, SemanticCandidateDecisionKind, SemanticCompleteness,
4205 SemanticNamespace, SemanticSymbol,
4206 };
4207
4208 let eligible = || SemanticCandidateDecision {
4209 query_id: 0,
4210 subject: SemanticSymbol {
4211 path: PathBuf::from("/p/src/mod.ts"),
4212 namespace: SemanticNamespace::Value,
4213 declaration_kind: "method".to_string(),
4214 exported_name: "Widget".to_string(),
4215 local_name: "legacyMethod".to_string(),
4216 owner: Some("Widget".to_string()),
4217 line: 2,
4218 col: 2,
4219 },
4220 decision: SemanticCandidateDecisionKind::ConfirmedNoStaticReferences,
4221 status: SemanticCompleteness::Complete,
4222 owning_projects: Vec::new(),
4223 evidence: Vec::new(),
4224 contract: None,
4225 framework_contract: None,
4226 closed_world_eligible: true,
4227 edit_guard: None,
4228 reason_code: None,
4229 explanation: "closed world proved".to_string(),
4230 actions: Vec::new(),
4231 total_evidence_count: 0,
4232 truncated: false,
4233 omissions: Vec::new(),
4234 };
4235
4236 let mut clean = UnusedClassMemberFinding::with_actions(class_member("legacyMethod"));
4237 clean.set_semantic_decision(eligible());
4238 assert!(
4239 clean.actions.iter().any(IssueAction::is_auto_fixable),
4240 "a closed-world verdict on a run that read every file still opens the removal"
4241 );
4242
4243 let mut caveated = UnusedClassMemberFinding::with_actions(class_member("legacyMethod"));
4244 caveated.set_reachability_caveats(vec![ReachabilityCaveat::IncompleteImportGraph]);
4245 caveated.set_semantic_decision(eligible());
4246 assert!(
4247 !caveated.actions.iter().any(IssueAction::is_auto_fixable),
4248 "the class-member semantic pass must ask the gate too"
4249 );
4250 let IssueAction::Fix(fix) = &caveated.actions[0] else {
4251 panic!("position 0 stays the fix action");
4252 };
4253 assert_eq!(
4254 fix.note.as_deref(),
4255 Some(INCOMPLETE_EVIDENCE_NOTE),
4256 "the withheld action says why, rather than repeating a closed-world explanation \
4257 computed over a program the run did not fully read"
4258 );
4259 }
4260}
4261
4262#[cfg(test)]
4263mod position_0_invariants {
4264 use super::*;
4265 use crate::output::FixActionType;
4266 use crate::results::{DependencyOverrideSource, DuplicateLocation};
4267 use std::path::PathBuf;
4268
4269 fn action_type(action: &IssueAction) -> &'static str {
4274 match action {
4275 IssueAction::Fix(fix) => match fix.kind {
4276 FixActionType::RemoveExport => "remove-export",
4277 FixActionType::DeleteFile => "delete-file",
4278 FixActionType::RemoveDependency => "remove-dependency",
4279 FixActionType::MoveDependency => "move-dependency",
4280 FixActionType::RemoveEnumMember => "remove-enum-member",
4281 FixActionType::RemoveClassMember => "remove-class-member",
4282 FixActionType::ResolveImport => "resolve-import",
4283 FixActionType::InstallDependency => "install-dependency",
4284 FixActionType::RemoveDuplicate => "remove-duplicate",
4285 FixActionType::MoveToDev => "move-to-dev",
4286 FixActionType::MoveToProd => "move-to-prod",
4287 FixActionType::RefactorCycle => "refactor-cycle",
4288 FixActionType::RefactorReExportCycle => "refactor-re-export-cycle",
4289 FixActionType::RefactorBoundary => "refactor-boundary",
4290 FixActionType::ExportType => "export-type",
4291 FixActionType::MigrateDeprecatedExport => "migrate-deprecated-export",
4292 FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
4293 FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
4294 FixActionType::UpdateCatalogReference => "update-catalog-reference",
4295 FixActionType::AddCatalogEntry => "add-catalog-entry",
4296 FixActionType::RemoveCatalogReference => "remove-catalog-reference",
4297 FixActionType::RemoveDependencyOverride => "remove-dependency-override",
4298 FixActionType::FixDependencyOverride => "fix-dependency-override",
4299 FixActionType::ResolvePolicyViolation => "resolve-policy-violation",
4300 FixActionType::MoveToServerModule => "move-to-server-module",
4301 FixActionType::SplitMixedBarrel => "split-mixed-barrel",
4302 FixActionType::HoistDirective => "hoist-directive",
4303 FixActionType::WireServerAction => "wire-server-action",
4304 FixActionType::ProvideInject => "provide-inject",
4305 FixActionType::UseLoadData => "use-load-data",
4306 FixActionType::RenderComponent => "render-component",
4307 FixActionType::UseComponentProp => "use-component-prop",
4308 FixActionType::EmitComponentEvent => "emit-component-event",
4309 FixActionType::WireSvelteEvent => "wire-svelte-event",
4310 FixActionType::ResolveRouteCollision => "resolve-route-collision",
4311 FixActionType::ResolveDynamicSegmentNameConflict => {
4312 "resolve-dynamic-segment-name-conflict"
4313 }
4314 FixActionType::AddSuppressionReason => "add-suppression-reason",
4315 FixActionType::RemoveStaleSuppression => "remove-stale-suppression",
4316 },
4317 IssueAction::SuppressLine(_) => "suppress-line",
4318 IssueAction::SuppressFile(_) => "suppress-file",
4319 IssueAction::AddToConfig(_) => "add-to-config",
4320 }
4321 }
4322
4323 fn assert_manual_fix_then_suppress(
4324 actions: &[IssueAction],
4325 primary_type: &str,
4326 suppress_comment: &str,
4327 ) {
4328 assert_eq!(actions.len(), 2);
4329 assert_eq!(action_type(&actions[0]), primary_type);
4330 let IssueAction::Fix(primary) = &actions[0] else {
4331 panic!("position-0 should be a manual fix action");
4332 };
4333 assert!(!primary.auto_fixable);
4334 assert!(primary.note.is_some());
4335 assert_eq!(action_type(&actions[1]), "suppress-line");
4336 let IssueAction::SuppressLine(suppress) = &actions[1] else {
4337 panic!("position-1 should be a suppress-line action");
4338 };
4339 assert_eq!(suppress.comment, suppress_comment);
4340 }
4341
4342 #[test]
4343 fn pnpm_catalog_entry_action_is_auto_fixable() {
4344 let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
4345 entry_name: "unused".to_string(),
4346 catalog_name: "default".to_string(),
4347 path: PathBuf::from("pnpm-workspace.yaml"),
4348 line: 3,
4349 hardcoded_consumers: vec![],
4350 });
4351
4352 let IssueAction::Fix(fix) = &finding.actions[0] else {
4353 panic!("position-0 should be a fix action");
4354 };
4355 assert!(fix.auto_fixable);
4356 assert_eq!(finding.actions.len(), 2);
4357 assert_eq!(action_type(&finding.actions[1]), "suppress-line");
4358 }
4359
4360 #[test]
4361 fn bun_package_json_catalog_entry_action_is_manual_only() {
4362 let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
4363 entry_name: "unused".to_string(),
4364 catalog_name: "default".to_string(),
4365 path: PathBuf::from("package.json"),
4366 line: 4,
4367 hardcoded_consumers: vec![],
4368 });
4369
4370 let IssueAction::Fix(fix) = &finding.actions[0] else {
4371 panic!("position-0 should be a fix action");
4372 };
4373 assert!(!fix.auto_fixable);
4374 assert!(fix.description.contains("manually"));
4375 assert_eq!(finding.actions.len(), 1);
4376 }
4377
4378 #[test]
4379 fn bun_package_json_empty_catalog_group_action_is_manual_only() {
4380 let finding = EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
4381 catalog_name: "empty".to_string(),
4382 path: PathBuf::from("package.json"),
4383 line: 4,
4384 });
4385
4386 let IssueAction::Fix(fix) = &finding.actions[0] else {
4387 panic!("position-0 should be a fix action");
4388 };
4389 assert!(!fix.auto_fixable);
4390 assert!(fix.description.contains("manually"));
4391 assert_eq!(finding.actions.len(), 1);
4392 }
4393
4394 #[test]
4395 fn unprovided_inject_primary_action_is_provide_inject() {
4396 let finding = UnprovidedInjectFinding::with_actions(UnprovidedInject {
4397 path: PathBuf::from("src/context.ts"),
4398 key_name: "userKey".to_string(),
4399 framework: "svelte".to_string(),
4400 line: 7,
4401 col: 12,
4402 });
4403
4404 assert_manual_fix_then_suppress(
4405 &finding.actions,
4406 "provide-inject",
4407 "// fallow-ignore-next-line unprovided-inject",
4408 );
4409 }
4410
4411 #[test]
4412 fn unused_server_action_primary_action_is_wire_server_action() {
4413 let finding = UnusedServerActionFinding::with_actions(UnusedServerAction {
4414 path: PathBuf::from("app/actions.ts"),
4415 action_name: "saveDraft".to_string(),
4416 line: 3,
4417 col: 13,
4418 });
4419
4420 assert_manual_fix_then_suppress(
4421 &finding.actions,
4422 "wire-server-action",
4423 "// fallow-ignore-next-line unused-server-action",
4424 );
4425 }
4426
4427 #[test]
4428 fn unused_load_data_key_primary_action_is_use_load_data() {
4429 let finding = UnusedLoadDataKeyFinding::with_actions(UnusedLoadDataKey {
4430 path: PathBuf::from("src/routes/+page.server.ts"),
4431 key_name: "profile".to_string(),
4432 line: 12,
4433 col: 6,
4434 route_dir: Some("src/routes".to_string()),
4435 });
4436
4437 assert_manual_fix_then_suppress(
4438 &finding.actions,
4439 "use-load-data",
4440 "// fallow-ignore-next-line unused-load-data-key",
4441 );
4442 }
4443
4444 #[test]
4445 fn unrendered_component_primary_action_is_render_component() {
4446 let finding = UnrenderedComponentFinding::with_actions(UnrenderedComponent {
4447 path: PathBuf::from("src/components/EmptyState.vue"),
4448 component_name: "EmptyState".to_string(),
4449 framework: "vue".to_string(),
4450 reachable_via: None,
4451 line: 1,
4452 col: 0,
4453 });
4454
4455 assert_manual_fix_then_suppress(
4456 &finding.actions,
4457 "render-component",
4458 "// fallow-ignore-next-line unrendered-component",
4459 );
4460 }
4461
4462 #[test]
4463 fn unused_component_prop_primary_action_is_use_component_prop() {
4464 let finding = UnusedComponentPropFinding::with_actions(UnusedComponentProp {
4465 path: PathBuf::from("src/components/Card.vue"),
4466 component_name: "Card".to_string(),
4467 prop_name: "variant".to_string(),
4468 line: 5,
4469 col: 10,
4470 });
4471
4472 assert_manual_fix_then_suppress(
4473 &finding.actions,
4474 "use-component-prop",
4475 "// fallow-ignore-next-line unused-component-prop",
4476 );
4477 }
4478
4479 #[test]
4480 fn unused_component_emit_primary_action_is_emit_component_event() {
4481 let finding = UnusedComponentEmitFinding::with_actions(UnusedComponentEmit {
4482 path: PathBuf::from("src/components/Picker.vue"),
4483 component_name: "Picker".to_string(),
4484 emit_name: "focus".to_string(),
4485 line: 6,
4486 col: 14,
4487 });
4488
4489 assert_manual_fix_then_suppress(
4490 &finding.actions,
4491 "emit-component-event",
4492 "// fallow-ignore-next-line unused-component-emit",
4493 );
4494 }
4495
4496 #[test]
4497 fn unused_svelte_event_primary_action_is_wire_svelte_event() {
4498 let finding = UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
4499 path: PathBuf::from("src/Dialog.svelte"),
4500 component_name: "Dialog".to_string(),
4501 event_name: "closed".to_string(),
4502 line: 19,
4503 col: 8,
4504 });
4505
4506 assert_manual_fix_then_suppress(
4507 &finding.actions,
4508 "wire-svelte-event",
4509 "// fallow-ignore-next-line unused-svelte-event",
4510 );
4511 }
4512
4513 #[test]
4514 fn unresolved_import_actions_include_ignore_unresolved_imports_config_suppress() {
4515 let inner = UnresolvedImport {
4516 specifier: "@example/icons".to_string(),
4517 path: PathBuf::from("src/index.ts"),
4518 line: 4,
4519 col: 12,
4520 specifier_col: 18,
4521 };
4522 let finding = UnresolvedImportFinding::with_actions(inner);
4523
4524 assert_eq!(action_type(&finding.actions[0]), "resolve-import");
4525 assert_eq!(action_type(&finding.actions[1]), "add-to-config");
4526 let IssueAction::AddToConfig(action) = &finding.actions[1] else {
4527 panic!("position-1 should be AddToConfig");
4528 };
4529 assert!(!action.auto_fixable);
4530 assert_eq!(action.config_key, "ignoreUnresolvedImports");
4531 let AddToConfigValue::Scalar(value) = &action.value else {
4532 panic!("ignoreUnresolvedImports action should carry a scalar value");
4533 };
4534 assert_eq!(value, "@example/icons");
4535 assert_eq!(
4536 action.value_schema.as_deref(),
4537 Some(
4538 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
4539 )
4540 );
4541 }
4542
4543 #[test]
4553 fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
4554 let inner = UnresolvedCatalogReference {
4555 entry_name: "react".to_string(),
4556 catalog_name: "default".to_string(),
4557 path: PathBuf::from("apps/web/package.json"),
4558 line: 7,
4559 available_in_catalogs: Vec::new(),
4560 };
4561 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
4562 assert_eq!(
4563 action_type(&finding.actions[0]),
4564 "add-catalog-entry",
4565 "position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
4566 );
4567 let IssueAction::Fix(fix) = &finding.actions[0] else {
4568 panic!("position-0 should be an IssueAction::Fix");
4569 };
4570 assert!(
4571 fix.available_in_catalogs.is_none(),
4572 "add-catalog-entry must NOT carry available_in_catalogs"
4573 );
4574 assert!(
4575 fix.suggested_target.is_none(),
4576 "add-catalog-entry must NOT carry suggested_target"
4577 );
4578 }
4579
4580 #[test]
4587 fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
4588 let inner = UnresolvedCatalogReference {
4589 entry_name: "react".to_string(),
4590 catalog_name: "default".to_string(),
4591 path: PathBuf::from("apps/web/package.json"),
4592 line: 7,
4593 available_in_catalogs: vec!["react18".to_string()],
4594 };
4595 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
4596 assert_eq!(
4597 action_type(&finding.actions[0]),
4598 "update-catalog-reference",
4599 "position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
4600 );
4601 let IssueAction::Fix(fix) = &finding.actions[0] else {
4602 panic!("position-0 should be an IssueAction::Fix");
4603 };
4604 assert_eq!(
4605 fix.available_in_catalogs.as_deref(),
4606 Some(&["react18".to_string()][..]),
4607 "update-catalog-reference must carry the alternative list"
4608 );
4609 assert_eq!(
4610 fix.suggested_target.as_deref(),
4611 Some("react18"),
4612 "single-alternative case must surface `suggested_target` for deterministic agents"
4613 );
4614
4615 let inner_two = UnresolvedCatalogReference {
4617 entry_name: "react".to_string(),
4618 catalog_name: "default".to_string(),
4619 path: PathBuf::from("apps/web/package.json"),
4620 line: 7,
4621 available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
4622 };
4623 let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
4624 assert_eq!(
4625 action_type(&finding_two.actions[0]),
4626 "update-catalog-reference"
4627 );
4628 let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
4629 panic!("position-0 should be an IssueAction::Fix");
4630 };
4631 assert!(
4632 fix_two.suggested_target.is_none(),
4633 "multi-alternative case must NOT carry `suggested_target` (agent must pick)"
4634 );
4635 }
4636
4637 #[test]
4652 fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
4653 let inner = DuplicateExport {
4654 export_name: "Root".to_string(),
4655 locations: vec![
4656 DuplicateLocation {
4657 path: PathBuf::from("components/ui/accordion/index.ts"),
4658 line: 1,
4659 col: 0,
4660 },
4661 DuplicateLocation {
4662 path: PathBuf::from("components/ui/dialog/index.ts"),
4663 line: 1,
4664 col: 0,
4665 },
4666 ],
4667 };
4668 let finding = DuplicateExportFinding::with_actions(inner);
4669 assert_eq!(
4670 action_type(&finding.actions[0]),
4671 "add-to-config",
4672 "position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
4673 );
4674 assert_eq!(
4675 action_type(&finding.actions[1]),
4676 "remove-duplicate",
4677 "position-1 must be the destructive `remove-duplicate` fallback"
4678 );
4679
4680 let mut promoted = finding;
4683 promoted.set_config_fixable(true);
4684 assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
4685 let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
4686 panic!("position-0 should still be AddToConfig after set_config_fixable");
4687 };
4688 assert!(
4689 action.auto_fixable,
4690 "set_config_fixable(true) must flip auto_fixable"
4691 );
4692 }
4693
4694 #[test]
4699 fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
4700 let inner = DuplicateExport {
4701 export_name: "Root".to_string(),
4702 locations: Vec::new(),
4703 };
4704 let finding = DuplicateExportFinding::with_actions(inner);
4705 assert_eq!(
4706 action_type(&finding.actions[0]),
4707 "remove-duplicate",
4708 "with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
4709 );
4710
4711 let mut promoted = finding;
4713 promoted.set_config_fixable(true);
4714 assert_eq!(
4715 action_type(&promoted.actions[0]),
4716 "remove-duplicate",
4717 "set_config_fixable is a no-op when position-0 is not add-to-config"
4718 );
4719 }
4720
4721 #[test]
4727 fn misconfigured_override_drops_suppress_when_no_package_name() {
4728 let inner = MisconfiguredDependencyOverride {
4729 raw_key: String::new(),
4730 target_package: None,
4731 raw_value: String::new(),
4732 reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
4733 source: DependencyOverrideSource::PnpmWorkspaceYaml,
4734 path: PathBuf::from("pnpm-workspace.yaml"),
4735 line: 12,
4736 };
4737 let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
4738 assert_eq!(finding.actions.len(), 1);
4740 assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
4741 }
4742}