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::SuppressLine(SuppressLineAction {
203 kind: SuppressLineKind::SuppressLine,
204 auto_fixable: false,
205 description: "Suppress with an inline comment above the line".to_string(),
206 comment: "// fallow-ignore-next-line unresolved-import".to_string(),
207 scope: None,
208 }),
209 ];
210 Self {
211 import,
212 actions,
213 introduced: None,
214 }
215 }
216}
217
218#[derive(Debug, Clone, Serialize)]
223#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
224pub struct CircularDependencyFinding {
225 #[serde(flatten)]
227 pub cycle: CircularDependency,
228 pub actions: Vec<IssueAction>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub introduced: Option<AuditIntroduced>,
235}
236
237impl CircularDependencyFinding {
238 #[must_use]
240 pub fn with_actions(cycle: CircularDependency) -> Self {
241 let actions = vec![
242 IssueAction::Fix(FixAction {
243 kind: FixActionType::RefactorCycle,
244 auto_fixable: false,
245 description: "Extract shared logic into a separate module to break the cycle"
246 .to_string(),
247 note: Some(
248 "Circular imports can cause initialization issues and make code harder to reason about"
249 .to_string(),
250 ),
251 available_in_catalogs: None,
252 suggested_target: None,
253 }),
254 IssueAction::SuppressLine(SuppressLineAction {
255 kind: SuppressLineKind::SuppressLine,
256 auto_fixable: false,
257 description: "Suppress with an inline comment above the line".to_string(),
258 comment: "// fallow-ignore-next-line circular-dependency".to_string(),
259 scope: None,
260 }),
261 ];
262 Self {
263 cycle,
264 actions,
265 introduced: None,
266 }
267 }
268}
269
270#[derive(Debug, Clone, Serialize)]
278#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
279pub struct ReExportCycleFinding {
280 #[serde(flatten)]
282 pub cycle: ReExportCycle,
283 pub actions: Vec<IssueAction>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub introduced: Option<AuditIntroduced>,
290}
291
292impl ReExportCycleFinding {
293 #[must_use]
300 pub fn with_actions(cycle: ReExportCycle) -> Self {
301 let suppress_description = match cycle.kind {
307 ReExportCycleKind::SelfLoop => {
308 "Suppress with a file-level comment at the top of this file. \
309 The cycle is a self-loop, so the suppression covers the entire finding."
310 .to_string()
311 }
312 ReExportCycleKind::MultiNode => {
313 "Suppress with a file-level comment at the top of this file. \
314 One suppression on any member breaks the cycle for every member \
315 (see the sibling `files` array)."
316 .to_string()
317 }
318 };
319 let actions = vec![
320 IssueAction::Fix(FixAction {
321 kind: FixActionType::RefactorReExportCycle,
322 auto_fixable: false,
323 description: "Remove one `export * from` (or `export { ... } from`) \
324 statement on any one member to break the cycle"
325 .to_string(),
326 note: Some(
327 "Re-export cycles are structurally a no-op: chain propagation through \
328 the loop never reaches a terminating module, so imports from any member \
329 may silently come up empty."
330 .to_string(),
331 ),
332 available_in_catalogs: None,
333 suggested_target: None,
334 }),
335 IssueAction::SuppressFile(SuppressFileAction {
336 kind: SuppressFileKind::SuppressFile,
337 auto_fixable: false,
338 description: suppress_description,
339 comment: "// fallow-ignore-file re-export-cycle".to_string(),
340 }),
341 ];
342 Self {
343 cycle,
344 actions,
345 introduced: None,
346 }
347 }
348}
349
350#[derive(Debug, Clone, Serialize)]
355#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
356pub struct BoundaryViolationFinding {
357 #[serde(flatten)]
359 pub violation: BoundaryViolation,
360 pub actions: Vec<IssueAction>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub introduced: Option<AuditIntroduced>,
367}
368
369impl BoundaryViolationFinding {
370 #[must_use]
372 pub fn with_actions(violation: BoundaryViolation) -> Self {
373 let actions = vec![
374 IssueAction::Fix(FixAction {
375 kind: FixActionType::RefactorBoundary,
376 auto_fixable: false,
377 description: "Move the import through an allowed zone or restructure the dependency"
378 .to_string(),
379 note: Some(
380 "This import crosses an architecture boundary that is not permitted by the configured rules"
381 .to_string(),
382 ),
383 available_in_catalogs: None,
384 suggested_target: None,
385 }),
386 IssueAction::SuppressLine(SuppressLineAction {
387 kind: SuppressLineKind::SuppressLine,
388 auto_fixable: false,
389 description: "Suppress with an inline comment above the line".to_string(),
390 comment: "// fallow-ignore-next-line boundary-violation".to_string(),
391 scope: None,
392 }),
393 ];
394 Self {
395 violation,
396 actions,
397 introduced: None,
398 }
399 }
400}
401
402#[derive(Debug, Clone, Serialize)]
407#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
408pub struct UnusedExportFinding {
409 #[serde(flatten)]
411 pub export: UnusedExport,
412 pub actions: Vec<IssueAction>,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub introduced: Option<AuditIntroduced>,
419}
420
421impl UnusedExportFinding {
422 #[must_use]
426 pub fn with_actions(export: UnusedExport) -> Self {
427 let note = if export.is_re_export {
428 Some(
429 "This finding originates from a re-export; verify it is not part of your public API before removing"
430 .to_string(),
431 )
432 } else {
433 None
434 };
435 let actions = vec![
436 IssueAction::Fix(FixAction {
437 kind: FixActionType::RemoveExport,
438 auto_fixable: true,
439 description: "Remove the unused export from the public API".to_string(),
440 note,
441 available_in_catalogs: None,
442 suggested_target: None,
443 }),
444 IssueAction::SuppressLine(SuppressLineAction {
445 kind: SuppressLineKind::SuppressLine,
446 auto_fixable: false,
447 description: "Suppress with an inline comment above the line".to_string(),
448 comment: "// fallow-ignore-next-line unused-export".to_string(),
449 scope: None,
450 }),
451 ];
452 Self {
453 export,
454 actions,
455 introduced: None,
456 }
457 }
458}
459
460#[derive(Debug, Clone, Serialize)]
465#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
466pub struct UnusedTypeFinding {
467 #[serde(flatten)]
469 pub export: UnusedExport,
470 pub actions: Vec<IssueAction>,
473 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub introduced: Option<AuditIntroduced>,
477}
478
479impl UnusedTypeFinding {
480 #[must_use]
483 pub fn with_actions(export: UnusedExport) -> Self {
484 let note = if export.is_re_export {
485 Some(
486 "This finding originates from a re-export; verify it is not part of your public API before removing"
487 .to_string(),
488 )
489 } else {
490 None
491 };
492 let actions = vec![
493 IssueAction::Fix(FixAction {
494 kind: FixActionType::RemoveExport,
495 auto_fixable: true,
496 description:
497 "Remove the `export` (or `export type`) keyword from the type declaration"
498 .to_string(),
499 note,
500 available_in_catalogs: None,
501 suggested_target: None,
502 }),
503 IssueAction::SuppressLine(SuppressLineAction {
504 kind: SuppressLineKind::SuppressLine,
505 auto_fixable: false,
506 description: "Suppress with an inline comment above the line".to_string(),
507 comment: "// fallow-ignore-next-line unused-type".to_string(),
508 scope: None,
509 }),
510 ];
511 Self {
512 export,
513 actions,
514 introduced: None,
515 }
516 }
517}
518
519#[derive(Debug, Clone, Serialize)]
522#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
523pub struct UnusedEnumMemberFinding {
524 #[serde(flatten)]
526 pub member: UnusedMember,
527 pub actions: Vec<IssueAction>,
530 #[serde(default, skip_serializing_if = "Option::is_none")]
533 pub introduced: Option<AuditIntroduced>,
534}
535
536impl UnusedEnumMemberFinding {
537 #[must_use]
539 pub fn with_actions(member: UnusedMember) -> Self {
540 let actions = vec![
541 IssueAction::Fix(FixAction {
542 kind: FixActionType::RemoveEnumMember,
543 auto_fixable: true,
544 description: "Remove this enum member".to_string(),
545 note: None,
546 available_in_catalogs: None,
547 suggested_target: None,
548 }),
549 IssueAction::SuppressLine(SuppressLineAction {
550 kind: SuppressLineKind::SuppressLine,
551 auto_fixable: false,
552 description: "Suppress with an inline comment above the line".to_string(),
553 comment: "// fallow-ignore-next-line unused-enum-member".to_string(),
554 scope: None,
555 }),
556 ];
557 Self {
558 member,
559 actions,
560 introduced: None,
561 }
562 }
563}
564
565#[derive(Debug, Clone, Serialize)]
570#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
571pub struct UnusedClassMemberFinding {
572 #[serde(flatten)]
574 pub member: UnusedMember,
575 pub actions: Vec<IssueAction>,
578 #[serde(default, skip_serializing_if = "Option::is_none")]
581 pub introduced: Option<AuditIntroduced>,
582}
583
584impl UnusedClassMemberFinding {
585 #[must_use]
590 pub fn with_actions(member: UnusedMember) -> Self {
591 let actions = vec![
592 IssueAction::Fix(FixAction {
593 kind: FixActionType::RemoveClassMember,
594 auto_fixable: false,
595 description: "Remove this class member".to_string(),
596 note: Some(
597 "Class member may be used via dependency injection or decorators".to_string(),
598 ),
599 available_in_catalogs: None,
600 suggested_target: None,
601 }),
602 IssueAction::SuppressLine(SuppressLineAction {
603 kind: SuppressLineKind::SuppressLine,
604 auto_fixable: false,
605 description: "Suppress with an inline comment above the line".to_string(),
606 comment: "// fallow-ignore-next-line unused-class-member".to_string(),
607 scope: None,
608 }),
609 ];
610 Self {
611 member,
612 actions,
613 introduced: None,
614 }
615 }
616}
617
618fn build_unused_dependency_actions(
629 dep: &UnusedDependency,
630 package_json_location: &str,
631 suppress_issue_kind: &str,
632) -> Vec<IssueAction> {
633 let mut actions = Vec::with_capacity(2);
634 let cross_workspace = !dep.used_in_workspaces.is_empty();
635 actions.push(if cross_workspace {
636 IssueAction::Fix(FixAction {
637 kind: FixActionType::MoveDependency,
638 auto_fixable: false,
639 description: "Move this dependency to the workspace package.json that imports it"
640 .to_string(),
641 note: Some(
642 "fallow fix will not remove dependencies that are imported by another workspace"
643 .to_string(),
644 ),
645 available_in_catalogs: None,
646 suggested_target: None,
647 })
648 } else {
649 IssueAction::Fix(FixAction {
650 kind: FixActionType::RemoveDependency,
651 auto_fixable: true,
652 description: format!("Remove from {package_json_location} in package.json"),
653 note: None,
654 available_in_catalogs: None,
655 suggested_target: None,
656 })
657 });
658 actions.push(build_ignore_dependencies_suppress_action(
659 &dep.package_name,
660 suppress_issue_kind,
661 ));
662 actions
663}
664
665fn build_ignore_dependencies_suppress_action(
673 package_name: &str,
674 _suppress_issue_kind: &str,
675) -> IssueAction {
676 IssueAction::AddToConfig(AddToConfigAction {
677 kind: AddToConfigKind::AddToConfig,
678 auto_fixable: false,
679 description: format!("Add \"{package_name}\" to ignoreDependencies in fallow config"),
680 config_key: "ignoreDependencies".to_string(),
681 value: AddToConfigValue::Scalar(package_name.to_string()),
682 value_schema: Some(
683 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items"
684 .to_string(),
685 ),
686 })
687}
688
689#[derive(Debug, Clone, Serialize)]
695#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
696pub struct UnusedDependencyFinding {
697 #[serde(flatten)]
699 pub dep: UnusedDependency,
700 pub actions: Vec<IssueAction>,
703 #[serde(default, skip_serializing_if = "Option::is_none")]
706 pub introduced: Option<AuditIntroduced>,
707}
708
709impl UnusedDependencyFinding {
710 #[must_use]
713 pub fn with_actions(dep: UnusedDependency) -> Self {
714 let actions = build_unused_dependency_actions(&dep, "dependencies", "unused-dependency");
715 Self {
716 dep,
717 actions,
718 introduced: None,
719 }
720 }
721}
722
723#[derive(Debug, Clone, Serialize)]
729#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
730pub struct UnusedDevDependencyFinding {
731 #[serde(flatten)]
733 pub dep: UnusedDependency,
734 pub actions: Vec<IssueAction>,
737 #[serde(default, skip_serializing_if = "Option::is_none")]
740 pub introduced: Option<AuditIntroduced>,
741}
742
743impl UnusedDevDependencyFinding {
744 #[must_use]
746 pub fn with_actions(dep: UnusedDependency) -> Self {
747 let actions =
748 build_unused_dependency_actions(&dep, "devDependencies", "unused-dev-dependency");
749 Self {
750 dep,
751 actions,
752 introduced: None,
753 }
754 }
755}
756
757#[derive(Debug, Clone, Serialize)]
763#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
764pub struct UnusedOptionalDependencyFinding {
765 #[serde(flatten)]
767 pub dep: UnusedDependency,
768 pub actions: Vec<IssueAction>,
771 #[serde(default, skip_serializing_if = "Option::is_none")]
774 pub introduced: Option<AuditIntroduced>,
775}
776
777impl UnusedOptionalDependencyFinding {
778 #[must_use]
780 pub fn with_actions(dep: UnusedDependency) -> Self {
781 let actions =
782 build_unused_dependency_actions(&dep, "optionalDependencies", "unused-dependency");
783 Self {
784 dep,
785 actions,
786 introduced: None,
787 }
788 }
789}
790
791#[derive(Debug, Clone, Serialize)]
795#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
796pub struct UnlistedDependencyFinding {
797 #[serde(flatten)]
799 pub dep: UnlistedDependency,
800 pub actions: Vec<IssueAction>,
803 #[serde(default, skip_serializing_if = "Option::is_none")]
806 pub introduced: Option<AuditIntroduced>,
807}
808
809impl UnlistedDependencyFinding {
810 #[must_use]
812 pub fn with_actions(dep: UnlistedDependency) -> Self {
813 let actions = vec![
814 IssueAction::Fix(FixAction {
815 kind: FixActionType::InstallDependency,
816 auto_fixable: false,
817 description: "Add this package to dependencies in package.json".to_string(),
818 note: Some(
819 "Verify this package should be a direct dependency before adding".to_string(),
820 ),
821 available_in_catalogs: None,
822 suggested_target: None,
823 }),
824 build_ignore_dependencies_suppress_action(&dep.package_name, "unlisted-dependency"),
825 ];
826 Self {
827 dep,
828 actions,
829 introduced: None,
830 }
831 }
832}
833
834#[derive(Debug, Clone, Serialize)]
838#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
839pub struct TypeOnlyDependencyFinding {
840 #[serde(flatten)]
842 pub dep: TypeOnlyDependency,
843 pub actions: Vec<IssueAction>,
846 #[serde(default, skip_serializing_if = "Option::is_none")]
849 pub introduced: Option<AuditIntroduced>,
850}
851
852impl TypeOnlyDependencyFinding {
853 #[must_use]
855 pub fn with_actions(dep: TypeOnlyDependency) -> Self {
856 let actions = vec![
857 IssueAction::Fix(FixAction {
858 kind: FixActionType::MoveToDev,
859 auto_fixable: false,
860 description: "Move to devDependencies (only type imports are used)".to_string(),
861 note: Some(
862 "Type imports are erased at runtime so this dependency is not needed in production"
863 .to_string(),
864 ),
865 available_in_catalogs: None,
866 suggested_target: None,
867 }),
868 build_ignore_dependencies_suppress_action(&dep.package_name, "type-only-dependency"),
869 ];
870 Self {
871 dep,
872 actions,
873 introduced: None,
874 }
875 }
876}
877
878#[derive(Debug, Clone, Serialize)]
882#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
883pub struct TestOnlyDependencyFinding {
884 #[serde(flatten)]
886 pub dep: TestOnlyDependency,
887 pub actions: Vec<IssueAction>,
890 #[serde(default, skip_serializing_if = "Option::is_none")]
893 pub introduced: Option<AuditIntroduced>,
894}
895
896impl TestOnlyDependencyFinding {
897 #[must_use]
899 pub fn with_actions(dep: TestOnlyDependency) -> Self {
900 let actions = vec![
901 IssueAction::Fix(FixAction {
902 kind: FixActionType::MoveToDev,
903 auto_fixable: false,
904 description: "Move to devDependencies (only test files import this)".to_string(),
905 note: Some(
906 "Only test files import this package so it does not need to be a production dependency"
907 .to_string(),
908 ),
909 available_in_catalogs: None,
910 suggested_target: None,
911 }),
912 build_ignore_dependencies_suppress_action(&dep.package_name, "test-only-dependency"),
913 ];
914 Self {
915 dep,
916 actions,
917 introduced: None,
918 }
919 }
920}
921
922#[derive(Debug, Clone, Serialize)]
943#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
944pub struct DuplicateExportFinding {
945 #[serde(flatten)]
947 pub export: DuplicateExport,
948 pub actions: Vec<IssueAction>,
951 #[serde(default, skip_serializing_if = "Option::is_none")]
954 pub introduced: Option<AuditIntroduced>,
955}
956
957impl DuplicateExportFinding {
958 #[must_use]
967 pub fn with_actions(export: DuplicateExport) -> Self {
968 let mut actions: Vec<IssueAction> = Vec::with_capacity(3);
969
970 if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
971 actions.push(IssueAction::AddToConfig(AddToConfigAction {
972 kind: AddToConfigKind::AddToConfig,
973 auto_fixable: false,
974 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(),
975 config_key: "ignoreExports".to_string(),
976 value: AddToConfigValue::ExportsRules(rules),
977 value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
978 }));
979 }
980
981 actions.push(IssueAction::Fix(FixAction {
982 kind: FixActionType::RemoveDuplicate,
983 auto_fixable: false,
984 description: "Keep one canonical export location and remove the others".to_string(),
985 note: Some(NAMESPACE_BARREL_HINT.to_string()),
986 available_in_catalogs: None,
987 suggested_target: None,
988 }));
989
990 actions.push(IssueAction::SuppressLine(SuppressLineAction {
991 kind: SuppressLineKind::SuppressLine,
992 auto_fixable: false,
993 description: "Suppress with an inline comment above the line".to_string(),
994 comment: "// fallow-ignore-next-line duplicate-export".to_string(),
995 scope: Some(SuppressLineScope::PerLocation),
996 }));
997
998 Self {
999 export,
1000 actions,
1001 introduced: None,
1002 }
1003 }
1004
1005 pub fn set_config_fixable(&mut self, fixable: bool) {
1011 if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
1012 action.auto_fixable = fixable;
1013 }
1014 }
1015}
1016
1017fn build_duplicate_exports_ignore_rules(
1021 export: &DuplicateExport,
1022) -> Option<Vec<IgnoreExportsRule>> {
1023 let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
1024 for loc in &export.locations {
1025 let path = loc.path.to_string_lossy().replace('\\', "/");
1033 if path.is_empty() {
1034 continue;
1035 }
1036 if entries.iter().any(|existing| existing.file == path) {
1037 continue;
1038 }
1039 entries.push(IgnoreExportsRule {
1040 file: path,
1041 exports: vec!["*".to_string()],
1042 });
1043 }
1044 if entries.is_empty() {
1045 None
1046 } else {
1047 Some(entries)
1048 }
1049}
1050
1051#[derive(Debug, Clone, Serialize)]
1056#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1057pub struct UnusedCatalogEntryFinding {
1058 #[serde(flatten)]
1060 pub entry: UnusedCatalogEntry,
1061 pub actions: Vec<IssueAction>,
1063 #[serde(default, skip_serializing_if = "Option::is_none")]
1066 pub introduced: Option<AuditIntroduced>,
1067}
1068
1069impl UnusedCatalogEntryFinding {
1070 #[must_use]
1074 pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
1075 let auto_fixable = entry.hardcoded_consumers.is_empty();
1076 let actions = vec![
1077 IssueAction::Fix(FixAction {
1078 kind: FixActionType::RemoveCatalogEntry,
1079 auto_fixable,
1080 description: "Remove the entry from pnpm-workspace.yaml".to_string(),
1081 note: Some(
1082 "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing"
1083 .to_string(),
1084 ),
1085 available_in_catalogs: None,
1086 suggested_target: None,
1087 }),
1088 IssueAction::SuppressLine(SuppressLineAction {
1089 kind: SuppressLineKind::SuppressLine,
1090 auto_fixable: false,
1091 description: "Suppress with a YAML comment above the line".to_string(),
1092 comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
1093 scope: None,
1094 }),
1095 ];
1096 Self {
1097 entry,
1098 actions,
1099 introduced: None,
1100 }
1101 }
1102}
1103
1104#[derive(Debug, Clone, Serialize)]
1108#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1109pub struct EmptyCatalogGroupFinding {
1110 #[serde(flatten)]
1112 pub group: EmptyCatalogGroup,
1113 pub actions: Vec<IssueAction>,
1115 #[serde(default, skip_serializing_if = "Option::is_none")]
1118 pub introduced: Option<AuditIntroduced>,
1119}
1120
1121impl EmptyCatalogGroupFinding {
1122 #[must_use]
1124 pub fn with_actions(group: EmptyCatalogGroup) -> Self {
1125 let actions = vec![
1126 IssueAction::Fix(FixAction {
1127 kind: FixActionType::RemoveEmptyCatalogGroup,
1128 auto_fixable: true,
1129 description: "Remove the empty named catalog group from pnpm-workspace.yaml"
1130 .to_string(),
1131 note: Some(
1132 "Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
1133 .to_string(),
1134 ),
1135 available_in_catalogs: None,
1136 suggested_target: None,
1137 }),
1138 IssueAction::SuppressLine(SuppressLineAction {
1139 kind: SuppressLineKind::SuppressLine,
1140 auto_fixable: false,
1141 description: "Suppress with a YAML comment above the line".to_string(),
1142 comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
1143 scope: None,
1144 }),
1145 ];
1146 Self {
1147 group,
1148 actions,
1149 introduced: None,
1150 }
1151 }
1152}
1153
1154#[derive(Debug, Clone, Serialize)]
1162#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1163pub struct UnresolvedCatalogReferenceFinding {
1164 #[serde(flatten)]
1166 pub reference: UnresolvedCatalogReference,
1167 pub actions: Vec<IssueAction>,
1170 #[serde(default, skip_serializing_if = "Option::is_none")]
1173 pub introduced: Option<AuditIntroduced>,
1174}
1175
1176impl UnresolvedCatalogReferenceFinding {
1177 #[must_use]
1181 pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
1182 let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
1187 let primary = if reference.available_in_catalogs.is_empty() {
1188 IssueAction::Fix(FixAction {
1189 kind: FixActionType::AddCatalogEntry,
1190 auto_fixable: false,
1191 description: format!(
1192 "Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
1193 reference.entry_name, reference.catalog_name
1194 ),
1195 note: Some(
1196 "Pin a version that satisfies the consumer's import; no other catalog declares this package today"
1197 .to_string(),
1198 ),
1199 available_in_catalogs: None,
1200 suggested_target: None,
1201 })
1202 } else {
1203 let available = reference.available_in_catalogs.clone();
1204 let suggested_target = (available.len() == 1).then(|| available[0].clone());
1205 IssueAction::Fix(FixAction {
1206 kind: FixActionType::UpdateCatalogReference,
1207 auto_fixable: false,
1208 description: format!(
1209 "Switch the reference from `catalog:{}` to a catalog that declares `{}`",
1210 reference.catalog_name, reference.entry_name
1211 ),
1212 note: None,
1213 available_in_catalogs: Some(available),
1214 suggested_target,
1215 })
1216 };
1217
1218 let fallback = IssueAction::Fix(FixAction {
1219 kind: FixActionType::RemoveCatalogReference,
1220 auto_fixable: false,
1221 description:
1222 "Remove the catalog reference and pin a hardcoded version in package.json"
1223 .to_string(),
1224 note: Some(
1225 "Use only when neither another catalog declares the package nor the named catalog should grow to include it"
1226 .to_string(),
1227 ),
1228 available_in_catalogs: None,
1229 suggested_target: None,
1230 });
1231
1232 let mut suppress_value = serde_json::Map::new();
1233 suppress_value.insert(
1234 "package".to_string(),
1235 serde_json::Value::String(reference.entry_name.clone()),
1236 );
1237 suppress_value.insert(
1238 "catalog".to_string(),
1239 serde_json::Value::String(reference.catalog_name.clone()),
1240 );
1241 suppress_value.insert(
1242 "consumer".to_string(),
1243 serde_json::Value::String(consumer_path),
1244 );
1245 let suppress = IssueAction::AddToConfig(AddToConfigAction {
1246 kind: AddToConfigKind::AddToConfig,
1247 auto_fixable: false,
1248 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(),
1249 config_key: "ignoreCatalogReferences".to_string(),
1250 value: AddToConfigValue::RuleObject(suppress_value),
1251 value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
1252 });
1253
1254 Self {
1255 reference,
1256 actions: vec![primary, fallback, suppress],
1257 introduced: None,
1258 }
1259 }
1260}
1261
1262#[derive(Debug, Clone, Serialize)]
1267#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1268pub struct UnusedDependencyOverrideFinding {
1269 #[serde(flatten)]
1271 pub entry: UnusedDependencyOverride,
1272 pub actions: Vec<IssueAction>,
1274 #[serde(default, skip_serializing_if = "Option::is_none")]
1277 pub introduced: Option<AuditIntroduced>,
1278}
1279
1280impl UnusedDependencyOverrideFinding {
1281 #[must_use]
1283 pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
1284 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
1285 actions.push(IssueAction::Fix(FixAction {
1286 kind: FixActionType::RemoveDependencyOverride,
1287 auto_fixable: false,
1288 description: "Remove the override entry from pnpm-workspace.yaml or pnpm.overrides"
1289 .to_string(),
1290 note: Some(
1291 "Conservative static check; verify against `pnpm install --frozen-lockfile` before removing in case the override targets a transitive dependency (CVE-fix pattern)"
1292 .to_string(),
1293 ),
1294 available_in_catalogs: None,
1295 suggested_target: None,
1296 }));
1297
1298 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
1299 Some(&entry.target_package),
1300 &entry.raw_key,
1301 entry.source,
1302 ) {
1303 actions.push(suppress);
1304 }
1305
1306 Self {
1307 entry,
1308 actions,
1309 introduced: None,
1310 }
1311 }
1312}
1313
1314#[derive(Debug, Clone, Serialize)]
1320#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1321pub struct MisconfiguredDependencyOverrideFinding {
1322 #[serde(flatten)]
1324 pub entry: MisconfiguredDependencyOverride,
1325 pub actions: Vec<IssueAction>,
1327 #[serde(default, skip_serializing_if = "Option::is_none")]
1330 pub introduced: Option<AuditIntroduced>,
1331}
1332
1333impl MisconfiguredDependencyOverrideFinding {
1334 #[must_use]
1339 pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
1340 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
1341 actions.push(IssueAction::Fix(FixAction {
1342 kind: FixActionType::FixDependencyOverride,
1343 auto_fixable: false,
1344 description:
1345 "Fix the override key or value: pnpm refuses to honor entries with an unparsable key or empty value"
1346 .to_string(),
1347 note: Some(
1348 "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`."
1349 .to_string(),
1350 ),
1351 available_in_catalogs: None,
1352 suggested_target: None,
1353 }));
1354
1355 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
1356 entry.target_package.as_deref(),
1357 &entry.raw_key,
1358 entry.source,
1359 ) {
1360 actions.push(suppress);
1361 }
1362
1363 Self {
1364 entry,
1365 actions,
1366 introduced: None,
1367 }
1368 }
1369}
1370
1371fn build_ignore_dependency_overrides_suppress(
1376 target_package: Option<&str>,
1377 raw_key: &str,
1378 source: DependencyOverrideSource,
1379) -> Option<IssueAction> {
1380 let package = target_package
1381 .filter(|s| !s.is_empty())
1382 .or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
1383 .to_string();
1384 let mut value = serde_json::Map::new();
1385 value.insert("package".to_string(), serde_json::Value::String(package));
1386 value.insert(
1387 "source".to_string(),
1388 serde_json::Value::String(source.as_label().to_string()),
1389 );
1390 Some(IssueAction::AddToConfig(AddToConfigAction {
1391 kind: AddToConfigKind::AddToConfig,
1392 auto_fixable: false,
1393 description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
1394 config_key: "ignoreDependencyOverrides".to_string(),
1395 value: AddToConfigValue::RuleObject(value),
1396 value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
1397 }))
1398}
1399
1400#[cfg(test)]
1410mod position_0_invariants {
1411 use super::*;
1412 use crate::output::FixActionType;
1413 use crate::results::{DependencyOverrideSource, DuplicateLocation};
1414 use std::path::PathBuf;
1415
1416 fn action_type(action: &IssueAction) -> &'static str {
1421 match action {
1422 IssueAction::Fix(fix) => match fix.kind {
1423 FixActionType::RemoveExport => "remove-export",
1424 FixActionType::DeleteFile => "delete-file",
1425 FixActionType::RemoveDependency => "remove-dependency",
1426 FixActionType::MoveDependency => "move-dependency",
1427 FixActionType::RemoveEnumMember => "remove-enum-member",
1428 FixActionType::RemoveClassMember => "remove-class-member",
1429 FixActionType::ResolveImport => "resolve-import",
1430 FixActionType::InstallDependency => "install-dependency",
1431 FixActionType::RemoveDuplicate => "remove-duplicate",
1432 FixActionType::MoveToDev => "move-to-dev",
1433 FixActionType::RefactorCycle => "refactor-cycle",
1434 FixActionType::RefactorReExportCycle => "refactor-re-export-cycle",
1435 FixActionType::RefactorBoundary => "refactor-boundary",
1436 FixActionType::ExportType => "export-type",
1437 FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
1438 FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
1439 FixActionType::UpdateCatalogReference => "update-catalog-reference",
1440 FixActionType::AddCatalogEntry => "add-catalog-entry",
1441 FixActionType::RemoveCatalogReference => "remove-catalog-reference",
1442 FixActionType::RemoveDependencyOverride => "remove-dependency-override",
1443 FixActionType::FixDependencyOverride => "fix-dependency-override",
1444 },
1445 IssueAction::SuppressLine(_) => "suppress-line",
1446 IssueAction::SuppressFile(_) => "suppress-file",
1447 IssueAction::AddToConfig(_) => "add-to-config",
1448 }
1449 }
1450
1451 #[test]
1462 fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
1463 let inner = UnresolvedCatalogReference {
1464 entry_name: "react".to_string(),
1465 catalog_name: "default".to_string(),
1466 path: PathBuf::from("apps/web/package.json"),
1467 line: 7,
1468 available_in_catalogs: Vec::new(),
1469 };
1470 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
1471 assert_eq!(
1472 action_type(&finding.actions[0]),
1473 "add-catalog-entry",
1474 "position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
1475 );
1476 let IssueAction::Fix(fix) = &finding.actions[0] else {
1477 panic!("position-0 should be an IssueAction::Fix");
1478 };
1479 assert!(
1480 fix.available_in_catalogs.is_none(),
1481 "add-catalog-entry must NOT carry available_in_catalogs"
1482 );
1483 assert!(
1484 fix.suggested_target.is_none(),
1485 "add-catalog-entry must NOT carry suggested_target"
1486 );
1487 }
1488
1489 #[test]
1496 fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
1497 let inner = UnresolvedCatalogReference {
1498 entry_name: "react".to_string(),
1499 catalog_name: "default".to_string(),
1500 path: PathBuf::from("apps/web/package.json"),
1501 line: 7,
1502 available_in_catalogs: vec!["react18".to_string()],
1503 };
1504 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
1505 assert_eq!(
1506 action_type(&finding.actions[0]),
1507 "update-catalog-reference",
1508 "position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
1509 );
1510 let IssueAction::Fix(fix) = &finding.actions[0] else {
1511 panic!("position-0 should be an IssueAction::Fix");
1512 };
1513 assert_eq!(
1514 fix.available_in_catalogs.as_deref(),
1515 Some(&["react18".to_string()][..]),
1516 "update-catalog-reference must carry the alternative list"
1517 );
1518 assert_eq!(
1519 fix.suggested_target.as_deref(),
1520 Some("react18"),
1521 "single-alternative case must surface `suggested_target` for deterministic agents"
1522 );
1523
1524 let inner_two = UnresolvedCatalogReference {
1526 entry_name: "react".to_string(),
1527 catalog_name: "default".to_string(),
1528 path: PathBuf::from("apps/web/package.json"),
1529 line: 7,
1530 available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
1531 };
1532 let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
1533 assert_eq!(
1534 action_type(&finding_two.actions[0]),
1535 "update-catalog-reference"
1536 );
1537 let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
1538 panic!("position-0 should be an IssueAction::Fix");
1539 };
1540 assert!(
1541 fix_two.suggested_target.is_none(),
1542 "multi-alternative case must NOT carry `suggested_target` (agent must pick)"
1543 );
1544 }
1545
1546 #[test]
1561 fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
1562 let inner = DuplicateExport {
1563 export_name: "Root".to_string(),
1564 locations: vec![
1565 DuplicateLocation {
1566 path: PathBuf::from("components/ui/accordion/index.ts"),
1567 line: 1,
1568 col: 0,
1569 },
1570 DuplicateLocation {
1571 path: PathBuf::from("components/ui/dialog/index.ts"),
1572 line: 1,
1573 col: 0,
1574 },
1575 ],
1576 };
1577 let finding = DuplicateExportFinding::with_actions(inner);
1578 assert_eq!(
1579 action_type(&finding.actions[0]),
1580 "add-to-config",
1581 "position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
1582 );
1583 assert_eq!(
1584 action_type(&finding.actions[1]),
1585 "remove-duplicate",
1586 "position-1 must be the destructive `remove-duplicate` fallback"
1587 );
1588
1589 let mut promoted = finding;
1592 promoted.set_config_fixable(true);
1593 assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
1594 let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
1595 panic!("position-0 should still be AddToConfig after set_config_fixable");
1596 };
1597 assert!(
1598 action.auto_fixable,
1599 "set_config_fixable(true) must flip auto_fixable"
1600 );
1601 }
1602
1603 #[test]
1608 fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
1609 let inner = DuplicateExport {
1610 export_name: "Root".to_string(),
1611 locations: Vec::new(),
1612 };
1613 let finding = DuplicateExportFinding::with_actions(inner);
1614 assert_eq!(
1615 action_type(&finding.actions[0]),
1616 "remove-duplicate",
1617 "with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
1618 );
1619
1620 let mut promoted = finding;
1622 promoted.set_config_fixable(true);
1623 assert_eq!(
1624 action_type(&promoted.actions[0]),
1625 "remove-duplicate",
1626 "set_config_fixable is a no-op when position-0 is not add-to-config"
1627 );
1628 }
1629
1630 #[test]
1636 fn misconfigured_override_drops_suppress_when_no_package_name() {
1637 let inner = MisconfiguredDependencyOverride {
1638 raw_key: String::new(),
1639 target_package: None,
1640 raw_value: String::new(),
1641 reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
1642 source: DependencyOverrideSource::PnpmWorkspaceYaml,
1643 path: PathBuf::from("pnpm-workspace.yaml"),
1644 line: 12,
1645 };
1646 let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
1647 assert_eq!(finding.actions.len(), 1);
1649 assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
1650 }
1651}