1use serde::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};
51
52pub 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.";
56
57const IGNORE_EXPORTS_VALUE_SCHEMA: &str =
61 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreExports";
62
63const IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreCatalogReferences/items";
66
67const IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencyOverrides/items";
71
72const PNPM_WORKSPACE_FILE: &str = "pnpm-workspace.yaml";
73
74fn manual_framework_fix(kind: FixActionType, description: &str, note: &str) -> IssueAction {
75 IssueAction::Fix(FixAction {
76 kind,
77 auto_fixable: false,
78 description: description.to_string(),
79 note: Some(note.to_string()),
80 available_in_catalogs: None,
81 suggested_target: None,
82 })
83}
84
85fn suppress_line(comment: &str) -> IssueAction {
86 IssueAction::SuppressLine(SuppressLineAction {
87 kind: SuppressLineKind::SuppressLine,
88 auto_fixable: false,
89 description: "Suppress with an inline comment above the line".to_string(),
90 comment: comment.to_string(),
91 scope: None,
92 })
93}
94
95#[derive(Debug, Clone, Serialize)]
100#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
101pub struct UnusedFileFinding {
102 #[serde(flatten)]
104 pub file: UnusedFile,
105 pub actions: Vec<IssueAction>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub introduced: Option<AuditIntroduced>,
112}
113
114impl UnusedFileFinding {
115 #[must_use]
119 pub fn with_actions(file: UnusedFile) -> Self {
120 let actions = vec![
121 IssueAction::Fix(FixAction {
122 kind: FixActionType::DeleteFile,
123 auto_fixable: false,
124 description: "Delete this file".to_string(),
125 note: Some(
126 "File deletion may remove runtime functionality not visible to static analysis"
127 .to_string(),
128 ),
129 available_in_catalogs: None,
130 suggested_target: None,
131 }),
132 IssueAction::SuppressFile(SuppressFileAction {
133 kind: SuppressFileKind::SuppressFile,
134 auto_fixable: false,
135 description: "Suppress with a file-level comment at the top of the file"
136 .to_string(),
137 comment: "// fallow-ignore-file unused-file".to_string(),
138 }),
139 ];
140 Self {
141 file,
142 actions,
143 introduced: None,
144 }
145 }
146}
147
148#[derive(Debug, Clone, Serialize)]
152#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
153pub struct PrivateTypeLeakFinding {
154 #[serde(flatten)]
156 pub leak: PrivateTypeLeak,
157 pub actions: Vec<IssueAction>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub introduced: Option<AuditIntroduced>,
164}
165
166impl PrivateTypeLeakFinding {
167 #[must_use]
169 pub fn with_actions(leak: PrivateTypeLeak) -> Self {
170 let actions = vec![
171 IssueAction::Fix(FixAction {
172 kind: FixActionType::ExportType,
173 auto_fixable: false,
174 description: "Export the referenced private type by name".to_string(),
175 note: Some(
176 "Keep the type exported while it is part of a public signature".to_string(),
177 ),
178 available_in_catalogs: None,
179 suggested_target: None,
180 }),
181 IssueAction::SuppressLine(SuppressLineAction {
182 kind: SuppressLineKind::SuppressLine,
183 auto_fixable: false,
184 description: "Suppress with an inline comment above the line".to_string(),
185 comment: "// fallow-ignore-next-line private-type-leak".to_string(),
186 scope: None,
187 }),
188 ];
189 Self {
190 leak,
191 actions,
192 introduced: None,
193 }
194 }
195}
196
197#[derive(Debug, Clone, Serialize)]
202#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
203pub struct UnresolvedImportFinding {
204 #[serde(flatten)]
206 pub import: UnresolvedImport,
207 pub actions: Vec<IssueAction>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub introduced: Option<AuditIntroduced>,
214}
215
216impl UnresolvedImportFinding {
217 #[must_use]
219 pub fn with_actions(import: UnresolvedImport) -> Self {
220 let actions = vec![
221 IssueAction::Fix(FixAction {
222 kind: FixActionType::ResolveImport,
223 auto_fixable: false,
224 description: "Fix the import specifier or install the missing module".to_string(),
225 note: Some(
226 "Verify the module path and check tsconfig paths configuration".to_string(),
227 ),
228 available_in_catalogs: None,
229 suggested_target: None,
230 }),
231 IssueAction::AddToConfig(AddToConfigAction {
232 kind: AddToConfigKind::AddToConfig,
233 auto_fixable: false,
234 description: format!(
235 "Add \"{}\" to ignoreUnresolvedImports in fallow config",
236 import.specifier
237 ),
238 config_key: "ignoreUnresolvedImports".to_string(),
239 value: AddToConfigValue::Scalar(import.specifier.clone()),
240 value_schema: Some(
241 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
242 .to_string(),
243 ),
244 }),
245 IssueAction::SuppressLine(SuppressLineAction {
246 kind: SuppressLineKind::SuppressLine,
247 auto_fixable: false,
248 description: "Suppress with an inline comment above the line".to_string(),
249 comment: "// fallow-ignore-next-line unresolved-import".to_string(),
250 scope: None,
251 }),
252 ];
253 Self {
254 import,
255 actions,
256 introduced: None,
257 }
258 }
259}
260
261#[derive(Debug, Clone, Serialize)]
266#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
267pub struct CircularDependencyFinding {
268 #[serde(flatten)]
270 pub cycle: CircularDependency,
271 pub actions: Vec<IssueAction>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub introduced: Option<AuditIntroduced>,
278}
279
280impl CircularDependencyFinding {
281 #[must_use]
283 pub fn with_actions(cycle: CircularDependency) -> Self {
284 let actions = vec![
285 IssueAction::Fix(FixAction {
286 kind: FixActionType::RefactorCycle,
287 auto_fixable: false,
288 description: "Extract shared logic into a separate module to break the cycle"
289 .to_string(),
290 note: Some(
291 "Circular imports can cause initialization issues and make code harder to reason about"
292 .to_string(),
293 ),
294 available_in_catalogs: None,
295 suggested_target: None,
296 }),
297 IssueAction::SuppressLine(SuppressLineAction {
298 kind: SuppressLineKind::SuppressLine,
299 auto_fixable: false,
300 description: "Suppress with an inline comment above the line".to_string(),
301 comment: "// fallow-ignore-next-line circular-dependency".to_string(),
302 scope: None,
303 }),
304 ];
305 Self {
306 cycle,
307 actions,
308 introduced: None,
309 }
310 }
311}
312
313#[derive(Debug, Clone, Serialize)]
321#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
322pub struct ReExportCycleFinding {
323 #[serde(flatten)]
325 pub cycle: ReExportCycle,
326 pub actions: Vec<IssueAction>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub introduced: Option<AuditIntroduced>,
333}
334
335impl ReExportCycleFinding {
336 #[must_use]
343 pub fn with_actions(cycle: ReExportCycle) -> Self {
344 let suppress_description = match cycle.kind {
350 ReExportCycleKind::SelfLoop => {
351 "Suppress with a file-level comment at the top of this file. \
352 The cycle is a self-loop, so the suppression covers the entire finding."
353 .to_string()
354 }
355 ReExportCycleKind::MultiNode => {
356 "Suppress with a file-level comment at the top of this file. \
357 One suppression on any member breaks the cycle for every member \
358 (see the sibling `files` array)."
359 .to_string()
360 }
361 };
362 let actions = vec![
363 IssueAction::Fix(FixAction {
364 kind: FixActionType::RefactorReExportCycle,
365 auto_fixable: false,
366 description: "Remove one `export * from` (or `export { ... } from`) \
367 statement on any one member to break the cycle"
368 .to_string(),
369 note: Some(
370 "Re-export cycles are structurally a no-op: chain propagation through \
371 the loop never reaches a terminating module, so imports from any member \
372 may silently come up empty."
373 .to_string(),
374 ),
375 available_in_catalogs: None,
376 suggested_target: None,
377 }),
378 IssueAction::SuppressFile(SuppressFileAction {
379 kind: SuppressFileKind::SuppressFile,
380 auto_fixable: false,
381 description: suppress_description,
382 comment: "// fallow-ignore-file re-export-cycle".to_string(),
383 }),
384 ];
385 Self {
386 cycle,
387 actions,
388 introduced: None,
389 }
390 }
391}
392
393#[derive(Debug, Clone, Serialize)]
398#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
399pub struct BoundaryViolationFinding {
400 #[serde(flatten)]
402 pub violation: BoundaryViolation,
403 pub actions: Vec<IssueAction>,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub introduced: Option<AuditIntroduced>,
410}
411
412impl BoundaryViolationFinding {
413 #[must_use]
415 pub fn with_actions(violation: BoundaryViolation) -> Self {
416 let actions = vec![
417 IssueAction::Fix(FixAction {
418 kind: FixActionType::RefactorBoundary,
419 auto_fixable: false,
420 description: "Move the import through an allowed zone or restructure the dependency"
421 .to_string(),
422 note: Some(
423 "This import crosses an architecture boundary that is not permitted by the configured rules"
424 .to_string(),
425 ),
426 available_in_catalogs: None,
427 suggested_target: None,
428 }),
429 IssueAction::SuppressLine(SuppressLineAction {
430 kind: SuppressLineKind::SuppressLine,
431 auto_fixable: false,
432 description: "Suppress with an inline comment above the line".to_string(),
433 comment: "// fallow-ignore-next-line boundary-violation".to_string(),
434 scope: None,
435 }),
436 ];
437 Self {
438 violation,
439 actions,
440 introduced: None,
441 }
442 }
443}
444
445#[derive(Debug, Clone, Serialize)]
449#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
450pub struct BoundaryCoverageViolationFinding {
451 #[serde(flatten)]
453 pub violation: BoundaryCoverageViolation,
454 pub actions: Vec<IssueAction>,
456 #[serde(default, skip_serializing_if = "Option::is_none")]
459 pub introduced: Option<AuditIntroduced>,
460}
461
462impl BoundaryCoverageViolationFinding {
463 #[must_use]
465 pub fn with_actions(violation: BoundaryCoverageViolation) -> Self {
466 let path = violation.path.to_string_lossy().replace('\\', "/");
467 let actions = vec![
468 IssueAction::Fix(FixAction {
469 kind: FixActionType::RefactorBoundary,
470 auto_fixable: false,
471 description: "Add this file to a boundary zone pattern or move it under an existing zone"
472 .to_string(),
473 note: Some(
474 "Boundary coverage is enabled, so every analyzed source file must match a zone unless allow-listed"
475 .to_string(),
476 ),
477 available_in_catalogs: None,
478 suggested_target: None,
479 }),
480 IssueAction::AddToConfig(AddToConfigAction {
481 kind: AddToConfigKind::AddToConfig,
482 auto_fixable: false,
483 description: format!(
484 "Add \"{path}\" to boundaries.coverage.allowUnmatched in fallow config"
485 ),
486 config_key: "boundaries.coverage.allowUnmatched".to_string(),
487 value: AddToConfigValue::Scalar(path),
488 value_schema: Some(
489 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/boundaries/properties/coverage/properties/allowUnmatched/items"
490 .to_string(),
491 ),
492 }),
493 IssueAction::SuppressFile(SuppressFileAction {
494 kind: SuppressFileKind::SuppressFile,
495 auto_fixable: false,
496 description: "Suppress with a file-level comment at the top of the file"
497 .to_string(),
498 comment: "// fallow-ignore-file boundary-violation".to_string(),
499 }),
500 ];
501 Self {
502 violation,
503 actions,
504 introduced: None,
505 }
506 }
507}
508
509#[derive(Debug, Clone, Serialize)]
513#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
514pub struct BoundaryCallViolationFinding {
515 #[serde(flatten)]
517 pub violation: BoundaryCallViolation,
518 pub actions: Vec<IssueAction>,
520 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub introduced: Option<AuditIntroduced>,
524}
525
526impl BoundaryCallViolationFinding {
527 #[must_use]
529 pub fn with_actions(violation: BoundaryCallViolation) -> Self {
530 let actions = vec![
531 IssueAction::Fix(FixAction {
532 kind: FixActionType::RefactorBoundary,
533 auto_fixable: false,
534 description: format!(
535 "Move the `{}` call out of zone '{}' or behind an allowed abstraction",
536 violation.callee, violation.zone,
537 ),
538 note: Some(format!(
539 "`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",
540 violation.pattern, violation.zone,
541 )),
542 available_in_catalogs: None,
543 suggested_target: None,
544 }),
545 IssueAction::SuppressLine(SuppressLineAction {
546 kind: SuppressLineKind::SuppressLine,
547 auto_fixable: false,
548 description: "Suppress with an inline comment above the line".to_string(),
549 comment: "// fallow-ignore-next-line boundary-violation".to_string(),
550 scope: None,
551 }),
552 IssueAction::SuppressFile(SuppressFileAction {
553 kind: SuppressFileKind::SuppressFile,
554 auto_fixable: false,
555 description: "Suppress with a file-level comment at the top of the file"
556 .to_string(),
557 comment: "// fallow-ignore-file boundary-violation".to_string(),
558 }),
559 ];
560 Self {
561 violation,
562 actions,
563 introduced: None,
564 }
565 }
566}
567
568#[derive(Debug, Clone, Serialize)]
572#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
573pub struct PolicyViolationFinding {
574 #[serde(flatten)]
576 pub violation: PolicyViolation,
577 pub actions: Vec<IssueAction>,
579 #[serde(default, skip_serializing_if = "Option::is_none")]
582 pub introduced: Option<AuditIntroduced>,
583}
584
585impl PolicyViolationFinding {
586 #[must_use]
588 pub fn with_actions(violation: PolicyViolation) -> Self {
589 let what = match violation.kind {
590 crate::results::PolicyRuleKind::BannedCall => "call",
591 crate::results::PolicyRuleKind::BannedImport => "import",
592 crate::results::PolicyRuleKind::BannedEffect => "effect",
593 crate::results::PolicyRuleKind::BannedExport => "export",
594 };
595 let description = match &violation.message {
596 Some(message) => format!("Replace the `{}` {what}: {message}", violation.matched),
597 None => format!("Replace the `{}` {what}", violation.matched),
598 };
599 let suppress_token = format!("policy-violation:{}/{}", violation.pack, violation.rule_id);
600 let actions = vec![
601 IssueAction::Fix(FixAction {
602 kind: FixActionType::ResolvePolicyViolation,
603 auto_fixable: false,
604 description,
605 note: Some(format!(
606 "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",
607 violation.pack, violation.rule_id,
608 )),
609 available_in_catalogs: None,
610 suggested_target: None,
611 }),
612 IssueAction::SuppressLine(SuppressLineAction {
613 kind: SuppressLineKind::SuppressLine,
614 auto_fixable: false,
615 description: "Suppress this rule-pack rule with an inline comment above the line"
616 .to_string(),
617 comment: format!("// fallow-ignore-next-line {suppress_token}"),
618 scope: None,
619 }),
620 IssueAction::SuppressFile(SuppressFileAction {
621 kind: SuppressFileKind::SuppressFile,
622 auto_fixable: false,
623 description:
624 "Suppress this rule-pack rule with a file-level comment at the top of the file"
625 .to_string(),
626 comment: format!("// fallow-ignore-file {suppress_token}"),
627 }),
628 ];
629 Self {
630 violation,
631 actions,
632 introduced: None,
633 }
634 }
635}
636
637#[derive(Debug, Clone, Serialize)]
642#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
643pub struct UnusedExportFinding {
644 #[serde(flatten)]
646 pub export: UnusedExport,
647 pub actions: Vec<IssueAction>,
650 #[serde(default, skip_serializing_if = "Option::is_none")]
653 pub introduced: Option<AuditIntroduced>,
654}
655
656impl UnusedExportFinding {
657 #[must_use]
661 pub fn with_actions(export: UnusedExport) -> Self {
662 let note = if export.is_re_export {
663 Some(
664 "This finding originates from a re-export; verify it is not part of your public API before removing"
665 .to_string(),
666 )
667 } else {
668 None
669 };
670 let actions = vec![
671 IssueAction::Fix(FixAction {
672 kind: FixActionType::RemoveExport,
673 auto_fixable: true,
674 description: "Remove the unused export from the public API".to_string(),
675 note,
676 available_in_catalogs: None,
677 suggested_target: None,
678 }),
679 IssueAction::SuppressLine(SuppressLineAction {
680 kind: SuppressLineKind::SuppressLine,
681 auto_fixable: false,
682 description: "Suppress with an inline comment above the line".to_string(),
683 comment: "// fallow-ignore-next-line unused-export".to_string(),
684 scope: None,
685 }),
686 ];
687 Self {
688 export,
689 actions,
690 introduced: None,
691 }
692 }
693}
694
695#[derive(Debug, Clone, Serialize)]
700#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
701pub struct UnusedTypeFinding {
702 #[serde(flatten)]
704 pub export: UnusedExport,
705 pub actions: Vec<IssueAction>,
708 #[serde(default, skip_serializing_if = "Option::is_none")]
711 pub introduced: Option<AuditIntroduced>,
712}
713
714impl UnusedTypeFinding {
715 #[must_use]
718 pub fn with_actions(export: UnusedExport) -> Self {
719 let note = if export.is_re_export {
720 Some(
721 "This finding originates from a re-export; verify it is not part of your public API before removing"
722 .to_string(),
723 )
724 } else {
725 None
726 };
727 let actions = vec![
728 IssueAction::Fix(FixAction {
729 kind: FixActionType::RemoveExport,
730 auto_fixable: true,
731 description:
732 "Remove the `export` (or `export type`) keyword from the type declaration"
733 .to_string(),
734 note,
735 available_in_catalogs: None,
736 suggested_target: None,
737 }),
738 IssueAction::SuppressLine(SuppressLineAction {
739 kind: SuppressLineKind::SuppressLine,
740 auto_fixable: false,
741 description: "Suppress with an inline comment above the line".to_string(),
742 comment: "// fallow-ignore-next-line unused-type".to_string(),
743 scope: None,
744 }),
745 ];
746 Self {
747 export,
748 actions,
749 introduced: None,
750 }
751 }
752}
753
754#[derive(Debug, Clone, Serialize)]
760#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
761pub struct InvalidClientExportFinding {
762 #[serde(flatten)]
764 pub export: InvalidClientExport,
765 pub actions: Vec<IssueAction>,
768 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub introduced: Option<AuditIntroduced>,
772}
773
774impl InvalidClientExportFinding {
775 #[must_use]
780 pub fn with_actions(export: InvalidClientExport) -> Self {
781 let actions = vec![
782 IssueAction::Fix(FixAction {
783 kind: FixActionType::MoveToServerModule,
784 auto_fixable: false,
785 description: "Move the server-only export to a non-client module and import it from there"
786 .to_string(),
787 note: Some(
788 "A \"use client\" file cannot export a Next.js server-only or route-config name; Next.js rejects it at build time"
789 .to_string(),
790 ),
791 available_in_catalogs: None,
792 suggested_target: None,
793 }),
794 IssueAction::SuppressLine(SuppressLineAction {
795 kind: SuppressLineKind::SuppressLine,
796 auto_fixable: false,
797 description: "Suppress with an inline comment above the line".to_string(),
798 comment: "// fallow-ignore-next-line invalid-client-export".to_string(),
799 scope: None,
800 }),
801 ];
802 Self {
803 export,
804 actions,
805 introduced: None,
806 }
807 }
808}
809
810#[derive(Debug, Clone, Serialize)]
816#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
817pub struct MixedClientServerBarrelFinding {
818 #[serde(flatten)]
820 pub barrel: MixedClientServerBarrel,
821 pub actions: Vec<IssueAction>,
824 #[serde(default, skip_serializing_if = "Option::is_none")]
827 pub introduced: Option<AuditIntroduced>,
828}
829
830impl MixedClientServerBarrelFinding {
831 #[must_use]
836 pub fn with_actions(barrel: MixedClientServerBarrel) -> Self {
837 let actions = vec![
838 IssueAction::Fix(FixAction {
839 kind: FixActionType::SplitMixedBarrel,
840 auto_fixable: false,
841 description: "Split the barrel so client and server-only modules are re-exported from separate files"
842 .to_string(),
843 note: Some(
844 "Importing one name from this barrel drags the other's directive across the client/server boundary"
845 .to_string(),
846 ),
847 available_in_catalogs: None,
848 suggested_target: None,
849 }),
850 IssueAction::SuppressLine(SuppressLineAction {
851 kind: SuppressLineKind::SuppressLine,
852 auto_fixable: false,
853 description: "Suppress with an inline comment above the line".to_string(),
854 comment: "// fallow-ignore-next-line mixed-client-server-barrel".to_string(),
855 scope: None,
856 }),
857 ];
858 Self {
859 barrel,
860 actions,
861 introduced: None,
862 }
863 }
864}
865
866#[derive(Debug, Clone, Serialize)]
872#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
873pub struct MisplacedDirectiveFinding {
874 #[serde(flatten)]
876 pub directive_site: MisplacedDirective,
877 pub actions: Vec<IssueAction>,
880 #[serde(default, skip_serializing_if = "Option::is_none")]
883 pub introduced: Option<AuditIntroduced>,
884}
885
886impl MisplacedDirectiveFinding {
887 #[must_use]
892 pub fn with_actions(directive_site: MisplacedDirective) -> Self {
893 let actions = vec![
894 IssueAction::Fix(FixAction {
895 kind: FixActionType::HoistDirective,
896 auto_fixable: false,
897 description: "Move the directive to the very top of the file, above all imports and statements"
898 .to_string(),
899 note: Some(
900 "An RSC bundler honors the directive only in the leading prologue; here it precedes other statements and is silently ignored"
901 .to_string(),
902 ),
903 available_in_catalogs: None,
904 suggested_target: None,
905 }),
906 IssueAction::SuppressLine(SuppressLineAction {
907 kind: SuppressLineKind::SuppressLine,
908 auto_fixable: false,
909 description: "Suppress with an inline comment above the line".to_string(),
910 comment: "// fallow-ignore-next-line misplaced-directive".to_string(),
911 scope: None,
912 }),
913 ];
914 Self {
915 directive_site,
916 actions,
917 introduced: None,
918 }
919 }
920}
921
922#[derive(Debug, Clone, Serialize)]
927#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
928pub struct UnprovidedInjectFinding {
929 #[serde(flatten)]
931 pub inject: UnprovidedInject,
932 pub actions: Vec<IssueAction>,
935 #[serde(default, skip_serializing_if = "Option::is_none")]
938 pub introduced: Option<AuditIntroduced>,
939}
940
941impl UnprovidedInjectFinding {
942 #[must_use]
945 pub fn with_actions(inject: UnprovidedInject) -> Self {
946 let actions = vec![
947 manual_framework_fix(
948 FixActionType::ProvideInject,
949 "Provide this injected key, or remove the inject / getContext call",
950 "Manual review required: dependency-injection keys can be provided by framework wiring, tests, or package consumers outside this project.",
951 ),
952 suppress_line("// fallow-ignore-next-line unprovided-inject"),
953 ];
954 Self {
955 inject,
956 actions,
957 introduced: None,
958 }
959 }
960}
961
962#[derive(Debug, Clone, Serialize)]
967#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
968pub struct UnusedServerActionFinding {
969 #[serde(flatten)]
971 pub action: UnusedServerAction,
972 pub actions: Vec<IssueAction>,
975 #[serde(default, skip_serializing_if = "Option::is_none")]
978 pub introduced: Option<AuditIntroduced>,
979}
980
981impl UnusedServerActionFinding {
982 #[must_use]
985 pub fn with_actions(action: UnusedServerAction) -> Self {
986 let actions = vec![
987 manual_framework_fix(
988 FixActionType::WireServerAction,
989 "Wire the server action to a caller or form action, or remove it",
990 "Manual review required: server actions may still be POST-able by action id or invoked reflectively outside the static project graph.",
991 ),
992 suppress_line("// fallow-ignore-next-line unused-server-action"),
993 ];
994 Self {
995 action,
996 actions,
997 introduced: None,
998 }
999 }
1000}
1001
1002#[derive(Debug, Clone, Serialize)]
1007#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1008pub struct UnusedLoadDataKeyFinding {
1009 #[serde(flatten)]
1011 pub key: UnusedLoadDataKey,
1012 pub actions: Vec<IssueAction>,
1015 #[serde(default, skip_serializing_if = "Option::is_none")]
1018 pub introduced: Option<AuditIntroduced>,
1019}
1020
1021impl UnusedLoadDataKeyFinding {
1022 #[must_use]
1025 pub fn with_actions(key: UnusedLoadDataKey) -> Self {
1026 let actions = vec![
1027 manual_framework_fix(
1028 FixActionType::UseLoadData,
1029 "Read this load data key from the route UI, or remove it from the load return",
1030 "Manual review required: load functions can perform real server or database work, so verify side effects before deleting the producer.",
1031 ),
1032 suppress_line("// fallow-ignore-next-line unused-load-data-key"),
1033 ];
1034 Self {
1035 key,
1036 actions,
1037 introduced: None,
1038 }
1039 }
1040}
1041
1042#[derive(Debug, Clone, Serialize)]
1047#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1048pub struct UnrenderedComponentFinding {
1049 #[serde(flatten)]
1051 pub component: UnrenderedComponent,
1052 pub actions: Vec<IssueAction>,
1055 #[serde(default, skip_serializing_if = "Option::is_none")]
1058 pub introduced: Option<AuditIntroduced>,
1059}
1060
1061impl UnrenderedComponentFinding {
1062 #[must_use]
1065 pub fn with_actions(component: UnrenderedComponent) -> Self {
1066 let actions = vec![
1067 manual_framework_fix(
1068 FixActionType::RenderComponent,
1069 "Render the reachable component from project code, or remove it",
1070 "Manual review required: exported library components and dynamic render registries can be intentionally reachable without static template usage.",
1071 ),
1072 suppress_line("// fallow-ignore-next-line unrendered-component"),
1073 ];
1074 Self {
1075 component,
1076 actions,
1077 introduced: None,
1078 }
1079 }
1080}
1081
1082#[derive(Debug, Clone, Serialize)]
1087#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1088pub struct UnusedComponentPropFinding {
1089 #[serde(flatten)]
1091 pub prop: UnusedComponentProp,
1092 pub actions: Vec<IssueAction>,
1095 #[serde(default, skip_serializing_if = "Option::is_none")]
1098 pub introduced: Option<AuditIntroduced>,
1099}
1100
1101impl UnusedComponentPropFinding {
1102 #[must_use]
1105 pub fn with_actions(prop: UnusedComponentProp) -> Self {
1106 let actions = vec![
1107 manual_framework_fix(
1108 FixActionType::UseComponentProp,
1109 "Use the declared prop in the component, or remove it from the component API",
1110 "Manual review required: public component APIs can intentionally keep stable props for external consumers.",
1111 ),
1112 suppress_line("// fallow-ignore-next-line unused-component-prop"),
1113 ];
1114 Self {
1115 prop,
1116 actions,
1117 introduced: None,
1118 }
1119 }
1120}
1121
1122#[derive(Debug, Clone, Serialize)]
1127#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1128pub struct UnusedComponentEmitFinding {
1129 #[serde(flatten)]
1131 pub emit: UnusedComponentEmit,
1132 pub actions: Vec<IssueAction>,
1135 #[serde(default, skip_serializing_if = "Option::is_none")]
1138 pub introduced: Option<AuditIntroduced>,
1139}
1140
1141impl UnusedComponentEmitFinding {
1142 #[must_use]
1145 pub fn with_actions(emit: UnusedComponentEmit) -> Self {
1146 let actions = vec![
1147 manual_framework_fix(
1148 FixActionType::EmitComponentEvent,
1149 "Emit the declared event from the component, or remove it from the component API",
1150 "Manual review required: public component APIs can intentionally keep stable events for external listeners.",
1151 ),
1152 suppress_line("// fallow-ignore-next-line unused-component-emit"),
1153 ];
1154 Self {
1155 emit,
1156 actions,
1157 introduced: None,
1158 }
1159 }
1160}
1161
1162#[derive(Debug, Clone, Serialize)]
1168#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1169pub struct UnusedSvelteEventFinding {
1170 #[serde(flatten)]
1172 pub event: UnusedSvelteEvent,
1173 pub actions: Vec<IssueAction>,
1176 #[serde(default, skip_serializing_if = "Option::is_none")]
1179 pub introduced: Option<AuditIntroduced>,
1180}
1181
1182impl UnusedSvelteEventFinding {
1183 #[must_use]
1186 pub fn with_actions(event: UnusedSvelteEvent) -> Self {
1187 let actions = vec![
1188 manual_framework_fix(
1189 FixActionType::WireSvelteEvent,
1190 "Add or forward a listener for this custom event, or remove the dispatch",
1191 "Manual review required: public Svelte component APIs can intentionally dispatch events for package consumers outside this project.",
1192 ),
1193 suppress_line("// fallow-ignore-next-line unused-svelte-event"),
1194 ];
1195 Self {
1196 event,
1197 actions,
1198 introduced: None,
1199 }
1200 }
1201}
1202
1203#[derive(Debug, Clone, Serialize)]
1209#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1210pub struct PropDrillingChainFinding {
1211 #[serde(flatten)]
1213 pub chain: PropDrillingChain,
1214 pub actions: Vec<IssueAction>,
1217 #[serde(default, skip_serializing_if = "Option::is_none")]
1220 pub introduced: Option<AuditIntroduced>,
1221}
1222
1223impl PropDrillingChainFinding {
1224 #[must_use]
1229 pub fn with_actions(chain: PropDrillingChain) -> Self {
1230 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1231 kind: SuppressLineKind::SuppressLine,
1232 auto_fixable: false,
1233 description: "Suppress with an inline comment above the source prop declaration"
1234 .to_string(),
1235 comment: "// fallow-ignore-next-line prop-drilling".to_string(),
1236 scope: None,
1237 })];
1238 Self {
1239 chain,
1240 actions,
1241 introduced: None,
1242 }
1243 }
1244}
1245
1246#[derive(Debug, Clone, Serialize)]
1252#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1253pub struct ThinWrapperFinding {
1254 #[serde(flatten)]
1256 pub wrapper: ThinWrapper,
1257 pub actions: Vec<IssueAction>,
1260 #[serde(default, skip_serializing_if = "Option::is_none")]
1263 pub introduced: Option<AuditIntroduced>,
1264}
1265
1266impl ThinWrapperFinding {
1267 #[must_use]
1271 pub fn with_actions(wrapper: ThinWrapper) -> Self {
1272 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1273 kind: SuppressLineKind::SuppressLine,
1274 auto_fixable: false,
1275 description: "Suppress with an inline comment above the component definition"
1276 .to_string(),
1277 comment: "// fallow-ignore-next-line thin-wrapper".to_string(),
1278 scope: None,
1279 })];
1280 Self {
1281 wrapper,
1282 actions,
1283 introduced: None,
1284 }
1285 }
1286}
1287
1288#[derive(Debug, Clone, Serialize)]
1296#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1297pub struct DuplicatePropShapeFinding {
1298 #[serde(flatten)]
1300 pub shape: DuplicatePropShape,
1301 pub actions: Vec<IssueAction>,
1304 #[serde(default, skip_serializing_if = "Option::is_none")]
1307 pub introduced: Option<AuditIntroduced>,
1308}
1309
1310impl DuplicatePropShapeFinding {
1311 #[must_use]
1318 pub fn with_actions(shape: DuplicatePropShape) -> Self {
1319 let actions = vec![
1320 IssueAction::SuppressLine(SuppressLineAction {
1321 kind: SuppressLineKind::SuppressLine,
1322 auto_fixable: false,
1323 description: "Three or more components share this exact prop shape. Extract one \
1324 shared `Props` type (or a base component) that every member reuses, \
1325 or keep them separate if a per-variant divergence is planned. \
1326 Suppress one member with an inline comment above the component \
1327 definition."
1328 .to_string(),
1329 comment: "// fallow-ignore-next-line duplicate-prop-shape".to_string(),
1330 scope: None,
1331 }),
1332 IssueAction::SuppressFile(SuppressFileAction {
1333 kind: SuppressFileKind::SuppressFile,
1334 auto_fixable: false,
1335 description: "Escape hatch: a file-level suppress silences this member but it \
1336 still appears in its siblings' `sharing_components` (the group is \
1337 real regardless of suppression)."
1338 .to_string(),
1339 comment: "// fallow-ignore-file duplicate-prop-shape".to_string(),
1340 }),
1341 ];
1342 Self {
1343 shape,
1344 actions,
1345 introduced: None,
1346 }
1347 }
1348}
1349
1350#[derive(Debug, Clone, Serialize)]
1355#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1356pub struct UnusedComponentInputFinding {
1357 #[serde(flatten)]
1359 pub input: UnusedComponentInput,
1360 pub actions: Vec<IssueAction>,
1363 #[serde(default, skip_serializing_if = "Option::is_none")]
1366 pub introduced: Option<AuditIntroduced>,
1367}
1368
1369impl UnusedComponentInputFinding {
1370 #[must_use]
1374 pub fn with_actions(input: UnusedComponentInput) -> Self {
1375 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1376 kind: SuppressLineKind::SuppressLine,
1377 auto_fixable: false,
1378 description: "Suppress with an inline comment above the line".to_string(),
1379 comment: "// fallow-ignore-next-line unused-component-input".to_string(),
1380 scope: None,
1381 })];
1382 Self {
1383 input,
1384 actions,
1385 introduced: None,
1386 }
1387 }
1388}
1389
1390#[derive(Debug, Clone, Serialize)]
1395#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1396pub struct UnusedComponentOutputFinding {
1397 #[serde(flatten)]
1399 pub output: UnusedComponentOutput,
1400 pub actions: Vec<IssueAction>,
1403 #[serde(default, skip_serializing_if = "Option::is_none")]
1406 pub introduced: Option<AuditIntroduced>,
1407}
1408
1409impl UnusedComponentOutputFinding {
1410 #[must_use]
1414 pub fn with_actions(output: UnusedComponentOutput) -> Self {
1415 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1416 kind: SuppressLineKind::SuppressLine,
1417 auto_fixable: false,
1418 description: "Suppress with an inline comment above the line".to_string(),
1419 comment: "// fallow-ignore-next-line unused-component-output".to_string(),
1420 scope: None,
1421 })];
1422 Self {
1423 output,
1424 actions,
1425 introduced: None,
1426 }
1427 }
1428}
1429
1430#[derive(Debug, Clone, Serialize)]
1436#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1437pub struct RouteCollisionFinding {
1438 #[serde(flatten)]
1440 pub collision: RouteCollision,
1441 pub actions: Vec<IssueAction>,
1444 #[serde(default, skip_serializing_if = "Option::is_none")]
1447 pub introduced: Option<AuditIntroduced>,
1448}
1449
1450impl RouteCollisionFinding {
1451 #[must_use]
1455 pub fn with_actions(collision: RouteCollision) -> Self {
1456 let actions = vec![
1457 IssueAction::Fix(FixAction {
1458 kind: FixActionType::ResolveRouteCollision,
1459 auto_fixable: false,
1460 description: "Two or more files resolve to the same URL. Move or merge one so \
1461 each URL has a single owner. Route groups `(name)` and parallel \
1462 slots `@name` are the only legal same-URL shapes."
1463 .to_string(),
1464 note: Some(
1465 "Next.js fails the build with \"You cannot have two parallel pages that \
1466 resolve to the same path\". See the sibling `conflicting_paths` array for \
1467 the other files that own this URL."
1468 .to_string(),
1469 ),
1470 available_in_catalogs: None,
1471 suggested_target: None,
1472 }),
1473 IssueAction::SuppressFile(SuppressFileAction {
1474 kind: SuppressFileKind::SuppressFile,
1475 auto_fixable: false,
1476 description: "Escape hatch only: a file-level suppress silences the finding but \
1477 does NOT make `next build` pass. Prefer moving or merging a file."
1478 .to_string(),
1479 comment: "// fallow-ignore-file route-collision".to_string(),
1480 }),
1481 ];
1482 Self {
1483 collision,
1484 actions,
1485 introduced: None,
1486 }
1487 }
1488}
1489
1490#[derive(Debug, Clone, Serialize)]
1495#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1496pub struct DynamicSegmentNameConflictFinding {
1497 #[serde(flatten)]
1499 pub conflict: DynamicSegmentNameConflict,
1500 pub actions: Vec<IssueAction>,
1503 #[serde(default, skip_serializing_if = "Option::is_none")]
1506 pub introduced: Option<AuditIntroduced>,
1507}
1508
1509impl DynamicSegmentNameConflictFinding {
1510 #[must_use]
1513 pub fn with_actions(conflict: DynamicSegmentNameConflict) -> Self {
1514 let actions = vec![
1515 IssueAction::Fix(FixAction {
1516 kind: FixActionType::ResolveDynamicSegmentNameConflict,
1517 auto_fixable: false,
1518 description: "Sibling dynamic segments at the same position use different param \
1519 names. Rename them to one consistent slug name (e.g. pick `[id]` \
1520 or `[slug]` for both)."
1521 .to_string(),
1522 note: Some(
1523 "Next.js throws \"You cannot use different slug names for the same dynamic \
1524 path\" at dev / runtime when the position is hit; `next build` does not \
1525 catch it. See the sibling `conflicting_segments` array."
1526 .to_string(),
1527 ),
1528 available_in_catalogs: None,
1529 suggested_target: None,
1530 }),
1531 IssueAction::SuppressFile(SuppressFileAction {
1532 kind: SuppressFileKind::SuppressFile,
1533 auto_fixable: false,
1534 description: "Escape hatch only: a file-level suppress silences the finding but \
1535 does NOT stop Next.js from throwing at dev / runtime. Prefer \
1536 renaming the segments."
1537 .to_string(),
1538 comment: "// fallow-ignore-file dynamic-segment-name-conflict".to_string(),
1539 }),
1540 ];
1541 Self {
1542 conflict,
1543 actions,
1544 introduced: None,
1545 }
1546 }
1547}
1548
1549#[derive(Debug, Clone, Serialize)]
1552#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1553pub struct UnusedEnumMemberFinding {
1554 #[serde(flatten)]
1556 pub member: UnusedMember,
1557 pub actions: Vec<IssueAction>,
1560 #[serde(default, skip_serializing_if = "Option::is_none")]
1563 pub introduced: Option<AuditIntroduced>,
1564}
1565
1566impl UnusedEnumMemberFinding {
1567 #[must_use]
1569 pub fn with_actions(member: UnusedMember) -> Self {
1570 let actions = vec![
1571 IssueAction::Fix(FixAction {
1572 kind: FixActionType::RemoveEnumMember,
1573 auto_fixable: true,
1574 description: "Remove this enum member".to_string(),
1575 note: None,
1576 available_in_catalogs: None,
1577 suggested_target: None,
1578 }),
1579 IssueAction::SuppressLine(SuppressLineAction {
1580 kind: SuppressLineKind::SuppressLine,
1581 auto_fixable: false,
1582 description: "Suppress with an inline comment above the line".to_string(),
1583 comment: "// fallow-ignore-next-line unused-enum-member".to_string(),
1584 scope: None,
1585 }),
1586 ];
1587 Self {
1588 member,
1589 actions,
1590 introduced: None,
1591 }
1592 }
1593}
1594
1595#[derive(Debug, Clone, Serialize)]
1600#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1601pub struct UnusedClassMemberFinding {
1602 #[serde(flatten)]
1604 pub member: UnusedMember,
1605 pub actions: Vec<IssueAction>,
1608 #[serde(default, skip_serializing_if = "Option::is_none")]
1611 pub introduced: Option<AuditIntroduced>,
1612}
1613
1614impl UnusedClassMemberFinding {
1615 #[must_use]
1620 pub fn with_actions(member: UnusedMember) -> Self {
1621 let actions = vec![
1622 IssueAction::Fix(FixAction {
1623 kind: FixActionType::RemoveClassMember,
1624 auto_fixable: false,
1625 description: "Remove this class member".to_string(),
1626 note: Some(
1627 "Class member may be used via dependency injection or decorators".to_string(),
1628 ),
1629 available_in_catalogs: None,
1630 suggested_target: None,
1631 }),
1632 IssueAction::SuppressLine(SuppressLineAction {
1633 kind: SuppressLineKind::SuppressLine,
1634 auto_fixable: false,
1635 description: "Suppress with an inline comment above the line".to_string(),
1636 comment: "// fallow-ignore-next-line unused-class-member".to_string(),
1637 scope: None,
1638 }),
1639 ];
1640 Self {
1641 member,
1642 actions,
1643 introduced: None,
1644 }
1645 }
1646}
1647
1648#[derive(Debug, Clone, Serialize)]
1657#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1658pub struct UnusedStoreMemberFinding {
1659 #[serde(flatten)]
1661 pub member: UnusedMember,
1662 pub actions: Vec<IssueAction>,
1665 #[serde(default, skip_serializing_if = "Option::is_none")]
1668 pub introduced: Option<AuditIntroduced>,
1669}
1670
1671impl UnusedStoreMemberFinding {
1672 #[must_use]
1676 pub fn with_actions(member: UnusedMember) -> Self {
1677 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1678 kind: SuppressLineKind::SuppressLine,
1679 auto_fixable: false,
1680 description: "Suppress with an inline comment above the line".to_string(),
1681 comment: "// fallow-ignore-next-line unused-store-member".to_string(),
1682 scope: None,
1683 })];
1684 Self {
1685 member,
1686 actions,
1687 introduced: None,
1688 }
1689 }
1690}
1691
1692fn build_unused_dependency_actions(
1703 dep: &UnusedDependency,
1704 package_json_location: &str,
1705 suppress_issue_kind: &str,
1706) -> Vec<IssueAction> {
1707 let mut actions = Vec::with_capacity(2);
1708 let cross_workspace = !dep.used_in_workspaces.is_empty();
1709 actions.push(if cross_workspace {
1710 IssueAction::Fix(FixAction {
1711 kind: FixActionType::MoveDependency,
1712 auto_fixable: false,
1713 description: "Move this dependency to the workspace package.json that imports it"
1714 .to_string(),
1715 note: Some(
1716 "fallow fix will not remove dependencies that are imported by another workspace"
1717 .to_string(),
1718 ),
1719 available_in_catalogs: None,
1720 suggested_target: None,
1721 })
1722 } else {
1723 IssueAction::Fix(FixAction {
1724 kind: FixActionType::RemoveDependency,
1725 auto_fixable: true,
1726 description: format!("Remove from {package_json_location} in package.json"),
1727 note: None,
1728 available_in_catalogs: None,
1729 suggested_target: None,
1730 })
1731 });
1732 actions.push(build_ignore_dependencies_suppress_action(
1733 &dep.package_name,
1734 suppress_issue_kind,
1735 ));
1736 actions
1737}
1738
1739fn build_ignore_dependencies_suppress_action(
1747 package_name: &str,
1748 _suppress_issue_kind: &str,
1749) -> IssueAction {
1750 IssueAction::AddToConfig(AddToConfigAction {
1751 kind: AddToConfigKind::AddToConfig,
1752 auto_fixable: false,
1753 description: format!("Add \"{package_name}\" to ignoreDependencies in fallow config"),
1754 config_key: "ignoreDependencies".to_string(),
1755 value: AddToConfigValue::Scalar(package_name.to_string()),
1756 value_schema: Some(
1757 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items"
1758 .to_string(),
1759 ),
1760 })
1761}
1762
1763#[derive(Debug, Clone, Serialize)]
1769#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1770pub struct UnusedDependencyFinding {
1771 #[serde(flatten)]
1773 pub dep: UnusedDependency,
1774 pub actions: Vec<IssueAction>,
1777 #[serde(default, skip_serializing_if = "Option::is_none")]
1780 pub introduced: Option<AuditIntroduced>,
1781}
1782
1783impl UnusedDependencyFinding {
1784 #[must_use]
1787 pub fn with_actions(dep: UnusedDependency) -> Self {
1788 let actions = build_unused_dependency_actions(&dep, "dependencies", "unused-dependency");
1789 Self {
1790 dep,
1791 actions,
1792 introduced: None,
1793 }
1794 }
1795}
1796
1797#[derive(Debug, Clone, Serialize)]
1803#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1804pub struct UnusedDevDependencyFinding {
1805 #[serde(flatten)]
1807 pub dep: UnusedDependency,
1808 pub actions: Vec<IssueAction>,
1811 #[serde(default, skip_serializing_if = "Option::is_none")]
1814 pub introduced: Option<AuditIntroduced>,
1815}
1816
1817impl UnusedDevDependencyFinding {
1818 #[must_use]
1820 pub fn with_actions(dep: UnusedDependency) -> Self {
1821 let actions =
1822 build_unused_dependency_actions(&dep, "devDependencies", "unused-dev-dependency");
1823 Self {
1824 dep,
1825 actions,
1826 introduced: None,
1827 }
1828 }
1829}
1830
1831#[derive(Debug, Clone, Serialize)]
1837#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1838pub struct UnusedOptionalDependencyFinding {
1839 #[serde(flatten)]
1841 pub dep: UnusedDependency,
1842 pub actions: Vec<IssueAction>,
1845 #[serde(default, skip_serializing_if = "Option::is_none")]
1848 pub introduced: Option<AuditIntroduced>,
1849}
1850
1851impl UnusedOptionalDependencyFinding {
1852 #[must_use]
1854 pub fn with_actions(dep: UnusedDependency) -> Self {
1855 let actions =
1856 build_unused_dependency_actions(&dep, "optionalDependencies", "unused-dependency");
1857 Self {
1858 dep,
1859 actions,
1860 introduced: None,
1861 }
1862 }
1863}
1864
1865#[derive(Debug, Clone, Serialize)]
1869#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1870pub struct UnlistedDependencyFinding {
1871 #[serde(flatten)]
1873 pub dep: UnlistedDependency,
1874 pub actions: Vec<IssueAction>,
1877 #[serde(default, skip_serializing_if = "Option::is_none")]
1880 pub introduced: Option<AuditIntroduced>,
1881}
1882
1883impl UnlistedDependencyFinding {
1884 #[must_use]
1886 pub fn with_actions(dep: UnlistedDependency) -> Self {
1887 let actions = vec![
1888 IssueAction::Fix(FixAction {
1889 kind: FixActionType::InstallDependency,
1890 auto_fixable: false,
1891 description: "Add this package to dependencies in package.json".to_string(),
1892 note: Some(
1893 "Verify this package should be a direct dependency before adding".to_string(),
1894 ),
1895 available_in_catalogs: None,
1896 suggested_target: None,
1897 }),
1898 build_ignore_dependencies_suppress_action(&dep.package_name, "unlisted-dependency"),
1899 ];
1900 Self {
1901 dep,
1902 actions,
1903 introduced: None,
1904 }
1905 }
1906}
1907
1908#[derive(Debug, Clone, Serialize)]
1912#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1913pub struct TypeOnlyDependencyFinding {
1914 #[serde(flatten)]
1916 pub dep: TypeOnlyDependency,
1917 pub actions: Vec<IssueAction>,
1920 #[serde(default, skip_serializing_if = "Option::is_none")]
1923 pub introduced: Option<AuditIntroduced>,
1924}
1925
1926impl TypeOnlyDependencyFinding {
1927 #[must_use]
1929 pub fn with_actions(dep: TypeOnlyDependency) -> Self {
1930 let actions = vec![
1931 IssueAction::Fix(FixAction {
1932 kind: FixActionType::MoveToDev,
1933 auto_fixable: false,
1934 description: "Move to devDependencies (only type imports are used)".to_string(),
1935 note: Some(
1936 "Type imports are erased at runtime so this dependency is not needed in production"
1937 .to_string(),
1938 ),
1939 available_in_catalogs: None,
1940 suggested_target: None,
1941 }),
1942 build_ignore_dependencies_suppress_action(&dep.package_name, "type-only-dependency"),
1943 ];
1944 Self {
1945 dep,
1946 actions,
1947 introduced: None,
1948 }
1949 }
1950}
1951
1952#[derive(Debug, Clone, Serialize)]
1956#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1957pub struct TestOnlyDependencyFinding {
1958 #[serde(flatten)]
1960 pub dep: TestOnlyDependency,
1961 pub actions: Vec<IssueAction>,
1964 #[serde(default, skip_serializing_if = "Option::is_none")]
1967 pub introduced: Option<AuditIntroduced>,
1968}
1969
1970impl TestOnlyDependencyFinding {
1971 #[must_use]
1973 pub fn with_actions(dep: TestOnlyDependency) -> Self {
1974 let actions = vec![
1975 IssueAction::Fix(FixAction {
1976 kind: FixActionType::MoveToDev,
1977 auto_fixable: false,
1978 description: "Move to devDependencies (only test files import this)".to_string(),
1979 note: Some(
1980 "Only test files import this package so it does not need to be a production dependency"
1981 .to_string(),
1982 ),
1983 available_in_catalogs: None,
1984 suggested_target: None,
1985 }),
1986 build_ignore_dependencies_suppress_action(&dep.package_name, "test-only-dependency"),
1987 ];
1988 Self {
1989 dep,
1990 actions,
1991 introduced: None,
1992 }
1993 }
1994}
1995
1996#[derive(Debug, Clone, Serialize)]
2001#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2002pub struct DevDependencyInProductionFinding {
2003 #[serde(flatten)]
2005 pub dep: DevDependencyInProduction,
2006 pub actions: Vec<IssueAction>,
2009 #[serde(default, skip_serializing_if = "Option::is_none")]
2012 pub introduced: Option<AuditIntroduced>,
2013}
2014
2015impl DevDependencyInProductionFinding {
2016 #[must_use]
2018 pub fn with_actions(dep: DevDependencyInProduction) -> Self {
2019 let actions = vec![
2020 IssueAction::Fix(FixAction {
2021 kind: FixActionType::MoveToProd,
2022 auto_fixable: false,
2023 description: "Move to dependencies (production code imports this at runtime)"
2024 .to_string(),
2025 note: Some(
2026 "A production-only install (`pnpm install --prod`) omits devDependencies, so this import would break at runtime"
2027 .to_string(),
2028 ),
2029 available_in_catalogs: None,
2030 suggested_target: None,
2031 }),
2032 build_ignore_dependencies_suppress_action(
2033 &dep.package_name,
2034 "dev-dependency-in-production",
2035 ),
2036 ];
2037 Self {
2038 dep,
2039 actions,
2040 introduced: None,
2041 }
2042 }
2043}
2044
2045#[derive(Debug, Clone, Serialize)]
2066#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2067pub struct DuplicateExportFinding {
2068 #[serde(flatten)]
2070 pub export: DuplicateExport,
2071 pub actions: Vec<IssueAction>,
2074 #[serde(default, skip_serializing_if = "Option::is_none")]
2077 pub introduced: Option<AuditIntroduced>,
2078}
2079
2080impl DuplicateExportFinding {
2081 #[must_use]
2090 pub fn with_actions(export: DuplicateExport) -> Self {
2091 let mut actions: Vec<IssueAction> = Vec::with_capacity(3);
2092
2093 if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
2094 actions.push(IssueAction::AddToConfig(AddToConfigAction {
2095 kind: AddToConfigKind::AddToConfig,
2096 auto_fixable: false,
2097 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(),
2098 config_key: "ignoreExports".to_string(),
2099 value: AddToConfigValue::ExportsRules(rules),
2100 value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
2101 }));
2102 }
2103
2104 actions.push(IssueAction::Fix(FixAction {
2105 kind: FixActionType::RemoveDuplicate,
2106 auto_fixable: false,
2107 description: "Keep one canonical export location and remove the others".to_string(),
2108 note: Some(NAMESPACE_BARREL_HINT.to_string()),
2109 available_in_catalogs: None,
2110 suggested_target: None,
2111 }));
2112
2113 actions.push(IssueAction::SuppressLine(SuppressLineAction {
2114 kind: SuppressLineKind::SuppressLine,
2115 auto_fixable: false,
2116 description: "Suppress with an inline comment above the line".to_string(),
2117 comment: "// fallow-ignore-next-line duplicate-export".to_string(),
2118 scope: Some(SuppressLineScope::PerLocation),
2119 }));
2120
2121 Self {
2122 export,
2123 actions,
2124 introduced: None,
2125 }
2126 }
2127
2128 pub fn set_config_fixable(&mut self, fixable: bool) {
2134 if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
2135 action.auto_fixable = fixable;
2136 }
2137 }
2138}
2139
2140fn build_duplicate_exports_ignore_rules(
2144 export: &DuplicateExport,
2145) -> Option<Vec<IgnoreExportsRule>> {
2146 let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
2147 for loc in &export.locations {
2148 let path = loc.path.to_string_lossy().replace('\\', "/");
2156 if path.is_empty() {
2157 continue;
2158 }
2159 if entries.iter().any(|existing| existing.file == path) {
2160 continue;
2161 }
2162 entries.push(IgnoreExportsRule {
2163 file: path,
2164 exports: vec!["*".to_string()],
2165 });
2166 }
2167 if entries.is_empty() {
2168 None
2169 } else {
2170 Some(entries)
2171 }
2172}
2173
2174#[derive(Debug, Clone, Serialize)]
2178#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2179pub struct UnusedCatalogEntryFinding {
2180 #[serde(flatten)]
2182 pub entry: UnusedCatalogEntry,
2183 pub actions: Vec<IssueAction>,
2185 #[serde(default, skip_serializing_if = "Option::is_none")]
2188 pub introduced: Option<AuditIntroduced>,
2189}
2190
2191impl UnusedCatalogEntryFinding {
2192 #[must_use]
2197 pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
2198 let is_pnpm_source = is_pnpm_catalog_source(&entry.path);
2199 let auto_fixable = entry.hardcoded_consumers.is_empty() && is_pnpm_source;
2200 let note = if is_pnpm_source {
2201 Some(
2202 "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing"
2203 .to_string(),
2204 )
2205 } else {
2206 Some(
2207 "fallow fix only edits pnpm-workspace.yaml catalog entries. Edit Bun package.json catalogs manually."
2208 .to_string(),
2209 )
2210 };
2211 let mut actions = vec![IssueAction::Fix(FixAction {
2212 kind: FixActionType::RemoveCatalogEntry,
2213 auto_fixable,
2214 description: if is_pnpm_source {
2215 "Remove the entry from pnpm-workspace.yaml".to_string()
2216 } else {
2217 "Remove the entry from the catalog source file manually".to_string()
2218 },
2219 note,
2220 available_in_catalogs: None,
2221 suggested_target: None,
2222 })];
2223 if is_pnpm_source {
2224 actions.push(IssueAction::SuppressLine(SuppressLineAction {
2225 kind: SuppressLineKind::SuppressLine,
2226 auto_fixable: false,
2227 description: "Suppress with a YAML comment above the line".to_string(),
2228 comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
2229 scope: None,
2230 }));
2231 }
2232 Self {
2233 entry,
2234 actions,
2235 introduced: None,
2236 }
2237 }
2238}
2239
2240#[derive(Debug, Clone, Serialize)]
2244#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2245pub struct EmptyCatalogGroupFinding {
2246 #[serde(flatten)]
2248 pub group: EmptyCatalogGroup,
2249 pub actions: Vec<IssueAction>,
2251 #[serde(default, skip_serializing_if = "Option::is_none")]
2254 pub introduced: Option<AuditIntroduced>,
2255}
2256
2257impl EmptyCatalogGroupFinding {
2258 #[must_use]
2260 pub fn with_actions(group: EmptyCatalogGroup) -> Self {
2261 let auto_fixable = is_pnpm_catalog_source(&group.path);
2262 let mut actions = vec![IssueAction::Fix(FixAction {
2263 kind: FixActionType::RemoveEmptyCatalogGroup,
2264 auto_fixable,
2265 description: if auto_fixable {
2266 "Remove the empty named catalog group from pnpm-workspace.yaml".to_string()
2267 } else {
2268 "Remove the empty named catalog group from the catalog source file manually"
2269 .to_string()
2270 },
2271 note: Some(if auto_fixable {
2272 "Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
2273 .to_string()
2274 } else {
2275 "fallow fix only edits pnpm-workspace.yaml catalog groups. Edit Bun package.json catalogs manually."
2276 .to_string()
2277 }),
2278 available_in_catalogs: None,
2279 suggested_target: None,
2280 })];
2281 if auto_fixable {
2282 actions.push(IssueAction::SuppressLine(SuppressLineAction {
2283 kind: SuppressLineKind::SuppressLine,
2284 auto_fixable: false,
2285 description: "Suppress with a YAML comment above the line".to_string(),
2286 comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
2287 scope: None,
2288 }));
2289 }
2290 Self {
2291 group,
2292 actions,
2293 introduced: None,
2294 }
2295 }
2296}
2297
2298fn is_pnpm_catalog_source(path: &Path) -> bool {
2299 path == Path::new(PNPM_WORKSPACE_FILE)
2300}
2301
2302#[derive(Debug, Clone, Serialize)]
2310#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2311pub struct UnresolvedCatalogReferenceFinding {
2312 #[serde(flatten)]
2314 pub reference: UnresolvedCatalogReference,
2315 pub actions: Vec<IssueAction>,
2318 #[serde(default, skip_serializing_if = "Option::is_none")]
2321 pub introduced: Option<AuditIntroduced>,
2322}
2323
2324impl UnresolvedCatalogReferenceFinding {
2325 #[must_use]
2329 pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
2330 let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
2335 let primary = catalog_reference_primary_action(&reference);
2336 let fallback = remove_catalog_reference_action();
2337 let suppress = suppress_catalog_reference_action(&reference, consumer_path);
2338
2339 Self {
2340 reference,
2341 actions: vec![primary, fallback, suppress],
2342 introduced: None,
2343 }
2344 }
2345}
2346
2347fn catalog_reference_primary_action(reference: &UnresolvedCatalogReference) -> IssueAction {
2348 if reference.available_in_catalogs.is_empty() {
2349 return IssueAction::Fix(FixAction {
2350 kind: FixActionType::AddCatalogEntry,
2351 auto_fixable: false,
2352 description: format!(
2353 "Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
2354 reference.entry_name, reference.catalog_name
2355 ),
2356 note: Some(
2357 "Pin a version that satisfies the consumer's import; no other catalog declares this package today"
2358 .to_string(),
2359 ),
2360 available_in_catalogs: None,
2361 suggested_target: None,
2362 });
2363 }
2364
2365 let available = reference.available_in_catalogs.clone();
2366 let suggested_target = (available.len() == 1).then(|| available[0].clone());
2367 IssueAction::Fix(FixAction {
2368 kind: FixActionType::UpdateCatalogReference,
2369 auto_fixable: false,
2370 description: format!(
2371 "Switch the reference from `catalog:{}` to a catalog that declares `{}`",
2372 reference.catalog_name, reference.entry_name
2373 ),
2374 note: None,
2375 available_in_catalogs: Some(available),
2376 suggested_target,
2377 })
2378}
2379
2380fn remove_catalog_reference_action() -> IssueAction {
2381 IssueAction::Fix(FixAction {
2382 kind: FixActionType::RemoveCatalogReference,
2383 auto_fixable: false,
2384 description: "Remove the catalog reference and pin a hardcoded version in package.json"
2385 .to_string(),
2386 note: Some(
2387 "Use only when neither another catalog declares the package nor the named catalog should grow to include it"
2388 .to_string(),
2389 ),
2390 available_in_catalogs: None,
2391 suggested_target: None,
2392 })
2393}
2394
2395fn suppress_catalog_reference_action(
2396 reference: &UnresolvedCatalogReference,
2397 consumer_path: String,
2398) -> IssueAction {
2399 let mut suppress_value = serde_json::Map::new();
2400 suppress_value.insert(
2401 "package".to_string(),
2402 serde_json::Value::String(reference.entry_name.clone()),
2403 );
2404 suppress_value.insert(
2405 "catalog".to_string(),
2406 serde_json::Value::String(reference.catalog_name.clone()),
2407 );
2408 suppress_value.insert(
2409 "consumer".to_string(),
2410 serde_json::Value::String(consumer_path),
2411 );
2412 IssueAction::AddToConfig(AddToConfigAction {
2413 kind: AddToConfigKind::AddToConfig,
2414 auto_fixable: false,
2415 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(),
2416 config_key: "ignoreCatalogReferences".to_string(),
2417 value: AddToConfigValue::RuleObject(suppress_value),
2418 value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
2419 })
2420}
2421
2422#[derive(Debug, Clone, Serialize)]
2427#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2428pub struct UnusedDependencyOverrideFinding {
2429 #[serde(flatten)]
2431 pub entry: UnusedDependencyOverride,
2432 pub actions: Vec<IssueAction>,
2434 #[serde(default, skip_serializing_if = "Option::is_none")]
2437 pub introduced: Option<AuditIntroduced>,
2438}
2439
2440impl UnusedDependencyOverrideFinding {
2441 #[must_use]
2443 pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
2444 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
2445 actions.push(IssueAction::Fix(FixAction {
2446 kind: FixActionType::RemoveDependencyOverride,
2447 auto_fixable: false,
2448 description: "Remove the override entry from pnpm-workspace.yaml or pnpm.overrides"
2449 .to_string(),
2450 note: Some(
2451 "Conservative static check; verify against `pnpm install --frozen-lockfile` before removing in case the override targets a transitive dependency (CVE-fix pattern)"
2452 .to_string(),
2453 ),
2454 available_in_catalogs: None,
2455 suggested_target: None,
2456 }));
2457
2458 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
2459 Some(&entry.target_package),
2460 &entry.raw_key,
2461 entry.source,
2462 ) {
2463 actions.push(suppress);
2464 }
2465
2466 Self {
2467 entry,
2468 actions,
2469 introduced: None,
2470 }
2471 }
2472}
2473
2474#[derive(Debug, Clone, Serialize)]
2480#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2481pub struct MisconfiguredDependencyOverrideFinding {
2482 #[serde(flatten)]
2484 pub entry: MisconfiguredDependencyOverride,
2485 pub actions: Vec<IssueAction>,
2487 #[serde(default, skip_serializing_if = "Option::is_none")]
2490 pub introduced: Option<AuditIntroduced>,
2491}
2492
2493impl MisconfiguredDependencyOverrideFinding {
2494 #[must_use]
2499 pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
2500 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
2501 actions.push(IssueAction::Fix(FixAction {
2502 kind: FixActionType::FixDependencyOverride,
2503 auto_fixable: false,
2504 description:
2505 "Fix the override key or value: pnpm refuses to honor entries with an unparsable key or empty value"
2506 .to_string(),
2507 note: Some(
2508 "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`."
2509 .to_string(),
2510 ),
2511 available_in_catalogs: None,
2512 suggested_target: None,
2513 }));
2514
2515 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
2516 entry.target_package.as_deref(),
2517 &entry.raw_key,
2518 entry.source,
2519 ) {
2520 actions.push(suppress);
2521 }
2522
2523 Self {
2524 entry,
2525 actions,
2526 introduced: None,
2527 }
2528 }
2529}
2530
2531fn build_ignore_dependency_overrides_suppress(
2536 target_package: Option<&str>,
2537 raw_key: &str,
2538 source: DependencyOverrideSource,
2539) -> Option<IssueAction> {
2540 let package = target_package
2541 .filter(|s| !s.is_empty())
2542 .or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
2543 .to_string();
2544 let mut value = serde_json::Map::new();
2545 value.insert("package".to_string(), serde_json::Value::String(package));
2546 value.insert(
2547 "source".to_string(),
2548 serde_json::Value::String(source.as_label().to_string()),
2549 );
2550 Some(IssueAction::AddToConfig(AddToConfigAction {
2551 kind: AddToConfigKind::AddToConfig,
2552 auto_fixable: false,
2553 description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
2554 config_key: "ignoreDependencyOverrides".to_string(),
2555 value: AddToConfigValue::RuleObject(value),
2556 value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
2557 }))
2558}
2559
2560#[cfg(test)]
2570mod position_0_invariants {
2571 use super::*;
2572 use crate::output::FixActionType;
2573 use crate::results::{DependencyOverrideSource, DuplicateLocation};
2574 use std::path::PathBuf;
2575
2576 fn action_type(action: &IssueAction) -> &'static str {
2581 match action {
2582 IssueAction::Fix(fix) => match fix.kind {
2583 FixActionType::RemoveExport => "remove-export",
2584 FixActionType::DeleteFile => "delete-file",
2585 FixActionType::RemoveDependency => "remove-dependency",
2586 FixActionType::MoveDependency => "move-dependency",
2587 FixActionType::RemoveEnumMember => "remove-enum-member",
2588 FixActionType::RemoveClassMember => "remove-class-member",
2589 FixActionType::ResolveImport => "resolve-import",
2590 FixActionType::InstallDependency => "install-dependency",
2591 FixActionType::RemoveDuplicate => "remove-duplicate",
2592 FixActionType::MoveToDev => "move-to-dev",
2593 FixActionType::MoveToProd => "move-to-prod",
2594 FixActionType::RefactorCycle => "refactor-cycle",
2595 FixActionType::RefactorReExportCycle => "refactor-re-export-cycle",
2596 FixActionType::RefactorBoundary => "refactor-boundary",
2597 FixActionType::ExportType => "export-type",
2598 FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
2599 FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
2600 FixActionType::UpdateCatalogReference => "update-catalog-reference",
2601 FixActionType::AddCatalogEntry => "add-catalog-entry",
2602 FixActionType::RemoveCatalogReference => "remove-catalog-reference",
2603 FixActionType::RemoveDependencyOverride => "remove-dependency-override",
2604 FixActionType::FixDependencyOverride => "fix-dependency-override",
2605 FixActionType::ResolvePolicyViolation => "resolve-policy-violation",
2606 FixActionType::MoveToServerModule => "move-to-server-module",
2607 FixActionType::SplitMixedBarrel => "split-mixed-barrel",
2608 FixActionType::HoistDirective => "hoist-directive",
2609 FixActionType::WireServerAction => "wire-server-action",
2610 FixActionType::ProvideInject => "provide-inject",
2611 FixActionType::UseLoadData => "use-load-data",
2612 FixActionType::RenderComponent => "render-component",
2613 FixActionType::UseComponentProp => "use-component-prop",
2614 FixActionType::EmitComponentEvent => "emit-component-event",
2615 FixActionType::WireSvelteEvent => "wire-svelte-event",
2616 FixActionType::ResolveRouteCollision => "resolve-route-collision",
2617 FixActionType::ResolveDynamicSegmentNameConflict => {
2618 "resolve-dynamic-segment-name-conflict"
2619 }
2620 FixActionType::AddSuppressionReason => "add-suppression-reason",
2621 FixActionType::RemoveStaleSuppression => "remove-stale-suppression",
2622 },
2623 IssueAction::SuppressLine(_) => "suppress-line",
2624 IssueAction::SuppressFile(_) => "suppress-file",
2625 IssueAction::AddToConfig(_) => "add-to-config",
2626 }
2627 }
2628
2629 fn assert_manual_fix_then_suppress(
2630 actions: &[IssueAction],
2631 primary_type: &str,
2632 suppress_comment: &str,
2633 ) {
2634 assert_eq!(actions.len(), 2);
2635 assert_eq!(action_type(&actions[0]), primary_type);
2636 let IssueAction::Fix(primary) = &actions[0] else {
2637 panic!("position-0 should be a manual fix action");
2638 };
2639 assert!(!primary.auto_fixable);
2640 assert!(primary.note.is_some());
2641 assert_eq!(action_type(&actions[1]), "suppress-line");
2642 let IssueAction::SuppressLine(suppress) = &actions[1] else {
2643 panic!("position-1 should be a suppress-line action");
2644 };
2645 assert_eq!(suppress.comment, suppress_comment);
2646 }
2647
2648 #[test]
2649 fn pnpm_catalog_entry_action_is_auto_fixable() {
2650 let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
2651 entry_name: "unused".to_string(),
2652 catalog_name: "default".to_string(),
2653 path: PathBuf::from("pnpm-workspace.yaml"),
2654 line: 3,
2655 hardcoded_consumers: vec![],
2656 });
2657
2658 let IssueAction::Fix(fix) = &finding.actions[0] else {
2659 panic!("position-0 should be a fix action");
2660 };
2661 assert!(fix.auto_fixable);
2662 assert_eq!(finding.actions.len(), 2);
2663 assert_eq!(action_type(&finding.actions[1]), "suppress-line");
2664 }
2665
2666 #[test]
2667 fn bun_package_json_catalog_entry_action_is_manual_only() {
2668 let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
2669 entry_name: "unused".to_string(),
2670 catalog_name: "default".to_string(),
2671 path: PathBuf::from("package.json"),
2672 line: 4,
2673 hardcoded_consumers: vec![],
2674 });
2675
2676 let IssueAction::Fix(fix) = &finding.actions[0] else {
2677 panic!("position-0 should be a fix action");
2678 };
2679 assert!(!fix.auto_fixable);
2680 assert!(fix.description.contains("manually"));
2681 assert_eq!(finding.actions.len(), 1);
2682 }
2683
2684 #[test]
2685 fn bun_package_json_empty_catalog_group_action_is_manual_only() {
2686 let finding = EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
2687 catalog_name: "empty".to_string(),
2688 path: PathBuf::from("package.json"),
2689 line: 4,
2690 });
2691
2692 let IssueAction::Fix(fix) = &finding.actions[0] else {
2693 panic!("position-0 should be a fix action");
2694 };
2695 assert!(!fix.auto_fixable);
2696 assert!(fix.description.contains("manually"));
2697 assert_eq!(finding.actions.len(), 1);
2698 }
2699
2700 #[test]
2701 fn unprovided_inject_primary_action_is_provide_inject() {
2702 let finding = UnprovidedInjectFinding::with_actions(UnprovidedInject {
2703 path: PathBuf::from("src/context.ts"),
2704 key_name: "userKey".to_string(),
2705 framework: "svelte".to_string(),
2706 line: 7,
2707 col: 12,
2708 });
2709
2710 assert_manual_fix_then_suppress(
2711 &finding.actions,
2712 "provide-inject",
2713 "// fallow-ignore-next-line unprovided-inject",
2714 );
2715 }
2716
2717 #[test]
2718 fn unused_server_action_primary_action_is_wire_server_action() {
2719 let finding = UnusedServerActionFinding::with_actions(UnusedServerAction {
2720 path: PathBuf::from("app/actions.ts"),
2721 action_name: "saveDraft".to_string(),
2722 line: 3,
2723 col: 13,
2724 });
2725
2726 assert_manual_fix_then_suppress(
2727 &finding.actions,
2728 "wire-server-action",
2729 "// fallow-ignore-next-line unused-server-action",
2730 );
2731 }
2732
2733 #[test]
2734 fn unused_load_data_key_primary_action_is_use_load_data() {
2735 let finding = UnusedLoadDataKeyFinding::with_actions(UnusedLoadDataKey {
2736 path: PathBuf::from("src/routes/+page.server.ts"),
2737 key_name: "profile".to_string(),
2738 line: 12,
2739 col: 6,
2740 route_dir: Some("src/routes".to_string()),
2741 });
2742
2743 assert_manual_fix_then_suppress(
2744 &finding.actions,
2745 "use-load-data",
2746 "// fallow-ignore-next-line unused-load-data-key",
2747 );
2748 }
2749
2750 #[test]
2751 fn unrendered_component_primary_action_is_render_component() {
2752 let finding = UnrenderedComponentFinding::with_actions(UnrenderedComponent {
2753 path: PathBuf::from("src/components/EmptyState.vue"),
2754 component_name: "EmptyState".to_string(),
2755 framework: "vue".to_string(),
2756 reachable_via: None,
2757 line: 1,
2758 col: 0,
2759 });
2760
2761 assert_manual_fix_then_suppress(
2762 &finding.actions,
2763 "render-component",
2764 "// fallow-ignore-next-line unrendered-component",
2765 );
2766 }
2767
2768 #[test]
2769 fn unused_component_prop_primary_action_is_use_component_prop() {
2770 let finding = UnusedComponentPropFinding::with_actions(UnusedComponentProp {
2771 path: PathBuf::from("src/components/Card.vue"),
2772 component_name: "Card".to_string(),
2773 prop_name: "variant".to_string(),
2774 line: 5,
2775 col: 10,
2776 });
2777
2778 assert_manual_fix_then_suppress(
2779 &finding.actions,
2780 "use-component-prop",
2781 "// fallow-ignore-next-line unused-component-prop",
2782 );
2783 }
2784
2785 #[test]
2786 fn unused_component_emit_primary_action_is_emit_component_event() {
2787 let finding = UnusedComponentEmitFinding::with_actions(UnusedComponentEmit {
2788 path: PathBuf::from("src/components/Picker.vue"),
2789 component_name: "Picker".to_string(),
2790 emit_name: "focus".to_string(),
2791 line: 6,
2792 col: 14,
2793 });
2794
2795 assert_manual_fix_then_suppress(
2796 &finding.actions,
2797 "emit-component-event",
2798 "// fallow-ignore-next-line unused-component-emit",
2799 );
2800 }
2801
2802 #[test]
2803 fn unused_svelte_event_primary_action_is_wire_svelte_event() {
2804 let finding = UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
2805 path: PathBuf::from("src/Dialog.svelte"),
2806 component_name: "Dialog".to_string(),
2807 event_name: "closed".to_string(),
2808 line: 19,
2809 col: 8,
2810 });
2811
2812 assert_manual_fix_then_suppress(
2813 &finding.actions,
2814 "wire-svelte-event",
2815 "// fallow-ignore-next-line unused-svelte-event",
2816 );
2817 }
2818
2819 #[test]
2820 fn unresolved_import_actions_include_ignore_unresolved_imports_config_suppress() {
2821 let inner = UnresolvedImport {
2822 specifier: "@example/icons".to_string(),
2823 path: PathBuf::from("src/index.ts"),
2824 line: 4,
2825 col: 12,
2826 specifier_col: 18,
2827 };
2828 let finding = UnresolvedImportFinding::with_actions(inner);
2829
2830 assert_eq!(action_type(&finding.actions[0]), "resolve-import");
2831 assert_eq!(action_type(&finding.actions[1]), "add-to-config");
2832 let IssueAction::AddToConfig(action) = &finding.actions[1] else {
2833 panic!("position-1 should be AddToConfig");
2834 };
2835 assert!(!action.auto_fixable);
2836 assert_eq!(action.config_key, "ignoreUnresolvedImports");
2837 let AddToConfigValue::Scalar(value) = &action.value else {
2838 panic!("ignoreUnresolvedImports action should carry a scalar value");
2839 };
2840 assert_eq!(value, "@example/icons");
2841 assert_eq!(
2842 action.value_schema.as_deref(),
2843 Some(
2844 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
2845 )
2846 );
2847 }
2848
2849 #[test]
2860 fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
2861 let inner = UnresolvedCatalogReference {
2862 entry_name: "react".to_string(),
2863 catalog_name: "default".to_string(),
2864 path: PathBuf::from("apps/web/package.json"),
2865 line: 7,
2866 available_in_catalogs: Vec::new(),
2867 };
2868 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
2869 assert_eq!(
2870 action_type(&finding.actions[0]),
2871 "add-catalog-entry",
2872 "position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
2873 );
2874 let IssueAction::Fix(fix) = &finding.actions[0] else {
2875 panic!("position-0 should be an IssueAction::Fix");
2876 };
2877 assert!(
2878 fix.available_in_catalogs.is_none(),
2879 "add-catalog-entry must NOT carry available_in_catalogs"
2880 );
2881 assert!(
2882 fix.suggested_target.is_none(),
2883 "add-catalog-entry must NOT carry suggested_target"
2884 );
2885 }
2886
2887 #[test]
2894 fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
2895 let inner = UnresolvedCatalogReference {
2896 entry_name: "react".to_string(),
2897 catalog_name: "default".to_string(),
2898 path: PathBuf::from("apps/web/package.json"),
2899 line: 7,
2900 available_in_catalogs: vec!["react18".to_string()],
2901 };
2902 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
2903 assert_eq!(
2904 action_type(&finding.actions[0]),
2905 "update-catalog-reference",
2906 "position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
2907 );
2908 let IssueAction::Fix(fix) = &finding.actions[0] else {
2909 panic!("position-0 should be an IssueAction::Fix");
2910 };
2911 assert_eq!(
2912 fix.available_in_catalogs.as_deref(),
2913 Some(&["react18".to_string()][..]),
2914 "update-catalog-reference must carry the alternative list"
2915 );
2916 assert_eq!(
2917 fix.suggested_target.as_deref(),
2918 Some("react18"),
2919 "single-alternative case must surface `suggested_target` for deterministic agents"
2920 );
2921
2922 let inner_two = UnresolvedCatalogReference {
2924 entry_name: "react".to_string(),
2925 catalog_name: "default".to_string(),
2926 path: PathBuf::from("apps/web/package.json"),
2927 line: 7,
2928 available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
2929 };
2930 let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
2931 assert_eq!(
2932 action_type(&finding_two.actions[0]),
2933 "update-catalog-reference"
2934 );
2935 let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
2936 panic!("position-0 should be an IssueAction::Fix");
2937 };
2938 assert!(
2939 fix_two.suggested_target.is_none(),
2940 "multi-alternative case must NOT carry `suggested_target` (agent must pick)"
2941 );
2942 }
2943
2944 #[test]
2959 fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
2960 let inner = DuplicateExport {
2961 export_name: "Root".to_string(),
2962 locations: vec![
2963 DuplicateLocation {
2964 path: PathBuf::from("components/ui/accordion/index.ts"),
2965 line: 1,
2966 col: 0,
2967 },
2968 DuplicateLocation {
2969 path: PathBuf::from("components/ui/dialog/index.ts"),
2970 line: 1,
2971 col: 0,
2972 },
2973 ],
2974 };
2975 let finding = DuplicateExportFinding::with_actions(inner);
2976 assert_eq!(
2977 action_type(&finding.actions[0]),
2978 "add-to-config",
2979 "position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
2980 );
2981 assert_eq!(
2982 action_type(&finding.actions[1]),
2983 "remove-duplicate",
2984 "position-1 must be the destructive `remove-duplicate` fallback"
2985 );
2986
2987 let mut promoted = finding;
2990 promoted.set_config_fixable(true);
2991 assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
2992 let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
2993 panic!("position-0 should still be AddToConfig after set_config_fixable");
2994 };
2995 assert!(
2996 action.auto_fixable,
2997 "set_config_fixable(true) must flip auto_fixable"
2998 );
2999 }
3000
3001 #[test]
3006 fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
3007 let inner = DuplicateExport {
3008 export_name: "Root".to_string(),
3009 locations: Vec::new(),
3010 };
3011 let finding = DuplicateExportFinding::with_actions(inner);
3012 assert_eq!(
3013 action_type(&finding.actions[0]),
3014 "remove-duplicate",
3015 "with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
3016 );
3017
3018 let mut promoted = finding;
3020 promoted.set_config_fixable(true);
3021 assert_eq!(
3022 action_type(&promoted.actions[0]),
3023 "remove-duplicate",
3024 "set_config_fixable is a no-op when position-0 is not add-to-config"
3025 );
3026 }
3027
3028 #[test]
3034 fn misconfigured_override_drops_suppress_when_no_package_name() {
3035 let inner = MisconfiguredDependencyOverride {
3036 raw_key: String::new(),
3037 target_package: None,
3038 raw_value: String::new(),
3039 reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
3040 source: DependencyOverrideSource::PnpmWorkspaceYaml,
3041 path: PathBuf::from("pnpm-workspace.yaml"),
3042 line: 12,
3043 };
3044 let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
3045 assert_eq!(finding.actions.len(), 1);
3047 assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
3048 }
3049}