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