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