Skip to main content

fallow_types/
output_dead_code.rs

1//! Typed envelope wrappers for the simple 1:1 dead-code findings whose
2//! actions are entirely determined by the wrapper type (no per-instance
3//! discriminants beyond what the bare finding already exposes).
4//!
5//! Each wrapper flattens the bare finding via `#[serde(flatten)]` so the
6//! wire shape matches the previous `actions`-grafted output byte-for-byte.
7//! `actions` is populated at construction time via each wrapper's
8//! `with_actions` constructor and replaces the per-finding `inject_actions`
9//! post-pass in `crates/cli/src/report/json.rs`. `introduced` carries the optional audit
10//! breadcrumb that `crates/cli/src/audit.rs::annotate_issue_array` inserts
11//! into the JSON object via `map.insert`; the wrapper-level field stays
12//! `None` when serialized directly from Rust and is set by the audit pass
13//! only when the issue was introduced relative to the merge-base.
14//!
15//! All nine wrappers ship with `IssueAction` arrays today; they pay the
16//! `serde_json` dependency cost because `IssueAction` transitively
17//! references `AddToConfigValue::RuleObject(serde_json::Map<...>)`. The
18//! variants the wrappers actually emit (`Fix`, `SuppressLine`,
19//! `SuppressFile`) are small, but reusing the existing enum keeps the
20//! wire-shape contract identical to the legacy post-pass.
21//!
22//! `introduced` is typed as `Option<AuditIntroduced>` (transparent newtype
23//! over `bool`) so the regenerated schema renders the field via
24//! `$ref: #/definitions/AuditIntroduced`, matching the reference the prior
25//! post-pass augmentation graft used. The audit pass continues to inject a
26//! bare bool via `map.insert("introduced", ...)`; serde reads it back into
27//! `AuditIntroduced` transparently. The field stays absent at the wire when
28//! `None` (`skip_serializing_if`).
29
30use serde::Serialize;
31
32use crate::envelope::AuditIntroduced;
33use crate::output::{
34    AddToConfigAction, AddToConfigKind, AddToConfigValue, FixAction, FixActionType,
35    IgnoreExportsRule, IssueAction, SuppressFileAction, SuppressFileKind, SuppressLineAction,
36    SuppressLineKind, SuppressLineScope,
37};
38use crate::results::{
39    BoundaryViolation, CircularDependency, DependencyOverrideSource, DuplicateExport,
40    EmptyCatalogGroup, MisconfiguredDependencyOverride, PrivateTypeLeak, TestOnlyDependency,
41    TypeOnlyDependency, UnlistedDependency, UnresolvedCatalogReference, UnresolvedImport,
42    UnusedCatalogEntry, UnusedDependency, UnusedDependencyOverride, UnusedExport, UnusedFile,
43    UnusedMember,
44};
45
46/// Shared note for the `duplicate-exports` fix action. Mirrors the const used
47/// by the human report (see `crates/cli/src/report/shared.rs`); kept here so
48/// the wire-format builder reads from the same source of truth.
49pub const NAMESPACE_BARREL_HINT: &str = "If every location is the sole `index.*` of its directory, this is likely an intentional namespace-barrel API. Prefer adding these files to `ignoreExports` over removing exports.";
50
51/// JSON Schema fragment URL for the `add-to-config` `ignoreExports` action's
52/// `value` payload. Pinned to the main branch so users browsing the action
53/// value can navigate directly to the rule shape.
54const IGNORE_EXPORTS_VALUE_SCHEMA: &str =
55    "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreExports";
56
57/// JSON Schema fragment URL for the `ignoreCatalogReferences` rule items
58/// referenced by `add-to-config` actions on `unresolved-catalog-references`.
59const IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreCatalogReferences/items";
60
61/// JSON Schema fragment URL for the `ignoreDependencyOverrides` rule items
62/// referenced by `add-to-config` actions on both the unused- and
63/// misconfigured-override findings.
64const IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencyOverrides/items";
65
66/// Wire-shape envelope for an [`UnusedFile`] finding. The bare finding
67/// flattens in via `#[serde(flatten)]`, with a typed `actions` array
68/// populated at construction time and the audit-pass `introduced` flag
69/// attached as an optional sibling.
70#[derive(Debug, Clone, Serialize)]
71#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
72pub struct UnusedFileFinding {
73    /// The underlying dead-code entry.
74    #[serde(flatten)]
75    pub file: UnusedFile,
76    /// Suggested next steps: a `delete-file` primary and a `suppress-file`
77    /// secondary. Always emitted (possibly empty for forward-compat).
78    pub actions: Vec<IssueAction>,
79    /// Set by the audit pass when this finding is introduced relative to
80    /// the merge-base. `None` when serialized directly from Rust.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub introduced: Option<AuditIntroduced>,
83}
84
85impl UnusedFileFinding {
86    /// Build the wrapper from a raw [`UnusedFile`], computing the typed
87    /// `actions` array inline. `introduced` stays `None` and is set later
88    /// by `annotate_dead_code_json` if the audit pass runs.
89    #[must_use]
90    pub fn with_actions(file: UnusedFile) -> Self {
91        let actions = vec![
92            IssueAction::Fix(FixAction {
93                kind: FixActionType::DeleteFile,
94                auto_fixable: false,
95                description: "Delete this file".to_string(),
96                note: Some(
97                    "File deletion may remove runtime functionality not visible to static analysis"
98                        .to_string(),
99                ),
100                available_in_catalogs: None,
101                suggested_target: None,
102            }),
103            IssueAction::SuppressFile(SuppressFileAction {
104                kind: SuppressFileKind::SuppressFile,
105                auto_fixable: false,
106                description: "Suppress with a file-level comment at the top of the file"
107                    .to_string(),
108                comment: "// fallow-ignore-file unused-file".to_string(),
109            }),
110        ];
111        Self {
112            file,
113            actions,
114            introduced: None,
115        }
116    }
117}
118
119/// Wire-shape envelope for a [`PrivateTypeLeak`] finding. Mirrors
120/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
121/// `actions` array (`export-type` primary plus `suppress-line` secondary).
122#[derive(Debug, Clone, Serialize)]
123#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
124pub struct PrivateTypeLeakFinding {
125    /// The underlying dead-code entry.
126    #[serde(flatten)]
127    pub leak: PrivateTypeLeak,
128    /// Suggested next steps. Always emitted (possibly empty for
129    /// forward-compat).
130    pub actions: Vec<IssueAction>,
131    /// Set by the audit pass when this finding is introduced relative to
132    /// the merge-base.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub introduced: Option<AuditIntroduced>,
135}
136
137impl PrivateTypeLeakFinding {
138    /// Build the wrapper from a raw [`PrivateTypeLeak`].
139    #[must_use]
140    pub fn with_actions(leak: PrivateTypeLeak) -> Self {
141        let actions = vec![
142            IssueAction::Fix(FixAction {
143                kind: FixActionType::ExportType,
144                auto_fixable: false,
145                description: "Export the referenced private type by name".to_string(),
146                note: Some(
147                    "Keep the type exported while it is part of a public signature".to_string(),
148                ),
149                available_in_catalogs: None,
150                suggested_target: None,
151            }),
152            IssueAction::SuppressLine(SuppressLineAction {
153                kind: SuppressLineKind::SuppressLine,
154                auto_fixable: false,
155                description: "Suppress with an inline comment above the line".to_string(),
156                comment: "// fallow-ignore-next-line private-type-leak".to_string(),
157                scope: None,
158            }),
159        ];
160        Self {
161            leak,
162            actions,
163            introduced: None,
164        }
165    }
166}
167
168/// Wire-shape envelope for an [`UnresolvedImport`] finding. Mirrors
169/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
170/// `actions` array (`resolve-import` primary plus `suppress-line`
171/// secondary).
172#[derive(Debug, Clone, Serialize)]
173#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
174pub struct UnresolvedImportFinding {
175    /// The underlying dead-code entry.
176    #[serde(flatten)]
177    pub import: UnresolvedImport,
178    /// Suggested next steps. Always emitted (possibly empty for
179    /// forward-compat).
180    pub actions: Vec<IssueAction>,
181    /// Set by the audit pass when this finding is introduced relative to
182    /// the merge-base.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub introduced: Option<AuditIntroduced>,
185}
186
187impl UnresolvedImportFinding {
188    /// Build the wrapper from a raw [`UnresolvedImport`].
189    #[must_use]
190    pub fn with_actions(import: UnresolvedImport) -> Self {
191        let actions = vec![
192            IssueAction::Fix(FixAction {
193                kind: FixActionType::ResolveImport,
194                auto_fixable: false,
195                description: "Fix the import specifier or install the missing module".to_string(),
196                note: Some(
197                    "Verify the module path and check tsconfig paths configuration".to_string(),
198                ),
199                available_in_catalogs: None,
200                suggested_target: None,
201            }),
202            IssueAction::SuppressLine(SuppressLineAction {
203                kind: SuppressLineKind::SuppressLine,
204                auto_fixable: false,
205                description: "Suppress with an inline comment above the line".to_string(),
206                comment: "// fallow-ignore-next-line unresolved-import".to_string(),
207                scope: None,
208            }),
209        ];
210        Self {
211            import,
212            actions,
213            introduced: None,
214        }
215    }
216}
217
218/// Wire-shape envelope for a [`CircularDependency`] finding. Mirrors
219/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
220/// `actions` array (`refactor-cycle` primary plus `suppress-line`
221/// secondary).
222#[derive(Debug, Clone, Serialize)]
223#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
224pub struct CircularDependencyFinding {
225    /// The underlying dead-code entry.
226    #[serde(flatten)]
227    pub cycle: CircularDependency,
228    /// Suggested next steps. Always emitted (possibly empty for
229    /// forward-compat).
230    pub actions: Vec<IssueAction>,
231    /// Set by the audit pass when this finding is introduced relative to
232    /// the merge-base.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub introduced: Option<AuditIntroduced>,
235}
236
237impl CircularDependencyFinding {
238    /// Build the wrapper from a raw [`CircularDependency`].
239    #[must_use]
240    pub fn with_actions(cycle: CircularDependency) -> Self {
241        let actions = vec![
242            IssueAction::Fix(FixAction {
243                kind: FixActionType::RefactorCycle,
244                auto_fixable: false,
245                description: "Extract shared logic into a separate module to break the cycle"
246                    .to_string(),
247                note: Some(
248                    "Circular imports can cause initialization issues and make code harder to reason about"
249                        .to_string(),
250                ),
251                available_in_catalogs: None,
252                suggested_target: None,
253            }),
254            IssueAction::SuppressLine(SuppressLineAction {
255                kind: SuppressLineKind::SuppressLine,
256                auto_fixable: false,
257                description: "Suppress with an inline comment above the line".to_string(),
258                comment: "// fallow-ignore-next-line circular-dependency".to_string(),
259                scope: None,
260            }),
261        ];
262        Self {
263            cycle,
264            actions,
265            introduced: None,
266        }
267    }
268}
269
270/// Wire-shape envelope for a [`BoundaryViolation`] finding. Mirrors
271/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
272/// `actions` array (`refactor-boundary` primary plus `suppress-line`
273/// secondary).
274#[derive(Debug, Clone, Serialize)]
275#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
276pub struct BoundaryViolationFinding {
277    /// The underlying dead-code entry.
278    #[serde(flatten)]
279    pub violation: BoundaryViolation,
280    /// Suggested next steps. Always emitted (possibly empty for
281    /// forward-compat).
282    pub actions: Vec<IssueAction>,
283    /// Set by the audit pass when this finding is introduced relative to
284    /// the merge-base.
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub introduced: Option<AuditIntroduced>,
287}
288
289impl BoundaryViolationFinding {
290    /// Build the wrapper from a raw [`BoundaryViolation`].
291    #[must_use]
292    pub fn with_actions(violation: BoundaryViolation) -> Self {
293        let actions = vec![
294            IssueAction::Fix(FixAction {
295                kind: FixActionType::RefactorBoundary,
296                auto_fixable: false,
297                description: "Move the import through an allowed zone or restructure the dependency"
298                    .to_string(),
299                note: Some(
300                    "This import crosses an architecture boundary that is not permitted by the configured rules"
301                        .to_string(),
302                ),
303                available_in_catalogs: None,
304                suggested_target: None,
305            }),
306            IssueAction::SuppressLine(SuppressLineAction {
307                kind: SuppressLineKind::SuppressLine,
308                auto_fixable: false,
309                description: "Suppress with an inline comment above the line".to_string(),
310                comment: "// fallow-ignore-next-line boundary-violation".to_string(),
311                scope: None,
312            }),
313        ];
314        Self {
315            violation,
316            actions,
317            introduced: None,
318        }
319    }
320}
321
322/// Wire-shape envelope for an [`UnusedExport`] finding consumed under the
323/// `unused_exports` key. Same Rust struct as [`UnusedTypeFinding`], with a
324/// different fix description so consumers can tell value-export from
325/// type-export removal at the action level.
326#[derive(Debug, Clone, Serialize)]
327#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
328pub struct UnusedExportFinding {
329    /// The underlying dead-code entry.
330    #[serde(flatten)]
331    pub export: UnusedExport,
332    /// Suggested next steps. Always emitted (possibly empty for
333    /// forward-compat).
334    pub actions: Vec<IssueAction>,
335    /// Set by the audit pass when this finding is introduced relative to
336    /// the merge-base.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub introduced: Option<AuditIntroduced>,
339}
340
341impl UnusedExportFinding {
342    /// Build the wrapper. When `export.is_re_export` is true, the fix
343    /// action's `note` warns about possible public-API surface; otherwise
344    /// `note` is absent on the fix action.
345    #[must_use]
346    pub fn with_actions(export: UnusedExport) -> Self {
347        let note = if export.is_re_export {
348            Some(
349                "This finding originates from a re-export; verify it is not part of your public API before removing"
350                    .to_string(),
351            )
352        } else {
353            None
354        };
355        let actions = vec![
356            IssueAction::Fix(FixAction {
357                kind: FixActionType::RemoveExport,
358                auto_fixable: true,
359                description: "Remove the unused export from the public API".to_string(),
360                note,
361                available_in_catalogs: None,
362                suggested_target: None,
363            }),
364            IssueAction::SuppressLine(SuppressLineAction {
365                kind: SuppressLineKind::SuppressLine,
366                auto_fixable: false,
367                description: "Suppress with an inline comment above the line".to_string(),
368                comment: "// fallow-ignore-next-line unused-export".to_string(),
369                scope: None,
370            }),
371        ];
372        Self {
373            export,
374            actions,
375            introduced: None,
376        }
377    }
378}
379
380/// Wire-shape envelope for an [`UnusedExport`] finding consumed under the
381/// `unused_types` key. Wraps the same bare [`UnusedExport`] struct as
382/// [`UnusedExportFinding`] but emits a fix action targeted at type-only
383/// declarations, with the same `is_re_export`-aware note swap.
384#[derive(Debug, Clone, Serialize)]
385#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
386pub struct UnusedTypeFinding {
387    /// The underlying dead-code entry.
388    #[serde(flatten)]
389    pub export: UnusedExport,
390    /// Suggested next steps. Always emitted (possibly empty for
391    /// forward-compat).
392    pub actions: Vec<IssueAction>,
393    /// Set by the audit pass when this finding is introduced relative to
394    /// the merge-base.
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub introduced: Option<AuditIntroduced>,
397}
398
399impl UnusedTypeFinding {
400    /// Build the wrapper. `is_re_export` swaps the fix note the same way as
401    /// [`UnusedExportFinding::with_actions`].
402    #[must_use]
403    pub fn with_actions(export: UnusedExport) -> Self {
404        let note = if export.is_re_export {
405            Some(
406                "This finding originates from a re-export; verify it is not part of your public API before removing"
407                    .to_string(),
408            )
409        } else {
410            None
411        };
412        let actions = vec![
413            IssueAction::Fix(FixAction {
414                kind: FixActionType::RemoveExport,
415                auto_fixable: true,
416                description:
417                    "Remove the `export` (or `export type`) keyword from the type declaration"
418                        .to_string(),
419                note,
420                available_in_catalogs: None,
421                suggested_target: None,
422            }),
423            IssueAction::SuppressLine(SuppressLineAction {
424                kind: SuppressLineKind::SuppressLine,
425                auto_fixable: false,
426                description: "Suppress with an inline comment above the line".to_string(),
427                comment: "// fallow-ignore-next-line unused-type".to_string(),
428                scope: None,
429            }),
430        ];
431        Self {
432            export,
433            actions,
434            introduced: None,
435        }
436    }
437}
438
439/// Wire-shape envelope for an [`UnusedMember`] finding consumed under the
440/// `unused_enum_members` key.
441#[derive(Debug, Clone, Serialize)]
442#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
443pub struct UnusedEnumMemberFinding {
444    /// The underlying dead-code entry.
445    #[serde(flatten)]
446    pub member: UnusedMember,
447    /// Suggested next steps. Always emitted (possibly empty for
448    /// forward-compat).
449    pub actions: Vec<IssueAction>,
450    /// Set by the audit pass when this finding is introduced relative to
451    /// the merge-base.
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub introduced: Option<AuditIntroduced>,
454}
455
456impl UnusedEnumMemberFinding {
457    /// Build the wrapper from a raw [`UnusedMember`].
458    #[must_use]
459    pub fn with_actions(member: UnusedMember) -> Self {
460        let actions = vec![
461            IssueAction::Fix(FixAction {
462                kind: FixActionType::RemoveEnumMember,
463                auto_fixable: true,
464                description: "Remove this enum member".to_string(),
465                note: None,
466                available_in_catalogs: None,
467                suggested_target: None,
468            }),
469            IssueAction::SuppressLine(SuppressLineAction {
470                kind: SuppressLineKind::SuppressLine,
471                auto_fixable: false,
472                description: "Suppress with an inline comment above the line".to_string(),
473                comment: "// fallow-ignore-next-line unused-enum-member".to_string(),
474                scope: None,
475            }),
476        ];
477        Self {
478            member,
479            actions,
480            introduced: None,
481        }
482    }
483}
484
485/// Wire-shape envelope for an [`UnusedMember`] finding consumed under the
486/// `unused_class_members` key. Same Rust struct as
487/// [`UnusedEnumMemberFinding`]; the fix action and suppress comment carry
488/// the class-member kebab-case identifier instead.
489#[derive(Debug, Clone, Serialize)]
490#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
491pub struct UnusedClassMemberFinding {
492    /// The underlying dead-code entry.
493    #[serde(flatten)]
494    pub member: UnusedMember,
495    /// Suggested next steps. Always emitted (possibly empty for
496    /// forward-compat).
497    pub actions: Vec<IssueAction>,
498    /// Set by the audit pass when this finding is introduced relative to
499    /// the merge-base.
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub introduced: Option<AuditIntroduced>,
502}
503
504impl UnusedClassMemberFinding {
505    /// Build the wrapper from a raw [`UnusedMember`]. Class-member fixes
506    /// are not auto-applied (members can be used via dependency injection
507    /// or decorators), so `auto_fixable` is `false` and a context note is
508    /// attached.
509    #[must_use]
510    pub fn with_actions(member: UnusedMember) -> Self {
511        let actions = vec![
512            IssueAction::Fix(FixAction {
513                kind: FixActionType::RemoveClassMember,
514                auto_fixable: false,
515                description: "Remove this class member".to_string(),
516                note: Some(
517                    "Class member may be used via dependency injection or decorators".to_string(),
518                ),
519                available_in_catalogs: None,
520                suggested_target: None,
521            }),
522            IssueAction::SuppressLine(SuppressLineAction {
523                kind: SuppressLineKind::SuppressLine,
524                auto_fixable: false,
525                description: "Suppress with an inline comment above the line".to_string(),
526                comment: "// fallow-ignore-next-line unused-class-member".to_string(),
527                scope: None,
528            }),
529        ];
530        Self {
531            member,
532            actions,
533            introduced: None,
534        }
535    }
536}
537
538/// Build the `IssueAction` vec for the three `unused_dependencies`,
539/// `unused_dev_dependencies`, `unused_optional_dependencies` views over the
540/// same bare [`UnusedDependency`] struct. Each wrapper differs only in the
541/// `package_json_location` string (`"dependencies"` / `"devDependencies"` /
542/// `"optionalDependencies"`) baked into the fix-action description and in
543/// the `suppress_issue_kind` used by the inline-suppress comment. All three
544/// share the cross-workspace swap (when `dep.used_in_workspaces` is
545/// non-empty the primary fix flips from `remove-dependency` to
546/// `move-dependency` because the dep is imported by ANOTHER workspace and
547/// `fallow fix` cannot safely remove it).
548fn build_unused_dependency_actions(
549    dep: &UnusedDependency,
550    package_json_location: &str,
551    suppress_issue_kind: &str,
552) -> Vec<IssueAction> {
553    let mut actions = Vec::with_capacity(2);
554    let cross_workspace = !dep.used_in_workspaces.is_empty();
555    actions.push(if cross_workspace {
556        IssueAction::Fix(FixAction {
557            kind: FixActionType::MoveDependency,
558            auto_fixable: false,
559            description: "Move this dependency to the workspace package.json that imports it"
560                .to_string(),
561            note: Some(
562                "fallow fix will not remove dependencies that are imported by another workspace"
563                    .to_string(),
564            ),
565            available_in_catalogs: None,
566            suggested_target: None,
567        })
568    } else {
569        IssueAction::Fix(FixAction {
570            kind: FixActionType::RemoveDependency,
571            auto_fixable: true,
572            description: format!("Remove from {package_json_location} in package.json"),
573            note: None,
574            available_in_catalogs: None,
575            suggested_target: None,
576        })
577    });
578    actions.push(build_ignore_dependencies_suppress_action(
579        &dep.package_name,
580        suppress_issue_kind,
581    ));
582    actions
583}
584
585/// Build the standard `add-to-config` `ignoreDependencies` suppress action
586/// for any finding whose primary key is a package name. Used by the four
587/// dependency-family wrappers (unused / unlisted / type-only / test-only).
588/// The `_suppress_issue_kind` argument is currently unused; the pre-2.76
589/// `inject_actions` post-pass also did not embed the issue kind in this
590/// shape (no inline `// fallow-ignore-next-line ...` comment because the
591/// finding is anchored at a package.json line, not at a source-file line).
592fn build_ignore_dependencies_suppress_action(
593    package_name: &str,
594    _suppress_issue_kind: &str,
595) -> IssueAction {
596    IssueAction::AddToConfig(AddToConfigAction {
597        kind: AddToConfigKind::AddToConfig,
598        auto_fixable: false,
599        description: format!("Add \"{package_name}\" to ignoreDependencies in fallow config"),
600        config_key: "ignoreDependencies".to_string(),
601        value: AddToConfigValue::Scalar(package_name.to_string()),
602        value_schema: Some(
603            "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items"
604                .to_string(),
605        ),
606    })
607}
608
609/// Wire-shape envelope for an [`UnusedDependency`] finding consumed under
610/// the `unused_dependencies` key (production deps). Flattens the bare
611/// finding; the typed `actions` array carries either a `remove-dependency`
612/// or `move-dependency` primary depending on
613/// `inner.used_in_workspaces`.
614#[derive(Debug, Clone, Serialize)]
615#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
616pub struct UnusedDependencyFinding {
617    /// The underlying dead-code entry.
618    #[serde(flatten)]
619    pub dep: UnusedDependency,
620    /// Suggested next steps. Always emitted (possibly empty for
621    /// forward-compat).
622    pub actions: Vec<IssueAction>,
623    /// Set by the audit pass when this finding is introduced relative to
624    /// the merge-base.
625    #[serde(default, skip_serializing_if = "Option::is_none")]
626    pub introduced: Option<AuditIntroduced>,
627}
628
629impl UnusedDependencyFinding {
630    /// Build the wrapper. Switches the primary fix from `remove-dependency`
631    /// to `move-dependency` when the dep is imported by another workspace.
632    #[must_use]
633    pub fn with_actions(dep: UnusedDependency) -> Self {
634        let actions = build_unused_dependency_actions(&dep, "dependencies", "unused-dependency");
635        Self {
636            dep,
637            actions,
638            introduced: None,
639        }
640    }
641}
642
643/// Wire-shape envelope for an [`UnusedDependency`] finding consumed under
644/// the `unused_dev_dependencies` key. Same bare struct as
645/// [`UnusedDependencyFinding`]; the fix description points at
646/// `devDependencies` and the suppress comment uses
647/// `unused-dev-dependency`.
648#[derive(Debug, Clone, Serialize)]
649#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
650pub struct UnusedDevDependencyFinding {
651    /// The underlying dead-code entry.
652    #[serde(flatten)]
653    pub dep: UnusedDependency,
654    /// Suggested next steps. Always emitted (possibly empty for
655    /// forward-compat).
656    pub actions: Vec<IssueAction>,
657    /// Set by the audit pass when this finding is introduced relative to
658    /// the merge-base.
659    #[serde(default, skip_serializing_if = "Option::is_none")]
660    pub introduced: Option<AuditIntroduced>,
661}
662
663impl UnusedDevDependencyFinding {
664    /// Build the wrapper.
665    #[must_use]
666    pub fn with_actions(dep: UnusedDependency) -> Self {
667        let actions =
668            build_unused_dependency_actions(&dep, "devDependencies", "unused-dev-dependency");
669        Self {
670            dep,
671            actions,
672            introduced: None,
673        }
674    }
675}
676
677/// Wire-shape envelope for an [`UnusedDependency`] finding consumed under
678/// the `unused_optional_dependencies` key. Same bare struct as
679/// [`UnusedDependencyFinding`]; the fix description points at
680/// `optionalDependencies`. Reuses the `unused-dependency` suppress
681/// `IssueKind` because there is no dedicated variant for optional deps.
682#[derive(Debug, Clone, Serialize)]
683#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
684pub struct UnusedOptionalDependencyFinding {
685    /// The underlying dead-code entry.
686    #[serde(flatten)]
687    pub dep: UnusedDependency,
688    /// Suggested next steps. Always emitted (possibly empty for
689    /// forward-compat).
690    pub actions: Vec<IssueAction>,
691    /// Set by the audit pass when this finding is introduced relative to
692    /// the merge-base.
693    #[serde(default, skip_serializing_if = "Option::is_none")]
694    pub introduced: Option<AuditIntroduced>,
695}
696
697impl UnusedOptionalDependencyFinding {
698    /// Build the wrapper.
699    #[must_use]
700    pub fn with_actions(dep: UnusedDependency) -> Self {
701        let actions =
702            build_unused_dependency_actions(&dep, "optionalDependencies", "unused-dependency");
703        Self {
704            dep,
705            actions,
706            introduced: None,
707        }
708    }
709}
710
711/// Wire-shape envelope for an [`UnlistedDependency`] finding. Carries an
712/// `install-dependency` primary (non-auto-fixable) plus the standard
713/// `ignoreDependencies` config suppress.
714#[derive(Debug, Clone, Serialize)]
715#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
716pub struct UnlistedDependencyFinding {
717    /// The underlying dead-code entry.
718    #[serde(flatten)]
719    pub dep: UnlistedDependency,
720    /// Suggested next steps. Always emitted (possibly empty for
721    /// forward-compat).
722    pub actions: Vec<IssueAction>,
723    /// Set by the audit pass when this finding is introduced relative to
724    /// the merge-base.
725    #[serde(default, skip_serializing_if = "Option::is_none")]
726    pub introduced: Option<AuditIntroduced>,
727}
728
729impl UnlistedDependencyFinding {
730    /// Build the wrapper.
731    #[must_use]
732    pub fn with_actions(dep: UnlistedDependency) -> Self {
733        let actions = vec![
734            IssueAction::Fix(FixAction {
735                kind: FixActionType::InstallDependency,
736                auto_fixable: false,
737                description: "Add this package to dependencies in package.json".to_string(),
738                note: Some(
739                    "Verify this package should be a direct dependency before adding".to_string(),
740                ),
741                available_in_catalogs: None,
742                suggested_target: None,
743            }),
744            build_ignore_dependencies_suppress_action(&dep.package_name, "unlisted-dependency"),
745        ];
746        Self {
747            dep,
748            actions,
749            introduced: None,
750        }
751    }
752}
753
754/// Wire-shape envelope for a [`TypeOnlyDependency`] finding. Carries a
755/// `move-to-dev` primary plus the standard `ignoreDependencies` config
756/// suppress.
757#[derive(Debug, Clone, Serialize)]
758#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
759pub struct TypeOnlyDependencyFinding {
760    /// The underlying dead-code entry.
761    #[serde(flatten)]
762    pub dep: TypeOnlyDependency,
763    /// Suggested next steps. Always emitted (possibly empty for
764    /// forward-compat).
765    pub actions: Vec<IssueAction>,
766    /// Set by the audit pass when this finding is introduced relative to
767    /// the merge-base.
768    #[serde(default, skip_serializing_if = "Option::is_none")]
769    pub introduced: Option<AuditIntroduced>,
770}
771
772impl TypeOnlyDependencyFinding {
773    /// Build the wrapper.
774    #[must_use]
775    pub fn with_actions(dep: TypeOnlyDependency) -> Self {
776        let actions = vec![
777            IssueAction::Fix(FixAction {
778                kind: FixActionType::MoveToDev,
779                auto_fixable: false,
780                description: "Move to devDependencies (only type imports are used)".to_string(),
781                note: Some(
782                    "Type imports are erased at runtime so this dependency is not needed in production"
783                        .to_string(),
784                ),
785                available_in_catalogs: None,
786                suggested_target: None,
787            }),
788            build_ignore_dependencies_suppress_action(&dep.package_name, "type-only-dependency"),
789        ];
790        Self {
791            dep,
792            actions,
793            introduced: None,
794        }
795    }
796}
797
798/// Wire-shape envelope for a [`TestOnlyDependency`] finding. Carries a
799/// `move-to-dev` primary (different prose than [`TypeOnlyDependencyFinding`])
800/// plus the standard `ignoreDependencies` config suppress.
801#[derive(Debug, Clone, Serialize)]
802#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
803pub struct TestOnlyDependencyFinding {
804    /// The underlying dead-code entry.
805    #[serde(flatten)]
806    pub dep: TestOnlyDependency,
807    /// Suggested next steps. Always emitted (possibly empty for
808    /// forward-compat).
809    pub actions: Vec<IssueAction>,
810    /// Set by the audit pass when this finding is introduced relative to
811    /// the merge-base.
812    #[serde(default, skip_serializing_if = "Option::is_none")]
813    pub introduced: Option<AuditIntroduced>,
814}
815
816impl TestOnlyDependencyFinding {
817    /// Build the wrapper.
818    #[must_use]
819    pub fn with_actions(dep: TestOnlyDependency) -> Self {
820        let actions = vec![
821            IssueAction::Fix(FixAction {
822                kind: FixActionType::MoveToDev,
823                auto_fixable: false,
824                description: "Move to devDependencies (only test files import this)".to_string(),
825                note: Some(
826                    "Only test files import this package so it does not need to be a production dependency"
827                        .to_string(),
828                ),
829                available_in_catalogs: None,
830                suggested_target: None,
831            }),
832            build_ignore_dependencies_suppress_action(&dep.package_name, "test-only-dependency"),
833        ];
834        Self {
835            dep,
836            actions,
837            introduced: None,
838        }
839    }
840}
841
842// ── Catalog / dep-override family ───────────────────────────────
843//
844// These six wrappers replace the legacy `inject_actions` post-pass in
845// `crates/cli/src/report/json.rs` for the catalog and dependency-override
846// findings. Each `with_actions(...)` builds the typed `actions` array
847// directly from the inner struct (and any per-call context such as
848// `config_fixable`), so the wire shape is identical to the pre-2.76
849// post-pass output but the Rust compiler now owns the action contract.
850
851/// Wire-shape envelope for a [`DuplicateExport`] finding. Carries up to
852/// three actions in position-locked order: an `add-to-config` `ignoreExports`
853/// snippet (only when `locations[]` carries at least one path) followed by
854/// the `remove-duplicate` fix and the multi-location suppress.
855///
856/// The `add-to-config` action sits at position 0 because the documented
857/// primary slot points at the safe, non-destructive path: the shadcn /
858/// Radix / bits-ui namespace-barrel case where every `index.*` reexports
859/// the directory's neighbours. The `remove-duplicate` fix stays as the
860/// secondary so consumers that pattern-match on `actions[0].type` for
861/// "primary fix" never propose deletion of an intentional barrel surface.
862#[derive(Debug, Clone, Serialize)]
863#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
864pub struct DuplicateExportFinding {
865    /// The underlying finding.
866    #[serde(flatten)]
867    pub export: DuplicateExport,
868    /// Suggested next steps. Always emitted (possibly empty for
869    /// forward-compat).
870    pub actions: Vec<IssueAction>,
871    /// Set by the audit pass when this finding is introduced relative to
872    /// the merge-base.
873    #[serde(default, skip_serializing_if = "Option::is_none")]
874    pub introduced: Option<AuditIntroduced>,
875}
876
877impl DuplicateExportFinding {
878    /// Build the wrapper with the `add-to-config` action's `auto_fixable`
879    /// defaulting to `false`. The CLI's `build_json_with_config_fixable`
880    /// path layers the actual `config_fixable` signal via
881    /// [`Self::set_config_fixable`] right before serialization (the
882    /// fix-applier readiness check lives in `fallow-cli::fix` and is not
883    /// reachable from the analyzer layer where wrappers are first built).
884    /// Embedders that build `AnalysisResults` directly and never route
885    /// through the CLI's JSON path keep the conservative default.
886    #[must_use]
887    pub fn with_actions(export: DuplicateExport) -> Self {
888        let mut actions: Vec<IssueAction> = Vec::with_capacity(3);
889
890        if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
891            actions.push(IssueAction::AddToConfig(AddToConfigAction {
892                kind: AddToConfigKind::AddToConfig,
893                auto_fixable: false,
894                description: "Add an ignoreExports rule so these files are excluded from duplicate-export grouping (use when this duplication is an intentional namespace-barrel API).".to_string(),
895                config_key: "ignoreExports".to_string(),
896                value: AddToConfigValue::ExportsRules(rules),
897                value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
898            }));
899        }
900
901        actions.push(IssueAction::Fix(FixAction {
902            kind: FixActionType::RemoveDuplicate,
903            auto_fixable: false,
904            description: "Keep one canonical export location and remove the others".to_string(),
905            note: Some(NAMESPACE_BARREL_HINT.to_string()),
906            available_in_catalogs: None,
907            suggested_target: None,
908        }));
909
910        actions.push(IssueAction::SuppressLine(SuppressLineAction {
911            kind: SuppressLineKind::SuppressLine,
912            auto_fixable: false,
913            description: "Suppress with an inline comment above the line".to_string(),
914            comment: "// fallow-ignore-next-line duplicate-export".to_string(),
915            scope: Some(SuppressLineScope::PerLocation),
916        }));
917
918        Self {
919            export,
920            actions,
921            introduced: None,
922        }
923    }
924
925    /// Update the position-0 `add-to-config` action's `auto_fixable` flag.
926    /// Idempotent and a no-op when position 0 is not an `add-to-config`
927    /// action (happens when the finding has no locations). Called by the
928    /// CLI's JSON serializer with the result of
929    /// `crate::fix::is_config_fixable` before emitting bytes.
930    pub fn set_config_fixable(&mut self, fixable: bool) {
931        if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
932            action.auto_fixable = fixable;
933        }
934    }
935}
936
937/// Build a paste-ready `ignoreExports` config value from a duplicate-export
938/// finding's locations. Returns one `{ file, exports: ["*"] }` entry per
939/// distinct file in insertion order. `None` when no locations carry a path.
940fn build_duplicate_exports_ignore_rules(
941    export: &DuplicateExport,
942) -> Option<Vec<IgnoreExportsRule>> {
943    let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
944    for loc in &export.locations {
945        // Normalize separators to forward slashes so pasting the action value
946        // into `.fallowrc.json` produces a portable rule. On Windows
947        // `to_string_lossy` preserves backslashes, which the old
948        // `inject_actions` post-pass implicitly normalized because it read
949        // the path AFTER `strip_root_prefix` had already run through
950        // `normalize_uri`; the typed wrapper builds the value before
951        // serialization, so the normalization has to be explicit here.
952        let path = loc.path.to_string_lossy().replace('\\', "/");
953        if path.is_empty() {
954            continue;
955        }
956        if entries.iter().any(|existing| existing.file == path) {
957            continue;
958        }
959        entries.push(IgnoreExportsRule {
960            file: path,
961            exports: vec!["*".to_string()],
962        });
963    }
964    if entries.is_empty() {
965        None
966    } else {
967        Some(entries)
968    }
969}
970
971/// Wire-shape envelope for an [`UnusedCatalogEntry`] finding. Per-instance
972/// `auto_fixable` flips to `false` when `hardcoded_consumers` is non-empty:
973/// the entry cannot be removed safely while a workspace package still pins
974/// the same package via a hardcoded version range.
975#[derive(Debug, Clone, Serialize)]
976#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
977pub struct UnusedCatalogEntryFinding {
978    /// The underlying finding.
979    #[serde(flatten)]
980    pub entry: UnusedCatalogEntry,
981    /// Suggested next steps. Always emitted.
982    pub actions: Vec<IssueAction>,
983    /// Set by the audit pass when this finding is introduced relative to
984    /// the merge-base.
985    #[serde(default, skip_serializing_if = "Option::is_none")]
986    pub introduced: Option<AuditIntroduced>,
987}
988
989impl UnusedCatalogEntryFinding {
990    /// Build the wrapper. Per-instance `auto_fixable` is `true` only when
991    /// `hardcoded_consumers` is empty; otherwise `fallow fix` skips the
992    /// entry to avoid breaking `pnpm install` on the holdout consumer.
993    #[must_use]
994    pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
995        let auto_fixable = entry.hardcoded_consumers.is_empty();
996        let actions = vec![
997            IssueAction::Fix(FixAction {
998                kind: FixActionType::RemoveCatalogEntry,
999                auto_fixable,
1000                description: "Remove the entry from pnpm-workspace.yaml".to_string(),
1001                note: Some(
1002                    "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing"
1003                        .to_string(),
1004                ),
1005                available_in_catalogs: None,
1006                suggested_target: None,
1007            }),
1008            IssueAction::SuppressLine(SuppressLineAction {
1009                kind: SuppressLineKind::SuppressLine,
1010                auto_fixable: false,
1011                description: "Suppress with a YAML comment above the line".to_string(),
1012                comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
1013                scope: None,
1014            }),
1015        ];
1016        Self {
1017            entry,
1018            actions,
1019            introduced: None,
1020        }
1021    }
1022}
1023
1024/// Wire-shape envelope for an [`EmptyCatalogGroup`] finding. Carries a
1025/// straightforward `remove-empty-catalog-group` primary plus a YAML-comment
1026/// suppress.
1027#[derive(Debug, Clone, Serialize)]
1028#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1029pub struct EmptyCatalogGroupFinding {
1030    /// The underlying finding.
1031    #[serde(flatten)]
1032    pub group: EmptyCatalogGroup,
1033    /// Suggested next steps. Always emitted.
1034    pub actions: Vec<IssueAction>,
1035    /// Set by the audit pass when this finding is introduced relative to
1036    /// the merge-base.
1037    #[serde(default, skip_serializing_if = "Option::is_none")]
1038    pub introduced: Option<AuditIntroduced>,
1039}
1040
1041impl EmptyCatalogGroupFinding {
1042    /// Build the wrapper.
1043    #[must_use]
1044    pub fn with_actions(group: EmptyCatalogGroup) -> Self {
1045        let actions = vec![
1046            IssueAction::Fix(FixAction {
1047                kind: FixActionType::RemoveEmptyCatalogGroup,
1048                auto_fixable: true,
1049                description: "Remove the empty named catalog group from pnpm-workspace.yaml"
1050                    .to_string(),
1051                note: Some(
1052                    "Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
1053                        .to_string(),
1054                ),
1055                available_in_catalogs: None,
1056                suggested_target: None,
1057            }),
1058            IssueAction::SuppressLine(SuppressLineAction {
1059                kind: SuppressLineKind::SuppressLine,
1060                auto_fixable: false,
1061                description: "Suppress with a YAML comment above the line".to_string(),
1062                comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
1063                scope: None,
1064            }),
1065        ];
1066        Self {
1067            group,
1068            actions,
1069            introduced: None,
1070        }
1071    }
1072}
1073
1074/// Wire-shape envelope for an [`UnresolvedCatalogReference`] finding. The
1075/// primary action at position 0 discriminates on `available_in_catalogs`:
1076/// `add-catalog-entry` when the array is empty (no other catalog declares
1077/// the package), or `update-catalog-reference` when at least one
1078/// alternative exists. When exactly one alternative exists, the action
1079/// also carries `suggested_target` so deterministic agents can land the
1080/// edit without picking from a list.
1081#[derive(Debug, Clone, Serialize)]
1082#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1083pub struct UnresolvedCatalogReferenceFinding {
1084    /// The underlying finding.
1085    #[serde(flatten)]
1086    pub reference: UnresolvedCatalogReference,
1087    /// Suggested next steps. Always emitted; position 0 is the discriminated
1088    /// primary (see struct docs).
1089    pub actions: Vec<IssueAction>,
1090    /// Set by the audit pass when this finding is introduced relative to
1091    /// the merge-base.
1092    #[serde(default, skip_serializing_if = "Option::is_none")]
1093    pub introduced: Option<AuditIntroduced>,
1094}
1095
1096impl UnresolvedCatalogReferenceFinding {
1097    /// Build the wrapper. The discriminator at position 0 is the
1098    /// `add-catalog-entry` vs `update-catalog-reference` pick documented on
1099    /// the struct.
1100    #[must_use]
1101    pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
1102        // Normalize separators to forward slashes so the
1103        // `ignoreCatalogReferences.consumer` action value is portable when
1104        // pasted into a Windows-authored config. See
1105        // `build_duplicate_exports_ignore_rules` for the same pattern.
1106        let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
1107        let primary = if reference.available_in_catalogs.is_empty() {
1108            IssueAction::Fix(FixAction {
1109                kind: FixActionType::AddCatalogEntry,
1110                auto_fixable: false,
1111                description: format!(
1112                    "Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
1113                    reference.entry_name, reference.catalog_name
1114                ),
1115                note: Some(
1116                    "Pin a version that satisfies the consumer's import; no other catalog declares this package today"
1117                        .to_string(),
1118                ),
1119                available_in_catalogs: None,
1120                suggested_target: None,
1121            })
1122        } else {
1123            let available = reference.available_in_catalogs.clone();
1124            let suggested_target = (available.len() == 1).then(|| available[0].clone());
1125            IssueAction::Fix(FixAction {
1126                kind: FixActionType::UpdateCatalogReference,
1127                auto_fixable: false,
1128                description: format!(
1129                    "Switch the reference from `catalog:{}` to a catalog that declares `{}`",
1130                    reference.catalog_name, reference.entry_name
1131                ),
1132                note: None,
1133                available_in_catalogs: Some(available),
1134                suggested_target,
1135            })
1136        };
1137
1138        let fallback = IssueAction::Fix(FixAction {
1139            kind: FixActionType::RemoveCatalogReference,
1140            auto_fixable: false,
1141            description:
1142                "Remove the catalog reference and pin a hardcoded version in package.json"
1143                    .to_string(),
1144            note: Some(
1145                "Use only when neither another catalog declares the package nor the named catalog should grow to include it"
1146                    .to_string(),
1147            ),
1148            available_in_catalogs: None,
1149            suggested_target: None,
1150        });
1151
1152        let mut suppress_value = serde_json::Map::new();
1153        suppress_value.insert(
1154            "package".to_string(),
1155            serde_json::Value::String(reference.entry_name.clone()),
1156        );
1157        suppress_value.insert(
1158            "catalog".to_string(),
1159            serde_json::Value::String(reference.catalog_name.clone()),
1160        );
1161        suppress_value.insert(
1162            "consumer".to_string(),
1163            serde_json::Value::String(consumer_path),
1164        );
1165        let suppress = IssueAction::AddToConfig(AddToConfigAction {
1166            kind: AddToConfigKind::AddToConfig,
1167            auto_fixable: false,
1168            description: "Suppress this reference via ignoreCatalogReferences in fallow config (use when the catalog edit is intentionally landing in a separate PR or the package is a placeholder).".to_string(),
1169            config_key: "ignoreCatalogReferences".to_string(),
1170            value: AddToConfigValue::RuleObject(suppress_value),
1171            value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
1172        });
1173
1174        Self {
1175            reference,
1176            actions: vec![primary, fallback, suppress],
1177            introduced: None,
1178        }
1179    }
1180}
1181
1182/// Wire-shape envelope for an [`UnusedDependencyOverride`] finding. Carries
1183/// a `remove-dependency-override` primary plus an `add-to-config`
1184/// `ignoreDependencyOverrides` suppress scoped to the target package and
1185/// declaration source.
1186#[derive(Debug, Clone, Serialize)]
1187#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1188pub struct UnusedDependencyOverrideFinding {
1189    /// The underlying finding.
1190    #[serde(flatten)]
1191    pub entry: UnusedDependencyOverride,
1192    /// Suggested next steps. Always emitted.
1193    pub actions: Vec<IssueAction>,
1194    /// Set by the audit pass when this finding is introduced relative to
1195    /// the merge-base.
1196    #[serde(default, skip_serializing_if = "Option::is_none")]
1197    pub introduced: Option<AuditIntroduced>,
1198}
1199
1200impl UnusedDependencyOverrideFinding {
1201    /// Build the wrapper.
1202    #[must_use]
1203    pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
1204        let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
1205        actions.push(IssueAction::Fix(FixAction {
1206            kind: FixActionType::RemoveDependencyOverride,
1207            auto_fixable: false,
1208            description: "Remove the override entry from pnpm-workspace.yaml or pnpm.overrides"
1209                .to_string(),
1210            note: Some(
1211                "Conservative static check; verify against `pnpm install --frozen-lockfile` before removing in case the override targets a transitive dependency (CVE-fix pattern)"
1212                    .to_string(),
1213            ),
1214            available_in_catalogs: None,
1215            suggested_target: None,
1216        }));
1217
1218        if let Some(suppress) = build_ignore_dependency_overrides_suppress(
1219            Some(&entry.target_package),
1220            &entry.raw_key,
1221            entry.source,
1222        ) {
1223            actions.push(suppress);
1224        }
1225
1226        Self {
1227            entry,
1228            actions,
1229            introduced: None,
1230        }
1231    }
1232}
1233
1234/// Wire-shape envelope for a [`MisconfiguredDependencyOverride`] finding.
1235/// Carries a `fix-dependency-override` primary plus the conditional
1236/// `add-to-config` `ignoreDependencyOverrides` suppress (skipped when both
1237/// `target_package` and `raw_key` are empty, since the rule matcher keys on
1238/// a non-empty package name).
1239#[derive(Debug, Clone, Serialize)]
1240#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1241pub struct MisconfiguredDependencyOverrideFinding {
1242    /// The underlying finding.
1243    #[serde(flatten)]
1244    pub entry: MisconfiguredDependencyOverride,
1245    /// Suggested next steps. Always emitted.
1246    pub actions: Vec<IssueAction>,
1247    /// Set by the audit pass when this finding is introduced relative to
1248    /// the merge-base.
1249    #[serde(default, skip_serializing_if = "Option::is_none")]
1250    pub introduced: Option<AuditIntroduced>,
1251}
1252
1253impl MisconfiguredDependencyOverrideFinding {
1254    /// Build the wrapper. The suppress action is omitted when neither
1255    /// `target_package` (set on `EmptyValue` cases) nor `raw_key` provides a
1256    /// non-empty package name; an `ignoreDependencyOverrides` entry with
1257    /// `package: ""` would be silently ignored by the config parser.
1258    #[must_use]
1259    pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
1260        let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
1261        actions.push(IssueAction::Fix(FixAction {
1262            kind: FixActionType::FixDependencyOverride,
1263            auto_fixable: false,
1264            description:
1265                "Fix the override key or value: pnpm refuses to honor entries with an unparsable key or empty value"
1266                    .to_string(),
1267            note: Some(
1268                "Common shapes: bare `pkg`, scoped `@scope/pkg`, version-selector `pkg@<2`, parent-chain `parent>child`. Valid values include semver ranges, `-` (removal), `$ref` (self-ref), and `npm:alias@^1`."
1269                    .to_string(),
1270            ),
1271            available_in_catalogs: None,
1272            suggested_target: None,
1273        }));
1274
1275        if let Some(suppress) = build_ignore_dependency_overrides_suppress(
1276            entry.target_package.as_deref(),
1277            &entry.raw_key,
1278            entry.source,
1279        ) {
1280            actions.push(suppress);
1281        }
1282
1283        Self {
1284            entry,
1285            actions,
1286            introduced: None,
1287        }
1288    }
1289}
1290
1291/// Shared `add-to-config` `ignoreDependencyOverrides` builder for the two
1292/// override findings. Returns `None` when no non-empty package name is
1293/// available; the config parser silently drops entries with an empty
1294/// `package` field, so emitting one would be a no-op that misleads agents.
1295fn build_ignore_dependency_overrides_suppress(
1296    target_package: Option<&str>,
1297    raw_key: &str,
1298    source: DependencyOverrideSource,
1299) -> Option<IssueAction> {
1300    let package = target_package
1301        .filter(|s| !s.is_empty())
1302        .or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
1303        .to_string();
1304    let mut value = serde_json::Map::new();
1305    value.insert("package".to_string(), serde_json::Value::String(package));
1306    value.insert(
1307        "source".to_string(),
1308        serde_json::Value::String(source.as_label().to_string()),
1309    );
1310    Some(IssueAction::AddToConfig(AddToConfigAction {
1311        kind: AddToConfigKind::AddToConfig,
1312        auto_fixable: false,
1313        description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
1314        config_key: "ignoreDependencyOverrides".to_string(),
1315        value: AddToConfigValue::RuleObject(value),
1316        value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
1317    }))
1318}
1319
1320// ── Position-0 invariant golden tests ───────────────────────────
1321//
1322// These tests document the load-bearing position-0 semantics that flow
1323// downstream into the GitHub Action / GitLab CI jq scripts, the MCP server
1324// `actions[0].type` pattern-match, and the VS Code LSP code-action
1325// rendering. Snapshot tests assert structural equality; these named tests
1326// document WHY position 0 has a specific value, so a future refactor that
1327// re-orders actions tells you what broke instead of just "the snapshot
1328// changed".
1329#[cfg(test)]
1330mod position_0_invariants {
1331    use super::*;
1332    use crate::output::FixActionType;
1333    use crate::results::{DependencyOverrideSource, DuplicateLocation};
1334    use std::path::PathBuf;
1335
1336    /// Helper: extract the kebab-case `type` discriminant from an
1337    /// [`IssueAction`] at a specific position. Returns `None` when the
1338    /// position is out of bounds or the action shape lacks a discriminant
1339    /// (today every variant has one).
1340    fn action_type(action: &IssueAction) -> &'static str {
1341        match action {
1342            IssueAction::Fix(fix) => match fix.kind {
1343                FixActionType::RemoveExport => "remove-export",
1344                FixActionType::DeleteFile => "delete-file",
1345                FixActionType::RemoveDependency => "remove-dependency",
1346                FixActionType::MoveDependency => "move-dependency",
1347                FixActionType::RemoveEnumMember => "remove-enum-member",
1348                FixActionType::RemoveClassMember => "remove-class-member",
1349                FixActionType::ResolveImport => "resolve-import",
1350                FixActionType::InstallDependency => "install-dependency",
1351                FixActionType::RemoveDuplicate => "remove-duplicate",
1352                FixActionType::MoveToDev => "move-to-dev",
1353                FixActionType::RefactorCycle => "refactor-cycle",
1354                FixActionType::RefactorBoundary => "refactor-boundary",
1355                FixActionType::ExportType => "export-type",
1356                FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
1357                FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
1358                FixActionType::UpdateCatalogReference => "update-catalog-reference",
1359                FixActionType::AddCatalogEntry => "add-catalog-entry",
1360                FixActionType::RemoveCatalogReference => "remove-catalog-reference",
1361                FixActionType::RemoveDependencyOverride => "remove-dependency-override",
1362                FixActionType::FixDependencyOverride => "fix-dependency-override",
1363            },
1364            IssueAction::SuppressLine(_) => "suppress-line",
1365            IssueAction::SuppressFile(_) => "suppress-file",
1366            IssueAction::AddToConfig(_) => "add-to-config",
1367        }
1368    }
1369
1370    /// Invariant: when no other catalog declares the package, position 0
1371    /// of `unresolved_catalog_references[].actions` is `add-catalog-entry`,
1372    /// directing the agent to grow the targeted catalog.
1373    ///
1374    /// Downstream consumers (MCP `actions[0].type` dispatch, jq scripts in
1375    /// `action/jq/review-comments-check.jq` and `ci/jq/review-check.jq`)
1376    /// pattern-match on this string. A future refactor that puts the
1377    /// generic `remove-catalog-reference` fallback at position 0 would
1378    /// flip every CI annotation from "add this entry" to "remove this
1379    /// reference", reversing the recommended action.
1380    #[test]
1381    fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
1382        let inner = UnresolvedCatalogReference {
1383            entry_name: "react".to_string(),
1384            catalog_name: "default".to_string(),
1385            path: PathBuf::from("apps/web/package.json"),
1386            line: 7,
1387            available_in_catalogs: Vec::new(),
1388        };
1389        let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
1390        assert_eq!(
1391            action_type(&finding.actions[0]),
1392            "add-catalog-entry",
1393            "position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
1394        );
1395        let IssueAction::Fix(fix) = &finding.actions[0] else {
1396            panic!("position-0 should be an IssueAction::Fix");
1397        };
1398        assert!(
1399            fix.available_in_catalogs.is_none(),
1400            "add-catalog-entry must NOT carry available_in_catalogs"
1401        );
1402        assert!(
1403            fix.suggested_target.is_none(),
1404            "add-catalog-entry must NOT carry suggested_target"
1405        );
1406    }
1407
1408    /// Invariant: when at least one alternative catalog declares the
1409    /// package, position 0 flips to `update-catalog-reference` and carries
1410    /// the alternative list. When exactly one alternative exists, the
1411    /// action also carries `suggested_target` so deterministic agents can
1412    /// land the edit without picking from the list. This is the
1413    /// counterpart to `unresolved_catalog_position_0_is_add_when_no_alternatives`.
1414    #[test]
1415    fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
1416        let inner = UnresolvedCatalogReference {
1417            entry_name: "react".to_string(),
1418            catalog_name: "default".to_string(),
1419            path: PathBuf::from("apps/web/package.json"),
1420            line: 7,
1421            available_in_catalogs: vec!["react18".to_string()],
1422        };
1423        let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
1424        assert_eq!(
1425            action_type(&finding.actions[0]),
1426            "update-catalog-reference",
1427            "position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
1428        );
1429        let IssueAction::Fix(fix) = &finding.actions[0] else {
1430            panic!("position-0 should be an IssueAction::Fix");
1431        };
1432        assert_eq!(
1433            fix.available_in_catalogs.as_deref(),
1434            Some(&["react18".to_string()][..]),
1435            "update-catalog-reference must carry the alternative list"
1436        );
1437        assert_eq!(
1438            fix.suggested_target.as_deref(),
1439            Some("react18"),
1440            "single-alternative case must surface `suggested_target` for deterministic agents"
1441        );
1442
1443        // Two alternatives: still update, but no unambiguous target.
1444        let inner_two = UnresolvedCatalogReference {
1445            entry_name: "react".to_string(),
1446            catalog_name: "default".to_string(),
1447            path: PathBuf::from("apps/web/package.json"),
1448            line: 7,
1449            available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
1450        };
1451        let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
1452        assert_eq!(
1453            action_type(&finding_two.actions[0]),
1454            "update-catalog-reference"
1455        );
1456        let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
1457            panic!("position-0 should be an IssueAction::Fix");
1458        };
1459        assert!(
1460            fix_two.suggested_target.is_none(),
1461            "multi-alternative case must NOT carry `suggested_target` (agent must pick)"
1462        );
1463    }
1464
1465    /// Invariant: position 0 of `duplicate_exports[].actions` is
1466    /// `add-to-config` (the safe `ignoreExports` rule for the
1467    /// namespace-barrel case), NOT the destructive `remove-duplicate`.
1468    ///
1469    /// This protects the shadcn / Radix / bits-ui pattern where every
1470    /// `components/ui/<name>/index.ts` intentionally re-exports the same
1471    /// short names. Any consumer that reads `actions[0].type` as "the
1472    /// recommended fix" must see the non-destructive path first; flipping
1473    /// position 0 to `remove-duplicate` would propose deleting an
1474    /// intentional API surface.
1475    ///
1476    /// This test pins position 0 across both possible auto_fixable values
1477    /// for the add-to-config action (the per-instance flip flag handled
1478    /// by `set_config_fixable`).
1479    #[test]
1480    fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
1481        let inner = DuplicateExport {
1482            export_name: "Root".to_string(),
1483            locations: vec![
1484                DuplicateLocation {
1485                    path: PathBuf::from("components/ui/accordion/index.ts"),
1486                    line: 1,
1487                    col: 0,
1488                },
1489                DuplicateLocation {
1490                    path: PathBuf::from("components/ui/dialog/index.ts"),
1491                    line: 1,
1492                    col: 0,
1493                },
1494            ],
1495        };
1496        let finding = DuplicateExportFinding::with_actions(inner);
1497        assert_eq!(
1498            action_type(&finding.actions[0]),
1499            "add-to-config",
1500            "position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
1501        );
1502        assert_eq!(
1503            action_type(&finding.actions[1]),
1504            "remove-duplicate",
1505            "position-1 must be the destructive `remove-duplicate` fallback"
1506        );
1507
1508        // `set_config_fixable(true)` flips the position-0 add-to-config
1509        // bool but must NOT re-order positions.
1510        let mut promoted = finding;
1511        promoted.set_config_fixable(true);
1512        assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
1513        let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
1514            panic!("position-0 should still be AddToConfig after set_config_fixable");
1515        };
1516        assert!(
1517            action.auto_fixable,
1518            "set_config_fixable(true) must flip auto_fixable"
1519        );
1520    }
1521
1522    /// Invariant: a duplicate-exports finding with empty `locations`
1523    /// degenerate input drops the `add-to-config` action entirely, so
1524    /// position 0 falls through to `remove-duplicate`. Documents the
1525    /// degenerate-case contract.
1526    #[test]
1527    fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
1528        let inner = DuplicateExport {
1529            export_name: "Root".to_string(),
1530            locations: Vec::new(),
1531        };
1532        let finding = DuplicateExportFinding::with_actions(inner);
1533        assert_eq!(
1534            action_type(&finding.actions[0]),
1535            "remove-duplicate",
1536            "with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
1537        );
1538
1539        // `set_config_fixable(true)` is a no-op on this shape.
1540        let mut promoted = finding;
1541        promoted.set_config_fixable(true);
1542        assert_eq!(
1543            action_type(&promoted.actions[0]),
1544            "remove-duplicate",
1545            "set_config_fixable is a no-op when position-0 is not add-to-config"
1546        );
1547    }
1548
1549    /// Invariant: misconfigured-dependency-override with empty
1550    /// `target_package` AND empty `raw_key` drops the suppress action
1551    /// (no usable package name for the `ignoreDependencyOverrides`
1552    /// matcher; emitting `package: ""` would be silently dropped by the
1553    /// config parser). Documents the suppress-omission contract.
1554    #[test]
1555    fn misconfigured_override_drops_suppress_when_no_package_name() {
1556        let inner = MisconfiguredDependencyOverride {
1557            raw_key: String::new(),
1558            target_package: None,
1559            raw_value: String::new(),
1560            reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
1561            source: DependencyOverrideSource::PnpmWorkspaceYaml,
1562            path: PathBuf::from("pnpm-workspace.yaml"),
1563            line: 12,
1564        };
1565        let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
1566        // Only the primary fix-dependency-override action: no suppress.
1567        assert_eq!(finding.actions.len(), 1);
1568        assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
1569    }
1570}