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