Skip to main content

fallow_types/
output_dead_code.rs

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