1use serde::Serialize;
31
32use crate::envelope::AuditIntroduced;
33use crate::output::{
34 AddToConfigAction, AddToConfigKind, AddToConfigValue, FixAction, FixActionType,
35 IgnoreExportsRule, IssueAction, SuppressFileAction, SuppressFileKind, SuppressLineAction,
36 SuppressLineKind, SuppressLineScope,
37};
38use crate::results::{
39 BoundaryCallViolation, BoundaryCoverageViolation, BoundaryViolation, CircularDependency,
40 DependencyOverrideSource, DuplicateExport, DuplicatePropShape, DynamicSegmentNameConflict,
41 EmptyCatalogGroup, InvalidClientExport, MisconfiguredDependencyOverride, MisplacedDirective,
42 MixedClientServerBarrel, PolicyViolation, PrivateTypeLeak, PropDrillingChain, ReExportCycle,
43 ReExportCycleKind, RouteCollision, TestOnlyDependency, ThinWrapper, TypeOnlyDependency,
44 UnlistedDependency, UnprovidedInject, UnrenderedComponent, UnresolvedCatalogReference,
45 UnresolvedImport, UnusedCatalogEntry, UnusedComponentEmit, UnusedComponentInput,
46 UnusedComponentOutput, UnusedComponentProp, UnusedDependency, UnusedDependencyOverride,
47 UnusedExport, UnusedFile, UnusedLoadDataKey, UnusedMember, UnusedServerAction,
48 UnusedSvelteEvent,
49};
50
51pub 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.";
55
56const IGNORE_EXPORTS_VALUE_SCHEMA: &str =
60 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreExports";
61
62const IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreCatalogReferences/items";
65
66const IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencyOverrides/items";
70
71fn manual_framework_fix(kind: FixActionType, description: &str, note: &str) -> IssueAction {
72 IssueAction::Fix(FixAction {
73 kind,
74 auto_fixable: false,
75 description: description.to_string(),
76 note: Some(note.to_string()),
77 available_in_catalogs: None,
78 suggested_target: None,
79 })
80}
81
82fn suppress_line(comment: &str) -> IssueAction {
83 IssueAction::SuppressLine(SuppressLineAction {
84 kind: SuppressLineKind::SuppressLine,
85 auto_fixable: false,
86 description: "Suppress with an inline comment above the line".to_string(),
87 comment: comment.to_string(),
88 scope: None,
89 })
90}
91
92#[derive(Debug, Clone, Serialize)]
97#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
98pub struct UnusedFileFinding {
99 #[serde(flatten)]
101 pub file: UnusedFile,
102 pub actions: Vec<IssueAction>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub introduced: Option<AuditIntroduced>,
109}
110
111impl UnusedFileFinding {
112 #[must_use]
116 pub fn with_actions(file: UnusedFile) -> Self {
117 let actions = vec![
118 IssueAction::Fix(FixAction {
119 kind: FixActionType::DeleteFile,
120 auto_fixable: false,
121 description: "Delete this file".to_string(),
122 note: Some(
123 "File deletion may remove runtime functionality not visible to static analysis"
124 .to_string(),
125 ),
126 available_in_catalogs: None,
127 suggested_target: None,
128 }),
129 IssueAction::SuppressFile(SuppressFileAction {
130 kind: SuppressFileKind::SuppressFile,
131 auto_fixable: false,
132 description: "Suppress with a file-level comment at the top of the file"
133 .to_string(),
134 comment: "// fallow-ignore-file unused-file".to_string(),
135 }),
136 ];
137 Self {
138 file,
139 actions,
140 introduced: None,
141 }
142 }
143}
144
145#[derive(Debug, Clone, Serialize)]
149#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
150pub struct PrivateTypeLeakFinding {
151 #[serde(flatten)]
153 pub leak: PrivateTypeLeak,
154 pub actions: Vec<IssueAction>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub introduced: Option<AuditIntroduced>,
161}
162
163impl PrivateTypeLeakFinding {
164 #[must_use]
166 pub fn with_actions(leak: PrivateTypeLeak) -> Self {
167 let actions = vec![
168 IssueAction::Fix(FixAction {
169 kind: FixActionType::ExportType,
170 auto_fixable: false,
171 description: "Export the referenced private type by name".to_string(),
172 note: Some(
173 "Keep the type exported while it is part of a public signature".to_string(),
174 ),
175 available_in_catalogs: None,
176 suggested_target: None,
177 }),
178 IssueAction::SuppressLine(SuppressLineAction {
179 kind: SuppressLineKind::SuppressLine,
180 auto_fixable: false,
181 description: "Suppress with an inline comment above the line".to_string(),
182 comment: "// fallow-ignore-next-line private-type-leak".to_string(),
183 scope: None,
184 }),
185 ];
186 Self {
187 leak,
188 actions,
189 introduced: None,
190 }
191 }
192}
193
194#[derive(Debug, Clone, Serialize)]
199#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
200pub struct UnresolvedImportFinding {
201 #[serde(flatten)]
203 pub import: UnresolvedImport,
204 pub actions: Vec<IssueAction>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub introduced: Option<AuditIntroduced>,
211}
212
213impl UnresolvedImportFinding {
214 #[must_use]
216 pub fn with_actions(import: UnresolvedImport) -> Self {
217 let actions = vec![
218 IssueAction::Fix(FixAction {
219 kind: FixActionType::ResolveImport,
220 auto_fixable: false,
221 description: "Fix the import specifier or install the missing module".to_string(),
222 note: Some(
223 "Verify the module path and check tsconfig paths configuration".to_string(),
224 ),
225 available_in_catalogs: None,
226 suggested_target: None,
227 }),
228 IssueAction::AddToConfig(AddToConfigAction {
229 kind: AddToConfigKind::AddToConfig,
230 auto_fixable: false,
231 description: format!(
232 "Add \"{}\" to ignoreUnresolvedImports in fallow config",
233 import.specifier
234 ),
235 config_key: "ignoreUnresolvedImports".to_string(),
236 value: AddToConfigValue::Scalar(import.specifier.clone()),
237 value_schema: Some(
238 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
239 .to_string(),
240 ),
241 }),
242 IssueAction::SuppressLine(SuppressLineAction {
243 kind: SuppressLineKind::SuppressLine,
244 auto_fixable: false,
245 description: "Suppress with an inline comment above the line".to_string(),
246 comment: "// fallow-ignore-next-line unresolved-import".to_string(),
247 scope: None,
248 }),
249 ];
250 Self {
251 import,
252 actions,
253 introduced: None,
254 }
255 }
256}
257
258#[derive(Debug, Clone, Serialize)]
263#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
264pub struct CircularDependencyFinding {
265 #[serde(flatten)]
267 pub cycle: CircularDependency,
268 pub actions: Vec<IssueAction>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub introduced: Option<AuditIntroduced>,
275}
276
277impl CircularDependencyFinding {
278 #[must_use]
280 pub fn with_actions(cycle: CircularDependency) -> Self {
281 let actions = vec![
282 IssueAction::Fix(FixAction {
283 kind: FixActionType::RefactorCycle,
284 auto_fixable: false,
285 description: "Extract shared logic into a separate module to break the cycle"
286 .to_string(),
287 note: Some(
288 "Circular imports can cause initialization issues and make code harder to reason about"
289 .to_string(),
290 ),
291 available_in_catalogs: None,
292 suggested_target: None,
293 }),
294 IssueAction::SuppressLine(SuppressLineAction {
295 kind: SuppressLineKind::SuppressLine,
296 auto_fixable: false,
297 description: "Suppress with an inline comment above the line".to_string(),
298 comment: "// fallow-ignore-next-line circular-dependency".to_string(),
299 scope: None,
300 }),
301 ];
302 Self {
303 cycle,
304 actions,
305 introduced: None,
306 }
307 }
308}
309
310#[derive(Debug, Clone, Serialize)]
318#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
319pub struct ReExportCycleFinding {
320 #[serde(flatten)]
322 pub cycle: ReExportCycle,
323 pub actions: Vec<IssueAction>,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub introduced: Option<AuditIntroduced>,
330}
331
332impl ReExportCycleFinding {
333 #[must_use]
340 pub fn with_actions(cycle: ReExportCycle) -> Self {
341 let suppress_description = match cycle.kind {
347 ReExportCycleKind::SelfLoop => {
348 "Suppress with a file-level comment at the top of this file. \
349 The cycle is a self-loop, so the suppression covers the entire finding."
350 .to_string()
351 }
352 ReExportCycleKind::MultiNode => {
353 "Suppress with a file-level comment at the top of this file. \
354 One suppression on any member breaks the cycle for every member \
355 (see the sibling `files` array)."
356 .to_string()
357 }
358 };
359 let actions = vec![
360 IssueAction::Fix(FixAction {
361 kind: FixActionType::RefactorReExportCycle,
362 auto_fixable: false,
363 description: "Remove one `export * from` (or `export { ... } from`) \
364 statement on any one member to break the cycle"
365 .to_string(),
366 note: Some(
367 "Re-export cycles are structurally a no-op: chain propagation through \
368 the loop never reaches a terminating module, so imports from any member \
369 may silently come up empty."
370 .to_string(),
371 ),
372 available_in_catalogs: None,
373 suggested_target: None,
374 }),
375 IssueAction::SuppressFile(SuppressFileAction {
376 kind: SuppressFileKind::SuppressFile,
377 auto_fixable: false,
378 description: suppress_description,
379 comment: "// fallow-ignore-file re-export-cycle".to_string(),
380 }),
381 ];
382 Self {
383 cycle,
384 actions,
385 introduced: None,
386 }
387 }
388}
389
390#[derive(Debug, Clone, Serialize)]
395#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
396pub struct BoundaryViolationFinding {
397 #[serde(flatten)]
399 pub violation: BoundaryViolation,
400 pub actions: Vec<IssueAction>,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
406 pub introduced: Option<AuditIntroduced>,
407}
408
409impl BoundaryViolationFinding {
410 #[must_use]
412 pub fn with_actions(violation: BoundaryViolation) -> Self {
413 let actions = vec![
414 IssueAction::Fix(FixAction {
415 kind: FixActionType::RefactorBoundary,
416 auto_fixable: false,
417 description: "Move the import through an allowed zone or restructure the dependency"
418 .to_string(),
419 note: Some(
420 "This import crosses an architecture boundary that is not permitted by the configured rules"
421 .to_string(),
422 ),
423 available_in_catalogs: None,
424 suggested_target: None,
425 }),
426 IssueAction::SuppressLine(SuppressLineAction {
427 kind: SuppressLineKind::SuppressLine,
428 auto_fixable: false,
429 description: "Suppress with an inline comment above the line".to_string(),
430 comment: "// fallow-ignore-next-line boundary-violation".to_string(),
431 scope: None,
432 }),
433 ];
434 Self {
435 violation,
436 actions,
437 introduced: None,
438 }
439 }
440}
441
442#[derive(Debug, Clone, Serialize)]
446#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
447pub struct BoundaryCoverageViolationFinding {
448 #[serde(flatten)]
450 pub violation: BoundaryCoverageViolation,
451 pub actions: Vec<IssueAction>,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub introduced: Option<AuditIntroduced>,
457}
458
459impl BoundaryCoverageViolationFinding {
460 #[must_use]
462 pub fn with_actions(violation: BoundaryCoverageViolation) -> Self {
463 let path = violation.path.to_string_lossy().replace('\\', "/");
464 let actions = vec![
465 IssueAction::Fix(FixAction {
466 kind: FixActionType::RefactorBoundary,
467 auto_fixable: false,
468 description: "Add this file to a boundary zone pattern or move it under an existing zone"
469 .to_string(),
470 note: Some(
471 "Boundary coverage is enabled, so every analyzed source file must match a zone unless allow-listed"
472 .to_string(),
473 ),
474 available_in_catalogs: None,
475 suggested_target: None,
476 }),
477 IssueAction::AddToConfig(AddToConfigAction {
478 kind: AddToConfigKind::AddToConfig,
479 auto_fixable: false,
480 description: format!(
481 "Add \"{path}\" to boundaries.coverage.allowUnmatched in fallow config"
482 ),
483 config_key: "boundaries.coverage.allowUnmatched".to_string(),
484 value: AddToConfigValue::Scalar(path),
485 value_schema: Some(
486 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/boundaries/properties/coverage/properties/allowUnmatched/items"
487 .to_string(),
488 ),
489 }),
490 IssueAction::SuppressFile(SuppressFileAction {
491 kind: SuppressFileKind::SuppressFile,
492 auto_fixable: false,
493 description: "Suppress with a file-level comment at the top of the file"
494 .to_string(),
495 comment: "// fallow-ignore-file boundary-violation".to_string(),
496 }),
497 ];
498 Self {
499 violation,
500 actions,
501 introduced: None,
502 }
503 }
504}
505
506#[derive(Debug, Clone, Serialize)]
510#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
511pub struct BoundaryCallViolationFinding {
512 #[serde(flatten)]
514 pub violation: BoundaryCallViolation,
515 pub actions: Vec<IssueAction>,
517 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub introduced: Option<AuditIntroduced>,
521}
522
523impl BoundaryCallViolationFinding {
524 #[must_use]
526 pub fn with_actions(violation: BoundaryCallViolation) -> Self {
527 let actions = vec![
528 IssueAction::Fix(FixAction {
529 kind: FixActionType::RefactorBoundary,
530 auto_fixable: false,
531 description: format!(
532 "Move the `{}` call out of zone '{}' or behind an allowed abstraction",
533 violation.callee, violation.zone,
534 ),
535 note: Some(format!(
536 "`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",
537 violation.pattern, violation.zone,
538 )),
539 available_in_catalogs: None,
540 suggested_target: None,
541 }),
542 IssueAction::SuppressLine(SuppressLineAction {
543 kind: SuppressLineKind::SuppressLine,
544 auto_fixable: false,
545 description: "Suppress with an inline comment above the line".to_string(),
546 comment: "// fallow-ignore-next-line boundary-violation".to_string(),
547 scope: None,
548 }),
549 IssueAction::SuppressFile(SuppressFileAction {
550 kind: SuppressFileKind::SuppressFile,
551 auto_fixable: false,
552 description: "Suppress with a file-level comment at the top of the file"
553 .to_string(),
554 comment: "// fallow-ignore-file boundary-violation".to_string(),
555 }),
556 ];
557 Self {
558 violation,
559 actions,
560 introduced: None,
561 }
562 }
563}
564
565#[derive(Debug, Clone, Serialize)]
569#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
570pub struct PolicyViolationFinding {
571 #[serde(flatten)]
573 pub violation: PolicyViolation,
574 pub actions: Vec<IssueAction>,
576 #[serde(default, skip_serializing_if = "Option::is_none")]
579 pub introduced: Option<AuditIntroduced>,
580}
581
582impl PolicyViolationFinding {
583 #[must_use]
585 pub fn with_actions(violation: PolicyViolation) -> Self {
586 let what = match violation.kind {
587 crate::results::PolicyRuleKind::BannedCall => "call",
588 crate::results::PolicyRuleKind::BannedImport => "import",
589 };
590 let description = match &violation.message {
591 Some(message) => format!("Replace the `{}` {what}: {message}", violation.matched),
592 None => format!("Replace the `{}` {what}", violation.matched),
593 };
594 let suppress_token = format!("policy-violation:{}/{}", violation.pack, violation.rule_id);
595 let actions = vec![
596 IssueAction::Fix(FixAction {
597 kind: FixActionType::ResolvePolicyViolation,
598 auto_fixable: false,
599 description,
600 note: Some(format!(
601 "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",
602 violation.pack, violation.rule_id,
603 )),
604 available_in_catalogs: None,
605 suggested_target: None,
606 }),
607 IssueAction::SuppressLine(SuppressLineAction {
608 kind: SuppressLineKind::SuppressLine,
609 auto_fixable: false,
610 description: "Suppress this rule-pack rule with an inline comment above the line"
611 .to_string(),
612 comment: format!("// fallow-ignore-next-line {suppress_token}"),
613 scope: None,
614 }),
615 IssueAction::SuppressFile(SuppressFileAction {
616 kind: SuppressFileKind::SuppressFile,
617 auto_fixable: false,
618 description:
619 "Suppress this rule-pack rule with a file-level comment at the top of the file"
620 .to_string(),
621 comment: format!("// fallow-ignore-file {suppress_token}"),
622 }),
623 ];
624 Self {
625 violation,
626 actions,
627 introduced: None,
628 }
629 }
630}
631
632#[derive(Debug, Clone, Serialize)]
637#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
638pub struct UnusedExportFinding {
639 #[serde(flatten)]
641 pub export: UnusedExport,
642 pub actions: Vec<IssueAction>,
645 #[serde(default, skip_serializing_if = "Option::is_none")]
648 pub introduced: Option<AuditIntroduced>,
649}
650
651impl UnusedExportFinding {
652 #[must_use]
656 pub fn with_actions(export: UnusedExport) -> Self {
657 let note = if export.is_re_export {
658 Some(
659 "This finding originates from a re-export; verify it is not part of your public API before removing"
660 .to_string(),
661 )
662 } else {
663 None
664 };
665 let actions = vec![
666 IssueAction::Fix(FixAction {
667 kind: FixActionType::RemoveExport,
668 auto_fixable: true,
669 description: "Remove the unused export from the public API".to_string(),
670 note,
671 available_in_catalogs: None,
672 suggested_target: None,
673 }),
674 IssueAction::SuppressLine(SuppressLineAction {
675 kind: SuppressLineKind::SuppressLine,
676 auto_fixable: false,
677 description: "Suppress with an inline comment above the line".to_string(),
678 comment: "// fallow-ignore-next-line unused-export".to_string(),
679 scope: None,
680 }),
681 ];
682 Self {
683 export,
684 actions,
685 introduced: None,
686 }
687 }
688}
689
690#[derive(Debug, Clone, Serialize)]
695#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
696pub struct UnusedTypeFinding {
697 #[serde(flatten)]
699 pub export: UnusedExport,
700 pub actions: Vec<IssueAction>,
703 #[serde(default, skip_serializing_if = "Option::is_none")]
706 pub introduced: Option<AuditIntroduced>,
707}
708
709impl UnusedTypeFinding {
710 #[must_use]
713 pub fn with_actions(export: UnusedExport) -> Self {
714 let note = if export.is_re_export {
715 Some(
716 "This finding originates from a re-export; verify it is not part of your public API before removing"
717 .to_string(),
718 )
719 } else {
720 None
721 };
722 let actions = vec![
723 IssueAction::Fix(FixAction {
724 kind: FixActionType::RemoveExport,
725 auto_fixable: true,
726 description:
727 "Remove the `export` (or `export type`) keyword from the type declaration"
728 .to_string(),
729 note,
730 available_in_catalogs: None,
731 suggested_target: None,
732 }),
733 IssueAction::SuppressLine(SuppressLineAction {
734 kind: SuppressLineKind::SuppressLine,
735 auto_fixable: false,
736 description: "Suppress with an inline comment above the line".to_string(),
737 comment: "// fallow-ignore-next-line unused-type".to_string(),
738 scope: None,
739 }),
740 ];
741 Self {
742 export,
743 actions,
744 introduced: None,
745 }
746 }
747}
748
749#[derive(Debug, Clone, Serialize)]
755#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
756pub struct InvalidClientExportFinding {
757 #[serde(flatten)]
759 pub export: InvalidClientExport,
760 pub actions: Vec<IssueAction>,
763 #[serde(default, skip_serializing_if = "Option::is_none")]
766 pub introduced: Option<AuditIntroduced>,
767}
768
769impl InvalidClientExportFinding {
770 #[must_use]
775 pub fn with_actions(export: InvalidClientExport) -> Self {
776 let actions = vec![
777 IssueAction::Fix(FixAction {
778 kind: FixActionType::MoveToServerModule,
779 auto_fixable: false,
780 description: "Move the server-only export to a non-client module and import it from there"
781 .to_string(),
782 note: Some(
783 "A \"use client\" file cannot export a Next.js server-only or route-config name; Next.js rejects it at build time"
784 .to_string(),
785 ),
786 available_in_catalogs: None,
787 suggested_target: None,
788 }),
789 IssueAction::SuppressLine(SuppressLineAction {
790 kind: SuppressLineKind::SuppressLine,
791 auto_fixable: false,
792 description: "Suppress with an inline comment above the line".to_string(),
793 comment: "// fallow-ignore-next-line invalid-client-export".to_string(),
794 scope: None,
795 }),
796 ];
797 Self {
798 export,
799 actions,
800 introduced: None,
801 }
802 }
803}
804
805#[derive(Debug, Clone, Serialize)]
811#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
812pub struct MixedClientServerBarrelFinding {
813 #[serde(flatten)]
815 pub barrel: MixedClientServerBarrel,
816 pub actions: Vec<IssueAction>,
819 #[serde(default, skip_serializing_if = "Option::is_none")]
822 pub introduced: Option<AuditIntroduced>,
823}
824
825impl MixedClientServerBarrelFinding {
826 #[must_use]
831 pub fn with_actions(barrel: MixedClientServerBarrel) -> Self {
832 let actions = vec![
833 IssueAction::Fix(FixAction {
834 kind: FixActionType::SplitMixedBarrel,
835 auto_fixable: false,
836 description: "Split the barrel so client and server-only modules are re-exported from separate files"
837 .to_string(),
838 note: Some(
839 "Importing one name from this barrel drags the other's directive across the client/server boundary"
840 .to_string(),
841 ),
842 available_in_catalogs: None,
843 suggested_target: None,
844 }),
845 IssueAction::SuppressLine(SuppressLineAction {
846 kind: SuppressLineKind::SuppressLine,
847 auto_fixable: false,
848 description: "Suppress with an inline comment above the line".to_string(),
849 comment: "// fallow-ignore-next-line mixed-client-server-barrel".to_string(),
850 scope: None,
851 }),
852 ];
853 Self {
854 barrel,
855 actions,
856 introduced: None,
857 }
858 }
859}
860
861#[derive(Debug, Clone, Serialize)]
867#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
868pub struct MisplacedDirectiveFinding {
869 #[serde(flatten)]
871 pub directive_site: MisplacedDirective,
872 pub actions: Vec<IssueAction>,
875 #[serde(default, skip_serializing_if = "Option::is_none")]
878 pub introduced: Option<AuditIntroduced>,
879}
880
881impl MisplacedDirectiveFinding {
882 #[must_use]
887 pub fn with_actions(directive_site: MisplacedDirective) -> Self {
888 let actions = vec![
889 IssueAction::Fix(FixAction {
890 kind: FixActionType::HoistDirective,
891 auto_fixable: false,
892 description: "Move the directive to the very top of the file, above all imports and statements"
893 .to_string(),
894 note: Some(
895 "An RSC bundler honors the directive only in the leading prologue; here it precedes other statements and is silently ignored"
896 .to_string(),
897 ),
898 available_in_catalogs: None,
899 suggested_target: None,
900 }),
901 IssueAction::SuppressLine(SuppressLineAction {
902 kind: SuppressLineKind::SuppressLine,
903 auto_fixable: false,
904 description: "Suppress with an inline comment above the line".to_string(),
905 comment: "// fallow-ignore-next-line misplaced-directive".to_string(),
906 scope: None,
907 }),
908 ];
909 Self {
910 directive_site,
911 actions,
912 introduced: None,
913 }
914 }
915}
916
917#[derive(Debug, Clone, Serialize)]
922#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
923pub struct UnprovidedInjectFinding {
924 #[serde(flatten)]
926 pub inject: UnprovidedInject,
927 pub actions: Vec<IssueAction>,
930 #[serde(default, skip_serializing_if = "Option::is_none")]
933 pub introduced: Option<AuditIntroduced>,
934}
935
936impl UnprovidedInjectFinding {
937 #[must_use]
940 pub fn with_actions(inject: UnprovidedInject) -> Self {
941 let actions = vec![
942 manual_framework_fix(
943 FixActionType::ProvideInject,
944 "Provide this injected key, or remove the inject / getContext call",
945 "Manual review required: dependency-injection keys can be provided by framework wiring, tests, or package consumers outside this project.",
946 ),
947 suppress_line("// fallow-ignore-next-line unprovided-inject"),
948 ];
949 Self {
950 inject,
951 actions,
952 introduced: None,
953 }
954 }
955}
956
957#[derive(Debug, Clone, Serialize)]
962#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
963pub struct UnusedServerActionFinding {
964 #[serde(flatten)]
966 pub action: UnusedServerAction,
967 pub actions: Vec<IssueAction>,
970 #[serde(default, skip_serializing_if = "Option::is_none")]
973 pub introduced: Option<AuditIntroduced>,
974}
975
976impl UnusedServerActionFinding {
977 #[must_use]
980 pub fn with_actions(action: UnusedServerAction) -> Self {
981 let actions = vec![
982 manual_framework_fix(
983 FixActionType::WireServerAction,
984 "Wire the server action to a caller or form action, or remove it",
985 "Manual review required: server actions may still be POST-able by action id or invoked reflectively outside the static project graph.",
986 ),
987 suppress_line("// fallow-ignore-next-line unused-server-action"),
988 ];
989 Self {
990 action,
991 actions,
992 introduced: None,
993 }
994 }
995}
996
997#[derive(Debug, Clone, Serialize)]
1002#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1003pub struct UnusedLoadDataKeyFinding {
1004 #[serde(flatten)]
1006 pub key: UnusedLoadDataKey,
1007 pub actions: Vec<IssueAction>,
1010 #[serde(default, skip_serializing_if = "Option::is_none")]
1013 pub introduced: Option<AuditIntroduced>,
1014}
1015
1016impl UnusedLoadDataKeyFinding {
1017 #[must_use]
1020 pub fn with_actions(key: UnusedLoadDataKey) -> Self {
1021 let actions = vec![
1022 manual_framework_fix(
1023 FixActionType::UseLoadData,
1024 "Read this load data key from the route UI, or remove it from the load return",
1025 "Manual review required: load functions can perform real server or database work, so verify side effects before deleting the producer.",
1026 ),
1027 suppress_line("// fallow-ignore-next-line unused-load-data-key"),
1028 ];
1029 Self {
1030 key,
1031 actions,
1032 introduced: None,
1033 }
1034 }
1035}
1036
1037#[derive(Debug, Clone, Serialize)]
1042#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1043pub struct UnrenderedComponentFinding {
1044 #[serde(flatten)]
1046 pub component: UnrenderedComponent,
1047 pub actions: Vec<IssueAction>,
1050 #[serde(default, skip_serializing_if = "Option::is_none")]
1053 pub introduced: Option<AuditIntroduced>,
1054}
1055
1056impl UnrenderedComponentFinding {
1057 #[must_use]
1060 pub fn with_actions(component: UnrenderedComponent) -> Self {
1061 let actions = vec![
1062 manual_framework_fix(
1063 FixActionType::RenderComponent,
1064 "Render the reachable component from project code, or remove it",
1065 "Manual review required: exported library components and dynamic render registries can be intentionally reachable without static template usage.",
1066 ),
1067 suppress_line("// fallow-ignore-next-line unrendered-component"),
1068 ];
1069 Self {
1070 component,
1071 actions,
1072 introduced: None,
1073 }
1074 }
1075}
1076
1077#[derive(Debug, Clone, Serialize)]
1082#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1083pub struct UnusedComponentPropFinding {
1084 #[serde(flatten)]
1086 pub prop: UnusedComponentProp,
1087 pub actions: Vec<IssueAction>,
1090 #[serde(default, skip_serializing_if = "Option::is_none")]
1093 pub introduced: Option<AuditIntroduced>,
1094}
1095
1096impl UnusedComponentPropFinding {
1097 #[must_use]
1100 pub fn with_actions(prop: UnusedComponentProp) -> Self {
1101 let actions = vec![
1102 manual_framework_fix(
1103 FixActionType::UseComponentProp,
1104 "Use the declared prop in the component, or remove it from the component API",
1105 "Manual review required: public component APIs can intentionally keep stable props for external consumers.",
1106 ),
1107 suppress_line("// fallow-ignore-next-line unused-component-prop"),
1108 ];
1109 Self {
1110 prop,
1111 actions,
1112 introduced: None,
1113 }
1114 }
1115}
1116
1117#[derive(Debug, Clone, Serialize)]
1122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1123pub struct UnusedComponentEmitFinding {
1124 #[serde(flatten)]
1126 pub emit: UnusedComponentEmit,
1127 pub actions: Vec<IssueAction>,
1130 #[serde(default, skip_serializing_if = "Option::is_none")]
1133 pub introduced: Option<AuditIntroduced>,
1134}
1135
1136impl UnusedComponentEmitFinding {
1137 #[must_use]
1140 pub fn with_actions(emit: UnusedComponentEmit) -> Self {
1141 let actions = vec![
1142 manual_framework_fix(
1143 FixActionType::EmitComponentEvent,
1144 "Emit the declared event from the component, or remove it from the component API",
1145 "Manual review required: public component APIs can intentionally keep stable events for external listeners.",
1146 ),
1147 suppress_line("// fallow-ignore-next-line unused-component-emit"),
1148 ];
1149 Self {
1150 emit,
1151 actions,
1152 introduced: None,
1153 }
1154 }
1155}
1156
1157#[derive(Debug, Clone, Serialize)]
1163#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1164pub struct UnusedSvelteEventFinding {
1165 #[serde(flatten)]
1167 pub event: UnusedSvelteEvent,
1168 pub actions: Vec<IssueAction>,
1171 #[serde(default, skip_serializing_if = "Option::is_none")]
1174 pub introduced: Option<AuditIntroduced>,
1175}
1176
1177impl UnusedSvelteEventFinding {
1178 #[must_use]
1181 pub fn with_actions(event: UnusedSvelteEvent) -> Self {
1182 let actions = vec![
1183 manual_framework_fix(
1184 FixActionType::WireSvelteEvent,
1185 "Add or forward a listener for this custom event, or remove the dispatch",
1186 "Manual review required: public Svelte component APIs can intentionally dispatch events for package consumers outside this project.",
1187 ),
1188 suppress_line("// fallow-ignore-next-line unused-svelte-event"),
1189 ];
1190 Self {
1191 event,
1192 actions,
1193 introduced: None,
1194 }
1195 }
1196}
1197
1198#[derive(Debug, Clone, Serialize)]
1204#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1205pub struct PropDrillingChainFinding {
1206 #[serde(flatten)]
1208 pub chain: PropDrillingChain,
1209 pub actions: Vec<IssueAction>,
1212 #[serde(default, skip_serializing_if = "Option::is_none")]
1215 pub introduced: Option<AuditIntroduced>,
1216}
1217
1218impl PropDrillingChainFinding {
1219 #[must_use]
1224 pub fn with_actions(chain: PropDrillingChain) -> Self {
1225 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1226 kind: SuppressLineKind::SuppressLine,
1227 auto_fixable: false,
1228 description: "Suppress with an inline comment above the source prop declaration"
1229 .to_string(),
1230 comment: "// fallow-ignore-next-line prop-drilling".to_string(),
1231 scope: None,
1232 })];
1233 Self {
1234 chain,
1235 actions,
1236 introduced: None,
1237 }
1238 }
1239}
1240
1241#[derive(Debug, Clone, Serialize)]
1247#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1248pub struct ThinWrapperFinding {
1249 #[serde(flatten)]
1251 pub wrapper: ThinWrapper,
1252 pub actions: Vec<IssueAction>,
1255 #[serde(default, skip_serializing_if = "Option::is_none")]
1258 pub introduced: Option<AuditIntroduced>,
1259}
1260
1261impl ThinWrapperFinding {
1262 #[must_use]
1266 pub fn with_actions(wrapper: ThinWrapper) -> Self {
1267 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1268 kind: SuppressLineKind::SuppressLine,
1269 auto_fixable: false,
1270 description: "Suppress with an inline comment above the component definition"
1271 .to_string(),
1272 comment: "// fallow-ignore-next-line thin-wrapper".to_string(),
1273 scope: None,
1274 })];
1275 Self {
1276 wrapper,
1277 actions,
1278 introduced: None,
1279 }
1280 }
1281}
1282
1283#[derive(Debug, Clone, Serialize)]
1291#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1292pub struct DuplicatePropShapeFinding {
1293 #[serde(flatten)]
1295 pub shape: DuplicatePropShape,
1296 pub actions: Vec<IssueAction>,
1299 #[serde(default, skip_serializing_if = "Option::is_none")]
1302 pub introduced: Option<AuditIntroduced>,
1303}
1304
1305impl DuplicatePropShapeFinding {
1306 #[must_use]
1313 pub fn with_actions(shape: DuplicatePropShape) -> Self {
1314 let actions = vec![
1315 IssueAction::SuppressLine(SuppressLineAction {
1316 kind: SuppressLineKind::SuppressLine,
1317 auto_fixable: false,
1318 description: "Three or more components share this exact prop shape. Extract one \
1319 shared `Props` type (or a base component) that every member reuses, \
1320 or keep them separate if a per-variant divergence is planned. \
1321 Suppress one member with an inline comment above the component \
1322 definition."
1323 .to_string(),
1324 comment: "// fallow-ignore-next-line duplicate-prop-shape".to_string(),
1325 scope: None,
1326 }),
1327 IssueAction::SuppressFile(SuppressFileAction {
1328 kind: SuppressFileKind::SuppressFile,
1329 auto_fixable: false,
1330 description: "Escape hatch: a file-level suppress silences this member but it \
1331 still appears in its siblings' `sharing_components` (the group is \
1332 real regardless of suppression)."
1333 .to_string(),
1334 comment: "// fallow-ignore-file duplicate-prop-shape".to_string(),
1335 }),
1336 ];
1337 Self {
1338 shape,
1339 actions,
1340 introduced: None,
1341 }
1342 }
1343}
1344
1345#[derive(Debug, Clone, Serialize)]
1350#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1351pub struct UnusedComponentInputFinding {
1352 #[serde(flatten)]
1354 pub input: UnusedComponentInput,
1355 pub actions: Vec<IssueAction>,
1358 #[serde(default, skip_serializing_if = "Option::is_none")]
1361 pub introduced: Option<AuditIntroduced>,
1362}
1363
1364impl UnusedComponentInputFinding {
1365 #[must_use]
1369 pub fn with_actions(input: UnusedComponentInput) -> Self {
1370 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1371 kind: SuppressLineKind::SuppressLine,
1372 auto_fixable: false,
1373 description: "Suppress with an inline comment above the line".to_string(),
1374 comment: "// fallow-ignore-next-line unused-component-input".to_string(),
1375 scope: None,
1376 })];
1377 Self {
1378 input,
1379 actions,
1380 introduced: None,
1381 }
1382 }
1383}
1384
1385#[derive(Debug, Clone, Serialize)]
1390#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1391pub struct UnusedComponentOutputFinding {
1392 #[serde(flatten)]
1394 pub output: UnusedComponentOutput,
1395 pub actions: Vec<IssueAction>,
1398 #[serde(default, skip_serializing_if = "Option::is_none")]
1401 pub introduced: Option<AuditIntroduced>,
1402}
1403
1404impl UnusedComponentOutputFinding {
1405 #[must_use]
1409 pub fn with_actions(output: UnusedComponentOutput) -> Self {
1410 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1411 kind: SuppressLineKind::SuppressLine,
1412 auto_fixable: false,
1413 description: "Suppress with an inline comment above the line".to_string(),
1414 comment: "// fallow-ignore-next-line unused-component-output".to_string(),
1415 scope: None,
1416 })];
1417 Self {
1418 output,
1419 actions,
1420 introduced: None,
1421 }
1422 }
1423}
1424
1425#[derive(Debug, Clone, Serialize)]
1431#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1432pub struct RouteCollisionFinding {
1433 #[serde(flatten)]
1435 pub collision: RouteCollision,
1436 pub actions: Vec<IssueAction>,
1439 #[serde(default, skip_serializing_if = "Option::is_none")]
1442 pub introduced: Option<AuditIntroduced>,
1443}
1444
1445impl RouteCollisionFinding {
1446 #[must_use]
1450 pub fn with_actions(collision: RouteCollision) -> Self {
1451 let actions = vec![
1452 IssueAction::Fix(FixAction {
1453 kind: FixActionType::ResolveRouteCollision,
1454 auto_fixable: false,
1455 description: "Two or more files resolve to the same URL. Move or merge one so \
1456 each URL has a single owner. Route groups `(name)` and parallel \
1457 slots `@name` are the only legal same-URL shapes."
1458 .to_string(),
1459 note: Some(
1460 "Next.js fails the build with \"You cannot have two parallel pages that \
1461 resolve to the same path\". See the sibling `conflicting_paths` array for \
1462 the other files that own this URL."
1463 .to_string(),
1464 ),
1465 available_in_catalogs: None,
1466 suggested_target: None,
1467 }),
1468 IssueAction::SuppressFile(SuppressFileAction {
1469 kind: SuppressFileKind::SuppressFile,
1470 auto_fixable: false,
1471 description: "Escape hatch only: a file-level suppress silences the finding but \
1472 does NOT make `next build` pass. Prefer moving or merging a file."
1473 .to_string(),
1474 comment: "// fallow-ignore-file route-collision".to_string(),
1475 }),
1476 ];
1477 Self {
1478 collision,
1479 actions,
1480 introduced: None,
1481 }
1482 }
1483}
1484
1485#[derive(Debug, Clone, Serialize)]
1490#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1491pub struct DynamicSegmentNameConflictFinding {
1492 #[serde(flatten)]
1494 pub conflict: DynamicSegmentNameConflict,
1495 pub actions: Vec<IssueAction>,
1498 #[serde(default, skip_serializing_if = "Option::is_none")]
1501 pub introduced: Option<AuditIntroduced>,
1502}
1503
1504impl DynamicSegmentNameConflictFinding {
1505 #[must_use]
1508 pub fn with_actions(conflict: DynamicSegmentNameConflict) -> Self {
1509 let actions = vec![
1510 IssueAction::Fix(FixAction {
1511 kind: FixActionType::ResolveDynamicSegmentNameConflict,
1512 auto_fixable: false,
1513 description: "Sibling dynamic segments at the same position use different param \
1514 names. Rename them to one consistent slug name (e.g. pick `[id]` \
1515 or `[slug]` for both)."
1516 .to_string(),
1517 note: Some(
1518 "Next.js throws \"You cannot use different slug names for the same dynamic \
1519 path\" at dev / runtime when the position is hit; `next build` does not \
1520 catch it. See the sibling `conflicting_segments` array."
1521 .to_string(),
1522 ),
1523 available_in_catalogs: None,
1524 suggested_target: None,
1525 }),
1526 IssueAction::SuppressFile(SuppressFileAction {
1527 kind: SuppressFileKind::SuppressFile,
1528 auto_fixable: false,
1529 description: "Escape hatch only: a file-level suppress silences the finding but \
1530 does NOT stop Next.js from throwing at dev / runtime. Prefer \
1531 renaming the segments."
1532 .to_string(),
1533 comment: "// fallow-ignore-file dynamic-segment-name-conflict".to_string(),
1534 }),
1535 ];
1536 Self {
1537 conflict,
1538 actions,
1539 introduced: None,
1540 }
1541 }
1542}
1543
1544#[derive(Debug, Clone, Serialize)]
1547#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1548pub struct UnusedEnumMemberFinding {
1549 #[serde(flatten)]
1551 pub member: UnusedMember,
1552 pub actions: Vec<IssueAction>,
1555 #[serde(default, skip_serializing_if = "Option::is_none")]
1558 pub introduced: Option<AuditIntroduced>,
1559}
1560
1561impl UnusedEnumMemberFinding {
1562 #[must_use]
1564 pub fn with_actions(member: UnusedMember) -> Self {
1565 let actions = vec![
1566 IssueAction::Fix(FixAction {
1567 kind: FixActionType::RemoveEnumMember,
1568 auto_fixable: true,
1569 description: "Remove this enum member".to_string(),
1570 note: None,
1571 available_in_catalogs: None,
1572 suggested_target: None,
1573 }),
1574 IssueAction::SuppressLine(SuppressLineAction {
1575 kind: SuppressLineKind::SuppressLine,
1576 auto_fixable: false,
1577 description: "Suppress with an inline comment above the line".to_string(),
1578 comment: "// fallow-ignore-next-line unused-enum-member".to_string(),
1579 scope: None,
1580 }),
1581 ];
1582 Self {
1583 member,
1584 actions,
1585 introduced: None,
1586 }
1587 }
1588}
1589
1590#[derive(Debug, Clone, Serialize)]
1595#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1596pub struct UnusedClassMemberFinding {
1597 #[serde(flatten)]
1599 pub member: UnusedMember,
1600 pub actions: Vec<IssueAction>,
1603 #[serde(default, skip_serializing_if = "Option::is_none")]
1606 pub introduced: Option<AuditIntroduced>,
1607}
1608
1609impl UnusedClassMemberFinding {
1610 #[must_use]
1615 pub fn with_actions(member: UnusedMember) -> Self {
1616 let actions = vec![
1617 IssueAction::Fix(FixAction {
1618 kind: FixActionType::RemoveClassMember,
1619 auto_fixable: false,
1620 description: "Remove this class member".to_string(),
1621 note: Some(
1622 "Class member may be used via dependency injection or decorators".to_string(),
1623 ),
1624 available_in_catalogs: None,
1625 suggested_target: None,
1626 }),
1627 IssueAction::SuppressLine(SuppressLineAction {
1628 kind: SuppressLineKind::SuppressLine,
1629 auto_fixable: false,
1630 description: "Suppress with an inline comment above the line".to_string(),
1631 comment: "// fallow-ignore-next-line unused-class-member".to_string(),
1632 scope: None,
1633 }),
1634 ];
1635 Self {
1636 member,
1637 actions,
1638 introduced: None,
1639 }
1640 }
1641}
1642
1643#[derive(Debug, Clone, Serialize)]
1652#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1653pub struct UnusedStoreMemberFinding {
1654 #[serde(flatten)]
1656 pub member: UnusedMember,
1657 pub actions: Vec<IssueAction>,
1660 #[serde(default, skip_serializing_if = "Option::is_none")]
1663 pub introduced: Option<AuditIntroduced>,
1664}
1665
1666impl UnusedStoreMemberFinding {
1667 #[must_use]
1671 pub fn with_actions(member: UnusedMember) -> Self {
1672 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1673 kind: SuppressLineKind::SuppressLine,
1674 auto_fixable: false,
1675 description: "Suppress with an inline comment above the line".to_string(),
1676 comment: "// fallow-ignore-next-line unused-store-member".to_string(),
1677 scope: None,
1678 })];
1679 Self {
1680 member,
1681 actions,
1682 introduced: None,
1683 }
1684 }
1685}
1686
1687fn build_unused_dependency_actions(
1698 dep: &UnusedDependency,
1699 package_json_location: &str,
1700 suppress_issue_kind: &str,
1701) -> Vec<IssueAction> {
1702 let mut actions = Vec::with_capacity(2);
1703 let cross_workspace = !dep.used_in_workspaces.is_empty();
1704 actions.push(if cross_workspace {
1705 IssueAction::Fix(FixAction {
1706 kind: FixActionType::MoveDependency,
1707 auto_fixable: false,
1708 description: "Move this dependency to the workspace package.json that imports it"
1709 .to_string(),
1710 note: Some(
1711 "fallow fix will not remove dependencies that are imported by another workspace"
1712 .to_string(),
1713 ),
1714 available_in_catalogs: None,
1715 suggested_target: None,
1716 })
1717 } else {
1718 IssueAction::Fix(FixAction {
1719 kind: FixActionType::RemoveDependency,
1720 auto_fixable: true,
1721 description: format!("Remove from {package_json_location} in package.json"),
1722 note: None,
1723 available_in_catalogs: None,
1724 suggested_target: None,
1725 })
1726 });
1727 actions.push(build_ignore_dependencies_suppress_action(
1728 &dep.package_name,
1729 suppress_issue_kind,
1730 ));
1731 actions
1732}
1733
1734fn build_ignore_dependencies_suppress_action(
1742 package_name: &str,
1743 _suppress_issue_kind: &str,
1744) -> IssueAction {
1745 IssueAction::AddToConfig(AddToConfigAction {
1746 kind: AddToConfigKind::AddToConfig,
1747 auto_fixable: false,
1748 description: format!("Add \"{package_name}\" to ignoreDependencies in fallow config"),
1749 config_key: "ignoreDependencies".to_string(),
1750 value: AddToConfigValue::Scalar(package_name.to_string()),
1751 value_schema: Some(
1752 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items"
1753 .to_string(),
1754 ),
1755 })
1756}
1757
1758#[derive(Debug, Clone, Serialize)]
1764#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1765pub struct UnusedDependencyFinding {
1766 #[serde(flatten)]
1768 pub dep: UnusedDependency,
1769 pub actions: Vec<IssueAction>,
1772 #[serde(default, skip_serializing_if = "Option::is_none")]
1775 pub introduced: Option<AuditIntroduced>,
1776}
1777
1778impl UnusedDependencyFinding {
1779 #[must_use]
1782 pub fn with_actions(dep: UnusedDependency) -> Self {
1783 let actions = build_unused_dependency_actions(&dep, "dependencies", "unused-dependency");
1784 Self {
1785 dep,
1786 actions,
1787 introduced: None,
1788 }
1789 }
1790}
1791
1792#[derive(Debug, Clone, Serialize)]
1798#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1799pub struct UnusedDevDependencyFinding {
1800 #[serde(flatten)]
1802 pub dep: UnusedDependency,
1803 pub actions: Vec<IssueAction>,
1806 #[serde(default, skip_serializing_if = "Option::is_none")]
1809 pub introduced: Option<AuditIntroduced>,
1810}
1811
1812impl UnusedDevDependencyFinding {
1813 #[must_use]
1815 pub fn with_actions(dep: UnusedDependency) -> Self {
1816 let actions =
1817 build_unused_dependency_actions(&dep, "devDependencies", "unused-dev-dependency");
1818 Self {
1819 dep,
1820 actions,
1821 introduced: None,
1822 }
1823 }
1824}
1825
1826#[derive(Debug, Clone, Serialize)]
1832#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1833pub struct UnusedOptionalDependencyFinding {
1834 #[serde(flatten)]
1836 pub dep: UnusedDependency,
1837 pub actions: Vec<IssueAction>,
1840 #[serde(default, skip_serializing_if = "Option::is_none")]
1843 pub introduced: Option<AuditIntroduced>,
1844}
1845
1846impl UnusedOptionalDependencyFinding {
1847 #[must_use]
1849 pub fn with_actions(dep: UnusedDependency) -> Self {
1850 let actions =
1851 build_unused_dependency_actions(&dep, "optionalDependencies", "unused-dependency");
1852 Self {
1853 dep,
1854 actions,
1855 introduced: None,
1856 }
1857 }
1858}
1859
1860#[derive(Debug, Clone, Serialize)]
1864#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1865pub struct UnlistedDependencyFinding {
1866 #[serde(flatten)]
1868 pub dep: UnlistedDependency,
1869 pub actions: Vec<IssueAction>,
1872 #[serde(default, skip_serializing_if = "Option::is_none")]
1875 pub introduced: Option<AuditIntroduced>,
1876}
1877
1878impl UnlistedDependencyFinding {
1879 #[must_use]
1881 pub fn with_actions(dep: UnlistedDependency) -> Self {
1882 let actions = vec![
1883 IssueAction::Fix(FixAction {
1884 kind: FixActionType::InstallDependency,
1885 auto_fixable: false,
1886 description: "Add this package to dependencies in package.json".to_string(),
1887 note: Some(
1888 "Verify this package should be a direct dependency before adding".to_string(),
1889 ),
1890 available_in_catalogs: None,
1891 suggested_target: None,
1892 }),
1893 build_ignore_dependencies_suppress_action(&dep.package_name, "unlisted-dependency"),
1894 ];
1895 Self {
1896 dep,
1897 actions,
1898 introduced: None,
1899 }
1900 }
1901}
1902
1903#[derive(Debug, Clone, Serialize)]
1907#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1908pub struct TypeOnlyDependencyFinding {
1909 #[serde(flatten)]
1911 pub dep: TypeOnlyDependency,
1912 pub actions: Vec<IssueAction>,
1915 #[serde(default, skip_serializing_if = "Option::is_none")]
1918 pub introduced: Option<AuditIntroduced>,
1919}
1920
1921impl TypeOnlyDependencyFinding {
1922 #[must_use]
1924 pub fn with_actions(dep: TypeOnlyDependency) -> Self {
1925 let actions = vec![
1926 IssueAction::Fix(FixAction {
1927 kind: FixActionType::MoveToDev,
1928 auto_fixable: false,
1929 description: "Move to devDependencies (only type imports are used)".to_string(),
1930 note: Some(
1931 "Type imports are erased at runtime so this dependency is not needed in production"
1932 .to_string(),
1933 ),
1934 available_in_catalogs: None,
1935 suggested_target: None,
1936 }),
1937 build_ignore_dependencies_suppress_action(&dep.package_name, "type-only-dependency"),
1938 ];
1939 Self {
1940 dep,
1941 actions,
1942 introduced: None,
1943 }
1944 }
1945}
1946
1947#[derive(Debug, Clone, Serialize)]
1951#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1952pub struct TestOnlyDependencyFinding {
1953 #[serde(flatten)]
1955 pub dep: TestOnlyDependency,
1956 pub actions: Vec<IssueAction>,
1959 #[serde(default, skip_serializing_if = "Option::is_none")]
1962 pub introduced: Option<AuditIntroduced>,
1963}
1964
1965impl TestOnlyDependencyFinding {
1966 #[must_use]
1968 pub fn with_actions(dep: TestOnlyDependency) -> Self {
1969 let actions = vec![
1970 IssueAction::Fix(FixAction {
1971 kind: FixActionType::MoveToDev,
1972 auto_fixable: false,
1973 description: "Move to devDependencies (only test files import this)".to_string(),
1974 note: Some(
1975 "Only test files import this package so it does not need to be a production dependency"
1976 .to_string(),
1977 ),
1978 available_in_catalogs: None,
1979 suggested_target: None,
1980 }),
1981 build_ignore_dependencies_suppress_action(&dep.package_name, "test-only-dependency"),
1982 ];
1983 Self {
1984 dep,
1985 actions,
1986 introduced: None,
1987 }
1988 }
1989}
1990
1991#[derive(Debug, Clone, Serialize)]
2012#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2013pub struct DuplicateExportFinding {
2014 #[serde(flatten)]
2016 pub export: DuplicateExport,
2017 pub actions: Vec<IssueAction>,
2020 #[serde(default, skip_serializing_if = "Option::is_none")]
2023 pub introduced: Option<AuditIntroduced>,
2024}
2025
2026impl DuplicateExportFinding {
2027 #[must_use]
2036 pub fn with_actions(export: DuplicateExport) -> Self {
2037 let mut actions: Vec<IssueAction> = Vec::with_capacity(3);
2038
2039 if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
2040 actions.push(IssueAction::AddToConfig(AddToConfigAction {
2041 kind: AddToConfigKind::AddToConfig,
2042 auto_fixable: false,
2043 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(),
2044 config_key: "ignoreExports".to_string(),
2045 value: AddToConfigValue::ExportsRules(rules),
2046 value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
2047 }));
2048 }
2049
2050 actions.push(IssueAction::Fix(FixAction {
2051 kind: FixActionType::RemoveDuplicate,
2052 auto_fixable: false,
2053 description: "Keep one canonical export location and remove the others".to_string(),
2054 note: Some(NAMESPACE_BARREL_HINT.to_string()),
2055 available_in_catalogs: None,
2056 suggested_target: None,
2057 }));
2058
2059 actions.push(IssueAction::SuppressLine(SuppressLineAction {
2060 kind: SuppressLineKind::SuppressLine,
2061 auto_fixable: false,
2062 description: "Suppress with an inline comment above the line".to_string(),
2063 comment: "// fallow-ignore-next-line duplicate-export".to_string(),
2064 scope: Some(SuppressLineScope::PerLocation),
2065 }));
2066
2067 Self {
2068 export,
2069 actions,
2070 introduced: None,
2071 }
2072 }
2073
2074 pub fn set_config_fixable(&mut self, fixable: bool) {
2080 if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
2081 action.auto_fixable = fixable;
2082 }
2083 }
2084}
2085
2086fn build_duplicate_exports_ignore_rules(
2090 export: &DuplicateExport,
2091) -> Option<Vec<IgnoreExportsRule>> {
2092 let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
2093 for loc in &export.locations {
2094 let path = loc.path.to_string_lossy().replace('\\', "/");
2102 if path.is_empty() {
2103 continue;
2104 }
2105 if entries.iter().any(|existing| existing.file == path) {
2106 continue;
2107 }
2108 entries.push(IgnoreExportsRule {
2109 file: path,
2110 exports: vec!["*".to_string()],
2111 });
2112 }
2113 if entries.is_empty() {
2114 None
2115 } else {
2116 Some(entries)
2117 }
2118}
2119
2120#[derive(Debug, Clone, Serialize)]
2125#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2126pub struct UnusedCatalogEntryFinding {
2127 #[serde(flatten)]
2129 pub entry: UnusedCatalogEntry,
2130 pub actions: Vec<IssueAction>,
2132 #[serde(default, skip_serializing_if = "Option::is_none")]
2135 pub introduced: Option<AuditIntroduced>,
2136}
2137
2138impl UnusedCatalogEntryFinding {
2139 #[must_use]
2143 pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
2144 let auto_fixable = entry.hardcoded_consumers.is_empty();
2145 let actions = vec![
2146 IssueAction::Fix(FixAction {
2147 kind: FixActionType::RemoveCatalogEntry,
2148 auto_fixable,
2149 description: "Remove the entry from pnpm-workspace.yaml".to_string(),
2150 note: 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 available_in_catalogs: None,
2155 suggested_target: None,
2156 }),
2157 IssueAction::SuppressLine(SuppressLineAction {
2158 kind: SuppressLineKind::SuppressLine,
2159 auto_fixable: false,
2160 description: "Suppress with a YAML comment above the line".to_string(),
2161 comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
2162 scope: None,
2163 }),
2164 ];
2165 Self {
2166 entry,
2167 actions,
2168 introduced: None,
2169 }
2170 }
2171}
2172
2173#[derive(Debug, Clone, Serialize)]
2177#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2178pub struct EmptyCatalogGroupFinding {
2179 #[serde(flatten)]
2181 pub group: EmptyCatalogGroup,
2182 pub actions: Vec<IssueAction>,
2184 #[serde(default, skip_serializing_if = "Option::is_none")]
2187 pub introduced: Option<AuditIntroduced>,
2188}
2189
2190impl EmptyCatalogGroupFinding {
2191 #[must_use]
2193 pub fn with_actions(group: EmptyCatalogGroup) -> Self {
2194 let actions = vec![
2195 IssueAction::Fix(FixAction {
2196 kind: FixActionType::RemoveEmptyCatalogGroup,
2197 auto_fixable: true,
2198 description: "Remove the empty named catalog group from pnpm-workspace.yaml"
2199 .to_string(),
2200 note: Some(
2201 "Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
2202 .to_string(),
2203 ),
2204 available_in_catalogs: None,
2205 suggested_target: None,
2206 }),
2207 IssueAction::SuppressLine(SuppressLineAction {
2208 kind: SuppressLineKind::SuppressLine,
2209 auto_fixable: false,
2210 description: "Suppress with a YAML comment above the line".to_string(),
2211 comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
2212 scope: None,
2213 }),
2214 ];
2215 Self {
2216 group,
2217 actions,
2218 introduced: None,
2219 }
2220 }
2221}
2222
2223#[derive(Debug, Clone, Serialize)]
2231#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2232pub struct UnresolvedCatalogReferenceFinding {
2233 #[serde(flatten)]
2235 pub reference: UnresolvedCatalogReference,
2236 pub actions: Vec<IssueAction>,
2239 #[serde(default, skip_serializing_if = "Option::is_none")]
2242 pub introduced: Option<AuditIntroduced>,
2243}
2244
2245impl UnresolvedCatalogReferenceFinding {
2246 #[must_use]
2250 pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
2251 let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
2256 let primary = if reference.available_in_catalogs.is_empty() {
2257 IssueAction::Fix(FixAction {
2258 kind: FixActionType::AddCatalogEntry,
2259 auto_fixable: false,
2260 description: format!(
2261 "Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
2262 reference.entry_name, reference.catalog_name
2263 ),
2264 note: Some(
2265 "Pin a version that satisfies the consumer's import; no other catalog declares this package today"
2266 .to_string(),
2267 ),
2268 available_in_catalogs: None,
2269 suggested_target: None,
2270 })
2271 } else {
2272 let available = reference.available_in_catalogs.clone();
2273 let suggested_target = (available.len() == 1).then(|| available[0].clone());
2274 IssueAction::Fix(FixAction {
2275 kind: FixActionType::UpdateCatalogReference,
2276 auto_fixable: false,
2277 description: format!(
2278 "Switch the reference from `catalog:{}` to a catalog that declares `{}`",
2279 reference.catalog_name, reference.entry_name
2280 ),
2281 note: None,
2282 available_in_catalogs: Some(available),
2283 suggested_target,
2284 })
2285 };
2286
2287 let fallback = IssueAction::Fix(FixAction {
2288 kind: FixActionType::RemoveCatalogReference,
2289 auto_fixable: false,
2290 description:
2291 "Remove the catalog reference and pin a hardcoded version in package.json"
2292 .to_string(),
2293 note: Some(
2294 "Use only when neither another catalog declares the package nor the named catalog should grow to include it"
2295 .to_string(),
2296 ),
2297 available_in_catalogs: None,
2298 suggested_target: None,
2299 });
2300
2301 let mut suppress_value = serde_json::Map::new();
2302 suppress_value.insert(
2303 "package".to_string(),
2304 serde_json::Value::String(reference.entry_name.clone()),
2305 );
2306 suppress_value.insert(
2307 "catalog".to_string(),
2308 serde_json::Value::String(reference.catalog_name.clone()),
2309 );
2310 suppress_value.insert(
2311 "consumer".to_string(),
2312 serde_json::Value::String(consumer_path),
2313 );
2314 let suppress = IssueAction::AddToConfig(AddToConfigAction {
2315 kind: AddToConfigKind::AddToConfig,
2316 auto_fixable: false,
2317 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(),
2318 config_key: "ignoreCatalogReferences".to_string(),
2319 value: AddToConfigValue::RuleObject(suppress_value),
2320 value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
2321 });
2322
2323 Self {
2324 reference,
2325 actions: vec![primary, fallback, suppress],
2326 introduced: None,
2327 }
2328 }
2329}
2330
2331#[derive(Debug, Clone, Serialize)]
2336#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2337pub struct UnusedDependencyOverrideFinding {
2338 #[serde(flatten)]
2340 pub entry: UnusedDependencyOverride,
2341 pub actions: Vec<IssueAction>,
2343 #[serde(default, skip_serializing_if = "Option::is_none")]
2346 pub introduced: Option<AuditIntroduced>,
2347}
2348
2349impl UnusedDependencyOverrideFinding {
2350 #[must_use]
2352 pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
2353 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
2354 actions.push(IssueAction::Fix(FixAction {
2355 kind: FixActionType::RemoveDependencyOverride,
2356 auto_fixable: false,
2357 description: "Remove the override entry from pnpm-workspace.yaml or pnpm.overrides"
2358 .to_string(),
2359 note: Some(
2360 "Conservative static check; verify against `pnpm install --frozen-lockfile` before removing in case the override targets a transitive dependency (CVE-fix pattern)"
2361 .to_string(),
2362 ),
2363 available_in_catalogs: None,
2364 suggested_target: None,
2365 }));
2366
2367 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
2368 Some(&entry.target_package),
2369 &entry.raw_key,
2370 entry.source,
2371 ) {
2372 actions.push(suppress);
2373 }
2374
2375 Self {
2376 entry,
2377 actions,
2378 introduced: None,
2379 }
2380 }
2381}
2382
2383#[derive(Debug, Clone, Serialize)]
2389#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2390pub struct MisconfiguredDependencyOverrideFinding {
2391 #[serde(flatten)]
2393 pub entry: MisconfiguredDependencyOverride,
2394 pub actions: Vec<IssueAction>,
2396 #[serde(default, skip_serializing_if = "Option::is_none")]
2399 pub introduced: Option<AuditIntroduced>,
2400}
2401
2402impl MisconfiguredDependencyOverrideFinding {
2403 #[must_use]
2408 pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
2409 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
2410 actions.push(IssueAction::Fix(FixAction {
2411 kind: FixActionType::FixDependencyOverride,
2412 auto_fixable: false,
2413 description:
2414 "Fix the override key or value: pnpm refuses to honor entries with an unparsable key or empty value"
2415 .to_string(),
2416 note: Some(
2417 "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`."
2418 .to_string(),
2419 ),
2420 available_in_catalogs: None,
2421 suggested_target: None,
2422 }));
2423
2424 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
2425 entry.target_package.as_deref(),
2426 &entry.raw_key,
2427 entry.source,
2428 ) {
2429 actions.push(suppress);
2430 }
2431
2432 Self {
2433 entry,
2434 actions,
2435 introduced: None,
2436 }
2437 }
2438}
2439
2440fn build_ignore_dependency_overrides_suppress(
2445 target_package: Option<&str>,
2446 raw_key: &str,
2447 source: DependencyOverrideSource,
2448) -> Option<IssueAction> {
2449 let package = target_package
2450 .filter(|s| !s.is_empty())
2451 .or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
2452 .to_string();
2453 let mut value = serde_json::Map::new();
2454 value.insert("package".to_string(), serde_json::Value::String(package));
2455 value.insert(
2456 "source".to_string(),
2457 serde_json::Value::String(source.as_label().to_string()),
2458 );
2459 Some(IssueAction::AddToConfig(AddToConfigAction {
2460 kind: AddToConfigKind::AddToConfig,
2461 auto_fixable: false,
2462 description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
2463 config_key: "ignoreDependencyOverrides".to_string(),
2464 value: AddToConfigValue::RuleObject(value),
2465 value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
2466 }))
2467}
2468
2469#[cfg(test)]
2479mod position_0_invariants {
2480 use super::*;
2481 use crate::output::FixActionType;
2482 use crate::results::{DependencyOverrideSource, DuplicateLocation};
2483 use std::path::PathBuf;
2484
2485 fn action_type(action: &IssueAction) -> &'static str {
2490 match action {
2491 IssueAction::Fix(fix) => match fix.kind {
2492 FixActionType::RemoveExport => "remove-export",
2493 FixActionType::DeleteFile => "delete-file",
2494 FixActionType::RemoveDependency => "remove-dependency",
2495 FixActionType::MoveDependency => "move-dependency",
2496 FixActionType::RemoveEnumMember => "remove-enum-member",
2497 FixActionType::RemoveClassMember => "remove-class-member",
2498 FixActionType::ResolveImport => "resolve-import",
2499 FixActionType::InstallDependency => "install-dependency",
2500 FixActionType::RemoveDuplicate => "remove-duplicate",
2501 FixActionType::MoveToDev => "move-to-dev",
2502 FixActionType::RefactorCycle => "refactor-cycle",
2503 FixActionType::RefactorReExportCycle => "refactor-re-export-cycle",
2504 FixActionType::RefactorBoundary => "refactor-boundary",
2505 FixActionType::ExportType => "export-type",
2506 FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
2507 FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
2508 FixActionType::UpdateCatalogReference => "update-catalog-reference",
2509 FixActionType::AddCatalogEntry => "add-catalog-entry",
2510 FixActionType::RemoveCatalogReference => "remove-catalog-reference",
2511 FixActionType::RemoveDependencyOverride => "remove-dependency-override",
2512 FixActionType::FixDependencyOverride => "fix-dependency-override",
2513 FixActionType::ResolvePolicyViolation => "resolve-policy-violation",
2514 FixActionType::MoveToServerModule => "move-to-server-module",
2515 FixActionType::SplitMixedBarrel => "split-mixed-barrel",
2516 FixActionType::HoistDirective => "hoist-directive",
2517 FixActionType::WireServerAction => "wire-server-action",
2518 FixActionType::ProvideInject => "provide-inject",
2519 FixActionType::UseLoadData => "use-load-data",
2520 FixActionType::RenderComponent => "render-component",
2521 FixActionType::UseComponentProp => "use-component-prop",
2522 FixActionType::EmitComponentEvent => "emit-component-event",
2523 FixActionType::WireSvelteEvent => "wire-svelte-event",
2524 FixActionType::ResolveRouteCollision => "resolve-route-collision",
2525 FixActionType::ResolveDynamicSegmentNameConflict => {
2526 "resolve-dynamic-segment-name-conflict"
2527 }
2528 },
2529 IssueAction::SuppressLine(_) => "suppress-line",
2530 IssueAction::SuppressFile(_) => "suppress-file",
2531 IssueAction::AddToConfig(_) => "add-to-config",
2532 }
2533 }
2534
2535 fn assert_manual_fix_then_suppress(
2536 actions: &[IssueAction],
2537 primary_type: &str,
2538 suppress_comment: &str,
2539 ) {
2540 assert_eq!(actions.len(), 2);
2541 assert_eq!(action_type(&actions[0]), primary_type);
2542 let IssueAction::Fix(primary) = &actions[0] else {
2543 panic!("position-0 should be a manual fix action");
2544 };
2545 assert!(!primary.auto_fixable);
2546 assert!(primary.note.is_some());
2547 assert_eq!(action_type(&actions[1]), "suppress-line");
2548 let IssueAction::SuppressLine(suppress) = &actions[1] else {
2549 panic!("position-1 should be a suppress-line action");
2550 };
2551 assert_eq!(suppress.comment, suppress_comment);
2552 }
2553
2554 #[test]
2555 fn unprovided_inject_primary_action_is_provide_inject() {
2556 let finding = UnprovidedInjectFinding::with_actions(UnprovidedInject {
2557 path: PathBuf::from("src/context.ts"),
2558 key_name: "userKey".to_string(),
2559 framework: "svelte".to_string(),
2560 line: 7,
2561 col: 12,
2562 });
2563
2564 assert_manual_fix_then_suppress(
2565 &finding.actions,
2566 "provide-inject",
2567 "// fallow-ignore-next-line unprovided-inject",
2568 );
2569 }
2570
2571 #[test]
2572 fn unused_server_action_primary_action_is_wire_server_action() {
2573 let finding = UnusedServerActionFinding::with_actions(UnusedServerAction {
2574 path: PathBuf::from("app/actions.ts"),
2575 action_name: "saveDraft".to_string(),
2576 line: 3,
2577 col: 13,
2578 });
2579
2580 assert_manual_fix_then_suppress(
2581 &finding.actions,
2582 "wire-server-action",
2583 "// fallow-ignore-next-line unused-server-action",
2584 );
2585 }
2586
2587 #[test]
2588 fn unused_load_data_key_primary_action_is_use_load_data() {
2589 let finding = UnusedLoadDataKeyFinding::with_actions(UnusedLoadDataKey {
2590 path: PathBuf::from("src/routes/+page.server.ts"),
2591 key_name: "profile".to_string(),
2592 line: 12,
2593 col: 6,
2594 route_dir: Some("src/routes".to_string()),
2595 });
2596
2597 assert_manual_fix_then_suppress(
2598 &finding.actions,
2599 "use-load-data",
2600 "// fallow-ignore-next-line unused-load-data-key",
2601 );
2602 }
2603
2604 #[test]
2605 fn unrendered_component_primary_action_is_render_component() {
2606 let finding = UnrenderedComponentFinding::with_actions(UnrenderedComponent {
2607 path: PathBuf::from("src/components/EmptyState.vue"),
2608 component_name: "EmptyState".to_string(),
2609 framework: "vue".to_string(),
2610 reachable_via: None,
2611 line: 1,
2612 col: 0,
2613 });
2614
2615 assert_manual_fix_then_suppress(
2616 &finding.actions,
2617 "render-component",
2618 "// fallow-ignore-next-line unrendered-component",
2619 );
2620 }
2621
2622 #[test]
2623 fn unused_component_prop_primary_action_is_use_component_prop() {
2624 let finding = UnusedComponentPropFinding::with_actions(UnusedComponentProp {
2625 path: PathBuf::from("src/components/Card.vue"),
2626 component_name: "Card".to_string(),
2627 prop_name: "variant".to_string(),
2628 line: 5,
2629 col: 10,
2630 });
2631
2632 assert_manual_fix_then_suppress(
2633 &finding.actions,
2634 "use-component-prop",
2635 "// fallow-ignore-next-line unused-component-prop",
2636 );
2637 }
2638
2639 #[test]
2640 fn unused_component_emit_primary_action_is_emit_component_event() {
2641 let finding = UnusedComponentEmitFinding::with_actions(UnusedComponentEmit {
2642 path: PathBuf::from("src/components/Picker.vue"),
2643 component_name: "Picker".to_string(),
2644 emit_name: "focus".to_string(),
2645 line: 6,
2646 col: 14,
2647 });
2648
2649 assert_manual_fix_then_suppress(
2650 &finding.actions,
2651 "emit-component-event",
2652 "// fallow-ignore-next-line unused-component-emit",
2653 );
2654 }
2655
2656 #[test]
2657 fn unused_svelte_event_primary_action_is_wire_svelte_event() {
2658 let finding = UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
2659 path: PathBuf::from("src/Dialog.svelte"),
2660 component_name: "Dialog".to_string(),
2661 event_name: "closed".to_string(),
2662 line: 19,
2663 col: 8,
2664 });
2665
2666 assert_manual_fix_then_suppress(
2667 &finding.actions,
2668 "wire-svelte-event",
2669 "// fallow-ignore-next-line unused-svelte-event",
2670 );
2671 }
2672
2673 #[test]
2674 fn unresolved_import_actions_include_ignore_unresolved_imports_config_suppress() {
2675 let inner = UnresolvedImport {
2676 specifier: "@example/icons".to_string(),
2677 path: PathBuf::from("src/index.ts"),
2678 line: 4,
2679 col: 12,
2680 specifier_col: 18,
2681 };
2682 let finding = UnresolvedImportFinding::with_actions(inner);
2683
2684 assert_eq!(action_type(&finding.actions[0]), "resolve-import");
2685 assert_eq!(action_type(&finding.actions[1]), "add-to-config");
2686 let IssueAction::AddToConfig(action) = &finding.actions[1] else {
2687 panic!("position-1 should be AddToConfig");
2688 };
2689 assert!(!action.auto_fixable);
2690 assert_eq!(action.config_key, "ignoreUnresolvedImports");
2691 let AddToConfigValue::Scalar(value) = &action.value else {
2692 panic!("ignoreUnresolvedImports action should carry a scalar value");
2693 };
2694 assert_eq!(value, "@example/icons");
2695 assert_eq!(
2696 action.value_schema.as_deref(),
2697 Some(
2698 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
2699 )
2700 );
2701 }
2702
2703 #[test]
2714 fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
2715 let inner = UnresolvedCatalogReference {
2716 entry_name: "react".to_string(),
2717 catalog_name: "default".to_string(),
2718 path: PathBuf::from("apps/web/package.json"),
2719 line: 7,
2720 available_in_catalogs: Vec::new(),
2721 };
2722 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
2723 assert_eq!(
2724 action_type(&finding.actions[0]),
2725 "add-catalog-entry",
2726 "position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
2727 );
2728 let IssueAction::Fix(fix) = &finding.actions[0] else {
2729 panic!("position-0 should be an IssueAction::Fix");
2730 };
2731 assert!(
2732 fix.available_in_catalogs.is_none(),
2733 "add-catalog-entry must NOT carry available_in_catalogs"
2734 );
2735 assert!(
2736 fix.suggested_target.is_none(),
2737 "add-catalog-entry must NOT carry suggested_target"
2738 );
2739 }
2740
2741 #[test]
2748 fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
2749 let inner = UnresolvedCatalogReference {
2750 entry_name: "react".to_string(),
2751 catalog_name: "default".to_string(),
2752 path: PathBuf::from("apps/web/package.json"),
2753 line: 7,
2754 available_in_catalogs: vec!["react18".to_string()],
2755 };
2756 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
2757 assert_eq!(
2758 action_type(&finding.actions[0]),
2759 "update-catalog-reference",
2760 "position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
2761 );
2762 let IssueAction::Fix(fix) = &finding.actions[0] else {
2763 panic!("position-0 should be an IssueAction::Fix");
2764 };
2765 assert_eq!(
2766 fix.available_in_catalogs.as_deref(),
2767 Some(&["react18".to_string()][..]),
2768 "update-catalog-reference must carry the alternative list"
2769 );
2770 assert_eq!(
2771 fix.suggested_target.as_deref(),
2772 Some("react18"),
2773 "single-alternative case must surface `suggested_target` for deterministic agents"
2774 );
2775
2776 let inner_two = UnresolvedCatalogReference {
2778 entry_name: "react".to_string(),
2779 catalog_name: "default".to_string(),
2780 path: PathBuf::from("apps/web/package.json"),
2781 line: 7,
2782 available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
2783 };
2784 let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
2785 assert_eq!(
2786 action_type(&finding_two.actions[0]),
2787 "update-catalog-reference"
2788 );
2789 let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
2790 panic!("position-0 should be an IssueAction::Fix");
2791 };
2792 assert!(
2793 fix_two.suggested_target.is_none(),
2794 "multi-alternative case must NOT carry `suggested_target` (agent must pick)"
2795 );
2796 }
2797
2798 #[test]
2813 fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
2814 let inner = DuplicateExport {
2815 export_name: "Root".to_string(),
2816 locations: vec![
2817 DuplicateLocation {
2818 path: PathBuf::from("components/ui/accordion/index.ts"),
2819 line: 1,
2820 col: 0,
2821 },
2822 DuplicateLocation {
2823 path: PathBuf::from("components/ui/dialog/index.ts"),
2824 line: 1,
2825 col: 0,
2826 },
2827 ],
2828 };
2829 let finding = DuplicateExportFinding::with_actions(inner);
2830 assert_eq!(
2831 action_type(&finding.actions[0]),
2832 "add-to-config",
2833 "position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
2834 );
2835 assert_eq!(
2836 action_type(&finding.actions[1]),
2837 "remove-duplicate",
2838 "position-1 must be the destructive `remove-duplicate` fallback"
2839 );
2840
2841 let mut promoted = finding;
2844 promoted.set_config_fixable(true);
2845 assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
2846 let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
2847 panic!("position-0 should still be AddToConfig after set_config_fixable");
2848 };
2849 assert!(
2850 action.auto_fixable,
2851 "set_config_fixable(true) must flip auto_fixable"
2852 );
2853 }
2854
2855 #[test]
2860 fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
2861 let inner = DuplicateExport {
2862 export_name: "Root".to_string(),
2863 locations: Vec::new(),
2864 };
2865 let finding = DuplicateExportFinding::with_actions(inner);
2866 assert_eq!(
2867 action_type(&finding.actions[0]),
2868 "remove-duplicate",
2869 "with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
2870 );
2871
2872 let mut promoted = finding;
2874 promoted.set_config_fixable(true);
2875 assert_eq!(
2876 action_type(&promoted.actions[0]),
2877 "remove-duplicate",
2878 "set_config_fixable is a no-op when position-0 is not add-to-config"
2879 );
2880 }
2881
2882 #[test]
2888 fn misconfigured_override_drops_suppress_when_no_package_name() {
2889 let inner = MisconfiguredDependencyOverride {
2890 raw_key: String::new(),
2891 target_package: None,
2892 raw_value: String::new(),
2893 reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
2894 source: DependencyOverrideSource::PnpmWorkspaceYaml,
2895 path: PathBuf::from("pnpm-workspace.yaml"),
2896 line: 12,
2897 };
2898 let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
2899 assert_eq!(finding.actions.len(), 1);
2901 assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
2902 }
2903}