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, DuplicateExport, DuplicatePropShape, DynamicSegmentNameConflict,
42 EmptyCatalogGroup, InvalidClientExport, MisconfiguredDependencyOverride, MisplacedDirective,
43 MixedClientServerBarrel, PolicyViolation, PrivateTypeLeak, PropDrillingChain, ReExportCycle,
44 ReExportCycleKind, RouteCollision, TestOnlyDependency, ThinWrapper, TypeOnlyDependency,
45 UnlistedDependency, UnprovidedInject, UnrenderedComponent, UnresolvedCatalogReference,
46 UnresolvedImport, UnusedCatalogEntry, UnusedComponentEmit, UnusedComponentInput,
47 UnusedComponentOutput, UnusedComponentProp, UnusedDependency, UnusedDependencyOverride,
48 UnusedExport, UnusedFile, UnusedLoadDataKey, UnusedMember, UnusedServerAction,
49 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 };
594 let description = match &violation.message {
595 Some(message) => format!("Replace the `{}` {what}: {message}", violation.matched),
596 None => format!("Replace the `{}` {what}", violation.matched),
597 };
598 let suppress_token = format!("policy-violation:{}/{}", violation.pack, violation.rule_id);
599 let actions = vec![
600 IssueAction::Fix(FixAction {
601 kind: FixActionType::ResolvePolicyViolation,
602 auto_fixable: false,
603 description,
604 note: Some(format!(
605 "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",
606 violation.pack, violation.rule_id,
607 )),
608 available_in_catalogs: None,
609 suggested_target: None,
610 }),
611 IssueAction::SuppressLine(SuppressLineAction {
612 kind: SuppressLineKind::SuppressLine,
613 auto_fixable: false,
614 description: "Suppress this rule-pack rule with an inline comment above the line"
615 .to_string(),
616 comment: format!("// fallow-ignore-next-line {suppress_token}"),
617 scope: None,
618 }),
619 IssueAction::SuppressFile(SuppressFileAction {
620 kind: SuppressFileKind::SuppressFile,
621 auto_fixable: false,
622 description:
623 "Suppress this rule-pack rule with a file-level comment at the top of the file"
624 .to_string(),
625 comment: format!("// fallow-ignore-file {suppress_token}"),
626 }),
627 ];
628 Self {
629 violation,
630 actions,
631 introduced: None,
632 }
633 }
634}
635
636#[derive(Debug, Clone, Serialize)]
641#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
642pub struct UnusedExportFinding {
643 #[serde(flatten)]
645 pub export: UnusedExport,
646 pub actions: Vec<IssueAction>,
649 #[serde(default, skip_serializing_if = "Option::is_none")]
652 pub introduced: Option<AuditIntroduced>,
653}
654
655impl UnusedExportFinding {
656 #[must_use]
660 pub fn with_actions(export: UnusedExport) -> Self {
661 let note = if export.is_re_export {
662 Some(
663 "This finding originates from a re-export; verify it is not part of your public API before removing"
664 .to_string(),
665 )
666 } else {
667 None
668 };
669 let actions = vec![
670 IssueAction::Fix(FixAction {
671 kind: FixActionType::RemoveExport,
672 auto_fixable: true,
673 description: "Remove the unused export from the public API".to_string(),
674 note,
675 available_in_catalogs: None,
676 suggested_target: None,
677 }),
678 IssueAction::SuppressLine(SuppressLineAction {
679 kind: SuppressLineKind::SuppressLine,
680 auto_fixable: false,
681 description: "Suppress with an inline comment above the line".to_string(),
682 comment: "// fallow-ignore-next-line unused-export".to_string(),
683 scope: None,
684 }),
685 ];
686 Self {
687 export,
688 actions,
689 introduced: None,
690 }
691 }
692}
693
694#[derive(Debug, Clone, Serialize)]
699#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
700pub struct UnusedTypeFinding {
701 #[serde(flatten)]
703 pub export: UnusedExport,
704 pub actions: Vec<IssueAction>,
707 #[serde(default, skip_serializing_if = "Option::is_none")]
710 pub introduced: Option<AuditIntroduced>,
711}
712
713impl UnusedTypeFinding {
714 #[must_use]
717 pub fn with_actions(export: UnusedExport) -> Self {
718 let note = if export.is_re_export {
719 Some(
720 "This finding originates from a re-export; verify it is not part of your public API before removing"
721 .to_string(),
722 )
723 } else {
724 None
725 };
726 let actions = vec![
727 IssueAction::Fix(FixAction {
728 kind: FixActionType::RemoveExport,
729 auto_fixable: true,
730 description:
731 "Remove the `export` (or `export type`) keyword from the type declaration"
732 .to_string(),
733 note,
734 available_in_catalogs: None,
735 suggested_target: None,
736 }),
737 IssueAction::SuppressLine(SuppressLineAction {
738 kind: SuppressLineKind::SuppressLine,
739 auto_fixable: false,
740 description: "Suppress with an inline comment above the line".to_string(),
741 comment: "// fallow-ignore-next-line unused-type".to_string(),
742 scope: None,
743 }),
744 ];
745 Self {
746 export,
747 actions,
748 introduced: None,
749 }
750 }
751}
752
753#[derive(Debug, Clone, Serialize)]
759#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
760pub struct InvalidClientExportFinding {
761 #[serde(flatten)]
763 pub export: InvalidClientExport,
764 pub actions: Vec<IssueAction>,
767 #[serde(default, skip_serializing_if = "Option::is_none")]
770 pub introduced: Option<AuditIntroduced>,
771}
772
773impl InvalidClientExportFinding {
774 #[must_use]
779 pub fn with_actions(export: InvalidClientExport) -> Self {
780 let actions = vec![
781 IssueAction::Fix(FixAction {
782 kind: FixActionType::MoveToServerModule,
783 auto_fixable: false,
784 description: "Move the server-only export to a non-client module and import it from there"
785 .to_string(),
786 note: Some(
787 "A \"use client\" file cannot export a Next.js server-only or route-config name; Next.js rejects it at build time"
788 .to_string(),
789 ),
790 available_in_catalogs: None,
791 suggested_target: None,
792 }),
793 IssueAction::SuppressLine(SuppressLineAction {
794 kind: SuppressLineKind::SuppressLine,
795 auto_fixable: false,
796 description: "Suppress with an inline comment above the line".to_string(),
797 comment: "// fallow-ignore-next-line invalid-client-export".to_string(),
798 scope: None,
799 }),
800 ];
801 Self {
802 export,
803 actions,
804 introduced: None,
805 }
806 }
807}
808
809#[derive(Debug, Clone, Serialize)]
815#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
816pub struct MixedClientServerBarrelFinding {
817 #[serde(flatten)]
819 pub barrel: MixedClientServerBarrel,
820 pub actions: Vec<IssueAction>,
823 #[serde(default, skip_serializing_if = "Option::is_none")]
826 pub introduced: Option<AuditIntroduced>,
827}
828
829impl MixedClientServerBarrelFinding {
830 #[must_use]
835 pub fn with_actions(barrel: MixedClientServerBarrel) -> Self {
836 let actions = vec![
837 IssueAction::Fix(FixAction {
838 kind: FixActionType::SplitMixedBarrel,
839 auto_fixable: false,
840 description: "Split the barrel so client and server-only modules are re-exported from separate files"
841 .to_string(),
842 note: Some(
843 "Importing one name from this barrel drags the other's directive across the client/server boundary"
844 .to_string(),
845 ),
846 available_in_catalogs: None,
847 suggested_target: None,
848 }),
849 IssueAction::SuppressLine(SuppressLineAction {
850 kind: SuppressLineKind::SuppressLine,
851 auto_fixable: false,
852 description: "Suppress with an inline comment above the line".to_string(),
853 comment: "// fallow-ignore-next-line mixed-client-server-barrel".to_string(),
854 scope: None,
855 }),
856 ];
857 Self {
858 barrel,
859 actions,
860 introduced: None,
861 }
862 }
863}
864
865#[derive(Debug, Clone, Serialize)]
871#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
872pub struct MisplacedDirectiveFinding {
873 #[serde(flatten)]
875 pub directive_site: MisplacedDirective,
876 pub actions: Vec<IssueAction>,
879 #[serde(default, skip_serializing_if = "Option::is_none")]
882 pub introduced: Option<AuditIntroduced>,
883}
884
885impl MisplacedDirectiveFinding {
886 #[must_use]
891 pub fn with_actions(directive_site: MisplacedDirective) -> Self {
892 let actions = vec![
893 IssueAction::Fix(FixAction {
894 kind: FixActionType::HoistDirective,
895 auto_fixable: false,
896 description: "Move the directive to the very top of the file, above all imports and statements"
897 .to_string(),
898 note: Some(
899 "An RSC bundler honors the directive only in the leading prologue; here it precedes other statements and is silently ignored"
900 .to_string(),
901 ),
902 available_in_catalogs: None,
903 suggested_target: None,
904 }),
905 IssueAction::SuppressLine(SuppressLineAction {
906 kind: SuppressLineKind::SuppressLine,
907 auto_fixable: false,
908 description: "Suppress with an inline comment above the line".to_string(),
909 comment: "// fallow-ignore-next-line misplaced-directive".to_string(),
910 scope: None,
911 }),
912 ];
913 Self {
914 directive_site,
915 actions,
916 introduced: None,
917 }
918 }
919}
920
921#[derive(Debug, Clone, Serialize)]
926#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
927pub struct UnprovidedInjectFinding {
928 #[serde(flatten)]
930 pub inject: UnprovidedInject,
931 pub actions: Vec<IssueAction>,
934 #[serde(default, skip_serializing_if = "Option::is_none")]
937 pub introduced: Option<AuditIntroduced>,
938}
939
940impl UnprovidedInjectFinding {
941 #[must_use]
944 pub fn with_actions(inject: UnprovidedInject) -> Self {
945 let actions = vec![
946 manual_framework_fix(
947 FixActionType::ProvideInject,
948 "Provide this injected key, or remove the inject / getContext call",
949 "Manual review required: dependency-injection keys can be provided by framework wiring, tests, or package consumers outside this project.",
950 ),
951 suppress_line("// fallow-ignore-next-line unprovided-inject"),
952 ];
953 Self {
954 inject,
955 actions,
956 introduced: None,
957 }
958 }
959}
960
961#[derive(Debug, Clone, Serialize)]
966#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
967pub struct UnusedServerActionFinding {
968 #[serde(flatten)]
970 pub action: UnusedServerAction,
971 pub actions: Vec<IssueAction>,
974 #[serde(default, skip_serializing_if = "Option::is_none")]
977 pub introduced: Option<AuditIntroduced>,
978}
979
980impl UnusedServerActionFinding {
981 #[must_use]
984 pub fn with_actions(action: UnusedServerAction) -> Self {
985 let actions = vec![
986 manual_framework_fix(
987 FixActionType::WireServerAction,
988 "Wire the server action to a caller or form action, or remove it",
989 "Manual review required: server actions may still be POST-able by action id or invoked reflectively outside the static project graph.",
990 ),
991 suppress_line("// fallow-ignore-next-line unused-server-action"),
992 ];
993 Self {
994 action,
995 actions,
996 introduced: None,
997 }
998 }
999}
1000
1001#[derive(Debug, Clone, Serialize)]
1006#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1007pub struct UnusedLoadDataKeyFinding {
1008 #[serde(flatten)]
1010 pub key: UnusedLoadDataKey,
1011 pub actions: Vec<IssueAction>,
1014 #[serde(default, skip_serializing_if = "Option::is_none")]
1017 pub introduced: Option<AuditIntroduced>,
1018}
1019
1020impl UnusedLoadDataKeyFinding {
1021 #[must_use]
1024 pub fn with_actions(key: UnusedLoadDataKey) -> Self {
1025 let actions = vec![
1026 manual_framework_fix(
1027 FixActionType::UseLoadData,
1028 "Read this load data key from the route UI, or remove it from the load return",
1029 "Manual review required: load functions can perform real server or database work, so verify side effects before deleting the producer.",
1030 ),
1031 suppress_line("// fallow-ignore-next-line unused-load-data-key"),
1032 ];
1033 Self {
1034 key,
1035 actions,
1036 introduced: None,
1037 }
1038 }
1039}
1040
1041#[derive(Debug, Clone, Serialize)]
1046#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1047pub struct UnrenderedComponentFinding {
1048 #[serde(flatten)]
1050 pub component: UnrenderedComponent,
1051 pub actions: Vec<IssueAction>,
1054 #[serde(default, skip_serializing_if = "Option::is_none")]
1057 pub introduced: Option<AuditIntroduced>,
1058}
1059
1060impl UnrenderedComponentFinding {
1061 #[must_use]
1064 pub fn with_actions(component: UnrenderedComponent) -> Self {
1065 let actions = vec![
1066 manual_framework_fix(
1067 FixActionType::RenderComponent,
1068 "Render the reachable component from project code, or remove it",
1069 "Manual review required: exported library components and dynamic render registries can be intentionally reachable without static template usage.",
1070 ),
1071 suppress_line("// fallow-ignore-next-line unrendered-component"),
1072 ];
1073 Self {
1074 component,
1075 actions,
1076 introduced: None,
1077 }
1078 }
1079}
1080
1081#[derive(Debug, Clone, Serialize)]
1086#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1087pub struct UnusedComponentPropFinding {
1088 #[serde(flatten)]
1090 pub prop: UnusedComponentProp,
1091 pub actions: Vec<IssueAction>,
1094 #[serde(default, skip_serializing_if = "Option::is_none")]
1097 pub introduced: Option<AuditIntroduced>,
1098}
1099
1100impl UnusedComponentPropFinding {
1101 #[must_use]
1104 pub fn with_actions(prop: UnusedComponentProp) -> Self {
1105 let actions = vec![
1106 manual_framework_fix(
1107 FixActionType::UseComponentProp,
1108 "Use the declared prop in the component, or remove it from the component API",
1109 "Manual review required: public component APIs can intentionally keep stable props for external consumers.",
1110 ),
1111 suppress_line("// fallow-ignore-next-line unused-component-prop"),
1112 ];
1113 Self {
1114 prop,
1115 actions,
1116 introduced: None,
1117 }
1118 }
1119}
1120
1121#[derive(Debug, Clone, Serialize)]
1126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1127pub struct UnusedComponentEmitFinding {
1128 #[serde(flatten)]
1130 pub emit: UnusedComponentEmit,
1131 pub actions: Vec<IssueAction>,
1134 #[serde(default, skip_serializing_if = "Option::is_none")]
1137 pub introduced: Option<AuditIntroduced>,
1138}
1139
1140impl UnusedComponentEmitFinding {
1141 #[must_use]
1144 pub fn with_actions(emit: UnusedComponentEmit) -> Self {
1145 let actions = vec![
1146 manual_framework_fix(
1147 FixActionType::EmitComponentEvent,
1148 "Emit the declared event from the component, or remove it from the component API",
1149 "Manual review required: public component APIs can intentionally keep stable events for external listeners.",
1150 ),
1151 suppress_line("// fallow-ignore-next-line unused-component-emit"),
1152 ];
1153 Self {
1154 emit,
1155 actions,
1156 introduced: None,
1157 }
1158 }
1159}
1160
1161#[derive(Debug, Clone, Serialize)]
1167#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1168pub struct UnusedSvelteEventFinding {
1169 #[serde(flatten)]
1171 pub event: UnusedSvelteEvent,
1172 pub actions: Vec<IssueAction>,
1175 #[serde(default, skip_serializing_if = "Option::is_none")]
1178 pub introduced: Option<AuditIntroduced>,
1179}
1180
1181impl UnusedSvelteEventFinding {
1182 #[must_use]
1185 pub fn with_actions(event: UnusedSvelteEvent) -> Self {
1186 let actions = vec![
1187 manual_framework_fix(
1188 FixActionType::WireSvelteEvent,
1189 "Add or forward a listener for this custom event, or remove the dispatch",
1190 "Manual review required: public Svelte component APIs can intentionally dispatch events for package consumers outside this project.",
1191 ),
1192 suppress_line("// fallow-ignore-next-line unused-svelte-event"),
1193 ];
1194 Self {
1195 event,
1196 actions,
1197 introduced: None,
1198 }
1199 }
1200}
1201
1202#[derive(Debug, Clone, Serialize)]
1208#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1209pub struct PropDrillingChainFinding {
1210 #[serde(flatten)]
1212 pub chain: PropDrillingChain,
1213 pub actions: Vec<IssueAction>,
1216 #[serde(default, skip_serializing_if = "Option::is_none")]
1219 pub introduced: Option<AuditIntroduced>,
1220}
1221
1222impl PropDrillingChainFinding {
1223 #[must_use]
1228 pub fn with_actions(chain: PropDrillingChain) -> Self {
1229 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1230 kind: SuppressLineKind::SuppressLine,
1231 auto_fixable: false,
1232 description: "Suppress with an inline comment above the source prop declaration"
1233 .to_string(),
1234 comment: "// fallow-ignore-next-line prop-drilling".to_string(),
1235 scope: None,
1236 })];
1237 Self {
1238 chain,
1239 actions,
1240 introduced: None,
1241 }
1242 }
1243}
1244
1245#[derive(Debug, Clone, Serialize)]
1251#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1252pub struct ThinWrapperFinding {
1253 #[serde(flatten)]
1255 pub wrapper: ThinWrapper,
1256 pub actions: Vec<IssueAction>,
1259 #[serde(default, skip_serializing_if = "Option::is_none")]
1262 pub introduced: Option<AuditIntroduced>,
1263}
1264
1265impl ThinWrapperFinding {
1266 #[must_use]
1270 pub fn with_actions(wrapper: ThinWrapper) -> 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 component definition"
1275 .to_string(),
1276 comment: "// fallow-ignore-next-line thin-wrapper".to_string(),
1277 scope: None,
1278 })];
1279 Self {
1280 wrapper,
1281 actions,
1282 introduced: None,
1283 }
1284 }
1285}
1286
1287#[derive(Debug, Clone, Serialize)]
1295#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1296pub struct DuplicatePropShapeFinding {
1297 #[serde(flatten)]
1299 pub shape: DuplicatePropShape,
1300 pub actions: Vec<IssueAction>,
1303 #[serde(default, skip_serializing_if = "Option::is_none")]
1306 pub introduced: Option<AuditIntroduced>,
1307}
1308
1309impl DuplicatePropShapeFinding {
1310 #[must_use]
1317 pub fn with_actions(shape: DuplicatePropShape) -> Self {
1318 let actions = vec![
1319 IssueAction::SuppressLine(SuppressLineAction {
1320 kind: SuppressLineKind::SuppressLine,
1321 auto_fixable: false,
1322 description: "Three or more components share this exact prop shape. Extract one \
1323 shared `Props` type (or a base component) that every member reuses, \
1324 or keep them separate if a per-variant divergence is planned. \
1325 Suppress one member with an inline comment above the component \
1326 definition."
1327 .to_string(),
1328 comment: "// fallow-ignore-next-line duplicate-prop-shape".to_string(),
1329 scope: None,
1330 }),
1331 IssueAction::SuppressFile(SuppressFileAction {
1332 kind: SuppressFileKind::SuppressFile,
1333 auto_fixable: false,
1334 description: "Escape hatch: a file-level suppress silences this member but it \
1335 still appears in its siblings' `sharing_components` (the group is \
1336 real regardless of suppression)."
1337 .to_string(),
1338 comment: "// fallow-ignore-file duplicate-prop-shape".to_string(),
1339 }),
1340 ];
1341 Self {
1342 shape,
1343 actions,
1344 introduced: None,
1345 }
1346 }
1347}
1348
1349#[derive(Debug, Clone, Serialize)]
1354#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1355pub struct UnusedComponentInputFinding {
1356 #[serde(flatten)]
1358 pub input: UnusedComponentInput,
1359 pub actions: Vec<IssueAction>,
1362 #[serde(default, skip_serializing_if = "Option::is_none")]
1365 pub introduced: Option<AuditIntroduced>,
1366}
1367
1368impl UnusedComponentInputFinding {
1369 #[must_use]
1373 pub fn with_actions(input: UnusedComponentInput) -> Self {
1374 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1375 kind: SuppressLineKind::SuppressLine,
1376 auto_fixable: false,
1377 description: "Suppress with an inline comment above the line".to_string(),
1378 comment: "// fallow-ignore-next-line unused-component-input".to_string(),
1379 scope: None,
1380 })];
1381 Self {
1382 input,
1383 actions,
1384 introduced: None,
1385 }
1386 }
1387}
1388
1389#[derive(Debug, Clone, Serialize)]
1394#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1395pub struct UnusedComponentOutputFinding {
1396 #[serde(flatten)]
1398 pub output: UnusedComponentOutput,
1399 pub actions: Vec<IssueAction>,
1402 #[serde(default, skip_serializing_if = "Option::is_none")]
1405 pub introduced: Option<AuditIntroduced>,
1406}
1407
1408impl UnusedComponentOutputFinding {
1409 #[must_use]
1413 pub fn with_actions(output: UnusedComponentOutput) -> Self {
1414 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1415 kind: SuppressLineKind::SuppressLine,
1416 auto_fixable: false,
1417 description: "Suppress with an inline comment above the line".to_string(),
1418 comment: "// fallow-ignore-next-line unused-component-output".to_string(),
1419 scope: None,
1420 })];
1421 Self {
1422 output,
1423 actions,
1424 introduced: None,
1425 }
1426 }
1427}
1428
1429#[derive(Debug, Clone, Serialize)]
1435#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1436pub struct RouteCollisionFinding {
1437 #[serde(flatten)]
1439 pub collision: RouteCollision,
1440 pub actions: Vec<IssueAction>,
1443 #[serde(default, skip_serializing_if = "Option::is_none")]
1446 pub introduced: Option<AuditIntroduced>,
1447}
1448
1449impl RouteCollisionFinding {
1450 #[must_use]
1454 pub fn with_actions(collision: RouteCollision) -> Self {
1455 let actions = vec![
1456 IssueAction::Fix(FixAction {
1457 kind: FixActionType::ResolveRouteCollision,
1458 auto_fixable: false,
1459 description: "Two or more files resolve to the same URL. Move or merge one so \
1460 each URL has a single owner. Route groups `(name)` and parallel \
1461 slots `@name` are the only legal same-URL shapes."
1462 .to_string(),
1463 note: Some(
1464 "Next.js fails the build with \"You cannot have two parallel pages that \
1465 resolve to the same path\". See the sibling `conflicting_paths` array for \
1466 the other files that own this URL."
1467 .to_string(),
1468 ),
1469 available_in_catalogs: None,
1470 suggested_target: None,
1471 }),
1472 IssueAction::SuppressFile(SuppressFileAction {
1473 kind: SuppressFileKind::SuppressFile,
1474 auto_fixable: false,
1475 description: "Escape hatch only: a file-level suppress silences the finding but \
1476 does NOT make `next build` pass. Prefer moving or merging a file."
1477 .to_string(),
1478 comment: "// fallow-ignore-file route-collision".to_string(),
1479 }),
1480 ];
1481 Self {
1482 collision,
1483 actions,
1484 introduced: None,
1485 }
1486 }
1487}
1488
1489#[derive(Debug, Clone, Serialize)]
1494#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1495pub struct DynamicSegmentNameConflictFinding {
1496 #[serde(flatten)]
1498 pub conflict: DynamicSegmentNameConflict,
1499 pub actions: Vec<IssueAction>,
1502 #[serde(default, skip_serializing_if = "Option::is_none")]
1505 pub introduced: Option<AuditIntroduced>,
1506}
1507
1508impl DynamicSegmentNameConflictFinding {
1509 #[must_use]
1512 pub fn with_actions(conflict: DynamicSegmentNameConflict) -> Self {
1513 let actions = vec![
1514 IssueAction::Fix(FixAction {
1515 kind: FixActionType::ResolveDynamicSegmentNameConflict,
1516 auto_fixable: false,
1517 description: "Sibling dynamic segments at the same position use different param \
1518 names. Rename them to one consistent slug name (e.g. pick `[id]` \
1519 or `[slug]` for both)."
1520 .to_string(),
1521 note: Some(
1522 "Next.js throws \"You cannot use different slug names for the same dynamic \
1523 path\" at dev / runtime when the position is hit; `next build` does not \
1524 catch it. See the sibling `conflicting_segments` array."
1525 .to_string(),
1526 ),
1527 available_in_catalogs: None,
1528 suggested_target: None,
1529 }),
1530 IssueAction::SuppressFile(SuppressFileAction {
1531 kind: SuppressFileKind::SuppressFile,
1532 auto_fixable: false,
1533 description: "Escape hatch only: a file-level suppress silences the finding but \
1534 does NOT stop Next.js from throwing at dev / runtime. Prefer \
1535 renaming the segments."
1536 .to_string(),
1537 comment: "// fallow-ignore-file dynamic-segment-name-conflict".to_string(),
1538 }),
1539 ];
1540 Self {
1541 conflict,
1542 actions,
1543 introduced: None,
1544 }
1545 }
1546}
1547
1548#[derive(Debug, Clone, Serialize)]
1551#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1552pub struct UnusedEnumMemberFinding {
1553 #[serde(flatten)]
1555 pub member: UnusedMember,
1556 pub actions: Vec<IssueAction>,
1559 #[serde(default, skip_serializing_if = "Option::is_none")]
1562 pub introduced: Option<AuditIntroduced>,
1563}
1564
1565impl UnusedEnumMemberFinding {
1566 #[must_use]
1568 pub fn with_actions(member: UnusedMember) -> Self {
1569 let actions = vec![
1570 IssueAction::Fix(FixAction {
1571 kind: FixActionType::RemoveEnumMember,
1572 auto_fixable: true,
1573 description: "Remove this enum member".to_string(),
1574 note: None,
1575 available_in_catalogs: None,
1576 suggested_target: None,
1577 }),
1578 IssueAction::SuppressLine(SuppressLineAction {
1579 kind: SuppressLineKind::SuppressLine,
1580 auto_fixable: false,
1581 description: "Suppress with an inline comment above the line".to_string(),
1582 comment: "// fallow-ignore-next-line unused-enum-member".to_string(),
1583 scope: None,
1584 }),
1585 ];
1586 Self {
1587 member,
1588 actions,
1589 introduced: None,
1590 }
1591 }
1592}
1593
1594#[derive(Debug, Clone, Serialize)]
1599#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1600pub struct UnusedClassMemberFinding {
1601 #[serde(flatten)]
1603 pub member: UnusedMember,
1604 pub actions: Vec<IssueAction>,
1607 #[serde(default, skip_serializing_if = "Option::is_none")]
1610 pub introduced: Option<AuditIntroduced>,
1611}
1612
1613impl UnusedClassMemberFinding {
1614 #[must_use]
1619 pub fn with_actions(member: UnusedMember) -> Self {
1620 let actions = vec![
1621 IssueAction::Fix(FixAction {
1622 kind: FixActionType::RemoveClassMember,
1623 auto_fixable: false,
1624 description: "Remove this class member".to_string(),
1625 note: Some(
1626 "Class member may be used via dependency injection or decorators".to_string(),
1627 ),
1628 available_in_catalogs: None,
1629 suggested_target: None,
1630 }),
1631 IssueAction::SuppressLine(SuppressLineAction {
1632 kind: SuppressLineKind::SuppressLine,
1633 auto_fixable: false,
1634 description: "Suppress with an inline comment above the line".to_string(),
1635 comment: "// fallow-ignore-next-line unused-class-member".to_string(),
1636 scope: None,
1637 }),
1638 ];
1639 Self {
1640 member,
1641 actions,
1642 introduced: None,
1643 }
1644 }
1645}
1646
1647#[derive(Debug, Clone, Serialize)]
1656#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1657pub struct UnusedStoreMemberFinding {
1658 #[serde(flatten)]
1660 pub member: UnusedMember,
1661 pub actions: Vec<IssueAction>,
1664 #[serde(default, skip_serializing_if = "Option::is_none")]
1667 pub introduced: Option<AuditIntroduced>,
1668}
1669
1670impl UnusedStoreMemberFinding {
1671 #[must_use]
1675 pub fn with_actions(member: UnusedMember) -> Self {
1676 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1677 kind: SuppressLineKind::SuppressLine,
1678 auto_fixable: false,
1679 description: "Suppress with an inline comment above the line".to_string(),
1680 comment: "// fallow-ignore-next-line unused-store-member".to_string(),
1681 scope: None,
1682 })];
1683 Self {
1684 member,
1685 actions,
1686 introduced: None,
1687 }
1688 }
1689}
1690
1691fn build_unused_dependency_actions(
1702 dep: &UnusedDependency,
1703 package_json_location: &str,
1704 suppress_issue_kind: &str,
1705) -> Vec<IssueAction> {
1706 let mut actions = Vec::with_capacity(2);
1707 let cross_workspace = !dep.used_in_workspaces.is_empty();
1708 actions.push(if cross_workspace {
1709 IssueAction::Fix(FixAction {
1710 kind: FixActionType::MoveDependency,
1711 auto_fixable: false,
1712 description: "Move this dependency to the workspace package.json that imports it"
1713 .to_string(),
1714 note: Some(
1715 "fallow fix will not remove dependencies that are imported by another workspace"
1716 .to_string(),
1717 ),
1718 available_in_catalogs: None,
1719 suggested_target: None,
1720 })
1721 } else {
1722 IssueAction::Fix(FixAction {
1723 kind: FixActionType::RemoveDependency,
1724 auto_fixable: true,
1725 description: format!("Remove from {package_json_location} in package.json"),
1726 note: None,
1727 available_in_catalogs: None,
1728 suggested_target: None,
1729 })
1730 });
1731 actions.push(build_ignore_dependencies_suppress_action(
1732 &dep.package_name,
1733 suppress_issue_kind,
1734 ));
1735 actions
1736}
1737
1738fn build_ignore_dependencies_suppress_action(
1746 package_name: &str,
1747 _suppress_issue_kind: &str,
1748) -> IssueAction {
1749 IssueAction::AddToConfig(AddToConfigAction {
1750 kind: AddToConfigKind::AddToConfig,
1751 auto_fixable: false,
1752 description: format!("Add \"{package_name}\" to ignoreDependencies in fallow config"),
1753 config_key: "ignoreDependencies".to_string(),
1754 value: AddToConfigValue::Scalar(package_name.to_string()),
1755 value_schema: Some(
1756 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items"
1757 .to_string(),
1758 ),
1759 })
1760}
1761
1762#[derive(Debug, Clone, Serialize)]
1768#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1769pub struct UnusedDependencyFinding {
1770 #[serde(flatten)]
1772 pub dep: UnusedDependency,
1773 pub actions: Vec<IssueAction>,
1776 #[serde(default, skip_serializing_if = "Option::is_none")]
1779 pub introduced: Option<AuditIntroduced>,
1780}
1781
1782impl UnusedDependencyFinding {
1783 #[must_use]
1786 pub fn with_actions(dep: UnusedDependency) -> Self {
1787 let actions = build_unused_dependency_actions(&dep, "dependencies", "unused-dependency");
1788 Self {
1789 dep,
1790 actions,
1791 introduced: None,
1792 }
1793 }
1794}
1795
1796#[derive(Debug, Clone, Serialize)]
1802#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1803pub struct UnusedDevDependencyFinding {
1804 #[serde(flatten)]
1806 pub dep: UnusedDependency,
1807 pub actions: Vec<IssueAction>,
1810 #[serde(default, skip_serializing_if = "Option::is_none")]
1813 pub introduced: Option<AuditIntroduced>,
1814}
1815
1816impl UnusedDevDependencyFinding {
1817 #[must_use]
1819 pub fn with_actions(dep: UnusedDependency) -> Self {
1820 let actions =
1821 build_unused_dependency_actions(&dep, "devDependencies", "unused-dev-dependency");
1822 Self {
1823 dep,
1824 actions,
1825 introduced: None,
1826 }
1827 }
1828}
1829
1830#[derive(Debug, Clone, Serialize)]
1836#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1837pub struct UnusedOptionalDependencyFinding {
1838 #[serde(flatten)]
1840 pub dep: UnusedDependency,
1841 pub actions: Vec<IssueAction>,
1844 #[serde(default, skip_serializing_if = "Option::is_none")]
1847 pub introduced: Option<AuditIntroduced>,
1848}
1849
1850impl UnusedOptionalDependencyFinding {
1851 #[must_use]
1853 pub fn with_actions(dep: UnusedDependency) -> Self {
1854 let actions =
1855 build_unused_dependency_actions(&dep, "optionalDependencies", "unused-dependency");
1856 Self {
1857 dep,
1858 actions,
1859 introduced: None,
1860 }
1861 }
1862}
1863
1864#[derive(Debug, Clone, Serialize)]
1868#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1869pub struct UnlistedDependencyFinding {
1870 #[serde(flatten)]
1872 pub dep: UnlistedDependency,
1873 pub actions: Vec<IssueAction>,
1876 #[serde(default, skip_serializing_if = "Option::is_none")]
1879 pub introduced: Option<AuditIntroduced>,
1880}
1881
1882impl UnlistedDependencyFinding {
1883 #[must_use]
1885 pub fn with_actions(dep: UnlistedDependency) -> Self {
1886 let actions = vec![
1887 IssueAction::Fix(FixAction {
1888 kind: FixActionType::InstallDependency,
1889 auto_fixable: false,
1890 description: "Add this package to dependencies in package.json".to_string(),
1891 note: Some(
1892 "Verify this package should be a direct dependency before adding".to_string(),
1893 ),
1894 available_in_catalogs: None,
1895 suggested_target: None,
1896 }),
1897 build_ignore_dependencies_suppress_action(&dep.package_name, "unlisted-dependency"),
1898 ];
1899 Self {
1900 dep,
1901 actions,
1902 introduced: None,
1903 }
1904 }
1905}
1906
1907#[derive(Debug, Clone, Serialize)]
1911#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1912pub struct TypeOnlyDependencyFinding {
1913 #[serde(flatten)]
1915 pub dep: TypeOnlyDependency,
1916 pub actions: Vec<IssueAction>,
1919 #[serde(default, skip_serializing_if = "Option::is_none")]
1922 pub introduced: Option<AuditIntroduced>,
1923}
1924
1925impl TypeOnlyDependencyFinding {
1926 #[must_use]
1928 pub fn with_actions(dep: TypeOnlyDependency) -> Self {
1929 let actions = vec![
1930 IssueAction::Fix(FixAction {
1931 kind: FixActionType::MoveToDev,
1932 auto_fixable: false,
1933 description: "Move to devDependencies (only type imports are used)".to_string(),
1934 note: Some(
1935 "Type imports are erased at runtime so this dependency is not needed in production"
1936 .to_string(),
1937 ),
1938 available_in_catalogs: None,
1939 suggested_target: None,
1940 }),
1941 build_ignore_dependencies_suppress_action(&dep.package_name, "type-only-dependency"),
1942 ];
1943 Self {
1944 dep,
1945 actions,
1946 introduced: None,
1947 }
1948 }
1949}
1950
1951#[derive(Debug, Clone, Serialize)]
1955#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1956pub struct TestOnlyDependencyFinding {
1957 #[serde(flatten)]
1959 pub dep: TestOnlyDependency,
1960 pub actions: Vec<IssueAction>,
1963 #[serde(default, skip_serializing_if = "Option::is_none")]
1966 pub introduced: Option<AuditIntroduced>,
1967}
1968
1969impl TestOnlyDependencyFinding {
1970 #[must_use]
1972 pub fn with_actions(dep: TestOnlyDependency) -> Self {
1973 let actions = vec![
1974 IssueAction::Fix(FixAction {
1975 kind: FixActionType::MoveToDev,
1976 auto_fixable: false,
1977 description: "Move to devDependencies (only test files import this)".to_string(),
1978 note: Some(
1979 "Only test files import this package so it does not need to be a production dependency"
1980 .to_string(),
1981 ),
1982 available_in_catalogs: None,
1983 suggested_target: None,
1984 }),
1985 build_ignore_dependencies_suppress_action(&dep.package_name, "test-only-dependency"),
1986 ];
1987 Self {
1988 dep,
1989 actions,
1990 introduced: None,
1991 }
1992 }
1993}
1994
1995#[derive(Debug, Clone, Serialize)]
2016#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2017pub struct DuplicateExportFinding {
2018 #[serde(flatten)]
2020 pub export: DuplicateExport,
2021 pub actions: Vec<IssueAction>,
2024 #[serde(default, skip_serializing_if = "Option::is_none")]
2027 pub introduced: Option<AuditIntroduced>,
2028}
2029
2030impl DuplicateExportFinding {
2031 #[must_use]
2040 pub fn with_actions(export: DuplicateExport) -> Self {
2041 let mut actions: Vec<IssueAction> = Vec::with_capacity(3);
2042
2043 if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
2044 actions.push(IssueAction::AddToConfig(AddToConfigAction {
2045 kind: AddToConfigKind::AddToConfig,
2046 auto_fixable: false,
2047 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(),
2048 config_key: "ignoreExports".to_string(),
2049 value: AddToConfigValue::ExportsRules(rules),
2050 value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
2051 }));
2052 }
2053
2054 actions.push(IssueAction::Fix(FixAction {
2055 kind: FixActionType::RemoveDuplicate,
2056 auto_fixable: false,
2057 description: "Keep one canonical export location and remove the others".to_string(),
2058 note: Some(NAMESPACE_BARREL_HINT.to_string()),
2059 available_in_catalogs: None,
2060 suggested_target: None,
2061 }));
2062
2063 actions.push(IssueAction::SuppressLine(SuppressLineAction {
2064 kind: SuppressLineKind::SuppressLine,
2065 auto_fixable: false,
2066 description: "Suppress with an inline comment above the line".to_string(),
2067 comment: "// fallow-ignore-next-line duplicate-export".to_string(),
2068 scope: Some(SuppressLineScope::PerLocation),
2069 }));
2070
2071 Self {
2072 export,
2073 actions,
2074 introduced: None,
2075 }
2076 }
2077
2078 pub fn set_config_fixable(&mut self, fixable: bool) {
2084 if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
2085 action.auto_fixable = fixable;
2086 }
2087 }
2088}
2089
2090fn build_duplicate_exports_ignore_rules(
2094 export: &DuplicateExport,
2095) -> Option<Vec<IgnoreExportsRule>> {
2096 let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
2097 for loc in &export.locations {
2098 let path = loc.path.to_string_lossy().replace('\\', "/");
2106 if path.is_empty() {
2107 continue;
2108 }
2109 if entries.iter().any(|existing| existing.file == path) {
2110 continue;
2111 }
2112 entries.push(IgnoreExportsRule {
2113 file: path,
2114 exports: vec!["*".to_string()],
2115 });
2116 }
2117 if entries.is_empty() {
2118 None
2119 } else {
2120 Some(entries)
2121 }
2122}
2123
2124#[derive(Debug, Clone, Serialize)]
2128#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2129pub struct UnusedCatalogEntryFinding {
2130 #[serde(flatten)]
2132 pub entry: UnusedCatalogEntry,
2133 pub actions: Vec<IssueAction>,
2135 #[serde(default, skip_serializing_if = "Option::is_none")]
2138 pub introduced: Option<AuditIntroduced>,
2139}
2140
2141impl UnusedCatalogEntryFinding {
2142 #[must_use]
2147 pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
2148 let is_pnpm_source = is_pnpm_catalog_source(&entry.path);
2149 let auto_fixable = entry.hardcoded_consumers.is_empty() && is_pnpm_source;
2150 let note = if is_pnpm_source {
2151 Some(
2152 "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing"
2153 .to_string(),
2154 )
2155 } else {
2156 Some(
2157 "fallow fix only edits pnpm-workspace.yaml catalog entries. Edit Bun package.json catalogs manually."
2158 .to_string(),
2159 )
2160 };
2161 let mut actions = vec![IssueAction::Fix(FixAction {
2162 kind: FixActionType::RemoveCatalogEntry,
2163 auto_fixable,
2164 description: if is_pnpm_source {
2165 "Remove the entry from pnpm-workspace.yaml".to_string()
2166 } else {
2167 "Remove the entry from the catalog source file manually".to_string()
2168 },
2169 note,
2170 available_in_catalogs: None,
2171 suggested_target: None,
2172 })];
2173 if is_pnpm_source {
2174 actions.push(IssueAction::SuppressLine(SuppressLineAction {
2175 kind: SuppressLineKind::SuppressLine,
2176 auto_fixable: false,
2177 description: "Suppress with a YAML comment above the line".to_string(),
2178 comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
2179 scope: None,
2180 }));
2181 }
2182 Self {
2183 entry,
2184 actions,
2185 introduced: None,
2186 }
2187 }
2188}
2189
2190#[derive(Debug, Clone, Serialize)]
2194#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2195pub struct EmptyCatalogGroupFinding {
2196 #[serde(flatten)]
2198 pub group: EmptyCatalogGroup,
2199 pub actions: Vec<IssueAction>,
2201 #[serde(default, skip_serializing_if = "Option::is_none")]
2204 pub introduced: Option<AuditIntroduced>,
2205}
2206
2207impl EmptyCatalogGroupFinding {
2208 #[must_use]
2210 pub fn with_actions(group: EmptyCatalogGroup) -> Self {
2211 let auto_fixable = is_pnpm_catalog_source(&group.path);
2212 let mut actions = vec![IssueAction::Fix(FixAction {
2213 kind: FixActionType::RemoveEmptyCatalogGroup,
2214 auto_fixable,
2215 description: if auto_fixable {
2216 "Remove the empty named catalog group from pnpm-workspace.yaml".to_string()
2217 } else {
2218 "Remove the empty named catalog group from the catalog source file manually"
2219 .to_string()
2220 },
2221 note: Some(if auto_fixable {
2222 "Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
2223 .to_string()
2224 } else {
2225 "fallow fix only edits pnpm-workspace.yaml catalog groups. Edit Bun package.json catalogs manually."
2226 .to_string()
2227 }),
2228 available_in_catalogs: None,
2229 suggested_target: None,
2230 })];
2231 if auto_fixable {
2232 actions.push(IssueAction::SuppressLine(SuppressLineAction {
2233 kind: SuppressLineKind::SuppressLine,
2234 auto_fixable: false,
2235 description: "Suppress with a YAML comment above the line".to_string(),
2236 comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
2237 scope: None,
2238 }));
2239 }
2240 Self {
2241 group,
2242 actions,
2243 introduced: None,
2244 }
2245 }
2246}
2247
2248fn is_pnpm_catalog_source(path: &Path) -> bool {
2249 path == Path::new(PNPM_WORKSPACE_FILE)
2250}
2251
2252#[derive(Debug, Clone, Serialize)]
2260#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2261pub struct UnresolvedCatalogReferenceFinding {
2262 #[serde(flatten)]
2264 pub reference: UnresolvedCatalogReference,
2265 pub actions: Vec<IssueAction>,
2268 #[serde(default, skip_serializing_if = "Option::is_none")]
2271 pub introduced: Option<AuditIntroduced>,
2272}
2273
2274impl UnresolvedCatalogReferenceFinding {
2275 #[must_use]
2279 pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
2280 let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
2285 let primary = if reference.available_in_catalogs.is_empty() {
2286 IssueAction::Fix(FixAction {
2287 kind: FixActionType::AddCatalogEntry,
2288 auto_fixable: false,
2289 description: format!(
2290 "Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
2291 reference.entry_name, reference.catalog_name
2292 ),
2293 note: Some(
2294 "Pin a version that satisfies the consumer's import; no other catalog declares this package today"
2295 .to_string(),
2296 ),
2297 available_in_catalogs: None,
2298 suggested_target: None,
2299 })
2300 } else {
2301 let available = reference.available_in_catalogs.clone();
2302 let suggested_target = (available.len() == 1).then(|| available[0].clone());
2303 IssueAction::Fix(FixAction {
2304 kind: FixActionType::UpdateCatalogReference,
2305 auto_fixable: false,
2306 description: format!(
2307 "Switch the reference from `catalog:{}` to a catalog that declares `{}`",
2308 reference.catalog_name, reference.entry_name
2309 ),
2310 note: None,
2311 available_in_catalogs: Some(available),
2312 suggested_target,
2313 })
2314 };
2315
2316 let fallback = IssueAction::Fix(FixAction {
2317 kind: FixActionType::RemoveCatalogReference,
2318 auto_fixable: false,
2319 description:
2320 "Remove the catalog reference and pin a hardcoded version in package.json"
2321 .to_string(),
2322 note: Some(
2323 "Use only when neither another catalog declares the package nor the named catalog should grow to include it"
2324 .to_string(),
2325 ),
2326 available_in_catalogs: None,
2327 suggested_target: None,
2328 });
2329
2330 let mut suppress_value = serde_json::Map::new();
2331 suppress_value.insert(
2332 "package".to_string(),
2333 serde_json::Value::String(reference.entry_name.clone()),
2334 );
2335 suppress_value.insert(
2336 "catalog".to_string(),
2337 serde_json::Value::String(reference.catalog_name.clone()),
2338 );
2339 suppress_value.insert(
2340 "consumer".to_string(),
2341 serde_json::Value::String(consumer_path),
2342 );
2343 let suppress = IssueAction::AddToConfig(AddToConfigAction {
2344 kind: AddToConfigKind::AddToConfig,
2345 auto_fixable: false,
2346 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(),
2347 config_key: "ignoreCatalogReferences".to_string(),
2348 value: AddToConfigValue::RuleObject(suppress_value),
2349 value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
2350 });
2351
2352 Self {
2353 reference,
2354 actions: vec![primary, fallback, suppress],
2355 introduced: None,
2356 }
2357 }
2358}
2359
2360#[derive(Debug, Clone, Serialize)]
2365#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2366pub struct UnusedDependencyOverrideFinding {
2367 #[serde(flatten)]
2369 pub entry: UnusedDependencyOverride,
2370 pub actions: Vec<IssueAction>,
2372 #[serde(default, skip_serializing_if = "Option::is_none")]
2375 pub introduced: Option<AuditIntroduced>,
2376}
2377
2378impl UnusedDependencyOverrideFinding {
2379 #[must_use]
2381 pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
2382 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
2383 actions.push(IssueAction::Fix(FixAction {
2384 kind: FixActionType::RemoveDependencyOverride,
2385 auto_fixable: false,
2386 description: "Remove the override entry from pnpm-workspace.yaml or pnpm.overrides"
2387 .to_string(),
2388 note: Some(
2389 "Conservative static check; verify against `pnpm install --frozen-lockfile` before removing in case the override targets a transitive dependency (CVE-fix pattern)"
2390 .to_string(),
2391 ),
2392 available_in_catalogs: None,
2393 suggested_target: None,
2394 }));
2395
2396 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
2397 Some(&entry.target_package),
2398 &entry.raw_key,
2399 entry.source,
2400 ) {
2401 actions.push(suppress);
2402 }
2403
2404 Self {
2405 entry,
2406 actions,
2407 introduced: None,
2408 }
2409 }
2410}
2411
2412#[derive(Debug, Clone, Serialize)]
2418#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2419pub struct MisconfiguredDependencyOverrideFinding {
2420 #[serde(flatten)]
2422 pub entry: MisconfiguredDependencyOverride,
2423 pub actions: Vec<IssueAction>,
2425 #[serde(default, skip_serializing_if = "Option::is_none")]
2428 pub introduced: Option<AuditIntroduced>,
2429}
2430
2431impl MisconfiguredDependencyOverrideFinding {
2432 #[must_use]
2437 pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
2438 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
2439 actions.push(IssueAction::Fix(FixAction {
2440 kind: FixActionType::FixDependencyOverride,
2441 auto_fixable: false,
2442 description:
2443 "Fix the override key or value: pnpm refuses to honor entries with an unparsable key or empty value"
2444 .to_string(),
2445 note: Some(
2446 "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`."
2447 .to_string(),
2448 ),
2449 available_in_catalogs: None,
2450 suggested_target: None,
2451 }));
2452
2453 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
2454 entry.target_package.as_deref(),
2455 &entry.raw_key,
2456 entry.source,
2457 ) {
2458 actions.push(suppress);
2459 }
2460
2461 Self {
2462 entry,
2463 actions,
2464 introduced: None,
2465 }
2466 }
2467}
2468
2469fn build_ignore_dependency_overrides_suppress(
2474 target_package: Option<&str>,
2475 raw_key: &str,
2476 source: DependencyOverrideSource,
2477) -> Option<IssueAction> {
2478 let package = target_package
2479 .filter(|s| !s.is_empty())
2480 .or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
2481 .to_string();
2482 let mut value = serde_json::Map::new();
2483 value.insert("package".to_string(), serde_json::Value::String(package));
2484 value.insert(
2485 "source".to_string(),
2486 serde_json::Value::String(source.as_label().to_string()),
2487 );
2488 Some(IssueAction::AddToConfig(AddToConfigAction {
2489 kind: AddToConfigKind::AddToConfig,
2490 auto_fixable: false,
2491 description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
2492 config_key: "ignoreDependencyOverrides".to_string(),
2493 value: AddToConfigValue::RuleObject(value),
2494 value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
2495 }))
2496}
2497
2498#[cfg(test)]
2508mod position_0_invariants {
2509 use super::*;
2510 use crate::output::FixActionType;
2511 use crate::results::{DependencyOverrideSource, DuplicateLocation};
2512 use std::path::PathBuf;
2513
2514 fn action_type(action: &IssueAction) -> &'static str {
2519 match action {
2520 IssueAction::Fix(fix) => match fix.kind {
2521 FixActionType::RemoveExport => "remove-export",
2522 FixActionType::DeleteFile => "delete-file",
2523 FixActionType::RemoveDependency => "remove-dependency",
2524 FixActionType::MoveDependency => "move-dependency",
2525 FixActionType::RemoveEnumMember => "remove-enum-member",
2526 FixActionType::RemoveClassMember => "remove-class-member",
2527 FixActionType::ResolveImport => "resolve-import",
2528 FixActionType::InstallDependency => "install-dependency",
2529 FixActionType::RemoveDuplicate => "remove-duplicate",
2530 FixActionType::MoveToDev => "move-to-dev",
2531 FixActionType::RefactorCycle => "refactor-cycle",
2532 FixActionType::RefactorReExportCycle => "refactor-re-export-cycle",
2533 FixActionType::RefactorBoundary => "refactor-boundary",
2534 FixActionType::ExportType => "export-type",
2535 FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
2536 FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
2537 FixActionType::UpdateCatalogReference => "update-catalog-reference",
2538 FixActionType::AddCatalogEntry => "add-catalog-entry",
2539 FixActionType::RemoveCatalogReference => "remove-catalog-reference",
2540 FixActionType::RemoveDependencyOverride => "remove-dependency-override",
2541 FixActionType::FixDependencyOverride => "fix-dependency-override",
2542 FixActionType::ResolvePolicyViolation => "resolve-policy-violation",
2543 FixActionType::MoveToServerModule => "move-to-server-module",
2544 FixActionType::SplitMixedBarrel => "split-mixed-barrel",
2545 FixActionType::HoistDirective => "hoist-directive",
2546 FixActionType::WireServerAction => "wire-server-action",
2547 FixActionType::ProvideInject => "provide-inject",
2548 FixActionType::UseLoadData => "use-load-data",
2549 FixActionType::RenderComponent => "render-component",
2550 FixActionType::UseComponentProp => "use-component-prop",
2551 FixActionType::EmitComponentEvent => "emit-component-event",
2552 FixActionType::WireSvelteEvent => "wire-svelte-event",
2553 FixActionType::ResolveRouteCollision => "resolve-route-collision",
2554 FixActionType::ResolveDynamicSegmentNameConflict => {
2555 "resolve-dynamic-segment-name-conflict"
2556 }
2557 FixActionType::AddSuppressionReason => "add-suppression-reason",
2558 FixActionType::RemoveStaleSuppression => "remove-stale-suppression",
2559 },
2560 IssueAction::SuppressLine(_) => "suppress-line",
2561 IssueAction::SuppressFile(_) => "suppress-file",
2562 IssueAction::AddToConfig(_) => "add-to-config",
2563 }
2564 }
2565
2566 fn assert_manual_fix_then_suppress(
2567 actions: &[IssueAction],
2568 primary_type: &str,
2569 suppress_comment: &str,
2570 ) {
2571 assert_eq!(actions.len(), 2);
2572 assert_eq!(action_type(&actions[0]), primary_type);
2573 let IssueAction::Fix(primary) = &actions[0] else {
2574 panic!("position-0 should be a manual fix action");
2575 };
2576 assert!(!primary.auto_fixable);
2577 assert!(primary.note.is_some());
2578 assert_eq!(action_type(&actions[1]), "suppress-line");
2579 let IssueAction::SuppressLine(suppress) = &actions[1] else {
2580 panic!("position-1 should be a suppress-line action");
2581 };
2582 assert_eq!(suppress.comment, suppress_comment);
2583 }
2584
2585 #[test]
2586 fn pnpm_catalog_entry_action_is_auto_fixable() {
2587 let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
2588 entry_name: "unused".to_string(),
2589 catalog_name: "default".to_string(),
2590 path: PathBuf::from("pnpm-workspace.yaml"),
2591 line: 3,
2592 hardcoded_consumers: vec![],
2593 });
2594
2595 let IssueAction::Fix(fix) = &finding.actions[0] else {
2596 panic!("position-0 should be a fix action");
2597 };
2598 assert!(fix.auto_fixable);
2599 assert_eq!(finding.actions.len(), 2);
2600 assert_eq!(action_type(&finding.actions[1]), "suppress-line");
2601 }
2602
2603 #[test]
2604 fn bun_package_json_catalog_entry_action_is_manual_only() {
2605 let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
2606 entry_name: "unused".to_string(),
2607 catalog_name: "default".to_string(),
2608 path: PathBuf::from("package.json"),
2609 line: 4,
2610 hardcoded_consumers: vec![],
2611 });
2612
2613 let IssueAction::Fix(fix) = &finding.actions[0] else {
2614 panic!("position-0 should be a fix action");
2615 };
2616 assert!(!fix.auto_fixable);
2617 assert!(fix.description.contains("manually"));
2618 assert_eq!(finding.actions.len(), 1);
2619 }
2620
2621 #[test]
2622 fn bun_package_json_empty_catalog_group_action_is_manual_only() {
2623 let finding = EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
2624 catalog_name: "empty".to_string(),
2625 path: PathBuf::from("package.json"),
2626 line: 4,
2627 });
2628
2629 let IssueAction::Fix(fix) = &finding.actions[0] else {
2630 panic!("position-0 should be a fix action");
2631 };
2632 assert!(!fix.auto_fixable);
2633 assert!(fix.description.contains("manually"));
2634 assert_eq!(finding.actions.len(), 1);
2635 }
2636
2637 #[test]
2638 fn unprovided_inject_primary_action_is_provide_inject() {
2639 let finding = UnprovidedInjectFinding::with_actions(UnprovidedInject {
2640 path: PathBuf::from("src/context.ts"),
2641 key_name: "userKey".to_string(),
2642 framework: "svelte".to_string(),
2643 line: 7,
2644 col: 12,
2645 });
2646
2647 assert_manual_fix_then_suppress(
2648 &finding.actions,
2649 "provide-inject",
2650 "// fallow-ignore-next-line unprovided-inject",
2651 );
2652 }
2653
2654 #[test]
2655 fn unused_server_action_primary_action_is_wire_server_action() {
2656 let finding = UnusedServerActionFinding::with_actions(UnusedServerAction {
2657 path: PathBuf::from("app/actions.ts"),
2658 action_name: "saveDraft".to_string(),
2659 line: 3,
2660 col: 13,
2661 });
2662
2663 assert_manual_fix_then_suppress(
2664 &finding.actions,
2665 "wire-server-action",
2666 "// fallow-ignore-next-line unused-server-action",
2667 );
2668 }
2669
2670 #[test]
2671 fn unused_load_data_key_primary_action_is_use_load_data() {
2672 let finding = UnusedLoadDataKeyFinding::with_actions(UnusedLoadDataKey {
2673 path: PathBuf::from("src/routes/+page.server.ts"),
2674 key_name: "profile".to_string(),
2675 line: 12,
2676 col: 6,
2677 route_dir: Some("src/routes".to_string()),
2678 });
2679
2680 assert_manual_fix_then_suppress(
2681 &finding.actions,
2682 "use-load-data",
2683 "// fallow-ignore-next-line unused-load-data-key",
2684 );
2685 }
2686
2687 #[test]
2688 fn unrendered_component_primary_action_is_render_component() {
2689 let finding = UnrenderedComponentFinding::with_actions(UnrenderedComponent {
2690 path: PathBuf::from("src/components/EmptyState.vue"),
2691 component_name: "EmptyState".to_string(),
2692 framework: "vue".to_string(),
2693 reachable_via: None,
2694 line: 1,
2695 col: 0,
2696 });
2697
2698 assert_manual_fix_then_suppress(
2699 &finding.actions,
2700 "render-component",
2701 "// fallow-ignore-next-line unrendered-component",
2702 );
2703 }
2704
2705 #[test]
2706 fn unused_component_prop_primary_action_is_use_component_prop() {
2707 let finding = UnusedComponentPropFinding::with_actions(UnusedComponentProp {
2708 path: PathBuf::from("src/components/Card.vue"),
2709 component_name: "Card".to_string(),
2710 prop_name: "variant".to_string(),
2711 line: 5,
2712 col: 10,
2713 });
2714
2715 assert_manual_fix_then_suppress(
2716 &finding.actions,
2717 "use-component-prop",
2718 "// fallow-ignore-next-line unused-component-prop",
2719 );
2720 }
2721
2722 #[test]
2723 fn unused_component_emit_primary_action_is_emit_component_event() {
2724 let finding = UnusedComponentEmitFinding::with_actions(UnusedComponentEmit {
2725 path: PathBuf::from("src/components/Picker.vue"),
2726 component_name: "Picker".to_string(),
2727 emit_name: "focus".to_string(),
2728 line: 6,
2729 col: 14,
2730 });
2731
2732 assert_manual_fix_then_suppress(
2733 &finding.actions,
2734 "emit-component-event",
2735 "// fallow-ignore-next-line unused-component-emit",
2736 );
2737 }
2738
2739 #[test]
2740 fn unused_svelte_event_primary_action_is_wire_svelte_event() {
2741 let finding = UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
2742 path: PathBuf::from("src/Dialog.svelte"),
2743 component_name: "Dialog".to_string(),
2744 event_name: "closed".to_string(),
2745 line: 19,
2746 col: 8,
2747 });
2748
2749 assert_manual_fix_then_suppress(
2750 &finding.actions,
2751 "wire-svelte-event",
2752 "// fallow-ignore-next-line unused-svelte-event",
2753 );
2754 }
2755
2756 #[test]
2757 fn unresolved_import_actions_include_ignore_unresolved_imports_config_suppress() {
2758 let inner = UnresolvedImport {
2759 specifier: "@example/icons".to_string(),
2760 path: PathBuf::from("src/index.ts"),
2761 line: 4,
2762 col: 12,
2763 specifier_col: 18,
2764 };
2765 let finding = UnresolvedImportFinding::with_actions(inner);
2766
2767 assert_eq!(action_type(&finding.actions[0]), "resolve-import");
2768 assert_eq!(action_type(&finding.actions[1]), "add-to-config");
2769 let IssueAction::AddToConfig(action) = &finding.actions[1] else {
2770 panic!("position-1 should be AddToConfig");
2771 };
2772 assert!(!action.auto_fixable);
2773 assert_eq!(action.config_key, "ignoreUnresolvedImports");
2774 let AddToConfigValue::Scalar(value) = &action.value else {
2775 panic!("ignoreUnresolvedImports action should carry a scalar value");
2776 };
2777 assert_eq!(value, "@example/icons");
2778 assert_eq!(
2779 action.value_schema.as_deref(),
2780 Some(
2781 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
2782 )
2783 );
2784 }
2785
2786 #[test]
2797 fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
2798 let inner = UnresolvedCatalogReference {
2799 entry_name: "react".to_string(),
2800 catalog_name: "default".to_string(),
2801 path: PathBuf::from("apps/web/package.json"),
2802 line: 7,
2803 available_in_catalogs: Vec::new(),
2804 };
2805 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
2806 assert_eq!(
2807 action_type(&finding.actions[0]),
2808 "add-catalog-entry",
2809 "position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
2810 );
2811 let IssueAction::Fix(fix) = &finding.actions[0] else {
2812 panic!("position-0 should be an IssueAction::Fix");
2813 };
2814 assert!(
2815 fix.available_in_catalogs.is_none(),
2816 "add-catalog-entry must NOT carry available_in_catalogs"
2817 );
2818 assert!(
2819 fix.suggested_target.is_none(),
2820 "add-catalog-entry must NOT carry suggested_target"
2821 );
2822 }
2823
2824 #[test]
2831 fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
2832 let inner = UnresolvedCatalogReference {
2833 entry_name: "react".to_string(),
2834 catalog_name: "default".to_string(),
2835 path: PathBuf::from("apps/web/package.json"),
2836 line: 7,
2837 available_in_catalogs: vec!["react18".to_string()],
2838 };
2839 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
2840 assert_eq!(
2841 action_type(&finding.actions[0]),
2842 "update-catalog-reference",
2843 "position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
2844 );
2845 let IssueAction::Fix(fix) = &finding.actions[0] else {
2846 panic!("position-0 should be an IssueAction::Fix");
2847 };
2848 assert_eq!(
2849 fix.available_in_catalogs.as_deref(),
2850 Some(&["react18".to_string()][..]),
2851 "update-catalog-reference must carry the alternative list"
2852 );
2853 assert_eq!(
2854 fix.suggested_target.as_deref(),
2855 Some("react18"),
2856 "single-alternative case must surface `suggested_target` for deterministic agents"
2857 );
2858
2859 let inner_two = UnresolvedCatalogReference {
2861 entry_name: "react".to_string(),
2862 catalog_name: "default".to_string(),
2863 path: PathBuf::from("apps/web/package.json"),
2864 line: 7,
2865 available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
2866 };
2867 let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
2868 assert_eq!(
2869 action_type(&finding_two.actions[0]),
2870 "update-catalog-reference"
2871 );
2872 let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
2873 panic!("position-0 should be an IssueAction::Fix");
2874 };
2875 assert!(
2876 fix_two.suggested_target.is_none(),
2877 "multi-alternative case must NOT carry `suggested_target` (agent must pick)"
2878 );
2879 }
2880
2881 #[test]
2896 fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
2897 let inner = DuplicateExport {
2898 export_name: "Root".to_string(),
2899 locations: vec![
2900 DuplicateLocation {
2901 path: PathBuf::from("components/ui/accordion/index.ts"),
2902 line: 1,
2903 col: 0,
2904 },
2905 DuplicateLocation {
2906 path: PathBuf::from("components/ui/dialog/index.ts"),
2907 line: 1,
2908 col: 0,
2909 },
2910 ],
2911 };
2912 let finding = DuplicateExportFinding::with_actions(inner);
2913 assert_eq!(
2914 action_type(&finding.actions[0]),
2915 "add-to-config",
2916 "position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
2917 );
2918 assert_eq!(
2919 action_type(&finding.actions[1]),
2920 "remove-duplicate",
2921 "position-1 must be the destructive `remove-duplicate` fallback"
2922 );
2923
2924 let mut promoted = finding;
2927 promoted.set_config_fixable(true);
2928 assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
2929 let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
2930 panic!("position-0 should still be AddToConfig after set_config_fixable");
2931 };
2932 assert!(
2933 action.auto_fixable,
2934 "set_config_fixable(true) must flip auto_fixable"
2935 );
2936 }
2937
2938 #[test]
2943 fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
2944 let inner = DuplicateExport {
2945 export_name: "Root".to_string(),
2946 locations: Vec::new(),
2947 };
2948 let finding = DuplicateExportFinding::with_actions(inner);
2949 assert_eq!(
2950 action_type(&finding.actions[0]),
2951 "remove-duplicate",
2952 "with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
2953 );
2954
2955 let mut promoted = finding;
2957 promoted.set_config_fixable(true);
2958 assert_eq!(
2959 action_type(&promoted.actions[0]),
2960 "remove-duplicate",
2961 "set_config_fixable is a no-op when position-0 is not add-to-config"
2962 );
2963 }
2964
2965 #[test]
2971 fn misconfigured_override_drops_suppress_when_no_package_name() {
2972 let inner = MisconfiguredDependencyOverride {
2973 raw_key: String::new(),
2974 target_package: None,
2975 raw_value: String::new(),
2976 reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
2977 source: DependencyOverrideSource::PnpmWorkspaceYaml,
2978 path: PathBuf::from("pnpm-workspace.yaml"),
2979 line: 12,
2980 };
2981 let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
2982 assert_eq!(finding.actions.len(), 1);
2984 assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
2985 }
2986}