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, UnusedComponentProp,
46 UnusedDependency, UnusedDependencyOverride, UnusedExport, UnusedFile, UnusedLoadDataKey,
47 UnusedMember, UnusedServerAction,
48};
49
50pub 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.";
54
55const IGNORE_EXPORTS_VALUE_SCHEMA: &str =
59 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreExports";
60
61const IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreCatalogReferences/items";
64
65const IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencyOverrides/items";
69
70#[derive(Debug, Clone, Serialize)]
75#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
76pub struct UnusedFileFinding {
77 #[serde(flatten)]
79 pub file: UnusedFile,
80 pub actions: Vec<IssueAction>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub introduced: Option<AuditIntroduced>,
87}
88
89impl UnusedFileFinding {
90 #[must_use]
94 pub fn with_actions(file: UnusedFile) -> Self {
95 let actions = vec![
96 IssueAction::Fix(FixAction {
97 kind: FixActionType::DeleteFile,
98 auto_fixable: false,
99 description: "Delete this file".to_string(),
100 note: Some(
101 "File deletion may remove runtime functionality not visible to static analysis"
102 .to_string(),
103 ),
104 available_in_catalogs: None,
105 suggested_target: None,
106 }),
107 IssueAction::SuppressFile(SuppressFileAction {
108 kind: SuppressFileKind::SuppressFile,
109 auto_fixable: false,
110 description: "Suppress with a file-level comment at the top of the file"
111 .to_string(),
112 comment: "// fallow-ignore-file unused-file".to_string(),
113 }),
114 ];
115 Self {
116 file,
117 actions,
118 introduced: None,
119 }
120 }
121}
122
123#[derive(Debug, Clone, Serialize)]
127#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
128pub struct PrivateTypeLeakFinding {
129 #[serde(flatten)]
131 pub leak: PrivateTypeLeak,
132 pub actions: Vec<IssueAction>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub introduced: Option<AuditIntroduced>,
139}
140
141impl PrivateTypeLeakFinding {
142 #[must_use]
144 pub fn with_actions(leak: PrivateTypeLeak) -> Self {
145 let actions = vec![
146 IssueAction::Fix(FixAction {
147 kind: FixActionType::ExportType,
148 auto_fixable: false,
149 description: "Export the referenced private type by name".to_string(),
150 note: Some(
151 "Keep the type exported while it is part of a public signature".to_string(),
152 ),
153 available_in_catalogs: None,
154 suggested_target: None,
155 }),
156 IssueAction::SuppressLine(SuppressLineAction {
157 kind: SuppressLineKind::SuppressLine,
158 auto_fixable: false,
159 description: "Suppress with an inline comment above the line".to_string(),
160 comment: "// fallow-ignore-next-line private-type-leak".to_string(),
161 scope: None,
162 }),
163 ];
164 Self {
165 leak,
166 actions,
167 introduced: None,
168 }
169 }
170}
171
172#[derive(Debug, Clone, Serialize)]
177#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
178pub struct UnresolvedImportFinding {
179 #[serde(flatten)]
181 pub import: UnresolvedImport,
182 pub actions: Vec<IssueAction>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub introduced: Option<AuditIntroduced>,
189}
190
191impl UnresolvedImportFinding {
192 #[must_use]
194 pub fn with_actions(import: UnresolvedImport) -> Self {
195 let actions = vec![
196 IssueAction::Fix(FixAction {
197 kind: FixActionType::ResolveImport,
198 auto_fixable: false,
199 description: "Fix the import specifier or install the missing module".to_string(),
200 note: Some(
201 "Verify the module path and check tsconfig paths configuration".to_string(),
202 ),
203 available_in_catalogs: None,
204 suggested_target: None,
205 }),
206 IssueAction::AddToConfig(AddToConfigAction {
207 kind: AddToConfigKind::AddToConfig,
208 auto_fixable: false,
209 description: format!(
210 "Add \"{}\" to ignoreUnresolvedImports in fallow config",
211 import.specifier
212 ),
213 config_key: "ignoreUnresolvedImports".to_string(),
214 value: AddToConfigValue::Scalar(import.specifier.clone()),
215 value_schema: Some(
216 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
217 .to_string(),
218 ),
219 }),
220 IssueAction::SuppressLine(SuppressLineAction {
221 kind: SuppressLineKind::SuppressLine,
222 auto_fixable: false,
223 description: "Suppress with an inline comment above the line".to_string(),
224 comment: "// fallow-ignore-next-line unresolved-import".to_string(),
225 scope: None,
226 }),
227 ];
228 Self {
229 import,
230 actions,
231 introduced: None,
232 }
233 }
234}
235
236#[derive(Debug, Clone, Serialize)]
241#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
242pub struct CircularDependencyFinding {
243 #[serde(flatten)]
245 pub cycle: CircularDependency,
246 pub actions: Vec<IssueAction>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub introduced: Option<AuditIntroduced>,
253}
254
255impl CircularDependencyFinding {
256 #[must_use]
258 pub fn with_actions(cycle: CircularDependency) -> Self {
259 let actions = vec![
260 IssueAction::Fix(FixAction {
261 kind: FixActionType::RefactorCycle,
262 auto_fixable: false,
263 description: "Extract shared logic into a separate module to break the cycle"
264 .to_string(),
265 note: Some(
266 "Circular imports can cause initialization issues and make code harder to reason about"
267 .to_string(),
268 ),
269 available_in_catalogs: None,
270 suggested_target: None,
271 }),
272 IssueAction::SuppressLine(SuppressLineAction {
273 kind: SuppressLineKind::SuppressLine,
274 auto_fixable: false,
275 description: "Suppress with an inline comment above the line".to_string(),
276 comment: "// fallow-ignore-next-line circular-dependency".to_string(),
277 scope: None,
278 }),
279 ];
280 Self {
281 cycle,
282 actions,
283 introduced: None,
284 }
285 }
286}
287
288#[derive(Debug, Clone, Serialize)]
296#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
297pub struct ReExportCycleFinding {
298 #[serde(flatten)]
300 pub cycle: ReExportCycle,
301 pub actions: Vec<IssueAction>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub introduced: Option<AuditIntroduced>,
308}
309
310impl ReExportCycleFinding {
311 #[must_use]
318 pub fn with_actions(cycle: ReExportCycle) -> Self {
319 let suppress_description = match cycle.kind {
325 ReExportCycleKind::SelfLoop => {
326 "Suppress with a file-level comment at the top of this file. \
327 The cycle is a self-loop, so the suppression covers the entire finding."
328 .to_string()
329 }
330 ReExportCycleKind::MultiNode => {
331 "Suppress with a file-level comment at the top of this file. \
332 One suppression on any member breaks the cycle for every member \
333 (see the sibling `files` array)."
334 .to_string()
335 }
336 };
337 let actions = vec![
338 IssueAction::Fix(FixAction {
339 kind: FixActionType::RefactorReExportCycle,
340 auto_fixable: false,
341 description: "Remove one `export * from` (or `export { ... } from`) \
342 statement on any one member to break the cycle"
343 .to_string(),
344 note: Some(
345 "Re-export cycles are structurally a no-op: chain propagation through \
346 the loop never reaches a terminating module, so imports from any member \
347 may silently come up empty."
348 .to_string(),
349 ),
350 available_in_catalogs: None,
351 suggested_target: None,
352 }),
353 IssueAction::SuppressFile(SuppressFileAction {
354 kind: SuppressFileKind::SuppressFile,
355 auto_fixable: false,
356 description: suppress_description,
357 comment: "// fallow-ignore-file re-export-cycle".to_string(),
358 }),
359 ];
360 Self {
361 cycle,
362 actions,
363 introduced: None,
364 }
365 }
366}
367
368#[derive(Debug, Clone, Serialize)]
373#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
374pub struct BoundaryViolationFinding {
375 #[serde(flatten)]
377 pub violation: BoundaryViolation,
378 pub actions: Vec<IssueAction>,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub introduced: Option<AuditIntroduced>,
385}
386
387impl BoundaryViolationFinding {
388 #[must_use]
390 pub fn with_actions(violation: BoundaryViolation) -> Self {
391 let actions = vec![
392 IssueAction::Fix(FixAction {
393 kind: FixActionType::RefactorBoundary,
394 auto_fixable: false,
395 description: "Move the import through an allowed zone or restructure the dependency"
396 .to_string(),
397 note: Some(
398 "This import crosses an architecture boundary that is not permitted by the configured rules"
399 .to_string(),
400 ),
401 available_in_catalogs: None,
402 suggested_target: None,
403 }),
404 IssueAction::SuppressLine(SuppressLineAction {
405 kind: SuppressLineKind::SuppressLine,
406 auto_fixable: false,
407 description: "Suppress with an inline comment above the line".to_string(),
408 comment: "// fallow-ignore-next-line boundary-violation".to_string(),
409 scope: None,
410 }),
411 ];
412 Self {
413 violation,
414 actions,
415 introduced: None,
416 }
417 }
418}
419
420#[derive(Debug, Clone, Serialize)]
424#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
425pub struct BoundaryCoverageViolationFinding {
426 #[serde(flatten)]
428 pub violation: BoundaryCoverageViolation,
429 pub actions: Vec<IssueAction>,
431 #[serde(default, skip_serializing_if = "Option::is_none")]
434 pub introduced: Option<AuditIntroduced>,
435}
436
437impl BoundaryCoverageViolationFinding {
438 #[must_use]
440 pub fn with_actions(violation: BoundaryCoverageViolation) -> Self {
441 let path = violation.path.to_string_lossy().replace('\\', "/");
442 let actions = vec![
443 IssueAction::Fix(FixAction {
444 kind: FixActionType::RefactorBoundary,
445 auto_fixable: false,
446 description: "Add this file to a boundary zone pattern or move it under an existing zone"
447 .to_string(),
448 note: Some(
449 "Boundary coverage is enabled, so every analyzed source file must match a zone unless allow-listed"
450 .to_string(),
451 ),
452 available_in_catalogs: None,
453 suggested_target: None,
454 }),
455 IssueAction::AddToConfig(AddToConfigAction {
456 kind: AddToConfigKind::AddToConfig,
457 auto_fixable: false,
458 description: format!(
459 "Add \"{path}\" to boundaries.coverage.allowUnmatched in fallow config"
460 ),
461 config_key: "boundaries.coverage.allowUnmatched".to_string(),
462 value: AddToConfigValue::Scalar(path),
463 value_schema: Some(
464 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/boundaries/properties/coverage/properties/allowUnmatched/items"
465 .to_string(),
466 ),
467 }),
468 IssueAction::SuppressFile(SuppressFileAction {
469 kind: SuppressFileKind::SuppressFile,
470 auto_fixable: false,
471 description: "Suppress with a file-level comment at the top of the file"
472 .to_string(),
473 comment: "// fallow-ignore-file boundary-violation".to_string(),
474 }),
475 ];
476 Self {
477 violation,
478 actions,
479 introduced: None,
480 }
481 }
482}
483
484#[derive(Debug, Clone, Serialize)]
488#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
489pub struct BoundaryCallViolationFinding {
490 #[serde(flatten)]
492 pub violation: BoundaryCallViolation,
493 pub actions: Vec<IssueAction>,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub introduced: Option<AuditIntroduced>,
499}
500
501impl BoundaryCallViolationFinding {
502 #[must_use]
504 pub fn with_actions(violation: BoundaryCallViolation) -> Self {
505 let actions = vec![
506 IssueAction::Fix(FixAction {
507 kind: FixActionType::RefactorBoundary,
508 auto_fixable: false,
509 description: format!(
510 "Move the `{}` call out of zone '{}' or behind an allowed abstraction",
511 violation.callee, violation.zone,
512 ),
513 note: Some(format!(
514 "`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",
515 violation.pattern, violation.zone,
516 )),
517 available_in_catalogs: None,
518 suggested_target: None,
519 }),
520 IssueAction::SuppressLine(SuppressLineAction {
521 kind: SuppressLineKind::SuppressLine,
522 auto_fixable: false,
523 description: "Suppress with an inline comment above the line".to_string(),
524 comment: "// fallow-ignore-next-line boundary-violation".to_string(),
525 scope: None,
526 }),
527 IssueAction::SuppressFile(SuppressFileAction {
528 kind: SuppressFileKind::SuppressFile,
529 auto_fixable: false,
530 description: "Suppress with a file-level comment at the top of the file"
531 .to_string(),
532 comment: "// fallow-ignore-file boundary-violation".to_string(),
533 }),
534 ];
535 Self {
536 violation,
537 actions,
538 introduced: None,
539 }
540 }
541}
542
543#[derive(Debug, Clone, Serialize)]
547#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
548pub struct PolicyViolationFinding {
549 #[serde(flatten)]
551 pub violation: PolicyViolation,
552 pub actions: Vec<IssueAction>,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
557 pub introduced: Option<AuditIntroduced>,
558}
559
560impl PolicyViolationFinding {
561 #[must_use]
563 pub fn with_actions(violation: PolicyViolation) -> Self {
564 let what = match violation.kind {
565 crate::results::PolicyRuleKind::BannedCall => "call",
566 crate::results::PolicyRuleKind::BannedImport => "import",
567 };
568 let description = match &violation.message {
569 Some(message) => format!("Replace the `{}` {what}: {message}", violation.matched),
570 None => format!("Replace the `{}` {what}", violation.matched),
571 };
572 let suppress_token = format!("policy-violation:{}/{}", violation.pack, violation.rule_id);
573 let actions = vec![
574 IssueAction::Fix(FixAction {
575 kind: FixActionType::ResolvePolicyViolation,
576 auto_fixable: false,
577 description,
578 note: Some(format!(
579 "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",
580 violation.pack, violation.rule_id,
581 )),
582 available_in_catalogs: None,
583 suggested_target: None,
584 }),
585 IssueAction::SuppressLine(SuppressLineAction {
586 kind: SuppressLineKind::SuppressLine,
587 auto_fixable: false,
588 description: "Suppress this rule-pack rule with an inline comment above the line"
589 .to_string(),
590 comment: format!("// fallow-ignore-next-line {suppress_token}"),
591 scope: None,
592 }),
593 IssueAction::SuppressFile(SuppressFileAction {
594 kind: SuppressFileKind::SuppressFile,
595 auto_fixable: false,
596 description:
597 "Suppress this rule-pack rule with a file-level comment at the top of the file"
598 .to_string(),
599 comment: format!("// fallow-ignore-file {suppress_token}"),
600 }),
601 ];
602 Self {
603 violation,
604 actions,
605 introduced: None,
606 }
607 }
608}
609
610#[derive(Debug, Clone, Serialize)]
615#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
616pub struct UnusedExportFinding {
617 #[serde(flatten)]
619 pub export: UnusedExport,
620 pub actions: Vec<IssueAction>,
623 #[serde(default, skip_serializing_if = "Option::is_none")]
626 pub introduced: Option<AuditIntroduced>,
627}
628
629impl UnusedExportFinding {
630 #[must_use]
634 pub fn with_actions(export: UnusedExport) -> Self {
635 let note = if export.is_re_export {
636 Some(
637 "This finding originates from a re-export; verify it is not part of your public API before removing"
638 .to_string(),
639 )
640 } else {
641 None
642 };
643 let actions = vec![
644 IssueAction::Fix(FixAction {
645 kind: FixActionType::RemoveExport,
646 auto_fixable: true,
647 description: "Remove the unused export from the public API".to_string(),
648 note,
649 available_in_catalogs: None,
650 suggested_target: None,
651 }),
652 IssueAction::SuppressLine(SuppressLineAction {
653 kind: SuppressLineKind::SuppressLine,
654 auto_fixable: false,
655 description: "Suppress with an inline comment above the line".to_string(),
656 comment: "// fallow-ignore-next-line unused-export".to_string(),
657 scope: None,
658 }),
659 ];
660 Self {
661 export,
662 actions,
663 introduced: None,
664 }
665 }
666}
667
668#[derive(Debug, Clone, Serialize)]
673#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
674pub struct UnusedTypeFinding {
675 #[serde(flatten)]
677 pub export: UnusedExport,
678 pub actions: Vec<IssueAction>,
681 #[serde(default, skip_serializing_if = "Option::is_none")]
684 pub introduced: Option<AuditIntroduced>,
685}
686
687impl UnusedTypeFinding {
688 #[must_use]
691 pub fn with_actions(export: UnusedExport) -> Self {
692 let note = if export.is_re_export {
693 Some(
694 "This finding originates from a re-export; verify it is not part of your public API before removing"
695 .to_string(),
696 )
697 } else {
698 None
699 };
700 let actions = vec![
701 IssueAction::Fix(FixAction {
702 kind: FixActionType::RemoveExport,
703 auto_fixable: true,
704 description:
705 "Remove the `export` (or `export type`) keyword from the type declaration"
706 .to_string(),
707 note,
708 available_in_catalogs: None,
709 suggested_target: None,
710 }),
711 IssueAction::SuppressLine(SuppressLineAction {
712 kind: SuppressLineKind::SuppressLine,
713 auto_fixable: false,
714 description: "Suppress with an inline comment above the line".to_string(),
715 comment: "// fallow-ignore-next-line unused-type".to_string(),
716 scope: None,
717 }),
718 ];
719 Self {
720 export,
721 actions,
722 introduced: None,
723 }
724 }
725}
726
727#[derive(Debug, Clone, Serialize)]
733#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
734pub struct InvalidClientExportFinding {
735 #[serde(flatten)]
737 pub export: InvalidClientExport,
738 pub actions: Vec<IssueAction>,
741 #[serde(default, skip_serializing_if = "Option::is_none")]
744 pub introduced: Option<AuditIntroduced>,
745}
746
747impl InvalidClientExportFinding {
748 #[must_use]
753 pub fn with_actions(export: InvalidClientExport) -> Self {
754 let actions = vec![
755 IssueAction::Fix(FixAction {
756 kind: FixActionType::MoveToServerModule,
757 auto_fixable: false,
758 description: "Move the server-only export to a non-client module and import it from there"
759 .to_string(),
760 note: Some(
761 "A \"use client\" file cannot export a Next.js server-only or route-config name; Next.js rejects it at build time"
762 .to_string(),
763 ),
764 available_in_catalogs: None,
765 suggested_target: None,
766 }),
767 IssueAction::SuppressLine(SuppressLineAction {
768 kind: SuppressLineKind::SuppressLine,
769 auto_fixable: false,
770 description: "Suppress with an inline comment above the line".to_string(),
771 comment: "// fallow-ignore-next-line invalid-client-export".to_string(),
772 scope: None,
773 }),
774 ];
775 Self {
776 export,
777 actions,
778 introduced: None,
779 }
780 }
781}
782
783#[derive(Debug, Clone, Serialize)]
789#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
790pub struct MixedClientServerBarrelFinding {
791 #[serde(flatten)]
793 pub barrel: MixedClientServerBarrel,
794 pub actions: Vec<IssueAction>,
797 #[serde(default, skip_serializing_if = "Option::is_none")]
800 pub introduced: Option<AuditIntroduced>,
801}
802
803impl MixedClientServerBarrelFinding {
804 #[must_use]
809 pub fn with_actions(barrel: MixedClientServerBarrel) -> Self {
810 let actions = vec![
811 IssueAction::Fix(FixAction {
812 kind: FixActionType::SplitMixedBarrel,
813 auto_fixable: false,
814 description: "Split the barrel so client and server-only modules are re-exported from separate files"
815 .to_string(),
816 note: Some(
817 "Importing one name from this barrel drags the other's directive across the client/server boundary"
818 .to_string(),
819 ),
820 available_in_catalogs: None,
821 suggested_target: None,
822 }),
823 IssueAction::SuppressLine(SuppressLineAction {
824 kind: SuppressLineKind::SuppressLine,
825 auto_fixable: false,
826 description: "Suppress with an inline comment above the line".to_string(),
827 comment: "// fallow-ignore-next-line mixed-client-server-barrel".to_string(),
828 scope: None,
829 }),
830 ];
831 Self {
832 barrel,
833 actions,
834 introduced: None,
835 }
836 }
837}
838
839#[derive(Debug, Clone, Serialize)]
845#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
846pub struct MisplacedDirectiveFinding {
847 #[serde(flatten)]
849 pub directive_site: MisplacedDirective,
850 pub actions: Vec<IssueAction>,
853 #[serde(default, skip_serializing_if = "Option::is_none")]
856 pub introduced: Option<AuditIntroduced>,
857}
858
859impl MisplacedDirectiveFinding {
860 #[must_use]
865 pub fn with_actions(directive_site: MisplacedDirective) -> Self {
866 let actions = vec![
867 IssueAction::Fix(FixAction {
868 kind: FixActionType::HoistDirective,
869 auto_fixable: false,
870 description: "Move the directive to the very top of the file, above all imports and statements"
871 .to_string(),
872 note: Some(
873 "An RSC bundler honors the directive only in the leading prologue; here it precedes other statements and is silently ignored"
874 .to_string(),
875 ),
876 available_in_catalogs: None,
877 suggested_target: None,
878 }),
879 IssueAction::SuppressLine(SuppressLineAction {
880 kind: SuppressLineKind::SuppressLine,
881 auto_fixable: false,
882 description: "Suppress with an inline comment above the line".to_string(),
883 comment: "// fallow-ignore-next-line misplaced-directive".to_string(),
884 scope: None,
885 }),
886 ];
887 Self {
888 directive_site,
889 actions,
890 introduced: None,
891 }
892 }
893}
894
895#[derive(Debug, Clone, Serialize)]
899#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
900pub struct UnprovidedInjectFinding {
901 #[serde(flatten)]
903 pub inject: UnprovidedInject,
904 pub actions: Vec<IssueAction>,
907 #[serde(default, skip_serializing_if = "Option::is_none")]
910 pub introduced: Option<AuditIntroduced>,
911}
912
913impl UnprovidedInjectFinding {
914 #[must_use]
918 pub fn with_actions(inject: UnprovidedInject) -> Self {
919 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
920 kind: SuppressLineKind::SuppressLine,
921 auto_fixable: false,
922 description: "Suppress with an inline comment above the line".to_string(),
923 comment: "// fallow-ignore-next-line unprovided-inject".to_string(),
924 scope: None,
925 })];
926 Self {
927 inject,
928 actions,
929 introduced: None,
930 }
931 }
932}
933
934#[derive(Debug, Clone, Serialize)]
938#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
939pub struct UnusedServerActionFinding {
940 #[serde(flatten)]
942 pub action: UnusedServerAction,
943 pub actions: Vec<IssueAction>,
946 #[serde(default, skip_serializing_if = "Option::is_none")]
949 pub introduced: Option<AuditIntroduced>,
950}
951
952impl UnusedServerActionFinding {
953 #[must_use]
957 pub fn with_actions(action: UnusedServerAction) -> Self {
958 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
959 kind: SuppressLineKind::SuppressLine,
960 auto_fixable: false,
961 description: "Suppress with an inline comment above the line".to_string(),
962 comment: "// fallow-ignore-next-line unused-server-action".to_string(),
963 scope: None,
964 })];
965 Self {
966 action,
967 actions,
968 introduced: None,
969 }
970 }
971}
972
973#[derive(Debug, Clone, Serialize)]
977#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
978pub struct UnusedLoadDataKeyFinding {
979 #[serde(flatten)]
981 pub key: UnusedLoadDataKey,
982 pub actions: Vec<IssueAction>,
985 #[serde(default, skip_serializing_if = "Option::is_none")]
988 pub introduced: Option<AuditIntroduced>,
989}
990
991impl UnusedLoadDataKeyFinding {
992 #[must_use]
996 pub fn with_actions(key: UnusedLoadDataKey) -> Self {
997 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
998 kind: SuppressLineKind::SuppressLine,
999 auto_fixable: false,
1000 description: "Suppress with an inline comment above the line".to_string(),
1001 comment: "// fallow-ignore-next-line unused-load-data-key".to_string(),
1002 scope: None,
1003 })];
1004 Self {
1005 key,
1006 actions,
1007 introduced: None,
1008 }
1009 }
1010}
1011
1012#[derive(Debug, Clone, Serialize)]
1017#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1018pub struct UnrenderedComponentFinding {
1019 #[serde(flatten)]
1021 pub component: UnrenderedComponent,
1022 pub actions: Vec<IssueAction>,
1025 #[serde(default, skip_serializing_if = "Option::is_none")]
1028 pub introduced: Option<AuditIntroduced>,
1029}
1030
1031impl UnrenderedComponentFinding {
1032 #[must_use]
1036 pub fn with_actions(component: UnrenderedComponent) -> Self {
1037 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1038 kind: SuppressLineKind::SuppressLine,
1039 auto_fixable: false,
1040 description: "Suppress with an inline comment above the line".to_string(),
1041 comment: "// fallow-ignore-next-line unrendered-component".to_string(),
1042 scope: None,
1043 })];
1044 Self {
1045 component,
1046 actions,
1047 introduced: None,
1048 }
1049 }
1050}
1051
1052#[derive(Debug, Clone, Serialize)]
1057#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1058pub struct UnusedComponentPropFinding {
1059 #[serde(flatten)]
1061 pub prop: UnusedComponentProp,
1062 pub actions: Vec<IssueAction>,
1065 #[serde(default, skip_serializing_if = "Option::is_none")]
1068 pub introduced: Option<AuditIntroduced>,
1069}
1070
1071impl UnusedComponentPropFinding {
1072 #[must_use]
1076 pub fn with_actions(prop: UnusedComponentProp) -> Self {
1077 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1078 kind: SuppressLineKind::SuppressLine,
1079 auto_fixable: false,
1080 description: "Suppress with an inline comment above the line".to_string(),
1081 comment: "// fallow-ignore-next-line unused-component-prop".to_string(),
1082 scope: None,
1083 })];
1084 Self {
1085 prop,
1086 actions,
1087 introduced: None,
1088 }
1089 }
1090}
1091
1092#[derive(Debug, Clone, Serialize)]
1097#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1098pub struct UnusedComponentEmitFinding {
1099 #[serde(flatten)]
1101 pub emit: UnusedComponentEmit,
1102 pub actions: Vec<IssueAction>,
1105 #[serde(default, skip_serializing_if = "Option::is_none")]
1108 pub introduced: Option<AuditIntroduced>,
1109}
1110
1111impl UnusedComponentEmitFinding {
1112 #[must_use]
1116 pub fn with_actions(emit: UnusedComponentEmit) -> Self {
1117 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1118 kind: SuppressLineKind::SuppressLine,
1119 auto_fixable: false,
1120 description: "Suppress with an inline comment above the line".to_string(),
1121 comment: "// fallow-ignore-next-line unused-component-emit".to_string(),
1122 scope: None,
1123 })];
1124 Self {
1125 emit,
1126 actions,
1127 introduced: None,
1128 }
1129 }
1130}
1131
1132#[derive(Debug, Clone, Serialize)]
1138#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1139pub struct PropDrillingChainFinding {
1140 #[serde(flatten)]
1142 pub chain: PropDrillingChain,
1143 pub actions: Vec<IssueAction>,
1146 #[serde(default, skip_serializing_if = "Option::is_none")]
1149 pub introduced: Option<AuditIntroduced>,
1150}
1151
1152impl PropDrillingChainFinding {
1153 #[must_use]
1158 pub fn with_actions(chain: PropDrillingChain) -> Self {
1159 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1160 kind: SuppressLineKind::SuppressLine,
1161 auto_fixable: false,
1162 description: "Suppress with an inline comment above the source prop declaration"
1163 .to_string(),
1164 comment: "// fallow-ignore-next-line prop-drilling".to_string(),
1165 scope: None,
1166 })];
1167 Self {
1168 chain,
1169 actions,
1170 introduced: None,
1171 }
1172 }
1173}
1174
1175#[derive(Debug, Clone, Serialize)]
1181#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1182pub struct ThinWrapperFinding {
1183 #[serde(flatten)]
1185 pub wrapper: ThinWrapper,
1186 pub actions: Vec<IssueAction>,
1189 #[serde(default, skip_serializing_if = "Option::is_none")]
1192 pub introduced: Option<AuditIntroduced>,
1193}
1194
1195impl ThinWrapperFinding {
1196 #[must_use]
1200 pub fn with_actions(wrapper: ThinWrapper) -> Self {
1201 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1202 kind: SuppressLineKind::SuppressLine,
1203 auto_fixable: false,
1204 description: "Suppress with an inline comment above the component definition"
1205 .to_string(),
1206 comment: "// fallow-ignore-next-line thin-wrapper".to_string(),
1207 scope: None,
1208 })];
1209 Self {
1210 wrapper,
1211 actions,
1212 introduced: None,
1213 }
1214 }
1215}
1216
1217#[derive(Debug, Clone, Serialize)]
1225#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1226pub struct DuplicatePropShapeFinding {
1227 #[serde(flatten)]
1229 pub shape: DuplicatePropShape,
1230 pub actions: Vec<IssueAction>,
1233 #[serde(default, skip_serializing_if = "Option::is_none")]
1236 pub introduced: Option<AuditIntroduced>,
1237}
1238
1239impl DuplicatePropShapeFinding {
1240 #[must_use]
1247 pub fn with_actions(shape: DuplicatePropShape) -> Self {
1248 let actions = vec![
1249 IssueAction::SuppressLine(SuppressLineAction {
1250 kind: SuppressLineKind::SuppressLine,
1251 auto_fixable: false,
1252 description: "Three or more components share this exact prop shape. Extract one \
1253 shared `Props` type (or a base component) that every member reuses, \
1254 or keep them separate if a per-variant divergence is planned. \
1255 Suppress one member with an inline comment above the component \
1256 definition."
1257 .to_string(),
1258 comment: "// fallow-ignore-next-line duplicate-prop-shape".to_string(),
1259 scope: None,
1260 }),
1261 IssueAction::SuppressFile(SuppressFileAction {
1262 kind: SuppressFileKind::SuppressFile,
1263 auto_fixable: false,
1264 description: "Escape hatch: a file-level suppress silences this member but it \
1265 still appears in its siblings' `sharing_components` (the group is \
1266 real regardless of suppression)."
1267 .to_string(),
1268 comment: "// fallow-ignore-file duplicate-prop-shape".to_string(),
1269 }),
1270 ];
1271 Self {
1272 shape,
1273 actions,
1274 introduced: None,
1275 }
1276 }
1277}
1278
1279#[derive(Debug, Clone, Serialize)]
1285#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1286pub struct RouteCollisionFinding {
1287 #[serde(flatten)]
1289 pub collision: RouteCollision,
1290 pub actions: Vec<IssueAction>,
1293 #[serde(default, skip_serializing_if = "Option::is_none")]
1296 pub introduced: Option<AuditIntroduced>,
1297}
1298
1299impl RouteCollisionFinding {
1300 #[must_use]
1304 pub fn with_actions(collision: RouteCollision) -> Self {
1305 let actions = vec![
1306 IssueAction::Fix(FixAction {
1307 kind: FixActionType::ResolveRouteCollision,
1308 auto_fixable: false,
1309 description: "Two or more files resolve to the same URL. Move or merge one so \
1310 each URL has a single owner. Route groups `(name)` and parallel \
1311 slots `@name` are the only legal same-URL shapes."
1312 .to_string(),
1313 note: Some(
1314 "Next.js fails the build with \"You cannot have two parallel pages that \
1315 resolve to the same path\". See the sibling `conflicting_paths` array for \
1316 the other files that own this URL."
1317 .to_string(),
1318 ),
1319 available_in_catalogs: None,
1320 suggested_target: None,
1321 }),
1322 IssueAction::SuppressFile(SuppressFileAction {
1323 kind: SuppressFileKind::SuppressFile,
1324 auto_fixable: false,
1325 description: "Escape hatch only: a file-level suppress silences the finding but \
1326 does NOT make `next build` pass. Prefer moving or merging a file."
1327 .to_string(),
1328 comment: "// fallow-ignore-file route-collision".to_string(),
1329 }),
1330 ];
1331 Self {
1332 collision,
1333 actions,
1334 introduced: None,
1335 }
1336 }
1337}
1338
1339#[derive(Debug, Clone, Serialize)]
1344#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1345pub struct DynamicSegmentNameConflictFinding {
1346 #[serde(flatten)]
1348 pub conflict: DynamicSegmentNameConflict,
1349 pub actions: Vec<IssueAction>,
1352 #[serde(default, skip_serializing_if = "Option::is_none")]
1355 pub introduced: Option<AuditIntroduced>,
1356}
1357
1358impl DynamicSegmentNameConflictFinding {
1359 #[must_use]
1362 pub fn with_actions(conflict: DynamicSegmentNameConflict) -> Self {
1363 let actions = vec![
1364 IssueAction::Fix(FixAction {
1365 kind: FixActionType::ResolveDynamicSegmentNameConflict,
1366 auto_fixable: false,
1367 description: "Sibling dynamic segments at the same position use different param \
1368 names. Rename them to one consistent slug name (e.g. pick `[id]` \
1369 or `[slug]` for both)."
1370 .to_string(),
1371 note: Some(
1372 "Next.js throws \"You cannot use different slug names for the same dynamic \
1373 path\" at dev / runtime when the position is hit; `next build` does not \
1374 catch it. See the sibling `conflicting_segments` array."
1375 .to_string(),
1376 ),
1377 available_in_catalogs: None,
1378 suggested_target: None,
1379 }),
1380 IssueAction::SuppressFile(SuppressFileAction {
1381 kind: SuppressFileKind::SuppressFile,
1382 auto_fixable: false,
1383 description: "Escape hatch only: a file-level suppress silences the finding but \
1384 does NOT stop Next.js from throwing at dev / runtime. Prefer \
1385 renaming the segments."
1386 .to_string(),
1387 comment: "// fallow-ignore-file dynamic-segment-name-conflict".to_string(),
1388 }),
1389 ];
1390 Self {
1391 conflict,
1392 actions,
1393 introduced: None,
1394 }
1395 }
1396}
1397
1398#[derive(Debug, Clone, Serialize)]
1401#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1402pub struct UnusedEnumMemberFinding {
1403 #[serde(flatten)]
1405 pub member: UnusedMember,
1406 pub actions: Vec<IssueAction>,
1409 #[serde(default, skip_serializing_if = "Option::is_none")]
1412 pub introduced: Option<AuditIntroduced>,
1413}
1414
1415impl UnusedEnumMemberFinding {
1416 #[must_use]
1418 pub fn with_actions(member: UnusedMember) -> Self {
1419 let actions = vec![
1420 IssueAction::Fix(FixAction {
1421 kind: FixActionType::RemoveEnumMember,
1422 auto_fixable: true,
1423 description: "Remove this enum member".to_string(),
1424 note: None,
1425 available_in_catalogs: None,
1426 suggested_target: None,
1427 }),
1428 IssueAction::SuppressLine(SuppressLineAction {
1429 kind: SuppressLineKind::SuppressLine,
1430 auto_fixable: false,
1431 description: "Suppress with an inline comment above the line".to_string(),
1432 comment: "// fallow-ignore-next-line unused-enum-member".to_string(),
1433 scope: None,
1434 }),
1435 ];
1436 Self {
1437 member,
1438 actions,
1439 introduced: None,
1440 }
1441 }
1442}
1443
1444#[derive(Debug, Clone, Serialize)]
1449#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1450pub struct UnusedClassMemberFinding {
1451 #[serde(flatten)]
1453 pub member: UnusedMember,
1454 pub actions: Vec<IssueAction>,
1457 #[serde(default, skip_serializing_if = "Option::is_none")]
1460 pub introduced: Option<AuditIntroduced>,
1461}
1462
1463impl UnusedClassMemberFinding {
1464 #[must_use]
1469 pub fn with_actions(member: UnusedMember) -> Self {
1470 let actions = vec![
1471 IssueAction::Fix(FixAction {
1472 kind: FixActionType::RemoveClassMember,
1473 auto_fixable: false,
1474 description: "Remove this class member".to_string(),
1475 note: Some(
1476 "Class member may be used via dependency injection or decorators".to_string(),
1477 ),
1478 available_in_catalogs: None,
1479 suggested_target: None,
1480 }),
1481 IssueAction::SuppressLine(SuppressLineAction {
1482 kind: SuppressLineKind::SuppressLine,
1483 auto_fixable: false,
1484 description: "Suppress with an inline comment above the line".to_string(),
1485 comment: "// fallow-ignore-next-line unused-class-member".to_string(),
1486 scope: None,
1487 }),
1488 ];
1489 Self {
1490 member,
1491 actions,
1492 introduced: None,
1493 }
1494 }
1495}
1496
1497#[derive(Debug, Clone, Serialize)]
1506#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1507pub struct UnusedStoreMemberFinding {
1508 #[serde(flatten)]
1510 pub member: UnusedMember,
1511 pub actions: Vec<IssueAction>,
1514 #[serde(default, skip_serializing_if = "Option::is_none")]
1517 pub introduced: Option<AuditIntroduced>,
1518}
1519
1520impl UnusedStoreMemberFinding {
1521 #[must_use]
1525 pub fn with_actions(member: UnusedMember) -> Self {
1526 let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1527 kind: SuppressLineKind::SuppressLine,
1528 auto_fixable: false,
1529 description: "Suppress with an inline comment above the line".to_string(),
1530 comment: "// fallow-ignore-next-line unused-store-member".to_string(),
1531 scope: None,
1532 })];
1533 Self {
1534 member,
1535 actions,
1536 introduced: None,
1537 }
1538 }
1539}
1540
1541fn build_unused_dependency_actions(
1552 dep: &UnusedDependency,
1553 package_json_location: &str,
1554 suppress_issue_kind: &str,
1555) -> Vec<IssueAction> {
1556 let mut actions = Vec::with_capacity(2);
1557 let cross_workspace = !dep.used_in_workspaces.is_empty();
1558 actions.push(if cross_workspace {
1559 IssueAction::Fix(FixAction {
1560 kind: FixActionType::MoveDependency,
1561 auto_fixable: false,
1562 description: "Move this dependency to the workspace package.json that imports it"
1563 .to_string(),
1564 note: Some(
1565 "fallow fix will not remove dependencies that are imported by another workspace"
1566 .to_string(),
1567 ),
1568 available_in_catalogs: None,
1569 suggested_target: None,
1570 })
1571 } else {
1572 IssueAction::Fix(FixAction {
1573 kind: FixActionType::RemoveDependency,
1574 auto_fixable: true,
1575 description: format!("Remove from {package_json_location} in package.json"),
1576 note: None,
1577 available_in_catalogs: None,
1578 suggested_target: None,
1579 })
1580 });
1581 actions.push(build_ignore_dependencies_suppress_action(
1582 &dep.package_name,
1583 suppress_issue_kind,
1584 ));
1585 actions
1586}
1587
1588fn build_ignore_dependencies_suppress_action(
1596 package_name: &str,
1597 _suppress_issue_kind: &str,
1598) -> IssueAction {
1599 IssueAction::AddToConfig(AddToConfigAction {
1600 kind: AddToConfigKind::AddToConfig,
1601 auto_fixable: false,
1602 description: format!("Add \"{package_name}\" to ignoreDependencies in fallow config"),
1603 config_key: "ignoreDependencies".to_string(),
1604 value: AddToConfigValue::Scalar(package_name.to_string()),
1605 value_schema: Some(
1606 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items"
1607 .to_string(),
1608 ),
1609 })
1610}
1611
1612#[derive(Debug, Clone, Serialize)]
1618#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1619pub struct UnusedDependencyFinding {
1620 #[serde(flatten)]
1622 pub dep: UnusedDependency,
1623 pub actions: Vec<IssueAction>,
1626 #[serde(default, skip_serializing_if = "Option::is_none")]
1629 pub introduced: Option<AuditIntroduced>,
1630}
1631
1632impl UnusedDependencyFinding {
1633 #[must_use]
1636 pub fn with_actions(dep: UnusedDependency) -> Self {
1637 let actions = build_unused_dependency_actions(&dep, "dependencies", "unused-dependency");
1638 Self {
1639 dep,
1640 actions,
1641 introduced: None,
1642 }
1643 }
1644}
1645
1646#[derive(Debug, Clone, Serialize)]
1652#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1653pub struct UnusedDevDependencyFinding {
1654 #[serde(flatten)]
1656 pub dep: UnusedDependency,
1657 pub actions: Vec<IssueAction>,
1660 #[serde(default, skip_serializing_if = "Option::is_none")]
1663 pub introduced: Option<AuditIntroduced>,
1664}
1665
1666impl UnusedDevDependencyFinding {
1667 #[must_use]
1669 pub fn with_actions(dep: UnusedDependency) -> Self {
1670 let actions =
1671 build_unused_dependency_actions(&dep, "devDependencies", "unused-dev-dependency");
1672 Self {
1673 dep,
1674 actions,
1675 introduced: None,
1676 }
1677 }
1678}
1679
1680#[derive(Debug, Clone, Serialize)]
1686#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1687pub struct UnusedOptionalDependencyFinding {
1688 #[serde(flatten)]
1690 pub dep: UnusedDependency,
1691 pub actions: Vec<IssueAction>,
1694 #[serde(default, skip_serializing_if = "Option::is_none")]
1697 pub introduced: Option<AuditIntroduced>,
1698}
1699
1700impl UnusedOptionalDependencyFinding {
1701 #[must_use]
1703 pub fn with_actions(dep: UnusedDependency) -> Self {
1704 let actions =
1705 build_unused_dependency_actions(&dep, "optionalDependencies", "unused-dependency");
1706 Self {
1707 dep,
1708 actions,
1709 introduced: None,
1710 }
1711 }
1712}
1713
1714#[derive(Debug, Clone, Serialize)]
1718#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1719pub struct UnlistedDependencyFinding {
1720 #[serde(flatten)]
1722 pub dep: UnlistedDependency,
1723 pub actions: Vec<IssueAction>,
1726 #[serde(default, skip_serializing_if = "Option::is_none")]
1729 pub introduced: Option<AuditIntroduced>,
1730}
1731
1732impl UnlistedDependencyFinding {
1733 #[must_use]
1735 pub fn with_actions(dep: UnlistedDependency) -> Self {
1736 let actions = vec![
1737 IssueAction::Fix(FixAction {
1738 kind: FixActionType::InstallDependency,
1739 auto_fixable: false,
1740 description: "Add this package to dependencies in package.json".to_string(),
1741 note: Some(
1742 "Verify this package should be a direct dependency before adding".to_string(),
1743 ),
1744 available_in_catalogs: None,
1745 suggested_target: None,
1746 }),
1747 build_ignore_dependencies_suppress_action(&dep.package_name, "unlisted-dependency"),
1748 ];
1749 Self {
1750 dep,
1751 actions,
1752 introduced: None,
1753 }
1754 }
1755}
1756
1757#[derive(Debug, Clone, Serialize)]
1761#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1762pub struct TypeOnlyDependencyFinding {
1763 #[serde(flatten)]
1765 pub dep: TypeOnlyDependency,
1766 pub actions: Vec<IssueAction>,
1769 #[serde(default, skip_serializing_if = "Option::is_none")]
1772 pub introduced: Option<AuditIntroduced>,
1773}
1774
1775impl TypeOnlyDependencyFinding {
1776 #[must_use]
1778 pub fn with_actions(dep: TypeOnlyDependency) -> Self {
1779 let actions = vec![
1780 IssueAction::Fix(FixAction {
1781 kind: FixActionType::MoveToDev,
1782 auto_fixable: false,
1783 description: "Move to devDependencies (only type imports are used)".to_string(),
1784 note: Some(
1785 "Type imports are erased at runtime so this dependency is not needed in production"
1786 .to_string(),
1787 ),
1788 available_in_catalogs: None,
1789 suggested_target: None,
1790 }),
1791 build_ignore_dependencies_suppress_action(&dep.package_name, "type-only-dependency"),
1792 ];
1793 Self {
1794 dep,
1795 actions,
1796 introduced: None,
1797 }
1798 }
1799}
1800
1801#[derive(Debug, Clone, Serialize)]
1805#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1806pub struct TestOnlyDependencyFinding {
1807 #[serde(flatten)]
1809 pub dep: TestOnlyDependency,
1810 pub actions: Vec<IssueAction>,
1813 #[serde(default, skip_serializing_if = "Option::is_none")]
1816 pub introduced: Option<AuditIntroduced>,
1817}
1818
1819impl TestOnlyDependencyFinding {
1820 #[must_use]
1822 pub fn with_actions(dep: TestOnlyDependency) -> Self {
1823 let actions = vec![
1824 IssueAction::Fix(FixAction {
1825 kind: FixActionType::MoveToDev,
1826 auto_fixable: false,
1827 description: "Move to devDependencies (only test files import this)".to_string(),
1828 note: Some(
1829 "Only test files import this package so it does not need to be a production dependency"
1830 .to_string(),
1831 ),
1832 available_in_catalogs: None,
1833 suggested_target: None,
1834 }),
1835 build_ignore_dependencies_suppress_action(&dep.package_name, "test-only-dependency"),
1836 ];
1837 Self {
1838 dep,
1839 actions,
1840 introduced: None,
1841 }
1842 }
1843}
1844
1845#[derive(Debug, Clone, Serialize)]
1866#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1867pub struct DuplicateExportFinding {
1868 #[serde(flatten)]
1870 pub export: DuplicateExport,
1871 pub actions: Vec<IssueAction>,
1874 #[serde(default, skip_serializing_if = "Option::is_none")]
1877 pub introduced: Option<AuditIntroduced>,
1878}
1879
1880impl DuplicateExportFinding {
1881 #[must_use]
1890 pub fn with_actions(export: DuplicateExport) -> Self {
1891 let mut actions: Vec<IssueAction> = Vec::with_capacity(3);
1892
1893 if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
1894 actions.push(IssueAction::AddToConfig(AddToConfigAction {
1895 kind: AddToConfigKind::AddToConfig,
1896 auto_fixable: false,
1897 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(),
1898 config_key: "ignoreExports".to_string(),
1899 value: AddToConfigValue::ExportsRules(rules),
1900 value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
1901 }));
1902 }
1903
1904 actions.push(IssueAction::Fix(FixAction {
1905 kind: FixActionType::RemoveDuplicate,
1906 auto_fixable: false,
1907 description: "Keep one canonical export location and remove the others".to_string(),
1908 note: Some(NAMESPACE_BARREL_HINT.to_string()),
1909 available_in_catalogs: None,
1910 suggested_target: None,
1911 }));
1912
1913 actions.push(IssueAction::SuppressLine(SuppressLineAction {
1914 kind: SuppressLineKind::SuppressLine,
1915 auto_fixable: false,
1916 description: "Suppress with an inline comment above the line".to_string(),
1917 comment: "// fallow-ignore-next-line duplicate-export".to_string(),
1918 scope: Some(SuppressLineScope::PerLocation),
1919 }));
1920
1921 Self {
1922 export,
1923 actions,
1924 introduced: None,
1925 }
1926 }
1927
1928 pub fn set_config_fixable(&mut self, fixable: bool) {
1934 if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
1935 action.auto_fixable = fixable;
1936 }
1937 }
1938}
1939
1940fn build_duplicate_exports_ignore_rules(
1944 export: &DuplicateExport,
1945) -> Option<Vec<IgnoreExportsRule>> {
1946 let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
1947 for loc in &export.locations {
1948 let path = loc.path.to_string_lossy().replace('\\', "/");
1956 if path.is_empty() {
1957 continue;
1958 }
1959 if entries.iter().any(|existing| existing.file == path) {
1960 continue;
1961 }
1962 entries.push(IgnoreExportsRule {
1963 file: path,
1964 exports: vec!["*".to_string()],
1965 });
1966 }
1967 if entries.is_empty() {
1968 None
1969 } else {
1970 Some(entries)
1971 }
1972}
1973
1974#[derive(Debug, Clone, Serialize)]
1979#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1980pub struct UnusedCatalogEntryFinding {
1981 #[serde(flatten)]
1983 pub entry: UnusedCatalogEntry,
1984 pub actions: Vec<IssueAction>,
1986 #[serde(default, skip_serializing_if = "Option::is_none")]
1989 pub introduced: Option<AuditIntroduced>,
1990}
1991
1992impl UnusedCatalogEntryFinding {
1993 #[must_use]
1997 pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
1998 let auto_fixable = entry.hardcoded_consumers.is_empty();
1999 let actions = vec![
2000 IssueAction::Fix(FixAction {
2001 kind: FixActionType::RemoveCatalogEntry,
2002 auto_fixable,
2003 description: "Remove the entry from pnpm-workspace.yaml".to_string(),
2004 note: Some(
2005 "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing"
2006 .to_string(),
2007 ),
2008 available_in_catalogs: None,
2009 suggested_target: None,
2010 }),
2011 IssueAction::SuppressLine(SuppressLineAction {
2012 kind: SuppressLineKind::SuppressLine,
2013 auto_fixable: false,
2014 description: "Suppress with a YAML comment above the line".to_string(),
2015 comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
2016 scope: None,
2017 }),
2018 ];
2019 Self {
2020 entry,
2021 actions,
2022 introduced: None,
2023 }
2024 }
2025}
2026
2027#[derive(Debug, Clone, Serialize)]
2031#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2032pub struct EmptyCatalogGroupFinding {
2033 #[serde(flatten)]
2035 pub group: EmptyCatalogGroup,
2036 pub actions: Vec<IssueAction>,
2038 #[serde(default, skip_serializing_if = "Option::is_none")]
2041 pub introduced: Option<AuditIntroduced>,
2042}
2043
2044impl EmptyCatalogGroupFinding {
2045 #[must_use]
2047 pub fn with_actions(group: EmptyCatalogGroup) -> Self {
2048 let actions = vec![
2049 IssueAction::Fix(FixAction {
2050 kind: FixActionType::RemoveEmptyCatalogGroup,
2051 auto_fixable: true,
2052 description: "Remove the empty named catalog group from pnpm-workspace.yaml"
2053 .to_string(),
2054 note: Some(
2055 "Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
2056 .to_string(),
2057 ),
2058 available_in_catalogs: None,
2059 suggested_target: None,
2060 }),
2061 IssueAction::SuppressLine(SuppressLineAction {
2062 kind: SuppressLineKind::SuppressLine,
2063 auto_fixable: false,
2064 description: "Suppress with a YAML comment above the line".to_string(),
2065 comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
2066 scope: None,
2067 }),
2068 ];
2069 Self {
2070 group,
2071 actions,
2072 introduced: None,
2073 }
2074 }
2075}
2076
2077#[derive(Debug, Clone, Serialize)]
2085#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2086pub struct UnresolvedCatalogReferenceFinding {
2087 #[serde(flatten)]
2089 pub reference: UnresolvedCatalogReference,
2090 pub actions: Vec<IssueAction>,
2093 #[serde(default, skip_serializing_if = "Option::is_none")]
2096 pub introduced: Option<AuditIntroduced>,
2097}
2098
2099impl UnresolvedCatalogReferenceFinding {
2100 #[must_use]
2104 pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
2105 let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
2110 let primary = if reference.available_in_catalogs.is_empty() {
2111 IssueAction::Fix(FixAction {
2112 kind: FixActionType::AddCatalogEntry,
2113 auto_fixable: false,
2114 description: format!(
2115 "Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
2116 reference.entry_name, reference.catalog_name
2117 ),
2118 note: Some(
2119 "Pin a version that satisfies the consumer's import; no other catalog declares this package today"
2120 .to_string(),
2121 ),
2122 available_in_catalogs: None,
2123 suggested_target: None,
2124 })
2125 } else {
2126 let available = reference.available_in_catalogs.clone();
2127 let suggested_target = (available.len() == 1).then(|| available[0].clone());
2128 IssueAction::Fix(FixAction {
2129 kind: FixActionType::UpdateCatalogReference,
2130 auto_fixable: false,
2131 description: format!(
2132 "Switch the reference from `catalog:{}` to a catalog that declares `{}`",
2133 reference.catalog_name, reference.entry_name
2134 ),
2135 note: None,
2136 available_in_catalogs: Some(available),
2137 suggested_target,
2138 })
2139 };
2140
2141 let fallback = IssueAction::Fix(FixAction {
2142 kind: FixActionType::RemoveCatalogReference,
2143 auto_fixable: false,
2144 description:
2145 "Remove the catalog reference and pin a hardcoded version in package.json"
2146 .to_string(),
2147 note: Some(
2148 "Use only when neither another catalog declares the package nor the named catalog should grow to include it"
2149 .to_string(),
2150 ),
2151 available_in_catalogs: None,
2152 suggested_target: None,
2153 });
2154
2155 let mut suppress_value = serde_json::Map::new();
2156 suppress_value.insert(
2157 "package".to_string(),
2158 serde_json::Value::String(reference.entry_name.clone()),
2159 );
2160 suppress_value.insert(
2161 "catalog".to_string(),
2162 serde_json::Value::String(reference.catalog_name.clone()),
2163 );
2164 suppress_value.insert(
2165 "consumer".to_string(),
2166 serde_json::Value::String(consumer_path),
2167 );
2168 let suppress = IssueAction::AddToConfig(AddToConfigAction {
2169 kind: AddToConfigKind::AddToConfig,
2170 auto_fixable: false,
2171 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(),
2172 config_key: "ignoreCatalogReferences".to_string(),
2173 value: AddToConfigValue::RuleObject(suppress_value),
2174 value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
2175 });
2176
2177 Self {
2178 reference,
2179 actions: vec![primary, fallback, suppress],
2180 introduced: None,
2181 }
2182 }
2183}
2184
2185#[derive(Debug, Clone, Serialize)]
2190#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2191pub struct UnusedDependencyOverrideFinding {
2192 #[serde(flatten)]
2194 pub entry: UnusedDependencyOverride,
2195 pub actions: Vec<IssueAction>,
2197 #[serde(default, skip_serializing_if = "Option::is_none")]
2200 pub introduced: Option<AuditIntroduced>,
2201}
2202
2203impl UnusedDependencyOverrideFinding {
2204 #[must_use]
2206 pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
2207 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
2208 actions.push(IssueAction::Fix(FixAction {
2209 kind: FixActionType::RemoveDependencyOverride,
2210 auto_fixable: false,
2211 description: "Remove the override entry from pnpm-workspace.yaml or pnpm.overrides"
2212 .to_string(),
2213 note: Some(
2214 "Conservative static check; verify against `pnpm install --frozen-lockfile` before removing in case the override targets a transitive dependency (CVE-fix pattern)"
2215 .to_string(),
2216 ),
2217 available_in_catalogs: None,
2218 suggested_target: None,
2219 }));
2220
2221 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
2222 Some(&entry.target_package),
2223 &entry.raw_key,
2224 entry.source,
2225 ) {
2226 actions.push(suppress);
2227 }
2228
2229 Self {
2230 entry,
2231 actions,
2232 introduced: None,
2233 }
2234 }
2235}
2236
2237#[derive(Debug, Clone, Serialize)]
2243#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2244pub struct MisconfiguredDependencyOverrideFinding {
2245 #[serde(flatten)]
2247 pub entry: MisconfiguredDependencyOverride,
2248 pub actions: Vec<IssueAction>,
2250 #[serde(default, skip_serializing_if = "Option::is_none")]
2253 pub introduced: Option<AuditIntroduced>,
2254}
2255
2256impl MisconfiguredDependencyOverrideFinding {
2257 #[must_use]
2262 pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
2263 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
2264 actions.push(IssueAction::Fix(FixAction {
2265 kind: FixActionType::FixDependencyOverride,
2266 auto_fixable: false,
2267 description:
2268 "Fix the override key or value: pnpm refuses to honor entries with an unparsable key or empty value"
2269 .to_string(),
2270 note: Some(
2271 "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`."
2272 .to_string(),
2273 ),
2274 available_in_catalogs: None,
2275 suggested_target: None,
2276 }));
2277
2278 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
2279 entry.target_package.as_deref(),
2280 &entry.raw_key,
2281 entry.source,
2282 ) {
2283 actions.push(suppress);
2284 }
2285
2286 Self {
2287 entry,
2288 actions,
2289 introduced: None,
2290 }
2291 }
2292}
2293
2294fn build_ignore_dependency_overrides_suppress(
2299 target_package: Option<&str>,
2300 raw_key: &str,
2301 source: DependencyOverrideSource,
2302) -> Option<IssueAction> {
2303 let package = target_package
2304 .filter(|s| !s.is_empty())
2305 .or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
2306 .to_string();
2307 let mut value = serde_json::Map::new();
2308 value.insert("package".to_string(), serde_json::Value::String(package));
2309 value.insert(
2310 "source".to_string(),
2311 serde_json::Value::String(source.as_label().to_string()),
2312 );
2313 Some(IssueAction::AddToConfig(AddToConfigAction {
2314 kind: AddToConfigKind::AddToConfig,
2315 auto_fixable: false,
2316 description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
2317 config_key: "ignoreDependencyOverrides".to_string(),
2318 value: AddToConfigValue::RuleObject(value),
2319 value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
2320 }))
2321}
2322
2323#[cfg(test)]
2333mod position_0_invariants {
2334 use super::*;
2335 use crate::output::FixActionType;
2336 use crate::results::{DependencyOverrideSource, DuplicateLocation};
2337 use std::path::PathBuf;
2338
2339 fn action_type(action: &IssueAction) -> &'static str {
2344 match action {
2345 IssueAction::Fix(fix) => match fix.kind {
2346 FixActionType::RemoveExport => "remove-export",
2347 FixActionType::DeleteFile => "delete-file",
2348 FixActionType::RemoveDependency => "remove-dependency",
2349 FixActionType::MoveDependency => "move-dependency",
2350 FixActionType::RemoveEnumMember => "remove-enum-member",
2351 FixActionType::RemoveClassMember => "remove-class-member",
2352 FixActionType::ResolveImport => "resolve-import",
2353 FixActionType::InstallDependency => "install-dependency",
2354 FixActionType::RemoveDuplicate => "remove-duplicate",
2355 FixActionType::MoveToDev => "move-to-dev",
2356 FixActionType::RefactorCycle => "refactor-cycle",
2357 FixActionType::RefactorReExportCycle => "refactor-re-export-cycle",
2358 FixActionType::RefactorBoundary => "refactor-boundary",
2359 FixActionType::ExportType => "export-type",
2360 FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
2361 FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
2362 FixActionType::UpdateCatalogReference => "update-catalog-reference",
2363 FixActionType::AddCatalogEntry => "add-catalog-entry",
2364 FixActionType::RemoveCatalogReference => "remove-catalog-reference",
2365 FixActionType::RemoveDependencyOverride => "remove-dependency-override",
2366 FixActionType::FixDependencyOverride => "fix-dependency-override",
2367 FixActionType::ResolvePolicyViolation => "resolve-policy-violation",
2368 FixActionType::MoveToServerModule => "move-to-server-module",
2369 FixActionType::SplitMixedBarrel => "split-mixed-barrel",
2370 FixActionType::HoistDirective => "hoist-directive",
2371 FixActionType::ResolveRouteCollision => "resolve-route-collision",
2372 FixActionType::ResolveDynamicSegmentNameConflict => {
2373 "resolve-dynamic-segment-name-conflict"
2374 }
2375 },
2376 IssueAction::SuppressLine(_) => "suppress-line",
2377 IssueAction::SuppressFile(_) => "suppress-file",
2378 IssueAction::AddToConfig(_) => "add-to-config",
2379 }
2380 }
2381
2382 #[test]
2383 fn unresolved_import_actions_include_ignore_unresolved_imports_config_suppress() {
2384 let inner = UnresolvedImport {
2385 specifier: "@example/icons".to_string(),
2386 path: PathBuf::from("src/index.ts"),
2387 line: 4,
2388 col: 12,
2389 specifier_col: 18,
2390 };
2391 let finding = UnresolvedImportFinding::with_actions(inner);
2392
2393 assert_eq!(action_type(&finding.actions[0]), "resolve-import");
2394 assert_eq!(action_type(&finding.actions[1]), "add-to-config");
2395 let IssueAction::AddToConfig(action) = &finding.actions[1] else {
2396 panic!("position-1 should be AddToConfig");
2397 };
2398 assert!(!action.auto_fixable);
2399 assert_eq!(action.config_key, "ignoreUnresolvedImports");
2400 let AddToConfigValue::Scalar(value) = &action.value else {
2401 panic!("ignoreUnresolvedImports action should carry a scalar value");
2402 };
2403 assert_eq!(value, "@example/icons");
2404 assert_eq!(
2405 action.value_schema.as_deref(),
2406 Some(
2407 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
2408 )
2409 );
2410 }
2411
2412 #[test]
2423 fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
2424 let inner = UnresolvedCatalogReference {
2425 entry_name: "react".to_string(),
2426 catalog_name: "default".to_string(),
2427 path: PathBuf::from("apps/web/package.json"),
2428 line: 7,
2429 available_in_catalogs: Vec::new(),
2430 };
2431 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
2432 assert_eq!(
2433 action_type(&finding.actions[0]),
2434 "add-catalog-entry",
2435 "position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
2436 );
2437 let IssueAction::Fix(fix) = &finding.actions[0] else {
2438 panic!("position-0 should be an IssueAction::Fix");
2439 };
2440 assert!(
2441 fix.available_in_catalogs.is_none(),
2442 "add-catalog-entry must NOT carry available_in_catalogs"
2443 );
2444 assert!(
2445 fix.suggested_target.is_none(),
2446 "add-catalog-entry must NOT carry suggested_target"
2447 );
2448 }
2449
2450 #[test]
2457 fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
2458 let inner = UnresolvedCatalogReference {
2459 entry_name: "react".to_string(),
2460 catalog_name: "default".to_string(),
2461 path: PathBuf::from("apps/web/package.json"),
2462 line: 7,
2463 available_in_catalogs: vec!["react18".to_string()],
2464 };
2465 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
2466 assert_eq!(
2467 action_type(&finding.actions[0]),
2468 "update-catalog-reference",
2469 "position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
2470 );
2471 let IssueAction::Fix(fix) = &finding.actions[0] else {
2472 panic!("position-0 should be an IssueAction::Fix");
2473 };
2474 assert_eq!(
2475 fix.available_in_catalogs.as_deref(),
2476 Some(&["react18".to_string()][..]),
2477 "update-catalog-reference must carry the alternative list"
2478 );
2479 assert_eq!(
2480 fix.suggested_target.as_deref(),
2481 Some("react18"),
2482 "single-alternative case must surface `suggested_target` for deterministic agents"
2483 );
2484
2485 let inner_two = UnresolvedCatalogReference {
2487 entry_name: "react".to_string(),
2488 catalog_name: "default".to_string(),
2489 path: PathBuf::from("apps/web/package.json"),
2490 line: 7,
2491 available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
2492 };
2493 let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
2494 assert_eq!(
2495 action_type(&finding_two.actions[0]),
2496 "update-catalog-reference"
2497 );
2498 let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
2499 panic!("position-0 should be an IssueAction::Fix");
2500 };
2501 assert!(
2502 fix_two.suggested_target.is_none(),
2503 "multi-alternative case must NOT carry `suggested_target` (agent must pick)"
2504 );
2505 }
2506
2507 #[test]
2522 fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
2523 let inner = DuplicateExport {
2524 export_name: "Root".to_string(),
2525 locations: vec![
2526 DuplicateLocation {
2527 path: PathBuf::from("components/ui/accordion/index.ts"),
2528 line: 1,
2529 col: 0,
2530 },
2531 DuplicateLocation {
2532 path: PathBuf::from("components/ui/dialog/index.ts"),
2533 line: 1,
2534 col: 0,
2535 },
2536 ],
2537 };
2538 let finding = DuplicateExportFinding::with_actions(inner);
2539 assert_eq!(
2540 action_type(&finding.actions[0]),
2541 "add-to-config",
2542 "position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
2543 );
2544 assert_eq!(
2545 action_type(&finding.actions[1]),
2546 "remove-duplicate",
2547 "position-1 must be the destructive `remove-duplicate` fallback"
2548 );
2549
2550 let mut promoted = finding;
2553 promoted.set_config_fixable(true);
2554 assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
2555 let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
2556 panic!("position-0 should still be AddToConfig after set_config_fixable");
2557 };
2558 assert!(
2559 action.auto_fixable,
2560 "set_config_fixable(true) must flip auto_fixable"
2561 );
2562 }
2563
2564 #[test]
2569 fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
2570 let inner = DuplicateExport {
2571 export_name: "Root".to_string(),
2572 locations: Vec::new(),
2573 };
2574 let finding = DuplicateExportFinding::with_actions(inner);
2575 assert_eq!(
2576 action_type(&finding.actions[0]),
2577 "remove-duplicate",
2578 "with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
2579 );
2580
2581 let mut promoted = finding;
2583 promoted.set_config_fixable(true);
2584 assert_eq!(
2585 action_type(&promoted.actions[0]),
2586 "remove-duplicate",
2587 "set_config_fixable is a no-op when position-0 is not add-to-config"
2588 );
2589 }
2590
2591 #[test]
2597 fn misconfigured_override_drops_suppress_when_no_package_name() {
2598 let inner = MisconfiguredDependencyOverride {
2599 raw_key: String::new(),
2600 target_package: None,
2601 raw_value: String::new(),
2602 reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
2603 source: DependencyOverrideSource::PnpmWorkspaceYaml,
2604 path: PathBuf::from("pnpm-workspace.yaml"),
2605 line: 12,
2606 };
2607 let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
2608 assert_eq!(finding.actions.len(), 1);
2610 assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
2611 }
2612}