1use serde::Serialize;
31
32use crate::envelope::AuditIntroduced;
33use crate::output::{
34 AddToConfigAction, AddToConfigKind, AddToConfigValue, FixAction, FixActionType,
35 IgnoreExportsRule, IssueAction, SuppressFileAction, SuppressFileKind, SuppressLineAction,
36 SuppressLineKind, SuppressLineScope,
37};
38use crate::results::{
39 BoundaryCallViolation, BoundaryCoverageViolation, BoundaryViolation, CircularDependency,
40 DependencyOverrideSource, DuplicateExport, EmptyCatalogGroup, MisconfiguredDependencyOverride,
41 PolicyViolation, PrivateTypeLeak, ReExportCycle, ReExportCycleKind, TestOnlyDependency,
42 TypeOnlyDependency, UnlistedDependency, UnresolvedCatalogReference, UnresolvedImport,
43 UnusedCatalogEntry, UnusedDependency, UnusedDependencyOverride, UnusedExport, UnusedFile,
44 UnusedMember,
45};
46
47pub 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.";
51
52const IGNORE_EXPORTS_VALUE_SCHEMA: &str =
56 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreExports";
57
58const IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreCatalogReferences/items";
61
62const IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencyOverrides/items";
66
67#[derive(Debug, Clone, Serialize)]
72#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
73pub struct UnusedFileFinding {
74 #[serde(flatten)]
76 pub file: UnusedFile,
77 pub actions: Vec<IssueAction>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub introduced: Option<AuditIntroduced>,
84}
85
86impl UnusedFileFinding {
87 #[must_use]
91 pub fn with_actions(file: UnusedFile) -> Self {
92 let actions = vec![
93 IssueAction::Fix(FixAction {
94 kind: FixActionType::DeleteFile,
95 auto_fixable: false,
96 description: "Delete this file".to_string(),
97 note: Some(
98 "File deletion may remove runtime functionality not visible to static analysis"
99 .to_string(),
100 ),
101 available_in_catalogs: None,
102 suggested_target: None,
103 }),
104 IssueAction::SuppressFile(SuppressFileAction {
105 kind: SuppressFileKind::SuppressFile,
106 auto_fixable: false,
107 description: "Suppress with a file-level comment at the top of the file"
108 .to_string(),
109 comment: "// fallow-ignore-file unused-file".to_string(),
110 }),
111 ];
112 Self {
113 file,
114 actions,
115 introduced: None,
116 }
117 }
118}
119
120#[derive(Debug, Clone, Serialize)]
124#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
125pub struct PrivateTypeLeakFinding {
126 #[serde(flatten)]
128 pub leak: PrivateTypeLeak,
129 pub actions: Vec<IssueAction>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub introduced: Option<AuditIntroduced>,
136}
137
138impl PrivateTypeLeakFinding {
139 #[must_use]
141 pub fn with_actions(leak: PrivateTypeLeak) -> Self {
142 let actions = vec![
143 IssueAction::Fix(FixAction {
144 kind: FixActionType::ExportType,
145 auto_fixable: false,
146 description: "Export the referenced private type by name".to_string(),
147 note: Some(
148 "Keep the type exported while it is part of a public signature".to_string(),
149 ),
150 available_in_catalogs: None,
151 suggested_target: None,
152 }),
153 IssueAction::SuppressLine(SuppressLineAction {
154 kind: SuppressLineKind::SuppressLine,
155 auto_fixable: false,
156 description: "Suppress with an inline comment above the line".to_string(),
157 comment: "// fallow-ignore-next-line private-type-leak".to_string(),
158 scope: None,
159 }),
160 ];
161 Self {
162 leak,
163 actions,
164 introduced: None,
165 }
166 }
167}
168
169#[derive(Debug, Clone, Serialize)]
174#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
175pub struct UnresolvedImportFinding {
176 #[serde(flatten)]
178 pub import: UnresolvedImport,
179 pub actions: Vec<IssueAction>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub introduced: Option<AuditIntroduced>,
186}
187
188impl UnresolvedImportFinding {
189 #[must_use]
191 pub fn with_actions(import: UnresolvedImport) -> Self {
192 let actions = vec![
193 IssueAction::Fix(FixAction {
194 kind: FixActionType::ResolveImport,
195 auto_fixable: false,
196 description: "Fix the import specifier or install the missing module".to_string(),
197 note: Some(
198 "Verify the module path and check tsconfig paths configuration".to_string(),
199 ),
200 available_in_catalogs: None,
201 suggested_target: None,
202 }),
203 IssueAction::AddToConfig(AddToConfigAction {
204 kind: AddToConfigKind::AddToConfig,
205 auto_fixable: false,
206 description: format!(
207 "Add \"{}\" to ignoreUnresolvedImports in fallow config",
208 import.specifier
209 ),
210 config_key: "ignoreUnresolvedImports".to_string(),
211 value: AddToConfigValue::Scalar(import.specifier.clone()),
212 value_schema: Some(
213 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
214 .to_string(),
215 ),
216 }),
217 IssueAction::SuppressLine(SuppressLineAction {
218 kind: SuppressLineKind::SuppressLine,
219 auto_fixable: false,
220 description: "Suppress with an inline comment above the line".to_string(),
221 comment: "// fallow-ignore-next-line unresolved-import".to_string(),
222 scope: None,
223 }),
224 ];
225 Self {
226 import,
227 actions,
228 introduced: None,
229 }
230 }
231}
232
233#[derive(Debug, Clone, Serialize)]
238#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
239pub struct CircularDependencyFinding {
240 #[serde(flatten)]
242 pub cycle: CircularDependency,
243 pub actions: Vec<IssueAction>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub introduced: Option<AuditIntroduced>,
250}
251
252impl CircularDependencyFinding {
253 #[must_use]
255 pub fn with_actions(cycle: CircularDependency) -> Self {
256 let actions = vec![
257 IssueAction::Fix(FixAction {
258 kind: FixActionType::RefactorCycle,
259 auto_fixable: false,
260 description: "Extract shared logic into a separate module to break the cycle"
261 .to_string(),
262 note: Some(
263 "Circular imports can cause initialization issues and make code harder to reason about"
264 .to_string(),
265 ),
266 available_in_catalogs: None,
267 suggested_target: None,
268 }),
269 IssueAction::SuppressLine(SuppressLineAction {
270 kind: SuppressLineKind::SuppressLine,
271 auto_fixable: false,
272 description: "Suppress with an inline comment above the line".to_string(),
273 comment: "// fallow-ignore-next-line circular-dependency".to_string(),
274 scope: None,
275 }),
276 ];
277 Self {
278 cycle,
279 actions,
280 introduced: None,
281 }
282 }
283}
284
285#[derive(Debug, Clone, Serialize)]
293#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
294pub struct ReExportCycleFinding {
295 #[serde(flatten)]
297 pub cycle: ReExportCycle,
298 pub actions: Vec<IssueAction>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub introduced: Option<AuditIntroduced>,
305}
306
307impl ReExportCycleFinding {
308 #[must_use]
315 pub fn with_actions(cycle: ReExportCycle) -> Self {
316 let suppress_description = match cycle.kind {
322 ReExportCycleKind::SelfLoop => {
323 "Suppress with a file-level comment at the top of this file. \
324 The cycle is a self-loop, so the suppression covers the entire finding."
325 .to_string()
326 }
327 ReExportCycleKind::MultiNode => {
328 "Suppress with a file-level comment at the top of this file. \
329 One suppression on any member breaks the cycle for every member \
330 (see the sibling `files` array)."
331 .to_string()
332 }
333 };
334 let actions = vec![
335 IssueAction::Fix(FixAction {
336 kind: FixActionType::RefactorReExportCycle,
337 auto_fixable: false,
338 description: "Remove one `export * from` (or `export { ... } from`) \
339 statement on any one member to break the cycle"
340 .to_string(),
341 note: Some(
342 "Re-export cycles are structurally a no-op: chain propagation through \
343 the loop never reaches a terminating module, so imports from any member \
344 may silently come up empty."
345 .to_string(),
346 ),
347 available_in_catalogs: None,
348 suggested_target: None,
349 }),
350 IssueAction::SuppressFile(SuppressFileAction {
351 kind: SuppressFileKind::SuppressFile,
352 auto_fixable: false,
353 description: suppress_description,
354 comment: "// fallow-ignore-file re-export-cycle".to_string(),
355 }),
356 ];
357 Self {
358 cycle,
359 actions,
360 introduced: None,
361 }
362 }
363}
364
365#[derive(Debug, Clone, Serialize)]
370#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
371pub struct BoundaryViolationFinding {
372 #[serde(flatten)]
374 pub violation: BoundaryViolation,
375 pub actions: Vec<IssueAction>,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub introduced: Option<AuditIntroduced>,
382}
383
384impl BoundaryViolationFinding {
385 #[must_use]
387 pub fn with_actions(violation: BoundaryViolation) -> Self {
388 let actions = vec![
389 IssueAction::Fix(FixAction {
390 kind: FixActionType::RefactorBoundary,
391 auto_fixable: false,
392 description: "Move the import through an allowed zone or restructure the dependency"
393 .to_string(),
394 note: Some(
395 "This import crosses an architecture boundary that is not permitted by the configured rules"
396 .to_string(),
397 ),
398 available_in_catalogs: None,
399 suggested_target: None,
400 }),
401 IssueAction::SuppressLine(SuppressLineAction {
402 kind: SuppressLineKind::SuppressLine,
403 auto_fixable: false,
404 description: "Suppress with an inline comment above the line".to_string(),
405 comment: "// fallow-ignore-next-line boundary-violation".to_string(),
406 scope: None,
407 }),
408 ];
409 Self {
410 violation,
411 actions,
412 introduced: None,
413 }
414 }
415}
416
417#[derive(Debug, Clone, Serialize)]
421#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
422pub struct BoundaryCoverageViolationFinding {
423 #[serde(flatten)]
425 pub violation: BoundaryCoverageViolation,
426 pub actions: Vec<IssueAction>,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub introduced: Option<AuditIntroduced>,
432}
433
434impl BoundaryCoverageViolationFinding {
435 #[must_use]
437 pub fn with_actions(violation: BoundaryCoverageViolation) -> Self {
438 let path = violation.path.to_string_lossy().replace('\\', "/");
439 let actions = vec![
440 IssueAction::Fix(FixAction {
441 kind: FixActionType::RefactorBoundary,
442 auto_fixable: false,
443 description: "Add this file to a boundary zone pattern or move it under an existing zone"
444 .to_string(),
445 note: Some(
446 "Boundary coverage is enabled, so every analyzed source file must match a zone unless allow-listed"
447 .to_string(),
448 ),
449 available_in_catalogs: None,
450 suggested_target: None,
451 }),
452 IssueAction::AddToConfig(AddToConfigAction {
453 kind: AddToConfigKind::AddToConfig,
454 auto_fixable: false,
455 description: format!(
456 "Add \"{path}\" to boundaries.coverage.allowUnmatched in fallow config"
457 ),
458 config_key: "boundaries.coverage.allowUnmatched".to_string(),
459 value: AddToConfigValue::Scalar(path),
460 value_schema: Some(
461 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/boundaries/properties/coverage/properties/allowUnmatched/items"
462 .to_string(),
463 ),
464 }),
465 IssueAction::SuppressFile(SuppressFileAction {
466 kind: SuppressFileKind::SuppressFile,
467 auto_fixable: false,
468 description: "Suppress with a file-level comment at the top of the file"
469 .to_string(),
470 comment: "// fallow-ignore-file boundary-violation".to_string(),
471 }),
472 ];
473 Self {
474 violation,
475 actions,
476 introduced: None,
477 }
478 }
479}
480
481#[derive(Debug, Clone, Serialize)]
485#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
486pub struct BoundaryCallViolationFinding {
487 #[serde(flatten)]
489 pub violation: BoundaryCallViolation,
490 pub actions: Vec<IssueAction>,
492 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub introduced: Option<AuditIntroduced>,
496}
497
498impl BoundaryCallViolationFinding {
499 #[must_use]
501 pub fn with_actions(violation: BoundaryCallViolation) -> Self {
502 let actions = vec![
503 IssueAction::Fix(FixAction {
504 kind: FixActionType::RefactorBoundary,
505 auto_fixable: false,
506 description: format!(
507 "Move the `{}` call out of zone '{}' or behind an allowed abstraction",
508 violation.callee, violation.zone,
509 ),
510 note: Some(format!(
511 "`boundaries.calls.forbidden` bans callees matching `{}` from zone '{}'. The check is syntactic: it applies only to files classified into a zone and does not follow aliased or re-bound callees",
512 violation.pattern, violation.zone,
513 )),
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 boundary-violation".to_string(),
522 scope: None,
523 }),
524 IssueAction::SuppressFile(SuppressFileAction {
525 kind: SuppressFileKind::SuppressFile,
526 auto_fixable: false,
527 description: "Suppress with a file-level comment at the top of the file"
528 .to_string(),
529 comment: "// fallow-ignore-file boundary-violation".to_string(),
530 }),
531 ];
532 Self {
533 violation,
534 actions,
535 introduced: None,
536 }
537 }
538}
539
540#[derive(Debug, Clone, Serialize)]
544#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
545pub struct PolicyViolationFinding {
546 #[serde(flatten)]
548 pub violation: PolicyViolation,
549 pub actions: Vec<IssueAction>,
551 #[serde(default, skip_serializing_if = "Option::is_none")]
554 pub introduced: Option<AuditIntroduced>,
555}
556
557impl PolicyViolationFinding {
558 #[must_use]
560 pub fn with_actions(violation: PolicyViolation) -> Self {
561 let what = match violation.kind {
562 crate::results::PolicyRuleKind::BannedCall => "call",
563 crate::results::PolicyRuleKind::BannedImport => "import",
564 };
565 let description = match &violation.message {
566 Some(message) => format!("Replace the `{}` {what}: {message}", violation.matched),
567 None => format!("Replace the `{}` {what}", violation.matched),
568 };
569 let actions = vec![
570 IssueAction::Fix(FixAction {
571 kind: FixActionType::ResolvePolicyViolation,
572 auto_fixable: false,
573 description,
574 note: Some(format!(
575 "Rule `{}/{}` from the configured rule packs bans this {what}. The check is syntactic: it does not follow aliased or re-bound callees, and import matching uses the raw specifier",
576 violation.pack, violation.rule_id,
577 )),
578 available_in_catalogs: None,
579 suggested_target: None,
580 }),
581 IssueAction::SuppressLine(SuppressLineAction {
582 kind: SuppressLineKind::SuppressLine,
583 auto_fixable: false,
584 description: "Suppress with an inline comment above the line. The token covers every rule-pack rule on that line"
585 .to_string(),
586 comment: "// fallow-ignore-next-line policy-violation".to_string(),
587 scope: None,
588 }),
589 IssueAction::SuppressFile(SuppressFileAction {
590 kind: SuppressFileKind::SuppressFile,
591 auto_fixable: false,
592 description: "Suppress with a file-level comment at the top of the file. The token covers every rule-pack rule in the file, not just this rule"
593 .to_string(),
594 comment: "// fallow-ignore-file policy-violation".to_string(),
595 }),
596 ];
597 Self {
598 violation,
599 actions,
600 introduced: None,
601 }
602 }
603}
604
605#[derive(Debug, Clone, Serialize)]
610#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
611pub struct UnusedExportFinding {
612 #[serde(flatten)]
614 pub export: UnusedExport,
615 pub actions: Vec<IssueAction>,
618 #[serde(default, skip_serializing_if = "Option::is_none")]
621 pub introduced: Option<AuditIntroduced>,
622}
623
624impl UnusedExportFinding {
625 #[must_use]
629 pub fn with_actions(export: UnusedExport) -> Self {
630 let note = if export.is_re_export {
631 Some(
632 "This finding originates from a re-export; verify it is not part of your public API before removing"
633 .to_string(),
634 )
635 } else {
636 None
637 };
638 let actions = vec![
639 IssueAction::Fix(FixAction {
640 kind: FixActionType::RemoveExport,
641 auto_fixable: true,
642 description: "Remove the unused export from the public API".to_string(),
643 note,
644 available_in_catalogs: None,
645 suggested_target: None,
646 }),
647 IssueAction::SuppressLine(SuppressLineAction {
648 kind: SuppressLineKind::SuppressLine,
649 auto_fixable: false,
650 description: "Suppress with an inline comment above the line".to_string(),
651 comment: "// fallow-ignore-next-line unused-export".to_string(),
652 scope: None,
653 }),
654 ];
655 Self {
656 export,
657 actions,
658 introduced: None,
659 }
660 }
661}
662
663#[derive(Debug, Clone, Serialize)]
668#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
669pub struct UnusedTypeFinding {
670 #[serde(flatten)]
672 pub export: UnusedExport,
673 pub actions: Vec<IssueAction>,
676 #[serde(default, skip_serializing_if = "Option::is_none")]
679 pub introduced: Option<AuditIntroduced>,
680}
681
682impl UnusedTypeFinding {
683 #[must_use]
686 pub fn with_actions(export: UnusedExport) -> Self {
687 let note = if export.is_re_export {
688 Some(
689 "This finding originates from a re-export; verify it is not part of your public API before removing"
690 .to_string(),
691 )
692 } else {
693 None
694 };
695 let actions = vec![
696 IssueAction::Fix(FixAction {
697 kind: FixActionType::RemoveExport,
698 auto_fixable: true,
699 description:
700 "Remove the `export` (or `export type`) keyword from the type declaration"
701 .to_string(),
702 note,
703 available_in_catalogs: None,
704 suggested_target: None,
705 }),
706 IssueAction::SuppressLine(SuppressLineAction {
707 kind: SuppressLineKind::SuppressLine,
708 auto_fixable: false,
709 description: "Suppress with an inline comment above the line".to_string(),
710 comment: "// fallow-ignore-next-line unused-type".to_string(),
711 scope: None,
712 }),
713 ];
714 Self {
715 export,
716 actions,
717 introduced: None,
718 }
719 }
720}
721
722#[derive(Debug, Clone, Serialize)]
725#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
726pub struct UnusedEnumMemberFinding {
727 #[serde(flatten)]
729 pub member: UnusedMember,
730 pub actions: Vec<IssueAction>,
733 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub introduced: Option<AuditIntroduced>,
737}
738
739impl UnusedEnumMemberFinding {
740 #[must_use]
742 pub fn with_actions(member: UnusedMember) -> Self {
743 let actions = vec![
744 IssueAction::Fix(FixAction {
745 kind: FixActionType::RemoveEnumMember,
746 auto_fixable: true,
747 description: "Remove this enum member".to_string(),
748 note: None,
749 available_in_catalogs: None,
750 suggested_target: None,
751 }),
752 IssueAction::SuppressLine(SuppressLineAction {
753 kind: SuppressLineKind::SuppressLine,
754 auto_fixable: false,
755 description: "Suppress with an inline comment above the line".to_string(),
756 comment: "// fallow-ignore-next-line unused-enum-member".to_string(),
757 scope: None,
758 }),
759 ];
760 Self {
761 member,
762 actions,
763 introduced: None,
764 }
765 }
766}
767
768#[derive(Debug, Clone, Serialize)]
773#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
774pub struct UnusedClassMemberFinding {
775 #[serde(flatten)]
777 pub member: UnusedMember,
778 pub actions: Vec<IssueAction>,
781 #[serde(default, skip_serializing_if = "Option::is_none")]
784 pub introduced: Option<AuditIntroduced>,
785}
786
787impl UnusedClassMemberFinding {
788 #[must_use]
793 pub fn with_actions(member: UnusedMember) -> Self {
794 let actions = vec![
795 IssueAction::Fix(FixAction {
796 kind: FixActionType::RemoveClassMember,
797 auto_fixable: false,
798 description: "Remove this class member".to_string(),
799 note: Some(
800 "Class member may be used via dependency injection or decorators".to_string(),
801 ),
802 available_in_catalogs: None,
803 suggested_target: None,
804 }),
805 IssueAction::SuppressLine(SuppressLineAction {
806 kind: SuppressLineKind::SuppressLine,
807 auto_fixable: false,
808 description: "Suppress with an inline comment above the line".to_string(),
809 comment: "// fallow-ignore-next-line unused-class-member".to_string(),
810 scope: None,
811 }),
812 ];
813 Self {
814 member,
815 actions,
816 introduced: None,
817 }
818 }
819}
820
821fn build_unused_dependency_actions(
832 dep: &UnusedDependency,
833 package_json_location: &str,
834 suppress_issue_kind: &str,
835) -> Vec<IssueAction> {
836 let mut actions = Vec::with_capacity(2);
837 let cross_workspace = !dep.used_in_workspaces.is_empty();
838 actions.push(if cross_workspace {
839 IssueAction::Fix(FixAction {
840 kind: FixActionType::MoveDependency,
841 auto_fixable: false,
842 description: "Move this dependency to the workspace package.json that imports it"
843 .to_string(),
844 note: Some(
845 "fallow fix will not remove dependencies that are imported by another workspace"
846 .to_string(),
847 ),
848 available_in_catalogs: None,
849 suggested_target: None,
850 })
851 } else {
852 IssueAction::Fix(FixAction {
853 kind: FixActionType::RemoveDependency,
854 auto_fixable: true,
855 description: format!("Remove from {package_json_location} in package.json"),
856 note: None,
857 available_in_catalogs: None,
858 suggested_target: None,
859 })
860 });
861 actions.push(build_ignore_dependencies_suppress_action(
862 &dep.package_name,
863 suppress_issue_kind,
864 ));
865 actions
866}
867
868fn build_ignore_dependencies_suppress_action(
876 package_name: &str,
877 _suppress_issue_kind: &str,
878) -> IssueAction {
879 IssueAction::AddToConfig(AddToConfigAction {
880 kind: AddToConfigKind::AddToConfig,
881 auto_fixable: false,
882 description: format!("Add \"{package_name}\" to ignoreDependencies in fallow config"),
883 config_key: "ignoreDependencies".to_string(),
884 value: AddToConfigValue::Scalar(package_name.to_string()),
885 value_schema: Some(
886 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items"
887 .to_string(),
888 ),
889 })
890}
891
892#[derive(Debug, Clone, Serialize)]
898#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
899pub struct UnusedDependencyFinding {
900 #[serde(flatten)]
902 pub dep: UnusedDependency,
903 pub actions: Vec<IssueAction>,
906 #[serde(default, skip_serializing_if = "Option::is_none")]
909 pub introduced: Option<AuditIntroduced>,
910}
911
912impl UnusedDependencyFinding {
913 #[must_use]
916 pub fn with_actions(dep: UnusedDependency) -> Self {
917 let actions = build_unused_dependency_actions(&dep, "dependencies", "unused-dependency");
918 Self {
919 dep,
920 actions,
921 introduced: None,
922 }
923 }
924}
925
926#[derive(Debug, Clone, Serialize)]
932#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
933pub struct UnusedDevDependencyFinding {
934 #[serde(flatten)]
936 pub dep: UnusedDependency,
937 pub actions: Vec<IssueAction>,
940 #[serde(default, skip_serializing_if = "Option::is_none")]
943 pub introduced: Option<AuditIntroduced>,
944}
945
946impl UnusedDevDependencyFinding {
947 #[must_use]
949 pub fn with_actions(dep: UnusedDependency) -> Self {
950 let actions =
951 build_unused_dependency_actions(&dep, "devDependencies", "unused-dev-dependency");
952 Self {
953 dep,
954 actions,
955 introduced: None,
956 }
957 }
958}
959
960#[derive(Debug, Clone, Serialize)]
966#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
967pub struct UnusedOptionalDependencyFinding {
968 #[serde(flatten)]
970 pub dep: UnusedDependency,
971 pub actions: Vec<IssueAction>,
974 #[serde(default, skip_serializing_if = "Option::is_none")]
977 pub introduced: Option<AuditIntroduced>,
978}
979
980impl UnusedOptionalDependencyFinding {
981 #[must_use]
983 pub fn with_actions(dep: UnusedDependency) -> Self {
984 let actions =
985 build_unused_dependency_actions(&dep, "optionalDependencies", "unused-dependency");
986 Self {
987 dep,
988 actions,
989 introduced: None,
990 }
991 }
992}
993
994#[derive(Debug, Clone, Serialize)]
998#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
999pub struct UnlistedDependencyFinding {
1000 #[serde(flatten)]
1002 pub dep: UnlistedDependency,
1003 pub actions: Vec<IssueAction>,
1006 #[serde(default, skip_serializing_if = "Option::is_none")]
1009 pub introduced: Option<AuditIntroduced>,
1010}
1011
1012impl UnlistedDependencyFinding {
1013 #[must_use]
1015 pub fn with_actions(dep: UnlistedDependency) -> Self {
1016 let actions = vec![
1017 IssueAction::Fix(FixAction {
1018 kind: FixActionType::InstallDependency,
1019 auto_fixable: false,
1020 description: "Add this package to dependencies in package.json".to_string(),
1021 note: Some(
1022 "Verify this package should be a direct dependency before adding".to_string(),
1023 ),
1024 available_in_catalogs: None,
1025 suggested_target: None,
1026 }),
1027 build_ignore_dependencies_suppress_action(&dep.package_name, "unlisted-dependency"),
1028 ];
1029 Self {
1030 dep,
1031 actions,
1032 introduced: None,
1033 }
1034 }
1035}
1036
1037#[derive(Debug, Clone, Serialize)]
1041#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1042pub struct TypeOnlyDependencyFinding {
1043 #[serde(flatten)]
1045 pub dep: TypeOnlyDependency,
1046 pub actions: Vec<IssueAction>,
1049 #[serde(default, skip_serializing_if = "Option::is_none")]
1052 pub introduced: Option<AuditIntroduced>,
1053}
1054
1055impl TypeOnlyDependencyFinding {
1056 #[must_use]
1058 pub fn with_actions(dep: TypeOnlyDependency) -> Self {
1059 let actions = vec![
1060 IssueAction::Fix(FixAction {
1061 kind: FixActionType::MoveToDev,
1062 auto_fixable: false,
1063 description: "Move to devDependencies (only type imports are used)".to_string(),
1064 note: Some(
1065 "Type imports are erased at runtime so this dependency is not needed in production"
1066 .to_string(),
1067 ),
1068 available_in_catalogs: None,
1069 suggested_target: None,
1070 }),
1071 build_ignore_dependencies_suppress_action(&dep.package_name, "type-only-dependency"),
1072 ];
1073 Self {
1074 dep,
1075 actions,
1076 introduced: None,
1077 }
1078 }
1079}
1080
1081#[derive(Debug, Clone, Serialize)]
1085#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1086pub struct TestOnlyDependencyFinding {
1087 #[serde(flatten)]
1089 pub dep: TestOnlyDependency,
1090 pub actions: Vec<IssueAction>,
1093 #[serde(default, skip_serializing_if = "Option::is_none")]
1096 pub introduced: Option<AuditIntroduced>,
1097}
1098
1099impl TestOnlyDependencyFinding {
1100 #[must_use]
1102 pub fn with_actions(dep: TestOnlyDependency) -> Self {
1103 let actions = vec![
1104 IssueAction::Fix(FixAction {
1105 kind: FixActionType::MoveToDev,
1106 auto_fixable: false,
1107 description: "Move to devDependencies (only test files import this)".to_string(),
1108 note: Some(
1109 "Only test files import this package so it does not need to be a production dependency"
1110 .to_string(),
1111 ),
1112 available_in_catalogs: None,
1113 suggested_target: None,
1114 }),
1115 build_ignore_dependencies_suppress_action(&dep.package_name, "test-only-dependency"),
1116 ];
1117 Self {
1118 dep,
1119 actions,
1120 introduced: None,
1121 }
1122 }
1123}
1124
1125#[derive(Debug, Clone, Serialize)]
1146#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1147pub struct DuplicateExportFinding {
1148 #[serde(flatten)]
1150 pub export: DuplicateExport,
1151 pub actions: Vec<IssueAction>,
1154 #[serde(default, skip_serializing_if = "Option::is_none")]
1157 pub introduced: Option<AuditIntroduced>,
1158}
1159
1160impl DuplicateExportFinding {
1161 #[must_use]
1170 pub fn with_actions(export: DuplicateExport) -> Self {
1171 let mut actions: Vec<IssueAction> = Vec::with_capacity(3);
1172
1173 if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
1174 actions.push(IssueAction::AddToConfig(AddToConfigAction {
1175 kind: AddToConfigKind::AddToConfig,
1176 auto_fixable: false,
1177 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(),
1178 config_key: "ignoreExports".to_string(),
1179 value: AddToConfigValue::ExportsRules(rules),
1180 value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
1181 }));
1182 }
1183
1184 actions.push(IssueAction::Fix(FixAction {
1185 kind: FixActionType::RemoveDuplicate,
1186 auto_fixable: false,
1187 description: "Keep one canonical export location and remove the others".to_string(),
1188 note: Some(NAMESPACE_BARREL_HINT.to_string()),
1189 available_in_catalogs: None,
1190 suggested_target: None,
1191 }));
1192
1193 actions.push(IssueAction::SuppressLine(SuppressLineAction {
1194 kind: SuppressLineKind::SuppressLine,
1195 auto_fixable: false,
1196 description: "Suppress with an inline comment above the line".to_string(),
1197 comment: "// fallow-ignore-next-line duplicate-export".to_string(),
1198 scope: Some(SuppressLineScope::PerLocation),
1199 }));
1200
1201 Self {
1202 export,
1203 actions,
1204 introduced: None,
1205 }
1206 }
1207
1208 pub fn set_config_fixable(&mut self, fixable: bool) {
1214 if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
1215 action.auto_fixable = fixable;
1216 }
1217 }
1218}
1219
1220fn build_duplicate_exports_ignore_rules(
1224 export: &DuplicateExport,
1225) -> Option<Vec<IgnoreExportsRule>> {
1226 let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
1227 for loc in &export.locations {
1228 let path = loc.path.to_string_lossy().replace('\\', "/");
1236 if path.is_empty() {
1237 continue;
1238 }
1239 if entries.iter().any(|existing| existing.file == path) {
1240 continue;
1241 }
1242 entries.push(IgnoreExportsRule {
1243 file: path,
1244 exports: vec!["*".to_string()],
1245 });
1246 }
1247 if entries.is_empty() {
1248 None
1249 } else {
1250 Some(entries)
1251 }
1252}
1253
1254#[derive(Debug, Clone, Serialize)]
1259#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1260pub struct UnusedCatalogEntryFinding {
1261 #[serde(flatten)]
1263 pub entry: UnusedCatalogEntry,
1264 pub actions: Vec<IssueAction>,
1266 #[serde(default, skip_serializing_if = "Option::is_none")]
1269 pub introduced: Option<AuditIntroduced>,
1270}
1271
1272impl UnusedCatalogEntryFinding {
1273 #[must_use]
1277 pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
1278 let auto_fixable = entry.hardcoded_consumers.is_empty();
1279 let actions = vec![
1280 IssueAction::Fix(FixAction {
1281 kind: FixActionType::RemoveCatalogEntry,
1282 auto_fixable,
1283 description: "Remove the entry from pnpm-workspace.yaml".to_string(),
1284 note: Some(
1285 "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing"
1286 .to_string(),
1287 ),
1288 available_in_catalogs: None,
1289 suggested_target: None,
1290 }),
1291 IssueAction::SuppressLine(SuppressLineAction {
1292 kind: SuppressLineKind::SuppressLine,
1293 auto_fixable: false,
1294 description: "Suppress with a YAML comment above the line".to_string(),
1295 comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
1296 scope: None,
1297 }),
1298 ];
1299 Self {
1300 entry,
1301 actions,
1302 introduced: None,
1303 }
1304 }
1305}
1306
1307#[derive(Debug, Clone, Serialize)]
1311#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1312pub struct EmptyCatalogGroupFinding {
1313 #[serde(flatten)]
1315 pub group: EmptyCatalogGroup,
1316 pub actions: Vec<IssueAction>,
1318 #[serde(default, skip_serializing_if = "Option::is_none")]
1321 pub introduced: Option<AuditIntroduced>,
1322}
1323
1324impl EmptyCatalogGroupFinding {
1325 #[must_use]
1327 pub fn with_actions(group: EmptyCatalogGroup) -> Self {
1328 let actions = vec![
1329 IssueAction::Fix(FixAction {
1330 kind: FixActionType::RemoveEmptyCatalogGroup,
1331 auto_fixable: true,
1332 description: "Remove the empty named catalog group from pnpm-workspace.yaml"
1333 .to_string(),
1334 note: Some(
1335 "Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
1336 .to_string(),
1337 ),
1338 available_in_catalogs: None,
1339 suggested_target: None,
1340 }),
1341 IssueAction::SuppressLine(SuppressLineAction {
1342 kind: SuppressLineKind::SuppressLine,
1343 auto_fixable: false,
1344 description: "Suppress with a YAML comment above the line".to_string(),
1345 comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
1346 scope: None,
1347 }),
1348 ];
1349 Self {
1350 group,
1351 actions,
1352 introduced: None,
1353 }
1354 }
1355}
1356
1357#[derive(Debug, Clone, Serialize)]
1365#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1366pub struct UnresolvedCatalogReferenceFinding {
1367 #[serde(flatten)]
1369 pub reference: UnresolvedCatalogReference,
1370 pub actions: Vec<IssueAction>,
1373 #[serde(default, skip_serializing_if = "Option::is_none")]
1376 pub introduced: Option<AuditIntroduced>,
1377}
1378
1379impl UnresolvedCatalogReferenceFinding {
1380 #[must_use]
1384 pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
1385 let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
1390 let primary = if reference.available_in_catalogs.is_empty() {
1391 IssueAction::Fix(FixAction {
1392 kind: FixActionType::AddCatalogEntry,
1393 auto_fixable: false,
1394 description: format!(
1395 "Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
1396 reference.entry_name, reference.catalog_name
1397 ),
1398 note: Some(
1399 "Pin a version that satisfies the consumer's import; no other catalog declares this package today"
1400 .to_string(),
1401 ),
1402 available_in_catalogs: None,
1403 suggested_target: None,
1404 })
1405 } else {
1406 let available = reference.available_in_catalogs.clone();
1407 let suggested_target = (available.len() == 1).then(|| available[0].clone());
1408 IssueAction::Fix(FixAction {
1409 kind: FixActionType::UpdateCatalogReference,
1410 auto_fixable: false,
1411 description: format!(
1412 "Switch the reference from `catalog:{}` to a catalog that declares `{}`",
1413 reference.catalog_name, reference.entry_name
1414 ),
1415 note: None,
1416 available_in_catalogs: Some(available),
1417 suggested_target,
1418 })
1419 };
1420
1421 let fallback = IssueAction::Fix(FixAction {
1422 kind: FixActionType::RemoveCatalogReference,
1423 auto_fixable: false,
1424 description:
1425 "Remove the catalog reference and pin a hardcoded version in package.json"
1426 .to_string(),
1427 note: Some(
1428 "Use only when neither another catalog declares the package nor the named catalog should grow to include it"
1429 .to_string(),
1430 ),
1431 available_in_catalogs: None,
1432 suggested_target: None,
1433 });
1434
1435 let mut suppress_value = serde_json::Map::new();
1436 suppress_value.insert(
1437 "package".to_string(),
1438 serde_json::Value::String(reference.entry_name.clone()),
1439 );
1440 suppress_value.insert(
1441 "catalog".to_string(),
1442 serde_json::Value::String(reference.catalog_name.clone()),
1443 );
1444 suppress_value.insert(
1445 "consumer".to_string(),
1446 serde_json::Value::String(consumer_path),
1447 );
1448 let suppress = IssueAction::AddToConfig(AddToConfigAction {
1449 kind: AddToConfigKind::AddToConfig,
1450 auto_fixable: false,
1451 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(),
1452 config_key: "ignoreCatalogReferences".to_string(),
1453 value: AddToConfigValue::RuleObject(suppress_value),
1454 value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
1455 });
1456
1457 Self {
1458 reference,
1459 actions: vec![primary, fallback, suppress],
1460 introduced: None,
1461 }
1462 }
1463}
1464
1465#[derive(Debug, Clone, Serialize)]
1470#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1471pub struct UnusedDependencyOverrideFinding {
1472 #[serde(flatten)]
1474 pub entry: UnusedDependencyOverride,
1475 pub actions: Vec<IssueAction>,
1477 #[serde(default, skip_serializing_if = "Option::is_none")]
1480 pub introduced: Option<AuditIntroduced>,
1481}
1482
1483impl UnusedDependencyOverrideFinding {
1484 #[must_use]
1486 pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
1487 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
1488 actions.push(IssueAction::Fix(FixAction {
1489 kind: FixActionType::RemoveDependencyOverride,
1490 auto_fixable: false,
1491 description: "Remove the override entry from pnpm-workspace.yaml or pnpm.overrides"
1492 .to_string(),
1493 note: Some(
1494 "Conservative static check; verify against `pnpm install --frozen-lockfile` before removing in case the override targets a transitive dependency (CVE-fix pattern)"
1495 .to_string(),
1496 ),
1497 available_in_catalogs: None,
1498 suggested_target: None,
1499 }));
1500
1501 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
1502 Some(&entry.target_package),
1503 &entry.raw_key,
1504 entry.source,
1505 ) {
1506 actions.push(suppress);
1507 }
1508
1509 Self {
1510 entry,
1511 actions,
1512 introduced: None,
1513 }
1514 }
1515}
1516
1517#[derive(Debug, Clone, Serialize)]
1523#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1524pub struct MisconfiguredDependencyOverrideFinding {
1525 #[serde(flatten)]
1527 pub entry: MisconfiguredDependencyOverride,
1528 pub actions: Vec<IssueAction>,
1530 #[serde(default, skip_serializing_if = "Option::is_none")]
1533 pub introduced: Option<AuditIntroduced>,
1534}
1535
1536impl MisconfiguredDependencyOverrideFinding {
1537 #[must_use]
1542 pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
1543 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
1544 actions.push(IssueAction::Fix(FixAction {
1545 kind: FixActionType::FixDependencyOverride,
1546 auto_fixable: false,
1547 description:
1548 "Fix the override key or value: pnpm refuses to honor entries with an unparsable key or empty value"
1549 .to_string(),
1550 note: Some(
1551 "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`."
1552 .to_string(),
1553 ),
1554 available_in_catalogs: None,
1555 suggested_target: None,
1556 }));
1557
1558 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
1559 entry.target_package.as_deref(),
1560 &entry.raw_key,
1561 entry.source,
1562 ) {
1563 actions.push(suppress);
1564 }
1565
1566 Self {
1567 entry,
1568 actions,
1569 introduced: None,
1570 }
1571 }
1572}
1573
1574fn build_ignore_dependency_overrides_suppress(
1579 target_package: Option<&str>,
1580 raw_key: &str,
1581 source: DependencyOverrideSource,
1582) -> Option<IssueAction> {
1583 let package = target_package
1584 .filter(|s| !s.is_empty())
1585 .or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
1586 .to_string();
1587 let mut value = serde_json::Map::new();
1588 value.insert("package".to_string(), serde_json::Value::String(package));
1589 value.insert(
1590 "source".to_string(),
1591 serde_json::Value::String(source.as_label().to_string()),
1592 );
1593 Some(IssueAction::AddToConfig(AddToConfigAction {
1594 kind: AddToConfigKind::AddToConfig,
1595 auto_fixable: false,
1596 description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
1597 config_key: "ignoreDependencyOverrides".to_string(),
1598 value: AddToConfigValue::RuleObject(value),
1599 value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
1600 }))
1601}
1602
1603#[cfg(test)]
1613mod position_0_invariants {
1614 use super::*;
1615 use crate::output::FixActionType;
1616 use crate::results::{DependencyOverrideSource, DuplicateLocation};
1617 use std::path::PathBuf;
1618
1619 fn action_type(action: &IssueAction) -> &'static str {
1624 match action {
1625 IssueAction::Fix(fix) => match fix.kind {
1626 FixActionType::RemoveExport => "remove-export",
1627 FixActionType::DeleteFile => "delete-file",
1628 FixActionType::RemoveDependency => "remove-dependency",
1629 FixActionType::MoveDependency => "move-dependency",
1630 FixActionType::RemoveEnumMember => "remove-enum-member",
1631 FixActionType::RemoveClassMember => "remove-class-member",
1632 FixActionType::ResolveImport => "resolve-import",
1633 FixActionType::InstallDependency => "install-dependency",
1634 FixActionType::RemoveDuplicate => "remove-duplicate",
1635 FixActionType::MoveToDev => "move-to-dev",
1636 FixActionType::RefactorCycle => "refactor-cycle",
1637 FixActionType::RefactorReExportCycle => "refactor-re-export-cycle",
1638 FixActionType::RefactorBoundary => "refactor-boundary",
1639 FixActionType::ExportType => "export-type",
1640 FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
1641 FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
1642 FixActionType::UpdateCatalogReference => "update-catalog-reference",
1643 FixActionType::AddCatalogEntry => "add-catalog-entry",
1644 FixActionType::RemoveCatalogReference => "remove-catalog-reference",
1645 FixActionType::RemoveDependencyOverride => "remove-dependency-override",
1646 FixActionType::FixDependencyOverride => "fix-dependency-override",
1647 FixActionType::ResolvePolicyViolation => "resolve-policy-violation",
1648 },
1649 IssueAction::SuppressLine(_) => "suppress-line",
1650 IssueAction::SuppressFile(_) => "suppress-file",
1651 IssueAction::AddToConfig(_) => "add-to-config",
1652 }
1653 }
1654
1655 #[test]
1656 fn unresolved_import_actions_include_ignore_unresolved_imports_config_suppress() {
1657 let inner = UnresolvedImport {
1658 specifier: "@example/icons".to_string(),
1659 path: PathBuf::from("src/index.ts"),
1660 line: 4,
1661 col: 12,
1662 specifier_col: 18,
1663 };
1664 let finding = UnresolvedImportFinding::with_actions(inner);
1665
1666 assert_eq!(action_type(&finding.actions[0]), "resolve-import");
1667 assert_eq!(action_type(&finding.actions[1]), "add-to-config");
1668 let IssueAction::AddToConfig(action) = &finding.actions[1] else {
1669 panic!("position-1 should be AddToConfig");
1670 };
1671 assert!(!action.auto_fixable);
1672 assert_eq!(action.config_key, "ignoreUnresolvedImports");
1673 let AddToConfigValue::Scalar(value) = &action.value else {
1674 panic!("ignoreUnresolvedImports action should carry a scalar value");
1675 };
1676 assert_eq!(value, "@example/icons");
1677 assert_eq!(
1678 action.value_schema.as_deref(),
1679 Some(
1680 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
1681 )
1682 );
1683 }
1684
1685 #[test]
1696 fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
1697 let inner = UnresolvedCatalogReference {
1698 entry_name: "react".to_string(),
1699 catalog_name: "default".to_string(),
1700 path: PathBuf::from("apps/web/package.json"),
1701 line: 7,
1702 available_in_catalogs: Vec::new(),
1703 };
1704 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
1705 assert_eq!(
1706 action_type(&finding.actions[0]),
1707 "add-catalog-entry",
1708 "position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
1709 );
1710 let IssueAction::Fix(fix) = &finding.actions[0] else {
1711 panic!("position-0 should be an IssueAction::Fix");
1712 };
1713 assert!(
1714 fix.available_in_catalogs.is_none(),
1715 "add-catalog-entry must NOT carry available_in_catalogs"
1716 );
1717 assert!(
1718 fix.suggested_target.is_none(),
1719 "add-catalog-entry must NOT carry suggested_target"
1720 );
1721 }
1722
1723 #[test]
1730 fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
1731 let inner = UnresolvedCatalogReference {
1732 entry_name: "react".to_string(),
1733 catalog_name: "default".to_string(),
1734 path: PathBuf::from("apps/web/package.json"),
1735 line: 7,
1736 available_in_catalogs: vec!["react18".to_string()],
1737 };
1738 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
1739 assert_eq!(
1740 action_type(&finding.actions[0]),
1741 "update-catalog-reference",
1742 "position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
1743 );
1744 let IssueAction::Fix(fix) = &finding.actions[0] else {
1745 panic!("position-0 should be an IssueAction::Fix");
1746 };
1747 assert_eq!(
1748 fix.available_in_catalogs.as_deref(),
1749 Some(&["react18".to_string()][..]),
1750 "update-catalog-reference must carry the alternative list"
1751 );
1752 assert_eq!(
1753 fix.suggested_target.as_deref(),
1754 Some("react18"),
1755 "single-alternative case must surface `suggested_target` for deterministic agents"
1756 );
1757
1758 let inner_two = UnresolvedCatalogReference {
1760 entry_name: "react".to_string(),
1761 catalog_name: "default".to_string(),
1762 path: PathBuf::from("apps/web/package.json"),
1763 line: 7,
1764 available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
1765 };
1766 let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
1767 assert_eq!(
1768 action_type(&finding_two.actions[0]),
1769 "update-catalog-reference"
1770 );
1771 let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
1772 panic!("position-0 should be an IssueAction::Fix");
1773 };
1774 assert!(
1775 fix_two.suggested_target.is_none(),
1776 "multi-alternative case must NOT carry `suggested_target` (agent must pick)"
1777 );
1778 }
1779
1780 #[test]
1795 fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
1796 let inner = DuplicateExport {
1797 export_name: "Root".to_string(),
1798 locations: vec![
1799 DuplicateLocation {
1800 path: PathBuf::from("components/ui/accordion/index.ts"),
1801 line: 1,
1802 col: 0,
1803 },
1804 DuplicateLocation {
1805 path: PathBuf::from("components/ui/dialog/index.ts"),
1806 line: 1,
1807 col: 0,
1808 },
1809 ],
1810 };
1811 let finding = DuplicateExportFinding::with_actions(inner);
1812 assert_eq!(
1813 action_type(&finding.actions[0]),
1814 "add-to-config",
1815 "position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
1816 );
1817 assert_eq!(
1818 action_type(&finding.actions[1]),
1819 "remove-duplicate",
1820 "position-1 must be the destructive `remove-duplicate` fallback"
1821 );
1822
1823 let mut promoted = finding;
1826 promoted.set_config_fixable(true);
1827 assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
1828 let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
1829 panic!("position-0 should still be AddToConfig after set_config_fixable");
1830 };
1831 assert!(
1832 action.auto_fixable,
1833 "set_config_fixable(true) must flip auto_fixable"
1834 );
1835 }
1836
1837 #[test]
1842 fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
1843 let inner = DuplicateExport {
1844 export_name: "Root".to_string(),
1845 locations: Vec::new(),
1846 };
1847 let finding = DuplicateExportFinding::with_actions(inner);
1848 assert_eq!(
1849 action_type(&finding.actions[0]),
1850 "remove-duplicate",
1851 "with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
1852 );
1853
1854 let mut promoted = finding;
1856 promoted.set_config_fixable(true);
1857 assert_eq!(
1858 action_type(&promoted.actions[0]),
1859 "remove-duplicate",
1860 "set_config_fixable is a no-op when position-0 is not add-to-config"
1861 );
1862 }
1863
1864 #[test]
1870 fn misconfigured_override_drops_suppress_when_no_package_name() {
1871 let inner = MisconfiguredDependencyOverride {
1872 raw_key: String::new(),
1873 target_package: None,
1874 raw_value: String::new(),
1875 reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
1876 source: DependencyOverrideSource::PnpmWorkspaceYaml,
1877 path: PathBuf::from("pnpm-workspace.yaml"),
1878 line: 12,
1879 };
1880 let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
1881 assert_eq!(finding.actions.len(), 1);
1883 assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
1884 }
1885}