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 BoundaryViolation, CircularDependency, DependencyOverrideSource, DuplicateExport,
40 EmptyCatalogGroup, MisconfiguredDependencyOverride, PrivateTypeLeak, ReExportCycle,
41 ReExportCycleKind, TestOnlyDependency, TypeOnlyDependency, UnlistedDependency,
42 UnresolvedCatalogReference, UnresolvedImport, UnusedCatalogEntry, UnusedDependency,
43 UnusedDependencyOverride, UnusedExport, UnusedFile, UnusedMember,
44};
45
46pub 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.";
50
51const IGNORE_EXPORTS_VALUE_SCHEMA: &str =
55 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreExports";
56
57const IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreCatalogReferences/items";
60
61const IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencyOverrides/items";
65
66#[derive(Debug, Clone, Serialize)]
71#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
72pub struct UnusedFileFinding {
73 #[serde(flatten)]
75 pub file: UnusedFile,
76 pub actions: Vec<IssueAction>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub introduced: Option<AuditIntroduced>,
83}
84
85impl UnusedFileFinding {
86 #[must_use]
90 pub fn with_actions(file: UnusedFile) -> Self {
91 let actions = vec![
92 IssueAction::Fix(FixAction {
93 kind: FixActionType::DeleteFile,
94 auto_fixable: false,
95 description: "Delete this file".to_string(),
96 note: Some(
97 "File deletion may remove runtime functionality not visible to static analysis"
98 .to_string(),
99 ),
100 available_in_catalogs: None,
101 suggested_target: None,
102 }),
103 IssueAction::SuppressFile(SuppressFileAction {
104 kind: SuppressFileKind::SuppressFile,
105 auto_fixable: false,
106 description: "Suppress with a file-level comment at the top of the file"
107 .to_string(),
108 comment: "// fallow-ignore-file unused-file".to_string(),
109 }),
110 ];
111 Self {
112 file,
113 actions,
114 introduced: None,
115 }
116 }
117}
118
119#[derive(Debug, Clone, Serialize)]
123#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
124pub struct PrivateTypeLeakFinding {
125 #[serde(flatten)]
127 pub leak: PrivateTypeLeak,
128 pub actions: Vec<IssueAction>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub introduced: Option<AuditIntroduced>,
135}
136
137impl PrivateTypeLeakFinding {
138 #[must_use]
140 pub fn with_actions(leak: PrivateTypeLeak) -> Self {
141 let actions = vec![
142 IssueAction::Fix(FixAction {
143 kind: FixActionType::ExportType,
144 auto_fixable: false,
145 description: "Export the referenced private type by name".to_string(),
146 note: Some(
147 "Keep the type exported while it is part of a public signature".to_string(),
148 ),
149 available_in_catalogs: None,
150 suggested_target: None,
151 }),
152 IssueAction::SuppressLine(SuppressLineAction {
153 kind: SuppressLineKind::SuppressLine,
154 auto_fixable: false,
155 description: "Suppress with an inline comment above the line".to_string(),
156 comment: "// fallow-ignore-next-line private-type-leak".to_string(),
157 scope: None,
158 }),
159 ];
160 Self {
161 leak,
162 actions,
163 introduced: None,
164 }
165 }
166}
167
168#[derive(Debug, Clone, Serialize)]
173#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
174pub struct UnresolvedImportFinding {
175 #[serde(flatten)]
177 pub import: UnresolvedImport,
178 pub actions: Vec<IssueAction>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub introduced: Option<AuditIntroduced>,
185}
186
187impl UnresolvedImportFinding {
188 #[must_use]
190 pub fn with_actions(import: UnresolvedImport) -> Self {
191 let actions = vec![
192 IssueAction::Fix(FixAction {
193 kind: FixActionType::ResolveImport,
194 auto_fixable: false,
195 description: "Fix the import specifier or install the missing module".to_string(),
196 note: Some(
197 "Verify the module path and check tsconfig paths configuration".to_string(),
198 ),
199 available_in_catalogs: None,
200 suggested_target: None,
201 }),
202 IssueAction::AddToConfig(AddToConfigAction {
203 kind: AddToConfigKind::AddToConfig,
204 auto_fixable: false,
205 description: format!(
206 "Add \"{}\" to ignoreUnresolvedImports in fallow config",
207 import.specifier
208 ),
209 config_key: "ignoreUnresolvedImports".to_string(),
210 value: AddToConfigValue::Scalar(import.specifier.clone()),
211 value_schema: Some(
212 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
213 .to_string(),
214 ),
215 }),
216 IssueAction::SuppressLine(SuppressLineAction {
217 kind: SuppressLineKind::SuppressLine,
218 auto_fixable: false,
219 description: "Suppress with an inline comment above the line".to_string(),
220 comment: "// fallow-ignore-next-line unresolved-import".to_string(),
221 scope: None,
222 }),
223 ];
224 Self {
225 import,
226 actions,
227 introduced: None,
228 }
229 }
230}
231
232#[derive(Debug, Clone, Serialize)]
237#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
238pub struct CircularDependencyFinding {
239 #[serde(flatten)]
241 pub cycle: CircularDependency,
242 pub actions: Vec<IssueAction>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub introduced: Option<AuditIntroduced>,
249}
250
251impl CircularDependencyFinding {
252 #[must_use]
254 pub fn with_actions(cycle: CircularDependency) -> Self {
255 let actions = vec![
256 IssueAction::Fix(FixAction {
257 kind: FixActionType::RefactorCycle,
258 auto_fixable: false,
259 description: "Extract shared logic into a separate module to break the cycle"
260 .to_string(),
261 note: Some(
262 "Circular imports can cause initialization issues and make code harder to reason about"
263 .to_string(),
264 ),
265 available_in_catalogs: None,
266 suggested_target: None,
267 }),
268 IssueAction::SuppressLine(SuppressLineAction {
269 kind: SuppressLineKind::SuppressLine,
270 auto_fixable: false,
271 description: "Suppress with an inline comment above the line".to_string(),
272 comment: "// fallow-ignore-next-line circular-dependency".to_string(),
273 scope: None,
274 }),
275 ];
276 Self {
277 cycle,
278 actions,
279 introduced: None,
280 }
281 }
282}
283
284#[derive(Debug, Clone, Serialize)]
292#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
293pub struct ReExportCycleFinding {
294 #[serde(flatten)]
296 pub cycle: ReExportCycle,
297 pub actions: Vec<IssueAction>,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub introduced: Option<AuditIntroduced>,
304}
305
306impl ReExportCycleFinding {
307 #[must_use]
314 pub fn with_actions(cycle: ReExportCycle) -> Self {
315 let suppress_description = match cycle.kind {
321 ReExportCycleKind::SelfLoop => {
322 "Suppress with a file-level comment at the top of this file. \
323 The cycle is a self-loop, so the suppression covers the entire finding."
324 .to_string()
325 }
326 ReExportCycleKind::MultiNode => {
327 "Suppress with a file-level comment at the top of this file. \
328 One suppression on any member breaks the cycle for every member \
329 (see the sibling `files` array)."
330 .to_string()
331 }
332 };
333 let actions = vec![
334 IssueAction::Fix(FixAction {
335 kind: FixActionType::RefactorReExportCycle,
336 auto_fixable: false,
337 description: "Remove one `export * from` (or `export { ... } from`) \
338 statement on any one member to break the cycle"
339 .to_string(),
340 note: Some(
341 "Re-export cycles are structurally a no-op: chain propagation through \
342 the loop never reaches a terminating module, so imports from any member \
343 may silently come up empty."
344 .to_string(),
345 ),
346 available_in_catalogs: None,
347 suggested_target: None,
348 }),
349 IssueAction::SuppressFile(SuppressFileAction {
350 kind: SuppressFileKind::SuppressFile,
351 auto_fixable: false,
352 description: suppress_description,
353 comment: "// fallow-ignore-file re-export-cycle".to_string(),
354 }),
355 ];
356 Self {
357 cycle,
358 actions,
359 introduced: None,
360 }
361 }
362}
363
364#[derive(Debug, Clone, Serialize)]
369#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
370pub struct BoundaryViolationFinding {
371 #[serde(flatten)]
373 pub violation: BoundaryViolation,
374 pub actions: Vec<IssueAction>,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub introduced: Option<AuditIntroduced>,
381}
382
383impl BoundaryViolationFinding {
384 #[must_use]
386 pub fn with_actions(violation: BoundaryViolation) -> Self {
387 let actions = vec![
388 IssueAction::Fix(FixAction {
389 kind: FixActionType::RefactorBoundary,
390 auto_fixable: false,
391 description: "Move the import through an allowed zone or restructure the dependency"
392 .to_string(),
393 note: Some(
394 "This import crosses an architecture boundary that is not permitted by the configured rules"
395 .to_string(),
396 ),
397 available_in_catalogs: None,
398 suggested_target: None,
399 }),
400 IssueAction::SuppressLine(SuppressLineAction {
401 kind: SuppressLineKind::SuppressLine,
402 auto_fixable: false,
403 description: "Suppress with an inline comment above the line".to_string(),
404 comment: "// fallow-ignore-next-line boundary-violation".to_string(),
405 scope: None,
406 }),
407 ];
408 Self {
409 violation,
410 actions,
411 introduced: None,
412 }
413 }
414}
415
416#[derive(Debug, Clone, Serialize)]
421#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
422pub struct UnusedExportFinding {
423 #[serde(flatten)]
425 pub export: UnusedExport,
426 pub actions: Vec<IssueAction>,
429 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub introduced: Option<AuditIntroduced>,
433}
434
435impl UnusedExportFinding {
436 #[must_use]
440 pub fn with_actions(export: UnusedExport) -> Self {
441 let note = if export.is_re_export {
442 Some(
443 "This finding originates from a re-export; verify it is not part of your public API before removing"
444 .to_string(),
445 )
446 } else {
447 None
448 };
449 let actions = vec![
450 IssueAction::Fix(FixAction {
451 kind: FixActionType::RemoveExport,
452 auto_fixable: true,
453 description: "Remove the unused export from the public API".to_string(),
454 note,
455 available_in_catalogs: None,
456 suggested_target: None,
457 }),
458 IssueAction::SuppressLine(SuppressLineAction {
459 kind: SuppressLineKind::SuppressLine,
460 auto_fixable: false,
461 description: "Suppress with an inline comment above the line".to_string(),
462 comment: "// fallow-ignore-next-line unused-export".to_string(),
463 scope: None,
464 }),
465 ];
466 Self {
467 export,
468 actions,
469 introduced: None,
470 }
471 }
472}
473
474#[derive(Debug, Clone, Serialize)]
479#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
480pub struct UnusedTypeFinding {
481 #[serde(flatten)]
483 pub export: UnusedExport,
484 pub actions: Vec<IssueAction>,
487 #[serde(default, skip_serializing_if = "Option::is_none")]
490 pub introduced: Option<AuditIntroduced>,
491}
492
493impl UnusedTypeFinding {
494 #[must_use]
497 pub fn with_actions(export: UnusedExport) -> Self {
498 let note = if export.is_re_export {
499 Some(
500 "This finding originates from a re-export; verify it is not part of your public API before removing"
501 .to_string(),
502 )
503 } else {
504 None
505 };
506 let actions = vec![
507 IssueAction::Fix(FixAction {
508 kind: FixActionType::RemoveExport,
509 auto_fixable: true,
510 description:
511 "Remove the `export` (or `export type`) keyword from the type declaration"
512 .to_string(),
513 note,
514 available_in_catalogs: None,
515 suggested_target: None,
516 }),
517 IssueAction::SuppressLine(SuppressLineAction {
518 kind: SuppressLineKind::SuppressLine,
519 auto_fixable: false,
520 description: "Suppress with an inline comment above the line".to_string(),
521 comment: "// fallow-ignore-next-line unused-type".to_string(),
522 scope: None,
523 }),
524 ];
525 Self {
526 export,
527 actions,
528 introduced: None,
529 }
530 }
531}
532
533#[derive(Debug, Clone, Serialize)]
536#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
537pub struct UnusedEnumMemberFinding {
538 #[serde(flatten)]
540 pub member: UnusedMember,
541 pub actions: Vec<IssueAction>,
544 #[serde(default, skip_serializing_if = "Option::is_none")]
547 pub introduced: Option<AuditIntroduced>,
548}
549
550impl UnusedEnumMemberFinding {
551 #[must_use]
553 pub fn with_actions(member: UnusedMember) -> Self {
554 let actions = vec![
555 IssueAction::Fix(FixAction {
556 kind: FixActionType::RemoveEnumMember,
557 auto_fixable: true,
558 description: "Remove this enum member".to_string(),
559 note: None,
560 available_in_catalogs: None,
561 suggested_target: None,
562 }),
563 IssueAction::SuppressLine(SuppressLineAction {
564 kind: SuppressLineKind::SuppressLine,
565 auto_fixable: false,
566 description: "Suppress with an inline comment above the line".to_string(),
567 comment: "// fallow-ignore-next-line unused-enum-member".to_string(),
568 scope: None,
569 }),
570 ];
571 Self {
572 member,
573 actions,
574 introduced: None,
575 }
576 }
577}
578
579#[derive(Debug, Clone, Serialize)]
584#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
585pub struct UnusedClassMemberFinding {
586 #[serde(flatten)]
588 pub member: UnusedMember,
589 pub actions: Vec<IssueAction>,
592 #[serde(default, skip_serializing_if = "Option::is_none")]
595 pub introduced: Option<AuditIntroduced>,
596}
597
598impl UnusedClassMemberFinding {
599 #[must_use]
604 pub fn with_actions(member: UnusedMember) -> Self {
605 let actions = vec![
606 IssueAction::Fix(FixAction {
607 kind: FixActionType::RemoveClassMember,
608 auto_fixable: false,
609 description: "Remove this class member".to_string(),
610 note: Some(
611 "Class member may be used via dependency injection or decorators".to_string(),
612 ),
613 available_in_catalogs: None,
614 suggested_target: None,
615 }),
616 IssueAction::SuppressLine(SuppressLineAction {
617 kind: SuppressLineKind::SuppressLine,
618 auto_fixable: false,
619 description: "Suppress with an inline comment above the line".to_string(),
620 comment: "// fallow-ignore-next-line unused-class-member".to_string(),
621 scope: None,
622 }),
623 ];
624 Self {
625 member,
626 actions,
627 introduced: None,
628 }
629 }
630}
631
632fn build_unused_dependency_actions(
643 dep: &UnusedDependency,
644 package_json_location: &str,
645 suppress_issue_kind: &str,
646) -> Vec<IssueAction> {
647 let mut actions = Vec::with_capacity(2);
648 let cross_workspace = !dep.used_in_workspaces.is_empty();
649 actions.push(if cross_workspace {
650 IssueAction::Fix(FixAction {
651 kind: FixActionType::MoveDependency,
652 auto_fixable: false,
653 description: "Move this dependency to the workspace package.json that imports it"
654 .to_string(),
655 note: Some(
656 "fallow fix will not remove dependencies that are imported by another workspace"
657 .to_string(),
658 ),
659 available_in_catalogs: None,
660 suggested_target: None,
661 })
662 } else {
663 IssueAction::Fix(FixAction {
664 kind: FixActionType::RemoveDependency,
665 auto_fixable: true,
666 description: format!("Remove from {package_json_location} in package.json"),
667 note: None,
668 available_in_catalogs: None,
669 suggested_target: None,
670 })
671 });
672 actions.push(build_ignore_dependencies_suppress_action(
673 &dep.package_name,
674 suppress_issue_kind,
675 ));
676 actions
677}
678
679fn build_ignore_dependencies_suppress_action(
687 package_name: &str,
688 _suppress_issue_kind: &str,
689) -> IssueAction {
690 IssueAction::AddToConfig(AddToConfigAction {
691 kind: AddToConfigKind::AddToConfig,
692 auto_fixable: false,
693 description: format!("Add \"{package_name}\" to ignoreDependencies in fallow config"),
694 config_key: "ignoreDependencies".to_string(),
695 value: AddToConfigValue::Scalar(package_name.to_string()),
696 value_schema: Some(
697 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items"
698 .to_string(),
699 ),
700 })
701}
702
703#[derive(Debug, Clone, Serialize)]
709#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
710pub struct UnusedDependencyFinding {
711 #[serde(flatten)]
713 pub dep: UnusedDependency,
714 pub actions: Vec<IssueAction>,
717 #[serde(default, skip_serializing_if = "Option::is_none")]
720 pub introduced: Option<AuditIntroduced>,
721}
722
723impl UnusedDependencyFinding {
724 #[must_use]
727 pub fn with_actions(dep: UnusedDependency) -> Self {
728 let actions = build_unused_dependency_actions(&dep, "dependencies", "unused-dependency");
729 Self {
730 dep,
731 actions,
732 introduced: None,
733 }
734 }
735}
736
737#[derive(Debug, Clone, Serialize)]
743#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
744pub struct UnusedDevDependencyFinding {
745 #[serde(flatten)]
747 pub dep: UnusedDependency,
748 pub actions: Vec<IssueAction>,
751 #[serde(default, skip_serializing_if = "Option::is_none")]
754 pub introduced: Option<AuditIntroduced>,
755}
756
757impl UnusedDevDependencyFinding {
758 #[must_use]
760 pub fn with_actions(dep: UnusedDependency) -> Self {
761 let actions =
762 build_unused_dependency_actions(&dep, "devDependencies", "unused-dev-dependency");
763 Self {
764 dep,
765 actions,
766 introduced: None,
767 }
768 }
769}
770
771#[derive(Debug, Clone, Serialize)]
777#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
778pub struct UnusedOptionalDependencyFinding {
779 #[serde(flatten)]
781 pub dep: UnusedDependency,
782 pub actions: Vec<IssueAction>,
785 #[serde(default, skip_serializing_if = "Option::is_none")]
788 pub introduced: Option<AuditIntroduced>,
789}
790
791impl UnusedOptionalDependencyFinding {
792 #[must_use]
794 pub fn with_actions(dep: UnusedDependency) -> Self {
795 let actions =
796 build_unused_dependency_actions(&dep, "optionalDependencies", "unused-dependency");
797 Self {
798 dep,
799 actions,
800 introduced: None,
801 }
802 }
803}
804
805#[derive(Debug, Clone, Serialize)]
809#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
810pub struct UnlistedDependencyFinding {
811 #[serde(flatten)]
813 pub dep: UnlistedDependency,
814 pub actions: Vec<IssueAction>,
817 #[serde(default, skip_serializing_if = "Option::is_none")]
820 pub introduced: Option<AuditIntroduced>,
821}
822
823impl UnlistedDependencyFinding {
824 #[must_use]
826 pub fn with_actions(dep: UnlistedDependency) -> Self {
827 let actions = vec![
828 IssueAction::Fix(FixAction {
829 kind: FixActionType::InstallDependency,
830 auto_fixable: false,
831 description: "Add this package to dependencies in package.json".to_string(),
832 note: Some(
833 "Verify this package should be a direct dependency before adding".to_string(),
834 ),
835 available_in_catalogs: None,
836 suggested_target: None,
837 }),
838 build_ignore_dependencies_suppress_action(&dep.package_name, "unlisted-dependency"),
839 ];
840 Self {
841 dep,
842 actions,
843 introduced: None,
844 }
845 }
846}
847
848#[derive(Debug, Clone, Serialize)]
852#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
853pub struct TypeOnlyDependencyFinding {
854 #[serde(flatten)]
856 pub dep: TypeOnlyDependency,
857 pub actions: Vec<IssueAction>,
860 #[serde(default, skip_serializing_if = "Option::is_none")]
863 pub introduced: Option<AuditIntroduced>,
864}
865
866impl TypeOnlyDependencyFinding {
867 #[must_use]
869 pub fn with_actions(dep: TypeOnlyDependency) -> Self {
870 let actions = vec![
871 IssueAction::Fix(FixAction {
872 kind: FixActionType::MoveToDev,
873 auto_fixable: false,
874 description: "Move to devDependencies (only type imports are used)".to_string(),
875 note: Some(
876 "Type imports are erased at runtime so this dependency is not needed in production"
877 .to_string(),
878 ),
879 available_in_catalogs: None,
880 suggested_target: None,
881 }),
882 build_ignore_dependencies_suppress_action(&dep.package_name, "type-only-dependency"),
883 ];
884 Self {
885 dep,
886 actions,
887 introduced: None,
888 }
889 }
890}
891
892#[derive(Debug, Clone, Serialize)]
896#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
897pub struct TestOnlyDependencyFinding {
898 #[serde(flatten)]
900 pub dep: TestOnlyDependency,
901 pub actions: Vec<IssueAction>,
904 #[serde(default, skip_serializing_if = "Option::is_none")]
907 pub introduced: Option<AuditIntroduced>,
908}
909
910impl TestOnlyDependencyFinding {
911 #[must_use]
913 pub fn with_actions(dep: TestOnlyDependency) -> Self {
914 let actions = vec![
915 IssueAction::Fix(FixAction {
916 kind: FixActionType::MoveToDev,
917 auto_fixable: false,
918 description: "Move to devDependencies (only test files import this)".to_string(),
919 note: Some(
920 "Only test files import this package so it does not need to be a production dependency"
921 .to_string(),
922 ),
923 available_in_catalogs: None,
924 suggested_target: None,
925 }),
926 build_ignore_dependencies_suppress_action(&dep.package_name, "test-only-dependency"),
927 ];
928 Self {
929 dep,
930 actions,
931 introduced: None,
932 }
933 }
934}
935
936#[derive(Debug, Clone, Serialize)]
957#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
958pub struct DuplicateExportFinding {
959 #[serde(flatten)]
961 pub export: DuplicateExport,
962 pub actions: Vec<IssueAction>,
965 #[serde(default, skip_serializing_if = "Option::is_none")]
968 pub introduced: Option<AuditIntroduced>,
969}
970
971impl DuplicateExportFinding {
972 #[must_use]
981 pub fn with_actions(export: DuplicateExport) -> Self {
982 let mut actions: Vec<IssueAction> = Vec::with_capacity(3);
983
984 if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
985 actions.push(IssueAction::AddToConfig(AddToConfigAction {
986 kind: AddToConfigKind::AddToConfig,
987 auto_fixable: false,
988 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(),
989 config_key: "ignoreExports".to_string(),
990 value: AddToConfigValue::ExportsRules(rules),
991 value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
992 }));
993 }
994
995 actions.push(IssueAction::Fix(FixAction {
996 kind: FixActionType::RemoveDuplicate,
997 auto_fixable: false,
998 description: "Keep one canonical export location and remove the others".to_string(),
999 note: Some(NAMESPACE_BARREL_HINT.to_string()),
1000 available_in_catalogs: None,
1001 suggested_target: None,
1002 }));
1003
1004 actions.push(IssueAction::SuppressLine(SuppressLineAction {
1005 kind: SuppressLineKind::SuppressLine,
1006 auto_fixable: false,
1007 description: "Suppress with an inline comment above the line".to_string(),
1008 comment: "// fallow-ignore-next-line duplicate-export".to_string(),
1009 scope: Some(SuppressLineScope::PerLocation),
1010 }));
1011
1012 Self {
1013 export,
1014 actions,
1015 introduced: None,
1016 }
1017 }
1018
1019 pub fn set_config_fixable(&mut self, fixable: bool) {
1025 if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
1026 action.auto_fixable = fixable;
1027 }
1028 }
1029}
1030
1031fn build_duplicate_exports_ignore_rules(
1035 export: &DuplicateExport,
1036) -> Option<Vec<IgnoreExportsRule>> {
1037 let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
1038 for loc in &export.locations {
1039 let path = loc.path.to_string_lossy().replace('\\', "/");
1047 if path.is_empty() {
1048 continue;
1049 }
1050 if entries.iter().any(|existing| existing.file == path) {
1051 continue;
1052 }
1053 entries.push(IgnoreExportsRule {
1054 file: path,
1055 exports: vec!["*".to_string()],
1056 });
1057 }
1058 if entries.is_empty() {
1059 None
1060 } else {
1061 Some(entries)
1062 }
1063}
1064
1065#[derive(Debug, Clone, Serialize)]
1070#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1071pub struct UnusedCatalogEntryFinding {
1072 #[serde(flatten)]
1074 pub entry: UnusedCatalogEntry,
1075 pub actions: Vec<IssueAction>,
1077 #[serde(default, skip_serializing_if = "Option::is_none")]
1080 pub introduced: Option<AuditIntroduced>,
1081}
1082
1083impl UnusedCatalogEntryFinding {
1084 #[must_use]
1088 pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
1089 let auto_fixable = entry.hardcoded_consumers.is_empty();
1090 let actions = vec![
1091 IssueAction::Fix(FixAction {
1092 kind: FixActionType::RemoveCatalogEntry,
1093 auto_fixable,
1094 description: "Remove the entry from pnpm-workspace.yaml".to_string(),
1095 note: Some(
1096 "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing"
1097 .to_string(),
1098 ),
1099 available_in_catalogs: None,
1100 suggested_target: None,
1101 }),
1102 IssueAction::SuppressLine(SuppressLineAction {
1103 kind: SuppressLineKind::SuppressLine,
1104 auto_fixable: false,
1105 description: "Suppress with a YAML comment above the line".to_string(),
1106 comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
1107 scope: None,
1108 }),
1109 ];
1110 Self {
1111 entry,
1112 actions,
1113 introduced: None,
1114 }
1115 }
1116}
1117
1118#[derive(Debug, Clone, Serialize)]
1122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1123pub struct EmptyCatalogGroupFinding {
1124 #[serde(flatten)]
1126 pub group: EmptyCatalogGroup,
1127 pub actions: Vec<IssueAction>,
1129 #[serde(default, skip_serializing_if = "Option::is_none")]
1132 pub introduced: Option<AuditIntroduced>,
1133}
1134
1135impl EmptyCatalogGroupFinding {
1136 #[must_use]
1138 pub fn with_actions(group: EmptyCatalogGroup) -> Self {
1139 let actions = vec![
1140 IssueAction::Fix(FixAction {
1141 kind: FixActionType::RemoveEmptyCatalogGroup,
1142 auto_fixable: true,
1143 description: "Remove the empty named catalog group from pnpm-workspace.yaml"
1144 .to_string(),
1145 note: Some(
1146 "Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
1147 .to_string(),
1148 ),
1149 available_in_catalogs: None,
1150 suggested_target: None,
1151 }),
1152 IssueAction::SuppressLine(SuppressLineAction {
1153 kind: SuppressLineKind::SuppressLine,
1154 auto_fixable: false,
1155 description: "Suppress with a YAML comment above the line".to_string(),
1156 comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
1157 scope: None,
1158 }),
1159 ];
1160 Self {
1161 group,
1162 actions,
1163 introduced: None,
1164 }
1165 }
1166}
1167
1168#[derive(Debug, Clone, Serialize)]
1176#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1177pub struct UnresolvedCatalogReferenceFinding {
1178 #[serde(flatten)]
1180 pub reference: UnresolvedCatalogReference,
1181 pub actions: Vec<IssueAction>,
1184 #[serde(default, skip_serializing_if = "Option::is_none")]
1187 pub introduced: Option<AuditIntroduced>,
1188}
1189
1190impl UnresolvedCatalogReferenceFinding {
1191 #[must_use]
1195 pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
1196 let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
1201 let primary = if reference.available_in_catalogs.is_empty() {
1202 IssueAction::Fix(FixAction {
1203 kind: FixActionType::AddCatalogEntry,
1204 auto_fixable: false,
1205 description: format!(
1206 "Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
1207 reference.entry_name, reference.catalog_name
1208 ),
1209 note: Some(
1210 "Pin a version that satisfies the consumer's import; no other catalog declares this package today"
1211 .to_string(),
1212 ),
1213 available_in_catalogs: None,
1214 suggested_target: None,
1215 })
1216 } else {
1217 let available = reference.available_in_catalogs.clone();
1218 let suggested_target = (available.len() == 1).then(|| available[0].clone());
1219 IssueAction::Fix(FixAction {
1220 kind: FixActionType::UpdateCatalogReference,
1221 auto_fixable: false,
1222 description: format!(
1223 "Switch the reference from `catalog:{}` to a catalog that declares `{}`",
1224 reference.catalog_name, reference.entry_name
1225 ),
1226 note: None,
1227 available_in_catalogs: Some(available),
1228 suggested_target,
1229 })
1230 };
1231
1232 let fallback = IssueAction::Fix(FixAction {
1233 kind: FixActionType::RemoveCatalogReference,
1234 auto_fixable: false,
1235 description:
1236 "Remove the catalog reference and pin a hardcoded version in package.json"
1237 .to_string(),
1238 note: Some(
1239 "Use only when neither another catalog declares the package nor the named catalog should grow to include it"
1240 .to_string(),
1241 ),
1242 available_in_catalogs: None,
1243 suggested_target: None,
1244 });
1245
1246 let mut suppress_value = serde_json::Map::new();
1247 suppress_value.insert(
1248 "package".to_string(),
1249 serde_json::Value::String(reference.entry_name.clone()),
1250 );
1251 suppress_value.insert(
1252 "catalog".to_string(),
1253 serde_json::Value::String(reference.catalog_name.clone()),
1254 );
1255 suppress_value.insert(
1256 "consumer".to_string(),
1257 serde_json::Value::String(consumer_path),
1258 );
1259 let suppress = IssueAction::AddToConfig(AddToConfigAction {
1260 kind: AddToConfigKind::AddToConfig,
1261 auto_fixable: false,
1262 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(),
1263 config_key: "ignoreCatalogReferences".to_string(),
1264 value: AddToConfigValue::RuleObject(suppress_value),
1265 value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
1266 });
1267
1268 Self {
1269 reference,
1270 actions: vec![primary, fallback, suppress],
1271 introduced: None,
1272 }
1273 }
1274}
1275
1276#[derive(Debug, Clone, Serialize)]
1281#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1282pub struct UnusedDependencyOverrideFinding {
1283 #[serde(flatten)]
1285 pub entry: UnusedDependencyOverride,
1286 pub actions: Vec<IssueAction>,
1288 #[serde(default, skip_serializing_if = "Option::is_none")]
1291 pub introduced: Option<AuditIntroduced>,
1292}
1293
1294impl UnusedDependencyOverrideFinding {
1295 #[must_use]
1297 pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
1298 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
1299 actions.push(IssueAction::Fix(FixAction {
1300 kind: FixActionType::RemoveDependencyOverride,
1301 auto_fixable: false,
1302 description: "Remove the override entry from pnpm-workspace.yaml or pnpm.overrides"
1303 .to_string(),
1304 note: Some(
1305 "Conservative static check; verify against `pnpm install --frozen-lockfile` before removing in case the override targets a transitive dependency (CVE-fix pattern)"
1306 .to_string(),
1307 ),
1308 available_in_catalogs: None,
1309 suggested_target: None,
1310 }));
1311
1312 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
1313 Some(&entry.target_package),
1314 &entry.raw_key,
1315 entry.source,
1316 ) {
1317 actions.push(suppress);
1318 }
1319
1320 Self {
1321 entry,
1322 actions,
1323 introduced: None,
1324 }
1325 }
1326}
1327
1328#[derive(Debug, Clone, Serialize)]
1334#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1335pub struct MisconfiguredDependencyOverrideFinding {
1336 #[serde(flatten)]
1338 pub entry: MisconfiguredDependencyOverride,
1339 pub actions: Vec<IssueAction>,
1341 #[serde(default, skip_serializing_if = "Option::is_none")]
1344 pub introduced: Option<AuditIntroduced>,
1345}
1346
1347impl MisconfiguredDependencyOverrideFinding {
1348 #[must_use]
1353 pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
1354 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
1355 actions.push(IssueAction::Fix(FixAction {
1356 kind: FixActionType::FixDependencyOverride,
1357 auto_fixable: false,
1358 description:
1359 "Fix the override key or value: pnpm refuses to honor entries with an unparsable key or empty value"
1360 .to_string(),
1361 note: Some(
1362 "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`."
1363 .to_string(),
1364 ),
1365 available_in_catalogs: None,
1366 suggested_target: None,
1367 }));
1368
1369 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
1370 entry.target_package.as_deref(),
1371 &entry.raw_key,
1372 entry.source,
1373 ) {
1374 actions.push(suppress);
1375 }
1376
1377 Self {
1378 entry,
1379 actions,
1380 introduced: None,
1381 }
1382 }
1383}
1384
1385fn build_ignore_dependency_overrides_suppress(
1390 target_package: Option<&str>,
1391 raw_key: &str,
1392 source: DependencyOverrideSource,
1393) -> Option<IssueAction> {
1394 let package = target_package
1395 .filter(|s| !s.is_empty())
1396 .or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
1397 .to_string();
1398 let mut value = serde_json::Map::new();
1399 value.insert("package".to_string(), serde_json::Value::String(package));
1400 value.insert(
1401 "source".to_string(),
1402 serde_json::Value::String(source.as_label().to_string()),
1403 );
1404 Some(IssueAction::AddToConfig(AddToConfigAction {
1405 kind: AddToConfigKind::AddToConfig,
1406 auto_fixable: false,
1407 description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
1408 config_key: "ignoreDependencyOverrides".to_string(),
1409 value: AddToConfigValue::RuleObject(value),
1410 value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
1411 }))
1412}
1413
1414#[cfg(test)]
1424mod position_0_invariants {
1425 use super::*;
1426 use crate::output::FixActionType;
1427 use crate::results::{DependencyOverrideSource, DuplicateLocation};
1428 use std::path::PathBuf;
1429
1430 fn action_type(action: &IssueAction) -> &'static str {
1435 match action {
1436 IssueAction::Fix(fix) => match fix.kind {
1437 FixActionType::RemoveExport => "remove-export",
1438 FixActionType::DeleteFile => "delete-file",
1439 FixActionType::RemoveDependency => "remove-dependency",
1440 FixActionType::MoveDependency => "move-dependency",
1441 FixActionType::RemoveEnumMember => "remove-enum-member",
1442 FixActionType::RemoveClassMember => "remove-class-member",
1443 FixActionType::ResolveImport => "resolve-import",
1444 FixActionType::InstallDependency => "install-dependency",
1445 FixActionType::RemoveDuplicate => "remove-duplicate",
1446 FixActionType::MoveToDev => "move-to-dev",
1447 FixActionType::RefactorCycle => "refactor-cycle",
1448 FixActionType::RefactorReExportCycle => "refactor-re-export-cycle",
1449 FixActionType::RefactorBoundary => "refactor-boundary",
1450 FixActionType::ExportType => "export-type",
1451 FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
1452 FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
1453 FixActionType::UpdateCatalogReference => "update-catalog-reference",
1454 FixActionType::AddCatalogEntry => "add-catalog-entry",
1455 FixActionType::RemoveCatalogReference => "remove-catalog-reference",
1456 FixActionType::RemoveDependencyOverride => "remove-dependency-override",
1457 FixActionType::FixDependencyOverride => "fix-dependency-override",
1458 },
1459 IssueAction::SuppressLine(_) => "suppress-line",
1460 IssueAction::SuppressFile(_) => "suppress-file",
1461 IssueAction::AddToConfig(_) => "add-to-config",
1462 }
1463 }
1464
1465 #[test]
1466 fn unresolved_import_actions_include_ignore_unresolved_imports_config_suppress() {
1467 let inner = UnresolvedImport {
1468 specifier: "@example/icons".to_string(),
1469 path: PathBuf::from("src/index.ts"),
1470 line: 4,
1471 col: 12,
1472 specifier_col: 18,
1473 };
1474 let finding = UnresolvedImportFinding::with_actions(inner);
1475
1476 assert_eq!(action_type(&finding.actions[0]), "resolve-import");
1477 assert_eq!(action_type(&finding.actions[1]), "add-to-config");
1478 let IssueAction::AddToConfig(action) = &finding.actions[1] else {
1479 panic!("position-1 should be AddToConfig");
1480 };
1481 assert!(!action.auto_fixable);
1482 assert_eq!(action.config_key, "ignoreUnresolvedImports");
1483 let AddToConfigValue::Scalar(value) = &action.value else {
1484 panic!("ignoreUnresolvedImports action should carry a scalar value");
1485 };
1486 assert_eq!(value, "@example/icons");
1487 assert_eq!(
1488 action.value_schema.as_deref(),
1489 Some(
1490 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
1491 )
1492 );
1493 }
1494
1495 #[test]
1506 fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
1507 let inner = UnresolvedCatalogReference {
1508 entry_name: "react".to_string(),
1509 catalog_name: "default".to_string(),
1510 path: PathBuf::from("apps/web/package.json"),
1511 line: 7,
1512 available_in_catalogs: Vec::new(),
1513 };
1514 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
1515 assert_eq!(
1516 action_type(&finding.actions[0]),
1517 "add-catalog-entry",
1518 "position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
1519 );
1520 let IssueAction::Fix(fix) = &finding.actions[0] else {
1521 panic!("position-0 should be an IssueAction::Fix");
1522 };
1523 assert!(
1524 fix.available_in_catalogs.is_none(),
1525 "add-catalog-entry must NOT carry available_in_catalogs"
1526 );
1527 assert!(
1528 fix.suggested_target.is_none(),
1529 "add-catalog-entry must NOT carry suggested_target"
1530 );
1531 }
1532
1533 #[test]
1540 fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
1541 let inner = UnresolvedCatalogReference {
1542 entry_name: "react".to_string(),
1543 catalog_name: "default".to_string(),
1544 path: PathBuf::from("apps/web/package.json"),
1545 line: 7,
1546 available_in_catalogs: vec!["react18".to_string()],
1547 };
1548 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
1549 assert_eq!(
1550 action_type(&finding.actions[0]),
1551 "update-catalog-reference",
1552 "position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
1553 );
1554 let IssueAction::Fix(fix) = &finding.actions[0] else {
1555 panic!("position-0 should be an IssueAction::Fix");
1556 };
1557 assert_eq!(
1558 fix.available_in_catalogs.as_deref(),
1559 Some(&["react18".to_string()][..]),
1560 "update-catalog-reference must carry the alternative list"
1561 );
1562 assert_eq!(
1563 fix.suggested_target.as_deref(),
1564 Some("react18"),
1565 "single-alternative case must surface `suggested_target` for deterministic agents"
1566 );
1567
1568 let inner_two = UnresolvedCatalogReference {
1570 entry_name: "react".to_string(),
1571 catalog_name: "default".to_string(),
1572 path: PathBuf::from("apps/web/package.json"),
1573 line: 7,
1574 available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
1575 };
1576 let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
1577 assert_eq!(
1578 action_type(&finding_two.actions[0]),
1579 "update-catalog-reference"
1580 );
1581 let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
1582 panic!("position-0 should be an IssueAction::Fix");
1583 };
1584 assert!(
1585 fix_two.suggested_target.is_none(),
1586 "multi-alternative case must NOT carry `suggested_target` (agent must pick)"
1587 );
1588 }
1589
1590 #[test]
1605 fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
1606 let inner = DuplicateExport {
1607 export_name: "Root".to_string(),
1608 locations: vec![
1609 DuplicateLocation {
1610 path: PathBuf::from("components/ui/accordion/index.ts"),
1611 line: 1,
1612 col: 0,
1613 },
1614 DuplicateLocation {
1615 path: PathBuf::from("components/ui/dialog/index.ts"),
1616 line: 1,
1617 col: 0,
1618 },
1619 ],
1620 };
1621 let finding = DuplicateExportFinding::with_actions(inner);
1622 assert_eq!(
1623 action_type(&finding.actions[0]),
1624 "add-to-config",
1625 "position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
1626 );
1627 assert_eq!(
1628 action_type(&finding.actions[1]),
1629 "remove-duplicate",
1630 "position-1 must be the destructive `remove-duplicate` fallback"
1631 );
1632
1633 let mut promoted = finding;
1636 promoted.set_config_fixable(true);
1637 assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
1638 let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
1639 panic!("position-0 should still be AddToConfig after set_config_fixable");
1640 };
1641 assert!(
1642 action.auto_fixable,
1643 "set_config_fixable(true) must flip auto_fixable"
1644 );
1645 }
1646
1647 #[test]
1652 fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
1653 let inner = DuplicateExport {
1654 export_name: "Root".to_string(),
1655 locations: Vec::new(),
1656 };
1657 let finding = DuplicateExportFinding::with_actions(inner);
1658 assert_eq!(
1659 action_type(&finding.actions[0]),
1660 "remove-duplicate",
1661 "with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
1662 );
1663
1664 let mut promoted = finding;
1666 promoted.set_config_fixable(true);
1667 assert_eq!(
1668 action_type(&promoted.actions[0]),
1669 "remove-duplicate",
1670 "set_config_fixable is a no-op when position-0 is not add-to-config"
1671 );
1672 }
1673
1674 #[test]
1680 fn misconfigured_override_drops_suppress_when_no_package_name() {
1681 let inner = MisconfiguredDependencyOverride {
1682 raw_key: String::new(),
1683 target_package: None,
1684 raw_value: String::new(),
1685 reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
1686 source: DependencyOverrideSource::PnpmWorkspaceYaml,
1687 path: PathBuf::from("pnpm-workspace.yaml"),
1688 line: 12,
1689 };
1690 let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
1691 assert_eq!(finding.actions.len(), 1);
1693 assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
1694 }
1695}