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 suppress_token = format!("policy-violation:{}/{}", violation.pack, violation.rule_id);
570 let actions = vec![
571 IssueAction::Fix(FixAction {
572 kind: FixActionType::ResolvePolicyViolation,
573 auto_fixable: false,
574 description,
575 note: Some(format!(
576 "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",
577 violation.pack, violation.rule_id,
578 )),
579 available_in_catalogs: None,
580 suggested_target: None,
581 }),
582 IssueAction::SuppressLine(SuppressLineAction {
583 kind: SuppressLineKind::SuppressLine,
584 auto_fixable: false,
585 description: "Suppress this rule-pack rule with an inline comment above the line"
586 .to_string(),
587 comment: format!("// fallow-ignore-next-line {suppress_token}"),
588 scope: None,
589 }),
590 IssueAction::SuppressFile(SuppressFileAction {
591 kind: SuppressFileKind::SuppressFile,
592 auto_fixable: false,
593 description:
594 "Suppress this rule-pack rule with a file-level comment at the top of the file"
595 .to_string(),
596 comment: format!("// fallow-ignore-file {suppress_token}"),
597 }),
598 ];
599 Self {
600 violation,
601 actions,
602 introduced: None,
603 }
604 }
605}
606
607#[derive(Debug, Clone, Serialize)]
612#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
613pub struct UnusedExportFinding {
614 #[serde(flatten)]
616 pub export: UnusedExport,
617 pub actions: Vec<IssueAction>,
620 #[serde(default, skip_serializing_if = "Option::is_none")]
623 pub introduced: Option<AuditIntroduced>,
624}
625
626impl UnusedExportFinding {
627 #[must_use]
631 pub fn with_actions(export: UnusedExport) -> Self {
632 let note = if export.is_re_export {
633 Some(
634 "This finding originates from a re-export; verify it is not part of your public API before removing"
635 .to_string(),
636 )
637 } else {
638 None
639 };
640 let actions = vec![
641 IssueAction::Fix(FixAction {
642 kind: FixActionType::RemoveExport,
643 auto_fixable: true,
644 description: "Remove the unused export from the public API".to_string(),
645 note,
646 available_in_catalogs: None,
647 suggested_target: None,
648 }),
649 IssueAction::SuppressLine(SuppressLineAction {
650 kind: SuppressLineKind::SuppressLine,
651 auto_fixable: false,
652 description: "Suppress with an inline comment above the line".to_string(),
653 comment: "// fallow-ignore-next-line unused-export".to_string(),
654 scope: None,
655 }),
656 ];
657 Self {
658 export,
659 actions,
660 introduced: None,
661 }
662 }
663}
664
665#[derive(Debug, Clone, Serialize)]
670#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
671pub struct UnusedTypeFinding {
672 #[serde(flatten)]
674 pub export: UnusedExport,
675 pub actions: Vec<IssueAction>,
678 #[serde(default, skip_serializing_if = "Option::is_none")]
681 pub introduced: Option<AuditIntroduced>,
682}
683
684impl UnusedTypeFinding {
685 #[must_use]
688 pub fn with_actions(export: UnusedExport) -> Self {
689 let note = if export.is_re_export {
690 Some(
691 "This finding originates from a re-export; verify it is not part of your public API before removing"
692 .to_string(),
693 )
694 } else {
695 None
696 };
697 let actions = vec![
698 IssueAction::Fix(FixAction {
699 kind: FixActionType::RemoveExport,
700 auto_fixable: true,
701 description:
702 "Remove the `export` (or `export type`) keyword from the type declaration"
703 .to_string(),
704 note,
705 available_in_catalogs: None,
706 suggested_target: None,
707 }),
708 IssueAction::SuppressLine(SuppressLineAction {
709 kind: SuppressLineKind::SuppressLine,
710 auto_fixable: false,
711 description: "Suppress with an inline comment above the line".to_string(),
712 comment: "// fallow-ignore-next-line unused-type".to_string(),
713 scope: None,
714 }),
715 ];
716 Self {
717 export,
718 actions,
719 introduced: None,
720 }
721 }
722}
723
724#[derive(Debug, Clone, Serialize)]
727#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
728pub struct UnusedEnumMemberFinding {
729 #[serde(flatten)]
731 pub member: UnusedMember,
732 pub actions: Vec<IssueAction>,
735 #[serde(default, skip_serializing_if = "Option::is_none")]
738 pub introduced: Option<AuditIntroduced>,
739}
740
741impl UnusedEnumMemberFinding {
742 #[must_use]
744 pub fn with_actions(member: UnusedMember) -> Self {
745 let actions = vec![
746 IssueAction::Fix(FixAction {
747 kind: FixActionType::RemoveEnumMember,
748 auto_fixable: true,
749 description: "Remove this enum member".to_string(),
750 note: None,
751 available_in_catalogs: None,
752 suggested_target: None,
753 }),
754 IssueAction::SuppressLine(SuppressLineAction {
755 kind: SuppressLineKind::SuppressLine,
756 auto_fixable: false,
757 description: "Suppress with an inline comment above the line".to_string(),
758 comment: "// fallow-ignore-next-line unused-enum-member".to_string(),
759 scope: None,
760 }),
761 ];
762 Self {
763 member,
764 actions,
765 introduced: None,
766 }
767 }
768}
769
770#[derive(Debug, Clone, Serialize)]
775#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
776pub struct UnusedClassMemberFinding {
777 #[serde(flatten)]
779 pub member: UnusedMember,
780 pub actions: Vec<IssueAction>,
783 #[serde(default, skip_serializing_if = "Option::is_none")]
786 pub introduced: Option<AuditIntroduced>,
787}
788
789impl UnusedClassMemberFinding {
790 #[must_use]
795 pub fn with_actions(member: UnusedMember) -> Self {
796 let actions = vec![
797 IssueAction::Fix(FixAction {
798 kind: FixActionType::RemoveClassMember,
799 auto_fixable: false,
800 description: "Remove this class member".to_string(),
801 note: Some(
802 "Class member may be used via dependency injection or decorators".to_string(),
803 ),
804 available_in_catalogs: None,
805 suggested_target: None,
806 }),
807 IssueAction::SuppressLine(SuppressLineAction {
808 kind: SuppressLineKind::SuppressLine,
809 auto_fixable: false,
810 description: "Suppress with an inline comment above the line".to_string(),
811 comment: "// fallow-ignore-next-line unused-class-member".to_string(),
812 scope: None,
813 }),
814 ];
815 Self {
816 member,
817 actions,
818 introduced: None,
819 }
820 }
821}
822
823fn build_unused_dependency_actions(
834 dep: &UnusedDependency,
835 package_json_location: &str,
836 suppress_issue_kind: &str,
837) -> Vec<IssueAction> {
838 let mut actions = Vec::with_capacity(2);
839 let cross_workspace = !dep.used_in_workspaces.is_empty();
840 actions.push(if cross_workspace {
841 IssueAction::Fix(FixAction {
842 kind: FixActionType::MoveDependency,
843 auto_fixable: false,
844 description: "Move this dependency to the workspace package.json that imports it"
845 .to_string(),
846 note: Some(
847 "fallow fix will not remove dependencies that are imported by another workspace"
848 .to_string(),
849 ),
850 available_in_catalogs: None,
851 suggested_target: None,
852 })
853 } else {
854 IssueAction::Fix(FixAction {
855 kind: FixActionType::RemoveDependency,
856 auto_fixable: true,
857 description: format!("Remove from {package_json_location} in package.json"),
858 note: None,
859 available_in_catalogs: None,
860 suggested_target: None,
861 })
862 });
863 actions.push(build_ignore_dependencies_suppress_action(
864 &dep.package_name,
865 suppress_issue_kind,
866 ));
867 actions
868}
869
870fn build_ignore_dependencies_suppress_action(
878 package_name: &str,
879 _suppress_issue_kind: &str,
880) -> IssueAction {
881 IssueAction::AddToConfig(AddToConfigAction {
882 kind: AddToConfigKind::AddToConfig,
883 auto_fixable: false,
884 description: format!("Add \"{package_name}\" to ignoreDependencies in fallow config"),
885 config_key: "ignoreDependencies".to_string(),
886 value: AddToConfigValue::Scalar(package_name.to_string()),
887 value_schema: Some(
888 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items"
889 .to_string(),
890 ),
891 })
892}
893
894#[derive(Debug, Clone, Serialize)]
900#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
901pub struct UnusedDependencyFinding {
902 #[serde(flatten)]
904 pub dep: UnusedDependency,
905 pub actions: Vec<IssueAction>,
908 #[serde(default, skip_serializing_if = "Option::is_none")]
911 pub introduced: Option<AuditIntroduced>,
912}
913
914impl UnusedDependencyFinding {
915 #[must_use]
918 pub fn with_actions(dep: UnusedDependency) -> Self {
919 let actions = build_unused_dependency_actions(&dep, "dependencies", "unused-dependency");
920 Self {
921 dep,
922 actions,
923 introduced: None,
924 }
925 }
926}
927
928#[derive(Debug, Clone, Serialize)]
934#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
935pub struct UnusedDevDependencyFinding {
936 #[serde(flatten)]
938 pub dep: UnusedDependency,
939 pub actions: Vec<IssueAction>,
942 #[serde(default, skip_serializing_if = "Option::is_none")]
945 pub introduced: Option<AuditIntroduced>,
946}
947
948impl UnusedDevDependencyFinding {
949 #[must_use]
951 pub fn with_actions(dep: UnusedDependency) -> Self {
952 let actions =
953 build_unused_dependency_actions(&dep, "devDependencies", "unused-dev-dependency");
954 Self {
955 dep,
956 actions,
957 introduced: None,
958 }
959 }
960}
961
962#[derive(Debug, Clone, Serialize)]
968#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
969pub struct UnusedOptionalDependencyFinding {
970 #[serde(flatten)]
972 pub dep: UnusedDependency,
973 pub actions: Vec<IssueAction>,
976 #[serde(default, skip_serializing_if = "Option::is_none")]
979 pub introduced: Option<AuditIntroduced>,
980}
981
982impl UnusedOptionalDependencyFinding {
983 #[must_use]
985 pub fn with_actions(dep: UnusedDependency) -> Self {
986 let actions =
987 build_unused_dependency_actions(&dep, "optionalDependencies", "unused-dependency");
988 Self {
989 dep,
990 actions,
991 introduced: None,
992 }
993 }
994}
995
996#[derive(Debug, Clone, Serialize)]
1000#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1001pub struct UnlistedDependencyFinding {
1002 #[serde(flatten)]
1004 pub dep: UnlistedDependency,
1005 pub actions: Vec<IssueAction>,
1008 #[serde(default, skip_serializing_if = "Option::is_none")]
1011 pub introduced: Option<AuditIntroduced>,
1012}
1013
1014impl UnlistedDependencyFinding {
1015 #[must_use]
1017 pub fn with_actions(dep: UnlistedDependency) -> Self {
1018 let actions = vec![
1019 IssueAction::Fix(FixAction {
1020 kind: FixActionType::InstallDependency,
1021 auto_fixable: false,
1022 description: "Add this package to dependencies in package.json".to_string(),
1023 note: Some(
1024 "Verify this package should be a direct dependency before adding".to_string(),
1025 ),
1026 available_in_catalogs: None,
1027 suggested_target: None,
1028 }),
1029 build_ignore_dependencies_suppress_action(&dep.package_name, "unlisted-dependency"),
1030 ];
1031 Self {
1032 dep,
1033 actions,
1034 introduced: None,
1035 }
1036 }
1037}
1038
1039#[derive(Debug, Clone, Serialize)]
1043#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1044pub struct TypeOnlyDependencyFinding {
1045 #[serde(flatten)]
1047 pub dep: TypeOnlyDependency,
1048 pub actions: Vec<IssueAction>,
1051 #[serde(default, skip_serializing_if = "Option::is_none")]
1054 pub introduced: Option<AuditIntroduced>,
1055}
1056
1057impl TypeOnlyDependencyFinding {
1058 #[must_use]
1060 pub fn with_actions(dep: TypeOnlyDependency) -> Self {
1061 let actions = vec![
1062 IssueAction::Fix(FixAction {
1063 kind: FixActionType::MoveToDev,
1064 auto_fixable: false,
1065 description: "Move to devDependencies (only type imports are used)".to_string(),
1066 note: Some(
1067 "Type imports are erased at runtime so this dependency is not needed in production"
1068 .to_string(),
1069 ),
1070 available_in_catalogs: None,
1071 suggested_target: None,
1072 }),
1073 build_ignore_dependencies_suppress_action(&dep.package_name, "type-only-dependency"),
1074 ];
1075 Self {
1076 dep,
1077 actions,
1078 introduced: None,
1079 }
1080 }
1081}
1082
1083#[derive(Debug, Clone, Serialize)]
1087#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1088pub struct TestOnlyDependencyFinding {
1089 #[serde(flatten)]
1091 pub dep: TestOnlyDependency,
1092 pub actions: Vec<IssueAction>,
1095 #[serde(default, skip_serializing_if = "Option::is_none")]
1098 pub introduced: Option<AuditIntroduced>,
1099}
1100
1101impl TestOnlyDependencyFinding {
1102 #[must_use]
1104 pub fn with_actions(dep: TestOnlyDependency) -> Self {
1105 let actions = vec![
1106 IssueAction::Fix(FixAction {
1107 kind: FixActionType::MoveToDev,
1108 auto_fixable: false,
1109 description: "Move to devDependencies (only test files import this)".to_string(),
1110 note: Some(
1111 "Only test files import this package so it does not need to be a production dependency"
1112 .to_string(),
1113 ),
1114 available_in_catalogs: None,
1115 suggested_target: None,
1116 }),
1117 build_ignore_dependencies_suppress_action(&dep.package_name, "test-only-dependency"),
1118 ];
1119 Self {
1120 dep,
1121 actions,
1122 introduced: None,
1123 }
1124 }
1125}
1126
1127#[derive(Debug, Clone, Serialize)]
1148#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1149pub struct DuplicateExportFinding {
1150 #[serde(flatten)]
1152 pub export: DuplicateExport,
1153 pub actions: Vec<IssueAction>,
1156 #[serde(default, skip_serializing_if = "Option::is_none")]
1159 pub introduced: Option<AuditIntroduced>,
1160}
1161
1162impl DuplicateExportFinding {
1163 #[must_use]
1172 pub fn with_actions(export: DuplicateExport) -> Self {
1173 let mut actions: Vec<IssueAction> = Vec::with_capacity(3);
1174
1175 if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
1176 actions.push(IssueAction::AddToConfig(AddToConfigAction {
1177 kind: AddToConfigKind::AddToConfig,
1178 auto_fixable: false,
1179 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(),
1180 config_key: "ignoreExports".to_string(),
1181 value: AddToConfigValue::ExportsRules(rules),
1182 value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
1183 }));
1184 }
1185
1186 actions.push(IssueAction::Fix(FixAction {
1187 kind: FixActionType::RemoveDuplicate,
1188 auto_fixable: false,
1189 description: "Keep one canonical export location and remove the others".to_string(),
1190 note: Some(NAMESPACE_BARREL_HINT.to_string()),
1191 available_in_catalogs: None,
1192 suggested_target: None,
1193 }));
1194
1195 actions.push(IssueAction::SuppressLine(SuppressLineAction {
1196 kind: SuppressLineKind::SuppressLine,
1197 auto_fixable: false,
1198 description: "Suppress with an inline comment above the line".to_string(),
1199 comment: "// fallow-ignore-next-line duplicate-export".to_string(),
1200 scope: Some(SuppressLineScope::PerLocation),
1201 }));
1202
1203 Self {
1204 export,
1205 actions,
1206 introduced: None,
1207 }
1208 }
1209
1210 pub fn set_config_fixable(&mut self, fixable: bool) {
1216 if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
1217 action.auto_fixable = fixable;
1218 }
1219 }
1220}
1221
1222fn build_duplicate_exports_ignore_rules(
1226 export: &DuplicateExport,
1227) -> Option<Vec<IgnoreExportsRule>> {
1228 let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
1229 for loc in &export.locations {
1230 let path = loc.path.to_string_lossy().replace('\\', "/");
1238 if path.is_empty() {
1239 continue;
1240 }
1241 if entries.iter().any(|existing| existing.file == path) {
1242 continue;
1243 }
1244 entries.push(IgnoreExportsRule {
1245 file: path,
1246 exports: vec!["*".to_string()],
1247 });
1248 }
1249 if entries.is_empty() {
1250 None
1251 } else {
1252 Some(entries)
1253 }
1254}
1255
1256#[derive(Debug, Clone, Serialize)]
1261#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1262pub struct UnusedCatalogEntryFinding {
1263 #[serde(flatten)]
1265 pub entry: UnusedCatalogEntry,
1266 pub actions: Vec<IssueAction>,
1268 #[serde(default, skip_serializing_if = "Option::is_none")]
1271 pub introduced: Option<AuditIntroduced>,
1272}
1273
1274impl UnusedCatalogEntryFinding {
1275 #[must_use]
1279 pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
1280 let auto_fixable = entry.hardcoded_consumers.is_empty();
1281 let actions = vec![
1282 IssueAction::Fix(FixAction {
1283 kind: FixActionType::RemoveCatalogEntry,
1284 auto_fixable,
1285 description: "Remove the entry from pnpm-workspace.yaml".to_string(),
1286 note: Some(
1287 "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing"
1288 .to_string(),
1289 ),
1290 available_in_catalogs: None,
1291 suggested_target: None,
1292 }),
1293 IssueAction::SuppressLine(SuppressLineAction {
1294 kind: SuppressLineKind::SuppressLine,
1295 auto_fixable: false,
1296 description: "Suppress with a YAML comment above the line".to_string(),
1297 comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
1298 scope: None,
1299 }),
1300 ];
1301 Self {
1302 entry,
1303 actions,
1304 introduced: None,
1305 }
1306 }
1307}
1308
1309#[derive(Debug, Clone, Serialize)]
1313#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1314pub struct EmptyCatalogGroupFinding {
1315 #[serde(flatten)]
1317 pub group: EmptyCatalogGroup,
1318 pub actions: Vec<IssueAction>,
1320 #[serde(default, skip_serializing_if = "Option::is_none")]
1323 pub introduced: Option<AuditIntroduced>,
1324}
1325
1326impl EmptyCatalogGroupFinding {
1327 #[must_use]
1329 pub fn with_actions(group: EmptyCatalogGroup) -> Self {
1330 let actions = vec![
1331 IssueAction::Fix(FixAction {
1332 kind: FixActionType::RemoveEmptyCatalogGroup,
1333 auto_fixable: true,
1334 description: "Remove the empty named catalog group from pnpm-workspace.yaml"
1335 .to_string(),
1336 note: Some(
1337 "Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
1338 .to_string(),
1339 ),
1340 available_in_catalogs: None,
1341 suggested_target: None,
1342 }),
1343 IssueAction::SuppressLine(SuppressLineAction {
1344 kind: SuppressLineKind::SuppressLine,
1345 auto_fixable: false,
1346 description: "Suppress with a YAML comment above the line".to_string(),
1347 comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
1348 scope: None,
1349 }),
1350 ];
1351 Self {
1352 group,
1353 actions,
1354 introduced: None,
1355 }
1356 }
1357}
1358
1359#[derive(Debug, Clone, Serialize)]
1367#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1368pub struct UnresolvedCatalogReferenceFinding {
1369 #[serde(flatten)]
1371 pub reference: UnresolvedCatalogReference,
1372 pub actions: Vec<IssueAction>,
1375 #[serde(default, skip_serializing_if = "Option::is_none")]
1378 pub introduced: Option<AuditIntroduced>,
1379}
1380
1381impl UnresolvedCatalogReferenceFinding {
1382 #[must_use]
1386 pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
1387 let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
1392 let primary = if reference.available_in_catalogs.is_empty() {
1393 IssueAction::Fix(FixAction {
1394 kind: FixActionType::AddCatalogEntry,
1395 auto_fixable: false,
1396 description: format!(
1397 "Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
1398 reference.entry_name, reference.catalog_name
1399 ),
1400 note: Some(
1401 "Pin a version that satisfies the consumer's import; no other catalog declares this package today"
1402 .to_string(),
1403 ),
1404 available_in_catalogs: None,
1405 suggested_target: None,
1406 })
1407 } else {
1408 let available = reference.available_in_catalogs.clone();
1409 let suggested_target = (available.len() == 1).then(|| available[0].clone());
1410 IssueAction::Fix(FixAction {
1411 kind: FixActionType::UpdateCatalogReference,
1412 auto_fixable: false,
1413 description: format!(
1414 "Switch the reference from `catalog:{}` to a catalog that declares `{}`",
1415 reference.catalog_name, reference.entry_name
1416 ),
1417 note: None,
1418 available_in_catalogs: Some(available),
1419 suggested_target,
1420 })
1421 };
1422
1423 let fallback = IssueAction::Fix(FixAction {
1424 kind: FixActionType::RemoveCatalogReference,
1425 auto_fixable: false,
1426 description:
1427 "Remove the catalog reference and pin a hardcoded version in package.json"
1428 .to_string(),
1429 note: Some(
1430 "Use only when neither another catalog declares the package nor the named catalog should grow to include it"
1431 .to_string(),
1432 ),
1433 available_in_catalogs: None,
1434 suggested_target: None,
1435 });
1436
1437 let mut suppress_value = serde_json::Map::new();
1438 suppress_value.insert(
1439 "package".to_string(),
1440 serde_json::Value::String(reference.entry_name.clone()),
1441 );
1442 suppress_value.insert(
1443 "catalog".to_string(),
1444 serde_json::Value::String(reference.catalog_name.clone()),
1445 );
1446 suppress_value.insert(
1447 "consumer".to_string(),
1448 serde_json::Value::String(consumer_path),
1449 );
1450 let suppress = IssueAction::AddToConfig(AddToConfigAction {
1451 kind: AddToConfigKind::AddToConfig,
1452 auto_fixable: false,
1453 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(),
1454 config_key: "ignoreCatalogReferences".to_string(),
1455 value: AddToConfigValue::RuleObject(suppress_value),
1456 value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
1457 });
1458
1459 Self {
1460 reference,
1461 actions: vec![primary, fallback, suppress],
1462 introduced: None,
1463 }
1464 }
1465}
1466
1467#[derive(Debug, Clone, Serialize)]
1472#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1473pub struct UnusedDependencyOverrideFinding {
1474 #[serde(flatten)]
1476 pub entry: UnusedDependencyOverride,
1477 pub actions: Vec<IssueAction>,
1479 #[serde(default, skip_serializing_if = "Option::is_none")]
1482 pub introduced: Option<AuditIntroduced>,
1483}
1484
1485impl UnusedDependencyOverrideFinding {
1486 #[must_use]
1488 pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
1489 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
1490 actions.push(IssueAction::Fix(FixAction {
1491 kind: FixActionType::RemoveDependencyOverride,
1492 auto_fixable: false,
1493 description: "Remove the override entry from pnpm-workspace.yaml or pnpm.overrides"
1494 .to_string(),
1495 note: Some(
1496 "Conservative static check; verify against `pnpm install --frozen-lockfile` before removing in case the override targets a transitive dependency (CVE-fix pattern)"
1497 .to_string(),
1498 ),
1499 available_in_catalogs: None,
1500 suggested_target: None,
1501 }));
1502
1503 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
1504 Some(&entry.target_package),
1505 &entry.raw_key,
1506 entry.source,
1507 ) {
1508 actions.push(suppress);
1509 }
1510
1511 Self {
1512 entry,
1513 actions,
1514 introduced: None,
1515 }
1516 }
1517}
1518
1519#[derive(Debug, Clone, Serialize)]
1525#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1526pub struct MisconfiguredDependencyOverrideFinding {
1527 #[serde(flatten)]
1529 pub entry: MisconfiguredDependencyOverride,
1530 pub actions: Vec<IssueAction>,
1532 #[serde(default, skip_serializing_if = "Option::is_none")]
1535 pub introduced: Option<AuditIntroduced>,
1536}
1537
1538impl MisconfiguredDependencyOverrideFinding {
1539 #[must_use]
1544 pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
1545 let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
1546 actions.push(IssueAction::Fix(FixAction {
1547 kind: FixActionType::FixDependencyOverride,
1548 auto_fixable: false,
1549 description:
1550 "Fix the override key or value: pnpm refuses to honor entries with an unparsable key or empty value"
1551 .to_string(),
1552 note: Some(
1553 "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`."
1554 .to_string(),
1555 ),
1556 available_in_catalogs: None,
1557 suggested_target: None,
1558 }));
1559
1560 if let Some(suppress) = build_ignore_dependency_overrides_suppress(
1561 entry.target_package.as_deref(),
1562 &entry.raw_key,
1563 entry.source,
1564 ) {
1565 actions.push(suppress);
1566 }
1567
1568 Self {
1569 entry,
1570 actions,
1571 introduced: None,
1572 }
1573 }
1574}
1575
1576fn build_ignore_dependency_overrides_suppress(
1581 target_package: Option<&str>,
1582 raw_key: &str,
1583 source: DependencyOverrideSource,
1584) -> Option<IssueAction> {
1585 let package = target_package
1586 .filter(|s| !s.is_empty())
1587 .or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
1588 .to_string();
1589 let mut value = serde_json::Map::new();
1590 value.insert("package".to_string(), serde_json::Value::String(package));
1591 value.insert(
1592 "source".to_string(),
1593 serde_json::Value::String(source.as_label().to_string()),
1594 );
1595 Some(IssueAction::AddToConfig(AddToConfigAction {
1596 kind: AddToConfigKind::AddToConfig,
1597 auto_fixable: false,
1598 description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
1599 config_key: "ignoreDependencyOverrides".to_string(),
1600 value: AddToConfigValue::RuleObject(value),
1601 value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
1602 }))
1603}
1604
1605#[cfg(test)]
1615mod position_0_invariants {
1616 use super::*;
1617 use crate::output::FixActionType;
1618 use crate::results::{DependencyOverrideSource, DuplicateLocation};
1619 use std::path::PathBuf;
1620
1621 fn action_type(action: &IssueAction) -> &'static str {
1626 match action {
1627 IssueAction::Fix(fix) => match fix.kind {
1628 FixActionType::RemoveExport => "remove-export",
1629 FixActionType::DeleteFile => "delete-file",
1630 FixActionType::RemoveDependency => "remove-dependency",
1631 FixActionType::MoveDependency => "move-dependency",
1632 FixActionType::RemoveEnumMember => "remove-enum-member",
1633 FixActionType::RemoveClassMember => "remove-class-member",
1634 FixActionType::ResolveImport => "resolve-import",
1635 FixActionType::InstallDependency => "install-dependency",
1636 FixActionType::RemoveDuplicate => "remove-duplicate",
1637 FixActionType::MoveToDev => "move-to-dev",
1638 FixActionType::RefactorCycle => "refactor-cycle",
1639 FixActionType::RefactorReExportCycle => "refactor-re-export-cycle",
1640 FixActionType::RefactorBoundary => "refactor-boundary",
1641 FixActionType::ExportType => "export-type",
1642 FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
1643 FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
1644 FixActionType::UpdateCatalogReference => "update-catalog-reference",
1645 FixActionType::AddCatalogEntry => "add-catalog-entry",
1646 FixActionType::RemoveCatalogReference => "remove-catalog-reference",
1647 FixActionType::RemoveDependencyOverride => "remove-dependency-override",
1648 FixActionType::FixDependencyOverride => "fix-dependency-override",
1649 FixActionType::ResolvePolicyViolation => "resolve-policy-violation",
1650 },
1651 IssueAction::SuppressLine(_) => "suppress-line",
1652 IssueAction::SuppressFile(_) => "suppress-file",
1653 IssueAction::AddToConfig(_) => "add-to-config",
1654 }
1655 }
1656
1657 #[test]
1658 fn unresolved_import_actions_include_ignore_unresolved_imports_config_suppress() {
1659 let inner = UnresolvedImport {
1660 specifier: "@example/icons".to_string(),
1661 path: PathBuf::from("src/index.ts"),
1662 line: 4,
1663 col: 12,
1664 specifier_col: 18,
1665 };
1666 let finding = UnresolvedImportFinding::with_actions(inner);
1667
1668 assert_eq!(action_type(&finding.actions[0]), "resolve-import");
1669 assert_eq!(action_type(&finding.actions[1]), "add-to-config");
1670 let IssueAction::AddToConfig(action) = &finding.actions[1] else {
1671 panic!("position-1 should be AddToConfig");
1672 };
1673 assert!(!action.auto_fixable);
1674 assert_eq!(action.config_key, "ignoreUnresolvedImports");
1675 let AddToConfigValue::Scalar(value) = &action.value else {
1676 panic!("ignoreUnresolvedImports action should carry a scalar value");
1677 };
1678 assert_eq!(value, "@example/icons");
1679 assert_eq!(
1680 action.value_schema.as_deref(),
1681 Some(
1682 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
1683 )
1684 );
1685 }
1686
1687 #[test]
1698 fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
1699 let inner = UnresolvedCatalogReference {
1700 entry_name: "react".to_string(),
1701 catalog_name: "default".to_string(),
1702 path: PathBuf::from("apps/web/package.json"),
1703 line: 7,
1704 available_in_catalogs: Vec::new(),
1705 };
1706 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
1707 assert_eq!(
1708 action_type(&finding.actions[0]),
1709 "add-catalog-entry",
1710 "position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
1711 );
1712 let IssueAction::Fix(fix) = &finding.actions[0] else {
1713 panic!("position-0 should be an IssueAction::Fix");
1714 };
1715 assert!(
1716 fix.available_in_catalogs.is_none(),
1717 "add-catalog-entry must NOT carry available_in_catalogs"
1718 );
1719 assert!(
1720 fix.suggested_target.is_none(),
1721 "add-catalog-entry must NOT carry suggested_target"
1722 );
1723 }
1724
1725 #[test]
1732 fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
1733 let inner = UnresolvedCatalogReference {
1734 entry_name: "react".to_string(),
1735 catalog_name: "default".to_string(),
1736 path: PathBuf::from("apps/web/package.json"),
1737 line: 7,
1738 available_in_catalogs: vec!["react18".to_string()],
1739 };
1740 let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
1741 assert_eq!(
1742 action_type(&finding.actions[0]),
1743 "update-catalog-reference",
1744 "position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
1745 );
1746 let IssueAction::Fix(fix) = &finding.actions[0] else {
1747 panic!("position-0 should be an IssueAction::Fix");
1748 };
1749 assert_eq!(
1750 fix.available_in_catalogs.as_deref(),
1751 Some(&["react18".to_string()][..]),
1752 "update-catalog-reference must carry the alternative list"
1753 );
1754 assert_eq!(
1755 fix.suggested_target.as_deref(),
1756 Some("react18"),
1757 "single-alternative case must surface `suggested_target` for deterministic agents"
1758 );
1759
1760 let inner_two = UnresolvedCatalogReference {
1762 entry_name: "react".to_string(),
1763 catalog_name: "default".to_string(),
1764 path: PathBuf::from("apps/web/package.json"),
1765 line: 7,
1766 available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
1767 };
1768 let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
1769 assert_eq!(
1770 action_type(&finding_two.actions[0]),
1771 "update-catalog-reference"
1772 );
1773 let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
1774 panic!("position-0 should be an IssueAction::Fix");
1775 };
1776 assert!(
1777 fix_two.suggested_target.is_none(),
1778 "multi-alternative case must NOT carry `suggested_target` (agent must pick)"
1779 );
1780 }
1781
1782 #[test]
1797 fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
1798 let inner = DuplicateExport {
1799 export_name: "Root".to_string(),
1800 locations: vec![
1801 DuplicateLocation {
1802 path: PathBuf::from("components/ui/accordion/index.ts"),
1803 line: 1,
1804 col: 0,
1805 },
1806 DuplicateLocation {
1807 path: PathBuf::from("components/ui/dialog/index.ts"),
1808 line: 1,
1809 col: 0,
1810 },
1811 ],
1812 };
1813 let finding = DuplicateExportFinding::with_actions(inner);
1814 assert_eq!(
1815 action_type(&finding.actions[0]),
1816 "add-to-config",
1817 "position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
1818 );
1819 assert_eq!(
1820 action_type(&finding.actions[1]),
1821 "remove-duplicate",
1822 "position-1 must be the destructive `remove-duplicate` fallback"
1823 );
1824
1825 let mut promoted = finding;
1828 promoted.set_config_fixable(true);
1829 assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
1830 let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
1831 panic!("position-0 should still be AddToConfig after set_config_fixable");
1832 };
1833 assert!(
1834 action.auto_fixable,
1835 "set_config_fixable(true) must flip auto_fixable"
1836 );
1837 }
1838
1839 #[test]
1844 fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
1845 let inner = DuplicateExport {
1846 export_name: "Root".to_string(),
1847 locations: Vec::new(),
1848 };
1849 let finding = DuplicateExportFinding::with_actions(inner);
1850 assert_eq!(
1851 action_type(&finding.actions[0]),
1852 "remove-duplicate",
1853 "with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
1854 );
1855
1856 let mut promoted = finding;
1858 promoted.set_config_fixable(true);
1859 assert_eq!(
1860 action_type(&promoted.actions[0]),
1861 "remove-duplicate",
1862 "set_config_fixable is a no-op when position-0 is not add-to-config"
1863 );
1864 }
1865
1866 #[test]
1872 fn misconfigured_override_drops_suppress_when_no_package_name() {
1873 let inner = MisconfiguredDependencyOverride {
1874 raw_key: String::new(),
1875 target_package: None,
1876 raw_value: String::new(),
1877 reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
1878 source: DependencyOverrideSource::PnpmWorkspaceYaml,
1879 path: PathBuf::from("pnpm-workspace.yaml"),
1880 line: 12,
1881 };
1882 let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
1883 assert_eq!(finding.actions.len(), 1);
1885 assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
1886 }
1887}