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, TestOnlyDependency,
41 TypeOnlyDependency, UnlistedDependency, UnresolvedCatalogReference, UnresolvedImport,
42 UnusedCatalogEntry, UnusedDependency, UnusedDependencyOverride, UnusedExport, UnusedFile,
43 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)]
275#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
276pub struct BoundaryViolationFinding {
277 #[serde(flatten)]
279 pub violation: BoundaryViolation,
280 pub actions: Vec<IssueAction>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub introduced: Option<AuditIntroduced>,
287}
288
289impl BoundaryViolationFinding {
290 #[must_use]
292 pub fn with_actions(violation: BoundaryViolation) -> Self {
293 let actions = vec![
294 IssueAction::Fix(FixAction {
295 kind: FixActionType::RefactorBoundary,
296 auto_fixable: false,
297 description: "Move the import through an allowed zone or restructure the dependency"
298 .to_string(),
299 note: Some(
300 "This import crosses an architecture boundary that is not permitted by the configured rules"
301 .to_string(),
302 ),
303 available_in_catalogs: None,
304 suggested_target: None,
305 }),
306 IssueAction::SuppressLine(SuppressLineAction {
307 kind: SuppressLineKind::SuppressLine,
308 auto_fixable: false,
309 description: "Suppress with an inline comment above the line".to_string(),
310 comment: "// fallow-ignore-next-line boundary-violation".to_string(),
311 scope: None,
312 }),
313 ];
314 Self {
315 violation,
316 actions,
317 introduced: None,
318 }
319 }
320}
321
322#[derive(Debug, Clone, Serialize)]
327#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
328pub struct UnusedExportFinding {
329 #[serde(flatten)]
331 pub export: UnusedExport,
332 pub actions: Vec<IssueAction>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub introduced: Option<AuditIntroduced>,
339}
340
341impl UnusedExportFinding {
342 #[must_use]
346 pub fn with_actions(export: UnusedExport) -> Self {
347 let note = if export.is_re_export {
348 Some(
349 "This finding originates from a re-export; verify it is not part of your public API before removing"
350 .to_string(),
351 )
352 } else {
353 None
354 };
355 let actions = vec![
356 IssueAction::Fix(FixAction {
357 kind: FixActionType::RemoveExport,
358 auto_fixable: true,
359 description: "Remove the unused export from the public API".to_string(),
360 note,
361 available_in_catalogs: None,
362 suggested_target: None,
363 }),
364 IssueAction::SuppressLine(SuppressLineAction {
365 kind: SuppressLineKind::SuppressLine,
366 auto_fixable: false,
367 description: "Suppress with an inline comment above the line".to_string(),
368 comment: "// fallow-ignore-next-line unused-export".to_string(),
369 scope: None,
370 }),
371 ];
372 Self {
373 export,
374 actions,
375 introduced: None,
376 }
377 }
378}
379
380#[derive(Debug, Clone, Serialize)]
385#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
386pub struct UnusedTypeFinding {
387 #[serde(flatten)]
389 pub export: UnusedExport,
390 pub actions: Vec<IssueAction>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub introduced: Option<AuditIntroduced>,
397}
398
399impl UnusedTypeFinding {
400 #[must_use]
403 pub fn with_actions(export: UnusedExport) -> Self {
404 let note = if export.is_re_export {
405 Some(
406 "This finding originates from a re-export; verify it is not part of your public API before removing"
407 .to_string(),
408 )
409 } else {
410 None
411 };
412 let actions = vec![
413 IssueAction::Fix(FixAction {
414 kind: FixActionType::RemoveExport,
415 auto_fixable: true,
416 description:
417 "Remove the `export` (or `export type`) keyword from the type declaration"
418 .to_string(),
419 note,
420 available_in_catalogs: None,
421 suggested_target: None,
422 }),
423 IssueAction::SuppressLine(SuppressLineAction {
424 kind: SuppressLineKind::SuppressLine,
425 auto_fixable: false,
426 description: "Suppress with an inline comment above the line".to_string(),
427 comment: "// fallow-ignore-next-line unused-type".to_string(),
428 scope: None,
429 }),
430 ];
431 Self {
432 export,
433 actions,
434 introduced: None,
435 }
436 }
437}
438
439#[derive(Debug, Clone, Serialize)]
442#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
443pub struct UnusedEnumMemberFinding {
444 #[serde(flatten)]
446 pub member: UnusedMember,
447 pub actions: Vec<IssueAction>,
450 #[serde(default, skip_serializing_if = "Option::is_none")]
453 pub introduced: Option<AuditIntroduced>,
454}
455
456impl UnusedEnumMemberFinding {
457 #[must_use]
459 pub fn with_actions(member: UnusedMember) -> Self {
460 let actions = vec![
461 IssueAction::Fix(FixAction {
462 kind: FixActionType::RemoveEnumMember,
463 auto_fixable: true,
464 description: "Remove this enum member".to_string(),
465 note: None,
466 available_in_catalogs: None,
467 suggested_target: None,
468 }),
469 IssueAction::SuppressLine(SuppressLineAction {
470 kind: SuppressLineKind::SuppressLine,
471 auto_fixable: false,
472 description: "Suppress with an inline comment above the line".to_string(),
473 comment: "// fallow-ignore-next-line unused-enum-member".to_string(),
474 scope: None,
475 }),
476 ];
477 Self {
478 member,
479 actions,
480 introduced: None,
481 }
482 }
483}
484
485#[derive(Debug, Clone, Serialize)]
490#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
491pub struct UnusedClassMemberFinding {
492 #[serde(flatten)]
494 pub member: UnusedMember,
495 pub actions: Vec<IssueAction>,
498 #[serde(default, skip_serializing_if = "Option::is_none")]
501 pub introduced: Option<AuditIntroduced>,
502}
503
504impl UnusedClassMemberFinding {
505 #[must_use]
510 pub fn with_actions(member: UnusedMember) -> Self {
511 let actions = vec![
512 IssueAction::Fix(FixAction {
513 kind: FixActionType::RemoveClassMember,
514 auto_fixable: false,
515 description: "Remove this class member".to_string(),
516 note: Some(
517 "Class member may be used via dependency injection or decorators".to_string(),
518 ),
519 available_in_catalogs: None,
520 suggested_target: None,
521 }),
522 IssueAction::SuppressLine(SuppressLineAction {
523 kind: SuppressLineKind::SuppressLine,
524 auto_fixable: false,
525 description: "Suppress with an inline comment above the line".to_string(),
526 comment: "// fallow-ignore-next-line unused-class-member".to_string(),
527 scope: None,
528 }),
529 ];
530 Self {
531 member,
532 actions,
533 introduced: None,
534 }
535 }
536}
537
538fn build_unused_dependency_actions(
549 dep: &UnusedDependency,
550 package_json_location: &str,
551 suppress_issue_kind: &str,
552) -> Vec<IssueAction> {
553 let mut actions = Vec::with_capacity(2);
554 let cross_workspace = !dep.used_in_workspaces.is_empty();
555 actions.push(if cross_workspace {
556 IssueAction::Fix(FixAction {
557 kind: FixActionType::MoveDependency,
558 auto_fixable: false,
559 description: "Move this dependency to the workspace package.json that imports it"
560 .to_string(),
561 note: Some(
562 "fallow fix will not remove dependencies that are imported by another workspace"
563 .to_string(),
564 ),
565 available_in_catalogs: None,
566 suggested_target: None,
567 })
568 } else {
569 IssueAction::Fix(FixAction {
570 kind: FixActionType::RemoveDependency,
571 auto_fixable: true,
572 description: format!("Remove from {package_json_location} in package.json"),
573 note: None,
574 available_in_catalogs: None,
575 suggested_target: None,
576 })
577 });
578 actions.push(build_ignore_dependencies_suppress_action(
579 &dep.package_name,
580 suppress_issue_kind,
581 ));
582 actions
583}
584
585fn build_ignore_dependencies_suppress_action(
593 package_name: &str,
594 _suppress_issue_kind: &str,
595) -> IssueAction {
596 IssueAction::AddToConfig(AddToConfigAction {
597 kind: AddToConfigKind::AddToConfig,
598 auto_fixable: false,
599 description: format!("Add \"{package_name}\" to ignoreDependencies in fallow config"),
600 config_key: "ignoreDependencies".to_string(),
601 value: AddToConfigValue::Scalar(package_name.to_string()),
602 value_schema: Some(
603 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items"
604 .to_string(),
605 ),
606 })
607}
608
609#[derive(Debug, Clone, Serialize)]
615#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
616pub struct UnusedDependencyFinding {
617 #[serde(flatten)]
619 pub dep: UnusedDependency,
620 pub actions: Vec<IssueAction>,
623 #[serde(default, skip_serializing_if = "Option::is_none")]
626 pub introduced: Option<AuditIntroduced>,
627}
628
629impl UnusedDependencyFinding {
630 #[must_use]
633 pub fn with_actions(dep: UnusedDependency) -> Self {
634 let actions = build_unused_dependency_actions(&dep, "dependencies", "unused-dependency");
635 Self {
636 dep,
637 actions,
638 introduced: None,
639 }
640 }
641}
642
643#[derive(Debug, Clone, Serialize)]
649#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
650pub struct UnusedDevDependencyFinding {
651 #[serde(flatten)]
653 pub dep: UnusedDependency,
654 pub actions: Vec<IssueAction>,
657 #[serde(default, skip_serializing_if = "Option::is_none")]
660 pub introduced: Option<AuditIntroduced>,
661}
662
663impl UnusedDevDependencyFinding {
664 #[must_use]
666 pub fn with_actions(dep: UnusedDependency) -> Self {
667 let actions =
668 build_unused_dependency_actions(&dep, "devDependencies", "unused-dev-dependency");
669 Self {
670 dep,
671 actions,
672 introduced: None,
673 }
674 }
675}
676
677#[derive(Debug, Clone, Serialize)]
683#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
684pub struct UnusedOptionalDependencyFinding {
685 #[serde(flatten)]
687 pub dep: UnusedDependency,
688 pub actions: Vec<IssueAction>,
691 #[serde(default, skip_serializing_if = "Option::is_none")]
694 pub introduced: Option<AuditIntroduced>,
695}
696
697impl UnusedOptionalDependencyFinding {
698 #[must_use]
700 pub fn with_actions(dep: UnusedDependency) -> Self {
701 let actions =
702 build_unused_dependency_actions(&dep, "optionalDependencies", "unused-dependency");
703 Self {
704 dep,
705 actions,
706 introduced: None,
707 }
708 }
709}
710
711#[derive(Debug, Clone, Serialize)]
715#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
716pub struct UnlistedDependencyFinding {
717 #[serde(flatten)]
719 pub dep: UnlistedDependency,
720 pub actions: Vec<IssueAction>,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub introduced: Option<AuditIntroduced>,
727}
728
729impl UnlistedDependencyFinding {
730 #[must_use]
732 pub fn with_actions(dep: UnlistedDependency) -> Self {
733 let actions = vec![
734 IssueAction::Fix(FixAction {
735 kind: FixActionType::InstallDependency,
736 auto_fixable: false,
737 description: "Add this package to dependencies in package.json".to_string(),
738 note: Some(
739 "Verify this package should be a direct dependency before adding".to_string(),
740 ),
741 available_in_catalogs: None,
742 suggested_target: None,
743 }),
744 build_ignore_dependencies_suppress_action(&dep.package_name, "unlisted-dependency"),
745 ];
746 Self {
747 dep,
748 actions,
749 introduced: None,
750 }
751 }
752}
753
754#[derive(Debug, Clone, Serialize)]
758#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
759pub struct TypeOnlyDependencyFinding {
760 #[serde(flatten)]
762 pub dep: TypeOnlyDependency,
763 pub actions: Vec<IssueAction>,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
769 pub introduced: Option<AuditIntroduced>,
770}
771
772impl TypeOnlyDependencyFinding {
773 #[must_use]
775 pub fn with_actions(dep: TypeOnlyDependency) -> Self {
776 let actions = vec![
777 IssueAction::Fix(FixAction {
778 kind: FixActionType::MoveToDev,
779 auto_fixable: false,
780 description: "Move to devDependencies (only type imports are used)".to_string(),
781 note: Some(
782 "Type imports are erased at runtime so this dependency is not needed in production"
783 .to_string(),
784 ),
785 available_in_catalogs: None,
786 suggested_target: None,
787 }),
788 build_ignore_dependencies_suppress_action(&dep.package_name, "type-only-dependency"),
789 ];
790 Self {
791 dep,
792 actions,
793 introduced: None,
794 }
795 }
796}
797
798#[derive(Debug, Clone, Serialize)]
802#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
803pub struct TestOnlyDependencyFinding {
804 #[serde(flatten)]
806 pub dep: TestOnlyDependency,
807 pub actions: Vec<IssueAction>,
810 #[serde(default, skip_serializing_if = "Option::is_none")]
813 pub introduced: Option<AuditIntroduced>,
814}
815
816impl TestOnlyDependencyFinding {
817 #[must_use]
819 pub fn with_actions(dep: TestOnlyDependency) -> Self {
820 let actions = vec![
821 IssueAction::Fix(FixAction {
822 kind: FixActionType::MoveToDev,
823 auto_fixable: false,
824 description: "Move to devDependencies (only test files import this)".to_string(),
825 note: Some(
826 "Only test files import this package so it does not need to be a production dependency"
827 .to_string(),
828 ),
829 available_in_catalogs: None,
830 suggested_target: None,
831 }),
832 build_ignore_dependencies_suppress_action(&dep.package_name, "test-only-dependency"),
833 ];
834 Self {
835 dep,
836 actions,
837 introduced: None,
838 }
839 }
840}
841
842#[derive(Debug, Clone, Serialize)]
863#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
864pub struct DuplicateExportFinding {
865 #[serde(flatten)]
867 pub export: DuplicateExport,
868 pub actions: Vec<IssueAction>,
871 #[serde(default, skip_serializing_if = "Option::is_none")]
874 pub introduced: Option<AuditIntroduced>,
875}
876
877impl DuplicateExportFinding {
878 #[must_use]
887 pub fn with_actions(export: DuplicateExport) -> Self {
888 let mut actions: Vec<IssueAction> = Vec::with_capacity(3);
889
890 if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
891 actions.push(IssueAction::AddToConfig(AddToConfigAction {
892 kind: AddToConfigKind::AddToConfig,
893 auto_fixable: false,
894 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(),
895 config_key: "ignoreExports".to_string(),
896 value: AddToConfigValue::ExportsRules(rules),
897 value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
898 }));
899 }
900
901 actions.push(IssueAction::Fix(FixAction {
902 kind: FixActionType::RemoveDuplicate,
903 auto_fixable: false,
904 description: "Keep one canonical export location and remove the others".to_string(),
905 note: Some(NAMESPACE_BARREL_HINT.to_string()),
906 available_in_catalogs: None,
907 suggested_target: None,
908 }));
909
910 actions.push(IssueAction::SuppressLine(SuppressLineAction {
911 kind: SuppressLineKind::SuppressLine,
912 auto_fixable: false,
913 description: "Suppress with an inline comment above the line".to_string(),
914 comment: "// fallow-ignore-next-line duplicate-export".to_string(),
915 scope: Some(SuppressLineScope::PerLocation),
916 }));
917
918 Self {
919 export,
920 actions,
921 introduced: None,
922 }
923 }
924
925 pub fn set_config_fixable(&mut self, fixable: bool) {
931 if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
932 action.auto_fixable = fixable;
933 }
934 }
935}
936
937fn build_duplicate_exports_ignore_rules(
941 export: &DuplicateExport,
942) -> Option<Vec<IgnoreExportsRule>> {
943 let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
944 for loc in &export.locations {
945 let path = loc.path.to_string_lossy().replace('\\', "/");
953 if path.is_empty() {
954 continue;
955 }
956 if entries.iter().any(|existing| existing.file == path) {
957 continue;
958 }
959 entries.push(IgnoreExportsRule {
960 file: path,
961 exports: vec!["*".to_string()],
962 });
963 }
964 if entries.is_empty() {
965 None
966 } else {
967 Some(entries)
968 }
969}
970
971#[derive(Debug, Clone, Serialize)]
976#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
977pub struct UnusedCatalogEntryFinding {
978 #[serde(flatten)]
980 pub entry: UnusedCatalogEntry,
981 pub actions: Vec<IssueAction>,
983 #[serde(default, skip_serializing_if = "Option::is_none")]
986 pub introduced: Option<AuditIntroduced>,
987}
988
989impl UnusedCatalogEntryFinding {
990 #[must_use]
994 pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
995 let auto_fixable = entry.hardcoded_consumers.is_empty();
996 let actions = vec![
997 IssueAction::Fix(FixAction {
998 kind: FixActionType::RemoveCatalogEntry,
999 auto_fixable,
1000 description: "Remove the entry from pnpm-workspace.yaml".to_string(),
1001 note: Some(
1002 "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing"
1003 .to_string(),
1004 ),
1005 available_in_catalogs: None,
1006 suggested_target: None,
1007 }),
1008 IssueAction::SuppressLine(SuppressLineAction {
1009 kind: SuppressLineKind::SuppressLine,
1010 auto_fixable: false,
1011 description: "Suppress with a YAML comment above the line".to_string(),
1012 comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
1013 scope: None,
1014 }),
1015 ];
1016 Self {
1017 entry,
1018 actions,
1019 introduced: None,
1020 }
1021 }
1022}
1023
1024#[derive(Debug, Clone, Serialize)]
1028#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1029pub struct EmptyCatalogGroupFinding {
1030 #[serde(flatten)]
1032 pub group: EmptyCatalogGroup,
1033 pub actions: Vec<IssueAction>,
1035 #[serde(default, skip_serializing_if = "Option::is_none")]
1038 pub introduced: Option<AuditIntroduced>,
1039}
1040
1041impl EmptyCatalogGroupFinding {
1042 #[must_use]
1044 pub fn with_actions(group: EmptyCatalogGroup) -> Self {
1045 let actions = vec![
1046 IssueAction::Fix(FixAction {
1047 kind: FixActionType::RemoveEmptyCatalogGroup,
1048 auto_fixable: true,
1049 description: "Remove the empty named catalog group from pnpm-workspace.yaml"
1050 .to_string(),
1051 note: Some(
1052 "Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
1053 .to_string(),
1054 ),
1055 available_in_catalogs: None,
1056 suggested_target: None,
1057 }),
1058 IssueAction::SuppressLine(SuppressLineAction {
1059 kind: SuppressLineKind::SuppressLine,
1060 auto_fixable: false,
1061 description: "Suppress with a YAML comment above the line".to_string(),
1062 comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
1063 scope: None,
1064 }),
1065 ];
1066 Self {
1067 group,
1068 actions,
1069 introduced: None,
1070 }
1071 }
1072}
1073
1074#[derive(Debug, Clone, Serialize)]
1082#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1083pub struct UnresolvedCatalogReferenceFinding {
1084 #[serde(flatten)]
1086 pub reference: UnresolvedCatalogReference,
1087 pub actions: Vec<IssueAction>,
1090 #[serde(default, skip_serializing_if = "Option::is_none")]
1093 pub introduced: Option<AuditIntroduced>,
1094}
1095
1096impl UnresolvedCatalogReferenceFinding {
1097 #[must_use]
1101 pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
1102 let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
1107 let primary = if reference.available_in_catalogs.is_empty() {
1108 IssueAction::Fix(FixAction {
1109 kind: FixActionType::AddCatalogEntry,
1110 auto_fixable: false,
1111 description: format!(
1112 "Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
1113 reference.entry_name, reference.catalog_name
1114 ),
1115 note: Some(
1116 "Pin a version that satisfies the consumer's import; no other catalog declares this package today"
1117 .to_string(),
1118 ),
1119 available_in_catalogs: None,
1120 suggested_target: None,
1121 })
1122 } else {
1123 let available = reference.available_in_catalogs.clone();
1124 let suggested_target = (available.len() == 1).then(|| available[0].clone());
1125 IssueAction::Fix(FixAction {
1126 kind: FixActionType::UpdateCatalogReference,
1127 auto_fixable: false,
1128 description: format!(
1129 "Switch the reference from `catalog:{}` to a catalog that declares `{}`",
1130 reference.catalog_name, reference.entry_name
1131 ),
1132 note: None,
1133 available_in_catalogs: Some(available),
1134 suggested_target,
1135 })
1136 };
1137
1138 let fallback = IssueAction::Fix(FixAction {
1139 kind: FixActionType::RemoveCatalogReference,
1140 auto_fixable: false,
1141 description:
1142 "Remove the catalog reference and pin a hardcoded version in package.json"
1143 .to_string(),
1144 note: Some(
1145 "Use only when neither another catalog declares the package nor the named catalog should grow to include it"
1146 .to_string(),
1147 ),
1148 available_in_catalogs: None,
1149 suggested_target: None,
1150 });
1151
1152 let mut suppress_value = serde_json::Map::new();
1153 suppress_value.insert(
1154 "package".to_string(),
1155 serde_json::Value::String(reference.entry_name.clone()),
1156 );
1157 suppress_value.insert(
1158 "catalog".to_string(),
1159 serde_json::Value::String(reference.catalog_name.clone()),
1160 );
1161 suppress_value.insert(
1162 "consumer".to_string(),
1163 serde_json::Value::String(consumer_path),
1164 );
1165 let suppress = IssueAction::AddToConfig(AddToConfigAction {
1166 kind: AddToConfigKind::AddToConfig,
1167 auto_fixable: false,
1168 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(),
1169 config_key: "ignoreCatalogReferences".to_string(),
1170 value: AddToConfigValue::RuleObject(suppress_value),
1171 value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
1172 });
1173
1174 Self {
1175 reference,
1176 actions: vec![primary, fallback, suppress],
1177 introduced: None,
1178 }
1179 }
1180}
1181
1182#[derive(Debug, Clone, Serialize)]
1187#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1188pub struct UnusedDependencyOverrideFinding {
1189 #[serde(flatten)]
1191 pub entry: UnusedDependencyOverride,
1192 pub actions: Vec<IssueAction>,
1194 #[serde(default, skip_serializing_if = "Option::is_none")]
1197 pub introduced: Option<AuditIntroduced>,
1198}
1199
1200impl UnusedDependencyOverrideFinding {
1201 #[must_use]
1203 pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
1204 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
1205 actions.push(IssueAction::Fix(FixAction {
1206 kind: FixActionType::RemoveDependencyOverride,
1207 auto_fixable: false,
1208 description: "Remove the override entry from pnpm-workspace.yaml or pnpm.overrides"
1209 .to_string(),
1210 note: Some(
1211 "Conservative static check; verify against `pnpm install --frozen-lockfile` before removing in case the override targets a transitive dependency (CVE-fix pattern)"
1212 .to_string(),
1213 ),
1214 available_in_catalogs: None,
1215 suggested_target: None,
1216 }));
1217
1218 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
1219 Some(&entry.target_package),
1220 &entry.raw_key,
1221 entry.source,
1222 ) {
1223 actions.push(suppress);
1224 }
1225
1226 Self {
1227 entry,
1228 actions,
1229 introduced: None,
1230 }
1231 }
1232}
1233
1234#[derive(Debug, Clone, Serialize)]
1240#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1241pub struct MisconfiguredDependencyOverrideFinding {
1242 #[serde(flatten)]
1244 pub entry: MisconfiguredDependencyOverride,
1245 pub actions: Vec<IssueAction>,
1247 #[serde(default, skip_serializing_if = "Option::is_none")]
1250 pub introduced: Option<AuditIntroduced>,
1251}
1252
1253impl MisconfiguredDependencyOverrideFinding {
1254 #[must_use]
1259 pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
1260 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
1261 actions.push(IssueAction::Fix(FixAction {
1262 kind: FixActionType::FixDependencyOverride,
1263 auto_fixable: false,
1264 description:
1265 "Fix the override key or value: pnpm refuses to honor entries with an unparsable key or empty value"
1266 .to_string(),
1267 note: Some(
1268 "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`."
1269 .to_string(),
1270 ),
1271 available_in_catalogs: None,
1272 suggested_target: None,
1273 }));
1274
1275 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
1276 entry.target_package.as_deref(),
1277 &entry.raw_key,
1278 entry.source,
1279 ) {
1280 actions.push(suppress);
1281 }
1282
1283 Self {
1284 entry,
1285 actions,
1286 introduced: None,
1287 }
1288 }
1289}
1290
1291fn build_ignore_dependency_overrides_suppress(
1296 target_package: Option<&str>,
1297 raw_key: &str,
1298 source: DependencyOverrideSource,
1299) -> Option<IssueAction> {
1300 let package = target_package
1301 .filter(|s| !s.is_empty())
1302 .or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
1303 .to_string();
1304 let mut value = serde_json::Map::new();
1305 value.insert("package".to_string(), serde_json::Value::String(package));
1306 value.insert(
1307 "source".to_string(),
1308 serde_json::Value::String(source.as_label().to_string()),
1309 );
1310 Some(IssueAction::AddToConfig(AddToConfigAction {
1311 kind: AddToConfigKind::AddToConfig,
1312 auto_fixable: false,
1313 description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
1314 config_key: "ignoreDependencyOverrides".to_string(),
1315 value: AddToConfigValue::RuleObject(value),
1316 value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
1317 }))
1318}
1319
1320#[cfg(test)]
1330mod position_0_invariants {
1331 use super::*;
1332 use crate::output::FixActionType;
1333 use crate::results::{DependencyOverrideSource, DuplicateLocation};
1334 use std::path::PathBuf;
1335
1336 fn action_type(action: &IssueAction) -> &'static str {
1341 match action {
1342 IssueAction::Fix(fix) => match fix.kind {
1343 FixActionType::RemoveExport => "remove-export",
1344 FixActionType::DeleteFile => "delete-file",
1345 FixActionType::RemoveDependency => "remove-dependency",
1346 FixActionType::MoveDependency => "move-dependency",
1347 FixActionType::RemoveEnumMember => "remove-enum-member",
1348 FixActionType::RemoveClassMember => "remove-class-member",
1349 FixActionType::ResolveImport => "resolve-import",
1350 FixActionType::InstallDependency => "install-dependency",
1351 FixActionType::RemoveDuplicate => "remove-duplicate",
1352 FixActionType::MoveToDev => "move-to-dev",
1353 FixActionType::RefactorCycle => "refactor-cycle",
1354 FixActionType::RefactorBoundary => "refactor-boundary",
1355 FixActionType::ExportType => "export-type",
1356 FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
1357 FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
1358 FixActionType::UpdateCatalogReference => "update-catalog-reference",
1359 FixActionType::AddCatalogEntry => "add-catalog-entry",
1360 FixActionType::RemoveCatalogReference => "remove-catalog-reference",
1361 FixActionType::RemoveDependencyOverride => "remove-dependency-override",
1362 FixActionType::FixDependencyOverride => "fix-dependency-override",
1363 },
1364 IssueAction::SuppressLine(_) => "suppress-line",
1365 IssueAction::SuppressFile(_) => "suppress-file",
1366 IssueAction::AddToConfig(_) => "add-to-config",
1367 }
1368 }
1369
1370 #[test]
1381 fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
1382 let inner = UnresolvedCatalogReference {
1383 entry_name: "react".to_string(),
1384 catalog_name: "default".to_string(),
1385 path: PathBuf::from("apps/web/package.json"),
1386 line: 7,
1387 available_in_catalogs: Vec::new(),
1388 };
1389 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
1390 assert_eq!(
1391 action_type(&finding.actions[0]),
1392 "add-catalog-entry",
1393 "position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
1394 );
1395 let IssueAction::Fix(fix) = &finding.actions[0] else {
1396 panic!("position-0 should be an IssueAction::Fix");
1397 };
1398 assert!(
1399 fix.available_in_catalogs.is_none(),
1400 "add-catalog-entry must NOT carry available_in_catalogs"
1401 );
1402 assert!(
1403 fix.suggested_target.is_none(),
1404 "add-catalog-entry must NOT carry suggested_target"
1405 );
1406 }
1407
1408 #[test]
1415 fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
1416 let inner = UnresolvedCatalogReference {
1417 entry_name: "react".to_string(),
1418 catalog_name: "default".to_string(),
1419 path: PathBuf::from("apps/web/package.json"),
1420 line: 7,
1421 available_in_catalogs: vec!["react18".to_string()],
1422 };
1423 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
1424 assert_eq!(
1425 action_type(&finding.actions[0]),
1426 "update-catalog-reference",
1427 "position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
1428 );
1429 let IssueAction::Fix(fix) = &finding.actions[0] else {
1430 panic!("position-0 should be an IssueAction::Fix");
1431 };
1432 assert_eq!(
1433 fix.available_in_catalogs.as_deref(),
1434 Some(&["react18".to_string()][..]),
1435 "update-catalog-reference must carry the alternative list"
1436 );
1437 assert_eq!(
1438 fix.suggested_target.as_deref(),
1439 Some("react18"),
1440 "single-alternative case must surface `suggested_target` for deterministic agents"
1441 );
1442
1443 let inner_two = UnresolvedCatalogReference {
1445 entry_name: "react".to_string(),
1446 catalog_name: "default".to_string(),
1447 path: PathBuf::from("apps/web/package.json"),
1448 line: 7,
1449 available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
1450 };
1451 let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
1452 assert_eq!(
1453 action_type(&finding_two.actions[0]),
1454 "update-catalog-reference"
1455 );
1456 let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
1457 panic!("position-0 should be an IssueAction::Fix");
1458 };
1459 assert!(
1460 fix_two.suggested_target.is_none(),
1461 "multi-alternative case must NOT carry `suggested_target` (agent must pick)"
1462 );
1463 }
1464
1465 #[test]
1480 fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
1481 let inner = DuplicateExport {
1482 export_name: "Root".to_string(),
1483 locations: vec![
1484 DuplicateLocation {
1485 path: PathBuf::from("components/ui/accordion/index.ts"),
1486 line: 1,
1487 col: 0,
1488 },
1489 DuplicateLocation {
1490 path: PathBuf::from("components/ui/dialog/index.ts"),
1491 line: 1,
1492 col: 0,
1493 },
1494 ],
1495 };
1496 let finding = DuplicateExportFinding::with_actions(inner);
1497 assert_eq!(
1498 action_type(&finding.actions[0]),
1499 "add-to-config",
1500 "position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
1501 );
1502 assert_eq!(
1503 action_type(&finding.actions[1]),
1504 "remove-duplicate",
1505 "position-1 must be the destructive `remove-duplicate` fallback"
1506 );
1507
1508 let mut promoted = finding;
1511 promoted.set_config_fixable(true);
1512 assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
1513 let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
1514 panic!("position-0 should still be AddToConfig after set_config_fixable");
1515 };
1516 assert!(
1517 action.auto_fixable,
1518 "set_config_fixable(true) must flip auto_fixable"
1519 );
1520 }
1521
1522 #[test]
1527 fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
1528 let inner = DuplicateExport {
1529 export_name: "Root".to_string(),
1530 locations: Vec::new(),
1531 };
1532 let finding = DuplicateExportFinding::with_actions(inner);
1533 assert_eq!(
1534 action_type(&finding.actions[0]),
1535 "remove-duplicate",
1536 "with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
1537 );
1538
1539 let mut promoted = finding;
1541 promoted.set_config_fixable(true);
1542 assert_eq!(
1543 action_type(&promoted.actions[0]),
1544 "remove-duplicate",
1545 "set_config_fixable is a no-op when position-0 is not add-to-config"
1546 );
1547 }
1548
1549 #[test]
1555 fn misconfigured_override_drops_suppress_when_no_package_name() {
1556 let inner = MisconfiguredDependencyOverride {
1557 raw_key: String::new(),
1558 target_package: None,
1559 raw_value: String::new(),
1560 reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
1561 source: DependencyOverrideSource::PnpmWorkspaceYaml,
1562 path: PathBuf::from("pnpm-workspace.yaml"),
1563 line: 12,
1564 };
1565 let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
1566 assert_eq!(finding.actions.len(), 1);
1568 assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
1569 }
1570}