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