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;
31use std::path::Path;
32
33use crate::envelope::AuditIntroduced;
34use crate::output::{
35    AddToConfigAction, AddToConfigKind, AddToConfigValue, FixAction, FixActionType,
36    IgnoreExportsRule, IssueAction, SuppressFileAction, SuppressFileKind, SuppressLineAction,
37    SuppressLineKind, SuppressLineScope,
38};
39use crate::results::{
40    BoundaryCallViolation, BoundaryCoverageViolation, BoundaryViolation, CircularDependency,
41    DependencyOverrideSource, DuplicateExport, DuplicatePropShape, DynamicSegmentNameConflict,
42    EmptyCatalogGroup, InvalidClientExport, MisconfiguredDependencyOverride, MisplacedDirective,
43    MixedClientServerBarrel, PolicyViolation, PrivateTypeLeak, PropDrillingChain, ReExportCycle,
44    ReExportCycleKind, RouteCollision, TestOnlyDependency, ThinWrapper, TypeOnlyDependency,
45    UnlistedDependency, UnprovidedInject, UnrenderedComponent, UnresolvedCatalogReference,
46    UnresolvedImport, UnusedCatalogEntry, UnusedComponentEmit, UnusedComponentInput,
47    UnusedComponentOutput, UnusedComponentProp, UnusedDependency, UnusedDependencyOverride,
48    UnusedExport, UnusedFile, UnusedLoadDataKey, UnusedMember, UnusedServerAction,
49    UnusedSvelteEvent,
50};
51
52/// Shared note for the `duplicate-exports` fix action. Mirrors the const used
53/// by the human report (see `crates/cli/src/report/shared.rs`); kept here so
54/// the wire-format builder reads from the same source of truth.
55pub 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.";
56
57/// JSON Schema fragment URL for the `add-to-config` `ignoreExports` action's
58/// `value` payload. Pinned to the main branch so users browsing the action
59/// value can navigate directly to the rule shape.
60const IGNORE_EXPORTS_VALUE_SCHEMA: &str =
61    "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreExports";
62
63/// JSON Schema fragment URL for the `ignoreCatalogReferences` rule items
64/// referenced by `add-to-config` actions on `unresolved-catalog-references`.
65const IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreCatalogReferences/items";
66
67/// JSON Schema fragment URL for the `ignoreDependencyOverrides` rule items
68/// referenced by `add-to-config` actions on both the unused- and
69/// misconfigured-override findings.
70const IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencyOverrides/items";
71
72const PNPM_WORKSPACE_FILE: &str = "pnpm-workspace.yaml";
73
74fn manual_framework_fix(kind: FixActionType, description: &str, note: &str) -> IssueAction {
75    IssueAction::Fix(FixAction {
76        kind,
77        auto_fixable: false,
78        description: description.to_string(),
79        note: Some(note.to_string()),
80        available_in_catalogs: None,
81        suggested_target: None,
82    })
83}
84
85fn suppress_line(comment: &str) -> IssueAction {
86    IssueAction::SuppressLine(SuppressLineAction {
87        kind: SuppressLineKind::SuppressLine,
88        auto_fixable: false,
89        description: "Suppress with an inline comment above the line".to_string(),
90        comment: comment.to_string(),
91        scope: None,
92    })
93}
94
95/// Wire-shape envelope for an [`UnusedFile`] finding. The bare finding
96/// flattens in via `#[serde(flatten)]`, with a typed `actions` array
97/// populated at construction time and the audit-pass `introduced` flag
98/// attached as an optional sibling.
99#[derive(Debug, Clone, Serialize)]
100#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
101pub struct UnusedFileFinding {
102    /// The underlying dead-code entry.
103    #[serde(flatten)]
104    pub file: UnusedFile,
105    /// Suggested next steps: a `delete-file` primary and a `suppress-file`
106    /// secondary. Always emitted (possibly empty for forward-compat).
107    pub actions: Vec<IssueAction>,
108    /// Set by the audit pass when this finding is introduced relative to
109    /// the merge-base. `None` when serialized directly from Rust.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub introduced: Option<AuditIntroduced>,
112}
113
114impl UnusedFileFinding {
115    /// Build the wrapper from a raw [`UnusedFile`], computing the typed
116    /// `actions` array inline. `introduced` stays `None` and is set later
117    /// by `annotate_dead_code_json` if the audit pass runs.
118    #[must_use]
119    pub fn with_actions(file: UnusedFile) -> Self {
120        let actions = vec![
121            IssueAction::Fix(FixAction {
122                kind: FixActionType::DeleteFile,
123                auto_fixable: false,
124                description: "Delete this file".to_string(),
125                note: Some(
126                    "File deletion may remove runtime functionality not visible to static analysis"
127                        .to_string(),
128                ),
129                available_in_catalogs: None,
130                suggested_target: None,
131            }),
132            IssueAction::SuppressFile(SuppressFileAction {
133                kind: SuppressFileKind::SuppressFile,
134                auto_fixable: false,
135                description: "Suppress with a file-level comment at the top of the file"
136                    .to_string(),
137                comment: "// fallow-ignore-file unused-file".to_string(),
138            }),
139        ];
140        Self {
141            file,
142            actions,
143            introduced: None,
144        }
145    }
146}
147
148/// Wire-shape envelope for a [`PrivateTypeLeak`] finding. Mirrors
149/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
150/// `actions` array (`export-type` primary plus `suppress-line` secondary).
151#[derive(Debug, Clone, Serialize)]
152#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
153pub struct PrivateTypeLeakFinding {
154    /// The underlying dead-code entry.
155    #[serde(flatten)]
156    pub leak: PrivateTypeLeak,
157    /// Suggested next steps. Always emitted (possibly empty for
158    /// forward-compat).
159    pub actions: Vec<IssueAction>,
160    /// Set by the audit pass when this finding is introduced relative to
161    /// the merge-base.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub introduced: Option<AuditIntroduced>,
164}
165
166impl PrivateTypeLeakFinding {
167    /// Build the wrapper from a raw [`PrivateTypeLeak`].
168    #[must_use]
169    pub fn with_actions(leak: PrivateTypeLeak) -> Self {
170        let actions = vec![
171            IssueAction::Fix(FixAction {
172                kind: FixActionType::ExportType,
173                auto_fixable: false,
174                description: "Export the referenced private type by name".to_string(),
175                note: Some(
176                    "Keep the type exported while it is part of a public signature".to_string(),
177                ),
178                available_in_catalogs: None,
179                suggested_target: None,
180            }),
181            IssueAction::SuppressLine(SuppressLineAction {
182                kind: SuppressLineKind::SuppressLine,
183                auto_fixable: false,
184                description: "Suppress with an inline comment above the line".to_string(),
185                comment: "// fallow-ignore-next-line private-type-leak".to_string(),
186                scope: None,
187            }),
188        ];
189        Self {
190            leak,
191            actions,
192            introduced: None,
193        }
194    }
195}
196
197/// Wire-shape envelope for an [`UnresolvedImport`] finding. Mirrors
198/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
199/// `actions` array (`resolve-import` primary plus config and inline
200/// suppression actions).
201#[derive(Debug, Clone, Serialize)]
202#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
203pub struct UnresolvedImportFinding {
204    /// The underlying dead-code entry.
205    #[serde(flatten)]
206    pub import: UnresolvedImport,
207    /// Suggested next steps. Always emitted (possibly empty for
208    /// forward-compat).
209    pub actions: Vec<IssueAction>,
210    /// Set by the audit pass when this finding is introduced relative to
211    /// the merge-base.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub introduced: Option<AuditIntroduced>,
214}
215
216impl UnresolvedImportFinding {
217    /// Build the wrapper from a raw [`UnresolvedImport`].
218    #[must_use]
219    pub fn with_actions(import: UnresolvedImport) -> Self {
220        let actions = vec![
221            IssueAction::Fix(FixAction {
222                kind: FixActionType::ResolveImport,
223                auto_fixable: false,
224                description: "Fix the import specifier or install the missing module".to_string(),
225                note: Some(
226                    "Verify the module path and check tsconfig paths configuration".to_string(),
227                ),
228                available_in_catalogs: None,
229                suggested_target: None,
230            }),
231            IssueAction::AddToConfig(AddToConfigAction {
232                kind: AddToConfigKind::AddToConfig,
233                auto_fixable: false,
234                description: format!(
235                    "Add \"{}\" to ignoreUnresolvedImports in fallow config",
236                    import.specifier
237                ),
238                config_key: "ignoreUnresolvedImports".to_string(),
239                value: AddToConfigValue::Scalar(import.specifier.clone()),
240                value_schema: Some(
241                    "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
242                        .to_string(),
243                ),
244            }),
245            IssueAction::SuppressLine(SuppressLineAction {
246                kind: SuppressLineKind::SuppressLine,
247                auto_fixable: false,
248                description: "Suppress with an inline comment above the line".to_string(),
249                comment: "// fallow-ignore-next-line unresolved-import".to_string(),
250                scope: None,
251            }),
252        ];
253        Self {
254            import,
255            actions,
256            introduced: None,
257        }
258    }
259}
260
261/// Wire-shape envelope for a [`CircularDependency`] finding. Mirrors
262/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
263/// `actions` array (`refactor-cycle` primary plus `suppress-line`
264/// secondary).
265#[derive(Debug, Clone, Serialize)]
266#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
267pub struct CircularDependencyFinding {
268    /// The underlying dead-code entry.
269    #[serde(flatten)]
270    pub cycle: CircularDependency,
271    /// Suggested next steps. Always emitted (possibly empty for
272    /// forward-compat).
273    pub actions: Vec<IssueAction>,
274    /// Set by the audit pass when this finding is introduced relative to
275    /// the merge-base.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub introduced: Option<AuditIntroduced>,
278}
279
280impl CircularDependencyFinding {
281    /// Build the wrapper from a raw [`CircularDependency`].
282    #[must_use]
283    pub fn with_actions(cycle: CircularDependency) -> Self {
284        let actions = vec![
285            IssueAction::Fix(FixAction {
286                kind: FixActionType::RefactorCycle,
287                auto_fixable: false,
288                description: "Extract shared logic into a separate module to break the cycle"
289                    .to_string(),
290                note: Some(
291                    "Circular imports can cause initialization issues and make code harder to reason about"
292                        .to_string(),
293                ),
294                available_in_catalogs: None,
295                suggested_target: None,
296            }),
297            IssueAction::SuppressLine(SuppressLineAction {
298                kind: SuppressLineKind::SuppressLine,
299                auto_fixable: false,
300                description: "Suppress with an inline comment above the line".to_string(),
301                comment: "// fallow-ignore-next-line circular-dependency".to_string(),
302                scope: None,
303            }),
304        ];
305        Self {
306            cycle,
307            actions,
308            introduced: None,
309        }
310    }
311}
312
313/// Wire-shape envelope for a [`ReExportCycle`] finding. Mirrors
314/// [`CircularDependencyFinding`]: flattens the bare finding and carries a
315/// typed `actions` array (`refactor-re-export-cycle` informational primary
316/// plus `suppress-file` secondary; cycles are file-scoped so a single
317/// file-level suppression on the alphabetically-first member breaks the
318/// cycle, and no `// fallow-ignore-next-line` form makes sense because the
319/// diagnostic is anchored at line 1 col 0 of each member).
320#[derive(Debug, Clone, Serialize)]
321#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
322pub struct ReExportCycleFinding {
323    /// The underlying dead-code entry.
324    #[serde(flatten)]
325    pub cycle: ReExportCycle,
326    /// Suggested next steps. Always emitted (possibly empty for
327    /// forward-compat).
328    pub actions: Vec<IssueAction>,
329    /// Set by the audit pass when this finding is introduced relative to
330    /// the merge-base.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub introduced: Option<AuditIntroduced>,
333}
334
335impl ReExportCycleFinding {
336    /// Build the wrapper from a raw [`ReExportCycle`].
337    ///
338    /// The `SuppressFile` action targets the alphabetically-first member
339    /// (`cycle.files[0]`; the `files` Vec is already sorted at graph layer);
340    /// for multi-node cycles the description names the other members so
341    /// consumers see context for why one file-level suppression suffices.
342    #[must_use]
343    pub fn with_actions(cycle: ReExportCycle) -> Self {
344        // The description is a path-free hint about the suppression's
345        // structural effect; the cycle's member list already ships in the
346        // sibling `files` field, so consumers can correlate without
347        // re-reading the description (and absolute paths cannot leak in
348        // here, which the wrapper has no root-prefix context to strip).
349        let suppress_description = match cycle.kind {
350            ReExportCycleKind::SelfLoop => {
351                "Suppress with a file-level comment at the top of this file. \
352                 The cycle is a self-loop, so the suppression covers the entire finding."
353                    .to_string()
354            }
355            ReExportCycleKind::MultiNode => {
356                "Suppress with a file-level comment at the top of this file. \
357                 One suppression on any member breaks the cycle for every member \
358                 (see the sibling `files` array)."
359                    .to_string()
360            }
361        };
362        let actions = vec![
363            IssueAction::Fix(FixAction {
364                kind: FixActionType::RefactorReExportCycle,
365                auto_fixable: false,
366                description: "Remove one `export * from` (or `export { ... } from`) \
367                              statement on any one member to break the cycle"
368                    .to_string(),
369                note: Some(
370                    "Re-export cycles are structurally a no-op: chain propagation through \
371                     the loop never reaches a terminating module, so imports from any member \
372                     may silently come up empty."
373                        .to_string(),
374                ),
375                available_in_catalogs: None,
376                suggested_target: None,
377            }),
378            IssueAction::SuppressFile(SuppressFileAction {
379                kind: SuppressFileKind::SuppressFile,
380                auto_fixable: false,
381                description: suppress_description,
382                comment: "// fallow-ignore-file re-export-cycle".to_string(),
383            }),
384        ];
385        Self {
386            cycle,
387            actions,
388            introduced: None,
389        }
390    }
391}
392
393/// Wire-shape envelope for a [`BoundaryViolation`] finding. Mirrors
394/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
395/// `actions` array (`refactor-boundary` primary plus `suppress-line`
396/// secondary).
397#[derive(Debug, Clone, Serialize)]
398#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
399pub struct BoundaryViolationFinding {
400    /// The underlying dead-code entry.
401    #[serde(flatten)]
402    pub violation: BoundaryViolation,
403    /// Suggested next steps. Always emitted (possibly empty for
404    /// forward-compat).
405    pub actions: Vec<IssueAction>,
406    /// Set by the audit pass when this finding is introduced relative to
407    /// the merge-base.
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub introduced: Option<AuditIntroduced>,
410}
411
412impl BoundaryViolationFinding {
413    /// Build the wrapper from a raw [`BoundaryViolation`].
414    #[must_use]
415    pub fn with_actions(violation: BoundaryViolation) -> Self {
416        let actions = vec![
417            IssueAction::Fix(FixAction {
418                kind: FixActionType::RefactorBoundary,
419                auto_fixable: false,
420                description: "Move the import through an allowed zone or restructure the dependency"
421                    .to_string(),
422                note: Some(
423                    "This import crosses an architecture boundary that is not permitted by the configured rules"
424                        .to_string(),
425                ),
426                available_in_catalogs: None,
427                suggested_target: None,
428            }),
429            IssueAction::SuppressLine(SuppressLineAction {
430                kind: SuppressLineKind::SuppressLine,
431                auto_fixable: false,
432                description: "Suppress with an inline comment above the line".to_string(),
433                comment: "// fallow-ignore-next-line boundary-violation".to_string(),
434                scope: None,
435            }),
436        ];
437        Self {
438            violation,
439            actions,
440            introduced: None,
441        }
442    }
443}
444
445/// Wire-shape envelope for a [`BoundaryCoverageViolation`] finding. Carries
446/// actions for assigning the file to a zone or explicitly allowing it to stay
447/// unmatched.
448#[derive(Debug, Clone, Serialize)]
449#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
450pub struct BoundaryCoverageViolationFinding {
451    /// The underlying coverage entry.
452    #[serde(flatten)]
453    pub violation: BoundaryCoverageViolation,
454    /// Suggested next steps.
455    pub actions: Vec<IssueAction>,
456    /// Set by the audit pass when this finding is introduced relative to
457    /// the merge-base.
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub introduced: Option<AuditIntroduced>,
460}
461
462impl BoundaryCoverageViolationFinding {
463    /// Build the wrapper from a raw [`BoundaryCoverageViolation`].
464    #[must_use]
465    pub fn with_actions(violation: BoundaryCoverageViolation) -> Self {
466        let path = violation.path.to_string_lossy().replace('\\', "/");
467        let actions = vec![
468            IssueAction::Fix(FixAction {
469                kind: FixActionType::RefactorBoundary,
470                auto_fixable: false,
471                description: "Add this file to a boundary zone pattern or move it under an existing zone"
472                    .to_string(),
473                note: Some(
474                    "Boundary coverage is enabled, so every analyzed source file must match a zone unless allow-listed"
475                        .to_string(),
476                ),
477                available_in_catalogs: None,
478                suggested_target: None,
479            }),
480            IssueAction::AddToConfig(AddToConfigAction {
481                kind: AddToConfigKind::AddToConfig,
482                auto_fixable: false,
483                description: format!(
484                    "Add \"{path}\" to boundaries.coverage.allowUnmatched in fallow config"
485                ),
486                config_key: "boundaries.coverage.allowUnmatched".to_string(),
487                value: AddToConfigValue::Scalar(path),
488                value_schema: Some(
489                    "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/boundaries/properties/coverage/properties/allowUnmatched/items"
490                        .to_string(),
491                ),
492            }),
493            IssueAction::SuppressFile(SuppressFileAction {
494                kind: SuppressFileKind::SuppressFile,
495                auto_fixable: false,
496                description: "Suppress with a file-level comment at the top of the file"
497                    .to_string(),
498                comment: "// fallow-ignore-file boundary-violation".to_string(),
499            }),
500        ];
501        Self {
502            violation,
503            actions,
504            introduced: None,
505        }
506    }
507}
508
509/// Wire-shape envelope for a [`BoundaryCallViolation`] finding. Carries
510/// actions for refactoring the forbidden call out of the zone or suppressing
511/// it with the shared `boundary-violation` token.
512#[derive(Debug, Clone, Serialize)]
513#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
514pub struct BoundaryCallViolationFinding {
515    /// The underlying forbidden-call entry.
516    #[serde(flatten)]
517    pub violation: BoundaryCallViolation,
518    /// Suggested next steps.
519    pub actions: Vec<IssueAction>,
520    /// Set by the audit pass when this finding is introduced relative to
521    /// the merge-base.
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub introduced: Option<AuditIntroduced>,
524}
525
526impl BoundaryCallViolationFinding {
527    /// Build the wrapper from a raw [`BoundaryCallViolation`].
528    #[must_use]
529    pub fn with_actions(violation: BoundaryCallViolation) -> Self {
530        let actions = vec![
531            IssueAction::Fix(FixAction {
532                kind: FixActionType::RefactorBoundary,
533                auto_fixable: false,
534                description: format!(
535                    "Move the `{}` call out of zone '{}' or behind an allowed abstraction",
536                    violation.callee, violation.zone,
537                ),
538                note: Some(format!(
539                    "`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",
540                    violation.pattern, violation.zone,
541                )),
542                available_in_catalogs: None,
543                suggested_target: None,
544            }),
545            IssueAction::SuppressLine(SuppressLineAction {
546                kind: SuppressLineKind::SuppressLine,
547                auto_fixable: false,
548                description: "Suppress with an inline comment above the line".to_string(),
549                comment: "// fallow-ignore-next-line boundary-violation".to_string(),
550                scope: None,
551            }),
552            IssueAction::SuppressFile(SuppressFileAction {
553                kind: SuppressFileKind::SuppressFile,
554                auto_fixable: false,
555                description: "Suppress with a file-level comment at the top of the file"
556                    .to_string(),
557                comment: "// fallow-ignore-file boundary-violation".to_string(),
558            }),
559        ];
560        Self {
561            violation,
562            actions,
563            introduced: None,
564        }
565    }
566}
567
568/// Wire-shape envelope for a [`PolicyViolation`] finding. Carries actions for
569/// replacing the banned call, import, or effect, or suppressing it with a scoped
570/// `policy-violation:<pack>/<rule-id>` token.
571#[derive(Debug, Clone, Serialize)]
572#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
573pub struct PolicyViolationFinding {
574    /// The underlying rule-pack policy entry.
575    #[serde(flatten)]
576    pub violation: PolicyViolation,
577    /// Suggested next steps.
578    pub actions: Vec<IssueAction>,
579    /// Set by the audit pass when this finding is introduced relative to
580    /// the merge-base.
581    #[serde(default, skip_serializing_if = "Option::is_none")]
582    pub introduced: Option<AuditIntroduced>,
583}
584
585impl PolicyViolationFinding {
586    /// Build the wrapper from a raw [`PolicyViolation`].
587    #[must_use]
588    pub fn with_actions(violation: PolicyViolation) -> Self {
589        let what = match violation.kind {
590            crate::results::PolicyRuleKind::BannedCall => "call",
591            crate::results::PolicyRuleKind::BannedImport => "import",
592            crate::results::PolicyRuleKind::BannedEffect => "effect",
593            crate::results::PolicyRuleKind::BannedExport => "export",
594        };
595        let description = match &violation.message {
596            Some(message) => format!("Replace the `{}` {what}: {message}", violation.matched),
597            None => format!("Replace the `{}` {what}", violation.matched),
598        };
599        let suppress_token = format!("policy-violation:{}/{}", violation.pack, violation.rule_id);
600        let actions = vec![
601            IssueAction::Fix(FixAction {
602                kind: FixActionType::ResolvePolicyViolation,
603                auto_fixable: false,
604                description,
605                note: Some(format!(
606                    "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",
607                    violation.pack, violation.rule_id,
608                )),
609                available_in_catalogs: None,
610                suggested_target: None,
611            }),
612            IssueAction::SuppressLine(SuppressLineAction {
613                kind: SuppressLineKind::SuppressLine,
614                auto_fixable: false,
615                description: "Suppress this rule-pack rule with an inline comment above the line"
616                    .to_string(),
617                comment: format!("// fallow-ignore-next-line {suppress_token}"),
618                scope: None,
619            }),
620            IssueAction::SuppressFile(SuppressFileAction {
621                kind: SuppressFileKind::SuppressFile,
622                auto_fixable: false,
623                description:
624                    "Suppress this rule-pack rule with a file-level comment at the top of the file"
625                        .to_string(),
626                comment: format!("// fallow-ignore-file {suppress_token}"),
627            }),
628        ];
629        Self {
630            violation,
631            actions,
632            introduced: None,
633        }
634    }
635}
636
637/// Wire-shape envelope for an [`UnusedExport`] finding consumed under the
638/// `unused_exports` key. Same Rust struct as [`UnusedTypeFinding`], with a
639/// different fix description so consumers can tell value-export from
640/// type-export removal at the action level.
641#[derive(Debug, Clone, Serialize)]
642#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
643pub struct UnusedExportFinding {
644    /// The underlying dead-code entry.
645    #[serde(flatten)]
646    pub export: UnusedExport,
647    /// Suggested next steps. Always emitted (possibly empty for
648    /// forward-compat).
649    pub actions: Vec<IssueAction>,
650    /// Set by the audit pass when this finding is introduced relative to
651    /// the merge-base.
652    #[serde(default, skip_serializing_if = "Option::is_none")]
653    pub introduced: Option<AuditIntroduced>,
654}
655
656impl UnusedExportFinding {
657    /// Build the wrapper. When `export.is_re_export` is true, the fix
658    /// action's `note` warns about possible public-API surface; otherwise
659    /// `note` is absent on the fix action.
660    #[must_use]
661    pub fn with_actions(export: UnusedExport) -> Self {
662        let note = if export.is_re_export {
663            Some(
664                "This finding originates from a re-export; verify it is not part of your public API before removing"
665                    .to_string(),
666            )
667        } else {
668            None
669        };
670        let actions = vec![
671            IssueAction::Fix(FixAction {
672                kind: FixActionType::RemoveExport,
673                auto_fixable: true,
674                description: "Remove the unused export from the public API".to_string(),
675                note,
676                available_in_catalogs: None,
677                suggested_target: None,
678            }),
679            IssueAction::SuppressLine(SuppressLineAction {
680                kind: SuppressLineKind::SuppressLine,
681                auto_fixable: false,
682                description: "Suppress with an inline comment above the line".to_string(),
683                comment: "// fallow-ignore-next-line unused-export".to_string(),
684                scope: None,
685            }),
686        ];
687        Self {
688            export,
689            actions,
690            introduced: None,
691        }
692    }
693}
694
695/// Wire-shape envelope for an [`UnusedExport`] finding consumed under the
696/// `unused_types` key. Wraps the same bare [`UnusedExport`] struct as
697/// [`UnusedExportFinding`] but emits a fix action targeted at type-only
698/// declarations, with the same `is_re_export`-aware note swap.
699#[derive(Debug, Clone, Serialize)]
700#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
701pub struct UnusedTypeFinding {
702    /// The underlying dead-code entry.
703    #[serde(flatten)]
704    pub export: UnusedExport,
705    /// Suggested next steps. Always emitted (possibly empty for
706    /// forward-compat).
707    pub actions: Vec<IssueAction>,
708    /// Set by the audit pass when this finding is introduced relative to
709    /// the merge-base.
710    #[serde(default, skip_serializing_if = "Option::is_none")]
711    pub introduced: Option<AuditIntroduced>,
712}
713
714impl UnusedTypeFinding {
715    /// Build the wrapper. `is_re_export` swaps the fix note the same way as
716    /// [`UnusedExportFinding::with_actions`].
717    #[must_use]
718    pub fn with_actions(export: UnusedExport) -> Self {
719        let note = if export.is_re_export {
720            Some(
721                "This finding originates from a re-export; verify it is not part of your public API before removing"
722                    .to_string(),
723            )
724        } else {
725            None
726        };
727        let actions = vec![
728            IssueAction::Fix(FixAction {
729                kind: FixActionType::RemoveExport,
730                auto_fixable: true,
731                description:
732                    "Remove the `export` (or `export type`) keyword from the type declaration"
733                        .to_string(),
734                note,
735                available_in_catalogs: None,
736                suggested_target: None,
737            }),
738            IssueAction::SuppressLine(SuppressLineAction {
739                kind: SuppressLineKind::SuppressLine,
740                auto_fixable: false,
741                description: "Suppress with an inline comment above the line".to_string(),
742                comment: "// fallow-ignore-next-line unused-type".to_string(),
743                scope: None,
744            }),
745        ];
746        Self {
747            export,
748            actions,
749            introduced: None,
750        }
751    }
752}
753
754/// Wire-shape envelope for an [`InvalidClientExport`] finding. There is no safe
755/// auto-fix: the export itself may be a legitimate client-component value
756/// export that happens to collide with a Next.js server-only name, so removing
757/// it could break the component. Actions are a manual `move-to-server-module`
758/// fix (the real remediation) plus a line-level suppress.
759#[derive(Debug, Clone, Serialize)]
760#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
761pub struct InvalidClientExportFinding {
762    /// The underlying dead-code entry.
763    #[serde(flatten)]
764    pub export: InvalidClientExport,
765    /// Suggested next steps. Always emitted (possibly empty for
766    /// forward-compat).
767    pub actions: Vec<IssueAction>,
768    /// Set by the audit pass when this finding is introduced relative to
769    /// the merge-base.
770    #[serde(default, skip_serializing_if = "Option::is_none")]
771    pub introduced: Option<AuditIntroduced>,
772}
773
774impl InvalidClientExportFinding {
775    /// Build the wrapper from a raw [`InvalidClientExport`]. Emits a manual
776    /// fix action (move the server-only export to a non-client module) plus a
777    /// line-level suppress: there is no safe auto-fix because removing the
778    /// export could break a legitimate client component.
779    #[must_use]
780    pub fn with_actions(export: InvalidClientExport) -> Self {
781        let actions = vec![
782            IssueAction::Fix(FixAction {
783                kind: FixActionType::MoveToServerModule,
784                auto_fixable: false,
785                description: "Move the server-only export to a non-client module and import it from there"
786                    .to_string(),
787                note: Some(
788                    "A \"use client\" file cannot export a Next.js server-only or route-config name; Next.js rejects it at build time"
789                        .to_string(),
790                ),
791                available_in_catalogs: None,
792                suggested_target: None,
793            }),
794            IssueAction::SuppressLine(SuppressLineAction {
795                kind: SuppressLineKind::SuppressLine,
796                auto_fixable: false,
797                description: "Suppress with an inline comment above the line".to_string(),
798                comment: "// fallow-ignore-next-line invalid-client-export".to_string(),
799                scope: None,
800            }),
801        ];
802        Self {
803            export,
804            actions,
805            introduced: None,
806        }
807    }
808}
809
810/// Wire-shape envelope for a [`MixedClientServerBarrel`] finding. There is no
811/// safe auto-fix: splitting a barrel into separate client and server modules is
812/// a human decision (the barrel may intentionally aggregate both surfaces).
813/// Actions are a manual `split-mixed-barrel` fix (the real remediation) plus a
814/// line-level suppress.
815#[derive(Debug, Clone, Serialize)]
816#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
817pub struct MixedClientServerBarrelFinding {
818    /// The underlying dead-code entry.
819    #[serde(flatten)]
820    pub barrel: MixedClientServerBarrel,
821    /// Suggested next steps. Always emitted (possibly empty for
822    /// forward-compat).
823    pub actions: Vec<IssueAction>,
824    /// Set by the audit pass when this finding is introduced relative to
825    /// the merge-base.
826    #[serde(default, skip_serializing_if = "Option::is_none")]
827    pub introduced: Option<AuditIntroduced>,
828}
829
830impl MixedClientServerBarrelFinding {
831    /// Build the wrapper from a raw [`MixedClientServerBarrel`]. Emits a manual
832    /// fix action (split the barrel into separate client and server halves)
833    /// plus a line-level suppress: there is no safe auto-fix because splitting
834    /// the barrel is a human decision.
835    #[must_use]
836    pub fn with_actions(barrel: MixedClientServerBarrel) -> Self {
837        let actions = vec![
838            IssueAction::Fix(FixAction {
839                kind: FixActionType::SplitMixedBarrel,
840                auto_fixable: false,
841                description: "Split the barrel so client and server-only modules are re-exported from separate files"
842                    .to_string(),
843                note: Some(
844                    "Importing one name from this barrel drags the other's directive across the client/server boundary"
845                        .to_string(),
846                ),
847                available_in_catalogs: None,
848                suggested_target: None,
849            }),
850            IssueAction::SuppressLine(SuppressLineAction {
851                kind: SuppressLineKind::SuppressLine,
852                auto_fixable: false,
853                description: "Suppress with an inline comment above the line".to_string(),
854                comment: "// fallow-ignore-next-line mixed-client-server-barrel".to_string(),
855                scope: None,
856            }),
857        ];
858        Self {
859            barrel,
860            actions,
861            introduced: None,
862        }
863    }
864}
865
866/// Wire-shape envelope for a [`MisplacedDirective`] finding. There is no safe
867/// auto-fix: moving a directive to the leading prologue is a small but
868/// judgement-bearing edit (the author may have intended the file to be a
869/// server module after all). Actions are a manual `hoist-directive` fix (the
870/// real remediation) plus a line-level suppress.
871#[derive(Debug, Clone, Serialize)]
872#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
873pub struct MisplacedDirectiveFinding {
874    /// The underlying dead-code entry.
875    #[serde(flatten)]
876    pub directive_site: MisplacedDirective,
877    /// Suggested next steps. Always emitted (possibly empty for
878    /// forward-compat).
879    pub actions: Vec<IssueAction>,
880    /// Set by the audit pass when this finding is introduced relative to
881    /// the merge-base.
882    #[serde(default, skip_serializing_if = "Option::is_none")]
883    pub introduced: Option<AuditIntroduced>,
884}
885
886impl MisplacedDirectiveFinding {
887    /// Build the wrapper from a raw [`MisplacedDirective`]. Emits a manual fix
888    /// action (hoist the directive to the leading prologue) plus a line-level
889    /// suppress: there is no safe auto-fix because moving a directive can
890    /// change module semantics and is a human decision.
891    #[must_use]
892    pub fn with_actions(directive_site: MisplacedDirective) -> Self {
893        let actions = vec![
894            IssueAction::Fix(FixAction {
895                kind: FixActionType::HoistDirective,
896                auto_fixable: false,
897                description: "Move the directive to the very top of the file, above all imports and statements"
898                    .to_string(),
899                note: Some(
900                    "An RSC bundler honors the directive only in the leading prologue; here it precedes other statements and is silently ignored"
901                        .to_string(),
902                ),
903                available_in_catalogs: None,
904                suggested_target: None,
905            }),
906            IssueAction::SuppressLine(SuppressLineAction {
907                kind: SuppressLineKind::SuppressLine,
908                auto_fixable: false,
909                description: "Suppress with an inline comment above the line".to_string(),
910                comment: "// fallow-ignore-next-line misplaced-directive".to_string(),
911                scope: None,
912            }),
913        ];
914        Self {
915            directive_site,
916            actions,
917            introduced: None,
918        }
919    }
920}
921
922/// Wire-shape envelope for an [`UnprovidedInject`] finding. There is no safe
923/// auto-fix: the fix is binary but judgement-bearing (add a `provide` for the
924/// key, or delete the dead inject). Actions are manual remediation guidance
925/// plus a line-level suppress.
926#[derive(Debug, Clone, Serialize)]
927#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
928pub struct UnprovidedInjectFinding {
929    /// The underlying finding.
930    #[serde(flatten)]
931    pub inject: UnprovidedInject,
932    /// Suggested next steps. Always emitted (possibly empty for
933    /// forward-compat).
934    pub actions: Vec<IssueAction>,
935    /// Set by the audit pass when this finding is introduced relative to
936    /// the merge-base.
937    #[serde(default, skip_serializing_if = "Option::is_none")]
938    pub introduced: Option<AuditIntroduced>,
939}
940
941impl UnprovidedInjectFinding {
942    /// Build the wrapper from a raw [`UnprovidedInject`]. Emits a manual fix
943    /// action plus a line-level suppress.
944    #[must_use]
945    pub fn with_actions(inject: UnprovidedInject) -> Self {
946        let actions = vec![
947            manual_framework_fix(
948                FixActionType::ProvideInject,
949                "Provide this injected key, or remove the inject / getContext call",
950                "Manual review required: dependency-injection keys can be provided by framework wiring, tests, or package consumers outside this project.",
951            ),
952            suppress_line("// fallow-ignore-next-line unprovided-inject"),
953        ];
954        Self {
955            inject,
956            actions,
957            introduced: None,
958        }
959    }
960}
961
962/// Wire-shape envelope for an [`UnusedServerAction`] finding. There is no safe
963/// auto-fix: the fix is binary but judgement-bearing (wire the action up to a
964/// consumer, or delete it). Actions are manual remediation guidance plus a
965/// line-level suppress.
966#[derive(Debug, Clone, Serialize)]
967#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
968pub struct UnusedServerActionFinding {
969    /// The underlying finding.
970    #[serde(flatten)]
971    pub action: UnusedServerAction,
972    /// Suggested next steps. Always emitted (possibly empty for
973    /// forward-compat).
974    pub actions: Vec<IssueAction>,
975    /// Set by the audit pass when this finding is introduced relative to
976    /// the merge-base.
977    #[serde(default, skip_serializing_if = "Option::is_none")]
978    pub introduced: Option<AuditIntroduced>,
979}
980
981impl UnusedServerActionFinding {
982    /// Build the wrapper from a raw [`UnusedServerAction`]. Emits a manual fix
983    /// action plus a line-level suppress.
984    #[must_use]
985    pub fn with_actions(action: UnusedServerAction) -> Self {
986        let actions = vec![
987            manual_framework_fix(
988                FixActionType::WireServerAction,
989                "Wire the server action to a caller or form action, or remove it",
990                "Manual review required: server actions may still be POST-able by action id or invoked reflectively outside the static project graph.",
991            ),
992            suppress_line("// fallow-ignore-next-line unused-server-action"),
993        ];
994        Self {
995            action,
996            actions,
997            introduced: None,
998        }
999    }
1000}
1001
1002/// Wire-shape envelope for an [`UnusedLoadDataKey`] finding. There is no safe
1003/// auto-fix: a `load()` fetch can have side effects, so deleting the key is a
1004/// human call. Actions are manual remediation guidance plus a line-level
1005/// suppress.
1006#[derive(Debug, Clone, Serialize)]
1007#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1008pub struct UnusedLoadDataKeyFinding {
1009    /// The underlying finding.
1010    #[serde(flatten)]
1011    pub key: UnusedLoadDataKey,
1012    /// Suggested next steps. Always emitted (possibly empty for
1013    /// forward-compat).
1014    pub actions: Vec<IssueAction>,
1015    /// Set by the audit pass when this finding is introduced relative to
1016    /// the merge-base.
1017    #[serde(default, skip_serializing_if = "Option::is_none")]
1018    pub introduced: Option<AuditIntroduced>,
1019}
1020
1021impl UnusedLoadDataKeyFinding {
1022    /// Build the wrapper from a raw [`UnusedLoadDataKey`]. Emits a manual fix
1023    /// action plus a line-level suppress.
1024    #[must_use]
1025    pub fn with_actions(key: UnusedLoadDataKey) -> Self {
1026        let actions = vec![
1027            manual_framework_fix(
1028                FixActionType::UseLoadData,
1029                "Read this load data key from the route UI, or remove it from the load return",
1030                "Manual review required: load functions can perform real server or database work, so verify side effects before deleting the producer.",
1031            ),
1032            suppress_line("// fallow-ignore-next-line unused-load-data-key"),
1033        ];
1034        Self {
1035            key,
1036            actions,
1037            introduced: None,
1038        }
1039    }
1040}
1041
1042/// Wire-shape envelope for an [`UnrenderedComponent`] finding. There is no safe
1043/// auto-fix: the fix is binary but judgement-bearing (render the component
1044/// somewhere, or delete the dead component). Actions are manual remediation
1045/// guidance plus a line-level suppress.
1046#[derive(Debug, Clone, Serialize)]
1047#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1048pub struct UnrenderedComponentFinding {
1049    /// The underlying finding.
1050    #[serde(flatten)]
1051    pub component: UnrenderedComponent,
1052    /// Suggested next steps. Always emitted (possibly empty for
1053    /// forward-compat).
1054    pub actions: Vec<IssueAction>,
1055    /// Set by the audit pass when this finding is introduced relative to
1056    /// the merge-base.
1057    #[serde(default, skip_serializing_if = "Option::is_none")]
1058    pub introduced: Option<AuditIntroduced>,
1059}
1060
1061impl UnrenderedComponentFinding {
1062    /// Build the wrapper from a raw [`UnrenderedComponent`]. Emits a manual
1063    /// fix action plus a line-level suppress.
1064    #[must_use]
1065    pub fn with_actions(component: UnrenderedComponent) -> Self {
1066        let actions = vec![
1067            manual_framework_fix(
1068                FixActionType::RenderComponent,
1069                "Render the reachable component from project code, or remove it",
1070                "Manual review required: exported library components and dynamic render registries can be intentionally reachable without static template usage.",
1071            ),
1072            suppress_line("// fallow-ignore-next-line unrendered-component"),
1073        ];
1074        Self {
1075            component,
1076            actions,
1077            introduced: None,
1078        }
1079    }
1080}
1081
1082/// Wire-shape envelope for an [`UnusedComponentProp`] finding. There is no safe
1083/// auto-fix: removing a declared prop is judgement-bearing (the prop may be part
1084/// of a deliberately-stable public component API). Actions are manual
1085/// remediation guidance plus a line-level suppress at the prop declaration.
1086#[derive(Debug, Clone, Serialize)]
1087#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1088pub struct UnusedComponentPropFinding {
1089    /// The underlying finding.
1090    #[serde(flatten)]
1091    pub prop: UnusedComponentProp,
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 UnusedComponentPropFinding {
1102    /// Build the wrapper from a raw [`UnusedComponentProp`]. Emits a manual
1103    /// fix action plus a line-level suppress.
1104    #[must_use]
1105    pub fn with_actions(prop: UnusedComponentProp) -> Self {
1106        let actions = vec![
1107            manual_framework_fix(
1108                FixActionType::UseComponentProp,
1109                "Use the declared prop in the component, or remove it from the component API",
1110                "Manual review required: public component APIs can intentionally keep stable props for external consumers.",
1111            ),
1112            suppress_line("// fallow-ignore-next-line unused-component-prop"),
1113        ];
1114        Self {
1115            prop,
1116            actions,
1117            introduced: None,
1118        }
1119    }
1120}
1121
1122/// Wire-shape envelope for an [`UnusedComponentEmit`] finding. There is no safe
1123/// auto-fix: removing a declared emit is judgement-bearing (the event may be
1124/// part of a deliberately-stable public component API). Actions are manual
1125/// remediation guidance plus a line-level suppress at the emit declaration.
1126#[derive(Debug, Clone, Serialize)]
1127#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1128pub struct UnusedComponentEmitFinding {
1129    /// The underlying finding.
1130    #[serde(flatten)]
1131    pub emit: UnusedComponentEmit,
1132    /// Suggested next steps. Always emitted (possibly empty for
1133    /// forward-compat).
1134    pub actions: Vec<IssueAction>,
1135    /// Set by the audit pass when this finding is introduced relative to
1136    /// the merge-base.
1137    #[serde(default, skip_serializing_if = "Option::is_none")]
1138    pub introduced: Option<AuditIntroduced>,
1139}
1140
1141impl UnusedComponentEmitFinding {
1142    /// Build the wrapper from a raw [`UnusedComponentEmit`]. Emits a manual
1143    /// fix action plus a line-level suppress.
1144    #[must_use]
1145    pub fn with_actions(emit: UnusedComponentEmit) -> Self {
1146        let actions = vec![
1147            manual_framework_fix(
1148                FixActionType::EmitComponentEvent,
1149                "Emit the declared event from the component, or remove it from the component API",
1150                "Manual review required: public component APIs can intentionally keep stable events for external listeners.",
1151            ),
1152            suppress_line("// fallow-ignore-next-line unused-component-emit"),
1153        ];
1154        Self {
1155            emit,
1156            actions,
1157            introduced: None,
1158        }
1159    }
1160}
1161
1162/// Wire-shape envelope for an [`UnusedSvelteEvent`] finding. There is no safe
1163/// auto-fix: removing a dispatched event is judgement-bearing (the event may be
1164/// part of a deliberately-stable public component API, or a listener may be
1165/// added later). Actions are manual remediation guidance plus a line-level
1166/// suppress at the `dispatch` call.
1167#[derive(Debug, Clone, Serialize)]
1168#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1169pub struct UnusedSvelteEventFinding {
1170    /// The underlying finding.
1171    #[serde(flatten)]
1172    pub event: UnusedSvelteEvent,
1173    /// Suggested next steps. Always emitted (possibly empty for
1174    /// forward-compat).
1175    pub actions: Vec<IssueAction>,
1176    /// Set by the audit pass when this finding is introduced relative to
1177    /// the merge-base.
1178    #[serde(default, skip_serializing_if = "Option::is_none")]
1179    pub introduced: Option<AuditIntroduced>,
1180}
1181
1182impl UnusedSvelteEventFinding {
1183    /// Build the wrapper from a raw [`UnusedSvelteEvent`]. Emits a manual fix
1184    /// action plus a line-level suppress.
1185    #[must_use]
1186    pub fn with_actions(event: UnusedSvelteEvent) -> Self {
1187        let actions = vec![
1188            manual_framework_fix(
1189                FixActionType::WireSvelteEvent,
1190                "Add or forward a listener for this custom event, or remove the dispatch",
1191                "Manual review required: public Svelte component APIs can intentionally dispatch events for package consumers outside this project.",
1192            ),
1193            suppress_line("// fallow-ignore-next-line unused-svelte-event"),
1194        ];
1195        Self {
1196            event,
1197            actions,
1198            introduced: None,
1199        }
1200    }
1201}
1202
1203/// Wire-shape envelope for a [`PropDrillingChain`] finding. There is no safe
1204/// auto-fix: collapsing a drilling chain (colocate the consumer, lift to a
1205/// context, or compose the component) is a design decision. The only action is a
1206/// line-level suppress at the source hop's prop declaration. The rule defaults
1207/// to `off` (opt-in health signal), so this finding is dormant by default.
1208#[derive(Debug, Clone, Serialize)]
1209#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1210pub struct PropDrillingChainFinding {
1211    /// The underlying located chain.
1212    #[serde(flatten)]
1213    pub chain: PropDrillingChain,
1214    /// Suggested next steps. Always emitted (possibly empty for
1215    /// forward-compat).
1216    pub actions: Vec<IssueAction>,
1217    /// Set by the audit pass when this finding is introduced relative to
1218    /// the merge-base.
1219    #[serde(default, skip_serializing_if = "Option::is_none")]
1220    pub introduced: Option<AuditIntroduced>,
1221}
1222
1223impl PropDrillingChainFinding {
1224    /// Build the wrapper from a raw [`PropDrillingChain`]. Emits only a
1225    /// line-level suppress action anchored at the source hop: there is no safe
1226    /// auto-fix because collapsing the chain is a design decision (colocate,
1227    /// lift to context, or compose).
1228    #[must_use]
1229    pub fn with_actions(chain: PropDrillingChain) -> Self {
1230        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1231            kind: SuppressLineKind::SuppressLine,
1232            auto_fixable: false,
1233            description: "Suppress with an inline comment above the source prop declaration"
1234                .to_string(),
1235            comment: "// fallow-ignore-next-line prop-drilling".to_string(),
1236            scope: None,
1237        })];
1238        Self {
1239            chain,
1240            actions,
1241            introduced: None,
1242        }
1243    }
1244}
1245
1246/// Wire-shape envelope for a [`ThinWrapper`] finding. There is no safe
1247/// auto-fix: inlining a thin wrapper at its call sites (or deleting it) is a
1248/// design decision. The only action is a line-level suppress at the wrapper's
1249/// definition. The rule defaults to `off` (opt-in health signal), so this
1250/// finding is dormant by default.
1251#[derive(Debug, Clone, Serialize)]
1252#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1253pub struct ThinWrapperFinding {
1254    /// The underlying located thin wrapper.
1255    #[serde(flatten)]
1256    pub wrapper: ThinWrapper,
1257    /// Suggested next steps. Always emitted (possibly empty for
1258    /// forward-compat).
1259    pub actions: Vec<IssueAction>,
1260    /// Set by the audit pass when this finding is introduced relative to
1261    /// the merge-base.
1262    #[serde(default, skip_serializing_if = "Option::is_none")]
1263    pub introduced: Option<AuditIntroduced>,
1264}
1265
1266impl ThinWrapperFinding {
1267    /// Build the wrapper from a raw [`ThinWrapper`]. Emits only a line-level
1268    /// suppress action anchored at the wrapper definition: there is no safe
1269    /// auto-fix because inlining or deleting the wrapper is a design decision.
1270    #[must_use]
1271    pub fn with_actions(wrapper: ThinWrapper) -> Self {
1272        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1273            kind: SuppressLineKind::SuppressLine,
1274            auto_fixable: false,
1275            description: "Suppress with an inline comment above the component definition"
1276                .to_string(),
1277            comment: "// fallow-ignore-next-line thin-wrapper".to_string(),
1278            scope: None,
1279        })];
1280        Self {
1281            wrapper,
1282            actions,
1283            introduced: None,
1284        }
1285    }
1286}
1287
1288/// Wire-shape envelope for a [`DuplicatePropShape`] finding. There is no safe
1289/// auto-fix: extracting a shared `Props` type or a base component for a group of
1290/// same-shaped components is a design decision. The actions are manual guidance
1291/// (extract the shared shape) plus a line-level suppress at the component
1292/// definition and a file-level suppress escape hatch (mirroring the
1293/// route-collision multi-file model). The rule defaults to `off` (opt-in health
1294/// signal), so this finding is dormant by default.
1295#[derive(Debug, Clone, Serialize)]
1296#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1297pub struct DuplicatePropShapeFinding {
1298    /// The underlying duplicate-prop-shape entry.
1299    #[serde(flatten)]
1300    pub shape: DuplicatePropShape,
1301    /// Suggested next steps. Always emitted (possibly empty for
1302    /// forward-compat).
1303    pub actions: Vec<IssueAction>,
1304    /// Set by the audit pass when this finding is introduced relative to
1305    /// the merge-base.
1306    #[serde(default, skip_serializing_if = "Option::is_none")]
1307    pub introduced: Option<AuditIntroduced>,
1308}
1309
1310impl DuplicatePropShapeFinding {
1311    /// Build the wrapper from a raw [`DuplicatePropShape`]. Manual guidance is
1312    /// the primary action (extract a shared shape); a line-level suppress at the
1313    /// component definition and a file-level suppress escape hatch follow,
1314    /// mirroring the multi-file route-collision suppress model. There is no safe
1315    /// auto-fix because extracting a shared type or base component is a design
1316    /// decision.
1317    #[must_use]
1318    pub fn with_actions(shape: DuplicatePropShape) -> Self {
1319        let actions = vec![
1320            IssueAction::SuppressLine(SuppressLineAction {
1321                kind: SuppressLineKind::SuppressLine,
1322                auto_fixable: false,
1323                description: "Three or more components share this exact prop shape. Extract one \
1324                              shared `Props` type (or a base component) that every member reuses, \
1325                              or keep them separate if a per-variant divergence is planned. \
1326                              Suppress one member with an inline comment above the component \
1327                              definition."
1328                    .to_string(),
1329                comment: "// fallow-ignore-next-line duplicate-prop-shape".to_string(),
1330                scope: None,
1331            }),
1332            IssueAction::SuppressFile(SuppressFileAction {
1333                kind: SuppressFileKind::SuppressFile,
1334                auto_fixable: false,
1335                description: "Escape hatch: a file-level suppress silences this member but it \
1336                              still appears in its siblings' `sharing_components` (the group is \
1337                              real regardless of suppression)."
1338                    .to_string(),
1339                comment: "// fallow-ignore-file duplicate-prop-shape".to_string(),
1340            }),
1341        ];
1342        Self {
1343            shape,
1344            actions,
1345            introduced: None,
1346        }
1347    }
1348}
1349
1350/// Wire-shape envelope for an [`UnusedComponentInput`] finding. There is no safe
1351/// auto-fix: removing a declared input is judgement-bearing (the input may be
1352/// part of a deliberately-stable public component API). The only action is a
1353/// line-level suppress at the input declaration.
1354#[derive(Debug, Clone, Serialize)]
1355#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1356pub struct UnusedComponentInputFinding {
1357    /// The underlying finding.
1358    #[serde(flatten)]
1359    pub input: UnusedComponentInput,
1360    /// Suggested next steps. Always emitted (possibly empty for
1361    /// forward-compat).
1362    pub actions: Vec<IssueAction>,
1363    /// Set by the audit pass when this finding is introduced relative to
1364    /// the merge-base.
1365    #[serde(default, skip_serializing_if = "Option::is_none")]
1366    pub introduced: Option<AuditIntroduced>,
1367}
1368
1369impl UnusedComponentInputFinding {
1370    /// Build the wrapper from a raw [`UnusedComponentInput`]. Emits only a
1371    /// line-level suppress action: there is no safe auto-fix because removing an
1372    /// input is a human decision (it may be part of a stable component API).
1373    #[must_use]
1374    pub fn with_actions(input: UnusedComponentInput) -> Self {
1375        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1376            kind: SuppressLineKind::SuppressLine,
1377            auto_fixable: false,
1378            description: "Suppress with an inline comment above the line".to_string(),
1379            comment: "// fallow-ignore-next-line unused-component-input".to_string(),
1380            scope: None,
1381        })];
1382        Self {
1383            input,
1384            actions,
1385            introduced: None,
1386        }
1387    }
1388}
1389
1390/// Wire-shape envelope for an [`UnusedComponentOutput`] finding. There is no safe
1391/// auto-fix: removing a declared output is judgement-bearing (the event may be
1392/// part of a deliberately-stable public component API). The only action is a
1393/// line-level suppress at the output declaration.
1394#[derive(Debug, Clone, Serialize)]
1395#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1396pub struct UnusedComponentOutputFinding {
1397    /// The underlying finding.
1398    #[serde(flatten)]
1399    pub output: UnusedComponentOutput,
1400    /// Suggested next steps. Always emitted (possibly empty for
1401    /// forward-compat).
1402    pub actions: Vec<IssueAction>,
1403    /// Set by the audit pass when this finding is introduced relative to
1404    /// the merge-base.
1405    #[serde(default, skip_serializing_if = "Option::is_none")]
1406    pub introduced: Option<AuditIntroduced>,
1407}
1408
1409impl UnusedComponentOutputFinding {
1410    /// Build the wrapper from a raw [`UnusedComponentOutput`]. Emits only a
1411    /// line-level suppress action: there is no safe auto-fix because removing an
1412    /// output is a human decision (it may be part of a stable component API).
1413    #[must_use]
1414    pub fn with_actions(output: UnusedComponentOutput) -> Self {
1415        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1416            kind: SuppressLineKind::SuppressLine,
1417            auto_fixable: false,
1418            description: "Suppress with an inline comment above the line".to_string(),
1419            comment: "// fallow-ignore-next-line unused-component-output".to_string(),
1420            scope: None,
1421        })];
1422        Self {
1423            output,
1424            actions,
1425            introduced: None,
1426        }
1427    }
1428}
1429
1430/// Wire-shape envelope for a [`RouteCollision`] finding. A route collision is a
1431/// guaranteed `next build` failure, so the PRIMARY action is manual guidance
1432/// (move or merge one of the colliding files), NOT a suppress: suppressing a
1433/// build error never makes the build pass. A file-level suppress is offered as
1434/// an escape hatch only.
1435#[derive(Debug, Clone, Serialize)]
1436#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1437pub struct RouteCollisionFinding {
1438    /// The underlying route-collision entry.
1439    #[serde(flatten)]
1440    pub collision: RouteCollision,
1441    /// Suggested next steps. Always emitted (possibly empty for
1442    /// forward-compat).
1443    pub actions: Vec<IssueAction>,
1444    /// Set by the audit pass when this finding is introduced relative to
1445    /// the merge-base.
1446    #[serde(default, skip_serializing_if = "Option::is_none")]
1447    pub introduced: Option<AuditIntroduced>,
1448}
1449
1450impl RouteCollisionFinding {
1451    /// Build the wrapper from a raw [`RouteCollision`]. The primary action is
1452    /// manual guidance because suppressing a guaranteed build error is never
1453    /// the right fix; a file-level suppress is the escape hatch only.
1454    #[must_use]
1455    pub fn with_actions(collision: RouteCollision) -> Self {
1456        let actions = vec![
1457            IssueAction::Fix(FixAction {
1458                kind: FixActionType::ResolveRouteCollision,
1459                auto_fixable: false,
1460                description: "Two or more files resolve to the same URL. Move or merge one so \
1461                              each URL has a single owner. Route groups `(name)` and parallel \
1462                              slots `@name` are the only legal same-URL shapes."
1463                    .to_string(),
1464                note: Some(
1465                    "Next.js fails the build with \"You cannot have two parallel pages that \
1466                     resolve to the same path\". See the sibling `conflicting_paths` array for \
1467                     the other files that own this URL."
1468                        .to_string(),
1469                ),
1470                available_in_catalogs: None,
1471                suggested_target: None,
1472            }),
1473            IssueAction::SuppressFile(SuppressFileAction {
1474                kind: SuppressFileKind::SuppressFile,
1475                auto_fixable: false,
1476                description: "Escape hatch only: a file-level suppress silences the finding but \
1477                              does NOT make `next build` pass. Prefer moving or merging a file."
1478                    .to_string(),
1479                comment: "// fallow-ignore-file route-collision".to_string(),
1480            }),
1481        ];
1482        Self {
1483            collision,
1484            actions,
1485            introduced: None,
1486        }
1487    }
1488}
1489
1490/// Wire-shape envelope for a [`DynamicSegmentNameConflict`] finding. The
1491/// conflict is a Next.js dev / runtime error (`next build` does NOT catch it),
1492/// so the primary action is manual guidance (rename the dynamic segments to a
1493/// single consistent slug name), with a file-level suppress as escape hatch.
1494#[derive(Debug, Clone, Serialize)]
1495#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1496pub struct DynamicSegmentNameConflictFinding {
1497    /// The underlying dynamic-segment-name-conflict entry.
1498    #[serde(flatten)]
1499    pub conflict: DynamicSegmentNameConflict,
1500    /// Suggested next steps. Always emitted (possibly empty for
1501    /// forward-compat).
1502    pub actions: Vec<IssueAction>,
1503    /// Set by the audit pass when this finding is introduced relative to
1504    /// the merge-base.
1505    #[serde(default, skip_serializing_if = "Option::is_none")]
1506    pub introduced: Option<AuditIntroduced>,
1507}
1508
1509impl DynamicSegmentNameConflictFinding {
1510    /// Build the wrapper from a raw [`DynamicSegmentNameConflict`]. Manual
1511    /// guidance primary action; file-level suppress escape hatch only.
1512    #[must_use]
1513    pub fn with_actions(conflict: DynamicSegmentNameConflict) -> Self {
1514        let actions = vec![
1515            IssueAction::Fix(FixAction {
1516                kind: FixActionType::ResolveDynamicSegmentNameConflict,
1517                auto_fixable: false,
1518                description: "Sibling dynamic segments at the same position use different param \
1519                              names. Rename them to one consistent slug name (e.g. pick `[id]` \
1520                              or `[slug]` for both)."
1521                    .to_string(),
1522                note: Some(
1523                    "Next.js throws \"You cannot use different slug names for the same dynamic \
1524                     path\" at dev / runtime when the position is hit; `next build` does not \
1525                     catch it. See the sibling `conflicting_segments` array."
1526                        .to_string(),
1527                ),
1528                available_in_catalogs: None,
1529                suggested_target: None,
1530            }),
1531            IssueAction::SuppressFile(SuppressFileAction {
1532                kind: SuppressFileKind::SuppressFile,
1533                auto_fixable: false,
1534                description: "Escape hatch only: a file-level suppress silences the finding but \
1535                              does NOT stop Next.js from throwing at dev / runtime. Prefer \
1536                              renaming the segments."
1537                    .to_string(),
1538                comment: "// fallow-ignore-file dynamic-segment-name-conflict".to_string(),
1539            }),
1540        ];
1541        Self {
1542            conflict,
1543            actions,
1544            introduced: None,
1545        }
1546    }
1547}
1548
1549/// Wire-shape envelope for an [`UnusedMember`] finding consumed under the
1550/// `unused_enum_members` key.
1551#[derive(Debug, Clone, Serialize)]
1552#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1553pub struct UnusedEnumMemberFinding {
1554    /// The underlying dead-code entry.
1555    #[serde(flatten)]
1556    pub member: UnusedMember,
1557    /// Suggested next steps. Always emitted (possibly empty for
1558    /// forward-compat).
1559    pub actions: Vec<IssueAction>,
1560    /// Set by the audit pass when this finding is introduced relative to
1561    /// the merge-base.
1562    #[serde(default, skip_serializing_if = "Option::is_none")]
1563    pub introduced: Option<AuditIntroduced>,
1564}
1565
1566impl UnusedEnumMemberFinding {
1567    /// Build the wrapper from a raw [`UnusedMember`].
1568    #[must_use]
1569    pub fn with_actions(member: UnusedMember) -> Self {
1570        let actions = vec![
1571            IssueAction::Fix(FixAction {
1572                kind: FixActionType::RemoveEnumMember,
1573                auto_fixable: true,
1574                description: "Remove this enum member".to_string(),
1575                note: None,
1576                available_in_catalogs: None,
1577                suggested_target: None,
1578            }),
1579            IssueAction::SuppressLine(SuppressLineAction {
1580                kind: SuppressLineKind::SuppressLine,
1581                auto_fixable: false,
1582                description: "Suppress with an inline comment above the line".to_string(),
1583                comment: "// fallow-ignore-next-line unused-enum-member".to_string(),
1584                scope: None,
1585            }),
1586        ];
1587        Self {
1588            member,
1589            actions,
1590            introduced: None,
1591        }
1592    }
1593}
1594
1595/// Wire-shape envelope for an [`UnusedMember`] finding consumed under the
1596/// `unused_class_members` key. Same Rust struct as
1597/// [`UnusedEnumMemberFinding`]; the fix action and suppress comment carry
1598/// the class-member kebab-case identifier instead.
1599#[derive(Debug, Clone, Serialize)]
1600#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1601pub struct UnusedClassMemberFinding {
1602    /// The underlying dead-code entry.
1603    #[serde(flatten)]
1604    pub member: UnusedMember,
1605    /// Suggested next steps. Always emitted (possibly empty for
1606    /// forward-compat).
1607    pub actions: Vec<IssueAction>,
1608    /// Set by the audit pass when this finding is introduced relative to
1609    /// the merge-base.
1610    #[serde(default, skip_serializing_if = "Option::is_none")]
1611    pub introduced: Option<AuditIntroduced>,
1612}
1613
1614impl UnusedClassMemberFinding {
1615    /// Build the wrapper from a raw [`UnusedMember`]. Class-member fixes
1616    /// are not auto-applied (members can be used via dependency injection
1617    /// or decorators), so `auto_fixable` is `false` and a context note is
1618    /// attached.
1619    #[must_use]
1620    pub fn with_actions(member: UnusedMember) -> Self {
1621        let actions = vec![
1622            IssueAction::Fix(FixAction {
1623                kind: FixActionType::RemoveClassMember,
1624                auto_fixable: false,
1625                description: "Remove this class member".to_string(),
1626                note: Some(
1627                    "Class member may be used via dependency injection or decorators".to_string(),
1628                ),
1629                available_in_catalogs: None,
1630                suggested_target: None,
1631            }),
1632            IssueAction::SuppressLine(SuppressLineAction {
1633                kind: SuppressLineKind::SuppressLine,
1634                auto_fixable: false,
1635                description: "Suppress with an inline comment above the line".to_string(),
1636                comment: "// fallow-ignore-next-line unused-class-member".to_string(),
1637                scope: None,
1638            }),
1639        ];
1640        Self {
1641            member,
1642            actions,
1643            introduced: None,
1644        }
1645    }
1646}
1647
1648/// Wire-shape envelope for an [`UnusedMember`] finding consumed under the
1649/// `unused_store_members` key (a Pinia `state` / `getters` / `actions` key, or
1650/// a setup-store returned key, declared but never accessed by any consumer
1651/// project-wide). Same Rust struct as [`UnusedClassMemberFinding`]. Emits only
1652/// a line-level suppress action: there is no safe auto-fix because a store
1653/// member can be accessed reflectively (a Pinia plugin, `store.$onAction`, or
1654/// dynamic dispatch) in ways syntactic analysis cannot see, so removal is a
1655/// behavioral change the user must own.
1656#[derive(Debug, Clone, Serialize)]
1657#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1658pub struct UnusedStoreMemberFinding {
1659    /// The underlying dead-code entry.
1660    #[serde(flatten)]
1661    pub member: UnusedMember,
1662    /// Suggested next steps. Always emitted (possibly empty for
1663    /// forward-compat).
1664    pub actions: Vec<IssueAction>,
1665    /// Set by the audit pass when this finding is introduced relative to
1666    /// the merge-base.
1667    #[serde(default, skip_serializing_if = "Option::is_none")]
1668    pub introduced: Option<AuditIntroduced>,
1669}
1670
1671impl UnusedStoreMemberFinding {
1672    /// Build the wrapper from a raw [`UnusedMember`]. Emits only a line-level
1673    /// suppress action (no auto-fix: store members can be accessed
1674    /// reflectively, so removal is never provably safe).
1675    #[must_use]
1676    pub fn with_actions(member: UnusedMember) -> Self {
1677        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1678            kind: SuppressLineKind::SuppressLine,
1679            auto_fixable: false,
1680            description: "Suppress with an inline comment above the line".to_string(),
1681            comment: "// fallow-ignore-next-line unused-store-member".to_string(),
1682            scope: None,
1683        })];
1684        Self {
1685            member,
1686            actions,
1687            introduced: None,
1688        }
1689    }
1690}
1691
1692/// Build the `IssueAction` vec for the three `unused_dependencies`,
1693/// `unused_dev_dependencies`, `unused_optional_dependencies` views over the
1694/// same bare [`UnusedDependency`] struct. Each wrapper differs only in the
1695/// `package_json_location` string (`"dependencies"` / `"devDependencies"` /
1696/// `"optionalDependencies"`) baked into the fix-action description and in
1697/// the `suppress_issue_kind` used by the inline-suppress comment. All three
1698/// share the cross-workspace swap (when `dep.used_in_workspaces` is
1699/// non-empty the primary fix flips from `remove-dependency` to
1700/// `move-dependency` because the dep is imported by ANOTHER workspace and
1701/// `fallow fix` cannot safely remove it).
1702fn build_unused_dependency_actions(
1703    dep: &UnusedDependency,
1704    package_json_location: &str,
1705    suppress_issue_kind: &str,
1706) -> Vec<IssueAction> {
1707    let mut actions = Vec::with_capacity(2);
1708    let cross_workspace = !dep.used_in_workspaces.is_empty();
1709    actions.push(if cross_workspace {
1710        IssueAction::Fix(FixAction {
1711            kind: FixActionType::MoveDependency,
1712            auto_fixable: false,
1713            description: "Move this dependency to the workspace package.json that imports it"
1714                .to_string(),
1715            note: Some(
1716                "fallow fix will not remove dependencies that are imported by another workspace"
1717                    .to_string(),
1718            ),
1719            available_in_catalogs: None,
1720            suggested_target: None,
1721        })
1722    } else {
1723        IssueAction::Fix(FixAction {
1724            kind: FixActionType::RemoveDependency,
1725            auto_fixable: true,
1726            description: format!("Remove from {package_json_location} in package.json"),
1727            note: None,
1728            available_in_catalogs: None,
1729            suggested_target: None,
1730        })
1731    });
1732    actions.push(build_ignore_dependencies_suppress_action(
1733        &dep.package_name,
1734        suppress_issue_kind,
1735    ));
1736    actions
1737}
1738
1739/// Build the standard `add-to-config` `ignoreDependencies` suppress action
1740/// for any finding whose primary key is a package name. Used by the four
1741/// dependency-family wrappers (unused / unlisted / type-only / test-only).
1742/// The `_suppress_issue_kind` argument is currently unused; the pre-2.76
1743/// `inject_actions` post-pass also did not embed the issue kind in this
1744/// shape (no inline `// fallow-ignore-next-line ...` comment because the
1745/// finding is anchored at a package.json line, not at a source-file line).
1746fn build_ignore_dependencies_suppress_action(
1747    package_name: &str,
1748    _suppress_issue_kind: &str,
1749) -> IssueAction {
1750    IssueAction::AddToConfig(AddToConfigAction {
1751        kind: AddToConfigKind::AddToConfig,
1752        auto_fixable: false,
1753        description: format!("Add \"{package_name}\" to ignoreDependencies in fallow config"),
1754        config_key: "ignoreDependencies".to_string(),
1755        value: AddToConfigValue::Scalar(package_name.to_string()),
1756        value_schema: Some(
1757            "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items"
1758                .to_string(),
1759        ),
1760    })
1761}
1762
1763/// Wire-shape envelope for an [`UnusedDependency`] finding consumed under
1764/// the `unused_dependencies` key (production deps). Flattens the bare
1765/// finding; the typed `actions` array carries either a `remove-dependency`
1766/// or `move-dependency` primary depending on
1767/// `inner.used_in_workspaces`.
1768#[derive(Debug, Clone, Serialize)]
1769#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1770pub struct UnusedDependencyFinding {
1771    /// The underlying dead-code entry.
1772    #[serde(flatten)]
1773    pub dep: UnusedDependency,
1774    /// Suggested next steps. Always emitted (possibly empty for
1775    /// forward-compat).
1776    pub actions: Vec<IssueAction>,
1777    /// Set by the audit pass when this finding is introduced relative to
1778    /// the merge-base.
1779    #[serde(default, skip_serializing_if = "Option::is_none")]
1780    pub introduced: Option<AuditIntroduced>,
1781}
1782
1783impl UnusedDependencyFinding {
1784    /// Build the wrapper. Switches the primary fix from `remove-dependency`
1785    /// to `move-dependency` when the dep is imported by another workspace.
1786    #[must_use]
1787    pub fn with_actions(dep: UnusedDependency) -> Self {
1788        let actions = build_unused_dependency_actions(&dep, "dependencies", "unused-dependency");
1789        Self {
1790            dep,
1791            actions,
1792            introduced: None,
1793        }
1794    }
1795}
1796
1797/// Wire-shape envelope for an [`UnusedDependency`] finding consumed under
1798/// the `unused_dev_dependencies` key. Same bare struct as
1799/// [`UnusedDependencyFinding`]; the fix description points at
1800/// `devDependencies` and the suppress comment uses
1801/// `unused-dev-dependency`.
1802#[derive(Debug, Clone, Serialize)]
1803#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1804pub struct UnusedDevDependencyFinding {
1805    /// The underlying dead-code entry.
1806    #[serde(flatten)]
1807    pub dep: UnusedDependency,
1808    /// Suggested next steps. Always emitted (possibly empty for
1809    /// forward-compat).
1810    pub actions: Vec<IssueAction>,
1811    /// Set by the audit pass when this finding is introduced relative to
1812    /// the merge-base.
1813    #[serde(default, skip_serializing_if = "Option::is_none")]
1814    pub introduced: Option<AuditIntroduced>,
1815}
1816
1817impl UnusedDevDependencyFinding {
1818    /// Build the wrapper.
1819    #[must_use]
1820    pub fn with_actions(dep: UnusedDependency) -> Self {
1821        let actions =
1822            build_unused_dependency_actions(&dep, "devDependencies", "unused-dev-dependency");
1823        Self {
1824            dep,
1825            actions,
1826            introduced: None,
1827        }
1828    }
1829}
1830
1831/// Wire-shape envelope for an [`UnusedDependency`] finding consumed under
1832/// the `unused_optional_dependencies` key. Same bare struct as
1833/// [`UnusedDependencyFinding`]; the fix description points at
1834/// `optionalDependencies`. Reuses the `unused-dependency` suppress
1835/// `IssueKind` because there is no dedicated variant for optional deps.
1836#[derive(Debug, Clone, Serialize)]
1837#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1838pub struct UnusedOptionalDependencyFinding {
1839    /// The underlying dead-code entry.
1840    #[serde(flatten)]
1841    pub dep: UnusedDependency,
1842    /// Suggested next steps. Always emitted (possibly empty for
1843    /// forward-compat).
1844    pub actions: Vec<IssueAction>,
1845    /// Set by the audit pass when this finding is introduced relative to
1846    /// the merge-base.
1847    #[serde(default, skip_serializing_if = "Option::is_none")]
1848    pub introduced: Option<AuditIntroduced>,
1849}
1850
1851impl UnusedOptionalDependencyFinding {
1852    /// Build the wrapper.
1853    #[must_use]
1854    pub fn with_actions(dep: UnusedDependency) -> Self {
1855        let actions =
1856            build_unused_dependency_actions(&dep, "optionalDependencies", "unused-dependency");
1857        Self {
1858            dep,
1859            actions,
1860            introduced: None,
1861        }
1862    }
1863}
1864
1865/// Wire-shape envelope for an [`UnlistedDependency`] finding. Carries an
1866/// `install-dependency` primary (non-auto-fixable) plus the standard
1867/// `ignoreDependencies` config suppress.
1868#[derive(Debug, Clone, Serialize)]
1869#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1870pub struct UnlistedDependencyFinding {
1871    /// The underlying dead-code entry.
1872    #[serde(flatten)]
1873    pub dep: UnlistedDependency,
1874    /// Suggested next steps. Always emitted (possibly empty for
1875    /// forward-compat).
1876    pub actions: Vec<IssueAction>,
1877    /// Set by the audit pass when this finding is introduced relative to
1878    /// the merge-base.
1879    #[serde(default, skip_serializing_if = "Option::is_none")]
1880    pub introduced: Option<AuditIntroduced>,
1881}
1882
1883impl UnlistedDependencyFinding {
1884    /// Build the wrapper.
1885    #[must_use]
1886    pub fn with_actions(dep: UnlistedDependency) -> Self {
1887        let actions = vec![
1888            IssueAction::Fix(FixAction {
1889                kind: FixActionType::InstallDependency,
1890                auto_fixable: false,
1891                description: "Add this package to dependencies in package.json".to_string(),
1892                note: Some(
1893                    "Verify this package should be a direct dependency before adding".to_string(),
1894                ),
1895                available_in_catalogs: None,
1896                suggested_target: None,
1897            }),
1898            build_ignore_dependencies_suppress_action(&dep.package_name, "unlisted-dependency"),
1899        ];
1900        Self {
1901            dep,
1902            actions,
1903            introduced: None,
1904        }
1905    }
1906}
1907
1908/// Wire-shape envelope for a [`TypeOnlyDependency`] finding. Carries a
1909/// `move-to-dev` primary plus the standard `ignoreDependencies` config
1910/// suppress.
1911#[derive(Debug, Clone, Serialize)]
1912#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1913pub struct TypeOnlyDependencyFinding {
1914    /// The underlying dead-code entry.
1915    #[serde(flatten)]
1916    pub dep: TypeOnlyDependency,
1917    /// Suggested next steps. Always emitted (possibly empty for
1918    /// forward-compat).
1919    pub actions: Vec<IssueAction>,
1920    /// Set by the audit pass when this finding is introduced relative to
1921    /// the merge-base.
1922    #[serde(default, skip_serializing_if = "Option::is_none")]
1923    pub introduced: Option<AuditIntroduced>,
1924}
1925
1926impl TypeOnlyDependencyFinding {
1927    /// Build the wrapper.
1928    #[must_use]
1929    pub fn with_actions(dep: TypeOnlyDependency) -> Self {
1930        let actions = vec![
1931            IssueAction::Fix(FixAction {
1932                kind: FixActionType::MoveToDev,
1933                auto_fixable: false,
1934                description: "Move to devDependencies (only type imports are used)".to_string(),
1935                note: Some(
1936                    "Type imports are erased at runtime so this dependency is not needed in production"
1937                        .to_string(),
1938                ),
1939                available_in_catalogs: None,
1940                suggested_target: None,
1941            }),
1942            build_ignore_dependencies_suppress_action(&dep.package_name, "type-only-dependency"),
1943        ];
1944        Self {
1945            dep,
1946            actions,
1947            introduced: None,
1948        }
1949    }
1950}
1951
1952/// Wire-shape envelope for a [`TestOnlyDependency`] finding. Carries a
1953/// `move-to-dev` primary (different prose than [`TypeOnlyDependencyFinding`])
1954/// plus the standard `ignoreDependencies` config suppress.
1955#[derive(Debug, Clone, Serialize)]
1956#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1957pub struct TestOnlyDependencyFinding {
1958    /// The underlying dead-code entry.
1959    #[serde(flatten)]
1960    pub dep: TestOnlyDependency,
1961    /// Suggested next steps. Always emitted (possibly empty for
1962    /// forward-compat).
1963    pub actions: Vec<IssueAction>,
1964    /// Set by the audit pass when this finding is introduced relative to
1965    /// the merge-base.
1966    #[serde(default, skip_serializing_if = "Option::is_none")]
1967    pub introduced: Option<AuditIntroduced>,
1968}
1969
1970impl TestOnlyDependencyFinding {
1971    /// Build the wrapper.
1972    #[must_use]
1973    pub fn with_actions(dep: TestOnlyDependency) -> Self {
1974        let actions = vec![
1975            IssueAction::Fix(FixAction {
1976                kind: FixActionType::MoveToDev,
1977                auto_fixable: false,
1978                description: "Move to devDependencies (only test files import this)".to_string(),
1979                note: Some(
1980                    "Only test files import this package so it does not need to be a production dependency"
1981                        .to_string(),
1982                ),
1983                available_in_catalogs: None,
1984                suggested_target: None,
1985            }),
1986            build_ignore_dependencies_suppress_action(&dep.package_name, "test-only-dependency"),
1987        ];
1988        Self {
1989            dep,
1990            actions,
1991            introduced: None,
1992        }
1993    }
1994}
1995
1996// ── Catalog / dep-override family ───────────────────────────────
1997//
1998// These six wrappers replace the legacy `inject_actions` post-pass in
1999// `crates/cli/src/report/json.rs` for the catalog and dependency-override
2000// findings. Each `with_actions(...)` builds the typed `actions` array
2001// directly from the inner struct (and any per-call context such as
2002// `config_fixable`), so the wire shape is identical to the pre-2.76
2003// post-pass output but the Rust compiler now owns the action contract.
2004
2005/// Wire-shape envelope for a [`DuplicateExport`] finding. Carries up to
2006/// three actions in position-locked order: an `add-to-config` `ignoreExports`
2007/// snippet (only when `locations[]` carries at least one path) followed by
2008/// the `remove-duplicate` fix and the multi-location suppress.
2009///
2010/// The `add-to-config` action sits at position 0 because the documented
2011/// primary slot points at the safe, non-destructive path: the shadcn /
2012/// Radix / bits-ui namespace-barrel case where every `index.*` reexports
2013/// the directory's neighbours. The `remove-duplicate` fix stays as the
2014/// secondary so consumers that pattern-match on `actions[0].type` for
2015/// "primary fix" never propose deletion of an intentional barrel surface.
2016#[derive(Debug, Clone, Serialize)]
2017#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2018pub struct DuplicateExportFinding {
2019    /// The underlying finding.
2020    #[serde(flatten)]
2021    pub export: DuplicateExport,
2022    /// Suggested next steps. Always emitted (possibly empty for
2023    /// forward-compat).
2024    pub actions: Vec<IssueAction>,
2025    /// Set by the audit pass when this finding is introduced relative to
2026    /// the merge-base.
2027    #[serde(default, skip_serializing_if = "Option::is_none")]
2028    pub introduced: Option<AuditIntroduced>,
2029}
2030
2031impl DuplicateExportFinding {
2032    /// Build the wrapper with the `add-to-config` action's `auto_fixable`
2033    /// defaulting to `false`. The CLI's `build_json_with_config_fixable`
2034    /// path layers the actual `config_fixable` signal via
2035    /// [`Self::set_config_fixable`] right before serialization (the
2036    /// fix-applier readiness check lives in `fallow-cli::fix` and is not
2037    /// reachable from the analyzer layer where wrappers are first built).
2038    /// Embedders that build `AnalysisResults` directly and never route
2039    /// through the CLI's JSON path keep the conservative default.
2040    #[must_use]
2041    pub fn with_actions(export: DuplicateExport) -> Self {
2042        let mut actions: Vec<IssueAction> = Vec::with_capacity(3);
2043
2044        if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
2045            actions.push(IssueAction::AddToConfig(AddToConfigAction {
2046                kind: AddToConfigKind::AddToConfig,
2047                auto_fixable: false,
2048                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(),
2049                config_key: "ignoreExports".to_string(),
2050                value: AddToConfigValue::ExportsRules(rules),
2051                value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
2052            }));
2053        }
2054
2055        actions.push(IssueAction::Fix(FixAction {
2056            kind: FixActionType::RemoveDuplicate,
2057            auto_fixable: false,
2058            description: "Keep one canonical export location and remove the others".to_string(),
2059            note: Some(NAMESPACE_BARREL_HINT.to_string()),
2060            available_in_catalogs: None,
2061            suggested_target: None,
2062        }));
2063
2064        actions.push(IssueAction::SuppressLine(SuppressLineAction {
2065            kind: SuppressLineKind::SuppressLine,
2066            auto_fixable: false,
2067            description: "Suppress with an inline comment above the line".to_string(),
2068            comment: "// fallow-ignore-next-line duplicate-export".to_string(),
2069            scope: Some(SuppressLineScope::PerLocation),
2070        }));
2071
2072        Self {
2073            export,
2074            actions,
2075            introduced: None,
2076        }
2077    }
2078
2079    /// Update the position-0 `add-to-config` action's `auto_fixable` flag.
2080    /// Idempotent and a no-op when position 0 is not an `add-to-config`
2081    /// action (happens when the finding has no locations). Called by the
2082    /// CLI's JSON serializer with the result of
2083    /// `crate::fix::is_config_fixable` before emitting bytes.
2084    pub fn set_config_fixable(&mut self, fixable: bool) {
2085        if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
2086            action.auto_fixable = fixable;
2087        }
2088    }
2089}
2090
2091/// Build a paste-ready `ignoreExports` config value from a duplicate-export
2092/// finding's locations. Returns one `{ file, exports: ["*"] }` entry per
2093/// distinct file in insertion order. `None` when no locations carry a path.
2094fn build_duplicate_exports_ignore_rules(
2095    export: &DuplicateExport,
2096) -> Option<Vec<IgnoreExportsRule>> {
2097    let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
2098    for loc in &export.locations {
2099        // Normalize separators to forward slashes so pasting the action value
2100        // into `.fallowrc.json` produces a portable rule. On Windows
2101        // `to_string_lossy` preserves backslashes, which the old
2102        // `inject_actions` post-pass implicitly normalized because it read
2103        // the path AFTER `strip_root_prefix` had already run through
2104        // `normalize_uri`; the typed wrapper builds the value before
2105        // serialization, so the normalization has to be explicit here.
2106        let path = loc.path.to_string_lossy().replace('\\', "/");
2107        if path.is_empty() {
2108            continue;
2109        }
2110        if entries.iter().any(|existing| existing.file == path) {
2111            continue;
2112        }
2113        entries.push(IgnoreExportsRule {
2114            file: path,
2115            exports: vec!["*".to_string()],
2116        });
2117    }
2118    if entries.is_empty() {
2119        None
2120    } else {
2121        Some(entries)
2122    }
2123}
2124
2125/// Wire-shape envelope for an [`UnusedCatalogEntry`] finding. Per-instance
2126/// `auto_fixable` flips to `false` when `hardcoded_consumers` is non-empty or
2127/// the source is not `pnpm-workspace.yaml`.
2128#[derive(Debug, Clone, Serialize)]
2129#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2130pub struct UnusedCatalogEntryFinding {
2131    /// The underlying finding.
2132    #[serde(flatten)]
2133    pub entry: UnusedCatalogEntry,
2134    /// Suggested next steps. Always emitted.
2135    pub actions: Vec<IssueAction>,
2136    /// Set by the audit pass when this finding is introduced relative to
2137    /// the merge-base.
2138    #[serde(default, skip_serializing_if = "Option::is_none")]
2139    pub introduced: Option<AuditIntroduced>,
2140}
2141
2142impl UnusedCatalogEntryFinding {
2143    /// Build the wrapper. Per-instance `auto_fixable` is `true` only when
2144    /// `hardcoded_consumers` is empty and the source is `pnpm-workspace.yaml`;
2145    /// otherwise `fallow fix` skips the entry to avoid breaking installs or
2146    /// applying YAML edits to Bun `package.json` catalogs.
2147    #[must_use]
2148    pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
2149        let is_pnpm_source = is_pnpm_catalog_source(&entry.path);
2150        let auto_fixable = entry.hardcoded_consumers.is_empty() && is_pnpm_source;
2151        let note = if is_pnpm_source {
2152            Some(
2153                "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing"
2154                    .to_string(),
2155            )
2156        } else {
2157            Some(
2158                "fallow fix only edits pnpm-workspace.yaml catalog entries. Edit Bun package.json catalogs manually."
2159                    .to_string(),
2160            )
2161        };
2162        let mut actions = vec![IssueAction::Fix(FixAction {
2163            kind: FixActionType::RemoveCatalogEntry,
2164            auto_fixable,
2165            description: if is_pnpm_source {
2166                "Remove the entry from pnpm-workspace.yaml".to_string()
2167            } else {
2168                "Remove the entry from the catalog source file manually".to_string()
2169            },
2170            note,
2171            available_in_catalogs: None,
2172            suggested_target: None,
2173        })];
2174        if is_pnpm_source {
2175            actions.push(IssueAction::SuppressLine(SuppressLineAction {
2176                kind: SuppressLineKind::SuppressLine,
2177                auto_fixable: false,
2178                description: "Suppress with a YAML comment above the line".to_string(),
2179                comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
2180                scope: None,
2181            }));
2182        }
2183        Self {
2184            entry,
2185            actions,
2186            introduced: None,
2187        }
2188    }
2189}
2190
2191/// Wire-shape envelope for an [`EmptyCatalogGroup`] finding. Carries a
2192/// `remove-empty-catalog-group` primary. YAML-sourced findings also include a
2193/// YAML-comment suppress action.
2194#[derive(Debug, Clone, Serialize)]
2195#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2196pub struct EmptyCatalogGroupFinding {
2197    /// The underlying finding.
2198    #[serde(flatten)]
2199    pub group: EmptyCatalogGroup,
2200    /// Suggested next steps. Always emitted.
2201    pub actions: Vec<IssueAction>,
2202    /// Set by the audit pass when this finding is introduced relative to
2203    /// the merge-base.
2204    #[serde(default, skip_serializing_if = "Option::is_none")]
2205    pub introduced: Option<AuditIntroduced>,
2206}
2207
2208impl EmptyCatalogGroupFinding {
2209    /// Build the wrapper.
2210    #[must_use]
2211    pub fn with_actions(group: EmptyCatalogGroup) -> Self {
2212        let auto_fixable = is_pnpm_catalog_source(&group.path);
2213        let mut actions = vec![IssueAction::Fix(FixAction {
2214            kind: FixActionType::RemoveEmptyCatalogGroup,
2215            auto_fixable,
2216            description: if auto_fixable {
2217                "Remove the empty named catalog group from pnpm-workspace.yaml".to_string()
2218            } else {
2219                "Remove the empty named catalog group from the catalog source file manually"
2220                    .to_string()
2221            },
2222            note: Some(if auto_fixable {
2223                "Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
2224                    .to_string()
2225            } else {
2226                "fallow fix only edits pnpm-workspace.yaml catalog groups. Edit Bun package.json catalogs manually."
2227                    .to_string()
2228            }),
2229            available_in_catalogs: None,
2230            suggested_target: None,
2231        })];
2232        if auto_fixable {
2233            actions.push(IssueAction::SuppressLine(SuppressLineAction {
2234                kind: SuppressLineKind::SuppressLine,
2235                auto_fixable: false,
2236                description: "Suppress with a YAML comment above the line".to_string(),
2237                comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
2238                scope: None,
2239            }));
2240        }
2241        Self {
2242            group,
2243            actions,
2244            introduced: None,
2245        }
2246    }
2247}
2248
2249fn is_pnpm_catalog_source(path: &Path) -> bool {
2250    path == Path::new(PNPM_WORKSPACE_FILE)
2251}
2252
2253/// Wire-shape envelope for an [`UnresolvedCatalogReference`] finding. The
2254/// primary action at position 0 discriminates on `available_in_catalogs`:
2255/// `add-catalog-entry` when the array is empty (no other catalog declares
2256/// the package), or `update-catalog-reference` when at least one
2257/// alternative exists. When exactly one alternative exists, the action
2258/// also carries `suggested_target` so deterministic agents can land the
2259/// edit without picking from a list.
2260#[derive(Debug, Clone, Serialize)]
2261#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2262pub struct UnresolvedCatalogReferenceFinding {
2263    /// The underlying finding.
2264    #[serde(flatten)]
2265    pub reference: UnresolvedCatalogReference,
2266    /// Suggested next steps. Always emitted; position 0 is the discriminated
2267    /// primary (see struct docs).
2268    pub actions: Vec<IssueAction>,
2269    /// Set by the audit pass when this finding is introduced relative to
2270    /// the merge-base.
2271    #[serde(default, skip_serializing_if = "Option::is_none")]
2272    pub introduced: Option<AuditIntroduced>,
2273}
2274
2275impl UnresolvedCatalogReferenceFinding {
2276    /// Build the wrapper. The discriminator at position 0 is the
2277    /// `add-catalog-entry` vs `update-catalog-reference` pick documented on
2278    /// the struct.
2279    #[must_use]
2280    pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
2281        // Normalize separators to forward slashes so the
2282        // `ignoreCatalogReferences.consumer` action value is portable when
2283        // pasted into a Windows-authored config. See
2284        // `build_duplicate_exports_ignore_rules` for the same pattern.
2285        let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
2286        let primary = if reference.available_in_catalogs.is_empty() {
2287            IssueAction::Fix(FixAction {
2288                kind: FixActionType::AddCatalogEntry,
2289                auto_fixable: false,
2290                description: format!(
2291                    "Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
2292                    reference.entry_name, reference.catalog_name
2293                ),
2294                note: Some(
2295                    "Pin a version that satisfies the consumer's import; no other catalog declares this package today"
2296                        .to_string(),
2297                ),
2298                available_in_catalogs: None,
2299                suggested_target: None,
2300            })
2301        } else {
2302            let available = reference.available_in_catalogs.clone();
2303            let suggested_target = (available.len() == 1).then(|| available[0].clone());
2304            IssueAction::Fix(FixAction {
2305                kind: FixActionType::UpdateCatalogReference,
2306                auto_fixable: false,
2307                description: format!(
2308                    "Switch the reference from `catalog:{}` to a catalog that declares `{}`",
2309                    reference.catalog_name, reference.entry_name
2310                ),
2311                note: None,
2312                available_in_catalogs: Some(available),
2313                suggested_target,
2314            })
2315        };
2316
2317        let fallback = IssueAction::Fix(FixAction {
2318            kind: FixActionType::RemoveCatalogReference,
2319            auto_fixable: false,
2320            description:
2321                "Remove the catalog reference and pin a hardcoded version in package.json"
2322                    .to_string(),
2323            note: Some(
2324                "Use only when neither another catalog declares the package nor the named catalog should grow to include it"
2325                    .to_string(),
2326            ),
2327            available_in_catalogs: None,
2328            suggested_target: None,
2329        });
2330
2331        let mut suppress_value = serde_json::Map::new();
2332        suppress_value.insert(
2333            "package".to_string(),
2334            serde_json::Value::String(reference.entry_name.clone()),
2335        );
2336        suppress_value.insert(
2337            "catalog".to_string(),
2338            serde_json::Value::String(reference.catalog_name.clone()),
2339        );
2340        suppress_value.insert(
2341            "consumer".to_string(),
2342            serde_json::Value::String(consumer_path),
2343        );
2344        let suppress = IssueAction::AddToConfig(AddToConfigAction {
2345            kind: AddToConfigKind::AddToConfig,
2346            auto_fixable: false,
2347            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(),
2348            config_key: "ignoreCatalogReferences".to_string(),
2349            value: AddToConfigValue::RuleObject(suppress_value),
2350            value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
2351        });
2352
2353        Self {
2354            reference,
2355            actions: vec![primary, fallback, suppress],
2356            introduced: None,
2357        }
2358    }
2359}
2360
2361/// Wire-shape envelope for an [`UnusedDependencyOverride`] finding. Carries
2362/// a `remove-dependency-override` primary plus an `add-to-config`
2363/// `ignoreDependencyOverrides` suppress scoped to the target package and
2364/// declaration source.
2365#[derive(Debug, Clone, Serialize)]
2366#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2367pub struct UnusedDependencyOverrideFinding {
2368    /// The underlying finding.
2369    #[serde(flatten)]
2370    pub entry: UnusedDependencyOverride,
2371    /// Suggested next steps. Always emitted.
2372    pub actions: Vec<IssueAction>,
2373    /// Set by the audit pass when this finding is introduced relative to
2374    /// the merge-base.
2375    #[serde(default, skip_serializing_if = "Option::is_none")]
2376    pub introduced: Option<AuditIntroduced>,
2377}
2378
2379impl UnusedDependencyOverrideFinding {
2380    /// Build the wrapper.
2381    #[must_use]
2382    pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
2383        let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
2384        actions.push(IssueAction::Fix(FixAction {
2385            kind: FixActionType::RemoveDependencyOverride,
2386            auto_fixable: false,
2387            description: "Remove the override entry from pnpm-workspace.yaml or pnpm.overrides"
2388                .to_string(),
2389            note: Some(
2390                "Conservative static check; verify against `pnpm install --frozen-lockfile` before removing in case the override targets a transitive dependency (CVE-fix pattern)"
2391                    .to_string(),
2392            ),
2393            available_in_catalogs: None,
2394            suggested_target: None,
2395        }));
2396
2397        if let Some(suppress) = build_ignore_dependency_overrides_suppress(
2398            Some(&entry.target_package),
2399            &entry.raw_key,
2400            entry.source,
2401        ) {
2402            actions.push(suppress);
2403        }
2404
2405        Self {
2406            entry,
2407            actions,
2408            introduced: None,
2409        }
2410    }
2411}
2412
2413/// Wire-shape envelope for a [`MisconfiguredDependencyOverride`] finding.
2414/// Carries a `fix-dependency-override` primary plus the conditional
2415/// `add-to-config` `ignoreDependencyOverrides` suppress (skipped when both
2416/// `target_package` and `raw_key` are empty, since the rule matcher keys on
2417/// a non-empty package name).
2418#[derive(Debug, Clone, Serialize)]
2419#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2420pub struct MisconfiguredDependencyOverrideFinding {
2421    /// The underlying finding.
2422    #[serde(flatten)]
2423    pub entry: MisconfiguredDependencyOverride,
2424    /// Suggested next steps. Always emitted.
2425    pub actions: Vec<IssueAction>,
2426    /// Set by the audit pass when this finding is introduced relative to
2427    /// the merge-base.
2428    #[serde(default, skip_serializing_if = "Option::is_none")]
2429    pub introduced: Option<AuditIntroduced>,
2430}
2431
2432impl MisconfiguredDependencyOverrideFinding {
2433    /// Build the wrapper. The suppress action is omitted when neither
2434    /// `target_package` (set on `EmptyValue` cases) nor `raw_key` provides a
2435    /// non-empty package name; an `ignoreDependencyOverrides` entry with
2436    /// `package: ""` would be silently ignored by the config parser.
2437    #[must_use]
2438    pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
2439        let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
2440        actions.push(IssueAction::Fix(FixAction {
2441            kind: FixActionType::FixDependencyOverride,
2442            auto_fixable: false,
2443            description:
2444                "Fix the override key or value: pnpm refuses to honor entries with an unparsable key or empty value"
2445                    .to_string(),
2446            note: Some(
2447                "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`."
2448                    .to_string(),
2449            ),
2450            available_in_catalogs: None,
2451            suggested_target: None,
2452        }));
2453
2454        if let Some(suppress) = build_ignore_dependency_overrides_suppress(
2455            entry.target_package.as_deref(),
2456            &entry.raw_key,
2457            entry.source,
2458        ) {
2459            actions.push(suppress);
2460        }
2461
2462        Self {
2463            entry,
2464            actions,
2465            introduced: None,
2466        }
2467    }
2468}
2469
2470/// Shared `add-to-config` `ignoreDependencyOverrides` builder for the two
2471/// override findings. Returns `None` when no non-empty package name is
2472/// available; the config parser silently drops entries with an empty
2473/// `package` field, so emitting one would be a no-op that misleads agents.
2474fn build_ignore_dependency_overrides_suppress(
2475    target_package: Option<&str>,
2476    raw_key: &str,
2477    source: DependencyOverrideSource,
2478) -> Option<IssueAction> {
2479    let package = target_package
2480        .filter(|s| !s.is_empty())
2481        .or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
2482        .to_string();
2483    let mut value = serde_json::Map::new();
2484    value.insert("package".to_string(), serde_json::Value::String(package));
2485    value.insert(
2486        "source".to_string(),
2487        serde_json::Value::String(source.as_label().to_string()),
2488    );
2489    Some(IssueAction::AddToConfig(AddToConfigAction {
2490        kind: AddToConfigKind::AddToConfig,
2491        auto_fixable: false,
2492        description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
2493        config_key: "ignoreDependencyOverrides".to_string(),
2494        value: AddToConfigValue::RuleObject(value),
2495        value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
2496    }))
2497}
2498
2499// ── Position-0 invariant golden tests ───────────────────────────
2500//
2501// These tests document the load-bearing position-0 semantics that flow
2502// downstream into the GitHub Action / GitLab CI jq scripts, the MCP server
2503// `actions[0].type` pattern-match, and the VS Code LSP code-action
2504// rendering. Snapshot tests assert structural equality; these named tests
2505// document WHY position 0 has a specific value, so a future refactor that
2506// re-orders actions tells you what broke instead of just "the snapshot
2507// changed".
2508#[cfg(test)]
2509mod position_0_invariants {
2510    use super::*;
2511    use crate::output::FixActionType;
2512    use crate::results::{DependencyOverrideSource, DuplicateLocation};
2513    use std::path::PathBuf;
2514
2515    /// Helper: extract the kebab-case `type` discriminant from an
2516    /// [`IssueAction`] at a specific position. Returns `None` when the
2517    /// position is out of bounds or the action shape lacks a discriminant
2518    /// (today every variant has one).
2519    fn action_type(action: &IssueAction) -> &'static str {
2520        match action {
2521            IssueAction::Fix(fix) => match fix.kind {
2522                FixActionType::RemoveExport => "remove-export",
2523                FixActionType::DeleteFile => "delete-file",
2524                FixActionType::RemoveDependency => "remove-dependency",
2525                FixActionType::MoveDependency => "move-dependency",
2526                FixActionType::RemoveEnumMember => "remove-enum-member",
2527                FixActionType::RemoveClassMember => "remove-class-member",
2528                FixActionType::ResolveImport => "resolve-import",
2529                FixActionType::InstallDependency => "install-dependency",
2530                FixActionType::RemoveDuplicate => "remove-duplicate",
2531                FixActionType::MoveToDev => "move-to-dev",
2532                FixActionType::RefactorCycle => "refactor-cycle",
2533                FixActionType::RefactorReExportCycle => "refactor-re-export-cycle",
2534                FixActionType::RefactorBoundary => "refactor-boundary",
2535                FixActionType::ExportType => "export-type",
2536                FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
2537                FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
2538                FixActionType::UpdateCatalogReference => "update-catalog-reference",
2539                FixActionType::AddCatalogEntry => "add-catalog-entry",
2540                FixActionType::RemoveCatalogReference => "remove-catalog-reference",
2541                FixActionType::RemoveDependencyOverride => "remove-dependency-override",
2542                FixActionType::FixDependencyOverride => "fix-dependency-override",
2543                FixActionType::ResolvePolicyViolation => "resolve-policy-violation",
2544                FixActionType::MoveToServerModule => "move-to-server-module",
2545                FixActionType::SplitMixedBarrel => "split-mixed-barrel",
2546                FixActionType::HoistDirective => "hoist-directive",
2547                FixActionType::WireServerAction => "wire-server-action",
2548                FixActionType::ProvideInject => "provide-inject",
2549                FixActionType::UseLoadData => "use-load-data",
2550                FixActionType::RenderComponent => "render-component",
2551                FixActionType::UseComponentProp => "use-component-prop",
2552                FixActionType::EmitComponentEvent => "emit-component-event",
2553                FixActionType::WireSvelteEvent => "wire-svelte-event",
2554                FixActionType::ResolveRouteCollision => "resolve-route-collision",
2555                FixActionType::ResolveDynamicSegmentNameConflict => {
2556                    "resolve-dynamic-segment-name-conflict"
2557                }
2558                FixActionType::AddSuppressionReason => "add-suppression-reason",
2559                FixActionType::RemoveStaleSuppression => "remove-stale-suppression",
2560            },
2561            IssueAction::SuppressLine(_) => "suppress-line",
2562            IssueAction::SuppressFile(_) => "suppress-file",
2563            IssueAction::AddToConfig(_) => "add-to-config",
2564        }
2565    }
2566
2567    fn assert_manual_fix_then_suppress(
2568        actions: &[IssueAction],
2569        primary_type: &str,
2570        suppress_comment: &str,
2571    ) {
2572        assert_eq!(actions.len(), 2);
2573        assert_eq!(action_type(&actions[0]), primary_type);
2574        let IssueAction::Fix(primary) = &actions[0] else {
2575            panic!("position-0 should be a manual fix action");
2576        };
2577        assert!(!primary.auto_fixable);
2578        assert!(primary.note.is_some());
2579        assert_eq!(action_type(&actions[1]), "suppress-line");
2580        let IssueAction::SuppressLine(suppress) = &actions[1] else {
2581            panic!("position-1 should be a suppress-line action");
2582        };
2583        assert_eq!(suppress.comment, suppress_comment);
2584    }
2585
2586    #[test]
2587    fn pnpm_catalog_entry_action_is_auto_fixable() {
2588        let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
2589            entry_name: "unused".to_string(),
2590            catalog_name: "default".to_string(),
2591            path: PathBuf::from("pnpm-workspace.yaml"),
2592            line: 3,
2593            hardcoded_consumers: vec![],
2594        });
2595
2596        let IssueAction::Fix(fix) = &finding.actions[0] else {
2597            panic!("position-0 should be a fix action");
2598        };
2599        assert!(fix.auto_fixable);
2600        assert_eq!(finding.actions.len(), 2);
2601        assert_eq!(action_type(&finding.actions[1]), "suppress-line");
2602    }
2603
2604    #[test]
2605    fn bun_package_json_catalog_entry_action_is_manual_only() {
2606        let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
2607            entry_name: "unused".to_string(),
2608            catalog_name: "default".to_string(),
2609            path: PathBuf::from("package.json"),
2610            line: 4,
2611            hardcoded_consumers: vec![],
2612        });
2613
2614        let IssueAction::Fix(fix) = &finding.actions[0] else {
2615            panic!("position-0 should be a fix action");
2616        };
2617        assert!(!fix.auto_fixable);
2618        assert!(fix.description.contains("manually"));
2619        assert_eq!(finding.actions.len(), 1);
2620    }
2621
2622    #[test]
2623    fn bun_package_json_empty_catalog_group_action_is_manual_only() {
2624        let finding = EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
2625            catalog_name: "empty".to_string(),
2626            path: PathBuf::from("package.json"),
2627            line: 4,
2628        });
2629
2630        let IssueAction::Fix(fix) = &finding.actions[0] else {
2631            panic!("position-0 should be a fix action");
2632        };
2633        assert!(!fix.auto_fixable);
2634        assert!(fix.description.contains("manually"));
2635        assert_eq!(finding.actions.len(), 1);
2636    }
2637
2638    #[test]
2639    fn unprovided_inject_primary_action_is_provide_inject() {
2640        let finding = UnprovidedInjectFinding::with_actions(UnprovidedInject {
2641            path: PathBuf::from("src/context.ts"),
2642            key_name: "userKey".to_string(),
2643            framework: "svelte".to_string(),
2644            line: 7,
2645            col: 12,
2646        });
2647
2648        assert_manual_fix_then_suppress(
2649            &finding.actions,
2650            "provide-inject",
2651            "// fallow-ignore-next-line unprovided-inject",
2652        );
2653    }
2654
2655    #[test]
2656    fn unused_server_action_primary_action_is_wire_server_action() {
2657        let finding = UnusedServerActionFinding::with_actions(UnusedServerAction {
2658            path: PathBuf::from("app/actions.ts"),
2659            action_name: "saveDraft".to_string(),
2660            line: 3,
2661            col: 13,
2662        });
2663
2664        assert_manual_fix_then_suppress(
2665            &finding.actions,
2666            "wire-server-action",
2667            "// fallow-ignore-next-line unused-server-action",
2668        );
2669    }
2670
2671    #[test]
2672    fn unused_load_data_key_primary_action_is_use_load_data() {
2673        let finding = UnusedLoadDataKeyFinding::with_actions(UnusedLoadDataKey {
2674            path: PathBuf::from("src/routes/+page.server.ts"),
2675            key_name: "profile".to_string(),
2676            line: 12,
2677            col: 6,
2678            route_dir: Some("src/routes".to_string()),
2679        });
2680
2681        assert_manual_fix_then_suppress(
2682            &finding.actions,
2683            "use-load-data",
2684            "// fallow-ignore-next-line unused-load-data-key",
2685        );
2686    }
2687
2688    #[test]
2689    fn unrendered_component_primary_action_is_render_component() {
2690        let finding = UnrenderedComponentFinding::with_actions(UnrenderedComponent {
2691            path: PathBuf::from("src/components/EmptyState.vue"),
2692            component_name: "EmptyState".to_string(),
2693            framework: "vue".to_string(),
2694            reachable_via: None,
2695            line: 1,
2696            col: 0,
2697        });
2698
2699        assert_manual_fix_then_suppress(
2700            &finding.actions,
2701            "render-component",
2702            "// fallow-ignore-next-line unrendered-component",
2703        );
2704    }
2705
2706    #[test]
2707    fn unused_component_prop_primary_action_is_use_component_prop() {
2708        let finding = UnusedComponentPropFinding::with_actions(UnusedComponentProp {
2709            path: PathBuf::from("src/components/Card.vue"),
2710            component_name: "Card".to_string(),
2711            prop_name: "variant".to_string(),
2712            line: 5,
2713            col: 10,
2714        });
2715
2716        assert_manual_fix_then_suppress(
2717            &finding.actions,
2718            "use-component-prop",
2719            "// fallow-ignore-next-line unused-component-prop",
2720        );
2721    }
2722
2723    #[test]
2724    fn unused_component_emit_primary_action_is_emit_component_event() {
2725        let finding = UnusedComponentEmitFinding::with_actions(UnusedComponentEmit {
2726            path: PathBuf::from("src/components/Picker.vue"),
2727            component_name: "Picker".to_string(),
2728            emit_name: "focus".to_string(),
2729            line: 6,
2730            col: 14,
2731        });
2732
2733        assert_manual_fix_then_suppress(
2734            &finding.actions,
2735            "emit-component-event",
2736            "// fallow-ignore-next-line unused-component-emit",
2737        );
2738    }
2739
2740    #[test]
2741    fn unused_svelte_event_primary_action_is_wire_svelte_event() {
2742        let finding = UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
2743            path: PathBuf::from("src/Dialog.svelte"),
2744            component_name: "Dialog".to_string(),
2745            event_name: "closed".to_string(),
2746            line: 19,
2747            col: 8,
2748        });
2749
2750        assert_manual_fix_then_suppress(
2751            &finding.actions,
2752            "wire-svelte-event",
2753            "// fallow-ignore-next-line unused-svelte-event",
2754        );
2755    }
2756
2757    #[test]
2758    fn unresolved_import_actions_include_ignore_unresolved_imports_config_suppress() {
2759        let inner = UnresolvedImport {
2760            specifier: "@example/icons".to_string(),
2761            path: PathBuf::from("src/index.ts"),
2762            line: 4,
2763            col: 12,
2764            specifier_col: 18,
2765        };
2766        let finding = UnresolvedImportFinding::with_actions(inner);
2767
2768        assert_eq!(action_type(&finding.actions[0]), "resolve-import");
2769        assert_eq!(action_type(&finding.actions[1]), "add-to-config");
2770        let IssueAction::AddToConfig(action) = &finding.actions[1] else {
2771            panic!("position-1 should be AddToConfig");
2772        };
2773        assert!(!action.auto_fixable);
2774        assert_eq!(action.config_key, "ignoreUnresolvedImports");
2775        let AddToConfigValue::Scalar(value) = &action.value else {
2776            panic!("ignoreUnresolvedImports action should carry a scalar value");
2777        };
2778        assert_eq!(value, "@example/icons");
2779        assert_eq!(
2780            action.value_schema.as_deref(),
2781            Some(
2782                "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
2783            )
2784        );
2785    }
2786
2787    /// Invariant: when no other catalog declares the package, position 0
2788    /// of `unresolved_catalog_references[].actions` is `add-catalog-entry`,
2789    /// directing the agent to grow the targeted catalog.
2790    ///
2791    /// Downstream consumers (MCP `actions[0].type` dispatch, jq scripts in
2792    /// `action/jq/review-comments-check.jq` and `ci/jq/review-check.jq`)
2793    /// pattern-match on this string. A future refactor that puts the
2794    /// generic `remove-catalog-reference` fallback at position 0 would
2795    /// flip every CI annotation from "add this entry" to "remove this
2796    /// reference", reversing the recommended action.
2797    #[test]
2798    fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
2799        let inner = UnresolvedCatalogReference {
2800            entry_name: "react".to_string(),
2801            catalog_name: "default".to_string(),
2802            path: PathBuf::from("apps/web/package.json"),
2803            line: 7,
2804            available_in_catalogs: Vec::new(),
2805        };
2806        let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
2807        assert_eq!(
2808            action_type(&finding.actions[0]),
2809            "add-catalog-entry",
2810            "position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
2811        );
2812        let IssueAction::Fix(fix) = &finding.actions[0] else {
2813            panic!("position-0 should be an IssueAction::Fix");
2814        };
2815        assert!(
2816            fix.available_in_catalogs.is_none(),
2817            "add-catalog-entry must NOT carry available_in_catalogs"
2818        );
2819        assert!(
2820            fix.suggested_target.is_none(),
2821            "add-catalog-entry must NOT carry suggested_target"
2822        );
2823    }
2824
2825    /// Invariant: when at least one alternative catalog declares the
2826    /// package, position 0 flips to `update-catalog-reference` and carries
2827    /// the alternative list. When exactly one alternative exists, the
2828    /// action also carries `suggested_target` so deterministic agents can
2829    /// land the edit without picking from the list. This is the
2830    /// counterpart to `unresolved_catalog_position_0_is_add_when_no_alternatives`.
2831    #[test]
2832    fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
2833        let inner = UnresolvedCatalogReference {
2834            entry_name: "react".to_string(),
2835            catalog_name: "default".to_string(),
2836            path: PathBuf::from("apps/web/package.json"),
2837            line: 7,
2838            available_in_catalogs: vec!["react18".to_string()],
2839        };
2840        let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
2841        assert_eq!(
2842            action_type(&finding.actions[0]),
2843            "update-catalog-reference",
2844            "position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
2845        );
2846        let IssueAction::Fix(fix) = &finding.actions[0] else {
2847            panic!("position-0 should be an IssueAction::Fix");
2848        };
2849        assert_eq!(
2850            fix.available_in_catalogs.as_deref(),
2851            Some(&["react18".to_string()][..]),
2852            "update-catalog-reference must carry the alternative list"
2853        );
2854        assert_eq!(
2855            fix.suggested_target.as_deref(),
2856            Some("react18"),
2857            "single-alternative case must surface `suggested_target` for deterministic agents"
2858        );
2859
2860        // Two alternatives: still update, but no unambiguous target.
2861        let inner_two = UnresolvedCatalogReference {
2862            entry_name: "react".to_string(),
2863            catalog_name: "default".to_string(),
2864            path: PathBuf::from("apps/web/package.json"),
2865            line: 7,
2866            available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
2867        };
2868        let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
2869        assert_eq!(
2870            action_type(&finding_two.actions[0]),
2871            "update-catalog-reference"
2872        );
2873        let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
2874            panic!("position-0 should be an IssueAction::Fix");
2875        };
2876        assert!(
2877            fix_two.suggested_target.is_none(),
2878            "multi-alternative case must NOT carry `suggested_target` (agent must pick)"
2879        );
2880    }
2881
2882    /// Invariant: position 0 of `duplicate_exports[].actions` is
2883    /// `add-to-config` (the safe `ignoreExports` rule for the
2884    /// namespace-barrel case), NOT the destructive `remove-duplicate`.
2885    ///
2886    /// This protects the shadcn / Radix / bits-ui pattern where every
2887    /// `components/ui/<name>/index.ts` intentionally re-exports the same
2888    /// short names. Any consumer that reads `actions[0].type` as "the
2889    /// recommended fix" must see the non-destructive path first; flipping
2890    /// position 0 to `remove-duplicate` would propose deleting an
2891    /// intentional API surface.
2892    ///
2893    /// This test pins position 0 across both possible auto_fixable values
2894    /// for the add-to-config action (the per-instance flip flag handled
2895    /// by `set_config_fixable`).
2896    #[test]
2897    fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
2898        let inner = DuplicateExport {
2899            export_name: "Root".to_string(),
2900            locations: vec![
2901                DuplicateLocation {
2902                    path: PathBuf::from("components/ui/accordion/index.ts"),
2903                    line: 1,
2904                    col: 0,
2905                },
2906                DuplicateLocation {
2907                    path: PathBuf::from("components/ui/dialog/index.ts"),
2908                    line: 1,
2909                    col: 0,
2910                },
2911            ],
2912        };
2913        let finding = DuplicateExportFinding::with_actions(inner);
2914        assert_eq!(
2915            action_type(&finding.actions[0]),
2916            "add-to-config",
2917            "position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
2918        );
2919        assert_eq!(
2920            action_type(&finding.actions[1]),
2921            "remove-duplicate",
2922            "position-1 must be the destructive `remove-duplicate` fallback"
2923        );
2924
2925        // `set_config_fixable(true)` flips the position-0 add-to-config
2926        // bool but must NOT re-order positions.
2927        let mut promoted = finding;
2928        promoted.set_config_fixable(true);
2929        assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
2930        let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
2931            panic!("position-0 should still be AddToConfig after set_config_fixable");
2932        };
2933        assert!(
2934            action.auto_fixable,
2935            "set_config_fixable(true) must flip auto_fixable"
2936        );
2937    }
2938
2939    /// Invariant: a duplicate-exports finding with empty `locations`
2940    /// degenerate input drops the `add-to-config` action entirely, so
2941    /// position 0 falls through to `remove-duplicate`. Documents the
2942    /// degenerate-case contract.
2943    #[test]
2944    fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
2945        let inner = DuplicateExport {
2946            export_name: "Root".to_string(),
2947            locations: Vec::new(),
2948        };
2949        let finding = DuplicateExportFinding::with_actions(inner);
2950        assert_eq!(
2951            action_type(&finding.actions[0]),
2952            "remove-duplicate",
2953            "with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
2954        );
2955
2956        // `set_config_fixable(true)` is a no-op on this shape.
2957        let mut promoted = finding;
2958        promoted.set_config_fixable(true);
2959        assert_eq!(
2960            action_type(&promoted.actions[0]),
2961            "remove-duplicate",
2962            "set_config_fixable is a no-op when position-0 is not add-to-config"
2963        );
2964    }
2965
2966    /// Invariant: misconfigured-dependency-override with empty
2967    /// `target_package` AND empty `raw_key` drops the suppress action
2968    /// (no usable package name for the `ignoreDependencyOverrides`
2969    /// matcher; emitting `package: ""` would be silently dropped by the
2970    /// config parser). Documents the suppress-omission contract.
2971    #[test]
2972    fn misconfigured_override_drops_suppress_when_no_package_name() {
2973        let inner = MisconfiguredDependencyOverride {
2974            raw_key: String::new(),
2975            target_package: None,
2976            raw_value: String::new(),
2977            reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
2978            source: DependencyOverrideSource::PnpmWorkspaceYaml,
2979            path: PathBuf::from("pnpm-workspace.yaml"),
2980            line: 12,
2981        };
2982        let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
2983        // Only the primary fix-dependency-override action: no suppress.
2984        assert_eq!(finding.actions.len(), 1);
2985        assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
2986    }
2987}