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, DevDependencyInProduction, DuplicateExport, DuplicatePropShape,
42 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(default, skip_serializing_if = "Vec::is_empty")]
422 pub reachability_caveats: Vec<ReachabilityCaveat>,
423}
424
425impl UnusedFileFinding {
426 #[must_use]
430 pub fn with_actions(file: UnusedFile) -> Self {
431 let actions = vec![
432 IssueAction::Fix(FixAction {
433 kind: FixActionType::DeleteFile,
434 auto_fixable: false,
435 description: "Delete this file".to_string(),
436 note: Some(
437 "File deletion may remove runtime functionality not visible to static analysis"
438 .to_string(),
439 ),
440 available_in_catalogs: None,
441 suggested_target: None,
442 }),
443 IssueAction::SuppressFile(SuppressFileAction {
444 kind: SuppressFileKind::SuppressFile,
445 auto_fixable: false,
446 description: "Suppress with a file-level comment at the top of the file"
447 .to_string(),
448 comment: "// fallow-ignore-file unused-file".to_string(),
449 }),
450 ];
451 Self {
452 file,
453 actions,
454 introduced: None,
455 reachability_caveats: Vec::new(),
456 }
457 }
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize)]
464#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
465pub struct PrivateTypeLeakFinding {
466 #[serde(flatten)]
468 pub leak: PrivateTypeLeak,
469 pub actions: Vec<IssueAction>,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
475 pub introduced: Option<AuditIntroduced>,
476}
477
478impl PrivateTypeLeakFinding {
479 #[must_use]
481 pub fn with_actions(leak: PrivateTypeLeak) -> Self {
482 let actions = vec![
483 IssueAction::Fix(FixAction {
484 kind: FixActionType::ExportType,
485 auto_fixable: false,
486 description: "Export the referenced private type by name".to_string(),
487 note: Some(
488 "Keep the type exported while it is part of a public signature".to_string(),
489 ),
490 available_in_catalogs: None,
491 suggested_target: None,
492 }),
493 IssueAction::SuppressLine(SuppressLineAction {
494 kind: SuppressLineKind::SuppressLine,
495 auto_fixable: false,
496 description: "Suppress with an inline comment above the line".to_string(),
497 comment: "// fallow-ignore-next-line private-type-leak".to_string(),
498 scope: None,
499 }),
500 ];
501 Self {
502 leak,
503 actions,
504 introduced: None,
505 }
506 }
507}
508
509#[derive(Debug, Clone, Serialize, Deserialize)]
514#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
515pub struct UnresolvedImportFinding {
516 #[serde(flatten)]
518 pub import: UnresolvedImport,
519 pub actions: Vec<IssueAction>,
522 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub introduced: Option<AuditIntroduced>,
526}
527
528impl UnresolvedImportFinding {
529 #[must_use]
531 pub fn with_actions(import: UnresolvedImport) -> Self {
532 let actions = vec![
533 IssueAction::Fix(FixAction {
534 kind: FixActionType::ResolveImport,
535 auto_fixable: false,
536 description: "Fix the import specifier or install the missing module".to_string(),
537 note: Some(
538 "Verify the module path and check tsconfig paths configuration".to_string(),
539 ),
540 available_in_catalogs: None,
541 suggested_target: None,
542 }),
543 IssueAction::AddToConfig(AddToConfigAction {
544 kind: AddToConfigKind::AddToConfig,
545 auto_fixable: false,
546 description: format!(
547 "Add \"{}\" to ignoreUnresolvedImports in fallow config",
548 import.specifier
549 ),
550 config_key: "ignoreUnresolvedImports".to_string(),
551 value: AddToConfigValue::Scalar(import.specifier.clone()),
552 value_schema: Some(
553 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
554 .to_string(),
555 ),
556 }),
557 IssueAction::SuppressLine(SuppressLineAction {
558 kind: SuppressLineKind::SuppressLine,
559 auto_fixable: false,
560 description: "Suppress with an inline comment above the line".to_string(),
561 comment: "// fallow-ignore-next-line unresolved-import".to_string(),
562 scope: None,
563 }),
564 ];
565 Self {
566 import,
567 actions,
568 introduced: None,
569 }
570 }
571}
572
573#[derive(Debug, Clone, Serialize, Deserialize)]
578#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
579pub struct CircularDependencyFinding {
580 #[serde(flatten)]
582 pub cycle: CircularDependency,
583 pub actions: Vec<IssueAction>,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
589 pub introduced: Option<AuditIntroduced>,
590}
591
592impl CircularDependencyFinding {
593 #[must_use]
595 pub fn with_actions(cycle: CircularDependency) -> Self {
596 let actions = vec![
597 IssueAction::Fix(FixAction {
598 kind: FixActionType::RefactorCycle,
599 auto_fixable: false,
600 description: "Extract shared logic into a separate module to break the cycle"
601 .to_string(),
602 note: Some(
603 "Circular imports can cause initialization issues and make code harder to reason about"
604 .to_string(),
605 ),
606 available_in_catalogs: None,
607 suggested_target: None,
608 }),
609 IssueAction::SuppressLine(SuppressLineAction {
610 kind: SuppressLineKind::SuppressLine,
611 auto_fixable: false,
612 description: "Suppress with an inline comment above the line".to_string(),
613 comment: "// fallow-ignore-next-line circular-dependency".to_string(),
614 scope: None,
615 }),
616 ];
617 Self {
618 cycle,
619 actions,
620 introduced: None,
621 }
622 }
623}
624
625#[derive(Debug, Clone, Serialize, Deserialize)]
633#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
634pub struct ReExportCycleFinding {
635 #[serde(flatten)]
637 pub cycle: ReExportCycle,
638 pub actions: Vec<IssueAction>,
641 #[serde(default, skip_serializing_if = "Option::is_none")]
644 pub introduced: Option<AuditIntroduced>,
645}
646
647impl ReExportCycleFinding {
648 #[must_use]
655 pub fn with_actions(cycle: ReExportCycle) -> Self {
656 let suppress_description = match cycle.kind {
662 ReExportCycleKind::SelfLoop => {
663 "Suppress with a file-level comment at the top of this file. \
664 The cycle is a self-loop, so the suppression covers the entire finding."
665 .to_string()
666 }
667 ReExportCycleKind::MultiNode => {
668 "Suppress with a file-level comment at the top of this file. \
669 One suppression on any member breaks the cycle for every member \
670 (see the sibling `files` array)."
671 .to_string()
672 }
673 };
674 let actions = vec![
675 IssueAction::Fix(FixAction {
676 kind: FixActionType::RefactorReExportCycle,
677 auto_fixable: false,
678 description: "Remove one `export * from` (or `export { ... } from`) \
679 statement on any one member to break the cycle"
680 .to_string(),
681 note: Some(
682 "Re-export cycles are structurally a no-op: chain propagation through \
683 the loop never reaches a terminating module, so imports from any member \
684 may silently come up empty."
685 .to_string(),
686 ),
687 available_in_catalogs: None,
688 suggested_target: None,
689 }),
690 IssueAction::SuppressFile(SuppressFileAction {
691 kind: SuppressFileKind::SuppressFile,
692 auto_fixable: false,
693 description: suppress_description,
694 comment: "// fallow-ignore-file re-export-cycle".to_string(),
695 }),
696 ];
697 Self {
698 cycle,
699 actions,
700 introduced: None,
701 }
702 }
703}
704
705#[derive(Debug, Clone, Serialize, Deserialize)]
710#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
711pub struct BoundaryViolationFinding {
712 #[serde(flatten)]
714 pub violation: BoundaryViolation,
715 pub actions: Vec<IssueAction>,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
721 pub introduced: Option<AuditIntroduced>,
722}
723
724impl BoundaryViolationFinding {
725 #[must_use]
727 pub fn with_actions(violation: BoundaryViolation) -> Self {
728 let actions = vec![
729 IssueAction::Fix(FixAction {
730 kind: FixActionType::RefactorBoundary,
731 auto_fixable: false,
732 description: "Move the import through an allowed zone or restructure the dependency"
733 .to_string(),
734 note: Some(
735 "This import crosses an architecture boundary that is not permitted by the configured rules"
736 .to_string(),
737 ),
738 available_in_catalogs: None,
739 suggested_target: None,
740 }),
741 IssueAction::SuppressLine(SuppressLineAction {
742 kind: SuppressLineKind::SuppressLine,
743 auto_fixable: false,
744 description: "Suppress with an inline comment above the line".to_string(),
745 comment: "// fallow-ignore-next-line boundary-violation".to_string(),
746 scope: None,
747 }),
748 ];
749 Self {
750 violation,
751 actions,
752 introduced: None,
753 }
754 }
755}
756
757#[derive(Debug, Clone, Serialize, Deserialize)]
761#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
762pub struct BoundaryCoverageViolationFinding {
763 #[serde(flatten)]
765 pub violation: BoundaryCoverageViolation,
766 pub actions: Vec<IssueAction>,
768 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub introduced: Option<AuditIntroduced>,
772}
773
774impl BoundaryCoverageViolationFinding {
775 #[must_use]
777 pub fn with_actions(violation: BoundaryCoverageViolation) -> Self {
778 let path = violation.path.to_string_lossy().replace('\\', "/");
779 let actions = vec![
780 IssueAction::Fix(FixAction {
781 kind: FixActionType::RefactorBoundary,
782 auto_fixable: false,
783 description: "Add this file to a boundary zone pattern or move it under an existing zone"
784 .to_string(),
785 note: Some(
786 "Boundary coverage is enabled, so every analyzed source file must match a zone unless allow-listed"
787 .to_string(),
788 ),
789 available_in_catalogs: None,
790 suggested_target: None,
791 }),
792 IssueAction::AddToConfig(AddToConfigAction {
793 kind: AddToConfigKind::AddToConfig,
794 auto_fixable: false,
795 description: format!(
796 "Add \"{path}\" to boundaries.coverage.allowUnmatched in fallow config"
797 ),
798 config_key: "boundaries.coverage.allowUnmatched".to_string(),
799 value: AddToConfigValue::Scalar(path),
800 value_schema: Some(
801 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/boundaries/properties/coverage/properties/allowUnmatched/items"
802 .to_string(),
803 ),
804 }),
805 IssueAction::SuppressFile(SuppressFileAction {
806 kind: SuppressFileKind::SuppressFile,
807 auto_fixable: false,
808 description: "Suppress with a file-level comment at the top of the file"
809 .to_string(),
810 comment: "// fallow-ignore-file boundary-violation".to_string(),
811 }),
812 ];
813 Self {
814 violation,
815 actions,
816 introduced: None,
817 }
818 }
819}
820
821#[derive(Debug, Clone, Serialize, Deserialize)]
825#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
826pub struct BoundaryCallViolationFinding {
827 #[serde(flatten)]
829 pub violation: BoundaryCallViolation,
830 pub actions: Vec<IssueAction>,
832 #[serde(default, skip_serializing_if = "Option::is_none")]
835 pub introduced: Option<AuditIntroduced>,
836}
837
838impl BoundaryCallViolationFinding {
839 #[must_use]
841 pub fn with_actions(violation: BoundaryCallViolation) -> Self {
842 let actions = vec![
843 IssueAction::Fix(FixAction {
844 kind: FixActionType::RefactorBoundary,
845 auto_fixable: false,
846 description: format!(
847 "Move the `{}` call out of zone '{}' or behind an allowed abstraction",
848 violation.callee, violation.zone,
849 ),
850 note: Some(format!(
851 "`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",
852 violation.pattern, violation.zone,
853 )),
854 available_in_catalogs: None,
855 suggested_target: None,
856 }),
857 IssueAction::SuppressLine(SuppressLineAction {
858 kind: SuppressLineKind::SuppressLine,
859 auto_fixable: false,
860 description: "Suppress with an inline comment above the line".to_string(),
861 comment: "// fallow-ignore-next-line boundary-violation".to_string(),
862 scope: None,
863 }),
864 IssueAction::SuppressFile(SuppressFileAction {
865 kind: SuppressFileKind::SuppressFile,
866 auto_fixable: false,
867 description: "Suppress with a file-level comment at the top of the file"
868 .to_string(),
869 comment: "// fallow-ignore-file boundary-violation".to_string(),
870 }),
871 ];
872 Self {
873 violation,
874 actions,
875 introduced: None,
876 }
877 }
878}
879
880#[derive(Debug, Clone, Serialize, Deserialize)]
884#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
885pub struct PolicyViolationFinding {
886 #[serde(flatten)]
888 pub violation: PolicyViolation,
889 pub actions: Vec<IssueAction>,
891 #[serde(default, skip_serializing_if = "Option::is_none")]
894 pub introduced: Option<AuditIntroduced>,
895}
896
897impl PolicyViolationFinding {
898 #[must_use]
900 pub fn with_actions(violation: PolicyViolation) -> Self {
901 let what = match violation.kind {
902 crate::results::PolicyRuleKind::BannedCall => "call",
903 crate::results::PolicyRuleKind::BannedImport => "import",
904 crate::results::PolicyRuleKind::BannedEffect => "effect",
905 crate::results::PolicyRuleKind::BannedExport => "export",
906 };
907 let description = match &violation.message {
908 Some(message) => format!("Replace the `{}` {what}: {message}", violation.matched),
909 None => format!("Replace the `{}` {what}", violation.matched),
910 };
911 let suppress_token = format!("policy-violation:{}/{}", violation.pack, violation.rule_id);
912 let actions = vec![
913 IssueAction::Fix(FixAction {
914 kind: FixActionType::ResolvePolicyViolation,
915 auto_fixable: false,
916 description,
917 note: Some(format!(
918 "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",
919 violation.pack, violation.rule_id,
920 )),
921 available_in_catalogs: None,
922 suggested_target: None,
923 }),
924 IssueAction::SuppressLine(SuppressLineAction {
925 kind: SuppressLineKind::SuppressLine,
926 auto_fixable: false,
927 description: "Suppress this rule-pack rule with an inline comment above the line"
928 .to_string(),
929 comment: format!("// fallow-ignore-next-line {suppress_token}"),
930 scope: None,
931 }),
932 IssueAction::SuppressFile(SuppressFileAction {
933 kind: SuppressFileKind::SuppressFile,
934 auto_fixable: false,
935 description:
936 "Suppress this rule-pack rule with a file-level comment at the top of the file"
937 .to_string(),
938 comment: format!("// fallow-ignore-file {suppress_token}"),
939 }),
940 ];
941 Self {
942 violation,
943 actions,
944 introduced: None,
945 }
946 }
947}
948
949#[derive(Debug, Clone, Serialize, Deserialize)]
954#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
955pub struct UnusedExportFinding {
956 #[serde(flatten)]
958 pub export: UnusedExport,
959 pub actions: Vec<IssueAction>,
962 #[serde(default, skip_serializing_if = "Option::is_none")]
964 pub semantic: Option<SemanticCandidateDecision>,
965 #[serde(default, skip_serializing_if = "Option::is_none")]
968 pub introduced: Option<AuditIntroduced>,
969 #[serde(default, skip_serializing_if = "Vec::is_empty")]
974 pub reachability_caveats: Vec<ReachabilityCaveat>,
975}
976
977impl UnusedExportFinding {
978 #[must_use]
982 pub fn with_actions(export: UnusedExport) -> Self {
983 let note = if export.is_re_export {
984 Some(
985 "This finding originates from a re-export; verify it is not part of your public API before removing"
986 .to_string(),
987 )
988 } else {
989 None
990 };
991 let actions = vec![
992 IssueAction::Fix(FixAction {
993 kind: FixActionType::RemoveExport,
994 auto_fixable: true,
995 description: "Remove the unused export from the public API".to_string(),
996 note,
997 available_in_catalogs: None,
998 suggested_target: None,
999 }),
1000 IssueAction::SuppressLine(SuppressLineAction {
1001 kind: SuppressLineKind::SuppressLine,
1002 auto_fixable: false,
1003 description: "Suppress with an inline comment above the line".to_string(),
1004 comment: "// fallow-ignore-next-line unused-export".to_string(),
1005 scope: None,
1006 }),
1007 ];
1008 Self {
1009 export,
1010 actions,
1011 semantic: None,
1012 introduced: None,
1013 reachability_caveats: Vec::new(),
1014 }
1015 }
1016
1017 pub fn set_semantic_decision(&mut self, decision: SemanticCandidateDecision) {
1020 set_export_semantic_action(&mut self.actions, &decision, &self.reachability_caveats);
1021 self.semantic = Some(decision);
1022 }
1023}
1024
1025#[derive(Debug, Clone, Serialize, Deserialize)]
1030#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1031pub struct UnusedTypeFinding {
1032 #[serde(flatten)]
1034 pub export: UnusedExport,
1035 pub actions: Vec<IssueAction>,
1038 #[serde(default, skip_serializing_if = "Option::is_none")]
1040 pub semantic: Option<SemanticCandidateDecision>,
1041 #[serde(default, skip_serializing_if = "Option::is_none")]
1044 pub introduced: Option<AuditIntroduced>,
1045 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1051 pub reachability_caveats: Vec<ReachabilityCaveat>,
1052}
1053
1054impl UnusedTypeFinding {
1055 #[must_use]
1058 pub fn with_actions(export: UnusedExport) -> Self {
1059 let note = if export.is_re_export {
1060 Some(
1061 "This finding originates from a re-export; verify it is not part of your public API before removing"
1062 .to_string(),
1063 )
1064 } else {
1065 None
1066 };
1067 let actions = vec![
1068 IssueAction::Fix(FixAction {
1069 kind: FixActionType::RemoveExport,
1070 auto_fixable: true,
1071 description:
1072 "Remove the `export` (or `export type`) keyword from the type declaration"
1073 .to_string(),
1074 note,
1075 available_in_catalogs: None,
1076 suggested_target: None,
1077 }),
1078 IssueAction::SuppressLine(SuppressLineAction {
1079 kind: SuppressLineKind::SuppressLine,
1080 auto_fixable: false,
1081 description: "Suppress with an inline comment above the line".to_string(),
1082 comment: "// fallow-ignore-next-line unused-type".to_string(),
1083 scope: None,
1084 }),
1085 ];
1086 Self {
1087 export,
1088 actions,
1089 semantic: None,
1090 introduced: None,
1091 reachability_caveats: Vec::new(),
1092 }
1093 }
1094
1095 pub fn set_semantic_decision(&mut self, decision: SemanticCandidateDecision) {
1098 set_export_semantic_action(&mut self.actions, &decision, &self.reachability_caveats);
1099 self.semantic = Some(decision);
1100 }
1101}
1102
1103fn set_export_semantic_action(
1108 actions: &mut [IssueAction],
1109 decision: &SemanticCandidateDecision,
1110 caveats: &[ReachabilityCaveat],
1111) {
1112 let complete_negative = decision.decision
1113 == SemanticCandidateDecisionKind::ConfirmedNoStaticReferences
1114 && decision.status == SemanticCompleteness::Complete;
1115 let Some(IssueAction::Fix(action)) = actions.first_mut() else {
1116 return;
1117 };
1118 action.auto_fixable = complete_negative && caveats.is_empty();
1119 if !complete_negative {
1120 action.note = Some(
1121 "Type-aware analysis retained this candidate because complete negative evidence was not available"
1122 .to_string(),
1123 );
1124 }
1125 if !caveats.is_empty() {
1126 action.note = Some(INCOMPLETE_EVIDENCE_NOTE.to_string());
1127 }
1128}
1129
1130#[derive(Debug, Clone, Serialize, Deserialize)]
1136#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1137pub struct InvalidClientExportFinding {
1138 #[serde(flatten)]
1140 pub export: InvalidClientExport,
1141 pub actions: Vec<IssueAction>,
1144 #[serde(default, skip_serializing_if = "Option::is_none")]
1147 pub introduced: Option<AuditIntroduced>,
1148}
1149
1150impl InvalidClientExportFinding {
1151 #[must_use]
1156 pub fn with_actions(export: InvalidClientExport) -> Self {
1157 let actions = vec![
1158 IssueAction::Fix(FixAction {
1159 kind: FixActionType::MoveToServerModule,
1160 auto_fixable: false,
1161 description: "Move the server-only export to a non-client module and import it from there"
1162 .to_string(),
1163 note: Some(
1164 "A \"use client\" file cannot export a Next.js server-only or route-config name; Next.js rejects it at build time"
1165 .to_string(),
1166 ),
1167 available_in_catalogs: None,
1168 suggested_target: None,
1169 }),
1170 IssueAction::SuppressLine(SuppressLineAction {
1171 kind: SuppressLineKind::SuppressLine,
1172 auto_fixable: false,
1173 description: "Suppress with an inline comment above the line".to_string(),
1174 comment: "// fallow-ignore-next-line invalid-client-export".to_string(),
1175 scope: None,
1176 }),
1177 ];
1178 Self {
1179 export,
1180 actions,
1181 introduced: None,
1182 }
1183 }
1184}
1185
1186#[derive(Debug, Clone, Serialize, Deserialize)]
1192#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1193pub struct MixedClientServerBarrelFinding {
1194 #[serde(flatten)]
1196 pub barrel: MixedClientServerBarrel,
1197 pub actions: Vec<IssueAction>,
1200 #[serde(default, skip_serializing_if = "Option::is_none")]
1203 pub introduced: Option<AuditIntroduced>,
1204}
1205
1206impl MixedClientServerBarrelFinding {
1207 #[must_use]
1212 pub fn with_actions(barrel: MixedClientServerBarrel) -> Self {
1213 let actions = vec![
1214 IssueAction::Fix(FixAction {
1215 kind: FixActionType::SplitMixedBarrel,
1216 auto_fixable: false,
1217 description: "Split the barrel so client and server-only modules are re-exported from separate files"
1218 .to_string(),
1219 note: Some(
1220 "Importing one name from this barrel drags the other's directive across the client/server boundary"
1221 .to_string(),
1222 ),
1223 available_in_catalogs: None,
1224 suggested_target: None,
1225 }),
1226 IssueAction::SuppressLine(SuppressLineAction {
1227 kind: SuppressLineKind::SuppressLine,
1228 auto_fixable: false,
1229 description: "Suppress with an inline comment above the line".to_string(),
1230 comment: "// fallow-ignore-next-line mixed-client-server-barrel".to_string(),
1231 scope: None,
1232 }),
1233 ];
1234 Self {
1235 barrel,
1236 actions,
1237 introduced: None,
1238 }
1239 }
1240}
1241
1242#[derive(Debug, Clone, Serialize, Deserialize)]
1248#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1249pub struct MisplacedDirectiveFinding {
1250 #[serde(flatten)]
1252 pub directive_site: MisplacedDirective,
1253 pub actions: Vec<IssueAction>,
1256 #[serde(default, skip_serializing_if = "Option::is_none")]
1259 pub introduced: Option<AuditIntroduced>,
1260}
1261
1262impl MisplacedDirectiveFinding {
1263 #[must_use]
1268 pub fn with_actions(directive_site: MisplacedDirective) -> Self {
1269 let actions = vec![
1270 IssueAction::Fix(FixAction {
1271 kind: FixActionType::HoistDirective,
1272 auto_fixable: false,
1273 description: "Move the directive to the very top of the file, above all imports and statements"
1274 .to_string(),
1275 note: Some(
1276 "An RSC bundler honors the directive only in the leading prologue; here it precedes other statements and is silently ignored"
1277 .to_string(),
1278 ),
1279 available_in_catalogs: None,
1280 suggested_target: None,
1281 }),
1282 IssueAction::SuppressLine(SuppressLineAction {
1283 kind: SuppressLineKind::SuppressLine,
1284 auto_fixable: false,
1285 description: "Suppress with an inline comment above the line".to_string(),
1286 comment: "// fallow-ignore-next-line misplaced-directive".to_string(),
1287 scope: None,
1288 }),
1289 ];
1290 Self {
1291 directive_site,
1292 actions,
1293 introduced: None,
1294 }
1295 }
1296}
1297
1298#[derive(Debug, Clone, Serialize, Deserialize)]
1303#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1304pub struct UnprovidedInjectFinding {
1305 #[serde(flatten)]
1307 pub inject: UnprovidedInject,
1308 pub actions: Vec<IssueAction>,
1311 #[serde(default, skip_serializing_if = "Option::is_none")]
1314 pub introduced: Option<AuditIntroduced>,
1315}
1316
1317impl UnprovidedInjectFinding {
1318 #[must_use]
1321 pub fn with_actions(inject: UnprovidedInject) -> Self {
1322 let actions = vec![
1323 manual_framework_fix(
1324 FixActionType::ProvideInject,
1325 "Provide this injected key, or remove the inject / getContext call",
1326 "Manual review required: dependency-injection keys can be provided by framework wiring, tests, or package consumers outside this project.",
1327 ),
1328 suppress_line("// fallow-ignore-next-line unprovided-inject"),
1329 ];
1330 Self {
1331 inject,
1332 actions,
1333 introduced: None,
1334 }
1335 }
1336}
1337
1338#[derive(Debug, Clone, Serialize, Deserialize)]
1343#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1344pub struct UnusedServerActionFinding {
1345 #[serde(flatten)]
1347 pub action: UnusedServerAction,
1348 pub actions: Vec<IssueAction>,
1351 #[serde(default, skip_serializing_if = "Option::is_none")]
1354 pub introduced: Option<AuditIntroduced>,
1355}
1356
1357impl UnusedServerActionFinding {
1358 #[must_use]
1361 pub fn with_actions(action: UnusedServerAction) -> Self {
1362 let actions = vec![
1363 manual_framework_fix(
1364 FixActionType::WireServerAction,
1365 "Wire the server action to a caller or form action, or remove it",
1366 "Manual review required: server actions may still be POST-able by action id or invoked reflectively outside the static project graph.",
1367 ),
1368 suppress_line("// fallow-ignore-next-line unused-server-action"),
1369 ];
1370 Self {
1371 action,
1372 actions,
1373 introduced: None,
1374 }
1375 }
1376}
1377
1378#[derive(Debug, Clone, Serialize, Deserialize)]
1383#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1384pub struct UnusedLoadDataKeyFinding {
1385 #[serde(flatten)]
1387 pub key: UnusedLoadDataKey,
1388 pub actions: Vec<IssueAction>,
1391 #[serde(default, skip_serializing_if = "Option::is_none")]
1394 pub introduced: Option<AuditIntroduced>,
1395}
1396
1397impl UnusedLoadDataKeyFinding {
1398 #[must_use]
1401 pub fn with_actions(key: UnusedLoadDataKey) -> Self {
1402 let actions = vec![
1403 manual_framework_fix(
1404 FixActionType::UseLoadData,
1405 "Read this load data key from the route UI, or remove it from the load return",
1406 "Manual review required: load functions can perform real server or database work, so verify side effects before deleting the producer.",
1407 ),
1408 suppress_line("// fallow-ignore-next-line unused-load-data-key"),
1409 ];
1410 Self {
1411 key,
1412 actions,
1413 introduced: None,
1414 }
1415 }
1416}
1417
1418#[derive(Debug, Clone, Serialize, Deserialize)]
1423#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1424pub struct UnrenderedComponentFinding {
1425 #[serde(flatten)]
1427 pub component: UnrenderedComponent,
1428 pub actions: Vec<IssueAction>,
1431 #[serde(default, skip_serializing_if = "Option::is_none")]
1434 pub introduced: Option<AuditIntroduced>,
1435}
1436
1437impl UnrenderedComponentFinding {
1438 #[must_use]
1441 pub fn with_actions(component: UnrenderedComponent) -> Self {
1442 let actions = vec![
1443 manual_framework_fix(
1444 FixActionType::RenderComponent,
1445 "Render the reachable component from project code, or remove it",
1446 "Manual review required: exported library components and dynamic render registries can be intentionally reachable without static template usage.",
1447 ),
1448 suppress_line("// fallow-ignore-next-line unrendered-component"),
1449 ];
1450 Self {
1451 component,
1452 actions,
1453 introduced: None,
1454 }
1455 }
1456}
1457
1458#[derive(Debug, Clone, Serialize, Deserialize)]
1463#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1464pub struct UnusedComponentPropFinding {
1465 #[serde(flatten)]
1467 pub prop: UnusedComponentProp,
1468 pub actions: Vec<IssueAction>,
1471 #[serde(default, skip_serializing_if = "Option::is_none")]
1474 pub introduced: Option<AuditIntroduced>,
1475}
1476
1477impl UnusedComponentPropFinding {
1478 #[must_use]
1481 pub fn with_actions(prop: UnusedComponentProp) -> Self {
1482 let actions = vec![
1483 manual_framework_fix(
1484 FixActionType::UseComponentProp,
1485 "Use the declared prop in the component, or remove it from the component API",
1486 "Manual review required: public component APIs can intentionally keep stable props for external consumers.",
1487 ),
1488 suppress_line("// fallow-ignore-next-line unused-component-prop"),
1489 ];
1490 Self {
1491 prop,
1492 actions,
1493 introduced: None,
1494 }
1495 }
1496}
1497
1498#[derive(Debug, Clone, Serialize, Deserialize)]
1503#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1504pub struct UnusedComponentEmitFinding {
1505 #[serde(flatten)]
1507 pub emit: UnusedComponentEmit,
1508 pub actions: Vec<IssueAction>,
1511 #[serde(default, skip_serializing_if = "Option::is_none")]
1514 pub introduced: Option<AuditIntroduced>,
1515}
1516
1517impl UnusedComponentEmitFinding {
1518 #[must_use]
1521 pub fn with_actions(emit: UnusedComponentEmit) -> Self {
1522 let actions = vec![
1523 manual_framework_fix(
1524 FixActionType::EmitComponentEvent,
1525 "Emit the declared event from the component, or remove it from the component API",
1526 "Manual review required: public component APIs can intentionally keep stable events for external listeners.",
1527 ),
1528 suppress_line("// fallow-ignore-next-line unused-component-emit"),
1529 ];
1530 Self {
1531 emit,
1532 actions,
1533 introduced: None,
1534 }
1535 }
1536}
1537
1538#[derive(Debug, Clone, Serialize, Deserialize)]
1544#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1545pub struct UnusedSvelteEventFinding {
1546 #[serde(flatten)]
1548 pub event: UnusedSvelteEvent,
1549 pub actions: Vec<IssueAction>,
1552 #[serde(default, skip_serializing_if = "Option::is_none")]
1555 pub introduced: Option<AuditIntroduced>,
1556}
1557
1558impl UnusedSvelteEventFinding {
1559 #[must_use]
1562 pub fn with_actions(event: UnusedSvelteEvent) -> Self {
1563 let actions = vec![
1564 manual_framework_fix(
1565 FixActionType::WireSvelteEvent,
1566 "Add or forward a listener for this custom event, or remove the dispatch",
1567 "Manual review required: public Svelte component APIs can intentionally dispatch events for package consumers outside this project.",
1568 ),
1569 suppress_line("// fallow-ignore-next-line unused-svelte-event"),
1570 ];
1571 Self {
1572 event,
1573 actions,
1574 introduced: None,
1575 }
1576 }
1577}
1578
1579#[derive(Debug, Clone, Serialize, Deserialize)]
1585#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1586pub struct PropDrillingChainFinding {
1587 #[serde(flatten)]
1589 pub chain: PropDrillingChain,
1590 pub actions: Vec<IssueAction>,
1593 #[serde(default, skip_serializing_if = "Option::is_none")]
1596 pub introduced: Option<AuditIntroduced>,
1597}
1598
1599impl PropDrillingChainFinding {
1600 #[must_use]
1605 pub fn with_actions(chain: PropDrillingChain) -> Self {
1606 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1607 kind: SuppressLineKind::SuppressLine,
1608 auto_fixable: false,
1609 description: "Suppress with an inline comment above the source prop declaration"
1610 .to_string(),
1611 comment: "// fallow-ignore-next-line prop-drilling".to_string(),
1612 scope: None,
1613 })];
1614 Self {
1615 chain,
1616 actions,
1617 introduced: None,
1618 }
1619 }
1620}
1621
1622#[derive(Debug, Clone, Serialize, Deserialize)]
1628#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1629pub struct ThinWrapperFinding {
1630 #[serde(flatten)]
1632 pub wrapper: ThinWrapper,
1633 pub actions: Vec<IssueAction>,
1636 #[serde(default, skip_serializing_if = "Option::is_none")]
1639 pub introduced: Option<AuditIntroduced>,
1640}
1641
1642impl ThinWrapperFinding {
1643 #[must_use]
1647 pub fn with_actions(wrapper: ThinWrapper) -> Self {
1648 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1649 kind: SuppressLineKind::SuppressLine,
1650 auto_fixable: false,
1651 description: "Suppress with an inline comment above the component definition"
1652 .to_string(),
1653 comment: "// fallow-ignore-next-line thin-wrapper".to_string(),
1654 scope: None,
1655 })];
1656 Self {
1657 wrapper,
1658 actions,
1659 introduced: None,
1660 }
1661 }
1662}
1663
1664#[derive(Debug, Clone, Serialize, Deserialize)]
1672#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1673pub struct DuplicatePropShapeFinding {
1674 #[serde(flatten)]
1676 pub shape: DuplicatePropShape,
1677 pub actions: Vec<IssueAction>,
1680 #[serde(default, skip_serializing_if = "Option::is_none")]
1683 pub introduced: Option<AuditIntroduced>,
1684}
1685
1686impl DuplicatePropShapeFinding {
1687 #[must_use]
1694 pub fn with_actions(shape: DuplicatePropShape) -> Self {
1695 let actions = vec![
1696 IssueAction::SuppressLine(SuppressLineAction {
1697 kind: SuppressLineKind::SuppressLine,
1698 auto_fixable: false,
1699 description: "Three or more components share this exact prop shape. Extract one \
1700 shared `Props` type (or a base component) that every member reuses, \
1701 or keep them separate if a per-variant divergence is planned. \
1702 Suppress one member with an inline comment above the component \
1703 definition."
1704 .to_string(),
1705 comment: "// fallow-ignore-next-line duplicate-prop-shape".to_string(),
1706 scope: None,
1707 }),
1708 IssueAction::SuppressFile(SuppressFileAction {
1709 kind: SuppressFileKind::SuppressFile,
1710 auto_fixable: false,
1711 description: "Escape hatch: a file-level suppress silences this member but it \
1712 still appears in its siblings' `sharing_components` (the group is \
1713 real regardless of suppression)."
1714 .to_string(),
1715 comment: "// fallow-ignore-file duplicate-prop-shape".to_string(),
1716 }),
1717 ];
1718 Self {
1719 shape,
1720 actions,
1721 introduced: None,
1722 }
1723 }
1724}
1725
1726#[derive(Debug, Clone, Serialize, Deserialize)]
1731#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1732pub struct UnusedComponentInputFinding {
1733 #[serde(flatten)]
1735 pub input: UnusedComponentInput,
1736 pub actions: Vec<IssueAction>,
1739 #[serde(default, skip_serializing_if = "Option::is_none")]
1742 pub introduced: Option<AuditIntroduced>,
1743}
1744
1745impl UnusedComponentInputFinding {
1746 #[must_use]
1750 pub fn with_actions(input: UnusedComponentInput) -> Self {
1751 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1752 kind: SuppressLineKind::SuppressLine,
1753 auto_fixable: false,
1754 description: "Suppress with an inline comment above the line".to_string(),
1755 comment: "// fallow-ignore-next-line unused-component-input".to_string(),
1756 scope: None,
1757 })];
1758 Self {
1759 input,
1760 actions,
1761 introduced: None,
1762 }
1763 }
1764}
1765
1766#[derive(Debug, Clone, Serialize, Deserialize)]
1771#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1772pub struct UnusedComponentOutputFinding {
1773 #[serde(flatten)]
1775 pub output: UnusedComponentOutput,
1776 pub actions: Vec<IssueAction>,
1779 #[serde(default, skip_serializing_if = "Option::is_none")]
1782 pub introduced: Option<AuditIntroduced>,
1783}
1784
1785impl UnusedComponentOutputFinding {
1786 #[must_use]
1790 pub fn with_actions(output: UnusedComponentOutput) -> Self {
1791 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1792 kind: SuppressLineKind::SuppressLine,
1793 auto_fixable: false,
1794 description: "Suppress with an inline comment above the line".to_string(),
1795 comment: "// fallow-ignore-next-line unused-component-output".to_string(),
1796 scope: None,
1797 })];
1798 Self {
1799 output,
1800 actions,
1801 introduced: None,
1802 }
1803 }
1804}
1805
1806#[derive(Debug, Clone, Serialize, Deserialize)]
1812#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1813pub struct RouteCollisionFinding {
1814 #[serde(flatten)]
1816 pub collision: RouteCollision,
1817 pub actions: Vec<IssueAction>,
1820 #[serde(default, skip_serializing_if = "Option::is_none")]
1823 pub introduced: Option<AuditIntroduced>,
1824}
1825
1826impl RouteCollisionFinding {
1827 #[must_use]
1831 pub fn with_actions(collision: RouteCollision) -> Self {
1832 let actions = vec![
1833 IssueAction::Fix(FixAction {
1834 kind: FixActionType::ResolveRouteCollision,
1835 auto_fixable: false,
1836 description: "Two or more files resolve to the same URL. Move or merge one so \
1837 each URL has a single owner. Route groups `(name)` and parallel \
1838 slots `@name` are the only legal same-URL shapes."
1839 .to_string(),
1840 note: Some(
1841 "Next.js fails the build with \"You cannot have two parallel pages that \
1842 resolve to the same path\". See the sibling `conflicting_paths` array for \
1843 the other files that own this URL."
1844 .to_string(),
1845 ),
1846 available_in_catalogs: None,
1847 suggested_target: None,
1848 }),
1849 IssueAction::SuppressFile(SuppressFileAction {
1850 kind: SuppressFileKind::SuppressFile,
1851 auto_fixable: false,
1852 description: "Escape hatch only: a file-level suppress silences the finding but \
1853 does NOT make `next build` pass. Prefer moving or merging a file."
1854 .to_string(),
1855 comment: "// fallow-ignore-file route-collision".to_string(),
1856 }),
1857 ];
1858 Self {
1859 collision,
1860 actions,
1861 introduced: None,
1862 }
1863 }
1864}
1865
1866#[derive(Debug, Clone, Serialize, Deserialize)]
1871#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1872pub struct DynamicSegmentNameConflictFinding {
1873 #[serde(flatten)]
1875 pub conflict: DynamicSegmentNameConflict,
1876 pub actions: Vec<IssueAction>,
1879 #[serde(default, skip_serializing_if = "Option::is_none")]
1882 pub introduced: Option<AuditIntroduced>,
1883}
1884
1885impl DynamicSegmentNameConflictFinding {
1886 #[must_use]
1889 pub fn with_actions(conflict: DynamicSegmentNameConflict) -> Self {
1890 let actions = vec![
1891 IssueAction::Fix(FixAction {
1892 kind: FixActionType::ResolveDynamicSegmentNameConflict,
1893 auto_fixable: false,
1894 description: "Sibling dynamic segments at the same position use different param \
1895 names. Rename them to one consistent slug name (e.g. pick `[id]` \
1896 or `[slug]` for both)."
1897 .to_string(),
1898 note: Some(
1899 "Next.js throws \"You cannot use different slug names for the same dynamic \
1900 path\" at dev / runtime when the position is hit; `next build` does not \
1901 catch it. See the sibling `conflicting_segments` array."
1902 .to_string(),
1903 ),
1904 available_in_catalogs: None,
1905 suggested_target: None,
1906 }),
1907 IssueAction::SuppressFile(SuppressFileAction {
1908 kind: SuppressFileKind::SuppressFile,
1909 auto_fixable: false,
1910 description: "Escape hatch only: a file-level suppress silences the finding but \
1911 does NOT stop Next.js from throwing at dev / runtime. Prefer \
1912 renaming the segments."
1913 .to_string(),
1914 comment: "// fallow-ignore-file dynamic-segment-name-conflict".to_string(),
1915 }),
1916 ];
1917 Self {
1918 conflict,
1919 actions,
1920 introduced: None,
1921 }
1922 }
1923}
1924
1925#[derive(Debug, Clone, Serialize, Deserialize)]
1928#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1929pub struct UnusedEnumMemberFinding {
1930 #[serde(flatten)]
1932 pub member: UnusedMember,
1933 pub actions: Vec<IssueAction>,
1936 #[serde(default, skip_serializing_if = "Option::is_none")]
1939 pub introduced: Option<AuditIntroduced>,
1940 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1947 pub reachability_caveats: Vec<ReachabilityCaveat>,
1948}
1949
1950impl UnusedEnumMemberFinding {
1951 #[must_use]
1953 pub fn with_actions(member: UnusedMember) -> Self {
1954 let actions = vec![
1955 IssueAction::Fix(FixAction {
1956 kind: FixActionType::RemoveEnumMember,
1957 auto_fixable: true,
1958 description: "Remove this enum member".to_string(),
1959 note: None,
1960 available_in_catalogs: None,
1961 suggested_target: None,
1962 }),
1963 IssueAction::SuppressLine(SuppressLineAction {
1964 kind: SuppressLineKind::SuppressLine,
1965 auto_fixable: false,
1966 description: "Suppress with an inline comment above the line".to_string(),
1967 comment: "// fallow-ignore-next-line unused-enum-member".to_string(),
1968 scope: None,
1969 }),
1970 ];
1971 Self {
1972 member,
1973 actions,
1974 introduced: None,
1975 reachability_caveats: Vec::new(),
1976 }
1977 }
1978}
1979
1980#[derive(Debug, Clone, Serialize, Deserialize)]
1985#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1986pub struct UnusedClassMemberFinding {
1987 #[serde(flatten)]
1989 pub member: UnusedMember,
1990 pub actions: Vec<IssueAction>,
1993 #[serde(default, skip_serializing_if = "Option::is_none")]
1995 pub semantic: Option<SemanticCandidateDecision>,
1996 #[serde(skip)]
2000 #[cfg_attr(feature = "schema", schemars(skip))]
2001 pub semantic_only_candidate: bool,
2002 #[serde(default, skip_serializing_if = "Option::is_none")]
2005 pub introduced: Option<AuditIntroduced>,
2006 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2014 pub reachability_caveats: Vec<ReachabilityCaveat>,
2015}
2016
2017impl UnusedClassMemberFinding {
2018 #[must_use]
2023 pub fn with_actions(member: UnusedMember) -> Self {
2024 let actions = vec![
2025 IssueAction::Fix(FixAction {
2026 kind: FixActionType::RemoveClassMember,
2027 auto_fixable: false,
2028 description: "Remove this class member".to_string(),
2029 note: Some(
2030 "Class member may be used via dependency injection or decorators".to_string(),
2031 ),
2032 available_in_catalogs: None,
2033 suggested_target: None,
2034 }),
2035 IssueAction::SuppressLine(SuppressLineAction {
2036 kind: SuppressLineKind::SuppressLine,
2037 auto_fixable: false,
2038 description: "Suppress with an inline comment above the line".to_string(),
2039 comment: "// fallow-ignore-next-line unused-class-member".to_string(),
2040 scope: None,
2041 }),
2042 ];
2043 Self {
2044 member,
2045 actions,
2046 semantic: None,
2047 semantic_only_candidate: false,
2048 introduced: None,
2049 reachability_caveats: Vec::new(),
2050 }
2051 }
2052
2053 #[must_use]
2056 pub const fn semantic_only_candidate(mut self) -> Self {
2057 self.semantic_only_candidate = true;
2058 self
2059 }
2060
2061 pub fn set_semantic_decision(&mut self, decision: SemanticCandidateDecision) {
2074 let evidence_complete = self.reachability_caveats.is_empty();
2075 if let Some(IssueAction::Fix(action)) = self.actions.first_mut() {
2076 action.auto_fixable = decision.closed_world_eligible && evidence_complete;
2077 action.note = Some(if evidence_complete {
2078 decision.explanation.clone()
2079 } else {
2080 INCOMPLETE_EVIDENCE_NOTE.to_string()
2081 });
2082 }
2083 self.semantic = Some(decision);
2084 }
2085}
2086
2087#[derive(Debug, Clone, Serialize, Deserialize)]
2096#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2097pub struct UnusedStoreMemberFinding {
2098 #[serde(flatten)]
2100 pub member: UnusedMember,
2101 pub actions: Vec<IssueAction>,
2104 #[serde(default, skip_serializing_if = "Option::is_none")]
2107 pub introduced: Option<AuditIntroduced>,
2108 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2117 pub reachability_caveats: Vec<ReachabilityCaveat>,
2118}
2119
2120impl UnusedStoreMemberFinding {
2121 #[must_use]
2125 pub fn with_actions(member: UnusedMember) -> Self {
2126 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
2127 kind: SuppressLineKind::SuppressLine,
2128 auto_fixable: false,
2129 description: "Suppress with an inline comment above the line".to_string(),
2130 comment: "// fallow-ignore-next-line unused-store-member".to_string(),
2131 scope: None,
2132 })];
2133 Self {
2134 member,
2135 actions,
2136 introduced: None,
2137 reachability_caveats: Vec::new(),
2138 }
2139 }
2140}
2141
2142fn build_unused_dependency_actions(
2153 dep: &UnusedDependency,
2154 package_json_location: &str,
2155 suppress_issue_kind: &str,
2156) -> Vec<IssueAction> {
2157 let mut actions = Vec::with_capacity(2);
2158 let cross_workspace = !dep.used_in_workspaces.is_empty();
2159 actions.push(if cross_workspace {
2160 IssueAction::Fix(FixAction {
2161 kind: FixActionType::MoveDependency,
2162 auto_fixable: false,
2163 description: "Move this dependency to the workspace package.json that imports it"
2164 .to_string(),
2165 note: Some(
2166 "fallow fix will not remove dependencies that are imported by another workspace"
2167 .to_string(),
2168 ),
2169 available_in_catalogs: None,
2170 suggested_target: None,
2171 })
2172 } else {
2173 IssueAction::Fix(FixAction {
2174 kind: FixActionType::RemoveDependency,
2175 auto_fixable: true,
2176 description: format!("Remove from {package_json_location} in package.json"),
2177 note: None,
2178 available_in_catalogs: None,
2179 suggested_target: None,
2180 })
2181 });
2182 actions.push(build_ignore_dependencies_suppress_action(
2183 &dep.package_name,
2184 suppress_issue_kind,
2185 ));
2186 actions
2187}
2188
2189fn build_ignore_dependencies_suppress_action(
2197 package_name: &str,
2198 _suppress_issue_kind: &str,
2199) -> IssueAction {
2200 IssueAction::AddToConfig(AddToConfigAction {
2201 kind: AddToConfigKind::AddToConfig,
2202 auto_fixable: false,
2203 description: format!("Add \"{package_name}\" to ignoreDependencies in fallow config"),
2204 config_key: "ignoreDependencies".to_string(),
2205 value: AddToConfigValue::Scalar(package_name.to_string()),
2206 value_schema: Some(
2207 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items"
2208 .to_string(),
2209 ),
2210 })
2211}
2212
2213#[derive(Debug, Clone, Serialize, Deserialize)]
2219#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2220pub struct UnusedDependencyFinding {
2221 #[serde(flatten)]
2223 pub dep: UnusedDependency,
2224 pub actions: Vec<IssueAction>,
2227 #[serde(default, skip_serializing_if = "Option::is_none")]
2230 pub introduced: Option<AuditIntroduced>,
2231 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2238 pub reachability_caveats: Vec<ReachabilityCaveat>,
2239}
2240
2241impl UnusedDependencyFinding {
2242 #[must_use]
2245 pub fn with_actions(dep: UnusedDependency) -> Self {
2246 let actions = build_unused_dependency_actions(&dep, "dependencies", "unused-dependency");
2247 Self {
2248 dep,
2249 actions,
2250 introduced: None,
2251 reachability_caveats: Vec::new(),
2252 }
2253 }
2254}
2255
2256#[derive(Debug, Clone, Serialize, Deserialize)]
2262#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2263pub struct UnusedDevDependencyFinding {
2264 #[serde(flatten)]
2266 pub dep: UnusedDependency,
2267 pub actions: Vec<IssueAction>,
2270 #[serde(default, skip_serializing_if = "Option::is_none")]
2273 pub introduced: Option<AuditIntroduced>,
2274 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2281 pub reachability_caveats: Vec<ReachabilityCaveat>,
2282}
2283
2284impl UnusedDevDependencyFinding {
2285 #[must_use]
2287 pub fn with_actions(dep: UnusedDependency) -> Self {
2288 let actions =
2289 build_unused_dependency_actions(&dep, "devDependencies", "unused-dev-dependency");
2290 Self {
2291 dep,
2292 actions,
2293 introduced: None,
2294 reachability_caveats: Vec::new(),
2295 }
2296 }
2297}
2298
2299#[derive(Debug, Clone, Serialize, Deserialize)]
2305#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2306pub struct UnusedOptionalDependencyFinding {
2307 #[serde(flatten)]
2309 pub dep: UnusedDependency,
2310 pub actions: Vec<IssueAction>,
2313 #[serde(default, skip_serializing_if = "Option::is_none")]
2316 pub introduced: Option<AuditIntroduced>,
2317 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2324 pub reachability_caveats: Vec<ReachabilityCaveat>,
2325}
2326
2327impl UnusedOptionalDependencyFinding {
2328 #[must_use]
2330 pub fn with_actions(dep: UnusedDependency) -> Self {
2331 let actions =
2332 build_unused_dependency_actions(&dep, "optionalDependencies", "unused-dependency");
2333 Self {
2334 dep,
2335 actions,
2336 introduced: None,
2337 reachability_caveats: Vec::new(),
2338 }
2339 }
2340}
2341
2342#[derive(Debug, Clone, Serialize, Deserialize)]
2346#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2347pub struct UnlistedDependencyFinding {
2348 #[serde(flatten)]
2350 pub dep: UnlistedDependency,
2351 pub actions: Vec<IssueAction>,
2354 #[serde(default, skip_serializing_if = "Option::is_none")]
2357 pub introduced: Option<AuditIntroduced>,
2358}
2359
2360impl UnlistedDependencyFinding {
2361 #[must_use]
2363 pub fn with_actions(dep: UnlistedDependency) -> Self {
2364 let actions = vec![
2365 IssueAction::Fix(FixAction {
2366 kind: FixActionType::InstallDependency,
2367 auto_fixable: false,
2368 description: "Add this package to dependencies in package.json".to_string(),
2369 note: Some(
2370 "Verify this package should be a direct dependency before adding".to_string(),
2371 ),
2372 available_in_catalogs: None,
2373 suggested_target: None,
2374 }),
2375 build_ignore_dependencies_suppress_action(&dep.package_name, "unlisted-dependency"),
2376 ];
2377 Self {
2378 dep,
2379 actions,
2380 introduced: None,
2381 }
2382 }
2383}
2384
2385#[derive(Debug, Clone, Serialize, Deserialize)]
2389#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2390pub struct TypeOnlyDependencyFinding {
2391 #[serde(flatten)]
2393 pub dep: TypeOnlyDependency,
2394 pub actions: Vec<IssueAction>,
2397 #[serde(default, skip_serializing_if = "Option::is_none")]
2400 pub introduced: Option<AuditIntroduced>,
2401}
2402
2403impl TypeOnlyDependencyFinding {
2404 #[must_use]
2406 pub fn with_actions(dep: TypeOnlyDependency) -> Self {
2407 let actions = vec![
2408 IssueAction::Fix(FixAction {
2409 kind: FixActionType::MoveToDev,
2410 auto_fixable: false,
2411 description: "Move to devDependencies (only type imports are used)".to_string(),
2412 note: Some(
2413 "Type imports are erased at runtime so this dependency is not needed in production"
2414 .to_string(),
2415 ),
2416 available_in_catalogs: None,
2417 suggested_target: None,
2418 }),
2419 build_ignore_dependencies_suppress_action(&dep.package_name, "type-only-dependency"),
2420 ];
2421 Self {
2422 dep,
2423 actions,
2424 introduced: None,
2425 }
2426 }
2427}
2428
2429#[derive(Debug, Clone, Serialize, Deserialize)]
2433#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2434pub struct TestOnlyDependencyFinding {
2435 #[serde(flatten)]
2437 pub dep: TestOnlyDependency,
2438 pub actions: Vec<IssueAction>,
2441 #[serde(default, skip_serializing_if = "Option::is_none")]
2444 pub introduced: Option<AuditIntroduced>,
2445}
2446
2447impl TestOnlyDependencyFinding {
2448 #[must_use]
2450 pub fn with_actions(dep: TestOnlyDependency) -> Self {
2451 let actions = vec![
2452 IssueAction::Fix(FixAction {
2453 kind: FixActionType::MoveToDev,
2454 auto_fixable: false,
2455 description: "Move to devDependencies (only test files import this)".to_string(),
2456 note: Some(
2457 "Only test files import this package so it does not need to be a production dependency"
2458 .to_string(),
2459 ),
2460 available_in_catalogs: None,
2461 suggested_target: None,
2462 }),
2463 build_ignore_dependencies_suppress_action(&dep.package_name, "test-only-dependency"),
2464 ];
2465 Self {
2466 dep,
2467 actions,
2468 introduced: None,
2469 }
2470 }
2471}
2472
2473#[derive(Debug, Clone, Serialize, Deserialize)]
2478#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2479pub struct DevDependencyInProductionFinding {
2480 #[serde(flatten)]
2482 pub dep: DevDependencyInProduction,
2483 pub actions: Vec<IssueAction>,
2486 #[serde(default, skip_serializing_if = "Option::is_none")]
2489 pub introduced: Option<AuditIntroduced>,
2490}
2491
2492impl DevDependencyInProductionFinding {
2493 #[must_use]
2495 pub fn with_actions(dep: DevDependencyInProduction) -> Self {
2496 let actions = vec![
2497 IssueAction::Fix(FixAction {
2498 kind: FixActionType::MoveToProd,
2499 auto_fixable: false,
2500 description:
2501 "Move to dependencies if the deployment installs them (production code imports this)"
2502 .to_string(),
2503 note: Some(
2504 "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"
2505 .to_string(),
2506 ),
2507 available_in_catalogs: None,
2508 suggested_target: None,
2509 }),
2510 build_ignore_dependencies_suppress_action(
2511 &dep.package_name,
2512 "dev-dependency-in-production",
2513 ),
2514 ];
2515 Self {
2516 dep,
2517 actions,
2518 introduced: None,
2519 }
2520 }
2521}
2522
2523#[derive(Debug, Clone, Serialize, Deserialize)]
2544#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2545pub struct DuplicateExportFinding {
2546 #[serde(flatten)]
2548 pub export: DuplicateExport,
2549 pub actions: Vec<IssueAction>,
2552 #[serde(default, skip_serializing_if = "Option::is_none")]
2555 pub introduced: Option<AuditIntroduced>,
2556}
2557
2558impl DuplicateExportFinding {
2559 #[must_use]
2568 pub fn with_actions(export: DuplicateExport) -> Self {
2569 let mut actions: Vec<IssueAction> = Vec::with_capacity(3);
2570
2571 if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
2572 actions.push(IssueAction::AddToConfig(AddToConfigAction {
2573 kind: AddToConfigKind::AddToConfig,
2574 auto_fixable: false,
2575 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(),
2576 config_key: "ignoreExports".to_string(),
2577 value: AddToConfigValue::ExportsRules(rules),
2578 value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
2579 }));
2580 }
2581
2582 actions.push(IssueAction::Fix(FixAction {
2583 kind: FixActionType::RemoveDuplicate,
2584 auto_fixable: false,
2585 description: "Keep one canonical export location and remove the others".to_string(),
2586 note: Some(NAMESPACE_BARREL_HINT.to_string()),
2587 available_in_catalogs: None,
2588 suggested_target: None,
2589 }));
2590
2591 actions.push(IssueAction::SuppressLine(SuppressLineAction {
2592 kind: SuppressLineKind::SuppressLine,
2593 auto_fixable: false,
2594 description: "Suppress with an inline comment above the line".to_string(),
2595 comment: "// fallow-ignore-next-line duplicate-export".to_string(),
2596 scope: Some(SuppressLineScope::PerLocation),
2597 }));
2598
2599 Self {
2600 export,
2601 actions,
2602 introduced: None,
2603 }
2604 }
2605
2606 pub fn set_config_fixable(&mut self, fixable: bool) {
2612 if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
2613 action.auto_fixable = fixable;
2614 }
2615 }
2616}
2617
2618fn build_duplicate_exports_ignore_rules(
2622 export: &DuplicateExport,
2623) -> Option<Vec<IgnoreExportsRule>> {
2624 let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
2625 for loc in &export.locations {
2626 let path = loc.path.to_string_lossy().replace('\\', "/");
2634 if path.is_empty() {
2635 continue;
2636 }
2637 if entries.iter().any(|existing| existing.file == path) {
2638 continue;
2639 }
2640 entries.push(IgnoreExportsRule {
2641 file: path,
2642 exports: vec!["*".to_string()],
2643 });
2644 }
2645 if entries.is_empty() {
2646 None
2647 } else {
2648 Some(entries)
2649 }
2650}
2651
2652#[derive(Debug, Clone, Serialize, Deserialize)]
2656#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2657pub struct UnusedCatalogEntryFinding {
2658 #[serde(flatten)]
2660 pub entry: UnusedCatalogEntry,
2661 pub actions: Vec<IssueAction>,
2663 #[serde(default, skip_serializing_if = "Option::is_none")]
2666 pub introduced: Option<AuditIntroduced>,
2667}
2668
2669impl UnusedCatalogEntryFinding {
2670 #[must_use]
2675 pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
2676 let is_pnpm_source = is_pnpm_catalog_source(&entry.path);
2677 let auto_fixable = entry.hardcoded_consumers.is_empty() && is_pnpm_source;
2678 let note = if is_pnpm_source {
2679 Some(
2680 "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing"
2681 .to_string(),
2682 )
2683 } else {
2684 Some(
2685 "fallow fix only edits pnpm-workspace.yaml catalog entries. Edit Bun package.json catalogs manually."
2686 .to_string(),
2687 )
2688 };
2689 let mut actions = vec![IssueAction::Fix(FixAction {
2690 kind: FixActionType::RemoveCatalogEntry,
2691 auto_fixable,
2692 description: if is_pnpm_source {
2693 "Remove the entry from pnpm-workspace.yaml".to_string()
2694 } else {
2695 "Remove the entry from the catalog source file manually".to_string()
2696 },
2697 note,
2698 available_in_catalogs: None,
2699 suggested_target: None,
2700 })];
2701 if is_pnpm_source {
2702 actions.push(IssueAction::SuppressLine(SuppressLineAction {
2703 kind: SuppressLineKind::SuppressLine,
2704 auto_fixable: false,
2705 description: "Suppress with a YAML comment above the line".to_string(),
2706 comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
2707 scope: None,
2708 }));
2709 }
2710 Self {
2711 entry,
2712 actions,
2713 introduced: None,
2714 }
2715 }
2716}
2717
2718#[derive(Debug, Clone, Serialize, Deserialize)]
2722#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2723pub struct EmptyCatalogGroupFinding {
2724 #[serde(flatten)]
2726 pub group: EmptyCatalogGroup,
2727 pub actions: Vec<IssueAction>,
2729 #[serde(default, skip_serializing_if = "Option::is_none")]
2732 pub introduced: Option<AuditIntroduced>,
2733}
2734
2735impl EmptyCatalogGroupFinding {
2736 #[must_use]
2738 pub fn with_actions(group: EmptyCatalogGroup) -> Self {
2739 let auto_fixable = is_pnpm_catalog_source(&group.path);
2740 let mut actions = vec![IssueAction::Fix(FixAction {
2741 kind: FixActionType::RemoveEmptyCatalogGroup,
2742 auto_fixable,
2743 description: if auto_fixable {
2744 "Remove the empty named catalog group from pnpm-workspace.yaml".to_string()
2745 } else {
2746 "Remove the empty named catalog group from the catalog source file manually"
2747 .to_string()
2748 },
2749 note: Some(if auto_fixable {
2750 "Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
2751 .to_string()
2752 } else {
2753 "fallow fix only edits pnpm-workspace.yaml catalog groups. Edit Bun package.json catalogs manually."
2754 .to_string()
2755 }),
2756 available_in_catalogs: None,
2757 suggested_target: None,
2758 })];
2759 if auto_fixable {
2760 actions.push(IssueAction::SuppressLine(SuppressLineAction {
2761 kind: SuppressLineKind::SuppressLine,
2762 auto_fixable: false,
2763 description: "Suppress with a YAML comment above the line".to_string(),
2764 comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
2765 scope: None,
2766 }));
2767 }
2768 Self {
2769 group,
2770 actions,
2771 introduced: None,
2772 }
2773 }
2774}
2775
2776fn is_pnpm_catalog_source(path: &Path) -> bool {
2777 path == Path::new(PNPM_WORKSPACE_FILE)
2778}
2779
2780#[derive(Debug, Clone, Serialize, Deserialize)]
2788#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2789pub struct UnresolvedCatalogReferenceFinding {
2790 #[serde(flatten)]
2792 pub reference: UnresolvedCatalogReference,
2793 pub actions: Vec<IssueAction>,
2796 #[serde(default, skip_serializing_if = "Option::is_none")]
2799 pub introduced: Option<AuditIntroduced>,
2800}
2801
2802impl UnresolvedCatalogReferenceFinding {
2803 #[must_use]
2807 pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
2808 let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
2813 let primary = catalog_reference_primary_action(&reference);
2814 let fallback = remove_catalog_reference_action();
2815 let suppress = suppress_catalog_reference_action(&reference, consumer_path);
2816
2817 Self {
2818 reference,
2819 actions: vec![primary, fallback, suppress],
2820 introduced: None,
2821 }
2822 }
2823}
2824
2825fn catalog_reference_primary_action(reference: &UnresolvedCatalogReference) -> IssueAction {
2826 if reference.available_in_catalogs.is_empty() {
2827 return IssueAction::Fix(FixAction {
2828 kind: FixActionType::AddCatalogEntry,
2829 auto_fixable: false,
2830 description: format!(
2831 "Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
2832 reference.entry_name, reference.catalog_name
2833 ),
2834 note: Some(
2835 "Pin a version that satisfies the consumer's import; no other catalog declares this package today"
2836 .to_string(),
2837 ),
2838 available_in_catalogs: None,
2839 suggested_target: None,
2840 });
2841 }
2842
2843 let available = reference.available_in_catalogs.clone();
2844 let suggested_target = (available.len() == 1).then(|| available[0].clone());
2845 IssueAction::Fix(FixAction {
2846 kind: FixActionType::UpdateCatalogReference,
2847 auto_fixable: false,
2848 description: format!(
2849 "Switch the reference from `catalog:{}` to a catalog that declares `{}`",
2850 reference.catalog_name, reference.entry_name
2851 ),
2852 note: None,
2853 available_in_catalogs: Some(available),
2854 suggested_target,
2855 })
2856}
2857
2858fn remove_catalog_reference_action() -> IssueAction {
2859 IssueAction::Fix(FixAction {
2860 kind: FixActionType::RemoveCatalogReference,
2861 auto_fixable: false,
2862 description: "Remove the catalog reference and pin a hardcoded version in package.json"
2863 .to_string(),
2864 note: Some(
2865 "Use only when neither another catalog declares the package nor the named catalog should grow to include it"
2866 .to_string(),
2867 ),
2868 available_in_catalogs: None,
2869 suggested_target: None,
2870 })
2871}
2872
2873fn suppress_catalog_reference_action(
2874 reference: &UnresolvedCatalogReference,
2875 consumer_path: String,
2876) -> IssueAction {
2877 let mut suppress_value = serde_json::Map::new();
2878 suppress_value.insert(
2879 "package".to_string(),
2880 serde_json::Value::String(reference.entry_name.clone()),
2881 );
2882 suppress_value.insert(
2883 "catalog".to_string(),
2884 serde_json::Value::String(reference.catalog_name.clone()),
2885 );
2886 suppress_value.insert(
2887 "consumer".to_string(),
2888 serde_json::Value::String(consumer_path),
2889 );
2890 IssueAction::AddToConfig(AddToConfigAction {
2891 kind: AddToConfigKind::AddToConfig,
2892 auto_fixable: false,
2893 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(),
2894 config_key: "ignoreCatalogReferences".to_string(),
2895 value: AddToConfigValue::RuleObject(suppress_value),
2896 value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
2897 })
2898}
2899
2900#[derive(Debug, Clone, Serialize, Deserialize)]
2905#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2906pub struct UnusedDependencyOverrideFinding {
2907 #[serde(flatten)]
2909 pub entry: UnusedDependencyOverride,
2910 pub actions: Vec<IssueAction>,
2912 #[serde(default, skip_serializing_if = "Option::is_none")]
2915 pub introduced: Option<AuditIntroduced>,
2916}
2917
2918impl UnusedDependencyOverrideFinding {
2919 #[must_use]
2921 pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
2922 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
2923 actions.push(IssueAction::Fix(FixAction {
2924 kind: FixActionType::RemoveDependencyOverride,
2925 auto_fixable: false,
2926 description: "Remove the package-manager override entry from its declaration source"
2927 .to_string(),
2928 note: Some(
2929 "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)"
2930 .to_string(),
2931 ),
2932 available_in_catalogs: None,
2933 suggested_target: None,
2934 }));
2935
2936 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
2937 Some(&entry.target_package),
2938 &entry.raw_key,
2939 entry.source,
2940 ) {
2941 actions.push(suppress);
2942 }
2943
2944 Self {
2945 entry,
2946 actions,
2947 introduced: None,
2948 }
2949 }
2950}
2951
2952#[derive(Debug, Clone, Serialize, Deserialize)]
2958#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2959pub struct MisconfiguredDependencyOverrideFinding {
2960 #[serde(flatten)]
2962 pub entry: MisconfiguredDependencyOverride,
2963 pub actions: Vec<IssueAction>,
2965 #[serde(default, skip_serializing_if = "Option::is_none")]
2968 pub introduced: Option<AuditIntroduced>,
2969}
2970
2971impl MisconfiguredDependencyOverrideFinding {
2972 #[must_use]
2977 pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
2978 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
2979 actions.push(IssueAction::Fix(FixAction {
2980 kind: FixActionType::FixDependencyOverride,
2981 auto_fixable: false,
2982 description:
2983 "Fix the package-manager override key or value: invalid entries are rejected or ignored"
2984 .to_string(),
2985 note: Some(
2986 "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`."
2987 .to_string(),
2988 ),
2989 available_in_catalogs: None,
2990 suggested_target: None,
2991 }));
2992
2993 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
2994 entry.target_package.as_deref(),
2995 &entry.raw_key,
2996 entry.source,
2997 ) {
2998 actions.push(suppress);
2999 }
3000
3001 Self {
3002 entry,
3003 actions,
3004 introduced: None,
3005 }
3006 }
3007}
3008
3009fn build_ignore_dependency_overrides_suppress(
3014 target_package: Option<&str>,
3015 raw_key: &str,
3016 source: DependencyOverrideSource,
3017) -> Option<IssueAction> {
3018 let package = target_package
3019 .filter(|s| !s.is_empty())
3020 .or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
3021 .to_string();
3022 let mut value = serde_json::Map::new();
3023 value.insert("package".to_string(), serde_json::Value::String(package));
3024 value.insert(
3025 "source".to_string(),
3026 serde_json::Value::String(source.as_label().to_string()),
3027 );
3028 Some(IssueAction::AddToConfig(AddToConfigAction {
3029 kind: AddToConfigKind::AddToConfig,
3030 auto_fixable: false,
3031 description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
3032 config_key: "ignoreDependencyOverrides".to_string(),
3033 value: AddToConfigValue::RuleObject(value),
3034 value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
3035 }))
3036}
3037
3038impl_caveated_finding!(
3045 UnusedFileFinding,
3046 UnusedExportFinding,
3047 UnusedTypeFinding,
3048 UnusedEnumMemberFinding,
3049 UnusedClassMemberFinding,
3050 UnusedStoreMemberFinding,
3051 UnusedDependencyFinding,
3052 UnusedDevDependencyFinding,
3053 UnusedOptionalDependencyFinding,
3054);
3055
3056#[cfg(test)]
3066mod caveat_tokens {
3067 use super::*;
3068
3069 #[test]
3073 fn token_labels_match_the_typed_labels() {
3074 let typed = [
3075 ReachabilityCaveat::IncompleteFileAnalysis,
3076 ReachabilityCaveat::IncompleteImportGraph,
3077 ];
3078 let tokens: Vec<&str> = typed.iter().map(|c| c.token()).collect();
3079
3080 assert_eq!(
3081 caveat_labels_for_tokens(tokens.iter().copied()),
3082 caveat_labels(&typed)
3083 );
3084 assert_eq!(
3085 caveat_suffix_for_tokens(tokens.iter().copied()),
3086 caveat_suffix(&typed)
3087 );
3088 }
3089
3090 #[test]
3091 fn no_tokens_means_nothing_to_say() {
3092 assert_eq!(caveat_labels_for_tokens(std::iter::empty()), None);
3093 assert_eq!(caveat_suffix_for_tokens(std::iter::empty()), None);
3094 }
3095
3096 #[test]
3101 fn a_rendered_suffix_is_recognised_by_the_marker() {
3102 for caveats in [
3103 &[ReachabilityCaveat::IncompleteImportGraph][..],
3104 &[
3105 ReachabilityCaveat::IncompleteFileAnalysis,
3106 ReachabilityCaveat::IncompleteImportGraph,
3107 ][..],
3108 ] {
3109 let suffix = caveat_suffix(caveats).expect("a caveat renders a suffix");
3110 assert!(
3111 description_carries_caveat(&format!("Something is never referenced{suffix}")),
3112 "the marker must match what caveat_suffix writes: {suffix}"
3113 );
3114 }
3115 assert!(
3116 description_carries_caveat(&format!(
3117 "Something is never referenced{}",
3118 caveat_suffix_for_tokens(["some-future-cause"]).expect("token suffix")
3119 )),
3120 "the token-side renderer writes the same marker"
3121 );
3122 assert!(
3123 !description_carries_caveat("Class member 'Widget.helper' is never referenced"),
3124 "a clean description must not read as caveated"
3125 );
3126 }
3127
3128 #[test]
3135 fn no_caveat_message_names_a_single_cause() {
3136 for caveat in [
3137 ReachabilityCaveat::IncompleteFileAnalysis,
3138 ReachabilityCaveat::IncompleteImportGraph,
3139 ] {
3140 let message = caveat.message();
3141 assert!(
3142 !message.contains("parse cleanly") && !message.contains("parse error"),
3143 "{} names the parse cause alone, but a size-skipped or unreadable \
3144 file reaches the same caveat: {message}",
3145 caveat.token()
3146 );
3147 assert!(
3148 message.contains("workspace_diagnostics"),
3149 "{} must point at the list that names the actual files: {message}",
3150 caveat.token()
3151 );
3152 }
3153 }
3154
3155 #[test]
3159 fn an_unrecognised_token_still_renders_as_a_caveat() {
3160 let suffix = caveat_suffix_for_tokens(["some-future-cause"])
3161 .expect("an unknown token is still a caveat");
3162
3163 assert_eq!(suffix, " (caveat: some future cause)");
3164 }
3165}
3166
3167#[cfg(test)]
3180mod mutation_gate {
3181 use super::*;
3182 use crate::extract::MemberKind;
3183 use crate::results::DependencyLocation;
3184 use std::path::PathBuf;
3185
3186 const BOTH: [ReachabilityCaveat; 2] = [
3187 ReachabilityCaveat::IncompleteFileAnalysis,
3188 ReachabilityCaveat::IncompleteImportGraph,
3189 ];
3190
3191 fn export(name: &str) -> UnusedExport {
3192 UnusedExport {
3193 path: PathBuf::from("/p/src/mod.ts"),
3194 export_name: name.to_string(),
3195 is_type_only: false,
3196 line: 1,
3197 col: 0,
3198 span_start: 0,
3199 is_re_export: false,
3200 }
3201 }
3202
3203 fn member(name: &str) -> UnusedMember {
3204 UnusedMember {
3205 path: PathBuf::from("/p/src/mod.ts"),
3206 parent_name: "Color".to_string(),
3207 member_name: name.to_string(),
3208 kind: MemberKind::EnumMember,
3209 line: 2,
3210 col: 2,
3211 }
3212 }
3213
3214 fn class_member(name: &str) -> UnusedMember {
3215 UnusedMember {
3216 parent_name: "Widget".to_string(),
3217 kind: MemberKind::ClassMethod,
3218 ..member(name)
3219 }
3220 }
3221
3222 fn store_member(name: &str) -> UnusedMember {
3223 UnusedMember {
3224 parent_name: "useCounterStore".to_string(),
3225 kind: MemberKind::StoreMember,
3226 ..member(name)
3227 }
3228 }
3229
3230 fn dependency(name: &str) -> UnusedDependency {
3231 UnusedDependency {
3232 package_name: name.to_string(),
3233 location: DependencyLocation::Dependencies,
3234 path: PathBuf::from("/p/package.json"),
3235 line: 5,
3236 used_in_workspaces: Vec::new(),
3237 }
3238 }
3239
3240 type GatedPair = (&'static str, Box<dyn Gated>, Box<dyn Gated>);
3243
3244 fn every_finding_type() -> Vec<GatedPair> {
3247 fn pair<T: Gated + Clone + 'static>(name: &'static str, clean: T) -> GatedPair {
3248 let mut caveated = clean.clone();
3249 caveated.stamp(BOTH.to_vec());
3250 (name, Box::new(clean), Box::new(caveated))
3251 }
3252 vec![
3253 pair(
3254 "unused_files",
3255 UnusedFileFinding::with_actions(UnusedFile {
3256 path: PathBuf::from("/p/src/orphan.ts"),
3257 }),
3258 ),
3259 pair(
3260 "unused_exports",
3261 UnusedExportFinding::with_actions(export("helper")),
3262 ),
3263 pair(
3264 "unused_types",
3265 UnusedTypeFinding::with_actions(export("Shape")),
3266 ),
3267 pair(
3268 "unused_enum_members",
3269 UnusedEnumMemberFinding::with_actions(member("Blue")),
3270 ),
3271 pair(
3272 "unused_class_members",
3273 UnusedClassMemberFinding::with_actions(class_member("legacyMethod")),
3274 ),
3275 pair(
3276 "unused_store_members",
3277 UnusedStoreMemberFinding::with_actions(store_member("onlyUsedInBigFile")),
3278 ),
3279 pair(
3280 "unused_dependencies",
3281 UnusedDependencyFinding::with_actions(dependency("lodash")),
3282 ),
3283 pair(
3284 "unused_dev_dependencies",
3285 UnusedDevDependencyFinding::with_actions(dependency("vitest")),
3286 ),
3287 pair(
3288 "unused_optional_dependencies",
3289 UnusedOptionalDependencyFinding::with_actions(dependency("fsevents")),
3290 ),
3291 ]
3292 }
3293
3294 trait Gated {
3297 fn actions(&self) -> &[IssueAction];
3298 fn gate_allows_mutation(&self) -> bool;
3299 fn stamp(&mut self, caveats: Vec<ReachabilityCaveat>);
3300 }
3301
3302 impl<T: MutationEvidence + CaveatedFinding + HasActions> Gated for T {
3303 fn actions(&self) -> &[IssueAction] {
3304 HasActions::actions(self)
3305 }
3306 fn gate_allows_mutation(&self) -> bool {
3307 self.may_auto_apply_mutation()
3308 }
3309 fn stamp(&mut self, caveats: Vec<ReachabilityCaveat>) {
3310 self.set_reachability_caveats(caveats);
3311 }
3312 }
3313
3314 trait HasActions {
3315 fn actions(&self) -> &[IssueAction];
3316 }
3317
3318 macro_rules! has_actions {
3319 ($($ty:ty),+ $(,)?) => { $( impl HasActions for $ty {
3320 fn actions(&self) -> &[IssueAction] { &self.actions }
3321 } )+ };
3322 }
3323 has_actions!(
3324 UnusedFileFinding,
3325 UnusedExportFinding,
3326 UnusedTypeFinding,
3327 UnusedEnumMemberFinding,
3328 UnusedClassMemberFinding,
3329 UnusedStoreMemberFinding,
3330 UnusedDependencyFinding,
3331 UnusedDevDependencyFinding,
3332 UnusedOptionalDependencyFinding,
3333 );
3334
3335 #[test]
3339 fn every_auto_fixable_dead_code_mutation_is_gated() {
3340 for (name, _clean, caveated) in every_finding_type() {
3341 assert!(
3342 !caveated.gate_allows_mutation(),
3343 "{name}: a stamped finding must fail the gate"
3344 );
3345 for action in caveated.actions() {
3346 assert!(
3347 !action.is_auto_fixable(),
3348 "{name}: a caveated finding still advertises an auto-fixable action, so an \
3349 agent following the documented actions contract would plan a removal \
3350 `fallow fix` refuses"
3351 );
3352 }
3353 }
3354 }
3355
3356 #[test]
3360 fn an_uncaveated_finding_keeps_its_auto_fix() {
3361 let auto_fixable_types = [
3362 "unused_exports",
3363 "unused_types",
3364 "unused_enum_members",
3365 "unused_dependencies",
3366 "unused_dev_dependencies",
3367 "unused_optional_dependencies",
3368 ];
3369 for (name, clean, _caveated) in every_finding_type() {
3370 assert!(
3371 clean.gate_allows_mutation(),
3372 "{name}: a finding with no caveat must pass the gate"
3373 );
3374 if auto_fixable_types.contains(&name) {
3375 assert!(
3376 clean.actions().iter().any(IssueAction::is_auto_fixable),
3377 "{name}: the gate must not withhold a mutation the run has the evidence for"
3378 );
3379 }
3380 }
3381 }
3382
3383 #[test]
3387 fn the_gate_downgrades_a_mutation_without_removing_it() {
3388 let clean = UnusedExportFinding::with_actions(export("helper"));
3389 let mut caveated = clean.clone();
3390 caveated.set_reachability_caveats(BOTH.to_vec());
3391
3392 assert_eq!(caveated.actions.len(), clean.actions.len());
3393 let IssueAction::Fix(fix) = &caveated.actions[0] else {
3394 panic!("position 0 stays the fix action");
3395 };
3396 assert!(!fix.auto_fixable);
3397 assert_eq!(
3398 fix.note.as_deref(),
3399 Some(INCOMPLETE_EVIDENCE_NOTE),
3400 "the withheld action says why in its own note, not only in a sibling array"
3401 );
3402 }
3403
3404 #[test]
3408 fn a_gated_mutation_keeps_the_note_it_already_had() {
3409 let mut re_export = export("helper");
3410 re_export.is_re_export = true;
3411 let mut finding = UnusedExportFinding::with_actions(re_export);
3412 finding.set_reachability_caveats(vec![ReachabilityCaveat::IncompleteImportGraph]);
3413
3414 let IssueAction::Fix(fix) = &finding.actions[0] else {
3415 panic!("position 0 stays the fix action");
3416 };
3417 let note = fix.note.as_deref().expect("note present");
3418 assert!(note.contains("public API"), "the original note survives");
3419 assert!(
3420 note.contains("Evidence is incomplete"),
3421 "the caveat is added"
3422 );
3423 }
3424
3425 #[test]
3444 fn a_store_member_exposes_no_mutation_at_all() {
3445 let store = UnusedStoreMemberFinding::with_actions(member("total"));
3446 assert!(
3447 !store.actions.iter().any(IssueAction::is_auto_fixable),
3448 "a store member must expose no automatically applicable mutation"
3449 );
3450 assert!(
3451 !store
3452 .actions
3453 .iter()
3454 .any(|action| matches!(action, IssueAction::Fix(_))),
3455 "and no fix action at all"
3456 );
3457
3458 let class = UnusedClassMemberFinding::with_actions(class_member("helper"));
3459 assert!(
3460 !class.actions.iter().any(IssueAction::is_auto_fixable),
3461 "a class member's syntactic removal stays withheld until semantic evidence opens it"
3462 );
3463 }
3464
3465 #[test]
3469 fn a_complete_semantic_verdict_cannot_reopen_a_caveated_mutation() {
3470 use crate::semantic::{
3471 SemanticCandidateDecision, SemanticCandidateDecisionKind, SemanticCompleteness,
3472 SemanticNamespace, SemanticSymbol,
3473 };
3474
3475 let complete_negative = || SemanticCandidateDecision {
3476 query_id: 0,
3477 subject: SemanticSymbol {
3478 path: PathBuf::from("/p/src/mod.ts"),
3479 namespace: SemanticNamespace::Value,
3480 declaration_kind: "function".to_string(),
3481 exported_name: "helper".to_string(),
3482 local_name: "helper".to_string(),
3483 owner: None,
3484 line: 1,
3485 col: 0,
3486 },
3487 decision: SemanticCandidateDecisionKind::ConfirmedNoStaticReferences,
3488 status: SemanticCompleteness::Complete,
3489 owning_projects: Vec::new(),
3490 evidence: Vec::new(),
3491 contract: None,
3492 framework_contract: None,
3493 closed_world_eligible: false,
3494 edit_guard: None,
3495 reason_code: None,
3496 explanation: String::new(),
3497 actions: Vec::new(),
3498 total_evidence_count: 0,
3499 truncated: false,
3500 omissions: Vec::new(),
3501 };
3502
3503 let mut clean = UnusedExportFinding::with_actions(export("helper"));
3504 clean.set_semantic_decision(complete_negative());
3505 assert!(
3506 clean.actions.iter().any(IssueAction::is_auto_fixable),
3507 "a complete negative verdict on a clean run still enables the fix"
3508 );
3509
3510 let mut caveated = UnusedExportFinding::with_actions(export("helper"));
3511 caveated.set_reachability_caveats(vec![ReachabilityCaveat::IncompleteImportGraph]);
3512 caveated.set_semantic_decision(complete_negative());
3513 assert!(
3514 !caveated.actions.iter().any(IssueAction::is_auto_fixable),
3515 "the semantic pass must ask the gate too"
3516 );
3517 }
3518
3519 #[test]
3526 fn a_complete_semantic_verdict_cannot_reopen_a_caveated_class_member() {
3527 use crate::semantic::{
3528 SemanticCandidateDecision, SemanticCandidateDecisionKind, SemanticCompleteness,
3529 SemanticNamespace, SemanticSymbol,
3530 };
3531
3532 let eligible = || SemanticCandidateDecision {
3533 query_id: 0,
3534 subject: SemanticSymbol {
3535 path: PathBuf::from("/p/src/mod.ts"),
3536 namespace: SemanticNamespace::Value,
3537 declaration_kind: "method".to_string(),
3538 exported_name: "Widget".to_string(),
3539 local_name: "legacyMethod".to_string(),
3540 owner: Some("Widget".to_string()),
3541 line: 2,
3542 col: 2,
3543 },
3544 decision: SemanticCandidateDecisionKind::ConfirmedNoStaticReferences,
3545 status: SemanticCompleteness::Complete,
3546 owning_projects: Vec::new(),
3547 evidence: Vec::new(),
3548 contract: None,
3549 framework_contract: None,
3550 closed_world_eligible: true,
3551 edit_guard: None,
3552 reason_code: None,
3553 explanation: "closed world proved".to_string(),
3554 actions: Vec::new(),
3555 total_evidence_count: 0,
3556 truncated: false,
3557 omissions: Vec::new(),
3558 };
3559
3560 let mut clean = UnusedClassMemberFinding::with_actions(class_member("legacyMethod"));
3561 clean.set_semantic_decision(eligible());
3562 assert!(
3563 clean.actions.iter().any(IssueAction::is_auto_fixable),
3564 "a closed-world verdict on a run that read every file still opens the removal"
3565 );
3566
3567 let mut caveated = UnusedClassMemberFinding::with_actions(class_member("legacyMethod"));
3568 caveated.set_reachability_caveats(vec![ReachabilityCaveat::IncompleteImportGraph]);
3569 caveated.set_semantic_decision(eligible());
3570 assert!(
3571 !caveated.actions.iter().any(IssueAction::is_auto_fixable),
3572 "the class-member semantic pass must ask the gate too"
3573 );
3574 let IssueAction::Fix(fix) = &caveated.actions[0] else {
3575 panic!("position 0 stays the fix action");
3576 };
3577 assert_eq!(
3578 fix.note.as_deref(),
3579 Some(INCOMPLETE_EVIDENCE_NOTE),
3580 "the withheld action says why, rather than repeating a closed-world explanation \
3581 computed over a program the run did not fully read"
3582 );
3583 }
3584}
3585
3586#[cfg(test)]
3587mod position_0_invariants {
3588 use super::*;
3589 use crate::output::FixActionType;
3590 use crate::results::{DependencyOverrideSource, DuplicateLocation};
3591 use std::path::PathBuf;
3592
3593 fn action_type(action: &IssueAction) -> &'static str {
3598 match action {
3599 IssueAction::Fix(fix) => match fix.kind {
3600 FixActionType::RemoveExport => "remove-export",
3601 FixActionType::DeleteFile => "delete-file",
3602 FixActionType::RemoveDependency => "remove-dependency",
3603 FixActionType::MoveDependency => "move-dependency",
3604 FixActionType::RemoveEnumMember => "remove-enum-member",
3605 FixActionType::RemoveClassMember => "remove-class-member",
3606 FixActionType::ResolveImport => "resolve-import",
3607 FixActionType::InstallDependency => "install-dependency",
3608 FixActionType::RemoveDuplicate => "remove-duplicate",
3609 FixActionType::MoveToDev => "move-to-dev",
3610 FixActionType::MoveToProd => "move-to-prod",
3611 FixActionType::RefactorCycle => "refactor-cycle",
3612 FixActionType::RefactorReExportCycle => "refactor-re-export-cycle",
3613 FixActionType::RefactorBoundary => "refactor-boundary",
3614 FixActionType::ExportType => "export-type",
3615 FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
3616 FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
3617 FixActionType::UpdateCatalogReference => "update-catalog-reference",
3618 FixActionType::AddCatalogEntry => "add-catalog-entry",
3619 FixActionType::RemoveCatalogReference => "remove-catalog-reference",
3620 FixActionType::RemoveDependencyOverride => "remove-dependency-override",
3621 FixActionType::FixDependencyOverride => "fix-dependency-override",
3622 FixActionType::ResolvePolicyViolation => "resolve-policy-violation",
3623 FixActionType::MoveToServerModule => "move-to-server-module",
3624 FixActionType::SplitMixedBarrel => "split-mixed-barrel",
3625 FixActionType::HoistDirective => "hoist-directive",
3626 FixActionType::WireServerAction => "wire-server-action",
3627 FixActionType::ProvideInject => "provide-inject",
3628 FixActionType::UseLoadData => "use-load-data",
3629 FixActionType::RenderComponent => "render-component",
3630 FixActionType::UseComponentProp => "use-component-prop",
3631 FixActionType::EmitComponentEvent => "emit-component-event",
3632 FixActionType::WireSvelteEvent => "wire-svelte-event",
3633 FixActionType::ResolveRouteCollision => "resolve-route-collision",
3634 FixActionType::ResolveDynamicSegmentNameConflict => {
3635 "resolve-dynamic-segment-name-conflict"
3636 }
3637 FixActionType::AddSuppressionReason => "add-suppression-reason",
3638 FixActionType::RemoveStaleSuppression => "remove-stale-suppression",
3639 },
3640 IssueAction::SuppressLine(_) => "suppress-line",
3641 IssueAction::SuppressFile(_) => "suppress-file",
3642 IssueAction::AddToConfig(_) => "add-to-config",
3643 }
3644 }
3645
3646 fn assert_manual_fix_then_suppress(
3647 actions: &[IssueAction],
3648 primary_type: &str,
3649 suppress_comment: &str,
3650 ) {
3651 assert_eq!(actions.len(), 2);
3652 assert_eq!(action_type(&actions[0]), primary_type);
3653 let IssueAction::Fix(primary) = &actions[0] else {
3654 panic!("position-0 should be a manual fix action");
3655 };
3656 assert!(!primary.auto_fixable);
3657 assert!(primary.note.is_some());
3658 assert_eq!(action_type(&actions[1]), "suppress-line");
3659 let IssueAction::SuppressLine(suppress) = &actions[1] else {
3660 panic!("position-1 should be a suppress-line action");
3661 };
3662 assert_eq!(suppress.comment, suppress_comment);
3663 }
3664
3665 #[test]
3666 fn pnpm_catalog_entry_action_is_auto_fixable() {
3667 let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
3668 entry_name: "unused".to_string(),
3669 catalog_name: "default".to_string(),
3670 path: PathBuf::from("pnpm-workspace.yaml"),
3671 line: 3,
3672 hardcoded_consumers: vec![],
3673 });
3674
3675 let IssueAction::Fix(fix) = &finding.actions[0] else {
3676 panic!("position-0 should be a fix action");
3677 };
3678 assert!(fix.auto_fixable);
3679 assert_eq!(finding.actions.len(), 2);
3680 assert_eq!(action_type(&finding.actions[1]), "suppress-line");
3681 }
3682
3683 #[test]
3684 fn bun_package_json_catalog_entry_action_is_manual_only() {
3685 let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
3686 entry_name: "unused".to_string(),
3687 catalog_name: "default".to_string(),
3688 path: PathBuf::from("package.json"),
3689 line: 4,
3690 hardcoded_consumers: vec![],
3691 });
3692
3693 let IssueAction::Fix(fix) = &finding.actions[0] else {
3694 panic!("position-0 should be a fix action");
3695 };
3696 assert!(!fix.auto_fixable);
3697 assert!(fix.description.contains("manually"));
3698 assert_eq!(finding.actions.len(), 1);
3699 }
3700
3701 #[test]
3702 fn bun_package_json_empty_catalog_group_action_is_manual_only() {
3703 let finding = EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
3704 catalog_name: "empty".to_string(),
3705 path: PathBuf::from("package.json"),
3706 line: 4,
3707 });
3708
3709 let IssueAction::Fix(fix) = &finding.actions[0] else {
3710 panic!("position-0 should be a fix action");
3711 };
3712 assert!(!fix.auto_fixable);
3713 assert!(fix.description.contains("manually"));
3714 assert_eq!(finding.actions.len(), 1);
3715 }
3716
3717 #[test]
3718 fn unprovided_inject_primary_action_is_provide_inject() {
3719 let finding = UnprovidedInjectFinding::with_actions(UnprovidedInject {
3720 path: PathBuf::from("src/context.ts"),
3721 key_name: "userKey".to_string(),
3722 framework: "svelte".to_string(),
3723 line: 7,
3724 col: 12,
3725 });
3726
3727 assert_manual_fix_then_suppress(
3728 &finding.actions,
3729 "provide-inject",
3730 "// fallow-ignore-next-line unprovided-inject",
3731 );
3732 }
3733
3734 #[test]
3735 fn unused_server_action_primary_action_is_wire_server_action() {
3736 let finding = UnusedServerActionFinding::with_actions(UnusedServerAction {
3737 path: PathBuf::from("app/actions.ts"),
3738 action_name: "saveDraft".to_string(),
3739 line: 3,
3740 col: 13,
3741 });
3742
3743 assert_manual_fix_then_suppress(
3744 &finding.actions,
3745 "wire-server-action",
3746 "// fallow-ignore-next-line unused-server-action",
3747 );
3748 }
3749
3750 #[test]
3751 fn unused_load_data_key_primary_action_is_use_load_data() {
3752 let finding = UnusedLoadDataKeyFinding::with_actions(UnusedLoadDataKey {
3753 path: PathBuf::from("src/routes/+page.server.ts"),
3754 key_name: "profile".to_string(),
3755 line: 12,
3756 col: 6,
3757 route_dir: Some("src/routes".to_string()),
3758 });
3759
3760 assert_manual_fix_then_suppress(
3761 &finding.actions,
3762 "use-load-data",
3763 "// fallow-ignore-next-line unused-load-data-key",
3764 );
3765 }
3766
3767 #[test]
3768 fn unrendered_component_primary_action_is_render_component() {
3769 let finding = UnrenderedComponentFinding::with_actions(UnrenderedComponent {
3770 path: PathBuf::from("src/components/EmptyState.vue"),
3771 component_name: "EmptyState".to_string(),
3772 framework: "vue".to_string(),
3773 reachable_via: None,
3774 line: 1,
3775 col: 0,
3776 });
3777
3778 assert_manual_fix_then_suppress(
3779 &finding.actions,
3780 "render-component",
3781 "// fallow-ignore-next-line unrendered-component",
3782 );
3783 }
3784
3785 #[test]
3786 fn unused_component_prop_primary_action_is_use_component_prop() {
3787 let finding = UnusedComponentPropFinding::with_actions(UnusedComponentProp {
3788 path: PathBuf::from("src/components/Card.vue"),
3789 component_name: "Card".to_string(),
3790 prop_name: "variant".to_string(),
3791 line: 5,
3792 col: 10,
3793 });
3794
3795 assert_manual_fix_then_suppress(
3796 &finding.actions,
3797 "use-component-prop",
3798 "// fallow-ignore-next-line unused-component-prop",
3799 );
3800 }
3801
3802 #[test]
3803 fn unused_component_emit_primary_action_is_emit_component_event() {
3804 let finding = UnusedComponentEmitFinding::with_actions(UnusedComponentEmit {
3805 path: PathBuf::from("src/components/Picker.vue"),
3806 component_name: "Picker".to_string(),
3807 emit_name: "focus".to_string(),
3808 line: 6,
3809 col: 14,
3810 });
3811
3812 assert_manual_fix_then_suppress(
3813 &finding.actions,
3814 "emit-component-event",
3815 "// fallow-ignore-next-line unused-component-emit",
3816 );
3817 }
3818
3819 #[test]
3820 fn unused_svelte_event_primary_action_is_wire_svelte_event() {
3821 let finding = UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
3822 path: PathBuf::from("src/Dialog.svelte"),
3823 component_name: "Dialog".to_string(),
3824 event_name: "closed".to_string(),
3825 line: 19,
3826 col: 8,
3827 });
3828
3829 assert_manual_fix_then_suppress(
3830 &finding.actions,
3831 "wire-svelte-event",
3832 "// fallow-ignore-next-line unused-svelte-event",
3833 );
3834 }
3835
3836 #[test]
3837 fn unresolved_import_actions_include_ignore_unresolved_imports_config_suppress() {
3838 let inner = UnresolvedImport {
3839 specifier: "@example/icons".to_string(),
3840 path: PathBuf::from("src/index.ts"),
3841 line: 4,
3842 col: 12,
3843 specifier_col: 18,
3844 };
3845 let finding = UnresolvedImportFinding::with_actions(inner);
3846
3847 assert_eq!(action_type(&finding.actions[0]), "resolve-import");
3848 assert_eq!(action_type(&finding.actions[1]), "add-to-config");
3849 let IssueAction::AddToConfig(action) = &finding.actions[1] else {
3850 panic!("position-1 should be AddToConfig");
3851 };
3852 assert!(!action.auto_fixable);
3853 assert_eq!(action.config_key, "ignoreUnresolvedImports");
3854 let AddToConfigValue::Scalar(value) = &action.value else {
3855 panic!("ignoreUnresolvedImports action should carry a scalar value");
3856 };
3857 assert_eq!(value, "@example/icons");
3858 assert_eq!(
3859 action.value_schema.as_deref(),
3860 Some(
3861 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
3862 )
3863 );
3864 }
3865
3866 #[test]
3877 fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
3878 let inner = UnresolvedCatalogReference {
3879 entry_name: "react".to_string(),
3880 catalog_name: "default".to_string(),
3881 path: PathBuf::from("apps/web/package.json"),
3882 line: 7,
3883 available_in_catalogs: Vec::new(),
3884 };
3885 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
3886 assert_eq!(
3887 action_type(&finding.actions[0]),
3888 "add-catalog-entry",
3889 "position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
3890 );
3891 let IssueAction::Fix(fix) = &finding.actions[0] else {
3892 panic!("position-0 should be an IssueAction::Fix");
3893 };
3894 assert!(
3895 fix.available_in_catalogs.is_none(),
3896 "add-catalog-entry must NOT carry available_in_catalogs"
3897 );
3898 assert!(
3899 fix.suggested_target.is_none(),
3900 "add-catalog-entry must NOT carry suggested_target"
3901 );
3902 }
3903
3904 #[test]
3911 fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
3912 let inner = UnresolvedCatalogReference {
3913 entry_name: "react".to_string(),
3914 catalog_name: "default".to_string(),
3915 path: PathBuf::from("apps/web/package.json"),
3916 line: 7,
3917 available_in_catalogs: vec!["react18".to_string()],
3918 };
3919 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
3920 assert_eq!(
3921 action_type(&finding.actions[0]),
3922 "update-catalog-reference",
3923 "position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
3924 );
3925 let IssueAction::Fix(fix) = &finding.actions[0] else {
3926 panic!("position-0 should be an IssueAction::Fix");
3927 };
3928 assert_eq!(
3929 fix.available_in_catalogs.as_deref(),
3930 Some(&["react18".to_string()][..]),
3931 "update-catalog-reference must carry the alternative list"
3932 );
3933 assert_eq!(
3934 fix.suggested_target.as_deref(),
3935 Some("react18"),
3936 "single-alternative case must surface `suggested_target` for deterministic agents"
3937 );
3938
3939 let inner_two = UnresolvedCatalogReference {
3941 entry_name: "react".to_string(),
3942 catalog_name: "default".to_string(),
3943 path: PathBuf::from("apps/web/package.json"),
3944 line: 7,
3945 available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
3946 };
3947 let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
3948 assert_eq!(
3949 action_type(&finding_two.actions[0]),
3950 "update-catalog-reference"
3951 );
3952 let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
3953 panic!("position-0 should be an IssueAction::Fix");
3954 };
3955 assert!(
3956 fix_two.suggested_target.is_none(),
3957 "multi-alternative case must NOT carry `suggested_target` (agent must pick)"
3958 );
3959 }
3960
3961 #[test]
3976 fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
3977 let inner = DuplicateExport {
3978 export_name: "Root".to_string(),
3979 locations: vec![
3980 DuplicateLocation {
3981 path: PathBuf::from("components/ui/accordion/index.ts"),
3982 line: 1,
3983 col: 0,
3984 },
3985 DuplicateLocation {
3986 path: PathBuf::from("components/ui/dialog/index.ts"),
3987 line: 1,
3988 col: 0,
3989 },
3990 ],
3991 };
3992 let finding = DuplicateExportFinding::with_actions(inner);
3993 assert_eq!(
3994 action_type(&finding.actions[0]),
3995 "add-to-config",
3996 "position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
3997 );
3998 assert_eq!(
3999 action_type(&finding.actions[1]),
4000 "remove-duplicate",
4001 "position-1 must be the destructive `remove-duplicate` fallback"
4002 );
4003
4004 let mut promoted = finding;
4007 promoted.set_config_fixable(true);
4008 assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
4009 let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
4010 panic!("position-0 should still be AddToConfig after set_config_fixable");
4011 };
4012 assert!(
4013 action.auto_fixable,
4014 "set_config_fixable(true) must flip auto_fixable"
4015 );
4016 }
4017
4018 #[test]
4023 fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
4024 let inner = DuplicateExport {
4025 export_name: "Root".to_string(),
4026 locations: Vec::new(),
4027 };
4028 let finding = DuplicateExportFinding::with_actions(inner);
4029 assert_eq!(
4030 action_type(&finding.actions[0]),
4031 "remove-duplicate",
4032 "with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
4033 );
4034
4035 let mut promoted = finding;
4037 promoted.set_config_fixable(true);
4038 assert_eq!(
4039 action_type(&promoted.actions[0]),
4040 "remove-duplicate",
4041 "set_config_fixable is a no-op when position-0 is not add-to-config"
4042 );
4043 }
4044
4045 #[test]
4051 fn misconfigured_override_drops_suppress_when_no_package_name() {
4052 let inner = MisconfiguredDependencyOverride {
4053 raw_key: String::new(),
4054 target_package: None,
4055 raw_value: String::new(),
4056 reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
4057 source: DependencyOverrideSource::PnpmWorkspaceYaml,
4058 path: PathBuf::from("pnpm-workspace.yaml"),
4059 line: 12,
4060 };
4061 let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
4062 assert_eq!(finding.actions.len(), 1);
4064 assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
4065 }
4066}