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