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