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: "Move to dependencies (production code imports this at runtime)"
2094 .to_string(),
2095 note: Some(
2096 "A production-only install (`pnpm install --prod`) omits devDependencies, so this import would break at runtime"
2097 .to_string(),
2098 ),
2099 available_in_catalogs: None,
2100 suggested_target: None,
2101 }),
2102 build_ignore_dependencies_suppress_action(
2103 &dep.package_name,
2104 "dev-dependency-in-production",
2105 ),
2106 ];
2107 Self {
2108 dep,
2109 actions,
2110 introduced: None,
2111 }
2112 }
2113}
2114
2115#[derive(Debug, Clone, Serialize, Deserialize)]
2136#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2137pub struct DuplicateExportFinding {
2138 #[serde(flatten)]
2140 pub export: DuplicateExport,
2141 pub actions: Vec<IssueAction>,
2144 #[serde(default, skip_serializing_if = "Option::is_none")]
2147 pub introduced: Option<AuditIntroduced>,
2148}
2149
2150impl DuplicateExportFinding {
2151 #[must_use]
2160 pub fn with_actions(export: DuplicateExport) -> Self {
2161 let mut actions: Vec<IssueAction> = Vec::with_capacity(3);
2162
2163 if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
2164 actions.push(IssueAction::AddToConfig(AddToConfigAction {
2165 kind: AddToConfigKind::AddToConfig,
2166 auto_fixable: false,
2167 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(),
2168 config_key: "ignoreExports".to_string(),
2169 value: AddToConfigValue::ExportsRules(rules),
2170 value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
2171 }));
2172 }
2173
2174 actions.push(IssueAction::Fix(FixAction {
2175 kind: FixActionType::RemoveDuplicate,
2176 auto_fixable: false,
2177 description: "Keep one canonical export location and remove the others".to_string(),
2178 note: Some(NAMESPACE_BARREL_HINT.to_string()),
2179 available_in_catalogs: None,
2180 suggested_target: None,
2181 }));
2182
2183 actions.push(IssueAction::SuppressLine(SuppressLineAction {
2184 kind: SuppressLineKind::SuppressLine,
2185 auto_fixable: false,
2186 description: "Suppress with an inline comment above the line".to_string(),
2187 comment: "// fallow-ignore-next-line duplicate-export".to_string(),
2188 scope: Some(SuppressLineScope::PerLocation),
2189 }));
2190
2191 Self {
2192 export,
2193 actions,
2194 introduced: None,
2195 }
2196 }
2197
2198 pub fn set_config_fixable(&mut self, fixable: bool) {
2204 if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
2205 action.auto_fixable = fixable;
2206 }
2207 }
2208}
2209
2210fn build_duplicate_exports_ignore_rules(
2214 export: &DuplicateExport,
2215) -> Option<Vec<IgnoreExportsRule>> {
2216 let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
2217 for loc in &export.locations {
2218 let path = loc.path.to_string_lossy().replace('\\', "/");
2226 if path.is_empty() {
2227 continue;
2228 }
2229 if entries.iter().any(|existing| existing.file == path) {
2230 continue;
2231 }
2232 entries.push(IgnoreExportsRule {
2233 file: path,
2234 exports: vec!["*".to_string()],
2235 });
2236 }
2237 if entries.is_empty() {
2238 None
2239 } else {
2240 Some(entries)
2241 }
2242}
2243
2244#[derive(Debug, Clone, Serialize, Deserialize)]
2248#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2249pub struct UnusedCatalogEntryFinding {
2250 #[serde(flatten)]
2252 pub entry: UnusedCatalogEntry,
2253 pub actions: Vec<IssueAction>,
2255 #[serde(default, skip_serializing_if = "Option::is_none")]
2258 pub introduced: Option<AuditIntroduced>,
2259}
2260
2261impl UnusedCatalogEntryFinding {
2262 #[must_use]
2267 pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
2268 let is_pnpm_source = is_pnpm_catalog_source(&entry.path);
2269 let auto_fixable = entry.hardcoded_consumers.is_empty() && is_pnpm_source;
2270 let note = if is_pnpm_source {
2271 Some(
2272 "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing"
2273 .to_string(),
2274 )
2275 } else {
2276 Some(
2277 "fallow fix only edits pnpm-workspace.yaml catalog entries. Edit Bun package.json catalogs manually."
2278 .to_string(),
2279 )
2280 };
2281 let mut actions = vec![IssueAction::Fix(FixAction {
2282 kind: FixActionType::RemoveCatalogEntry,
2283 auto_fixable,
2284 description: if is_pnpm_source {
2285 "Remove the entry from pnpm-workspace.yaml".to_string()
2286 } else {
2287 "Remove the entry from the catalog source file manually".to_string()
2288 },
2289 note,
2290 available_in_catalogs: None,
2291 suggested_target: None,
2292 })];
2293 if is_pnpm_source {
2294 actions.push(IssueAction::SuppressLine(SuppressLineAction {
2295 kind: SuppressLineKind::SuppressLine,
2296 auto_fixable: false,
2297 description: "Suppress with a YAML comment above the line".to_string(),
2298 comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
2299 scope: None,
2300 }));
2301 }
2302 Self {
2303 entry,
2304 actions,
2305 introduced: None,
2306 }
2307 }
2308}
2309
2310#[derive(Debug, Clone, Serialize, Deserialize)]
2314#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2315pub struct EmptyCatalogGroupFinding {
2316 #[serde(flatten)]
2318 pub group: EmptyCatalogGroup,
2319 pub actions: Vec<IssueAction>,
2321 #[serde(default, skip_serializing_if = "Option::is_none")]
2324 pub introduced: Option<AuditIntroduced>,
2325}
2326
2327impl EmptyCatalogGroupFinding {
2328 #[must_use]
2330 pub fn with_actions(group: EmptyCatalogGroup) -> Self {
2331 let auto_fixable = is_pnpm_catalog_source(&group.path);
2332 let mut actions = vec![IssueAction::Fix(FixAction {
2333 kind: FixActionType::RemoveEmptyCatalogGroup,
2334 auto_fixable,
2335 description: if auto_fixable {
2336 "Remove the empty named catalog group from pnpm-workspace.yaml".to_string()
2337 } else {
2338 "Remove the empty named catalog group from the catalog source file manually"
2339 .to_string()
2340 },
2341 note: Some(if auto_fixable {
2342 "Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
2343 .to_string()
2344 } else {
2345 "fallow fix only edits pnpm-workspace.yaml catalog groups. Edit Bun package.json catalogs manually."
2346 .to_string()
2347 }),
2348 available_in_catalogs: None,
2349 suggested_target: None,
2350 })];
2351 if auto_fixable {
2352 actions.push(IssueAction::SuppressLine(SuppressLineAction {
2353 kind: SuppressLineKind::SuppressLine,
2354 auto_fixable: false,
2355 description: "Suppress with a YAML comment above the line".to_string(),
2356 comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
2357 scope: None,
2358 }));
2359 }
2360 Self {
2361 group,
2362 actions,
2363 introduced: None,
2364 }
2365 }
2366}
2367
2368fn is_pnpm_catalog_source(path: &Path) -> bool {
2369 path == Path::new(PNPM_WORKSPACE_FILE)
2370}
2371
2372#[derive(Debug, Clone, Serialize, Deserialize)]
2380#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2381pub struct UnresolvedCatalogReferenceFinding {
2382 #[serde(flatten)]
2384 pub reference: UnresolvedCatalogReference,
2385 pub actions: Vec<IssueAction>,
2388 #[serde(default, skip_serializing_if = "Option::is_none")]
2391 pub introduced: Option<AuditIntroduced>,
2392}
2393
2394impl UnresolvedCatalogReferenceFinding {
2395 #[must_use]
2399 pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
2400 let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
2405 let primary = catalog_reference_primary_action(&reference);
2406 let fallback = remove_catalog_reference_action();
2407 let suppress = suppress_catalog_reference_action(&reference, consumer_path);
2408
2409 Self {
2410 reference,
2411 actions: vec![primary, fallback, suppress],
2412 introduced: None,
2413 }
2414 }
2415}
2416
2417fn catalog_reference_primary_action(reference: &UnresolvedCatalogReference) -> IssueAction {
2418 if reference.available_in_catalogs.is_empty() {
2419 return IssueAction::Fix(FixAction {
2420 kind: FixActionType::AddCatalogEntry,
2421 auto_fixable: false,
2422 description: format!(
2423 "Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
2424 reference.entry_name, reference.catalog_name
2425 ),
2426 note: Some(
2427 "Pin a version that satisfies the consumer's import; no other catalog declares this package today"
2428 .to_string(),
2429 ),
2430 available_in_catalogs: None,
2431 suggested_target: None,
2432 });
2433 }
2434
2435 let available = reference.available_in_catalogs.clone();
2436 let suggested_target = (available.len() == 1).then(|| available[0].clone());
2437 IssueAction::Fix(FixAction {
2438 kind: FixActionType::UpdateCatalogReference,
2439 auto_fixable: false,
2440 description: format!(
2441 "Switch the reference from `catalog:{}` to a catalog that declares `{}`",
2442 reference.catalog_name, reference.entry_name
2443 ),
2444 note: None,
2445 available_in_catalogs: Some(available),
2446 suggested_target,
2447 })
2448}
2449
2450fn remove_catalog_reference_action() -> IssueAction {
2451 IssueAction::Fix(FixAction {
2452 kind: FixActionType::RemoveCatalogReference,
2453 auto_fixable: false,
2454 description: "Remove the catalog reference and pin a hardcoded version in package.json"
2455 .to_string(),
2456 note: Some(
2457 "Use only when neither another catalog declares the package nor the named catalog should grow to include it"
2458 .to_string(),
2459 ),
2460 available_in_catalogs: None,
2461 suggested_target: None,
2462 })
2463}
2464
2465fn suppress_catalog_reference_action(
2466 reference: &UnresolvedCatalogReference,
2467 consumer_path: String,
2468) -> IssueAction {
2469 let mut suppress_value = serde_json::Map::new();
2470 suppress_value.insert(
2471 "package".to_string(),
2472 serde_json::Value::String(reference.entry_name.clone()),
2473 );
2474 suppress_value.insert(
2475 "catalog".to_string(),
2476 serde_json::Value::String(reference.catalog_name.clone()),
2477 );
2478 suppress_value.insert(
2479 "consumer".to_string(),
2480 serde_json::Value::String(consumer_path),
2481 );
2482 IssueAction::AddToConfig(AddToConfigAction {
2483 kind: AddToConfigKind::AddToConfig,
2484 auto_fixable: false,
2485 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(),
2486 config_key: "ignoreCatalogReferences".to_string(),
2487 value: AddToConfigValue::RuleObject(suppress_value),
2488 value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
2489 })
2490}
2491
2492#[derive(Debug, Clone, Serialize, Deserialize)]
2497#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2498pub struct UnusedDependencyOverrideFinding {
2499 #[serde(flatten)]
2501 pub entry: UnusedDependencyOverride,
2502 pub actions: Vec<IssueAction>,
2504 #[serde(default, skip_serializing_if = "Option::is_none")]
2507 pub introduced: Option<AuditIntroduced>,
2508}
2509
2510impl UnusedDependencyOverrideFinding {
2511 #[must_use]
2513 pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
2514 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
2515 actions.push(IssueAction::Fix(FixAction {
2516 kind: FixActionType::RemoveDependencyOverride,
2517 auto_fixable: false,
2518 description: "Remove the override entry from pnpm-workspace.yaml or pnpm.overrides"
2519 .to_string(),
2520 note: Some(
2521 "Conservative static check; verify against `pnpm install --frozen-lockfile` before removing in case the override targets a transitive dependency (CVE-fix pattern)"
2522 .to_string(),
2523 ),
2524 available_in_catalogs: None,
2525 suggested_target: None,
2526 }));
2527
2528 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
2529 Some(&entry.target_package),
2530 &entry.raw_key,
2531 entry.source,
2532 ) {
2533 actions.push(suppress);
2534 }
2535
2536 Self {
2537 entry,
2538 actions,
2539 introduced: None,
2540 }
2541 }
2542}
2543
2544#[derive(Debug, Clone, Serialize, Deserialize)]
2550#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2551pub struct MisconfiguredDependencyOverrideFinding {
2552 #[serde(flatten)]
2554 pub entry: MisconfiguredDependencyOverride,
2555 pub actions: Vec<IssueAction>,
2557 #[serde(default, skip_serializing_if = "Option::is_none")]
2560 pub introduced: Option<AuditIntroduced>,
2561}
2562
2563impl MisconfiguredDependencyOverrideFinding {
2564 #[must_use]
2569 pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
2570 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
2571 actions.push(IssueAction::Fix(FixAction {
2572 kind: FixActionType::FixDependencyOverride,
2573 auto_fixable: false,
2574 description:
2575 "Fix the override key or value: pnpm refuses to honor entries with an unparsable key or empty value"
2576 .to_string(),
2577 note: Some(
2578 "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`."
2579 .to_string(),
2580 ),
2581 available_in_catalogs: None,
2582 suggested_target: None,
2583 }));
2584
2585 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
2586 entry.target_package.as_deref(),
2587 &entry.raw_key,
2588 entry.source,
2589 ) {
2590 actions.push(suppress);
2591 }
2592
2593 Self {
2594 entry,
2595 actions,
2596 introduced: None,
2597 }
2598 }
2599}
2600
2601fn build_ignore_dependency_overrides_suppress(
2606 target_package: Option<&str>,
2607 raw_key: &str,
2608 source: DependencyOverrideSource,
2609) -> Option<IssueAction> {
2610 let package = target_package
2611 .filter(|s| !s.is_empty())
2612 .or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
2613 .to_string();
2614 let mut value = serde_json::Map::new();
2615 value.insert("package".to_string(), serde_json::Value::String(package));
2616 value.insert(
2617 "source".to_string(),
2618 serde_json::Value::String(source.as_label().to_string()),
2619 );
2620 Some(IssueAction::AddToConfig(AddToConfigAction {
2621 kind: AddToConfigKind::AddToConfig,
2622 auto_fixable: false,
2623 description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
2624 config_key: "ignoreDependencyOverrides".to_string(),
2625 value: AddToConfigValue::RuleObject(value),
2626 value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
2627 }))
2628}
2629
2630#[cfg(test)]
2640mod position_0_invariants {
2641 use super::*;
2642 use crate::output::FixActionType;
2643 use crate::results::{DependencyOverrideSource, DuplicateLocation};
2644 use std::path::PathBuf;
2645
2646 fn action_type(action: &IssueAction) -> &'static str {
2651 match action {
2652 IssueAction::Fix(fix) => match fix.kind {
2653 FixActionType::RemoveExport => "remove-export",
2654 FixActionType::DeleteFile => "delete-file",
2655 FixActionType::RemoveDependency => "remove-dependency",
2656 FixActionType::MoveDependency => "move-dependency",
2657 FixActionType::RemoveEnumMember => "remove-enum-member",
2658 FixActionType::RemoveClassMember => "remove-class-member",
2659 FixActionType::ResolveImport => "resolve-import",
2660 FixActionType::InstallDependency => "install-dependency",
2661 FixActionType::RemoveDuplicate => "remove-duplicate",
2662 FixActionType::MoveToDev => "move-to-dev",
2663 FixActionType::MoveToProd => "move-to-prod",
2664 FixActionType::RefactorCycle => "refactor-cycle",
2665 FixActionType::RefactorReExportCycle => "refactor-re-export-cycle",
2666 FixActionType::RefactorBoundary => "refactor-boundary",
2667 FixActionType::ExportType => "export-type",
2668 FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
2669 FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
2670 FixActionType::UpdateCatalogReference => "update-catalog-reference",
2671 FixActionType::AddCatalogEntry => "add-catalog-entry",
2672 FixActionType::RemoveCatalogReference => "remove-catalog-reference",
2673 FixActionType::RemoveDependencyOverride => "remove-dependency-override",
2674 FixActionType::FixDependencyOverride => "fix-dependency-override",
2675 FixActionType::ResolvePolicyViolation => "resolve-policy-violation",
2676 FixActionType::MoveToServerModule => "move-to-server-module",
2677 FixActionType::SplitMixedBarrel => "split-mixed-barrel",
2678 FixActionType::HoistDirective => "hoist-directive",
2679 FixActionType::WireServerAction => "wire-server-action",
2680 FixActionType::ProvideInject => "provide-inject",
2681 FixActionType::UseLoadData => "use-load-data",
2682 FixActionType::RenderComponent => "render-component",
2683 FixActionType::UseComponentProp => "use-component-prop",
2684 FixActionType::EmitComponentEvent => "emit-component-event",
2685 FixActionType::WireSvelteEvent => "wire-svelte-event",
2686 FixActionType::ResolveRouteCollision => "resolve-route-collision",
2687 FixActionType::ResolveDynamicSegmentNameConflict => {
2688 "resolve-dynamic-segment-name-conflict"
2689 }
2690 FixActionType::AddSuppressionReason => "add-suppression-reason",
2691 FixActionType::RemoveStaleSuppression => "remove-stale-suppression",
2692 },
2693 IssueAction::SuppressLine(_) => "suppress-line",
2694 IssueAction::SuppressFile(_) => "suppress-file",
2695 IssueAction::AddToConfig(_) => "add-to-config",
2696 }
2697 }
2698
2699 fn assert_manual_fix_then_suppress(
2700 actions: &[IssueAction],
2701 primary_type: &str,
2702 suppress_comment: &str,
2703 ) {
2704 assert_eq!(actions.len(), 2);
2705 assert_eq!(action_type(&actions[0]), primary_type);
2706 let IssueAction::Fix(primary) = &actions[0] else {
2707 panic!("position-0 should be a manual fix action");
2708 };
2709 assert!(!primary.auto_fixable);
2710 assert!(primary.note.is_some());
2711 assert_eq!(action_type(&actions[1]), "suppress-line");
2712 let IssueAction::SuppressLine(suppress) = &actions[1] else {
2713 panic!("position-1 should be a suppress-line action");
2714 };
2715 assert_eq!(suppress.comment, suppress_comment);
2716 }
2717
2718 #[test]
2719 fn pnpm_catalog_entry_action_is_auto_fixable() {
2720 let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
2721 entry_name: "unused".to_string(),
2722 catalog_name: "default".to_string(),
2723 path: PathBuf::from("pnpm-workspace.yaml"),
2724 line: 3,
2725 hardcoded_consumers: vec![],
2726 });
2727
2728 let IssueAction::Fix(fix) = &finding.actions[0] else {
2729 panic!("position-0 should be a fix action");
2730 };
2731 assert!(fix.auto_fixable);
2732 assert_eq!(finding.actions.len(), 2);
2733 assert_eq!(action_type(&finding.actions[1]), "suppress-line");
2734 }
2735
2736 #[test]
2737 fn bun_package_json_catalog_entry_action_is_manual_only() {
2738 let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
2739 entry_name: "unused".to_string(),
2740 catalog_name: "default".to_string(),
2741 path: PathBuf::from("package.json"),
2742 line: 4,
2743 hardcoded_consumers: vec![],
2744 });
2745
2746 let IssueAction::Fix(fix) = &finding.actions[0] else {
2747 panic!("position-0 should be a fix action");
2748 };
2749 assert!(!fix.auto_fixable);
2750 assert!(fix.description.contains("manually"));
2751 assert_eq!(finding.actions.len(), 1);
2752 }
2753
2754 #[test]
2755 fn bun_package_json_empty_catalog_group_action_is_manual_only() {
2756 let finding = EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
2757 catalog_name: "empty".to_string(),
2758 path: PathBuf::from("package.json"),
2759 line: 4,
2760 });
2761
2762 let IssueAction::Fix(fix) = &finding.actions[0] else {
2763 panic!("position-0 should be a fix action");
2764 };
2765 assert!(!fix.auto_fixable);
2766 assert!(fix.description.contains("manually"));
2767 assert_eq!(finding.actions.len(), 1);
2768 }
2769
2770 #[test]
2771 fn unprovided_inject_primary_action_is_provide_inject() {
2772 let finding = UnprovidedInjectFinding::with_actions(UnprovidedInject {
2773 path: PathBuf::from("src/context.ts"),
2774 key_name: "userKey".to_string(),
2775 framework: "svelte".to_string(),
2776 line: 7,
2777 col: 12,
2778 });
2779
2780 assert_manual_fix_then_suppress(
2781 &finding.actions,
2782 "provide-inject",
2783 "// fallow-ignore-next-line unprovided-inject",
2784 );
2785 }
2786
2787 #[test]
2788 fn unused_server_action_primary_action_is_wire_server_action() {
2789 let finding = UnusedServerActionFinding::with_actions(UnusedServerAction {
2790 path: PathBuf::from("app/actions.ts"),
2791 action_name: "saveDraft".to_string(),
2792 line: 3,
2793 col: 13,
2794 });
2795
2796 assert_manual_fix_then_suppress(
2797 &finding.actions,
2798 "wire-server-action",
2799 "// fallow-ignore-next-line unused-server-action",
2800 );
2801 }
2802
2803 #[test]
2804 fn unused_load_data_key_primary_action_is_use_load_data() {
2805 let finding = UnusedLoadDataKeyFinding::with_actions(UnusedLoadDataKey {
2806 path: PathBuf::from("src/routes/+page.server.ts"),
2807 key_name: "profile".to_string(),
2808 line: 12,
2809 col: 6,
2810 route_dir: Some("src/routes".to_string()),
2811 });
2812
2813 assert_manual_fix_then_suppress(
2814 &finding.actions,
2815 "use-load-data",
2816 "// fallow-ignore-next-line unused-load-data-key",
2817 );
2818 }
2819
2820 #[test]
2821 fn unrendered_component_primary_action_is_render_component() {
2822 let finding = UnrenderedComponentFinding::with_actions(UnrenderedComponent {
2823 path: PathBuf::from("src/components/EmptyState.vue"),
2824 component_name: "EmptyState".to_string(),
2825 framework: "vue".to_string(),
2826 reachable_via: None,
2827 line: 1,
2828 col: 0,
2829 });
2830
2831 assert_manual_fix_then_suppress(
2832 &finding.actions,
2833 "render-component",
2834 "// fallow-ignore-next-line unrendered-component",
2835 );
2836 }
2837
2838 #[test]
2839 fn unused_component_prop_primary_action_is_use_component_prop() {
2840 let finding = UnusedComponentPropFinding::with_actions(UnusedComponentProp {
2841 path: PathBuf::from("src/components/Card.vue"),
2842 component_name: "Card".to_string(),
2843 prop_name: "variant".to_string(),
2844 line: 5,
2845 col: 10,
2846 });
2847
2848 assert_manual_fix_then_suppress(
2849 &finding.actions,
2850 "use-component-prop",
2851 "// fallow-ignore-next-line unused-component-prop",
2852 );
2853 }
2854
2855 #[test]
2856 fn unused_component_emit_primary_action_is_emit_component_event() {
2857 let finding = UnusedComponentEmitFinding::with_actions(UnusedComponentEmit {
2858 path: PathBuf::from("src/components/Picker.vue"),
2859 component_name: "Picker".to_string(),
2860 emit_name: "focus".to_string(),
2861 line: 6,
2862 col: 14,
2863 });
2864
2865 assert_manual_fix_then_suppress(
2866 &finding.actions,
2867 "emit-component-event",
2868 "// fallow-ignore-next-line unused-component-emit",
2869 );
2870 }
2871
2872 #[test]
2873 fn unused_svelte_event_primary_action_is_wire_svelte_event() {
2874 let finding = UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
2875 path: PathBuf::from("src/Dialog.svelte"),
2876 component_name: "Dialog".to_string(),
2877 event_name: "closed".to_string(),
2878 line: 19,
2879 col: 8,
2880 });
2881
2882 assert_manual_fix_then_suppress(
2883 &finding.actions,
2884 "wire-svelte-event",
2885 "// fallow-ignore-next-line unused-svelte-event",
2886 );
2887 }
2888
2889 #[test]
2890 fn unresolved_import_actions_include_ignore_unresolved_imports_config_suppress() {
2891 let inner = UnresolvedImport {
2892 specifier: "@example/icons".to_string(),
2893 path: PathBuf::from("src/index.ts"),
2894 line: 4,
2895 col: 12,
2896 specifier_col: 18,
2897 };
2898 let finding = UnresolvedImportFinding::with_actions(inner);
2899
2900 assert_eq!(action_type(&finding.actions[0]), "resolve-import");
2901 assert_eq!(action_type(&finding.actions[1]), "add-to-config");
2902 let IssueAction::AddToConfig(action) = &finding.actions[1] else {
2903 panic!("position-1 should be AddToConfig");
2904 };
2905 assert!(!action.auto_fixable);
2906 assert_eq!(action.config_key, "ignoreUnresolvedImports");
2907 let AddToConfigValue::Scalar(value) = &action.value else {
2908 panic!("ignoreUnresolvedImports action should carry a scalar value");
2909 };
2910 assert_eq!(value, "@example/icons");
2911 assert_eq!(
2912 action.value_schema.as_deref(),
2913 Some(
2914 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
2915 )
2916 );
2917 }
2918
2919 #[test]
2930 fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
2931 let inner = UnresolvedCatalogReference {
2932 entry_name: "react".to_string(),
2933 catalog_name: "default".to_string(),
2934 path: PathBuf::from("apps/web/package.json"),
2935 line: 7,
2936 available_in_catalogs: Vec::new(),
2937 };
2938 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
2939 assert_eq!(
2940 action_type(&finding.actions[0]),
2941 "add-catalog-entry",
2942 "position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
2943 );
2944 let IssueAction::Fix(fix) = &finding.actions[0] else {
2945 panic!("position-0 should be an IssueAction::Fix");
2946 };
2947 assert!(
2948 fix.available_in_catalogs.is_none(),
2949 "add-catalog-entry must NOT carry available_in_catalogs"
2950 );
2951 assert!(
2952 fix.suggested_target.is_none(),
2953 "add-catalog-entry must NOT carry suggested_target"
2954 );
2955 }
2956
2957 #[test]
2964 fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
2965 let inner = UnresolvedCatalogReference {
2966 entry_name: "react".to_string(),
2967 catalog_name: "default".to_string(),
2968 path: PathBuf::from("apps/web/package.json"),
2969 line: 7,
2970 available_in_catalogs: vec!["react18".to_string()],
2971 };
2972 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
2973 assert_eq!(
2974 action_type(&finding.actions[0]),
2975 "update-catalog-reference",
2976 "position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
2977 );
2978 let IssueAction::Fix(fix) = &finding.actions[0] else {
2979 panic!("position-0 should be an IssueAction::Fix");
2980 };
2981 assert_eq!(
2982 fix.available_in_catalogs.as_deref(),
2983 Some(&["react18".to_string()][..]),
2984 "update-catalog-reference must carry the alternative list"
2985 );
2986 assert_eq!(
2987 fix.suggested_target.as_deref(),
2988 Some("react18"),
2989 "single-alternative case must surface `suggested_target` for deterministic agents"
2990 );
2991
2992 let inner_two = UnresolvedCatalogReference {
2994 entry_name: "react".to_string(),
2995 catalog_name: "default".to_string(),
2996 path: PathBuf::from("apps/web/package.json"),
2997 line: 7,
2998 available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
2999 };
3000 let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
3001 assert_eq!(
3002 action_type(&finding_two.actions[0]),
3003 "update-catalog-reference"
3004 );
3005 let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
3006 panic!("position-0 should be an IssueAction::Fix");
3007 };
3008 assert!(
3009 fix_two.suggested_target.is_none(),
3010 "multi-alternative case must NOT carry `suggested_target` (agent must pick)"
3011 );
3012 }
3013
3014 #[test]
3029 fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
3030 let inner = DuplicateExport {
3031 export_name: "Root".to_string(),
3032 locations: vec![
3033 DuplicateLocation {
3034 path: PathBuf::from("components/ui/accordion/index.ts"),
3035 line: 1,
3036 col: 0,
3037 },
3038 DuplicateLocation {
3039 path: PathBuf::from("components/ui/dialog/index.ts"),
3040 line: 1,
3041 col: 0,
3042 },
3043 ],
3044 };
3045 let finding = DuplicateExportFinding::with_actions(inner);
3046 assert_eq!(
3047 action_type(&finding.actions[0]),
3048 "add-to-config",
3049 "position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
3050 );
3051 assert_eq!(
3052 action_type(&finding.actions[1]),
3053 "remove-duplicate",
3054 "position-1 must be the destructive `remove-duplicate` fallback"
3055 );
3056
3057 let mut promoted = finding;
3060 promoted.set_config_fixable(true);
3061 assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
3062 let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
3063 panic!("position-0 should still be AddToConfig after set_config_fixable");
3064 };
3065 assert!(
3066 action.auto_fixable,
3067 "set_config_fixable(true) must flip auto_fixable"
3068 );
3069 }
3070
3071 #[test]
3076 fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
3077 let inner = DuplicateExport {
3078 export_name: "Root".to_string(),
3079 locations: Vec::new(),
3080 };
3081 let finding = DuplicateExportFinding::with_actions(inner);
3082 assert_eq!(
3083 action_type(&finding.actions[0]),
3084 "remove-duplicate",
3085 "with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
3086 );
3087
3088 let mut promoted = finding;
3090 promoted.set_config_fixable(true);
3091 assert_eq!(
3092 action_type(&promoted.actions[0]),
3093 "remove-duplicate",
3094 "set_config_fixable is a no-op when position-0 is not add-to-config"
3095 );
3096 }
3097
3098 #[test]
3104 fn misconfigured_override_drops_suppress_when_no_package_name() {
3105 let inner = MisconfiguredDependencyOverride {
3106 raw_key: String::new(),
3107 target_package: None,
3108 raw_value: String::new(),
3109 reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
3110 source: DependencyOverrideSource::PnpmWorkspaceYaml,
3111 path: PathBuf::from("pnpm-workspace.yaml"),
3112 line: 12,
3113 };
3114 let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
3115 assert_eq!(finding.actions.len(), 1);
3117 assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
3118 }
3119}