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, Serialize, Deserialize)]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104pub struct UnusedFileFinding {
105 #[serde(flatten)]
107 pub file: UnusedFile,
108 pub actions: Vec<IssueAction>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub introduced: Option<AuditIntroduced>,
115}
116
117impl UnusedFileFinding {
118 #[must_use]
122 pub fn with_actions(file: UnusedFile) -> Self {
123 let actions = vec![
124 IssueAction::Fix(FixAction {
125 kind: FixActionType::DeleteFile,
126 auto_fixable: false,
127 description: "Delete this file".to_string(),
128 note: Some(
129 "File deletion may remove runtime functionality not visible to static analysis"
130 .to_string(),
131 ),
132 available_in_catalogs: None,
133 suggested_target: None,
134 }),
135 IssueAction::SuppressFile(SuppressFileAction {
136 kind: SuppressFileKind::SuppressFile,
137 auto_fixable: false,
138 description: "Suppress with a file-level comment at the top of the file"
139 .to_string(),
140 comment: "// fallow-ignore-file unused-file".to_string(),
141 }),
142 ];
143 Self {
144 file,
145 actions,
146 introduced: None,
147 }
148 }
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
155#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
156pub struct PrivateTypeLeakFinding {
157 #[serde(flatten)]
159 pub leak: PrivateTypeLeak,
160 pub actions: Vec<IssueAction>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub introduced: Option<AuditIntroduced>,
167}
168
169impl PrivateTypeLeakFinding {
170 #[must_use]
172 pub fn with_actions(leak: PrivateTypeLeak) -> Self {
173 let actions = vec![
174 IssueAction::Fix(FixAction {
175 kind: FixActionType::ExportType,
176 auto_fixable: false,
177 description: "Export the referenced private type by name".to_string(),
178 note: Some(
179 "Keep the type exported while it is part of a public signature".to_string(),
180 ),
181 available_in_catalogs: None,
182 suggested_target: None,
183 }),
184 IssueAction::SuppressLine(SuppressLineAction {
185 kind: SuppressLineKind::SuppressLine,
186 auto_fixable: false,
187 description: "Suppress with an inline comment above the line".to_string(),
188 comment: "// fallow-ignore-next-line private-type-leak".to_string(),
189 scope: None,
190 }),
191 ];
192 Self {
193 leak,
194 actions,
195 introduced: None,
196 }
197 }
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
205#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
206pub struct UnresolvedImportFinding {
207 #[serde(flatten)]
209 pub import: UnresolvedImport,
210 pub actions: Vec<IssueAction>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub introduced: Option<AuditIntroduced>,
217}
218
219impl UnresolvedImportFinding {
220 #[must_use]
222 pub fn with_actions(import: UnresolvedImport) -> Self {
223 let actions = vec![
224 IssueAction::Fix(FixAction {
225 kind: FixActionType::ResolveImport,
226 auto_fixable: false,
227 description: "Fix the import specifier or install the missing module".to_string(),
228 note: Some(
229 "Verify the module path and check tsconfig paths configuration".to_string(),
230 ),
231 available_in_catalogs: None,
232 suggested_target: None,
233 }),
234 IssueAction::AddToConfig(AddToConfigAction {
235 kind: AddToConfigKind::AddToConfig,
236 auto_fixable: false,
237 description: format!(
238 "Add \"{}\" to ignoreUnresolvedImports in fallow config",
239 import.specifier
240 ),
241 config_key: "ignoreUnresolvedImports".to_string(),
242 value: AddToConfigValue::Scalar(import.specifier.clone()),
243 value_schema: Some(
244 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
245 .to_string(),
246 ),
247 }),
248 IssueAction::SuppressLine(SuppressLineAction {
249 kind: SuppressLineKind::SuppressLine,
250 auto_fixable: false,
251 description: "Suppress with an inline comment above the line".to_string(),
252 comment: "// fallow-ignore-next-line unresolved-import".to_string(),
253 scope: None,
254 }),
255 ];
256 Self {
257 import,
258 actions,
259 introduced: None,
260 }
261 }
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
269#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
270pub struct CircularDependencyFinding {
271 #[serde(flatten)]
273 pub cycle: CircularDependency,
274 pub actions: Vec<IssueAction>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub introduced: Option<AuditIntroduced>,
281}
282
283impl CircularDependencyFinding {
284 #[must_use]
286 pub fn with_actions(cycle: CircularDependency) -> Self {
287 let actions = vec![
288 IssueAction::Fix(FixAction {
289 kind: FixActionType::RefactorCycle,
290 auto_fixable: false,
291 description: "Extract shared logic into a separate module to break the cycle"
292 .to_string(),
293 note: Some(
294 "Circular imports can cause initialization issues and make code harder to reason about"
295 .to_string(),
296 ),
297 available_in_catalogs: None,
298 suggested_target: None,
299 }),
300 IssueAction::SuppressLine(SuppressLineAction {
301 kind: SuppressLineKind::SuppressLine,
302 auto_fixable: false,
303 description: "Suppress with an inline comment above the line".to_string(),
304 comment: "// fallow-ignore-next-line circular-dependency".to_string(),
305 scope: None,
306 }),
307 ];
308 Self {
309 cycle,
310 actions,
311 introduced: None,
312 }
313 }
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
324#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
325pub struct ReExportCycleFinding {
326 #[serde(flatten)]
328 pub cycle: ReExportCycle,
329 pub actions: Vec<IssueAction>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub introduced: Option<AuditIntroduced>,
336}
337
338impl ReExportCycleFinding {
339 #[must_use]
346 pub fn with_actions(cycle: ReExportCycle) -> Self {
347 let suppress_description = match cycle.kind {
353 ReExportCycleKind::SelfLoop => {
354 "Suppress with a file-level comment at the top of this file. \
355 The cycle is a self-loop, so the suppression covers the entire finding."
356 .to_string()
357 }
358 ReExportCycleKind::MultiNode => {
359 "Suppress with a file-level comment at the top of this file. \
360 One suppression on any member breaks the cycle for every member \
361 (see the sibling `files` array)."
362 .to_string()
363 }
364 };
365 let actions = vec![
366 IssueAction::Fix(FixAction {
367 kind: FixActionType::RefactorReExportCycle,
368 auto_fixable: false,
369 description: "Remove one `export * from` (or `export { ... } from`) \
370 statement on any one member to break the cycle"
371 .to_string(),
372 note: Some(
373 "Re-export cycles are structurally a no-op: chain propagation through \
374 the loop never reaches a terminating module, so imports from any member \
375 may silently come up empty."
376 .to_string(),
377 ),
378 available_in_catalogs: None,
379 suggested_target: None,
380 }),
381 IssueAction::SuppressFile(SuppressFileAction {
382 kind: SuppressFileKind::SuppressFile,
383 auto_fixable: false,
384 description: suppress_description,
385 comment: "// fallow-ignore-file re-export-cycle".to_string(),
386 }),
387 ];
388 Self {
389 cycle,
390 actions,
391 introduced: None,
392 }
393 }
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize)]
401#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
402pub struct BoundaryViolationFinding {
403 #[serde(flatten)]
405 pub violation: BoundaryViolation,
406 pub actions: Vec<IssueAction>,
409 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub introduced: Option<AuditIntroduced>,
413}
414
415impl BoundaryViolationFinding {
416 #[must_use]
418 pub fn with_actions(violation: BoundaryViolation) -> Self {
419 let actions = vec![
420 IssueAction::Fix(FixAction {
421 kind: FixActionType::RefactorBoundary,
422 auto_fixable: false,
423 description: "Move the import through an allowed zone or restructure the dependency"
424 .to_string(),
425 note: Some(
426 "This import crosses an architecture boundary that is not permitted by the configured rules"
427 .to_string(),
428 ),
429 available_in_catalogs: None,
430 suggested_target: None,
431 }),
432 IssueAction::SuppressLine(SuppressLineAction {
433 kind: SuppressLineKind::SuppressLine,
434 auto_fixable: false,
435 description: "Suppress with an inline comment above the line".to_string(),
436 comment: "// fallow-ignore-next-line boundary-violation".to_string(),
437 scope: None,
438 }),
439 ];
440 Self {
441 violation,
442 actions,
443 introduced: None,
444 }
445 }
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize)]
452#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
453pub struct BoundaryCoverageViolationFinding {
454 #[serde(flatten)]
456 pub violation: BoundaryCoverageViolation,
457 pub actions: Vec<IssueAction>,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub introduced: Option<AuditIntroduced>,
463}
464
465impl BoundaryCoverageViolationFinding {
466 #[must_use]
468 pub fn with_actions(violation: BoundaryCoverageViolation) -> Self {
469 let path = violation.path.to_string_lossy().replace('\\', "/");
470 let actions = vec![
471 IssueAction::Fix(FixAction {
472 kind: FixActionType::RefactorBoundary,
473 auto_fixable: false,
474 description: "Add this file to a boundary zone pattern or move it under an existing zone"
475 .to_string(),
476 note: Some(
477 "Boundary coverage is enabled, so every analyzed source file must match a zone unless allow-listed"
478 .to_string(),
479 ),
480 available_in_catalogs: None,
481 suggested_target: None,
482 }),
483 IssueAction::AddToConfig(AddToConfigAction {
484 kind: AddToConfigKind::AddToConfig,
485 auto_fixable: false,
486 description: format!(
487 "Add \"{path}\" to boundaries.coverage.allowUnmatched in fallow config"
488 ),
489 config_key: "boundaries.coverage.allowUnmatched".to_string(),
490 value: AddToConfigValue::Scalar(path),
491 value_schema: Some(
492 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/boundaries/properties/coverage/properties/allowUnmatched/items"
493 .to_string(),
494 ),
495 }),
496 IssueAction::SuppressFile(SuppressFileAction {
497 kind: SuppressFileKind::SuppressFile,
498 auto_fixable: false,
499 description: "Suppress with a file-level comment at the top of the file"
500 .to_string(),
501 comment: "// fallow-ignore-file boundary-violation".to_string(),
502 }),
503 ];
504 Self {
505 violation,
506 actions,
507 introduced: None,
508 }
509 }
510}
511
512#[derive(Debug, Clone, Serialize, Deserialize)]
516#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
517pub struct BoundaryCallViolationFinding {
518 #[serde(flatten)]
520 pub violation: BoundaryCallViolation,
521 pub actions: Vec<IssueAction>,
523 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub introduced: Option<AuditIntroduced>,
527}
528
529impl BoundaryCallViolationFinding {
530 #[must_use]
532 pub fn with_actions(violation: BoundaryCallViolation) -> Self {
533 let actions = vec![
534 IssueAction::Fix(FixAction {
535 kind: FixActionType::RefactorBoundary,
536 auto_fixable: false,
537 description: format!(
538 "Move the `{}` call out of zone '{}' or behind an allowed abstraction",
539 violation.callee, violation.zone,
540 ),
541 note: Some(format!(
542 "`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",
543 violation.pattern, violation.zone,
544 )),
545 available_in_catalogs: None,
546 suggested_target: None,
547 }),
548 IssueAction::SuppressLine(SuppressLineAction {
549 kind: SuppressLineKind::SuppressLine,
550 auto_fixable: false,
551 description: "Suppress with an inline comment above the line".to_string(),
552 comment: "// fallow-ignore-next-line boundary-violation".to_string(),
553 scope: None,
554 }),
555 IssueAction::SuppressFile(SuppressFileAction {
556 kind: SuppressFileKind::SuppressFile,
557 auto_fixable: false,
558 description: "Suppress with a file-level comment at the top of the file"
559 .to_string(),
560 comment: "// fallow-ignore-file boundary-violation".to_string(),
561 }),
562 ];
563 Self {
564 violation,
565 actions,
566 introduced: None,
567 }
568 }
569}
570
571#[derive(Debug, Clone, Serialize, Deserialize)]
575#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
576pub struct PolicyViolationFinding {
577 #[serde(flatten)]
579 pub violation: PolicyViolation,
580 pub actions: Vec<IssueAction>,
582 #[serde(default, skip_serializing_if = "Option::is_none")]
585 pub introduced: Option<AuditIntroduced>,
586}
587
588impl PolicyViolationFinding {
589 #[must_use]
591 pub fn with_actions(violation: PolicyViolation) -> Self {
592 let what = match violation.kind {
593 crate::results::PolicyRuleKind::BannedCall => "call",
594 crate::results::PolicyRuleKind::BannedImport => "import",
595 crate::results::PolicyRuleKind::BannedEffect => "effect",
596 crate::results::PolicyRuleKind::BannedExport => "export",
597 };
598 let description = match &violation.message {
599 Some(message) => format!("Replace the `{}` {what}: {message}", violation.matched),
600 None => format!("Replace the `{}` {what}", violation.matched),
601 };
602 let suppress_token = format!("policy-violation:{}/{}", violation.pack, violation.rule_id);
603 let actions = vec![
604 IssueAction::Fix(FixAction {
605 kind: FixActionType::ResolvePolicyViolation,
606 auto_fixable: false,
607 description,
608 note: Some(format!(
609 "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",
610 violation.pack, violation.rule_id,
611 )),
612 available_in_catalogs: None,
613 suggested_target: None,
614 }),
615 IssueAction::SuppressLine(SuppressLineAction {
616 kind: SuppressLineKind::SuppressLine,
617 auto_fixable: false,
618 description: "Suppress this rule-pack rule with an inline comment above the line"
619 .to_string(),
620 comment: format!("// fallow-ignore-next-line {suppress_token}"),
621 scope: None,
622 }),
623 IssueAction::SuppressFile(SuppressFileAction {
624 kind: SuppressFileKind::SuppressFile,
625 auto_fixable: false,
626 description:
627 "Suppress this rule-pack rule with a file-level comment at the top of the file"
628 .to_string(),
629 comment: format!("// fallow-ignore-file {suppress_token}"),
630 }),
631 ];
632 Self {
633 violation,
634 actions,
635 introduced: None,
636 }
637 }
638}
639
640#[derive(Debug, Clone, Serialize, Deserialize)]
645#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
646pub struct UnusedExportFinding {
647 #[serde(flatten)]
649 pub export: UnusedExport,
650 pub actions: Vec<IssueAction>,
653 #[serde(default, skip_serializing_if = "Option::is_none")]
655 pub semantic: Option<SemanticCandidateDecision>,
656 #[serde(default, skip_serializing_if = "Option::is_none")]
659 pub introduced: Option<AuditIntroduced>,
660}
661
662impl UnusedExportFinding {
663 #[must_use]
667 pub fn with_actions(export: UnusedExport) -> Self {
668 let note = if export.is_re_export {
669 Some(
670 "This finding originates from a re-export; verify it is not part of your public API before removing"
671 .to_string(),
672 )
673 } else {
674 None
675 };
676 let actions = vec![
677 IssueAction::Fix(FixAction {
678 kind: FixActionType::RemoveExport,
679 auto_fixable: true,
680 description: "Remove the unused export from the public API".to_string(),
681 note,
682 available_in_catalogs: None,
683 suggested_target: None,
684 }),
685 IssueAction::SuppressLine(SuppressLineAction {
686 kind: SuppressLineKind::SuppressLine,
687 auto_fixable: false,
688 description: "Suppress with an inline comment above the line".to_string(),
689 comment: "// fallow-ignore-next-line unused-export".to_string(),
690 scope: None,
691 }),
692 ];
693 Self {
694 export,
695 actions,
696 semantic: None,
697 introduced: None,
698 }
699 }
700
701 pub fn set_semantic_decision(&mut self, decision: SemanticCandidateDecision) {
704 set_export_semantic_action(&mut self.actions, &decision);
705 self.semantic = Some(decision);
706 }
707}
708
709#[derive(Debug, Clone, Serialize, Deserialize)]
714#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
715pub struct UnusedTypeFinding {
716 #[serde(flatten)]
718 pub export: UnusedExport,
719 pub actions: Vec<IssueAction>,
722 #[serde(default, skip_serializing_if = "Option::is_none")]
724 pub semantic: Option<SemanticCandidateDecision>,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub introduced: Option<AuditIntroduced>,
729}
730
731impl UnusedTypeFinding {
732 #[must_use]
735 pub fn with_actions(export: UnusedExport) -> Self {
736 let note = if export.is_re_export {
737 Some(
738 "This finding originates from a re-export; verify it is not part of your public API before removing"
739 .to_string(),
740 )
741 } else {
742 None
743 };
744 let actions = vec![
745 IssueAction::Fix(FixAction {
746 kind: FixActionType::RemoveExport,
747 auto_fixable: true,
748 description:
749 "Remove the `export` (or `export type`) keyword from the type declaration"
750 .to_string(),
751 note,
752 available_in_catalogs: None,
753 suggested_target: None,
754 }),
755 IssueAction::SuppressLine(SuppressLineAction {
756 kind: SuppressLineKind::SuppressLine,
757 auto_fixable: false,
758 description: "Suppress with an inline comment above the line".to_string(),
759 comment: "// fallow-ignore-next-line unused-type".to_string(),
760 scope: None,
761 }),
762 ];
763 Self {
764 export,
765 actions,
766 semantic: None,
767 introduced: None,
768 }
769 }
770
771 pub fn set_semantic_decision(&mut self, decision: SemanticCandidateDecision) {
774 set_export_semantic_action(&mut self.actions, &decision);
775 self.semantic = Some(decision);
776 }
777}
778
779fn set_export_semantic_action(actions: &mut [IssueAction], decision: &SemanticCandidateDecision) {
780 let complete_negative = decision.decision
781 == SemanticCandidateDecisionKind::ConfirmedNoStaticReferences
782 && decision.status == SemanticCompleteness::Complete;
783 let Some(IssueAction::Fix(action)) = actions.first_mut() else {
784 return;
785 };
786 action.auto_fixable = complete_negative;
787 if !complete_negative {
788 action.note = Some(
789 "Type-aware analysis retained this candidate because complete negative evidence was not available"
790 .to_string(),
791 );
792 }
793}
794
795#[derive(Debug, Clone, Serialize, Deserialize)]
801#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
802pub struct InvalidClientExportFinding {
803 #[serde(flatten)]
805 pub export: InvalidClientExport,
806 pub actions: Vec<IssueAction>,
809 #[serde(default, skip_serializing_if = "Option::is_none")]
812 pub introduced: Option<AuditIntroduced>,
813}
814
815impl InvalidClientExportFinding {
816 #[must_use]
821 pub fn with_actions(export: InvalidClientExport) -> Self {
822 let actions = vec![
823 IssueAction::Fix(FixAction {
824 kind: FixActionType::MoveToServerModule,
825 auto_fixable: false,
826 description: "Move the server-only export to a non-client module and import it from there"
827 .to_string(),
828 note: Some(
829 "A \"use client\" file cannot export a Next.js server-only or route-config name; Next.js rejects it at build time"
830 .to_string(),
831 ),
832 available_in_catalogs: None,
833 suggested_target: None,
834 }),
835 IssueAction::SuppressLine(SuppressLineAction {
836 kind: SuppressLineKind::SuppressLine,
837 auto_fixable: false,
838 description: "Suppress with an inline comment above the line".to_string(),
839 comment: "// fallow-ignore-next-line invalid-client-export".to_string(),
840 scope: None,
841 }),
842 ];
843 Self {
844 export,
845 actions,
846 introduced: None,
847 }
848 }
849}
850
851#[derive(Debug, Clone, Serialize, Deserialize)]
857#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
858pub struct MixedClientServerBarrelFinding {
859 #[serde(flatten)]
861 pub barrel: MixedClientServerBarrel,
862 pub actions: Vec<IssueAction>,
865 #[serde(default, skip_serializing_if = "Option::is_none")]
868 pub introduced: Option<AuditIntroduced>,
869}
870
871impl MixedClientServerBarrelFinding {
872 #[must_use]
877 pub fn with_actions(barrel: MixedClientServerBarrel) -> Self {
878 let actions = vec![
879 IssueAction::Fix(FixAction {
880 kind: FixActionType::SplitMixedBarrel,
881 auto_fixable: false,
882 description: "Split the barrel so client and server-only modules are re-exported from separate files"
883 .to_string(),
884 note: Some(
885 "Importing one name from this barrel drags the other's directive across the client/server boundary"
886 .to_string(),
887 ),
888 available_in_catalogs: None,
889 suggested_target: None,
890 }),
891 IssueAction::SuppressLine(SuppressLineAction {
892 kind: SuppressLineKind::SuppressLine,
893 auto_fixable: false,
894 description: "Suppress with an inline comment above the line".to_string(),
895 comment: "// fallow-ignore-next-line mixed-client-server-barrel".to_string(),
896 scope: None,
897 }),
898 ];
899 Self {
900 barrel,
901 actions,
902 introduced: None,
903 }
904 }
905}
906
907#[derive(Debug, Clone, Serialize, Deserialize)]
913#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
914pub struct MisplacedDirectiveFinding {
915 #[serde(flatten)]
917 pub directive_site: MisplacedDirective,
918 pub actions: Vec<IssueAction>,
921 #[serde(default, skip_serializing_if = "Option::is_none")]
924 pub introduced: Option<AuditIntroduced>,
925}
926
927impl MisplacedDirectiveFinding {
928 #[must_use]
933 pub fn with_actions(directive_site: MisplacedDirective) -> Self {
934 let actions = vec![
935 IssueAction::Fix(FixAction {
936 kind: FixActionType::HoistDirective,
937 auto_fixable: false,
938 description: "Move the directive to the very top of the file, above all imports and statements"
939 .to_string(),
940 note: Some(
941 "An RSC bundler honors the directive only in the leading prologue; here it precedes other statements and is silently ignored"
942 .to_string(),
943 ),
944 available_in_catalogs: None,
945 suggested_target: None,
946 }),
947 IssueAction::SuppressLine(SuppressLineAction {
948 kind: SuppressLineKind::SuppressLine,
949 auto_fixable: false,
950 description: "Suppress with an inline comment above the line".to_string(),
951 comment: "// fallow-ignore-next-line misplaced-directive".to_string(),
952 scope: None,
953 }),
954 ];
955 Self {
956 directive_site,
957 actions,
958 introduced: None,
959 }
960 }
961}
962
963#[derive(Debug, Clone, Serialize, Deserialize)]
968#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
969pub struct UnprovidedInjectFinding {
970 #[serde(flatten)]
972 pub inject: UnprovidedInject,
973 pub actions: Vec<IssueAction>,
976 #[serde(default, skip_serializing_if = "Option::is_none")]
979 pub introduced: Option<AuditIntroduced>,
980}
981
982impl UnprovidedInjectFinding {
983 #[must_use]
986 pub fn with_actions(inject: UnprovidedInject) -> Self {
987 let actions = vec![
988 manual_framework_fix(
989 FixActionType::ProvideInject,
990 "Provide this injected key, or remove the inject / getContext call",
991 "Manual review required: dependency-injection keys can be provided by framework wiring, tests, or package consumers outside this project.",
992 ),
993 suppress_line("// fallow-ignore-next-line unprovided-inject"),
994 ];
995 Self {
996 inject,
997 actions,
998 introduced: None,
999 }
1000 }
1001}
1002
1003#[derive(Debug, Clone, Serialize, Deserialize)]
1008#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1009pub struct UnusedServerActionFinding {
1010 #[serde(flatten)]
1012 pub action: UnusedServerAction,
1013 pub actions: Vec<IssueAction>,
1016 #[serde(default, skip_serializing_if = "Option::is_none")]
1019 pub introduced: Option<AuditIntroduced>,
1020}
1021
1022impl UnusedServerActionFinding {
1023 #[must_use]
1026 pub fn with_actions(action: UnusedServerAction) -> Self {
1027 let actions = vec![
1028 manual_framework_fix(
1029 FixActionType::WireServerAction,
1030 "Wire the server action to a caller or form action, or remove it",
1031 "Manual review required: server actions may still be POST-able by action id or invoked reflectively outside the static project graph.",
1032 ),
1033 suppress_line("// fallow-ignore-next-line unused-server-action"),
1034 ];
1035 Self {
1036 action,
1037 actions,
1038 introduced: None,
1039 }
1040 }
1041}
1042
1043#[derive(Debug, Clone, Serialize, Deserialize)]
1048#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1049pub struct UnusedLoadDataKeyFinding {
1050 #[serde(flatten)]
1052 pub key: UnusedLoadDataKey,
1053 pub actions: Vec<IssueAction>,
1056 #[serde(default, skip_serializing_if = "Option::is_none")]
1059 pub introduced: Option<AuditIntroduced>,
1060}
1061
1062impl UnusedLoadDataKeyFinding {
1063 #[must_use]
1066 pub fn with_actions(key: UnusedLoadDataKey) -> Self {
1067 let actions = vec![
1068 manual_framework_fix(
1069 FixActionType::UseLoadData,
1070 "Read this load data key from the route UI, or remove it from the load return",
1071 "Manual review required: load functions can perform real server or database work, so verify side effects before deleting the producer.",
1072 ),
1073 suppress_line("// fallow-ignore-next-line unused-load-data-key"),
1074 ];
1075 Self {
1076 key,
1077 actions,
1078 introduced: None,
1079 }
1080 }
1081}
1082
1083#[derive(Debug, Clone, Serialize, Deserialize)]
1088#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1089pub struct UnrenderedComponentFinding {
1090 #[serde(flatten)]
1092 pub component: UnrenderedComponent,
1093 pub actions: Vec<IssueAction>,
1096 #[serde(default, skip_serializing_if = "Option::is_none")]
1099 pub introduced: Option<AuditIntroduced>,
1100}
1101
1102impl UnrenderedComponentFinding {
1103 #[must_use]
1106 pub fn with_actions(component: UnrenderedComponent) -> Self {
1107 let actions = vec![
1108 manual_framework_fix(
1109 FixActionType::RenderComponent,
1110 "Render the reachable component from project code, or remove it",
1111 "Manual review required: exported library components and dynamic render registries can be intentionally reachable without static template usage.",
1112 ),
1113 suppress_line("// fallow-ignore-next-line unrendered-component"),
1114 ];
1115 Self {
1116 component,
1117 actions,
1118 introduced: None,
1119 }
1120 }
1121}
1122
1123#[derive(Debug, Clone, Serialize, Deserialize)]
1128#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1129pub struct UnusedComponentPropFinding {
1130 #[serde(flatten)]
1132 pub prop: UnusedComponentProp,
1133 pub actions: Vec<IssueAction>,
1136 #[serde(default, skip_serializing_if = "Option::is_none")]
1139 pub introduced: Option<AuditIntroduced>,
1140}
1141
1142impl UnusedComponentPropFinding {
1143 #[must_use]
1146 pub fn with_actions(prop: UnusedComponentProp) -> Self {
1147 let actions = vec![
1148 manual_framework_fix(
1149 FixActionType::UseComponentProp,
1150 "Use the declared prop in the component, or remove it from the component API",
1151 "Manual review required: public component APIs can intentionally keep stable props for external consumers.",
1152 ),
1153 suppress_line("// fallow-ignore-next-line unused-component-prop"),
1154 ];
1155 Self {
1156 prop,
1157 actions,
1158 introduced: None,
1159 }
1160 }
1161}
1162
1163#[derive(Debug, Clone, Serialize, Deserialize)]
1168#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1169pub struct UnusedComponentEmitFinding {
1170 #[serde(flatten)]
1172 pub emit: UnusedComponentEmit,
1173 pub actions: Vec<IssueAction>,
1176 #[serde(default, skip_serializing_if = "Option::is_none")]
1179 pub introduced: Option<AuditIntroduced>,
1180}
1181
1182impl UnusedComponentEmitFinding {
1183 #[must_use]
1186 pub fn with_actions(emit: UnusedComponentEmit) -> Self {
1187 let actions = vec![
1188 manual_framework_fix(
1189 FixActionType::EmitComponentEvent,
1190 "Emit the declared event from the component, or remove it from the component API",
1191 "Manual review required: public component APIs can intentionally keep stable events for external listeners.",
1192 ),
1193 suppress_line("// fallow-ignore-next-line unused-component-emit"),
1194 ];
1195 Self {
1196 emit,
1197 actions,
1198 introduced: None,
1199 }
1200 }
1201}
1202
1203#[derive(Debug, Clone, Serialize, Deserialize)]
1209#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1210pub struct UnusedSvelteEventFinding {
1211 #[serde(flatten)]
1213 pub event: UnusedSvelteEvent,
1214 pub actions: Vec<IssueAction>,
1217 #[serde(default, skip_serializing_if = "Option::is_none")]
1220 pub introduced: Option<AuditIntroduced>,
1221}
1222
1223impl UnusedSvelteEventFinding {
1224 #[must_use]
1227 pub fn with_actions(event: UnusedSvelteEvent) -> Self {
1228 let actions = vec![
1229 manual_framework_fix(
1230 FixActionType::WireSvelteEvent,
1231 "Add or forward a listener for this custom event, or remove the dispatch",
1232 "Manual review required: public Svelte component APIs can intentionally dispatch events for package consumers outside this project.",
1233 ),
1234 suppress_line("// fallow-ignore-next-line unused-svelte-event"),
1235 ];
1236 Self {
1237 event,
1238 actions,
1239 introduced: None,
1240 }
1241 }
1242}
1243
1244#[derive(Debug, Clone, Serialize, Deserialize)]
1250#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1251pub struct PropDrillingChainFinding {
1252 #[serde(flatten)]
1254 pub chain: PropDrillingChain,
1255 pub actions: Vec<IssueAction>,
1258 #[serde(default, skip_serializing_if = "Option::is_none")]
1261 pub introduced: Option<AuditIntroduced>,
1262}
1263
1264impl PropDrillingChainFinding {
1265 #[must_use]
1270 pub fn with_actions(chain: PropDrillingChain) -> Self {
1271 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1272 kind: SuppressLineKind::SuppressLine,
1273 auto_fixable: false,
1274 description: "Suppress with an inline comment above the source prop declaration"
1275 .to_string(),
1276 comment: "// fallow-ignore-next-line prop-drilling".to_string(),
1277 scope: None,
1278 })];
1279 Self {
1280 chain,
1281 actions,
1282 introduced: None,
1283 }
1284 }
1285}
1286
1287#[derive(Debug, Clone, Serialize, Deserialize)]
1293#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1294pub struct ThinWrapperFinding {
1295 #[serde(flatten)]
1297 pub wrapper: ThinWrapper,
1298 pub actions: Vec<IssueAction>,
1301 #[serde(default, skip_serializing_if = "Option::is_none")]
1304 pub introduced: Option<AuditIntroduced>,
1305}
1306
1307impl ThinWrapperFinding {
1308 #[must_use]
1312 pub fn with_actions(wrapper: ThinWrapper) -> Self {
1313 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1314 kind: SuppressLineKind::SuppressLine,
1315 auto_fixable: false,
1316 description: "Suppress with an inline comment above the component definition"
1317 .to_string(),
1318 comment: "// fallow-ignore-next-line thin-wrapper".to_string(),
1319 scope: None,
1320 })];
1321 Self {
1322 wrapper,
1323 actions,
1324 introduced: None,
1325 }
1326 }
1327}
1328
1329#[derive(Debug, Clone, Serialize, Deserialize)]
1337#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1338pub struct DuplicatePropShapeFinding {
1339 #[serde(flatten)]
1341 pub shape: DuplicatePropShape,
1342 pub actions: Vec<IssueAction>,
1345 #[serde(default, skip_serializing_if = "Option::is_none")]
1348 pub introduced: Option<AuditIntroduced>,
1349}
1350
1351impl DuplicatePropShapeFinding {
1352 #[must_use]
1359 pub fn with_actions(shape: DuplicatePropShape) -> Self {
1360 let actions = vec![
1361 IssueAction::SuppressLine(SuppressLineAction {
1362 kind: SuppressLineKind::SuppressLine,
1363 auto_fixable: false,
1364 description: "Three or more components share this exact prop shape. Extract one \
1365 shared `Props` type (or a base component) that every member reuses, \
1366 or keep them separate if a per-variant divergence is planned. \
1367 Suppress one member with an inline comment above the component \
1368 definition."
1369 .to_string(),
1370 comment: "// fallow-ignore-next-line duplicate-prop-shape".to_string(),
1371 scope: None,
1372 }),
1373 IssueAction::SuppressFile(SuppressFileAction {
1374 kind: SuppressFileKind::SuppressFile,
1375 auto_fixable: false,
1376 description: "Escape hatch: a file-level suppress silences this member but it \
1377 still appears in its siblings' `sharing_components` (the group is \
1378 real regardless of suppression)."
1379 .to_string(),
1380 comment: "// fallow-ignore-file duplicate-prop-shape".to_string(),
1381 }),
1382 ];
1383 Self {
1384 shape,
1385 actions,
1386 introduced: None,
1387 }
1388 }
1389}
1390
1391#[derive(Debug, Clone, Serialize, Deserialize)]
1396#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1397pub struct UnusedComponentInputFinding {
1398 #[serde(flatten)]
1400 pub input: UnusedComponentInput,
1401 pub actions: Vec<IssueAction>,
1404 #[serde(default, skip_serializing_if = "Option::is_none")]
1407 pub introduced: Option<AuditIntroduced>,
1408}
1409
1410impl UnusedComponentInputFinding {
1411 #[must_use]
1415 pub fn with_actions(input: UnusedComponentInput) -> Self {
1416 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1417 kind: SuppressLineKind::SuppressLine,
1418 auto_fixable: false,
1419 description: "Suppress with an inline comment above the line".to_string(),
1420 comment: "// fallow-ignore-next-line unused-component-input".to_string(),
1421 scope: None,
1422 })];
1423 Self {
1424 input,
1425 actions,
1426 introduced: None,
1427 }
1428 }
1429}
1430
1431#[derive(Debug, Clone, Serialize, Deserialize)]
1436#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1437pub struct UnusedComponentOutputFinding {
1438 #[serde(flatten)]
1440 pub output: UnusedComponentOutput,
1441 pub actions: Vec<IssueAction>,
1444 #[serde(default, skip_serializing_if = "Option::is_none")]
1447 pub introduced: Option<AuditIntroduced>,
1448}
1449
1450impl UnusedComponentOutputFinding {
1451 #[must_use]
1455 pub fn with_actions(output: UnusedComponentOutput) -> Self {
1456 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1457 kind: SuppressLineKind::SuppressLine,
1458 auto_fixable: false,
1459 description: "Suppress with an inline comment above the line".to_string(),
1460 comment: "// fallow-ignore-next-line unused-component-output".to_string(),
1461 scope: None,
1462 })];
1463 Self {
1464 output,
1465 actions,
1466 introduced: None,
1467 }
1468 }
1469}
1470
1471#[derive(Debug, Clone, Serialize, Deserialize)]
1477#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1478pub struct RouteCollisionFinding {
1479 #[serde(flatten)]
1481 pub collision: RouteCollision,
1482 pub actions: Vec<IssueAction>,
1485 #[serde(default, skip_serializing_if = "Option::is_none")]
1488 pub introduced: Option<AuditIntroduced>,
1489}
1490
1491impl RouteCollisionFinding {
1492 #[must_use]
1496 pub fn with_actions(collision: RouteCollision) -> Self {
1497 let actions = vec![
1498 IssueAction::Fix(FixAction {
1499 kind: FixActionType::ResolveRouteCollision,
1500 auto_fixable: false,
1501 description: "Two or more files resolve to the same URL. Move or merge one so \
1502 each URL has a single owner. Route groups `(name)` and parallel \
1503 slots `@name` are the only legal same-URL shapes."
1504 .to_string(),
1505 note: Some(
1506 "Next.js fails the build with \"You cannot have two parallel pages that \
1507 resolve to the same path\". See the sibling `conflicting_paths` array for \
1508 the other files that own this URL."
1509 .to_string(),
1510 ),
1511 available_in_catalogs: None,
1512 suggested_target: None,
1513 }),
1514 IssueAction::SuppressFile(SuppressFileAction {
1515 kind: SuppressFileKind::SuppressFile,
1516 auto_fixable: false,
1517 description: "Escape hatch only: a file-level suppress silences the finding but \
1518 does NOT make `next build` pass. Prefer moving or merging a file."
1519 .to_string(),
1520 comment: "// fallow-ignore-file route-collision".to_string(),
1521 }),
1522 ];
1523 Self {
1524 collision,
1525 actions,
1526 introduced: None,
1527 }
1528 }
1529}
1530
1531#[derive(Debug, Clone, Serialize, Deserialize)]
1536#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1537pub struct DynamicSegmentNameConflictFinding {
1538 #[serde(flatten)]
1540 pub conflict: DynamicSegmentNameConflict,
1541 pub actions: Vec<IssueAction>,
1544 #[serde(default, skip_serializing_if = "Option::is_none")]
1547 pub introduced: Option<AuditIntroduced>,
1548}
1549
1550impl DynamicSegmentNameConflictFinding {
1551 #[must_use]
1554 pub fn with_actions(conflict: DynamicSegmentNameConflict) -> Self {
1555 let actions = vec![
1556 IssueAction::Fix(FixAction {
1557 kind: FixActionType::ResolveDynamicSegmentNameConflict,
1558 auto_fixable: false,
1559 description: "Sibling dynamic segments at the same position use different param \
1560 names. Rename them to one consistent slug name (e.g. pick `[id]` \
1561 or `[slug]` for both)."
1562 .to_string(),
1563 note: Some(
1564 "Next.js throws \"You cannot use different slug names for the same dynamic \
1565 path\" at dev / runtime when the position is hit; `next build` does not \
1566 catch it. See the sibling `conflicting_segments` array."
1567 .to_string(),
1568 ),
1569 available_in_catalogs: None,
1570 suggested_target: None,
1571 }),
1572 IssueAction::SuppressFile(SuppressFileAction {
1573 kind: SuppressFileKind::SuppressFile,
1574 auto_fixable: false,
1575 description: "Escape hatch only: a file-level suppress silences the finding but \
1576 does NOT stop Next.js from throwing at dev / runtime. Prefer \
1577 renaming the segments."
1578 .to_string(),
1579 comment: "// fallow-ignore-file dynamic-segment-name-conflict".to_string(),
1580 }),
1581 ];
1582 Self {
1583 conflict,
1584 actions,
1585 introduced: None,
1586 }
1587 }
1588}
1589
1590#[derive(Debug, Clone, Serialize, Deserialize)]
1593#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1594pub struct UnusedEnumMemberFinding {
1595 #[serde(flatten)]
1597 pub member: UnusedMember,
1598 pub actions: Vec<IssueAction>,
1601 #[serde(default, skip_serializing_if = "Option::is_none")]
1604 pub introduced: Option<AuditIntroduced>,
1605}
1606
1607impl UnusedEnumMemberFinding {
1608 #[must_use]
1610 pub fn with_actions(member: UnusedMember) -> Self {
1611 let actions = vec![
1612 IssueAction::Fix(FixAction {
1613 kind: FixActionType::RemoveEnumMember,
1614 auto_fixable: true,
1615 description: "Remove this enum member".to_string(),
1616 note: None,
1617 available_in_catalogs: None,
1618 suggested_target: None,
1619 }),
1620 IssueAction::SuppressLine(SuppressLineAction {
1621 kind: SuppressLineKind::SuppressLine,
1622 auto_fixable: false,
1623 description: "Suppress with an inline comment above the line".to_string(),
1624 comment: "// fallow-ignore-next-line unused-enum-member".to_string(),
1625 scope: None,
1626 }),
1627 ];
1628 Self {
1629 member,
1630 actions,
1631 introduced: None,
1632 }
1633 }
1634}
1635
1636#[derive(Debug, Clone, Serialize, Deserialize)]
1641#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1642pub struct UnusedClassMemberFinding {
1643 #[serde(flatten)]
1645 pub member: UnusedMember,
1646 pub actions: Vec<IssueAction>,
1649 #[serde(default, skip_serializing_if = "Option::is_none")]
1651 pub semantic: Option<SemanticCandidateDecision>,
1652 #[serde(skip)]
1656 #[cfg_attr(feature = "schema", schemars(skip))]
1657 pub semantic_only_candidate: bool,
1658 #[serde(default, skip_serializing_if = "Option::is_none")]
1661 pub introduced: Option<AuditIntroduced>,
1662}
1663
1664impl UnusedClassMemberFinding {
1665 #[must_use]
1670 pub fn with_actions(member: UnusedMember) -> Self {
1671 let actions = vec![
1672 IssueAction::Fix(FixAction {
1673 kind: FixActionType::RemoveClassMember,
1674 auto_fixable: false,
1675 description: "Remove this class member".to_string(),
1676 note: Some(
1677 "Class member may be used via dependency injection or decorators".to_string(),
1678 ),
1679 available_in_catalogs: None,
1680 suggested_target: None,
1681 }),
1682 IssueAction::SuppressLine(SuppressLineAction {
1683 kind: SuppressLineKind::SuppressLine,
1684 auto_fixable: false,
1685 description: "Suppress with an inline comment above the line".to_string(),
1686 comment: "// fallow-ignore-next-line unused-class-member".to_string(),
1687 scope: None,
1688 }),
1689 ];
1690 Self {
1691 member,
1692 actions,
1693 semantic: None,
1694 semantic_only_candidate: false,
1695 introduced: None,
1696 }
1697 }
1698
1699 #[must_use]
1702 pub const fn semantic_only_candidate(mut self) -> Self {
1703 self.semantic_only_candidate = true;
1704 self
1705 }
1706
1707 pub fn set_semantic_decision(&mut self, decision: SemanticCandidateDecision) {
1710 if let Some(IssueAction::Fix(action)) = self.actions.first_mut() {
1711 action.auto_fixable = decision.closed_world_eligible;
1712 action.note = Some(decision.explanation.clone());
1713 }
1714 self.semantic = Some(decision);
1715 }
1716}
1717
1718#[derive(Debug, Clone, Serialize, Deserialize)]
1727#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1728pub struct UnusedStoreMemberFinding {
1729 #[serde(flatten)]
1731 pub member: UnusedMember,
1732 pub actions: Vec<IssueAction>,
1735 #[serde(default, skip_serializing_if = "Option::is_none")]
1738 pub introduced: Option<AuditIntroduced>,
1739}
1740
1741impl UnusedStoreMemberFinding {
1742 #[must_use]
1746 pub fn with_actions(member: UnusedMember) -> Self {
1747 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1748 kind: SuppressLineKind::SuppressLine,
1749 auto_fixable: false,
1750 description: "Suppress with an inline comment above the line".to_string(),
1751 comment: "// fallow-ignore-next-line unused-store-member".to_string(),
1752 scope: None,
1753 })];
1754 Self {
1755 member,
1756 actions,
1757 introduced: None,
1758 }
1759 }
1760}
1761
1762fn build_unused_dependency_actions(
1773 dep: &UnusedDependency,
1774 package_json_location: &str,
1775 suppress_issue_kind: &str,
1776) -> Vec<IssueAction> {
1777 let mut actions = Vec::with_capacity(2);
1778 let cross_workspace = !dep.used_in_workspaces.is_empty();
1779 actions.push(if cross_workspace {
1780 IssueAction::Fix(FixAction {
1781 kind: FixActionType::MoveDependency,
1782 auto_fixable: false,
1783 description: "Move this dependency to the workspace package.json that imports it"
1784 .to_string(),
1785 note: Some(
1786 "fallow fix will not remove dependencies that are imported by another workspace"
1787 .to_string(),
1788 ),
1789 available_in_catalogs: None,
1790 suggested_target: None,
1791 })
1792 } else {
1793 IssueAction::Fix(FixAction {
1794 kind: FixActionType::RemoveDependency,
1795 auto_fixable: true,
1796 description: format!("Remove from {package_json_location} in package.json"),
1797 note: None,
1798 available_in_catalogs: None,
1799 suggested_target: None,
1800 })
1801 });
1802 actions.push(build_ignore_dependencies_suppress_action(
1803 &dep.package_name,
1804 suppress_issue_kind,
1805 ));
1806 actions
1807}
1808
1809fn build_ignore_dependencies_suppress_action(
1817 package_name: &str,
1818 _suppress_issue_kind: &str,
1819) -> IssueAction {
1820 IssueAction::AddToConfig(AddToConfigAction {
1821 kind: AddToConfigKind::AddToConfig,
1822 auto_fixable: false,
1823 description: format!("Add \"{package_name}\" to ignoreDependencies in fallow config"),
1824 config_key: "ignoreDependencies".to_string(),
1825 value: AddToConfigValue::Scalar(package_name.to_string()),
1826 value_schema: Some(
1827 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items"
1828 .to_string(),
1829 ),
1830 })
1831}
1832
1833#[derive(Debug, Clone, Serialize, Deserialize)]
1839#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1840pub struct UnusedDependencyFinding {
1841 #[serde(flatten)]
1843 pub dep: UnusedDependency,
1844 pub actions: Vec<IssueAction>,
1847 #[serde(default, skip_serializing_if = "Option::is_none")]
1850 pub introduced: Option<AuditIntroduced>,
1851}
1852
1853impl UnusedDependencyFinding {
1854 #[must_use]
1857 pub fn with_actions(dep: UnusedDependency) -> Self {
1858 let actions = build_unused_dependency_actions(&dep, "dependencies", "unused-dependency");
1859 Self {
1860 dep,
1861 actions,
1862 introduced: None,
1863 }
1864 }
1865}
1866
1867#[derive(Debug, Clone, Serialize, Deserialize)]
1873#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1874pub struct UnusedDevDependencyFinding {
1875 #[serde(flatten)]
1877 pub dep: UnusedDependency,
1878 pub actions: Vec<IssueAction>,
1881 #[serde(default, skip_serializing_if = "Option::is_none")]
1884 pub introduced: Option<AuditIntroduced>,
1885}
1886
1887impl UnusedDevDependencyFinding {
1888 #[must_use]
1890 pub fn with_actions(dep: UnusedDependency) -> Self {
1891 let actions =
1892 build_unused_dependency_actions(&dep, "devDependencies", "unused-dev-dependency");
1893 Self {
1894 dep,
1895 actions,
1896 introduced: None,
1897 }
1898 }
1899}
1900
1901#[derive(Debug, Clone, Serialize, Deserialize)]
1907#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1908pub struct UnusedOptionalDependencyFinding {
1909 #[serde(flatten)]
1911 pub dep: UnusedDependency,
1912 pub actions: Vec<IssueAction>,
1915 #[serde(default, skip_serializing_if = "Option::is_none")]
1918 pub introduced: Option<AuditIntroduced>,
1919}
1920
1921impl UnusedOptionalDependencyFinding {
1922 #[must_use]
1924 pub fn with_actions(dep: UnusedDependency) -> Self {
1925 let actions =
1926 build_unused_dependency_actions(&dep, "optionalDependencies", "unused-dependency");
1927 Self {
1928 dep,
1929 actions,
1930 introduced: None,
1931 }
1932 }
1933}
1934
1935#[derive(Debug, Clone, Serialize, Deserialize)]
1939#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1940pub struct UnlistedDependencyFinding {
1941 #[serde(flatten)]
1943 pub dep: UnlistedDependency,
1944 pub actions: Vec<IssueAction>,
1947 #[serde(default, skip_serializing_if = "Option::is_none")]
1950 pub introduced: Option<AuditIntroduced>,
1951}
1952
1953impl UnlistedDependencyFinding {
1954 #[must_use]
1956 pub fn with_actions(dep: UnlistedDependency) -> Self {
1957 let actions = vec![
1958 IssueAction::Fix(FixAction {
1959 kind: FixActionType::InstallDependency,
1960 auto_fixable: false,
1961 description: "Add this package to dependencies in package.json".to_string(),
1962 note: Some(
1963 "Verify this package should be a direct dependency before adding".to_string(),
1964 ),
1965 available_in_catalogs: None,
1966 suggested_target: None,
1967 }),
1968 build_ignore_dependencies_suppress_action(&dep.package_name, "unlisted-dependency"),
1969 ];
1970 Self {
1971 dep,
1972 actions,
1973 introduced: None,
1974 }
1975 }
1976}
1977
1978#[derive(Debug, Clone, Serialize, Deserialize)]
1982#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1983pub struct TypeOnlyDependencyFinding {
1984 #[serde(flatten)]
1986 pub dep: TypeOnlyDependency,
1987 pub actions: Vec<IssueAction>,
1990 #[serde(default, skip_serializing_if = "Option::is_none")]
1993 pub introduced: Option<AuditIntroduced>,
1994}
1995
1996impl TypeOnlyDependencyFinding {
1997 #[must_use]
1999 pub fn with_actions(dep: TypeOnlyDependency) -> Self {
2000 let actions = vec![
2001 IssueAction::Fix(FixAction {
2002 kind: FixActionType::MoveToDev,
2003 auto_fixable: false,
2004 description: "Move to devDependencies (only type imports are used)".to_string(),
2005 note: Some(
2006 "Type imports are erased at runtime so this dependency is not needed in production"
2007 .to_string(),
2008 ),
2009 available_in_catalogs: None,
2010 suggested_target: None,
2011 }),
2012 build_ignore_dependencies_suppress_action(&dep.package_name, "type-only-dependency"),
2013 ];
2014 Self {
2015 dep,
2016 actions,
2017 introduced: None,
2018 }
2019 }
2020}
2021
2022#[derive(Debug, Clone, Serialize, Deserialize)]
2026#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2027pub struct TestOnlyDependencyFinding {
2028 #[serde(flatten)]
2030 pub dep: TestOnlyDependency,
2031 pub actions: Vec<IssueAction>,
2034 #[serde(default, skip_serializing_if = "Option::is_none")]
2037 pub introduced: Option<AuditIntroduced>,
2038}
2039
2040impl TestOnlyDependencyFinding {
2041 #[must_use]
2043 pub fn with_actions(dep: TestOnlyDependency) -> Self {
2044 let actions = vec![
2045 IssueAction::Fix(FixAction {
2046 kind: FixActionType::MoveToDev,
2047 auto_fixable: false,
2048 description: "Move to devDependencies (only test files import this)".to_string(),
2049 note: Some(
2050 "Only test files import this package so it does not need to be a production dependency"
2051 .to_string(),
2052 ),
2053 available_in_catalogs: None,
2054 suggested_target: None,
2055 }),
2056 build_ignore_dependencies_suppress_action(&dep.package_name, "test-only-dependency"),
2057 ];
2058 Self {
2059 dep,
2060 actions,
2061 introduced: None,
2062 }
2063 }
2064}
2065
2066#[derive(Debug, Clone, Serialize, Deserialize)]
2071#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2072pub struct DevDependencyInProductionFinding {
2073 #[serde(flatten)]
2075 pub dep: DevDependencyInProduction,
2076 pub actions: Vec<IssueAction>,
2079 #[serde(default, skip_serializing_if = "Option::is_none")]
2082 pub introduced: Option<AuditIntroduced>,
2083}
2084
2085impl DevDependencyInProductionFinding {
2086 #[must_use]
2088 pub fn with_actions(dep: DevDependencyInProduction) -> Self {
2089 let actions = vec![
2090 IssueAction::Fix(FixAction {
2091 kind: FixActionType::MoveToProd,
2092 auto_fixable: false,
2093 description:
2094 "Move to dependencies if the deployment installs them (production code imports this)"
2095 .to_string(),
2096 note: Some(
2097 "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"
2098 .to_string(),
2099 ),
2100 available_in_catalogs: None,
2101 suggested_target: None,
2102 }),
2103 build_ignore_dependencies_suppress_action(
2104 &dep.package_name,
2105 "dev-dependency-in-production",
2106 ),
2107 ];
2108 Self {
2109 dep,
2110 actions,
2111 introduced: None,
2112 }
2113 }
2114}
2115
2116#[derive(Debug, Clone, Serialize, Deserialize)]
2137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2138pub struct DuplicateExportFinding {
2139 #[serde(flatten)]
2141 pub export: DuplicateExport,
2142 pub actions: Vec<IssueAction>,
2145 #[serde(default, skip_serializing_if = "Option::is_none")]
2148 pub introduced: Option<AuditIntroduced>,
2149}
2150
2151impl DuplicateExportFinding {
2152 #[must_use]
2161 pub fn with_actions(export: DuplicateExport) -> Self {
2162 let mut actions: Vec<IssueAction> = Vec::with_capacity(3);
2163
2164 if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
2165 actions.push(IssueAction::AddToConfig(AddToConfigAction {
2166 kind: AddToConfigKind::AddToConfig,
2167 auto_fixable: false,
2168 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(),
2169 config_key: "ignoreExports".to_string(),
2170 value: AddToConfigValue::ExportsRules(rules),
2171 value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
2172 }));
2173 }
2174
2175 actions.push(IssueAction::Fix(FixAction {
2176 kind: FixActionType::RemoveDuplicate,
2177 auto_fixable: false,
2178 description: "Keep one canonical export location and remove the others".to_string(),
2179 note: Some(NAMESPACE_BARREL_HINT.to_string()),
2180 available_in_catalogs: None,
2181 suggested_target: None,
2182 }));
2183
2184 actions.push(IssueAction::SuppressLine(SuppressLineAction {
2185 kind: SuppressLineKind::SuppressLine,
2186 auto_fixable: false,
2187 description: "Suppress with an inline comment above the line".to_string(),
2188 comment: "// fallow-ignore-next-line duplicate-export".to_string(),
2189 scope: Some(SuppressLineScope::PerLocation),
2190 }));
2191
2192 Self {
2193 export,
2194 actions,
2195 introduced: None,
2196 }
2197 }
2198
2199 pub fn set_config_fixable(&mut self, fixable: bool) {
2205 if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
2206 action.auto_fixable = fixable;
2207 }
2208 }
2209}
2210
2211fn build_duplicate_exports_ignore_rules(
2215 export: &DuplicateExport,
2216) -> Option<Vec<IgnoreExportsRule>> {
2217 let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
2218 for loc in &export.locations {
2219 let path = loc.path.to_string_lossy().replace('\\', "/");
2227 if path.is_empty() {
2228 continue;
2229 }
2230 if entries.iter().any(|existing| existing.file == path) {
2231 continue;
2232 }
2233 entries.push(IgnoreExportsRule {
2234 file: path,
2235 exports: vec!["*".to_string()],
2236 });
2237 }
2238 if entries.is_empty() {
2239 None
2240 } else {
2241 Some(entries)
2242 }
2243}
2244
2245#[derive(Debug, Clone, Serialize, Deserialize)]
2249#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2250pub struct UnusedCatalogEntryFinding {
2251 #[serde(flatten)]
2253 pub entry: UnusedCatalogEntry,
2254 pub actions: Vec<IssueAction>,
2256 #[serde(default, skip_serializing_if = "Option::is_none")]
2259 pub introduced: Option<AuditIntroduced>,
2260}
2261
2262impl UnusedCatalogEntryFinding {
2263 #[must_use]
2268 pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
2269 let is_pnpm_source = is_pnpm_catalog_source(&entry.path);
2270 let auto_fixable = entry.hardcoded_consumers.is_empty() && is_pnpm_source;
2271 let note = if is_pnpm_source {
2272 Some(
2273 "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing"
2274 .to_string(),
2275 )
2276 } else {
2277 Some(
2278 "fallow fix only edits pnpm-workspace.yaml catalog entries. Edit Bun package.json catalogs manually."
2279 .to_string(),
2280 )
2281 };
2282 let mut actions = vec![IssueAction::Fix(FixAction {
2283 kind: FixActionType::RemoveCatalogEntry,
2284 auto_fixable,
2285 description: if is_pnpm_source {
2286 "Remove the entry from pnpm-workspace.yaml".to_string()
2287 } else {
2288 "Remove the entry from the catalog source file manually".to_string()
2289 },
2290 note,
2291 available_in_catalogs: None,
2292 suggested_target: None,
2293 })];
2294 if is_pnpm_source {
2295 actions.push(IssueAction::SuppressLine(SuppressLineAction {
2296 kind: SuppressLineKind::SuppressLine,
2297 auto_fixable: false,
2298 description: "Suppress with a YAML comment above the line".to_string(),
2299 comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
2300 scope: None,
2301 }));
2302 }
2303 Self {
2304 entry,
2305 actions,
2306 introduced: None,
2307 }
2308 }
2309}
2310
2311#[derive(Debug, Clone, Serialize, Deserialize)]
2315#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2316pub struct EmptyCatalogGroupFinding {
2317 #[serde(flatten)]
2319 pub group: EmptyCatalogGroup,
2320 pub actions: Vec<IssueAction>,
2322 #[serde(default, skip_serializing_if = "Option::is_none")]
2325 pub introduced: Option<AuditIntroduced>,
2326}
2327
2328impl EmptyCatalogGroupFinding {
2329 #[must_use]
2331 pub fn with_actions(group: EmptyCatalogGroup) -> Self {
2332 let auto_fixable = is_pnpm_catalog_source(&group.path);
2333 let mut actions = vec![IssueAction::Fix(FixAction {
2334 kind: FixActionType::RemoveEmptyCatalogGroup,
2335 auto_fixable,
2336 description: if auto_fixable {
2337 "Remove the empty named catalog group from pnpm-workspace.yaml".to_string()
2338 } else {
2339 "Remove the empty named catalog group from the catalog source file manually"
2340 .to_string()
2341 },
2342 note: Some(if auto_fixable {
2343 "Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
2344 .to_string()
2345 } else {
2346 "fallow fix only edits pnpm-workspace.yaml catalog groups. Edit Bun package.json catalogs manually."
2347 .to_string()
2348 }),
2349 available_in_catalogs: None,
2350 suggested_target: None,
2351 })];
2352 if auto_fixable {
2353 actions.push(IssueAction::SuppressLine(SuppressLineAction {
2354 kind: SuppressLineKind::SuppressLine,
2355 auto_fixable: false,
2356 description: "Suppress with a YAML comment above the line".to_string(),
2357 comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
2358 scope: None,
2359 }));
2360 }
2361 Self {
2362 group,
2363 actions,
2364 introduced: None,
2365 }
2366 }
2367}
2368
2369fn is_pnpm_catalog_source(path: &Path) -> bool {
2370 path == Path::new(PNPM_WORKSPACE_FILE)
2371}
2372
2373#[derive(Debug, Clone, Serialize, Deserialize)]
2381#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2382pub struct UnresolvedCatalogReferenceFinding {
2383 #[serde(flatten)]
2385 pub reference: UnresolvedCatalogReference,
2386 pub actions: Vec<IssueAction>,
2389 #[serde(default, skip_serializing_if = "Option::is_none")]
2392 pub introduced: Option<AuditIntroduced>,
2393}
2394
2395impl UnresolvedCatalogReferenceFinding {
2396 #[must_use]
2400 pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
2401 let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
2406 let primary = catalog_reference_primary_action(&reference);
2407 let fallback = remove_catalog_reference_action();
2408 let suppress = suppress_catalog_reference_action(&reference, consumer_path);
2409
2410 Self {
2411 reference,
2412 actions: vec![primary, fallback, suppress],
2413 introduced: None,
2414 }
2415 }
2416}
2417
2418fn catalog_reference_primary_action(reference: &UnresolvedCatalogReference) -> IssueAction {
2419 if reference.available_in_catalogs.is_empty() {
2420 return IssueAction::Fix(FixAction {
2421 kind: FixActionType::AddCatalogEntry,
2422 auto_fixable: false,
2423 description: format!(
2424 "Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
2425 reference.entry_name, reference.catalog_name
2426 ),
2427 note: Some(
2428 "Pin a version that satisfies the consumer's import; no other catalog declares this package today"
2429 .to_string(),
2430 ),
2431 available_in_catalogs: None,
2432 suggested_target: None,
2433 });
2434 }
2435
2436 let available = reference.available_in_catalogs.clone();
2437 let suggested_target = (available.len() == 1).then(|| available[0].clone());
2438 IssueAction::Fix(FixAction {
2439 kind: FixActionType::UpdateCatalogReference,
2440 auto_fixable: false,
2441 description: format!(
2442 "Switch the reference from `catalog:{}` to a catalog that declares `{}`",
2443 reference.catalog_name, reference.entry_name
2444 ),
2445 note: None,
2446 available_in_catalogs: Some(available),
2447 suggested_target,
2448 })
2449}
2450
2451fn remove_catalog_reference_action() -> IssueAction {
2452 IssueAction::Fix(FixAction {
2453 kind: FixActionType::RemoveCatalogReference,
2454 auto_fixable: false,
2455 description: "Remove the catalog reference and pin a hardcoded version in package.json"
2456 .to_string(),
2457 note: Some(
2458 "Use only when neither another catalog declares the package nor the named catalog should grow to include it"
2459 .to_string(),
2460 ),
2461 available_in_catalogs: None,
2462 suggested_target: None,
2463 })
2464}
2465
2466fn suppress_catalog_reference_action(
2467 reference: &UnresolvedCatalogReference,
2468 consumer_path: String,
2469) -> IssueAction {
2470 let mut suppress_value = serde_json::Map::new();
2471 suppress_value.insert(
2472 "package".to_string(),
2473 serde_json::Value::String(reference.entry_name.clone()),
2474 );
2475 suppress_value.insert(
2476 "catalog".to_string(),
2477 serde_json::Value::String(reference.catalog_name.clone()),
2478 );
2479 suppress_value.insert(
2480 "consumer".to_string(),
2481 serde_json::Value::String(consumer_path),
2482 );
2483 IssueAction::AddToConfig(AddToConfigAction {
2484 kind: AddToConfigKind::AddToConfig,
2485 auto_fixable: false,
2486 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(),
2487 config_key: "ignoreCatalogReferences".to_string(),
2488 value: AddToConfigValue::RuleObject(suppress_value),
2489 value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
2490 })
2491}
2492
2493#[derive(Debug, Clone, Serialize, Deserialize)]
2498#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2499pub struct UnusedDependencyOverrideFinding {
2500 #[serde(flatten)]
2502 pub entry: UnusedDependencyOverride,
2503 pub actions: Vec<IssueAction>,
2505 #[serde(default, skip_serializing_if = "Option::is_none")]
2508 pub introduced: Option<AuditIntroduced>,
2509}
2510
2511impl UnusedDependencyOverrideFinding {
2512 #[must_use]
2514 pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
2515 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
2516 actions.push(IssueAction::Fix(FixAction {
2517 kind: FixActionType::RemoveDependencyOverride,
2518 auto_fixable: false,
2519 description: "Remove the package-manager override entry from its declaration source"
2520 .to_string(),
2521 note: Some(
2522 "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)"
2523 .to_string(),
2524 ),
2525 available_in_catalogs: None,
2526 suggested_target: None,
2527 }));
2528
2529 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
2530 Some(&entry.target_package),
2531 &entry.raw_key,
2532 entry.source,
2533 ) {
2534 actions.push(suppress);
2535 }
2536
2537 Self {
2538 entry,
2539 actions,
2540 introduced: None,
2541 }
2542 }
2543}
2544
2545#[derive(Debug, Clone, Serialize, Deserialize)]
2551#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2552pub struct MisconfiguredDependencyOverrideFinding {
2553 #[serde(flatten)]
2555 pub entry: MisconfiguredDependencyOverride,
2556 pub actions: Vec<IssueAction>,
2558 #[serde(default, skip_serializing_if = "Option::is_none")]
2561 pub introduced: Option<AuditIntroduced>,
2562}
2563
2564impl MisconfiguredDependencyOverrideFinding {
2565 #[must_use]
2570 pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
2571 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
2572 actions.push(IssueAction::Fix(FixAction {
2573 kind: FixActionType::FixDependencyOverride,
2574 auto_fixable: false,
2575 description:
2576 "Fix the package-manager override key or value: invalid entries are rejected or ignored"
2577 .to_string(),
2578 note: Some(
2579 "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`."
2580 .to_string(),
2581 ),
2582 available_in_catalogs: None,
2583 suggested_target: None,
2584 }));
2585
2586 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
2587 entry.target_package.as_deref(),
2588 &entry.raw_key,
2589 entry.source,
2590 ) {
2591 actions.push(suppress);
2592 }
2593
2594 Self {
2595 entry,
2596 actions,
2597 introduced: None,
2598 }
2599 }
2600}
2601
2602fn build_ignore_dependency_overrides_suppress(
2607 target_package: Option<&str>,
2608 raw_key: &str,
2609 source: DependencyOverrideSource,
2610) -> Option<IssueAction> {
2611 let package = target_package
2612 .filter(|s| !s.is_empty())
2613 .or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
2614 .to_string();
2615 let mut value = serde_json::Map::new();
2616 value.insert("package".to_string(), serde_json::Value::String(package));
2617 value.insert(
2618 "source".to_string(),
2619 serde_json::Value::String(source.as_label().to_string()),
2620 );
2621 Some(IssueAction::AddToConfig(AddToConfigAction {
2622 kind: AddToConfigKind::AddToConfig,
2623 auto_fixable: false,
2624 description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
2625 config_key: "ignoreDependencyOverrides".to_string(),
2626 value: AddToConfigValue::RuleObject(value),
2627 value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
2628 }))
2629}
2630
2631#[cfg(test)]
2641mod position_0_invariants {
2642 use super::*;
2643 use crate::output::FixActionType;
2644 use crate::results::{DependencyOverrideSource, DuplicateLocation};
2645 use std::path::PathBuf;
2646
2647 fn action_type(action: &IssueAction) -> &'static str {
2652 match action {
2653 IssueAction::Fix(fix) => match fix.kind {
2654 FixActionType::RemoveExport => "remove-export",
2655 FixActionType::DeleteFile => "delete-file",
2656 FixActionType::RemoveDependency => "remove-dependency",
2657 FixActionType::MoveDependency => "move-dependency",
2658 FixActionType::RemoveEnumMember => "remove-enum-member",
2659 FixActionType::RemoveClassMember => "remove-class-member",
2660 FixActionType::ResolveImport => "resolve-import",
2661 FixActionType::InstallDependency => "install-dependency",
2662 FixActionType::RemoveDuplicate => "remove-duplicate",
2663 FixActionType::MoveToDev => "move-to-dev",
2664 FixActionType::MoveToProd => "move-to-prod",
2665 FixActionType::RefactorCycle => "refactor-cycle",
2666 FixActionType::RefactorReExportCycle => "refactor-re-export-cycle",
2667 FixActionType::RefactorBoundary => "refactor-boundary",
2668 FixActionType::ExportType => "export-type",
2669 FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
2670 FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
2671 FixActionType::UpdateCatalogReference => "update-catalog-reference",
2672 FixActionType::AddCatalogEntry => "add-catalog-entry",
2673 FixActionType::RemoveCatalogReference => "remove-catalog-reference",
2674 FixActionType::RemoveDependencyOverride => "remove-dependency-override",
2675 FixActionType::FixDependencyOverride => "fix-dependency-override",
2676 FixActionType::ResolvePolicyViolation => "resolve-policy-violation",
2677 FixActionType::MoveToServerModule => "move-to-server-module",
2678 FixActionType::SplitMixedBarrel => "split-mixed-barrel",
2679 FixActionType::HoistDirective => "hoist-directive",
2680 FixActionType::WireServerAction => "wire-server-action",
2681 FixActionType::ProvideInject => "provide-inject",
2682 FixActionType::UseLoadData => "use-load-data",
2683 FixActionType::RenderComponent => "render-component",
2684 FixActionType::UseComponentProp => "use-component-prop",
2685 FixActionType::EmitComponentEvent => "emit-component-event",
2686 FixActionType::WireSvelteEvent => "wire-svelte-event",
2687 FixActionType::ResolveRouteCollision => "resolve-route-collision",
2688 FixActionType::ResolveDynamicSegmentNameConflict => {
2689 "resolve-dynamic-segment-name-conflict"
2690 }
2691 FixActionType::AddSuppressionReason => "add-suppression-reason",
2692 FixActionType::RemoveStaleSuppression => "remove-stale-suppression",
2693 },
2694 IssueAction::SuppressLine(_) => "suppress-line",
2695 IssueAction::SuppressFile(_) => "suppress-file",
2696 IssueAction::AddToConfig(_) => "add-to-config",
2697 }
2698 }
2699
2700 fn assert_manual_fix_then_suppress(
2701 actions: &[IssueAction],
2702 primary_type: &str,
2703 suppress_comment: &str,
2704 ) {
2705 assert_eq!(actions.len(), 2);
2706 assert_eq!(action_type(&actions[0]), primary_type);
2707 let IssueAction::Fix(primary) = &actions[0] else {
2708 panic!("position-0 should be a manual fix action");
2709 };
2710 assert!(!primary.auto_fixable);
2711 assert!(primary.note.is_some());
2712 assert_eq!(action_type(&actions[1]), "suppress-line");
2713 let IssueAction::SuppressLine(suppress) = &actions[1] else {
2714 panic!("position-1 should be a suppress-line action");
2715 };
2716 assert_eq!(suppress.comment, suppress_comment);
2717 }
2718
2719 #[test]
2720 fn pnpm_catalog_entry_action_is_auto_fixable() {
2721 let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
2722 entry_name: "unused".to_string(),
2723 catalog_name: "default".to_string(),
2724 path: PathBuf::from("pnpm-workspace.yaml"),
2725 line: 3,
2726 hardcoded_consumers: vec![],
2727 });
2728
2729 let IssueAction::Fix(fix) = &finding.actions[0] else {
2730 panic!("position-0 should be a fix action");
2731 };
2732 assert!(fix.auto_fixable);
2733 assert_eq!(finding.actions.len(), 2);
2734 assert_eq!(action_type(&finding.actions[1]), "suppress-line");
2735 }
2736
2737 #[test]
2738 fn bun_package_json_catalog_entry_action_is_manual_only() {
2739 let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
2740 entry_name: "unused".to_string(),
2741 catalog_name: "default".to_string(),
2742 path: PathBuf::from("package.json"),
2743 line: 4,
2744 hardcoded_consumers: vec![],
2745 });
2746
2747 let IssueAction::Fix(fix) = &finding.actions[0] else {
2748 panic!("position-0 should be a fix action");
2749 };
2750 assert!(!fix.auto_fixable);
2751 assert!(fix.description.contains("manually"));
2752 assert_eq!(finding.actions.len(), 1);
2753 }
2754
2755 #[test]
2756 fn bun_package_json_empty_catalog_group_action_is_manual_only() {
2757 let finding = EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
2758 catalog_name: "empty".to_string(),
2759 path: PathBuf::from("package.json"),
2760 line: 4,
2761 });
2762
2763 let IssueAction::Fix(fix) = &finding.actions[0] else {
2764 panic!("position-0 should be a fix action");
2765 };
2766 assert!(!fix.auto_fixable);
2767 assert!(fix.description.contains("manually"));
2768 assert_eq!(finding.actions.len(), 1);
2769 }
2770
2771 #[test]
2772 fn unprovided_inject_primary_action_is_provide_inject() {
2773 let finding = UnprovidedInjectFinding::with_actions(UnprovidedInject {
2774 path: PathBuf::from("src/context.ts"),
2775 key_name: "userKey".to_string(),
2776 framework: "svelte".to_string(),
2777 line: 7,
2778 col: 12,
2779 });
2780
2781 assert_manual_fix_then_suppress(
2782 &finding.actions,
2783 "provide-inject",
2784 "// fallow-ignore-next-line unprovided-inject",
2785 );
2786 }
2787
2788 #[test]
2789 fn unused_server_action_primary_action_is_wire_server_action() {
2790 let finding = UnusedServerActionFinding::with_actions(UnusedServerAction {
2791 path: PathBuf::from("app/actions.ts"),
2792 action_name: "saveDraft".to_string(),
2793 line: 3,
2794 col: 13,
2795 });
2796
2797 assert_manual_fix_then_suppress(
2798 &finding.actions,
2799 "wire-server-action",
2800 "// fallow-ignore-next-line unused-server-action",
2801 );
2802 }
2803
2804 #[test]
2805 fn unused_load_data_key_primary_action_is_use_load_data() {
2806 let finding = UnusedLoadDataKeyFinding::with_actions(UnusedLoadDataKey {
2807 path: PathBuf::from("src/routes/+page.server.ts"),
2808 key_name: "profile".to_string(),
2809 line: 12,
2810 col: 6,
2811 route_dir: Some("src/routes".to_string()),
2812 });
2813
2814 assert_manual_fix_then_suppress(
2815 &finding.actions,
2816 "use-load-data",
2817 "// fallow-ignore-next-line unused-load-data-key",
2818 );
2819 }
2820
2821 #[test]
2822 fn unrendered_component_primary_action_is_render_component() {
2823 let finding = UnrenderedComponentFinding::with_actions(UnrenderedComponent {
2824 path: PathBuf::from("src/components/EmptyState.vue"),
2825 component_name: "EmptyState".to_string(),
2826 framework: "vue".to_string(),
2827 reachable_via: None,
2828 line: 1,
2829 col: 0,
2830 });
2831
2832 assert_manual_fix_then_suppress(
2833 &finding.actions,
2834 "render-component",
2835 "// fallow-ignore-next-line unrendered-component",
2836 );
2837 }
2838
2839 #[test]
2840 fn unused_component_prop_primary_action_is_use_component_prop() {
2841 let finding = UnusedComponentPropFinding::with_actions(UnusedComponentProp {
2842 path: PathBuf::from("src/components/Card.vue"),
2843 component_name: "Card".to_string(),
2844 prop_name: "variant".to_string(),
2845 line: 5,
2846 col: 10,
2847 });
2848
2849 assert_manual_fix_then_suppress(
2850 &finding.actions,
2851 "use-component-prop",
2852 "// fallow-ignore-next-line unused-component-prop",
2853 );
2854 }
2855
2856 #[test]
2857 fn unused_component_emit_primary_action_is_emit_component_event() {
2858 let finding = UnusedComponentEmitFinding::with_actions(UnusedComponentEmit {
2859 path: PathBuf::from("src/components/Picker.vue"),
2860 component_name: "Picker".to_string(),
2861 emit_name: "focus".to_string(),
2862 line: 6,
2863 col: 14,
2864 });
2865
2866 assert_manual_fix_then_suppress(
2867 &finding.actions,
2868 "emit-component-event",
2869 "// fallow-ignore-next-line unused-component-emit",
2870 );
2871 }
2872
2873 #[test]
2874 fn unused_svelte_event_primary_action_is_wire_svelte_event() {
2875 let finding = UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
2876 path: PathBuf::from("src/Dialog.svelte"),
2877 component_name: "Dialog".to_string(),
2878 event_name: "closed".to_string(),
2879 line: 19,
2880 col: 8,
2881 });
2882
2883 assert_manual_fix_then_suppress(
2884 &finding.actions,
2885 "wire-svelte-event",
2886 "// fallow-ignore-next-line unused-svelte-event",
2887 );
2888 }
2889
2890 #[test]
2891 fn unresolved_import_actions_include_ignore_unresolved_imports_config_suppress() {
2892 let inner = UnresolvedImport {
2893 specifier: "@example/icons".to_string(),
2894 path: PathBuf::from("src/index.ts"),
2895 line: 4,
2896 col: 12,
2897 specifier_col: 18,
2898 };
2899 let finding = UnresolvedImportFinding::with_actions(inner);
2900
2901 assert_eq!(action_type(&finding.actions[0]), "resolve-import");
2902 assert_eq!(action_type(&finding.actions[1]), "add-to-config");
2903 let IssueAction::AddToConfig(action) = &finding.actions[1] else {
2904 panic!("position-1 should be AddToConfig");
2905 };
2906 assert!(!action.auto_fixable);
2907 assert_eq!(action.config_key, "ignoreUnresolvedImports");
2908 let AddToConfigValue::Scalar(value) = &action.value else {
2909 panic!("ignoreUnresolvedImports action should carry a scalar value");
2910 };
2911 assert_eq!(value, "@example/icons");
2912 assert_eq!(
2913 action.value_schema.as_deref(),
2914 Some(
2915 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
2916 )
2917 );
2918 }
2919
2920 #[test]
2931 fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
2932 let inner = UnresolvedCatalogReference {
2933 entry_name: "react".to_string(),
2934 catalog_name: "default".to_string(),
2935 path: PathBuf::from("apps/web/package.json"),
2936 line: 7,
2937 available_in_catalogs: Vec::new(),
2938 };
2939 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
2940 assert_eq!(
2941 action_type(&finding.actions[0]),
2942 "add-catalog-entry",
2943 "position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
2944 );
2945 let IssueAction::Fix(fix) = &finding.actions[0] else {
2946 panic!("position-0 should be an IssueAction::Fix");
2947 };
2948 assert!(
2949 fix.available_in_catalogs.is_none(),
2950 "add-catalog-entry must NOT carry available_in_catalogs"
2951 );
2952 assert!(
2953 fix.suggested_target.is_none(),
2954 "add-catalog-entry must NOT carry suggested_target"
2955 );
2956 }
2957
2958 #[test]
2965 fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
2966 let inner = UnresolvedCatalogReference {
2967 entry_name: "react".to_string(),
2968 catalog_name: "default".to_string(),
2969 path: PathBuf::from("apps/web/package.json"),
2970 line: 7,
2971 available_in_catalogs: vec!["react18".to_string()],
2972 };
2973 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
2974 assert_eq!(
2975 action_type(&finding.actions[0]),
2976 "update-catalog-reference",
2977 "position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
2978 );
2979 let IssueAction::Fix(fix) = &finding.actions[0] else {
2980 panic!("position-0 should be an IssueAction::Fix");
2981 };
2982 assert_eq!(
2983 fix.available_in_catalogs.as_deref(),
2984 Some(&["react18".to_string()][..]),
2985 "update-catalog-reference must carry the alternative list"
2986 );
2987 assert_eq!(
2988 fix.suggested_target.as_deref(),
2989 Some("react18"),
2990 "single-alternative case must surface `suggested_target` for deterministic agents"
2991 );
2992
2993 let inner_two = UnresolvedCatalogReference {
2995 entry_name: "react".to_string(),
2996 catalog_name: "default".to_string(),
2997 path: PathBuf::from("apps/web/package.json"),
2998 line: 7,
2999 available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
3000 };
3001 let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
3002 assert_eq!(
3003 action_type(&finding_two.actions[0]),
3004 "update-catalog-reference"
3005 );
3006 let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
3007 panic!("position-0 should be an IssueAction::Fix");
3008 };
3009 assert!(
3010 fix_two.suggested_target.is_none(),
3011 "multi-alternative case must NOT carry `suggested_target` (agent must pick)"
3012 );
3013 }
3014
3015 #[test]
3030 fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
3031 let inner = DuplicateExport {
3032 export_name: "Root".to_string(),
3033 locations: vec![
3034 DuplicateLocation {
3035 path: PathBuf::from("components/ui/accordion/index.ts"),
3036 line: 1,
3037 col: 0,
3038 },
3039 DuplicateLocation {
3040 path: PathBuf::from("components/ui/dialog/index.ts"),
3041 line: 1,
3042 col: 0,
3043 },
3044 ],
3045 };
3046 let finding = DuplicateExportFinding::with_actions(inner);
3047 assert_eq!(
3048 action_type(&finding.actions[0]),
3049 "add-to-config",
3050 "position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
3051 );
3052 assert_eq!(
3053 action_type(&finding.actions[1]),
3054 "remove-duplicate",
3055 "position-1 must be the destructive `remove-duplicate` fallback"
3056 );
3057
3058 let mut promoted = finding;
3061 promoted.set_config_fixable(true);
3062 assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
3063 let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
3064 panic!("position-0 should still be AddToConfig after set_config_fixable");
3065 };
3066 assert!(
3067 action.auto_fixable,
3068 "set_config_fixable(true) must flip auto_fixable"
3069 );
3070 }
3071
3072 #[test]
3077 fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
3078 let inner = DuplicateExport {
3079 export_name: "Root".to_string(),
3080 locations: Vec::new(),
3081 };
3082 let finding = DuplicateExportFinding::with_actions(inner);
3083 assert_eq!(
3084 action_type(&finding.actions[0]),
3085 "remove-duplicate",
3086 "with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
3087 );
3088
3089 let mut promoted = finding;
3091 promoted.set_config_fixable(true);
3092 assert_eq!(
3093 action_type(&promoted.actions[0]),
3094 "remove-duplicate",
3095 "set_config_fixable is a no-op when position-0 is not add-to-config"
3096 );
3097 }
3098
3099 #[test]
3105 fn misconfigured_override_drops_suppress_when_no_package_name() {
3106 let inner = MisconfiguredDependencyOverride {
3107 raw_key: String::new(),
3108 target_package: None,
3109 raw_value: String::new(),
3110 reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
3111 source: DependencyOverrideSource::PnpmWorkspaceYaml,
3112 path: PathBuf::from("pnpm-workspace.yaml"),
3113 line: 12,
3114 };
3115 let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
3116 assert_eq!(finding.actions.len(), 1);
3118 assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
3119 }
3120}