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::{Deserialize, 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    AbsentComponentProp, BoundaryCallViolation, BoundaryCoverageViolation, BoundaryViolation,
41    CircularDependency, DependencyOverrideSource, DeprecatedExportInUse, DevDependencyInProduction,
42    DuplicateExport, DuplicatePropShape, DynamicSegmentNameConflict, EmptyCatalogGroup,
43    InvalidClientExport, MisconfiguredDependencyOverride, MisplacedDirective,
44    MixedClientServerBarrel, PackageCycle, PolicyViolation, PrivateTypeLeak, PropDrillingChain,
45    ReExportCycle, ReExportCycleKind, RouteCollision, TestOnlyDependency, ThinWrapper,
46    TypeOnlyDependency, UnlistedDependency, UnproducedCatchTag, UnprovidedInject,
47    UnrenderedComponent, UnresolvedCatalogReference, UnresolvedImport, UnusedCatalogEntry,
48    UnusedComponentEmit, UnusedComponentInput, UnusedComponentOutput, UnusedComponentProp,
49    UnusedDependency, UnusedDependencyOverride, UnusedEffectLayer, UnusedExport, UnusedFile,
50    UnusedLoadDataKey, UnusedMember, UnusedServerAction, UnusedSvelteEvent, UnusedTaggedError,
51};
52use crate::semantic::{
53    SemanticCandidateDecision, SemanticCandidateDecisionKind, SemanticCompleteness,
54};
55
56/// Shared note for the `duplicate-exports` fix action. This const is the
57/// single source of truth: `crates/cli/src/report/shared.rs` aliases it for
58/// the human report.
59pub 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.";
60
61/// JSON Schema fragment URL for the `add-to-config` `ignoreExports` action's
62/// `value` payload. Pinned to the main branch so users browsing the action
63/// value can navigate directly to the rule shape.
64const IGNORE_EXPORTS_VALUE_SCHEMA: &str =
65    "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreExports";
66
67/// JSON Schema fragment URL for the `ignoreCatalogReferences` rule items
68/// referenced by `add-to-config` actions on `unresolved-catalog-references`.
69const IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreCatalogReferences/items";
70
71/// JSON Schema fragment URL for the `ignoreDependencyOverrides` rule items
72/// referenced by `add-to-config` actions on both the unused- and
73/// misconfigured-override findings.
74const IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencyOverrides/items";
75
76const PNPM_WORKSPACE_FILE: &str = "pnpm-workspace.yaml";
77
78fn manual_framework_fix(kind: FixActionType, description: &str, note: &str) -> IssueAction {
79    IssueAction::Fix(FixAction {
80        kind,
81        auto_fixable: false,
82        description: description.to_string(),
83        note: Some(note.to_string()),
84        available_in_catalogs: None,
85        suggested_target: None,
86    })
87}
88
89fn suppress_line(comment: &str) -> IssueAction {
90    IssueAction::SuppressLine(SuppressLineAction {
91        kind: SuppressLineKind::SuppressLine,
92        auto_fixable: false,
93        description: "Suppress with an inline comment above the line".to_string(),
94        comment: comment.to_string(),
95        scope: None,
96    })
97}
98
99/// A per-finding caveat on a dead-code verdict that a file this run never
100/// fully analyzed can distort.
101///
102/// Advisory provenance, in the same spirit as the fix path's
103/// `low_confidence_off_graph` / `low_confidence_unresolved_imports` skip
104/// reasons: a caveat NEVER withholds, reorders, downgrades, or re-severities
105/// the finding, and never changes an exit code. It records that the verdict
106/// was computed over an import graph fallow already knows is incomplete, so a
107/// reader who sees the finding also sees the caveat instead of having to
108/// notice a diagnostic at the other end of the envelope.
109///
110/// Deliberately NOT named `confidence`: `health --targets` already emits a
111/// `confidence` key holding an enum string, and a shared consumer helper that
112/// met both would see the same key change type. Emitted on every finding type
113/// that registers it: the reachability arrays (`unused_files[]`,
114/// `unused_exports[]`, `unused_types[]`), the member arrays
115/// (`unused_enum_members[]`, `unused_class_members[]`, `unused_store_members[]`),
116/// and the three dependency arrays. Sorted and deduplicated, absent from the
117/// wire when empty. The set is open in the same sense
118/// `workspace_diagnostics[].kind` is: treat an unrecognised value as "some
119/// caveat" rather than as an error.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
121#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
122#[serde(rename_all = "kebab-case")]
123pub enum ReachabilityCaveat {
124    /// This finding's own file is one the run did not fully analyze, so the
125    /// export and import lists extracted from it may stop short of the real
126    /// ones. That reaches an `unused-file` verdict directly, because the
127    /// "is any export of this file referenced from a reachable module" test
128    /// reads exactly that truncated export list.
129    ///
130    /// Two workspace diagnostics put a file in this state: it was read but did
131    /// not parse cleanly (`source-parse-degraded`), or it could not be read at
132    /// all (`source-read-failure`). The token names the consequence rather than
133    /// either cause, so a future kind that leaves a discovered file partially
134    /// extracted carries the same value.
135    ///
136    /// Dependency findings never carry this value: the file they name is a
137    /// `package.json`, not a parsed source module.
138    IncompleteFileAnalysis,
139    /// A module whose import list feeds this verdict was not analyzed, so the
140    /// import that would have credited this finding may never have been seen.
141    ///
142    /// The cause is any workspace diagnostic that leaves a source file's
143    /// imports unseen: a degraded parse (`source-parse-degraded`), a file that
144    /// could not be read (`source-read-failure`), or a file discovery skipped
145    /// before reading it (`skipped-large-file`, `skipped-minified-file`,
146    /// `skipped-source-dotdir`). The token names the class rather than any one
147    /// cause.
148    ///
149    /// Which modules feed the verdict differs by array, and the caveat is
150    /// emitted only when a degraded module is actually one of them:
151    ///
152    /// - `unused_files[]`, `unused_exports[]`, and `unused_types[]` rest on
153    ///   reachability, so only a degraded module that is itself observed
154    ///   reachable can change the verdict. When every degraded module is
155    ///   unreachable the caveat is absent, and soundly: the FIRST missing edge
156    ///   on any entry-point path leaves from a module whose every predecessor
157    ///   edge was observed, so that module is observed reachable. A file the
158    ///   run never read has no module and no graph node, so its reachability
159    ///   is not observable at all and that narrowing cannot be applied: any
160    ///   skipped or unreadable source caveats every reachability verdict in
161    ///   the run.
162    /// - the member arrays (`unused_enum_members[]`, `unused_class_members[]`,
163    ///   `unused_store_members[]`) do not rest on reachability at all: member
164    ///   usage is collected by walking every module the run resolved,
165    ///   reachable or not, so this narrowing does not apply to them either.
166    ///   Same unnarrowed condition as the dependency arrays below, plus the
167    ///   per-finding value above when the member's own file is the one that
168    ///   was incompletely analyzed.
169    /// - the dependency arrays rest on whether ANY module in the project
170    ///   imports the package specifier, reachable or not, so any degraded
171    ///   parse anywhere can hide the import that would have credited the
172    ///   package. Reachability does not narrow that one.
173    ///
174    /// The limit, stated because an approximation presented as exact is worse
175    /// than nothing: this is a RUN-level condition, not proof that a degraded
176    /// module imports this path or package. An import the parser never saw
177    /// cannot be attributed to a target, so the link cannot be narrowed
178    /// further without re-reading the source. Read `workspace_diagnostics[]`
179    /// for which files degraded.
180    IncompleteImportGraph,
181}
182
183impl ReachabilityCaveat {
184    /// The wire token.
185    #[must_use]
186    pub const fn token(self) -> &'static str {
187        match self {
188            Self::IncompleteFileAnalysis => "incomplete-file-analysis",
189            Self::IncompleteImportGraph => "incomplete-import-graph",
190        }
191    }
192
193    /// A one-line explanation for human and agent-facing renderers.
194    ///
195    /// Neither sentence names a single cause. A degraded parse is only one of
196    /// the ways a file goes unread: it may also have been unreadable, or
197    /// skipped before it was ever opened (oversized, minified, in a dotdir).
198    /// Naming the parse case alone sent a reader whose run was degraded by the
199    /// size guard hunting for parse errors that do not exist, so both messages
200    /// point at `workspace_diagnostics[]`, which names the actual files.
201    #[must_use]
202    pub const fn message(self) -> &'static str {
203        match self {
204            Self::IncompleteFileAnalysis => {
205                "low: this file was not fully analyzed, so its extracted exports and imports may be incomplete; see workspace_diagnostics[]"
206            }
207            Self::IncompleteImportGraph => {
208                "low: a module this run did not fully read may hold an import that would credit this; see workspace_diagnostics[]"
209            }
210        }
211    }
212
213    /// A compact label for a one-line human renderer, where the full
214    /// [`Self::message`] would not fit next to the finding.
215    #[must_use]
216    pub const fn short_label(self) -> &'static str {
217        match self {
218            Self::IncompleteFileAnalysis => "incomplete file analysis",
219            Self::IncompleteImportGraph => "incomplete import graph",
220        }
221    }
222}
223
224/// The compact labels of `caveats`, joined for a one-line renderer, or `None`
225/// when there is nothing to say.
226#[must_use]
227pub fn caveat_labels(caveats: &[ReachabilityCaveat]) -> Option<String> {
228    if caveats.is_empty() {
229        return None;
230    }
231    let labels: Vec<&str> = caveats
232        .iter()
233        .map(|caveat| ReachabilityCaveat::short_label(*caveat))
234        .collect();
235    Some(labels.join(", "))
236}
237
238/// The compact parenthetical a one-line human renderer appends to a finding
239/// carrying `caveats`, or `None` when there is nothing to say. Shared by every
240/// dead-code section so the suffix reads the same everywhere.
241#[must_use]
242pub fn caveat_suffix(caveats: &[ReachabilityCaveat]) -> Option<String> {
243    caveat_labels(caveats).map(|labels| format!("{CAVEAT_SUFFIX_MARKER}{labels})"))
244}
245
246/// The opening of the parenthetical [`caveat_suffix`] renders. Public because
247/// one consumer can only see the rendered description: the CI review formats
248/// build their comments from CodeClimate issues, whose `description` is the
249/// only place the caveat survives (the CodeClimate wire is a published
250/// contract with no field for it). Recognising the marker is what lets those
251/// formats withhold a one-click mutation.
252pub const CAVEAT_SUFFIX_MARKER: &str = " (caveat: ";
253
254/// Whether a rendered finding description already carries a caveat
255/// parenthetical, for a surface holding the string rather than the typed
256/// finding.
257///
258/// Lives here, next to the renderer, so the producer and the recogniser cannot
259/// drift; `a_rendered_suffix_is_recognised_by_the_marker` pins the pair.
260#[must_use]
261pub fn description_carries_caveat(description: &str) -> bool {
262    description.contains(CAVEAT_SUFFIX_MARKER)
263}
264
265/// The compact label for one wire token, for a renderer that reads
266/// `reachability_caveats[]` back off a serialized envelope instead of holding
267/// the typed findings.
268///
269/// The value set is OPEN, exactly as the wire documentation says: a token this
270/// build does not recognise is still a caveat, so it is rendered as itself with
271/// its separators relaxed into spaces rather than dropped. Dropping it would
272/// turn a finding whose evidence is incomplete back into a confident one,
273/// which is the failure this whole mechanism exists to prevent.
274#[must_use]
275pub fn caveat_label_for_token(token: &str) -> String {
276    match token {
277        "incomplete-file-analysis" => {
278            ReachabilityCaveat::short_label(ReachabilityCaveat::IncompleteFileAnalysis).to_owned()
279        }
280        "incomplete-import-graph" => {
281            ReachabilityCaveat::short_label(ReachabilityCaveat::IncompleteImportGraph).to_owned()
282        }
283        other => other.replace('-', " "),
284    }
285}
286
287/// The joined compact labels for wire tokens, or `None` when there are none.
288/// The token-side twin of [`caveat_labels`], for renderers driven by a
289/// serialized envelope rather than by typed findings.
290#[must_use]
291pub fn caveat_labels_for_tokens<'a>(tokens: impl IntoIterator<Item = &'a str>) -> Option<String> {
292    let labels: Vec<String> = tokens.into_iter().map(caveat_label_for_token).collect();
293    if labels.is_empty() {
294        return None;
295    }
296    Some(labels.join(", "))
297}
298
299/// The token-side twin of [`caveat_suffix`], so an envelope-driven renderer
300/// appends the same parenthetical as a findings-driven one.
301#[must_use]
302pub fn caveat_suffix_for_tokens<'a>(tokens: impl IntoIterator<Item = &'a str>) -> Option<String> {
303    caveat_labels_for_tokens(tokens).map(|labels| format!("{CAVEAT_SUFFIX_MARKER}{labels})"))
304}
305
306/// The note every mutating action carries once [`MutationEvidence`] withholds
307/// it. One string, so the CLI action array, the LSP diagnostic, and the MCP
308/// tool contract all say the same thing about the same finding.
309pub const INCOMPLETE_EVIDENCE_NOTE: &str = "Evidence is incomplete: a file this run did not fully analyze may hold the reference that \
310     credits this finding, so this mutation is not applied automatically. Resolve the files named \
311     in workspace_diagnostics[] and re-run, or confirm and remove it by hand.";
312
313/// The one question every mutation surface asks before it offers, plans, or
314/// performs a dead-code finding's removal.
315///
316/// A finding whose reachability verdict rests on a file the run never fully
317/// read is still REPORTED, always: a caveat withholds no finding, changes no
318/// severity, and moves no exit code. What it withholds is the automation. The
319/// predicate lives here, next to the findings, rather than in any one consumer,
320/// because it was re-derived per surface three times and a fourth door opened
321/// every time: `fallow fix`, the LSP quick fix, and the `auto_fixable` flag an
322/// agent plans against each answered it differently. Every one of those now
323/// calls [`Self::may_auto_apply_mutation`], so a sixth finding type or a fourth
324/// mutation surface cannot silently opt out.
325///
326/// Implemented only by the findings that can carry a caveat. A finding type
327/// that exposes an auto-fixable mutation and does NOT implement this trait is
328/// the bug this trait exists to make visible; `every_auto_fixable_dead_code_
329/// mutation_is_gated` in this module's tests pins that.
330pub trait MutationEvidence {
331    /// The advisory caveats recorded on the reachability verdict behind this
332    /// finding. Empty when the run analyzed every file it discovered.
333    fn reachability_caveats(&self) -> &[ReachabilityCaveat];
334
335    /// Whether this finding's mutation may be applied without a human first
336    /// being told the evidence is incomplete. THE gate: never re-derive it,
337    /// never widen it per surface.
338    fn may_auto_apply_mutation(&self) -> bool {
339        self.reachability_caveats().is_empty()
340    }
341}
342
343/// Record a run's caveats on a finding, enforcing [`MutationEvidence`] on its
344/// typed `actions` in the same step.
345///
346/// Separate from [`MutationEvidence`] so a read-only consumer (the fixer, the
347/// LSP, a renderer) depends only on the question and never on the answer's
348/// setter. `annotate` in the analysis layer is the single writer.
349pub trait CaveatedFinding: MutationEvidence {
350    /// Store `caveats` and downgrade every mutating action the gate now
351    /// withholds. The field itself stays `pub` (a renderer test builds an
352    /// already-caveated fixture directly, without running the annotation
353    /// pass); every non-test writer goes through this setter instead of the
354    /// field so the downgrade travels with the write.
355    fn set_reachability_caveats(&mut self, caveats: Vec<ReachabilityCaveat>);
356}
357
358/// Downgrade every `Fix` action in `actions` when `caveats` is non-empty, so
359/// the `auto_fixable` flag an agent plans against matches what `fallow fix`
360/// will actually do. Only ever downgrades: a surface that has already decided
361/// a mutation is unsafe for its own reasons keeps that decision.
362fn withhold_caveated_mutations(actions: &mut [IssueAction], caveats: &[ReachabilityCaveat]) {
363    if caveats.is_empty() {
364        return;
365    }
366    for action in actions {
367        let IssueAction::Fix(fix) = action else {
368            continue;
369        };
370        fix.auto_fixable = false;
371        fix.note = Some(match fix.note.take() {
372            Some(existing) => format!("{existing}. {INCOMPLETE_EVIDENCE_NOTE}"),
373            None => INCOMPLETE_EVIDENCE_NOTE.to_string(),
374        });
375    }
376}
377
378/// Implement the gate for a finding wrapper carrying a `reachability_caveats`
379/// field alongside a typed `actions` array. A new caveated finding type adds
380/// one line here rather than a new per-surface branch.
381macro_rules! impl_caveated_finding {
382    ($($finding:ty),+ $(,)?) => {
383        $(
384            impl MutationEvidence for $finding {
385                fn reachability_caveats(&self) -> &[ReachabilityCaveat] {
386                    &self.reachability_caveats
387                }
388            }
389
390            impl CaveatedFinding for $finding {
391                fn set_reachability_caveats(&mut self, caveats: Vec<ReachabilityCaveat>) {
392                    withhold_caveated_mutations(&mut self.actions, &caveats);
393                    self.reachability_caveats = caveats;
394                }
395            }
396        )+
397    };
398}
399
400/// Wire-shape envelope for an [`UnusedFile`] finding. The bare finding
401/// flattens in via `#[serde(flatten)]`, with a typed `actions` array
402/// populated at construction time and the audit-pass `introduced` flag
403/// attached as an optional sibling.
404#[derive(Debug, Clone, Serialize, Deserialize)]
405#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
406pub struct UnusedFileFinding {
407    /// The underlying dead-code entry.
408    #[serde(flatten)]
409    pub file: UnusedFile,
410    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
411    /// `~<k>` suffix when several findings of one type share an identity.
412    /// Line and column are not inputs, so the id survives line shifts,
413    /// reformats and reorders. A rename of the file or the symbol gives a
414    /// new id. Absent in output from older versions.
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub finding_id: Option<String>,
417    /// Suggested next steps: a `delete-file` primary and a `suppress-file`
418    /// secondary. Always emitted (possibly empty for forward-compat).
419    pub actions: Vec<IssueAction>,
420    /// Set by the audit pass when this finding is introduced relative to
421    /// the merge-base. `None` when serialized directly from Rust.
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub introduced: Option<AuditIntroduced>,
424    /// Gate severity of this finding after `rules` and `overrides[].rules`
425    /// resolve for its path. CI formats read it for the annotation, SARIF
426    /// and CodeClimate level. Absent in output from older versions. Not
427    /// part of the finding identity, baseline keys or fingerprints.
428    #[serde(
429        default,
430        skip_serializing_if = "Option::is_none",
431        deserialize_with = "deserialize_effective_severity"
432    )]
433    pub effective_severity: Option<EffectiveSeverity>,
434    /// Advisory caveats on the reachability verdict behind this finding.
435    /// Sorted, deduplicated, and omitted from the wire when empty, so a run
436    /// that analyzed every discovered file is byte-identical. Never gates the
437    /// finding or the `delete-file` action, though `fallow fix` does withhold
438    /// the removal of a caveated finding as low confidence.
439    #[serde(default, skip_serializing_if = "Vec::is_empty")]
440    pub reachability_caveats: Vec<ReachabilityCaveat>,
441}
442
443impl UnusedFileFinding {
444    /// Build the wrapper from a raw [`UnusedFile`], computing the typed
445    /// `actions` array inline. `introduced` stays `None` and is set later
446    /// by `annotate_dead_code_json` if the audit pass runs.
447    #[must_use]
448    pub fn with_actions(file: UnusedFile) -> Self {
449        let actions = vec![
450            IssueAction::Fix(FixAction {
451                kind: FixActionType::DeleteFile,
452                auto_fixable: false,
453                description: "Delete this file".to_string(),
454                note: Some(
455                    "File deletion may remove runtime functionality not visible to static analysis"
456                        .to_string(),
457                ),
458                available_in_catalogs: None,
459                suggested_target: None,
460            }),
461            IssueAction::SuppressFile(SuppressFileAction {
462                kind: SuppressFileKind::SuppressFile,
463                auto_fixable: false,
464                description: "Suppress with a file-level comment at the top of the file"
465                    .to_string(),
466                comment: "// fallow-ignore-file unused-file".to_string(),
467            }),
468        ];
469        Self {
470            finding_id: None,
471            file,
472            actions,
473            introduced: None,
474            effective_severity: None,
475            reachability_caveats: Vec::new(),
476        }
477    }
478}
479
480/// Wire-shape envelope for a [`PrivateTypeLeak`] finding. Mirrors
481/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
482/// `actions` array (`export-type` primary plus `suppress-line` secondary).
483#[derive(Debug, Clone, Serialize, Deserialize)]
484#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
485pub struct PrivateTypeLeakFinding {
486    /// The underlying dead-code entry.
487    #[serde(flatten)]
488    pub leak: PrivateTypeLeak,
489    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
490    /// `~<k>` suffix when several findings of one type share an identity.
491    /// Line and column are not inputs, so the id survives line shifts,
492    /// reformats and reorders. A rename of the file or the symbol gives a
493    /// new id. Absent in output from older versions.
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub finding_id: Option<String>,
496    /// Suggested next steps. Always emitted (possibly empty for
497    /// forward-compat).
498    pub actions: Vec<IssueAction>,
499    /// Set by the audit pass when this finding is introduced relative to
500    /// the merge-base.
501    #[serde(default, skip_serializing_if = "Option::is_none")]
502    pub introduced: Option<AuditIntroduced>,
503    /// Gate severity of this finding after `rules` and `overrides[].rules`
504    /// resolve for its path. CI formats read it for the annotation, SARIF
505    /// and CodeClimate level. Absent in output from older versions. Not
506    /// part of the finding identity, baseline keys or fingerprints.
507    #[serde(
508        default,
509        skip_serializing_if = "Option::is_none",
510        deserialize_with = "deserialize_effective_severity"
511    )]
512    pub effective_severity: Option<EffectiveSeverity>,
513}
514
515impl PrivateTypeLeakFinding {
516    /// Build the wrapper from a raw [`PrivateTypeLeak`].
517    #[must_use]
518    pub fn with_actions(leak: PrivateTypeLeak) -> Self {
519        let actions = vec![
520            IssueAction::Fix(FixAction {
521                kind: FixActionType::ExportType,
522                auto_fixable: false,
523                description: "Export the referenced private type by name".to_string(),
524                note: Some(
525                    "Keep the type exported while it is part of a public signature".to_string(),
526                ),
527                available_in_catalogs: None,
528                suggested_target: None,
529            }),
530            IssueAction::SuppressLine(SuppressLineAction {
531                kind: SuppressLineKind::SuppressLine,
532                auto_fixable: false,
533                description: "Suppress with an inline comment above the line".to_string(),
534                comment: "// fallow-ignore-next-line private-type-leak".to_string(),
535                scope: None,
536            }),
537        ];
538        Self {
539            finding_id: None,
540            leak,
541            actions,
542            introduced: None,
543            effective_severity: None,
544        }
545    }
546}
547
548/// Wire-shape envelope for a [`DeprecatedExportInUse`] finding. Carries a
549/// manual `migrate-deprecated-export` primary action plus a `suppress-line`
550/// secondary. Never auto-fixable: fallow does not rewrite consumers.
551#[derive(Debug, Clone, Serialize, Deserialize)]
552#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
553pub struct DeprecatedExportInUseFinding {
554    /// The underlying dead-code entry.
555    #[serde(flatten)]
556    pub export: DeprecatedExportInUse,
557    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
558    /// `~<k>` suffix when several findings of one type share an identity.
559    /// Line and column are not inputs, so the id survives line shifts,
560    /// reformats and reorders. A rename of the file or the symbol gives a
561    /// new id. Absent in output from older versions.
562    #[serde(default, skip_serializing_if = "Option::is_none")]
563    pub finding_id: Option<String>,
564    /// Suggested next steps. Always emitted (possibly empty for
565    /// forward-compat).
566    pub actions: Vec<IssueAction>,
567    /// Set by the audit pass when this finding is introduced relative to
568    /// the merge-base.
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub introduced: Option<AuditIntroduced>,
571    /// Gate severity of this finding after `rules` and `overrides[].rules`
572    /// resolve for its path. CI formats read it for the annotation, SARIF
573    /// and CodeClimate level. Absent in output from older versions. Not
574    /// part of the finding identity, baseline keys or fingerprints.
575    #[serde(
576        default,
577        skip_serializing_if = "Option::is_none",
578        deserialize_with = "deserialize_effective_severity"
579    )]
580    pub effective_severity: Option<EffectiveSeverity>,
581}
582
583impl DeprecatedExportInUseFinding {
584    /// Build the wrapper from a raw [`DeprecatedExportInUse`].
585    #[must_use]
586    pub fn with_actions(export: DeprecatedExportInUse) -> Self {
587        let trace_hint = format!(
588            "For the full consumer list, run `fallow dead-code --trace <path>:{}` with the `path` of this finding.",
589            export.export_name
590        );
591        let note = if export.public_api {
592            format!(
593                "This export is public API. External consumers are not visible, so do not remove it on this evidence alone. {trace_hint}"
594            )
595        } else {
596            format!(
597                "Move each consumer to the replacement that the deprecation message names, then remove the export. {trace_hint}"
598            )
599        };
600        let actions = vec![
601            IssueAction::Fix(FixAction {
602                kind: FixActionType::MigrateDeprecatedExport,
603                auto_fixable: false,
604                description: "Move the consumers off the deprecated export".to_string(),
605                note: Some(note),
606                available_in_catalogs: None,
607                suggested_target: None,
608            }),
609            suppress_line("// fallow-ignore-next-line deprecated-export-in-use"),
610        ];
611        Self {
612            finding_id: None,
613            export,
614            actions,
615            introduced: None,
616            effective_severity: None,
617        }
618    }
619}
620
621/// Wire-shape envelope for an [`UnresolvedImport`] finding. Mirrors
622/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
623/// `actions` array (`resolve-import` primary plus config and inline
624/// suppression actions).
625#[derive(Debug, Clone, Serialize, Deserialize)]
626#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
627pub struct UnresolvedImportFinding {
628    /// The underlying dead-code entry.
629    #[serde(flatten)]
630    pub import: UnresolvedImport,
631    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
632    /// `~<k>` suffix when several findings of one type share an identity.
633    /// Line and column are not inputs, so the id survives line shifts,
634    /// reformats and reorders. A rename of the file or the symbol gives a
635    /// new id. Absent in output from older versions.
636    #[serde(default, skip_serializing_if = "Option::is_none")]
637    pub finding_id: Option<String>,
638    /// Suggested next steps. Always emitted (possibly empty for
639    /// forward-compat).
640    pub actions: Vec<IssueAction>,
641    /// Set by the audit pass when this finding is introduced relative to
642    /// the merge-base.
643    #[serde(default, skip_serializing_if = "Option::is_none")]
644    pub introduced: Option<AuditIntroduced>,
645    /// Gate severity of this finding after `rules` and `overrides[].rules`
646    /// resolve for its path. CI formats read it for the annotation, SARIF
647    /// and CodeClimate level. Absent in output from older versions. Not
648    /// part of the finding identity, baseline keys or fingerprints.
649    #[serde(
650        default,
651        skip_serializing_if = "Option::is_none",
652        deserialize_with = "deserialize_effective_severity"
653    )]
654    pub effective_severity: Option<EffectiveSeverity>,
655}
656
657impl UnresolvedImportFinding {
658    /// Build the wrapper from a raw [`UnresolvedImport`].
659    #[must_use]
660    pub fn with_actions(import: UnresolvedImport) -> Self {
661        let actions = vec![
662            IssueAction::Fix(FixAction {
663                kind: FixActionType::ResolveImport,
664                auto_fixable: false,
665                description: "Fix the import specifier or install the missing module".to_string(),
666                note: Some(
667                    "Verify the module path and check tsconfig paths configuration".to_string(),
668                ),
669                available_in_catalogs: None,
670                suggested_target: None,
671            }),
672            IssueAction::AddToConfig(AddToConfigAction {
673                kind: AddToConfigKind::AddToConfig,
674                auto_fixable: false,
675                description: format!(
676                    "Add \"{}\" to ignoreUnresolvedImports in fallow config",
677                    import.specifier
678                ),
679                config_key: "ignoreUnresolvedImports".to_string(),
680                value: AddToConfigValue::Scalar(import.specifier.clone()),
681                value_schema: Some(
682                    "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
683                        .to_string(),
684                ),
685            }),
686            IssueAction::SuppressLine(SuppressLineAction {
687                kind: SuppressLineKind::SuppressLine,
688                auto_fixable: false,
689                description: "Suppress with an inline comment above the line".to_string(),
690                comment: "// fallow-ignore-next-line unresolved-import".to_string(),
691                scope: None,
692            }),
693        ];
694        Self {
695            finding_id: None,
696            import,
697            actions,
698            introduced: None,
699            effective_severity: None,
700        }
701    }
702}
703
704/// Wire-shape envelope for a [`CircularDependency`] finding. Mirrors
705/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
706/// `actions` array (`refactor-cycle` primary plus `suppress-line`
707/// secondary).
708#[derive(Debug, Clone, Serialize, Deserialize)]
709#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
710pub struct CircularDependencyFinding {
711    /// The underlying dead-code entry.
712    #[serde(flatten)]
713    pub cycle: CircularDependency,
714    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
715    /// `~<k>` suffix when several findings of one type share an identity.
716    /// Line and column are not inputs, so the id survives line shifts,
717    /// reformats and reorders. A rename of the file or the symbol gives a
718    /// new id. Absent in output from older versions.
719    #[serde(default, skip_serializing_if = "Option::is_none")]
720    pub finding_id: Option<String>,
721    /// Suggested next steps. Always emitted (possibly empty for
722    /// forward-compat).
723    pub actions: Vec<IssueAction>,
724    /// Set by the audit pass when this finding is introduced relative to
725    /// the merge-base.
726    #[serde(default, skip_serializing_if = "Option::is_none")]
727    pub introduced: Option<AuditIntroduced>,
728    /// Gate severity of this finding after `rules` and `overrides[].rules`
729    /// resolve for its path. CI formats read it for the annotation, SARIF
730    /// and CodeClimate level. Absent in output from older versions. Not
731    /// part of the finding identity, baseline keys or fingerprints.
732    #[serde(
733        default,
734        skip_serializing_if = "Option::is_none",
735        deserialize_with = "deserialize_effective_severity"
736    )]
737    pub effective_severity: Option<EffectiveSeverity>,
738}
739
740impl CircularDependencyFinding {
741    /// Build the wrapper from a raw [`CircularDependency`].
742    #[must_use]
743    pub fn with_actions(cycle: CircularDependency) -> Self {
744        let actions = vec![
745            IssueAction::Fix(FixAction {
746                kind: FixActionType::RefactorCycle,
747                auto_fixable: false,
748                description: "Extract shared logic into a separate module to break the cycle"
749                    .to_string(),
750                note: Some(
751                    "Circular imports can cause initialization issues and make code harder to reason about"
752                        .to_string(),
753                ),
754                available_in_catalogs: None,
755                suggested_target: None,
756            }),
757            IssueAction::SuppressLine(SuppressLineAction {
758                kind: SuppressLineKind::SuppressLine,
759                auto_fixable: false,
760                description: "Suppress with an inline comment above the line".to_string(),
761                comment: "// fallow-ignore-next-line circular-dependency".to_string(),
762                scope: None,
763            }),
764        ];
765        Self {
766            finding_id: None,
767            cycle,
768            actions,
769            introduced: None,
770            effective_severity: None,
771        }
772    }
773}
774
775/// Wire-shape envelope for a [`ReExportCycle`] finding. Mirrors
776/// [`CircularDependencyFinding`]: flattens the bare finding and carries a
777/// typed `actions` array (`refactor-re-export-cycle` informational primary
778/// plus `suppress-file` secondary; cycles are file-scoped so a single
779/// file-level suppression on the alphabetically-first member breaks the
780/// cycle, and no `// fallow-ignore-next-line` form makes sense because the
781/// diagnostic is anchored at line 1 col 0 of each member).
782#[derive(Debug, Clone, Serialize, Deserialize)]
783#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
784pub struct ReExportCycleFinding {
785    /// The underlying dead-code entry.
786    #[serde(flatten)]
787    pub cycle: ReExportCycle,
788    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
789    /// `~<k>` suffix when several findings of one type share an identity.
790    /// Line and column are not inputs, so the id survives line shifts,
791    /// reformats and reorders. A rename of the file or the symbol gives a
792    /// new id. Absent in output from older versions.
793    #[serde(default, skip_serializing_if = "Option::is_none")]
794    pub finding_id: Option<String>,
795    /// Suggested next steps. Always emitted (possibly empty for
796    /// forward-compat).
797    pub actions: Vec<IssueAction>,
798    /// Set by the audit pass when this finding is introduced relative to
799    /// the merge-base.
800    #[serde(default, skip_serializing_if = "Option::is_none")]
801    pub introduced: Option<AuditIntroduced>,
802    /// Gate severity of this finding after `rules` and `overrides[].rules`
803    /// resolve for its path. CI formats read it for the annotation, SARIF
804    /// and CodeClimate level. Absent in output from older versions. Not
805    /// part of the finding identity, baseline keys or fingerprints.
806    #[serde(
807        default,
808        skip_serializing_if = "Option::is_none",
809        deserialize_with = "deserialize_effective_severity"
810    )]
811    pub effective_severity: Option<EffectiveSeverity>,
812}
813
814impl ReExportCycleFinding {
815    /// Build the wrapper from a raw [`ReExportCycle`].
816    ///
817    /// The `SuppressFile` action targets the alphabetically-first member
818    /// (`cycle.files[0]`; the `files` Vec is already sorted at graph layer);
819    /// for multi-node cycles the description names the other members so
820    /// consumers see context for why one file-level suppression suffices.
821    #[must_use]
822    pub fn with_actions(cycle: ReExportCycle) -> Self {
823        // The description is a path-free hint about the suppression's
824        // structural effect; the cycle's member list already ships in the
825        // sibling `files` field, so consumers can correlate without
826        // re-reading the description (and absolute paths cannot leak in
827        // here, which the wrapper has no root-prefix context to strip).
828        let suppress_description = match cycle.kind {
829            ReExportCycleKind::SelfLoop => {
830                "Suppress with a file-level comment at the top of this file. \
831                 The cycle is a self-loop, so the suppression covers the entire finding."
832                    .to_string()
833            }
834            ReExportCycleKind::MultiNode => {
835                "Suppress with a file-level comment at the top of this file. \
836                 One suppression on any member breaks the cycle for every member \
837                 (see the sibling `files` array)."
838                    .to_string()
839            }
840        };
841        let actions = vec![
842            IssueAction::Fix(FixAction {
843                kind: FixActionType::RefactorReExportCycle,
844                auto_fixable: false,
845                description: "Remove one `export * from` (or `export { ... } from`) \
846                              statement on any one member to break the cycle"
847                    .to_string(),
848                note: Some(
849                    "Re-export cycles are structurally a no-op: chain propagation through \
850                     the loop never reaches a terminating module, so imports from any member \
851                     may silently come up empty."
852                        .to_string(),
853                ),
854                available_in_catalogs: None,
855                suggested_target: None,
856            }),
857            IssueAction::SuppressFile(SuppressFileAction {
858                kind: SuppressFileKind::SuppressFile,
859                auto_fixable: false,
860                description: suppress_description,
861                comment: "// fallow-ignore-file re-export-cycle".to_string(),
862            }),
863        ];
864        Self {
865            finding_id: None,
866            cycle,
867            actions,
868            introduced: None,
869            effective_severity: None,
870        }
871    }
872}
873
874/// Wire-shape envelope for a [`PackageCycle`] finding. Mirrors
875/// [`CircularDependencyFinding`]: flattens the bare finding and carries a
876/// typed `actions` array (`refactor-cycle` primary plus `suppress-line`
877/// secondary).
878#[derive(Debug, Clone, Serialize, Deserialize)]
879#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
880pub struct PackageCycleFinding {
881    /// The underlying dead-code entry.
882    #[serde(flatten)]
883    pub cycle: PackageCycle,
884    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
885    /// `~<k>` suffix when several findings of one type share an identity.
886    /// Line and column are not inputs, so the id survives line shifts,
887    /// reformats and reorders. A rename of the file or the symbol gives a
888    /// new id. Absent in output from older versions.
889    #[serde(default, skip_serializing_if = "Option::is_none")]
890    pub finding_id: Option<String>,
891    /// Suggested next steps. Always emitted (possibly empty for
892    /// forward-compat).
893    pub actions: Vec<IssueAction>,
894    /// Set by the audit pass when this finding is introduced relative to
895    /// the merge-base.
896    #[serde(default, skip_serializing_if = "Option::is_none")]
897    pub introduced: Option<AuditIntroduced>,
898    /// Gate severity of this finding after `rules` and `overrides[].rules`
899    /// resolve for its path. CI formats read it for the annotation, SARIF
900    /// and CodeClimate level. Absent in output from older versions. Not
901    /// part of the finding identity, baseline keys or fingerprints.
902    #[serde(
903        default,
904        skip_serializing_if = "Option::is_none",
905        deserialize_with = "deserialize_effective_severity"
906    )]
907    pub effective_severity: Option<EffectiveSeverity>,
908}
909
910impl PackageCycleFinding {
911    /// Build the wrapper from a raw [`PackageCycle`].
912    #[must_use]
913    pub fn with_actions(cycle: PackageCycle) -> Self {
914        let actions = vec![
915            IssueAction::Fix(FixAction {
916                kind: FixActionType::RefactorCycle,
917                auto_fixable: false,
918                description: "Remove the imports on one hop of the cycle, for example by \
919                              moving the shared code to a package that both packages import"
920                    .to_string(),
921                note: Some(
922                    "Packages that import each other cannot be built in dependency order"
923                        .to_string(),
924                ),
925                available_in_catalogs: None,
926                suggested_target: None,
927            }),
928            IssueAction::SuppressLine(SuppressLineAction {
929                kind: SuppressLineKind::SuppressLine,
930                auto_fixable: false,
931                description: "Suppress with an inline comment above the import. The cycle \
932                              is gone when every import on one hop is suppressed"
933                    .to_string(),
934                comment: "// fallow-ignore-next-line package-cycle".to_string(),
935                scope: None,
936            }),
937        ];
938        Self {
939            cycle,
940            finding_id: None,
941            actions,
942            introduced: None,
943            effective_severity: None,
944        }
945    }
946}
947
948/// Wire-shape envelope for a [`BoundaryViolation`] finding. Mirrors
949/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
950/// `actions` array (`refactor-boundary` primary plus `suppress-line`
951/// secondary).
952#[derive(Debug, Clone, Serialize, Deserialize)]
953#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
954pub struct BoundaryViolationFinding {
955    /// The underlying dead-code entry.
956    #[serde(flatten)]
957    pub violation: BoundaryViolation,
958    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
959    /// `~<k>` suffix when several findings of one type share an identity.
960    /// Line and column are not inputs, so the id survives line shifts,
961    /// reformats and reorders. A rename of the file or the symbol gives a
962    /// new id. Absent in output from older versions.
963    #[serde(default, skip_serializing_if = "Option::is_none")]
964    pub finding_id: Option<String>,
965    /// Suggested next steps. Always emitted (possibly empty for
966    /// forward-compat).
967    pub actions: Vec<IssueAction>,
968    /// Set by the audit pass when this finding is introduced relative to
969    /// the merge-base.
970    #[serde(default, skip_serializing_if = "Option::is_none")]
971    pub introduced: Option<AuditIntroduced>,
972    /// Gate severity of this finding after `rules` and `overrides[].rules`
973    /// resolve for its path. CI formats read it for the annotation, SARIF
974    /// and CodeClimate level. Absent in output from older versions. Not
975    /// part of the finding identity, baseline keys or fingerprints.
976    #[serde(
977        default,
978        skip_serializing_if = "Option::is_none",
979        deserialize_with = "deserialize_effective_severity"
980    )]
981    pub effective_severity: Option<EffectiveSeverity>,
982}
983
984impl BoundaryViolationFinding {
985    /// Build the wrapper from a raw [`BoundaryViolation`].
986    #[must_use]
987    pub fn with_actions(violation: BoundaryViolation) -> Self {
988        let actions = vec![
989            IssueAction::Fix(FixAction {
990                kind: FixActionType::RefactorBoundary,
991                auto_fixable: false,
992                description: "Move the import through an allowed zone or restructure the dependency"
993                    .to_string(),
994                note: Some(
995                    "This import crosses an architecture boundary that is not permitted by the configured rules"
996                        .to_string(),
997                ),
998                available_in_catalogs: None,
999                suggested_target: None,
1000            }),
1001            IssueAction::SuppressLine(SuppressLineAction {
1002                kind: SuppressLineKind::SuppressLine,
1003                auto_fixable: false,
1004                description: "Suppress with an inline comment above the line".to_string(),
1005                comment: "// fallow-ignore-next-line boundary-violation".to_string(),
1006                scope: None,
1007            }),
1008        ];
1009        Self {
1010            finding_id: None,
1011            violation,
1012            actions,
1013            introduced: None,
1014            effective_severity: None,
1015        }
1016    }
1017}
1018
1019/// Wire-shape envelope for a [`BoundaryCoverageViolation`] finding. Carries
1020/// actions for assigning the file to a zone or explicitly allowing it to stay
1021/// unmatched.
1022#[derive(Debug, Clone, Serialize, Deserialize)]
1023#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1024pub struct BoundaryCoverageViolationFinding {
1025    /// The underlying coverage entry.
1026    #[serde(flatten)]
1027    pub violation: BoundaryCoverageViolation,
1028    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
1029    /// `~<k>` suffix when several findings of one type share an identity.
1030    /// Line and column are not inputs, so the id survives line shifts,
1031    /// reformats and reorders. A rename of the file or the symbol gives a
1032    /// new id. Absent in output from older versions.
1033    #[serde(default, skip_serializing_if = "Option::is_none")]
1034    pub finding_id: Option<String>,
1035    /// Suggested next steps.
1036    pub actions: Vec<IssueAction>,
1037    /// Set by the audit pass when this finding is introduced relative to
1038    /// the merge-base.
1039    #[serde(default, skip_serializing_if = "Option::is_none")]
1040    pub introduced: Option<AuditIntroduced>,
1041    /// Gate severity of this finding after `rules` and `overrides[].rules`
1042    /// resolve for its path. CI formats read it for the annotation, SARIF
1043    /// and CodeClimate level. Absent in output from older versions. Not
1044    /// part of the finding identity, baseline keys or fingerprints.
1045    #[serde(
1046        default,
1047        skip_serializing_if = "Option::is_none",
1048        deserialize_with = "deserialize_effective_severity"
1049    )]
1050    pub effective_severity: Option<EffectiveSeverity>,
1051}
1052
1053impl BoundaryCoverageViolationFinding {
1054    /// Build the wrapper from a raw [`BoundaryCoverageViolation`].
1055    #[must_use]
1056    pub fn with_actions(violation: BoundaryCoverageViolation) -> Self {
1057        let path = violation.path.to_string_lossy().replace('\\', "/");
1058        let actions = vec![
1059            IssueAction::Fix(FixAction {
1060                kind: FixActionType::RefactorBoundary,
1061                auto_fixable: false,
1062                description: "Add this file to a boundary zone pattern or move it under an existing zone"
1063                    .to_string(),
1064                note: Some(
1065                    "Boundary coverage is enabled, so every analyzed source file must match a zone unless allow-listed"
1066                        .to_string(),
1067                ),
1068                available_in_catalogs: None,
1069                suggested_target: None,
1070            }),
1071            IssueAction::AddToConfig(AddToConfigAction {
1072                kind: AddToConfigKind::AddToConfig,
1073                auto_fixable: false,
1074                description: format!(
1075                    "Add \"{path}\" to boundaries.coverage.allowUnmatched in fallow config"
1076                ),
1077                config_key: "boundaries.coverage.allowUnmatched".to_string(),
1078                value: AddToConfigValue::Scalar(path),
1079                value_schema: Some(
1080                    "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/boundaries/properties/coverage/properties/allowUnmatched/items"
1081                        .to_string(),
1082                ),
1083            }),
1084            IssueAction::SuppressFile(SuppressFileAction {
1085                kind: SuppressFileKind::SuppressFile,
1086                auto_fixable: false,
1087                description: "Suppress with a file-level comment at the top of the file"
1088                    .to_string(),
1089                comment: "// fallow-ignore-file boundary-violation".to_string(),
1090            }),
1091        ];
1092        Self {
1093            finding_id: None,
1094            violation,
1095            actions,
1096            introduced: None,
1097            effective_severity: None,
1098        }
1099    }
1100}
1101
1102/// Wire-shape envelope for a [`BoundaryCallViolation`] finding. Carries
1103/// actions for refactoring the forbidden call out of the zone or suppressing
1104/// it with the shared `boundary-violation` token.
1105#[derive(Debug, Clone, Serialize, Deserialize)]
1106#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1107pub struct BoundaryCallViolationFinding {
1108    /// The underlying forbidden-call entry.
1109    #[serde(flatten)]
1110    pub violation: BoundaryCallViolation,
1111    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
1112    /// `~<k>` suffix when several findings of one type share an identity.
1113    /// Line and column are not inputs, so the id survives line shifts,
1114    /// reformats and reorders. A rename of the file or the symbol gives a
1115    /// new id. Absent in output from older versions.
1116    #[serde(default, skip_serializing_if = "Option::is_none")]
1117    pub finding_id: Option<String>,
1118    /// Suggested next steps.
1119    pub actions: Vec<IssueAction>,
1120    /// Set by the audit pass when this finding is introduced relative to
1121    /// the merge-base.
1122    #[serde(default, skip_serializing_if = "Option::is_none")]
1123    pub introduced: Option<AuditIntroduced>,
1124    /// Gate severity of this finding after `rules` and `overrides[].rules`
1125    /// resolve for its path. CI formats read it for the annotation, SARIF
1126    /// and CodeClimate level. Absent in output from older versions. Not
1127    /// part of the finding identity, baseline keys or fingerprints.
1128    #[serde(
1129        default,
1130        skip_serializing_if = "Option::is_none",
1131        deserialize_with = "deserialize_effective_severity"
1132    )]
1133    pub effective_severity: Option<EffectiveSeverity>,
1134}
1135
1136impl BoundaryCallViolationFinding {
1137    /// Build the wrapper from a raw [`BoundaryCallViolation`].
1138    #[must_use]
1139    pub fn with_actions(violation: BoundaryCallViolation) -> Self {
1140        let actions = vec![
1141            IssueAction::Fix(FixAction {
1142                kind: FixActionType::RefactorBoundary,
1143                auto_fixable: false,
1144                description: format!(
1145                    "Move the `{}` call out of zone '{}' or behind an allowed abstraction",
1146                    violation.callee, violation.zone,
1147                ),
1148                note: Some(format!(
1149                    "`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",
1150                    violation.pattern, violation.zone,
1151                )),
1152                available_in_catalogs: None,
1153                suggested_target: None,
1154            }),
1155            IssueAction::SuppressLine(SuppressLineAction {
1156                kind: SuppressLineKind::SuppressLine,
1157                auto_fixable: false,
1158                description: "Suppress with an inline comment above the line".to_string(),
1159                comment: "// fallow-ignore-next-line boundary-violation".to_string(),
1160                scope: None,
1161            }),
1162            IssueAction::SuppressFile(SuppressFileAction {
1163                kind: SuppressFileKind::SuppressFile,
1164                auto_fixable: false,
1165                description: "Suppress with a file-level comment at the top of the file"
1166                    .to_string(),
1167                comment: "// fallow-ignore-file boundary-violation".to_string(),
1168            }),
1169        ];
1170        Self {
1171            finding_id: None,
1172            violation,
1173            actions,
1174            introduced: None,
1175            effective_severity: None,
1176        }
1177    }
1178}
1179
1180/// Wire-shape envelope for a [`PolicyViolation`] finding. Carries actions for
1181/// replacing the banned call, import, or effect, or suppressing it with a scoped
1182/// `policy-violation:<pack>/<rule-id>` token.
1183#[derive(Debug, Clone, Serialize, Deserialize)]
1184#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1185pub struct PolicyViolationFinding {
1186    /// The underlying rule-pack policy entry.
1187    #[serde(flatten)]
1188    pub violation: PolicyViolation,
1189    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
1190    /// `~<k>` suffix when several findings of one type share an identity.
1191    /// Line and column are not inputs, so the id survives line shifts,
1192    /// reformats and reorders. A rename of the file or the symbol gives a
1193    /// new id. Absent in output from older versions.
1194    #[serde(default, skip_serializing_if = "Option::is_none")]
1195    pub finding_id: Option<String>,
1196    /// Suggested next steps.
1197    pub actions: Vec<IssueAction>,
1198    /// Set by the audit pass when this finding is introduced relative to
1199    /// the merge-base.
1200    #[serde(default, skip_serializing_if = "Option::is_none")]
1201    pub introduced: Option<AuditIntroduced>,
1202}
1203
1204impl PolicyViolationFinding {
1205    /// Build the wrapper from a raw [`PolicyViolation`].
1206    #[must_use]
1207    pub fn with_actions(violation: PolicyViolation) -> Self {
1208        let what = match violation.kind {
1209            crate::results::PolicyRuleKind::BannedCall => "call",
1210            crate::results::PolicyRuleKind::BannedImport => "import",
1211            crate::results::PolicyRuleKind::BannedEffect => "effect",
1212            crate::results::PolicyRuleKind::BannedExport => "export",
1213            crate::results::PolicyRuleKind::GdpProofProducer => "proof producer",
1214        };
1215        let description = match &violation.message {
1216            Some(message) => format!("Replace the `{}` {what}: {message}", violation.matched),
1217            None => format!("Replace the `{}` {what}", violation.matched),
1218        };
1219        let suppress_token = format!("policy-violation:{}/{}", violation.pack, violation.rule_id);
1220        let note = if violation.kind == crate::results::PolicyRuleKind::GdpProofProducer {
1221            "This check resolves the gdp-ts factory through static imports and unambiguous re-exports. Move proof creation to an allowed module. It does not verify authorization logic or follow arbitrary wrappers.".to_owned()
1222        } else {
1223            format!(
1224                "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",
1225                violation.pack, violation.rule_id,
1226            )
1227        };
1228        let actions = vec![
1229            IssueAction::Fix(FixAction {
1230                kind: FixActionType::ResolvePolicyViolation,
1231                auto_fixable: false,
1232                description,
1233                note: Some(note),
1234                available_in_catalogs: None,
1235                suggested_target: None,
1236            }),
1237            IssueAction::SuppressLine(SuppressLineAction {
1238                kind: SuppressLineKind::SuppressLine,
1239                auto_fixable: false,
1240                description: "Suppress this rule-pack rule with an inline comment above the line"
1241                    .to_string(),
1242                comment: format!("// fallow-ignore-next-line {suppress_token}"),
1243                scope: None,
1244            }),
1245            IssueAction::SuppressFile(SuppressFileAction {
1246                kind: SuppressFileKind::SuppressFile,
1247                auto_fixable: false,
1248                description:
1249                    "Suppress this rule-pack rule with a file-level comment at the top of the file"
1250                        .to_string(),
1251                comment: format!("// fallow-ignore-file {suppress_token}"),
1252            }),
1253        ];
1254        Self {
1255            finding_id: None,
1256            violation,
1257            actions,
1258            introduced: None,
1259        }
1260    }
1261}
1262
1263/// Wire-shape envelope for an [`UnusedExport`] finding consumed under the
1264/// `unused_exports` key. Same Rust struct as [`UnusedTypeFinding`], with a
1265/// different fix description so consumers can tell value-export from
1266/// type-export removal at the action level.
1267#[derive(Debug, Clone, Serialize, Deserialize)]
1268#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1269pub struct UnusedExportFinding {
1270    /// The underlying dead-code entry.
1271    #[serde(flatten)]
1272    pub export: UnusedExport,
1273    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
1274    /// `~<k>` suffix when several findings of one type share an identity.
1275    /// Line and column are not inputs, so the id survives line shifts,
1276    /// reformats and reorders. A rename of the file or the symbol gives a
1277    /// new id. Absent in output from older versions.
1278    #[serde(default, skip_serializing_if = "Option::is_none")]
1279    pub finding_id: Option<String>,
1280    /// Suggested next steps. Always emitted (possibly empty for
1281    /// forward-compat).
1282    pub actions: Vec<IssueAction>,
1283    /// Type-aware evidence for this exact candidate when requested.
1284    #[serde(default, skip_serializing_if = "Option::is_none")]
1285    pub semantic: Option<SemanticCandidateDecision>,
1286    /// Set by the audit pass when this finding is introduced relative to
1287    /// the merge-base.
1288    #[serde(default, skip_serializing_if = "Option::is_none")]
1289    pub introduced: Option<AuditIntroduced>,
1290    /// Gate severity of this finding after `rules` and `overrides[].rules`
1291    /// resolve for its path. CI formats read it for the annotation, SARIF
1292    /// and CodeClimate level. Absent in output from older versions. Not
1293    /// part of the finding identity, baseline keys or fingerprints.
1294    #[serde(
1295        default,
1296        skip_serializing_if = "Option::is_none",
1297        deserialize_with = "deserialize_effective_severity"
1298    )]
1299    pub effective_severity: Option<EffectiveSeverity>,
1300    /// Advisory caveats on the reachability verdict behind this finding.
1301    /// Sorted, deduplicated, and omitted from the wire when empty. Never gates
1302    /// the finding or the `remove-export` action, though `fallow fix` does
1303    /// withhold the removal of a caveated export as low confidence.
1304    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1305    pub reachability_caveats: Vec<ReachabilityCaveat>,
1306}
1307
1308impl UnusedExportFinding {
1309    /// Build the wrapper. When `export.is_re_export` is true, the fix
1310    /// action's `note` warns about possible public-API surface; otherwise
1311    /// `note` is absent on the fix action.
1312    #[must_use]
1313    pub fn with_actions(export: UnusedExport) -> Self {
1314        let note = if export.is_re_export {
1315            Some(
1316                "This finding originates from a re-export; verify it is not part of your public API before removing"
1317                    .to_string(),
1318            )
1319        } else {
1320            None
1321        };
1322        let actions = vec![
1323            IssueAction::Fix(FixAction {
1324                kind: FixActionType::RemoveExport,
1325                auto_fixable: true,
1326                description: "Remove the unused export from the public API".to_string(),
1327                note,
1328                available_in_catalogs: None,
1329                suggested_target: None,
1330            }),
1331            IssueAction::SuppressLine(SuppressLineAction {
1332                kind: SuppressLineKind::SuppressLine,
1333                auto_fixable: false,
1334                description: "Suppress with an inline comment above the line".to_string(),
1335                comment: "// fallow-ignore-next-line unused-export".to_string(),
1336                scope: None,
1337            }),
1338        ];
1339        Self {
1340            finding_id: None,
1341            export,
1342            actions,
1343            semantic: None,
1344            introduced: None,
1345            effective_severity: None,
1346            reachability_caveats: Vec::new(),
1347        }
1348    }
1349
1350    /// Attach type-aware evidence and disable the syntactic fix when semantic
1351    /// analysis could not establish complete negative evidence.
1352    pub fn set_semantic_decision(&mut self, decision: SemanticCandidateDecision) {
1353        set_export_semantic_action(&mut self.actions, &decision, &self.reachability_caveats);
1354        self.semantic = Some(decision);
1355    }
1356}
1357
1358/// Wire-shape envelope for an [`UnusedExport`] finding consumed under the
1359/// `unused_types` key. Wraps the same bare [`UnusedExport`] struct as
1360/// [`UnusedExportFinding`] but emits a fix action targeted at type-only
1361/// declarations, with the same `is_re_export`-aware note swap.
1362#[derive(Debug, Clone, Serialize, Deserialize)]
1363#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1364pub struct UnusedTypeFinding {
1365    /// The underlying dead-code entry.
1366    #[serde(flatten)]
1367    pub export: UnusedExport,
1368    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
1369    /// `~<k>` suffix when several findings of one type share an identity.
1370    /// Line and column are not inputs, so the id survives line shifts,
1371    /// reformats and reorders. A rename of the file or the symbol gives a
1372    /// new id. Absent in output from older versions.
1373    #[serde(default, skip_serializing_if = "Option::is_none")]
1374    pub finding_id: Option<String>,
1375    /// Suggested next steps. Always emitted (possibly empty for
1376    /// forward-compat).
1377    pub actions: Vec<IssueAction>,
1378    /// Type-aware evidence for this exact candidate when requested.
1379    #[serde(default, skip_serializing_if = "Option::is_none")]
1380    pub semantic: Option<SemanticCandidateDecision>,
1381    /// Set by the audit pass when this finding is introduced relative to
1382    /// the merge-base.
1383    #[serde(default, skip_serializing_if = "Option::is_none")]
1384    pub introduced: Option<AuditIntroduced>,
1385    /// Gate severity of this finding after `rules` and `overrides[].rules`
1386    /// resolve for its path. CI formats read it for the annotation, SARIF
1387    /// and CodeClimate level. Absent in output from older versions. Not
1388    /// part of the finding identity, baseline keys or fingerprints.
1389    #[serde(
1390        default,
1391        skip_serializing_if = "Option::is_none",
1392        deserialize_with = "deserialize_effective_severity"
1393    )]
1394    pub effective_severity: Option<EffectiveSeverity>,
1395    /// Advisory caveats on the reachability verdict behind this finding.
1396    /// A type export rests on exactly the reachability test an
1397    /// `unused_exports[]` entry does, and the LSP offers the same
1398    /// remove-the-`export`-keyword quick fix for both, so the two must render
1399    /// with the same confidence. Sorted, deduplicated, omitted when empty.
1400    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1401    pub reachability_caveats: Vec<ReachabilityCaveat>,
1402}
1403
1404impl UnusedTypeFinding {
1405    /// Build the wrapper. `is_re_export` swaps the fix note the same way as
1406    /// [`UnusedExportFinding::with_actions`].
1407    #[must_use]
1408    pub fn with_actions(export: UnusedExport) -> Self {
1409        let note = if export.is_re_export {
1410            Some(
1411                "This finding originates from a re-export; verify it is not part of your public API before removing"
1412                    .to_string(),
1413            )
1414        } else {
1415            None
1416        };
1417        let actions = vec![
1418            IssueAction::Fix(FixAction {
1419                kind: FixActionType::RemoveExport,
1420                auto_fixable: true,
1421                description:
1422                    "Remove the `export` (or `export type`) keyword from the type declaration"
1423                        .to_string(),
1424                note,
1425                available_in_catalogs: None,
1426                suggested_target: None,
1427            }),
1428            IssueAction::SuppressLine(SuppressLineAction {
1429                kind: SuppressLineKind::SuppressLine,
1430                auto_fixable: false,
1431                description: "Suppress with an inline comment above the line".to_string(),
1432                comment: "// fallow-ignore-next-line unused-type".to_string(),
1433                scope: None,
1434            }),
1435        ];
1436        Self {
1437            finding_id: None,
1438            export,
1439            actions,
1440            semantic: None,
1441            introduced: None,
1442            effective_severity: None,
1443            reachability_caveats: Vec::new(),
1444        }
1445    }
1446
1447    /// Attach type-aware evidence and disable the syntactic fix when semantic
1448    /// analysis could not establish complete negative evidence.
1449    pub fn set_semantic_decision(&mut self, decision: SemanticCandidateDecision) {
1450        set_export_semantic_action(&mut self.actions, &decision, &self.reachability_caveats);
1451        self.semantic = Some(decision);
1452    }
1453}
1454
1455/// The semantic pass runs in the API layer, AFTER the analysis layer stamped
1456/// this run's caveats, and it is the one code path that RAISES `auto_fixable`.
1457/// It therefore has to ask the gate too, or a `Complete` semantic verdict would
1458/// silently re-open a mutation the incomplete run had already withheld.
1459fn set_export_semantic_action(
1460    actions: &mut [IssueAction],
1461    decision: &SemanticCandidateDecision,
1462    caveats: &[ReachabilityCaveat],
1463) {
1464    let complete_negative = decision.decision
1465        == SemanticCandidateDecisionKind::ConfirmedNoStaticReferences
1466        && decision.status == SemanticCompleteness::Complete;
1467    let Some(IssueAction::Fix(action)) = actions.first_mut() else {
1468        return;
1469    };
1470    action.auto_fixable = complete_negative && caveats.is_empty();
1471    if !complete_negative {
1472        action.note = Some(
1473            "Type-aware analysis retained this candidate because complete negative evidence was not available"
1474                .to_string(),
1475        );
1476    }
1477    if !caveats.is_empty() {
1478        action.note = Some(INCOMPLETE_EVIDENCE_NOTE.to_string());
1479    }
1480}
1481
1482/// Wire-shape envelope for an [`InvalidClientExport`] finding. There is no safe
1483/// auto-fix: the export itself may be a legitimate client-component value
1484/// export that happens to collide with a Next.js server-only name, so removing
1485/// it could break the component. Actions are a manual `move-to-server-module`
1486/// fix (the real remediation) plus a line-level suppress.
1487#[derive(Debug, Clone, Serialize, Deserialize)]
1488#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1489pub struct InvalidClientExportFinding {
1490    /// The underlying dead-code entry.
1491    #[serde(flatten)]
1492    pub export: InvalidClientExport,
1493    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
1494    /// `~<k>` suffix when several findings of one type share an identity.
1495    /// Line and column are not inputs, so the id survives line shifts,
1496    /// reformats and reorders. A rename of the file or the symbol gives a
1497    /// new id. Absent in output from older versions.
1498    #[serde(default, skip_serializing_if = "Option::is_none")]
1499    pub finding_id: Option<String>,
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    /// Gate severity of this finding after `rules` and `overrides[].rules`
1508    /// resolve for its path. CI formats read it for the annotation, SARIF
1509    /// and CodeClimate level. Absent in output from older versions. Not
1510    /// part of the finding identity, baseline keys or fingerprints.
1511    #[serde(
1512        default,
1513        skip_serializing_if = "Option::is_none",
1514        deserialize_with = "deserialize_effective_severity"
1515    )]
1516    pub effective_severity: Option<EffectiveSeverity>,
1517}
1518
1519impl InvalidClientExportFinding {
1520    /// Build the wrapper from a raw [`InvalidClientExport`]. Emits a manual
1521    /// fix action (move the server-only export to a non-client module) plus a
1522    /// line-level suppress: there is no safe auto-fix because removing the
1523    /// export could break a legitimate client component.
1524    #[must_use]
1525    pub fn with_actions(export: InvalidClientExport) -> Self {
1526        let actions = vec![
1527            IssueAction::Fix(FixAction {
1528                kind: FixActionType::MoveToServerModule,
1529                auto_fixable: false,
1530                description: "Move the server-only export to a non-client module and import it from there"
1531                    .to_string(),
1532                note: Some(
1533                    "A \"use client\" file cannot export a Next.js server-only or route-config name; Next.js rejects it at build time"
1534                        .to_string(),
1535                ),
1536                available_in_catalogs: None,
1537                suggested_target: None,
1538            }),
1539            IssueAction::SuppressLine(SuppressLineAction {
1540                kind: SuppressLineKind::SuppressLine,
1541                auto_fixable: false,
1542                description: "Suppress with an inline comment above the line".to_string(),
1543                comment: "// fallow-ignore-next-line invalid-client-export".to_string(),
1544                scope: None,
1545            }),
1546        ];
1547        Self {
1548            finding_id: None,
1549            export,
1550            actions,
1551            introduced: None,
1552            effective_severity: None,
1553        }
1554    }
1555}
1556
1557/// Wire-shape envelope for a [`MixedClientServerBarrel`] finding. There is no
1558/// safe auto-fix: splitting a barrel into separate client and server modules is
1559/// a human decision (the barrel may intentionally aggregate both surfaces).
1560/// Actions are a manual `split-mixed-barrel` fix (the real remediation) plus a
1561/// line-level suppress.
1562#[derive(Debug, Clone, Serialize, Deserialize)]
1563#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1564pub struct MixedClientServerBarrelFinding {
1565    /// The underlying dead-code entry.
1566    #[serde(flatten)]
1567    pub barrel: MixedClientServerBarrel,
1568    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
1569    /// `~<k>` suffix when several findings of one type share an identity.
1570    /// Line and column are not inputs, so the id survives line shifts,
1571    /// reformats and reorders. A rename of the file or the symbol gives a
1572    /// new id. Absent in output from older versions.
1573    #[serde(default, skip_serializing_if = "Option::is_none")]
1574    pub finding_id: Option<String>,
1575    /// Suggested next steps. Always emitted (possibly empty for
1576    /// forward-compat).
1577    pub actions: Vec<IssueAction>,
1578    /// Set by the audit pass when this finding is introduced relative to
1579    /// the merge-base.
1580    #[serde(default, skip_serializing_if = "Option::is_none")]
1581    pub introduced: Option<AuditIntroduced>,
1582    /// Gate severity of this finding after `rules` and `overrides[].rules`
1583    /// resolve for its path. CI formats read it for the annotation, SARIF
1584    /// and CodeClimate level. Absent in output from older versions. Not
1585    /// part of the finding identity, baseline keys or fingerprints.
1586    #[serde(
1587        default,
1588        skip_serializing_if = "Option::is_none",
1589        deserialize_with = "deserialize_effective_severity"
1590    )]
1591    pub effective_severity: Option<EffectiveSeverity>,
1592}
1593
1594impl MixedClientServerBarrelFinding {
1595    /// Build the wrapper from a raw [`MixedClientServerBarrel`]. Emits a manual
1596    /// fix action (split the barrel into separate client and server halves)
1597    /// plus a line-level suppress: there is no safe auto-fix because splitting
1598    /// the barrel is a human decision.
1599    #[must_use]
1600    pub fn with_actions(barrel: MixedClientServerBarrel) -> Self {
1601        let actions = vec![
1602            IssueAction::Fix(FixAction {
1603                kind: FixActionType::SplitMixedBarrel,
1604                auto_fixable: false,
1605                description: "Split the barrel so client and server-only modules are re-exported from separate files"
1606                    .to_string(),
1607                note: Some(
1608                    "Importing one name from this barrel drags the other's directive across the client/server boundary"
1609                        .to_string(),
1610                ),
1611                available_in_catalogs: None,
1612                suggested_target: None,
1613            }),
1614            IssueAction::SuppressLine(SuppressLineAction {
1615                kind: SuppressLineKind::SuppressLine,
1616                auto_fixable: false,
1617                description: "Suppress with an inline comment above the line".to_string(),
1618                comment: "// fallow-ignore-next-line mixed-client-server-barrel".to_string(),
1619                scope: None,
1620            }),
1621        ];
1622        Self {
1623            finding_id: None,
1624            barrel,
1625            actions,
1626            introduced: None,
1627            effective_severity: None,
1628        }
1629    }
1630}
1631
1632/// Wire-shape envelope for a [`MisplacedDirective`] finding. There is no safe
1633/// auto-fix: moving a directive to the leading prologue is a small but
1634/// judgement-bearing edit (the author may have intended the file to be a
1635/// server module after all). Actions are a manual `hoist-directive` fix (the
1636/// real remediation) plus a line-level suppress.
1637#[derive(Debug, Clone, Serialize, Deserialize)]
1638#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1639pub struct MisplacedDirectiveFinding {
1640    /// The underlying dead-code entry.
1641    #[serde(flatten)]
1642    pub directive_site: MisplacedDirective,
1643    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
1644    /// `~<k>` suffix when several findings of one type share an identity.
1645    /// Line and column are not inputs, so the id survives line shifts,
1646    /// reformats and reorders. A rename of the file or the symbol gives a
1647    /// new id. Absent in output from older versions.
1648    #[serde(default, skip_serializing_if = "Option::is_none")]
1649    pub finding_id: Option<String>,
1650    /// Suggested next steps. Always emitted (possibly empty for
1651    /// forward-compat).
1652    pub actions: Vec<IssueAction>,
1653    /// Set by the audit pass when this finding is introduced relative to
1654    /// the merge-base.
1655    #[serde(default, skip_serializing_if = "Option::is_none")]
1656    pub introduced: Option<AuditIntroduced>,
1657    /// Gate severity of this finding after `rules` and `overrides[].rules`
1658    /// resolve for its path. CI formats read it for the annotation, SARIF
1659    /// and CodeClimate level. Absent in output from older versions. Not
1660    /// part of the finding identity, baseline keys or fingerprints.
1661    #[serde(
1662        default,
1663        skip_serializing_if = "Option::is_none",
1664        deserialize_with = "deserialize_effective_severity"
1665    )]
1666    pub effective_severity: Option<EffectiveSeverity>,
1667}
1668
1669impl MisplacedDirectiveFinding {
1670    /// Build the wrapper from a raw [`MisplacedDirective`]. Emits a manual fix
1671    /// action (hoist the directive to the leading prologue) plus a line-level
1672    /// suppress: there is no safe auto-fix because moving a directive can
1673    /// change module semantics and is a human decision.
1674    #[must_use]
1675    pub fn with_actions(directive_site: MisplacedDirective) -> Self {
1676        let actions = vec![
1677            IssueAction::Fix(FixAction {
1678                kind: FixActionType::HoistDirective,
1679                auto_fixable: false,
1680                description: "Move the directive to the very top of the file, above all imports and statements"
1681                    .to_string(),
1682                note: Some(
1683                    "An RSC bundler honors the directive only in the leading prologue; here it precedes other statements and is silently ignored"
1684                        .to_string(),
1685                ),
1686                available_in_catalogs: None,
1687                suggested_target: None,
1688            }),
1689            IssueAction::SuppressLine(SuppressLineAction {
1690                kind: SuppressLineKind::SuppressLine,
1691                auto_fixable: false,
1692                description: "Suppress with an inline comment above the line".to_string(),
1693                comment: "// fallow-ignore-next-line misplaced-directive".to_string(),
1694                scope: None,
1695            }),
1696        ];
1697        Self {
1698            finding_id: None,
1699            directive_site,
1700            actions,
1701            introduced: None,
1702            effective_severity: None,
1703        }
1704    }
1705}
1706
1707/// Wire-shape envelope for an [`UnprovidedInject`] finding. There is no safe
1708/// auto-fix: the fix is binary but judgement-bearing (add a `provide` for the
1709/// key, or delete the dead inject). Actions are manual remediation guidance
1710/// plus a line-level suppress.
1711#[derive(Debug, Clone, Serialize, Deserialize)]
1712#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1713pub struct UnprovidedInjectFinding {
1714    /// The underlying finding.
1715    #[serde(flatten)]
1716    pub inject: UnprovidedInject,
1717    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
1718    /// `~<k>` suffix when several findings of one type share an identity.
1719    /// Line and column are not inputs, so the id survives line shifts,
1720    /// reformats and reorders. A rename of the file or the symbol gives a
1721    /// new id. Absent in output from older versions.
1722    #[serde(default, skip_serializing_if = "Option::is_none")]
1723    pub finding_id: Option<String>,
1724    /// Suggested next steps. Always emitted (possibly empty for
1725    /// forward-compat).
1726    pub actions: Vec<IssueAction>,
1727    /// Set by the audit pass when this finding is introduced relative to
1728    /// the merge-base.
1729    #[serde(default, skip_serializing_if = "Option::is_none")]
1730    pub introduced: Option<AuditIntroduced>,
1731    /// Gate severity of this finding after `rules` and `overrides[].rules`
1732    /// resolve for its path. CI formats read it for the annotation, SARIF
1733    /// and CodeClimate level. Absent in output from older versions. Not
1734    /// part of the finding identity, baseline keys or fingerprints.
1735    #[serde(
1736        default,
1737        skip_serializing_if = "Option::is_none",
1738        deserialize_with = "deserialize_effective_severity"
1739    )]
1740    pub effective_severity: Option<EffectiveSeverity>,
1741}
1742
1743impl UnprovidedInjectFinding {
1744    /// Build the wrapper from a raw [`UnprovidedInject`]. Emits a manual fix
1745    /// action plus a line-level suppress.
1746    #[must_use]
1747    pub fn with_actions(inject: UnprovidedInject) -> Self {
1748        let actions = vec![
1749            manual_framework_fix(
1750                FixActionType::ProvideInject,
1751                "Provide this injected key, or remove the inject / getContext call",
1752                "Manual review required: dependency-injection keys can be provided by framework wiring, tests, or package consumers outside this project.",
1753            ),
1754            suppress_line("// fallow-ignore-next-line unprovided-inject"),
1755        ];
1756        Self {
1757            finding_id: None,
1758            inject,
1759            actions,
1760            introduced: None,
1761            effective_severity: None,
1762        }
1763    }
1764}
1765
1766/// Wire-shape envelope for an [`UnusedServerAction`] finding. There is no safe
1767/// auto-fix: the fix is binary but judgement-bearing (wire the action up to a
1768/// consumer, or delete it). Actions are manual remediation guidance plus a
1769/// line-level suppress.
1770#[derive(Debug, Clone, Serialize, Deserialize)]
1771#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1772pub struct UnusedServerActionFinding {
1773    /// The underlying finding.
1774    #[serde(flatten)]
1775    pub action: UnusedServerAction,
1776    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
1777    /// `~<k>` suffix when several findings of one type share an identity.
1778    /// Line and column are not inputs, so the id survives line shifts,
1779    /// reformats and reorders. A rename of the file or the symbol gives a
1780    /// new id. Absent in output from older versions.
1781    #[serde(default, skip_serializing_if = "Option::is_none")]
1782    pub finding_id: Option<String>,
1783    /// Suggested next steps. Always emitted (possibly empty for
1784    /// forward-compat).
1785    pub actions: Vec<IssueAction>,
1786    /// Set by the audit pass when this finding is introduced relative to
1787    /// the merge-base.
1788    #[serde(default, skip_serializing_if = "Option::is_none")]
1789    pub introduced: Option<AuditIntroduced>,
1790    /// Gate severity of this finding after `rules` and `overrides[].rules`
1791    /// resolve for its path. CI formats read it for the annotation, SARIF
1792    /// and CodeClimate level. Absent in output from older versions. Not
1793    /// part of the finding identity, baseline keys or fingerprints.
1794    #[serde(
1795        default,
1796        skip_serializing_if = "Option::is_none",
1797        deserialize_with = "deserialize_effective_severity"
1798    )]
1799    pub effective_severity: Option<EffectiveSeverity>,
1800}
1801
1802impl UnusedServerActionFinding {
1803    /// Build the wrapper from a raw [`UnusedServerAction`]. Emits a manual fix
1804    /// action plus a line-level suppress.
1805    #[must_use]
1806    pub fn with_actions(action: UnusedServerAction) -> Self {
1807        let actions = vec![
1808            manual_framework_fix(
1809                FixActionType::WireServerAction,
1810                "Wire the server action to a caller or form action, or remove it",
1811                "Manual review required: server actions may still be POST-able by action id or invoked reflectively outside the static project graph.",
1812            ),
1813            suppress_line("// fallow-ignore-next-line unused-server-action"),
1814        ];
1815        Self {
1816            finding_id: None,
1817            action,
1818            actions,
1819            introduced: None,
1820            effective_severity: None,
1821        }
1822    }
1823}
1824
1825/// Wire-shape envelope for an [`UnusedLoadDataKey`] finding. There is no safe
1826/// auto-fix: a `load()` fetch can have side effects, so deleting the key is a
1827/// human call. Actions are manual remediation guidance plus a line-level
1828/// suppress.
1829#[derive(Debug, Clone, Serialize, Deserialize)]
1830#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1831pub struct UnusedLoadDataKeyFinding {
1832    /// The underlying finding.
1833    #[serde(flatten)]
1834    pub key: UnusedLoadDataKey,
1835    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
1836    /// `~<k>` suffix when several findings of one type share an identity.
1837    /// Line and column are not inputs, so the id survives line shifts,
1838    /// reformats and reorders. A rename of the file or the symbol gives a
1839    /// new id. Absent in output from older versions.
1840    #[serde(default, skip_serializing_if = "Option::is_none")]
1841    pub finding_id: Option<String>,
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    /// Gate severity of this finding after `rules` and `overrides[].rules`
1850    /// resolve for its path. CI formats read it for the annotation, SARIF
1851    /// and CodeClimate level. Absent in output from older versions. Not
1852    /// part of the finding identity, baseline keys or fingerprints.
1853    #[serde(
1854        default,
1855        skip_serializing_if = "Option::is_none",
1856        deserialize_with = "deserialize_effective_severity"
1857    )]
1858    pub effective_severity: Option<EffectiveSeverity>,
1859}
1860
1861impl UnusedLoadDataKeyFinding {
1862    /// Build the wrapper from a raw [`UnusedLoadDataKey`]. Emits a manual fix
1863    /// action plus a line-level suppress.
1864    #[must_use]
1865    pub fn with_actions(key: UnusedLoadDataKey) -> Self {
1866        let actions = vec![
1867            manual_framework_fix(
1868                FixActionType::UseLoadData,
1869                "Read this load data key from the route UI, or remove it from the load return",
1870                "Manual review required: load functions can perform real server or database work, so verify side effects before deleting the producer.",
1871            ),
1872            suppress_line("// fallow-ignore-next-line unused-load-data-key"),
1873        ];
1874        Self {
1875            finding_id: None,
1876            key,
1877            actions,
1878            introduced: None,
1879            effective_severity: None,
1880        }
1881    }
1882}
1883
1884/// Wire-shape envelope for an [`UnrenderedComponent`] finding. There is no safe
1885/// auto-fix: the fix is binary but judgement-bearing (render the component
1886/// somewhere, or delete the dead component). Actions are manual remediation
1887/// guidance plus a line-level suppress.
1888#[derive(Debug, Clone, Serialize, Deserialize)]
1889#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1890pub struct UnrenderedComponentFinding {
1891    /// The underlying finding.
1892    #[serde(flatten)]
1893    pub component: UnrenderedComponent,
1894    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
1895    /// `~<k>` suffix when several findings of one type share an identity.
1896    /// Line and column are not inputs, so the id survives line shifts,
1897    /// reformats and reorders. A rename of the file or the symbol gives a
1898    /// new id. Absent in output from older versions.
1899    #[serde(default, skip_serializing_if = "Option::is_none")]
1900    pub finding_id: Option<String>,
1901    /// Suggested next steps. Always emitted (possibly empty for
1902    /// forward-compat).
1903    pub actions: Vec<IssueAction>,
1904    /// Set by the audit pass when this finding is introduced relative to
1905    /// the merge-base.
1906    #[serde(default, skip_serializing_if = "Option::is_none")]
1907    pub introduced: Option<AuditIntroduced>,
1908    /// Gate severity of this finding after `rules` and `overrides[].rules`
1909    /// resolve for its path. CI formats read it for the annotation, SARIF
1910    /// and CodeClimate level. Absent in output from older versions. Not
1911    /// part of the finding identity, baseline keys or fingerprints.
1912    #[serde(
1913        default,
1914        skip_serializing_if = "Option::is_none",
1915        deserialize_with = "deserialize_effective_severity"
1916    )]
1917    pub effective_severity: Option<EffectiveSeverity>,
1918}
1919
1920impl UnrenderedComponentFinding {
1921    /// Build the wrapper from a raw [`UnrenderedComponent`]. Emits a manual
1922    /// fix action plus a line-level suppress.
1923    #[must_use]
1924    pub fn with_actions(component: UnrenderedComponent) -> Self {
1925        let actions = vec![
1926            manual_framework_fix(
1927                FixActionType::RenderComponent,
1928                "Render the reachable component from project code, or remove it",
1929                "Manual review required: exported library components and dynamic render registries can be intentionally reachable without static template usage.",
1930            ),
1931            suppress_line("// fallow-ignore-next-line unrendered-component"),
1932        ];
1933        Self {
1934            finding_id: None,
1935            component,
1936            actions,
1937            introduced: None,
1938            effective_severity: None,
1939        }
1940    }
1941}
1942
1943/// Wire-shape envelope for an [`UnusedComponentProp`] finding. There is no safe
1944/// auto-fix: removing a declared prop is judgement-bearing (the prop may be part
1945/// of a deliberately-stable public component API). Actions are manual
1946/// remediation guidance plus a line-level suppress at the prop declaration.
1947#[derive(Debug, Clone, Serialize, Deserialize)]
1948#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1949pub struct UnusedComponentPropFinding {
1950    /// The underlying finding.
1951    #[serde(flatten)]
1952    pub prop: UnusedComponentProp,
1953    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
1954    /// `~<k>` suffix when several findings of one type share an identity.
1955    /// Line and column are not inputs, so the id survives line shifts,
1956    /// reformats and reorders. A rename of the file or the symbol gives a
1957    /// new id. Absent in output from older versions.
1958    #[serde(default, skip_serializing_if = "Option::is_none")]
1959    pub finding_id: Option<String>,
1960    /// Suggested next steps. Always emitted (possibly empty for
1961    /// forward-compat).
1962    pub actions: Vec<IssueAction>,
1963    /// Set by the audit pass when this finding is introduced relative to
1964    /// the merge-base.
1965    #[serde(default, skip_serializing_if = "Option::is_none")]
1966    pub introduced: Option<AuditIntroduced>,
1967    /// Gate severity of this finding after `rules` and `overrides[].rules`
1968    /// resolve for its path. CI formats read it for the annotation, SARIF
1969    /// and CodeClimate level. Absent in output from older versions. Not
1970    /// part of the finding identity, baseline keys or fingerprints.
1971    #[serde(
1972        default,
1973        skip_serializing_if = "Option::is_none",
1974        deserialize_with = "deserialize_effective_severity"
1975    )]
1976    pub effective_severity: Option<EffectiveSeverity>,
1977}
1978
1979impl UnusedComponentPropFinding {
1980    /// Build the wrapper from a raw [`UnusedComponentProp`]. Emits a manual
1981    /// fix action plus a line-level suppress.
1982    #[must_use]
1983    pub fn with_actions(prop: UnusedComponentProp) -> Self {
1984        let actions = vec![
1985            manual_framework_fix(
1986                FixActionType::UseComponentProp,
1987                "Use the declared prop in the component, or remove it from the component API",
1988                "Manual review required: public component APIs can intentionally keep stable props for external consumers.",
1989            ),
1990            suppress_line("// fallow-ignore-next-line unused-component-prop"),
1991        ];
1992        Self {
1993            finding_id: None,
1994            prop,
1995            actions,
1996            introduced: None,
1997            effective_severity: None,
1998        }
1999    }
2000}
2001
2002/// Wire-shape envelope for an [`AbsentComponentProp`] finding. There is no safe
2003/// auto-fix: removing a declared prop is judgement-bearing (the prop may be part
2004/// of a deliberately-stable public component API). Actions are manual
2005/// remediation guidance plus a line-level suppress at the prop declaration.
2006#[derive(Debug, Clone, Serialize, Deserialize)]
2007#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2008pub struct AbsentComponentPropFinding {
2009    /// The underlying finding.
2010    #[serde(flatten)]
2011    pub prop: AbsentComponentProp,
2012    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2013    /// `~<k>` suffix when several findings of one type share an identity.
2014    /// Line and column are not inputs, so the id survives line shifts,
2015    /// reformats and reorders. A rename of the file or the symbol gives a
2016    /// new id. Absent in output from older versions.
2017    #[serde(default, skip_serializing_if = "Option::is_none")]
2018    pub finding_id: Option<String>,
2019    /// Suggested next steps. Always emitted (possibly empty for
2020    /// forward-compat).
2021    pub actions: Vec<IssueAction>,
2022    /// Set by the audit pass when this finding is introduced relative to
2023    /// the merge-base.
2024    #[serde(default, skip_serializing_if = "Option::is_none")]
2025    pub introduced: Option<AuditIntroduced>,
2026    /// Gate severity of this finding after `rules` and `overrides[].rules`
2027    /// resolve for its path. CI formats read it for the annotation, SARIF
2028    /// and CodeClimate level. Absent in output from older versions. Not
2029    /// part of the finding identity, baseline keys or fingerprints.
2030    #[serde(
2031        default,
2032        skip_serializing_if = "Option::is_none",
2033        deserialize_with = "deserialize_effective_severity"
2034    )]
2035    pub effective_severity: Option<EffectiveSeverity>,
2036}
2037
2038impl AbsentComponentPropFinding {
2039    /// Build the wrapper from a raw [`AbsentComponentProp`]. Emits a manual
2040    /// fix action plus a line-level suppress.
2041    #[must_use]
2042    pub fn with_actions(prop: AbsentComponentProp) -> Self {
2043        let actions = vec![
2044            manual_framework_fix(
2045                FixActionType::ReviewComponentProp,
2046                "Review inspected callers, defaults and API intent",
2047                "Retain or suppress intentional stable props, or update the component manually; static analysis does not prove runtime unreachability.",
2048            ),
2049            suppress_line("// fallow-ignore-next-line absent-component-prop"),
2050        ];
2051        Self {
2052            finding_id: None,
2053            prop,
2054            actions,
2055            introduced: None,
2056            effective_severity: None,
2057        }
2058    }
2059}
2060
2061/// Wire-shape envelope for an [`UnusedComponentEmit`] finding. There is no safe
2062/// auto-fix: removing a declared emit is judgement-bearing (the event may be
2063/// part of a deliberately-stable public component API). Actions are manual
2064/// remediation guidance plus a line-level suppress at the emit declaration.
2065#[derive(Debug, Clone, Serialize, Deserialize)]
2066#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2067pub struct UnusedComponentEmitFinding {
2068    /// The underlying finding.
2069    #[serde(flatten)]
2070    pub emit: UnusedComponentEmit,
2071    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2072    /// `~<k>` suffix when several findings of one type share an identity.
2073    /// Line and column are not inputs, so the id survives line shifts,
2074    /// reformats and reorders. A rename of the file or the symbol gives a
2075    /// new id. Absent in output from older versions.
2076    #[serde(default, skip_serializing_if = "Option::is_none")]
2077    pub finding_id: Option<String>,
2078    /// Suggested next steps. Always emitted (possibly empty for
2079    /// forward-compat).
2080    pub actions: Vec<IssueAction>,
2081    /// Set by the audit pass when this finding is introduced relative to
2082    /// the merge-base.
2083    #[serde(default, skip_serializing_if = "Option::is_none")]
2084    pub introduced: Option<AuditIntroduced>,
2085    /// Gate severity of this finding after `rules` and `overrides[].rules`
2086    /// resolve for its path. CI formats read it for the annotation, SARIF
2087    /// and CodeClimate level. Absent in output from older versions. Not
2088    /// part of the finding identity, baseline keys or fingerprints.
2089    #[serde(
2090        default,
2091        skip_serializing_if = "Option::is_none",
2092        deserialize_with = "deserialize_effective_severity"
2093    )]
2094    pub effective_severity: Option<EffectiveSeverity>,
2095}
2096
2097impl UnusedComponentEmitFinding {
2098    /// Build the wrapper from a raw [`UnusedComponentEmit`]. Emits a manual
2099    /// fix action plus a line-level suppress.
2100    #[must_use]
2101    pub fn with_actions(emit: UnusedComponentEmit) -> Self {
2102        let actions = vec![
2103            manual_framework_fix(
2104                FixActionType::EmitComponentEvent,
2105                "Emit the declared event from the component, or remove it from the component API",
2106                "Manual review required: public component APIs can intentionally keep stable events for external listeners.",
2107            ),
2108            suppress_line("// fallow-ignore-next-line unused-component-emit"),
2109        ];
2110        Self {
2111            finding_id: None,
2112            emit,
2113            actions,
2114            introduced: None,
2115            effective_severity: None,
2116        }
2117    }
2118}
2119
2120/// Wire-shape envelope for an [`UnusedSvelteEvent`] finding. There is no safe
2121/// auto-fix: removing a dispatched event is judgement-bearing (the event may be
2122/// part of a deliberately-stable public component API, or a listener may be
2123/// added later). Actions are manual remediation guidance plus a line-level
2124/// suppress at the `dispatch` call.
2125#[derive(Debug, Clone, Serialize, Deserialize)]
2126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2127pub struct UnusedSvelteEventFinding {
2128    /// The underlying finding.
2129    #[serde(flatten)]
2130    pub event: UnusedSvelteEvent,
2131    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2132    /// `~<k>` suffix when several findings of one type share an identity.
2133    /// Line and column are not inputs, so the id survives line shifts,
2134    /// reformats and reorders. A rename of the file or the symbol gives a
2135    /// new id. Absent in output from older versions.
2136    #[serde(default, skip_serializing_if = "Option::is_none")]
2137    pub finding_id: Option<String>,
2138    /// Suggested next steps. Always emitted (possibly empty for
2139    /// forward-compat).
2140    pub actions: Vec<IssueAction>,
2141    /// Set by the audit pass when this finding is introduced relative to
2142    /// the merge-base.
2143    #[serde(default, skip_serializing_if = "Option::is_none")]
2144    pub introduced: Option<AuditIntroduced>,
2145    /// Gate severity of this finding after `rules` and `overrides[].rules`
2146    /// resolve for its path. CI formats read it for the annotation, SARIF
2147    /// and CodeClimate level. Absent in output from older versions. Not
2148    /// part of the finding identity, baseline keys or fingerprints.
2149    #[serde(
2150        default,
2151        skip_serializing_if = "Option::is_none",
2152        deserialize_with = "deserialize_effective_severity"
2153    )]
2154    pub effective_severity: Option<EffectiveSeverity>,
2155}
2156
2157impl UnusedSvelteEventFinding {
2158    /// Build the wrapper from a raw [`UnusedSvelteEvent`]. Emits a manual fix
2159    /// action plus a line-level suppress.
2160    #[must_use]
2161    pub fn with_actions(event: UnusedSvelteEvent) -> Self {
2162        let actions = vec![
2163            manual_framework_fix(
2164                FixActionType::WireSvelteEvent,
2165                "Add or forward a listener for this custom event, or remove the dispatch",
2166                "Manual review required: public Svelte component APIs can intentionally dispatch events for package consumers outside this project.",
2167            ),
2168            suppress_line("// fallow-ignore-next-line unused-svelte-event"),
2169        ];
2170        Self {
2171            finding_id: None,
2172            event,
2173            actions,
2174            introduced: None,
2175            effective_severity: None,
2176        }
2177    }
2178}
2179
2180/// Wire-shape envelope for an [`UnusedEffectLayer`] finding. There is no safe auto-fix: a test or a script outside the project graph can still use the layer. Actions are manual
2181/// remediation guidance plus a line-level suppress.
2182#[derive(Debug, Clone, Serialize, Deserialize)]
2183#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2184pub struct UnusedEffectLayerFinding {
2185    /// The underlying finding.
2186    #[serde(flatten)]
2187    pub layer: UnusedEffectLayer,
2188    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2189    /// `~<k>` suffix when several findings of one type share an identity.
2190    /// Line and column are not inputs, so the id survives line shifts,
2191    /// reformats and reorders. A rename of the file or the symbol gives a
2192    /// new id. Absent in output from older versions.
2193    #[serde(default, skip_serializing_if = "Option::is_none")]
2194    pub finding_id: Option<String>,
2195    /// Suggested next steps. Always emitted (possibly empty for
2196    /// forward-compat).
2197    pub actions: Vec<IssueAction>,
2198    /// Set by the audit pass when this finding is introduced relative to
2199    /// the merge-base.
2200    #[serde(default, skip_serializing_if = "Option::is_none")]
2201    pub introduced: Option<AuditIntroduced>,
2202    /// Gate severity of this finding after `rules` and `overrides[].rules`
2203    /// resolve for its path. CI formats read it for the annotation, SARIF
2204    /// and CodeClimate level. Absent in output from older versions. Not
2205    /// part of the finding identity, baseline keys or fingerprints.
2206    #[serde(
2207        default,
2208        skip_serializing_if = "Option::is_none",
2209        deserialize_with = "deserialize_effective_severity"
2210    )]
2211    pub effective_severity: Option<EffectiveSeverity>,
2212}
2213
2214impl UnusedEffectLayerFinding {
2215    /// Build the wrapper from a raw [`UnusedEffectLayer`]. Emits a manual fix action plus
2216    /// a line-level suppress.
2217    #[must_use]
2218    pub fn with_actions(layer: UnusedEffectLayer) -> Self {
2219        let actions = vec![
2220            manual_framework_fix(
2221                FixActionType::ComposeEffectLayer,
2222                "Compose the layer into a layer that reaches a runtime, or remove it with its unused consumers",
2223                "Manual review required: remove the unused consumer layers first, then this layer. Code outside the analyzed project can still compose it.",
2224            ),
2225            suppress_line("// fallow-ignore-next-line unused-effect-layer"),
2226        ];
2227        Self {
2228            finding_id: None,
2229            layer,
2230            actions,
2231            introduced: None,
2232            effective_severity: None,
2233        }
2234    }
2235}
2236
2237/// Wire-shape envelope for an [`UnusedTaggedError`] finding. There is no safe auto-fix: the error can be part of a public API contract. Actions are manual
2238/// remediation guidance plus a line-level suppress.
2239#[derive(Debug, Clone, Serialize, Deserialize)]
2240#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2241pub struct UnusedTaggedErrorFinding {
2242    /// The underlying finding.
2243    #[serde(flatten)]
2244    pub error: UnusedTaggedError,
2245    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2246    /// `~<k>` suffix when several findings of one type share an identity.
2247    /// Line and column are not inputs, so the id survives line shifts,
2248    /// reformats and reorders. A rename of the file or the symbol gives a
2249    /// new id. Absent in output from older versions.
2250    #[serde(default, skip_serializing_if = "Option::is_none")]
2251    pub finding_id: Option<String>,
2252    /// Suggested next steps. Always emitted (possibly empty for
2253    /// forward-compat).
2254    pub actions: Vec<IssueAction>,
2255    /// Set by the audit pass when this finding is introduced relative to
2256    /// the merge-base.
2257    #[serde(default, skip_serializing_if = "Option::is_none")]
2258    pub introduced: Option<AuditIntroduced>,
2259    /// Gate severity of this finding after `rules` and `overrides[].rules`
2260    /// resolve for its path. CI formats read it for the annotation, SARIF
2261    /// and CodeClimate level. Absent in output from older versions. Not
2262    /// part of the finding identity, baseline keys or fingerprints.
2263    #[serde(
2264        default,
2265        skip_serializing_if = "Option::is_none",
2266        deserialize_with = "deserialize_effective_severity"
2267    )]
2268    pub effective_severity: Option<EffectiveSeverity>,
2269}
2270
2271impl UnusedTaggedErrorFinding {
2272    /// Build the wrapper from a raw [`UnusedTaggedError`]. Emits a manual fix action plus
2273    /// a line-level suppress.
2274    #[must_use]
2275    pub fn with_actions(error: UnusedTaggedError) -> Self {
2276        let actions = vec![
2277            manual_framework_fix(
2278                FixActionType::FailTaggedError,
2279                "Fail with this error where the condition occurs, or remove the class and its catchTag handlers",
2280                "Manual review required: consumers outside the analyzed project can construct or decode the error.",
2281            ),
2282            suppress_line("// fallow-ignore-next-line unused-tagged-error"),
2283        ];
2284        Self {
2285            finding_id: None,
2286            error,
2287            actions,
2288            introduced: None,
2289            effective_severity: None,
2290        }
2291    }
2292}
2293
2294/// Wire-shape envelope for an [`UnproducedCatchTag`] finding. There is no safe auto-fix: the handler can guard against a future producer. Actions are manual
2295/// remediation guidance plus a line-level suppress.
2296#[derive(Debug, Clone, Serialize, Deserialize)]
2297#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2298pub struct UnproducedCatchTagFinding {
2299    /// The underlying finding.
2300    #[serde(flatten)]
2301    pub catch_tag: UnproducedCatchTag,
2302    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2303    /// `~<k>` suffix when several findings of one type share an identity.
2304    /// Line and column are not inputs, so the id survives line shifts,
2305    /// reformats and reorders. A rename of the file or the symbol gives a
2306    /// new id. Absent in output from older versions.
2307    #[serde(default, skip_serializing_if = "Option::is_none")]
2308    pub finding_id: Option<String>,
2309    /// Suggested next steps. Always emitted (possibly empty for
2310    /// forward-compat).
2311    pub actions: Vec<IssueAction>,
2312    /// Set by the audit pass when this finding is introduced relative to
2313    /// the merge-base.
2314    #[serde(default, skip_serializing_if = "Option::is_none")]
2315    pub introduced: Option<AuditIntroduced>,
2316    /// Gate severity of this finding after `rules` and `overrides[].rules`
2317    /// resolve for its path. CI formats read it for the annotation, SARIF
2318    /// and CodeClimate level. Absent in output from older versions. Not
2319    /// part of the finding identity, baseline keys or fingerprints.
2320    #[serde(
2321        default,
2322        skip_serializing_if = "Option::is_none",
2323        deserialize_with = "deserialize_effective_severity"
2324    )]
2325    pub effective_severity: Option<EffectiveSeverity>,
2326}
2327
2328impl UnproducedCatchTagFinding {
2329    /// Build the wrapper from a raw [`UnproducedCatchTag`]. Emits a manual fix action plus
2330    /// a line-level suppress.
2331    #[must_use]
2332    pub fn with_actions(catch_tag: UnproducedCatchTag) -> Self {
2333        let actions = vec![
2334            manual_framework_fix(
2335                FixActionType::RemoveCatchTagHandler,
2336                "Remove the handler for this tag, or fail with the error where the condition occurs",
2337                "Manual review required: a package or code outside the analyzed project can produce the tag.",
2338            ),
2339            suppress_line("// fallow-ignore-next-line unproduced-catch-tag"),
2340        ];
2341        Self {
2342            finding_id: None,
2343            catch_tag,
2344            actions,
2345            introduced: None,
2346            effective_severity: None,
2347        }
2348    }
2349}
2350
2351/// Wire-shape envelope for a [`PropDrillingChain`] finding. There is no safe
2352/// auto-fix: collapsing a drilling chain (colocate the consumer, lift to a
2353/// context, or compose the component) is a design decision. The only action is a
2354/// line-level suppress at the source hop's prop declaration. The rule defaults
2355/// to `off` (opt-in health signal), so this finding is dormant by default.
2356#[derive(Debug, Clone, Serialize, Deserialize)]
2357#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2358pub struct PropDrillingChainFinding {
2359    /// The underlying located chain.
2360    #[serde(flatten)]
2361    pub chain: PropDrillingChain,
2362    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2363    /// `~<k>` suffix when several findings of one type share an identity.
2364    /// Line and column are not inputs, so the id survives line shifts,
2365    /// reformats and reorders. A rename of the file or the symbol gives a
2366    /// new id. Absent in output from older versions.
2367    #[serde(default, skip_serializing_if = "Option::is_none")]
2368    pub finding_id: Option<String>,
2369    /// Suggested next steps. Always emitted (possibly empty for
2370    /// forward-compat).
2371    pub actions: Vec<IssueAction>,
2372    /// Set by the audit pass when this finding is introduced relative to
2373    /// the merge-base.
2374    #[serde(default, skip_serializing_if = "Option::is_none")]
2375    pub introduced: Option<AuditIntroduced>,
2376    /// Rule severity after resolving overrides for the source hop. An explicit
2377    /// `error` enforces the chain; `warn` stays advisory under strict flags.
2378    /// Saved reports use this value for CI levels. Absent in older output.
2379    /// Not part of finding identity, baseline keys or fingerprints.
2380    #[serde(
2381        default,
2382        skip_serializing_if = "Option::is_none",
2383        deserialize_with = "deserialize_effective_severity"
2384    )]
2385    pub effective_severity: Option<EffectiveSeverity>,
2386}
2387
2388impl PropDrillingChainFinding {
2389    /// Build the wrapper from a raw [`PropDrillingChain`]. Emits only a
2390    /// line-level suppress action anchored at the source hop: there is no safe
2391    /// auto-fix because collapsing the chain is a design decision (colocate,
2392    /// lift to context, or compose).
2393    #[must_use]
2394    pub fn with_actions(chain: PropDrillingChain) -> Self {
2395        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
2396            kind: SuppressLineKind::SuppressLine,
2397            auto_fixable: false,
2398            description: "Suppress with an inline comment above the source prop declaration"
2399                .to_string(),
2400            comment: "// fallow-ignore-next-line prop-drilling".to_string(),
2401            scope: None,
2402        })];
2403        Self {
2404            finding_id: None,
2405            chain,
2406            actions,
2407            introduced: None,
2408            effective_severity: None,
2409        }
2410    }
2411}
2412
2413/// Wire-shape envelope for a [`ThinWrapper`] finding. There is no safe
2414/// auto-fix: inlining a thin wrapper at its call sites (or deleting it) is a
2415/// design decision. The only action is a line-level suppress at the wrapper's
2416/// definition. The rule defaults to `off` (opt-in health signal), so this
2417/// finding is dormant by default.
2418#[derive(Debug, Clone, Serialize, Deserialize)]
2419#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2420pub struct ThinWrapperFinding {
2421    /// The underlying located thin wrapper.
2422    #[serde(flatten)]
2423    pub wrapper: ThinWrapper,
2424    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2425    /// `~<k>` suffix when several findings of one type share an identity.
2426    /// Line and column are not inputs, so the id survives line shifts,
2427    /// reformats and reorders. A rename of the file or the symbol gives a
2428    /// new id. Absent in output from older versions.
2429    #[serde(default, skip_serializing_if = "Option::is_none")]
2430    pub finding_id: Option<String>,
2431    /// Suggested next steps. Always emitted (possibly empty for
2432    /// forward-compat).
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    /// Rule severity of this finding. This type never gates the run, so the
2439    /// value does not change the exit code. `fallow report --from` reads it
2440    /// for the SARIF level, so the level does not depend on the config at
2441    /// render time. Absent in output from older versions. Not part of the
2442    /// finding identity, baseline keys or fingerprints.
2443    #[serde(
2444        default,
2445        skip_serializing_if = "Option::is_none",
2446        deserialize_with = "deserialize_effective_severity"
2447    )]
2448    pub effective_severity: Option<EffectiveSeverity>,
2449}
2450
2451impl ThinWrapperFinding {
2452    /// Build the wrapper from a raw [`ThinWrapper`]. Emits only a line-level
2453    /// suppress action anchored at the wrapper definition: there is no safe
2454    /// auto-fix because inlining or deleting the wrapper is a design decision.
2455    #[must_use]
2456    pub fn with_actions(wrapper: ThinWrapper) -> Self {
2457        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
2458            kind: SuppressLineKind::SuppressLine,
2459            auto_fixable: false,
2460            description: "Suppress with an inline comment above the component definition"
2461                .to_string(),
2462            comment: "// fallow-ignore-next-line thin-wrapper".to_string(),
2463            scope: None,
2464        })];
2465        Self {
2466            finding_id: None,
2467            wrapper,
2468            actions,
2469            introduced: None,
2470            effective_severity: None,
2471        }
2472    }
2473}
2474
2475/// Wire-shape envelope for a [`DuplicatePropShape`] finding. There is no safe
2476/// auto-fix: extracting a shared `Props` type or a base component for a group of
2477/// same-shaped components is a design decision. The actions are manual guidance
2478/// (extract the shared shape) plus a line-level suppress at the component
2479/// definition and a file-level suppress escape hatch (mirroring the
2480/// route-collision multi-file model). The rule defaults to `off` (opt-in health
2481/// signal), so this finding is dormant by default.
2482#[derive(Debug, Clone, Serialize, Deserialize)]
2483#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2484pub struct DuplicatePropShapeFinding {
2485    /// The underlying duplicate-prop-shape entry.
2486    #[serde(flatten)]
2487    pub shape: DuplicatePropShape,
2488    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2489    /// `~<k>` suffix when several findings of one type share an identity.
2490    /// Line and column are not inputs, so the id survives line shifts,
2491    /// reformats and reorders. A rename of the file or the symbol gives a
2492    /// new id. Absent in output from older versions.
2493    #[serde(default, skip_serializing_if = "Option::is_none")]
2494    pub finding_id: Option<String>,
2495    /// Suggested next steps. Always emitted (possibly empty for
2496    /// forward-compat).
2497    pub actions: Vec<IssueAction>,
2498    /// Set by the audit pass when this finding is introduced relative to
2499    /// the merge-base.
2500    #[serde(default, skip_serializing_if = "Option::is_none")]
2501    pub introduced: Option<AuditIntroduced>,
2502    /// Rule severity of this finding. This type never gates the run, so the
2503    /// value does not change the exit code. `fallow report --from` reads it
2504    /// for the SARIF level, so the level does not depend on the config at
2505    /// render time. Absent in output from older versions. Not part of the
2506    /// finding identity, baseline keys or fingerprints.
2507    #[serde(
2508        default,
2509        skip_serializing_if = "Option::is_none",
2510        deserialize_with = "deserialize_effective_severity"
2511    )]
2512    pub effective_severity: Option<EffectiveSeverity>,
2513}
2514
2515impl DuplicatePropShapeFinding {
2516    /// Build the wrapper from a raw [`DuplicatePropShape`]. Manual guidance is
2517    /// the primary action (extract a shared shape); a line-level suppress at the
2518    /// component definition and a file-level suppress escape hatch follow,
2519    /// mirroring the multi-file route-collision suppress model. There is no safe
2520    /// auto-fix because extracting a shared type or base component is a design
2521    /// decision.
2522    #[must_use]
2523    pub fn with_actions(shape: DuplicatePropShape) -> Self {
2524        let actions = vec![
2525            IssueAction::SuppressLine(SuppressLineAction {
2526                kind: SuppressLineKind::SuppressLine,
2527                auto_fixable: false,
2528                description: "Three or more components share this exact prop shape. Extract one \
2529                              shared `Props` type (or a base component) that every member reuses, \
2530                              or keep them separate if a per-variant divergence is planned. \
2531                              Suppress one member with an inline comment above the component \
2532                              definition."
2533                    .to_string(),
2534                comment: "// fallow-ignore-next-line duplicate-prop-shape".to_string(),
2535                scope: None,
2536            }),
2537            IssueAction::SuppressFile(SuppressFileAction {
2538                kind: SuppressFileKind::SuppressFile,
2539                auto_fixable: false,
2540                description: "Escape hatch: a file-level suppress silences this member but it \
2541                              still appears in its siblings' `sharing_components` (the group is \
2542                              real regardless of suppression)."
2543                    .to_string(),
2544                comment: "// fallow-ignore-file duplicate-prop-shape".to_string(),
2545            }),
2546        ];
2547        Self {
2548            finding_id: None,
2549            shape,
2550            actions,
2551            introduced: None,
2552            effective_severity: None,
2553        }
2554    }
2555}
2556
2557/// Wire-shape envelope for an [`UnusedComponentInput`] finding. There is no safe
2558/// auto-fix: removing a declared input is judgement-bearing (the input may be
2559/// part of a deliberately-stable public component API). The only action is a
2560/// line-level suppress at the input declaration.
2561#[derive(Debug, Clone, Serialize, Deserialize)]
2562#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2563pub struct UnusedComponentInputFinding {
2564    /// The underlying finding.
2565    #[serde(flatten)]
2566    pub input: UnusedComponentInput,
2567    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2568    /// `~<k>` suffix when several findings of one type share an identity.
2569    /// Line and column are not inputs, so the id survives line shifts,
2570    /// reformats and reorders. A rename of the file or the symbol gives a
2571    /// new id. Absent in output from older versions.
2572    #[serde(default, skip_serializing_if = "Option::is_none")]
2573    pub finding_id: Option<String>,
2574    /// Suggested next steps. Always emitted (possibly empty for
2575    /// forward-compat).
2576    pub actions: Vec<IssueAction>,
2577    /// Set by the audit pass when this finding is introduced relative to
2578    /// the merge-base.
2579    #[serde(default, skip_serializing_if = "Option::is_none")]
2580    pub introduced: Option<AuditIntroduced>,
2581    /// Gate severity of this finding after `rules` and `overrides[].rules`
2582    /// resolve for its path. CI formats read it for the annotation, SARIF
2583    /// and CodeClimate level. Absent in output from older versions. Not
2584    /// part of the finding identity, baseline keys or fingerprints.
2585    #[serde(
2586        default,
2587        skip_serializing_if = "Option::is_none",
2588        deserialize_with = "deserialize_effective_severity"
2589    )]
2590    pub effective_severity: Option<EffectiveSeverity>,
2591}
2592
2593impl UnusedComponentInputFinding {
2594    /// Build the wrapper from a raw [`UnusedComponentInput`]. Emits only a
2595    /// line-level suppress action: there is no safe auto-fix because removing an
2596    /// input is a human decision (it may be part of a stable component API).
2597    #[must_use]
2598    pub fn with_actions(input: UnusedComponentInput) -> Self {
2599        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
2600            kind: SuppressLineKind::SuppressLine,
2601            auto_fixable: false,
2602            description: "Suppress with an inline comment above the line".to_string(),
2603            comment: "// fallow-ignore-next-line unused-component-input".to_string(),
2604            scope: None,
2605        })];
2606        Self {
2607            finding_id: None,
2608            input,
2609            actions,
2610            introduced: None,
2611            effective_severity: None,
2612        }
2613    }
2614}
2615
2616/// Wire-shape envelope for an [`UnusedComponentOutput`] finding. There is no safe
2617/// auto-fix: removing a declared output is judgement-bearing (the event may be
2618/// part of a deliberately-stable public component API). The only action is a
2619/// line-level suppress at the output declaration.
2620#[derive(Debug, Clone, Serialize, Deserialize)]
2621#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2622pub struct UnusedComponentOutputFinding {
2623    /// The underlying finding.
2624    #[serde(flatten)]
2625    pub output: UnusedComponentOutput,
2626    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2627    /// `~<k>` suffix when several findings of one type share an identity.
2628    /// Line and column are not inputs, so the id survives line shifts,
2629    /// reformats and reorders. A rename of the file or the symbol gives a
2630    /// new id. Absent in output from older versions.
2631    #[serde(default, skip_serializing_if = "Option::is_none")]
2632    pub finding_id: Option<String>,
2633    /// Suggested next steps. Always emitted (possibly empty for
2634    /// forward-compat).
2635    pub actions: Vec<IssueAction>,
2636    /// Set by the audit pass when this finding is introduced relative to
2637    /// the merge-base.
2638    #[serde(default, skip_serializing_if = "Option::is_none")]
2639    pub introduced: Option<AuditIntroduced>,
2640    /// Gate severity of this finding after `rules` and `overrides[].rules`
2641    /// resolve for its path. CI formats read it for the annotation, SARIF
2642    /// and CodeClimate level. Absent in output from older versions. Not
2643    /// part of the finding identity, baseline keys or fingerprints.
2644    #[serde(
2645        default,
2646        skip_serializing_if = "Option::is_none",
2647        deserialize_with = "deserialize_effective_severity"
2648    )]
2649    pub effective_severity: Option<EffectiveSeverity>,
2650}
2651
2652impl UnusedComponentOutputFinding {
2653    /// Build the wrapper from a raw [`UnusedComponentOutput`]. Emits only a
2654    /// line-level suppress action: there is no safe auto-fix because removing an
2655    /// output is a human decision (it may be part of a stable component API).
2656    #[must_use]
2657    pub fn with_actions(output: UnusedComponentOutput) -> Self {
2658        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
2659            kind: SuppressLineKind::SuppressLine,
2660            auto_fixable: false,
2661            description: "Suppress with an inline comment above the line".to_string(),
2662            comment: "// fallow-ignore-next-line unused-component-output".to_string(),
2663            scope: None,
2664        })];
2665        Self {
2666            finding_id: None,
2667            output,
2668            actions,
2669            introduced: None,
2670            effective_severity: None,
2671        }
2672    }
2673}
2674
2675/// Wire-shape envelope for a [`RouteCollision`] finding. A route collision is a
2676/// guaranteed `next build` failure, so the PRIMARY action is manual guidance
2677/// (move or merge one of the colliding files), NOT a suppress: suppressing a
2678/// build error never makes the build pass. A file-level suppress is offered as
2679/// an escape hatch only.
2680#[derive(Debug, Clone, Serialize, Deserialize)]
2681#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2682pub struct RouteCollisionFinding {
2683    /// The underlying route-collision entry.
2684    #[serde(flatten)]
2685    pub collision: RouteCollision,
2686    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2687    /// `~<k>` suffix when several findings of one type share an identity.
2688    /// Line and column are not inputs, so the id survives line shifts,
2689    /// reformats and reorders. A rename of the file or the symbol gives a
2690    /// new id. Absent in output from older versions.
2691    #[serde(default, skip_serializing_if = "Option::is_none")]
2692    pub finding_id: Option<String>,
2693    /// Suggested next steps. Always emitted (possibly empty for
2694    /// forward-compat).
2695    pub actions: Vec<IssueAction>,
2696    /// Set by the audit pass when this finding is introduced relative to
2697    /// the merge-base.
2698    #[serde(default, skip_serializing_if = "Option::is_none")]
2699    pub introduced: Option<AuditIntroduced>,
2700    /// Gate severity of this finding after `rules` and `overrides[].rules`
2701    /// resolve for its path. CI formats read it for the annotation, SARIF
2702    /// and CodeClimate level. Absent in output from older versions. Not
2703    /// part of the finding identity, baseline keys or fingerprints.
2704    #[serde(
2705        default,
2706        skip_serializing_if = "Option::is_none",
2707        deserialize_with = "deserialize_effective_severity"
2708    )]
2709    pub effective_severity: Option<EffectiveSeverity>,
2710}
2711
2712impl RouteCollisionFinding {
2713    /// Build the wrapper from a raw [`RouteCollision`]. The primary action is
2714    /// manual guidance because suppressing a guaranteed build error is never
2715    /// the right fix; a file-level suppress is the escape hatch only.
2716    #[must_use]
2717    pub fn with_actions(collision: RouteCollision) -> Self {
2718        let actions = vec![
2719            IssueAction::Fix(FixAction {
2720                kind: FixActionType::ResolveRouteCollision,
2721                auto_fixable: false,
2722                description: "Two or more files resolve to the same URL. Move or merge one so \
2723                              each URL has a single owner. Route groups `(name)` and parallel \
2724                              slots `@name` are the only legal same-URL shapes."
2725                    .to_string(),
2726                note: Some(
2727                    "Next.js fails the build with \"You cannot have two parallel pages that \
2728                     resolve to the same path\". See the sibling `conflicting_paths` array for \
2729                     the other files that own this URL."
2730                        .to_string(),
2731                ),
2732                available_in_catalogs: None,
2733                suggested_target: None,
2734            }),
2735            IssueAction::SuppressFile(SuppressFileAction {
2736                kind: SuppressFileKind::SuppressFile,
2737                auto_fixable: false,
2738                description: "Escape hatch only: a file-level suppress silences the finding but \
2739                              does NOT make `next build` pass. Prefer moving or merging a file."
2740                    .to_string(),
2741                comment: "// fallow-ignore-file route-collision".to_string(),
2742            }),
2743        ];
2744        Self {
2745            finding_id: None,
2746            collision,
2747            actions,
2748            introduced: None,
2749            effective_severity: None,
2750        }
2751    }
2752}
2753
2754/// Wire-shape envelope for a [`DynamicSegmentNameConflict`] finding. The
2755/// conflict is a Next.js dev / runtime error (`next build` does NOT catch it),
2756/// so the primary action is manual guidance (rename the dynamic segments to a
2757/// single consistent slug name), with a file-level suppress as escape hatch.
2758#[derive(Debug, Clone, Serialize, Deserialize)]
2759#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2760pub struct DynamicSegmentNameConflictFinding {
2761    /// The underlying dynamic-segment-name-conflict entry.
2762    #[serde(flatten)]
2763    pub conflict: DynamicSegmentNameConflict,
2764    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2765    /// `~<k>` suffix when several findings of one type share an identity.
2766    /// Line and column are not inputs, so the id survives line shifts,
2767    /// reformats and reorders. A rename of the file or the symbol gives a
2768    /// new id. Absent in output from older versions.
2769    #[serde(default, skip_serializing_if = "Option::is_none")]
2770    pub finding_id: Option<String>,
2771    /// Suggested next steps. Always emitted (possibly empty for
2772    /// forward-compat).
2773    pub actions: Vec<IssueAction>,
2774    /// Set by the audit pass when this finding is introduced relative to
2775    /// the merge-base.
2776    #[serde(default, skip_serializing_if = "Option::is_none")]
2777    pub introduced: Option<AuditIntroduced>,
2778    /// Gate severity of this finding after `rules` and `overrides[].rules`
2779    /// resolve for its path. CI formats read it for the annotation, SARIF
2780    /// and CodeClimate level. Absent in output from older versions. Not
2781    /// part of the finding identity, baseline keys or fingerprints.
2782    #[serde(
2783        default,
2784        skip_serializing_if = "Option::is_none",
2785        deserialize_with = "deserialize_effective_severity"
2786    )]
2787    pub effective_severity: Option<EffectiveSeverity>,
2788}
2789
2790impl DynamicSegmentNameConflictFinding {
2791    /// Build the wrapper from a raw [`DynamicSegmentNameConflict`]. Manual
2792    /// guidance primary action; file-level suppress escape hatch only.
2793    #[must_use]
2794    pub fn with_actions(conflict: DynamicSegmentNameConflict) -> Self {
2795        let actions = vec![
2796            IssueAction::Fix(FixAction {
2797                kind: FixActionType::ResolveDynamicSegmentNameConflict,
2798                auto_fixable: false,
2799                description: "Sibling dynamic segments at the same position use different param \
2800                              names. Rename them to one consistent slug name (e.g. pick `[id]` \
2801                              or `[slug]` for both)."
2802                    .to_string(),
2803                note: Some(
2804                    "Next.js throws \"You cannot use different slug names for the same dynamic \
2805                     path\" at dev / runtime when the position is hit; `next build` does not \
2806                     catch it. See the sibling `conflicting_segments` array."
2807                        .to_string(),
2808                ),
2809                available_in_catalogs: None,
2810                suggested_target: None,
2811            }),
2812            IssueAction::SuppressFile(SuppressFileAction {
2813                kind: SuppressFileKind::SuppressFile,
2814                auto_fixable: false,
2815                description: "Escape hatch only: a file-level suppress silences the finding but \
2816                              does NOT stop Next.js from throwing at dev / runtime. Prefer \
2817                              renaming the segments."
2818                    .to_string(),
2819                comment: "// fallow-ignore-file dynamic-segment-name-conflict".to_string(),
2820            }),
2821        ];
2822        Self {
2823            finding_id: None,
2824            conflict,
2825            actions,
2826            introduced: None,
2827            effective_severity: None,
2828        }
2829    }
2830}
2831
2832/// Wire-shape envelope for an [`UnusedMember`] finding consumed under the
2833/// `unused_enum_members` key.
2834#[derive(Debug, Clone, Serialize, Deserialize)]
2835#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2836pub struct UnusedEnumMemberFinding {
2837    /// The underlying dead-code entry.
2838    #[serde(flatten)]
2839    pub member: UnusedMember,
2840    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2841    /// `~<k>` suffix when several findings of one type share an identity.
2842    /// Line and column are not inputs, so the id survives line shifts,
2843    /// reformats and reorders. A rename of the file or the symbol gives a
2844    /// new id. Absent in output from older versions.
2845    #[serde(default, skip_serializing_if = "Option::is_none")]
2846    pub finding_id: Option<String>,
2847    /// Suggested next steps. Always emitted (possibly empty for
2848    /// forward-compat).
2849    pub actions: Vec<IssueAction>,
2850    /// Set by the audit pass when this finding is introduced relative to
2851    /// the merge-base.
2852    #[serde(default, skip_serializing_if = "Option::is_none")]
2853    pub introduced: Option<AuditIntroduced>,
2854    /// Gate severity of this finding after `rules` and `overrides[].rules`
2855    /// resolve for its path. CI formats read it for the annotation, SARIF
2856    /// and CodeClimate level. Absent in output from older versions. Not
2857    /// part of the finding identity, baseline keys or fingerprints.
2858    #[serde(
2859        default,
2860        skip_serializing_if = "Option::is_none",
2861        deserialize_with = "deserialize_effective_severity"
2862    )]
2863    pub effective_severity: Option<EffectiveSeverity>,
2864    /// Advisory caveats on the verdict behind this finding. A member's usage
2865    /// is collected by walking the member accesses of every module the run
2866    /// parsed, so a member whose only reference lives in a file the run never
2867    /// read reads as unused exactly like an export does. Sorted,
2868    /// deduplicated, and omitted from the wire when empty. Never gates the
2869    /// finding; it does withhold the `remove-enum-member` mutation.
2870    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2871    pub reachability_caveats: Vec<ReachabilityCaveat>,
2872}
2873
2874impl UnusedEnumMemberFinding {
2875    /// Build the wrapper from a raw [`UnusedMember`].
2876    #[must_use]
2877    pub fn with_actions(member: UnusedMember) -> Self {
2878        let actions = vec![
2879            IssueAction::Fix(FixAction {
2880                kind: FixActionType::RemoveEnumMember,
2881                auto_fixable: true,
2882                description: "Remove this enum member".to_string(),
2883                note: None,
2884                available_in_catalogs: None,
2885                suggested_target: None,
2886            }),
2887            IssueAction::SuppressLine(SuppressLineAction {
2888                kind: SuppressLineKind::SuppressLine,
2889                auto_fixable: false,
2890                description: "Suppress with an inline comment above the line".to_string(),
2891                comment: "// fallow-ignore-next-line unused-enum-member".to_string(),
2892                scope: None,
2893            }),
2894        ];
2895        Self {
2896            finding_id: None,
2897            member,
2898            actions,
2899            introduced: None,
2900            effective_severity: None,
2901            reachability_caveats: Vec::new(),
2902        }
2903    }
2904}
2905
2906/// Wire-shape envelope for an [`UnusedMember`] finding consumed under the
2907/// `unused_class_members` key. Same Rust struct as
2908/// [`UnusedEnumMemberFinding`]; the fix action and suppress comment carry
2909/// the class-member kebab-case identifier instead.
2910#[derive(Debug, Clone, Serialize, Deserialize)]
2911#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2912pub struct UnusedClassMemberFinding {
2913    /// The underlying dead-code entry.
2914    #[serde(flatten)]
2915    pub member: UnusedMember,
2916    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2917    /// `~<k>` suffix when several findings of one type share an identity.
2918    /// Line and column are not inputs, so the id survives line shifts,
2919    /// reformats and reorders. A rename of the file or the symbol gives a
2920    /// new id. Absent in output from older versions.
2921    #[serde(default, skip_serializing_if = "Option::is_none")]
2922    pub finding_id: Option<String>,
2923    /// Suggested next steps. Always emitted (possibly empty for
2924    /// forward-compat).
2925    pub actions: Vec<IssueAction>,
2926    /// Type-aware evidence for this exact candidate when requested.
2927    #[serde(default, skip_serializing_if = "Option::is_none")]
2928    pub semantic: Option<SemanticCandidateDecision>,
2929    /// Internal marker for a framework member that the syntactic analysis
2930    /// suppresses, but the semantic pass may promote after proving complete
2931    /// closed-world absence. Never serialized as part of the public finding.
2932    #[serde(skip)]
2933    #[cfg_attr(feature = "schema", schemars(skip))]
2934    pub semantic_only_candidate: bool,
2935    /// Set by the audit pass when this finding is introduced relative to
2936    /// the merge-base.
2937    #[serde(default, skip_serializing_if = "Option::is_none")]
2938    pub introduced: Option<AuditIntroduced>,
2939    /// Gate severity of this finding after `rules` and `overrides[].rules`
2940    /// resolve for its path. CI formats read it for the annotation, SARIF
2941    /// and CodeClimate level. Absent in output from older versions. Not
2942    /// part of the finding identity, baseline keys or fingerprints.
2943    #[serde(
2944        default,
2945        skip_serializing_if = "Option::is_none",
2946        deserialize_with = "deserialize_effective_severity"
2947    )]
2948    pub effective_severity: Option<EffectiveSeverity>,
2949    /// Advisory caveats on the verdict behind this finding. A class member's
2950    /// usage is collected by the same reachability-free member-access walk an
2951    /// enum member's is, so it takes the enum-member rule unchanged: any module
2952    /// this run analyzed incompletely can hold the access that credits it.
2953    /// Sorted, deduplicated, and omitted from the wire when empty. Never gates
2954    /// the finding; it does withhold the `remove-class-member` mutation that
2955    /// the type-aware pass would otherwise open.
2956    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2957    pub reachability_caveats: Vec<ReachabilityCaveat>,
2958}
2959
2960impl UnusedClassMemberFinding {
2961    /// Build the wrapper from a raw [`UnusedMember`]. Class-member fixes
2962    /// are not auto-applied (members can be used via dependency injection
2963    /// or decorators), so `auto_fixable` is `false` and a context note is
2964    /// attached.
2965    #[must_use]
2966    pub fn with_actions(member: UnusedMember) -> Self {
2967        let actions = vec![
2968            IssueAction::Fix(FixAction {
2969                kind: FixActionType::RemoveClassMember,
2970                auto_fixable: false,
2971                description: "Remove this class member".to_string(),
2972                note: Some(
2973                    "Class member may be used via dependency injection or decorators".to_string(),
2974                ),
2975                available_in_catalogs: None,
2976                suggested_target: None,
2977            }),
2978            IssueAction::SuppressLine(SuppressLineAction {
2979                kind: SuppressLineKind::SuppressLine,
2980                auto_fixable: false,
2981                description: "Suppress with an inline comment above the line".to_string(),
2982                comment: "// fallow-ignore-next-line unused-class-member".to_string(),
2983                scope: None,
2984            }),
2985        ];
2986        Self {
2987            finding_id: None,
2988            member,
2989            actions,
2990            semantic: None,
2991            semantic_only_candidate: false,
2992            introduced: None,
2993            effective_severity: None,
2994            reachability_caveats: Vec::new(),
2995        }
2996    }
2997
2998    /// Mark this finding as latent until semantic analysis proves that the
2999    /// framework contract does not apply and no static references exist.
3000    #[must_use]
3001    pub const fn semantic_only_candidate(mut self) -> Self {
3002        self.semantic_only_candidate = true;
3003        self
3004    }
3005
3006    /// Attach the canonical semantic decision and expose the class-member fix
3007    /// only when the API policy granted closed-world eligibility AND this run
3008    /// holds the evidence for the mutation.
3009    ///
3010    /// This is the one code path that RAISES `auto_fixable` on a class member,
3011    /// and it runs in the API layer AFTER the analysis layer stamped the run's
3012    /// caveats, so it asks the gate for the same reason
3013    /// `set_export_semantic_action` does: a closed-world verdict computed
3014    /// over a program the run never fully read must not re-open a removal the
3015    /// incomplete run already withheld. The withheld note names the evidence
3016    /// gap rather than the semantic explanation, which stays readable on the
3017    /// finding's own `semantic` object.
3018    pub fn set_semantic_decision(&mut self, decision: SemanticCandidateDecision) {
3019        let evidence_complete = self.reachability_caveats.is_empty();
3020        if let Some(IssueAction::Fix(action)) = self.actions.first_mut() {
3021            action.auto_fixable = decision.closed_world_eligible && evidence_complete;
3022            action.note = Some(if evidence_complete {
3023                decision.explanation.clone()
3024            } else {
3025                INCOMPLETE_EVIDENCE_NOTE.to_string()
3026            });
3027        }
3028        self.semantic = Some(decision);
3029    }
3030}
3031
3032/// Wire-shape envelope for an [`UnusedMember`] finding consumed under the
3033/// `unused_store_members` key (a Pinia `state` / `getters` / `actions` key, or
3034/// a setup-store returned key, declared but never accessed by any consumer
3035/// project-wide). Same Rust struct as [`UnusedClassMemberFinding`]. Emits only
3036/// a line-level suppress action: there is no safe auto-fix because a store
3037/// member can be accessed reflectively (a Pinia plugin, `store.$onAction`, or
3038/// dynamic dispatch) in ways syntactic analysis cannot see, so removal is a
3039/// behavioral change the user must own.
3040#[derive(Debug, Clone, Serialize, Deserialize)]
3041#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3042pub struct UnusedStoreMemberFinding {
3043    /// The underlying dead-code entry.
3044    #[serde(flatten)]
3045    pub member: UnusedMember,
3046    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3047    /// `~<k>` suffix when several findings of one type share an identity.
3048    /// Line and column are not inputs, so the id survives line shifts,
3049    /// reformats and reorders. A rename of the file or the symbol gives a
3050    /// new id. Absent in output from older versions.
3051    #[serde(default, skip_serializing_if = "Option::is_none")]
3052    pub finding_id: Option<String>,
3053    /// Suggested next steps. Always emitted (possibly empty for
3054    /// forward-compat).
3055    pub actions: Vec<IssueAction>,
3056    /// Set by the audit pass when this finding is introduced relative to
3057    /// the merge-base.
3058    #[serde(default, skip_serializing_if = "Option::is_none")]
3059    pub introduced: Option<AuditIntroduced>,
3060    /// Gate severity of this finding after `rules` and `overrides[].rules`
3061    /// resolve for its path. CI formats read it for the annotation, SARIF
3062    /// and CodeClimate level. Absent in output from older versions. Not
3063    /// part of the finding identity, baseline keys or fingerprints.
3064    #[serde(
3065        default,
3066        skip_serializing_if = "Option::is_none",
3067        deserialize_with = "deserialize_effective_severity"
3068    )]
3069    pub effective_severity: Option<EffectiveSeverity>,
3070    /// Advisory caveats on the verdict behind this finding. A store member's
3071    /// usage is collected by the same reachability-free member-access walk a
3072    /// class member's is, so it takes the member rule unchanged: any module
3073    /// this run analyzed incompletely can hold the access that credits it.
3074    /// Sorted, deduplicated, and omitted from the wire when empty. There is no
3075    /// mutation here to withhold, because a store member offers none on any
3076    /// surface; this is disclosure only, so a reader deciding by hand is told
3077    /// what the run did not see.
3078    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3079    pub reachability_caveats: Vec<ReachabilityCaveat>,
3080}
3081
3082impl UnusedStoreMemberFinding {
3083    /// Build the wrapper from a raw [`UnusedMember`]. Emits only a line-level
3084    /// suppress action (no auto-fix: store members can be accessed
3085    /// reflectively, so removal is never provably safe).
3086    #[must_use]
3087    pub fn with_actions(member: UnusedMember) -> Self {
3088        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
3089            kind: SuppressLineKind::SuppressLine,
3090            auto_fixable: false,
3091            description: "Suppress with an inline comment above the line".to_string(),
3092            comment: "// fallow-ignore-next-line unused-store-member".to_string(),
3093            scope: None,
3094        })];
3095        Self {
3096            finding_id: None,
3097            member,
3098            actions,
3099            introduced: None,
3100            effective_severity: None,
3101            reachability_caveats: Vec::new(),
3102        }
3103    }
3104}
3105
3106/// Build the `IssueAction` vec for the three `unused_dependencies`,
3107/// `unused_dev_dependencies`, `unused_optional_dependencies` views over the
3108/// same bare [`UnusedDependency`] struct. Each wrapper differs only in the
3109/// `package_json_location` string (`"dependencies"` / `"devDependencies"` /
3110/// `"optionalDependencies"`) baked into the fix-action description and in
3111/// the `suppress_issue_kind` used by the inline-suppress comment. All three
3112/// share the cross-workspace swap (when `dep.used_in_workspaces` is
3113/// non-empty the primary fix flips from `remove-dependency` to
3114/// `move-dependency` because the dep is imported by ANOTHER workspace and
3115/// `fallow fix` cannot safely remove it).
3116fn build_unused_dependency_actions(
3117    dep: &UnusedDependency,
3118    package_json_location: &str,
3119    suppress_issue_kind: &str,
3120) -> Vec<IssueAction> {
3121    let mut actions = Vec::with_capacity(2);
3122    let cross_workspace = !dep.used_in_workspaces.is_empty();
3123    actions.push(if cross_workspace {
3124        IssueAction::Fix(FixAction {
3125            kind: FixActionType::MoveDependency,
3126            auto_fixable: false,
3127            description: "Move this dependency to the workspace package.json that imports it"
3128                .to_string(),
3129            note: Some(
3130                "fallow fix will not remove dependencies that are imported by another workspace"
3131                    .to_string(),
3132            ),
3133            available_in_catalogs: None,
3134            suggested_target: None,
3135        })
3136    } else {
3137        IssueAction::Fix(FixAction {
3138            kind: FixActionType::RemoveDependency,
3139            auto_fixable: true,
3140            description: format!("Remove from {package_json_location} in package.json"),
3141            note: None,
3142            available_in_catalogs: None,
3143            suggested_target: None,
3144        })
3145    });
3146    actions.push(build_ignore_dependencies_suppress_action(
3147        &dep.package_name,
3148        suppress_issue_kind,
3149    ));
3150    actions
3151}
3152
3153/// Build the standard `add-to-config` `ignoreDependencies` suppress action
3154/// for any finding whose primary key is a package name. Used by the four
3155/// dependency-family wrappers (unused / unlisted / type-only / test-only).
3156/// The `_suppress_issue_kind` argument is currently unused; the pre-2.76
3157/// `inject_actions` post-pass also did not embed the issue kind in this
3158/// shape (no inline `// fallow-ignore-next-line ...` comment because the
3159/// finding is anchored at a package.json line, not at a source-file line).
3160fn build_ignore_dependencies_suppress_action(
3161    package_name: &str,
3162    _suppress_issue_kind: &str,
3163) -> IssueAction {
3164    IssueAction::AddToConfig(AddToConfigAction {
3165        kind: AddToConfigKind::AddToConfig,
3166        auto_fixable: false,
3167        description: format!("Add \"{package_name}\" to ignoreDependencies in fallow config"),
3168        config_key: "ignoreDependencies".to_string(),
3169        value: AddToConfigValue::Scalar(package_name.to_string()),
3170        value_schema: Some(
3171            "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items"
3172                .to_string(),
3173        ),
3174    })
3175}
3176
3177/// Wire-shape envelope for an [`UnusedDependency`] finding consumed under
3178/// the `unused_dependencies` key (production deps). Flattens the bare
3179/// finding; the typed `actions` array carries either a `remove-dependency`
3180/// or `move-dependency` primary depending on
3181/// `inner.used_in_workspaces`.
3182#[derive(Debug, Clone, Serialize, Deserialize)]
3183#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3184pub struct UnusedDependencyFinding {
3185    /// The underlying dead-code entry.
3186    #[serde(flatten)]
3187    pub dep: UnusedDependency,
3188    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3189    /// `~<k>` suffix when several findings of one type share an identity.
3190    /// Line and column are not inputs, so the id survives line shifts,
3191    /// reformats and reorders. A rename of the file or the symbol gives a
3192    /// new id. Absent in output from older versions.
3193    #[serde(default, skip_serializing_if = "Option::is_none")]
3194    pub finding_id: Option<String>,
3195    /// Suggested next steps. Always emitted (possibly empty for
3196    /// forward-compat).
3197    pub actions: Vec<IssueAction>,
3198    /// Set by the audit pass when this finding is introduced relative to
3199    /// the merge-base.
3200    #[serde(default, skip_serializing_if = "Option::is_none")]
3201    pub introduced: Option<AuditIntroduced>,
3202    /// Gate severity of this finding after `rules` and `overrides[].rules`
3203    /// resolve for its path. CI formats read it for the annotation, SARIF
3204    /// and CodeClimate level. Absent in output from older versions. Not
3205    /// part of the finding identity, baseline keys or fingerprints.
3206    #[serde(
3207        default,
3208        skip_serializing_if = "Option::is_none",
3209        deserialize_with = "deserialize_effective_severity"
3210    )]
3211    pub effective_severity: Option<EffectiveSeverity>,
3212    /// Advisory caveats on the verdict behind this finding. A dependency is
3213    /// reported unused when NO module in the project imports its specifier,
3214    /// so a module that parsed with errors can hide the import that would
3215    /// have credited the package. Sorted, deduplicated, and omitted from the
3216    /// wire when empty. Never gates the finding, though `fallow fix`
3217    /// withholds the `remove-dependency` write while a caveat stands.
3218    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3219    pub reachability_caveats: Vec<ReachabilityCaveat>,
3220}
3221
3222impl UnusedDependencyFinding {
3223    /// Build the wrapper. Switches the primary fix from `remove-dependency`
3224    /// to `move-dependency` when the dep is imported by another workspace.
3225    #[must_use]
3226    pub fn with_actions(dep: UnusedDependency) -> Self {
3227        let actions = build_unused_dependency_actions(&dep, "dependencies", "unused-dependency");
3228        Self {
3229            finding_id: None,
3230            dep,
3231            actions,
3232            introduced: None,
3233            effective_severity: None,
3234            reachability_caveats: Vec::new(),
3235        }
3236    }
3237}
3238
3239/// Wire-shape envelope for an [`UnusedDependency`] finding consumed under
3240/// the `unused_dev_dependencies` key. Same bare struct as
3241/// [`UnusedDependencyFinding`]; the fix description points at
3242/// `devDependencies` and the suppress comment uses
3243/// `unused-dev-dependency`.
3244#[derive(Debug, Clone, Serialize, Deserialize)]
3245#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3246pub struct UnusedDevDependencyFinding {
3247    /// The underlying dead-code entry.
3248    #[serde(flatten)]
3249    pub dep: UnusedDependency,
3250    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3251    /// `~<k>` suffix when several findings of one type share an identity.
3252    /// Line and column are not inputs, so the id survives line shifts,
3253    /// reformats and reorders. A rename of the file or the symbol gives a
3254    /// new id. Absent in output from older versions.
3255    #[serde(default, skip_serializing_if = "Option::is_none")]
3256    pub finding_id: Option<String>,
3257    /// Suggested next steps. Always emitted (possibly empty for
3258    /// forward-compat).
3259    pub actions: Vec<IssueAction>,
3260    /// Set by the audit pass when this finding is introduced relative to
3261    /// the merge-base.
3262    #[serde(default, skip_serializing_if = "Option::is_none")]
3263    pub introduced: Option<AuditIntroduced>,
3264    /// Gate severity of this finding after `rules` and `overrides[].rules`
3265    /// resolve for its path. CI formats read it for the annotation, SARIF
3266    /// and CodeClimate level. Absent in output from older versions. Not
3267    /// part of the finding identity, baseline keys or fingerprints.
3268    #[serde(
3269        default,
3270        skip_serializing_if = "Option::is_none",
3271        deserialize_with = "deserialize_effective_severity"
3272    )]
3273    pub effective_severity: Option<EffectiveSeverity>,
3274    /// Advisory caveats on the verdict behind this finding. A dependency is
3275    /// reported unused when NO module in the project imports its specifier,
3276    /// so a module that parsed with errors can hide the import that would
3277    /// have credited the package. Sorted, deduplicated, and omitted from the
3278    /// wire when empty. Never gates the finding, though `fallow fix`
3279    /// withholds the `remove-dependency` write while a caveat stands.
3280    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3281    pub reachability_caveats: Vec<ReachabilityCaveat>,
3282}
3283
3284impl UnusedDevDependencyFinding {
3285    /// Build the wrapper.
3286    #[must_use]
3287    pub fn with_actions(dep: UnusedDependency) -> Self {
3288        let actions =
3289            build_unused_dependency_actions(&dep, "devDependencies", "unused-dev-dependency");
3290        Self {
3291            finding_id: None,
3292            dep,
3293            actions,
3294            introduced: None,
3295            effective_severity: None,
3296            reachability_caveats: Vec::new(),
3297        }
3298    }
3299}
3300
3301/// Wire-shape envelope for an [`UnusedDependency`] finding consumed under
3302/// the `unused_optional_dependencies` key. Same bare struct as
3303/// [`UnusedDependencyFinding`]; the fix description points at
3304/// `optionalDependencies`. Reuses the `unused-dependency` suppress
3305/// `IssueKind` because there is no dedicated variant for optional deps.
3306#[derive(Debug, Clone, Serialize, Deserialize)]
3307#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3308pub struct UnusedOptionalDependencyFinding {
3309    /// The underlying dead-code entry.
3310    #[serde(flatten)]
3311    pub dep: UnusedDependency,
3312    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3313    /// `~<k>` suffix when several findings of one type share an identity.
3314    /// Line and column are not inputs, so the id survives line shifts,
3315    /// reformats and reorders. A rename of the file or the symbol gives a
3316    /// new id. Absent in output from older versions.
3317    #[serde(default, skip_serializing_if = "Option::is_none")]
3318    pub finding_id: Option<String>,
3319    /// Suggested next steps. Always emitted (possibly empty for
3320    /// forward-compat).
3321    pub actions: Vec<IssueAction>,
3322    /// Set by the audit pass when this finding is introduced relative to
3323    /// the merge-base.
3324    #[serde(default, skip_serializing_if = "Option::is_none")]
3325    pub introduced: Option<AuditIntroduced>,
3326    /// Gate severity of this finding after `rules` and `overrides[].rules`
3327    /// resolve for its path. CI formats read it for the annotation, SARIF
3328    /// and CodeClimate level. Absent in output from older versions. Not
3329    /// part of the finding identity, baseline keys or fingerprints.
3330    #[serde(
3331        default,
3332        skip_serializing_if = "Option::is_none",
3333        deserialize_with = "deserialize_effective_severity"
3334    )]
3335    pub effective_severity: Option<EffectiveSeverity>,
3336    /// Advisory caveats on the verdict behind this finding. A dependency is
3337    /// reported unused when NO module in the project imports its specifier,
3338    /// so a module that parsed with errors can hide the import that would
3339    /// have credited the package. Sorted, deduplicated, and omitted from the
3340    /// wire when empty. Never gates the finding, though `fallow fix`
3341    /// withholds the `remove-dependency` write while a caveat stands.
3342    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3343    pub reachability_caveats: Vec<ReachabilityCaveat>,
3344}
3345
3346impl UnusedOptionalDependencyFinding {
3347    /// Build the wrapper.
3348    #[must_use]
3349    pub fn with_actions(dep: UnusedDependency) -> Self {
3350        let actions =
3351            build_unused_dependency_actions(&dep, "optionalDependencies", "unused-dependency");
3352        Self {
3353            finding_id: None,
3354            dep,
3355            actions,
3356            introduced: None,
3357            effective_severity: None,
3358            reachability_caveats: Vec::new(),
3359        }
3360    }
3361}
3362
3363/// Wire-shape envelope for an [`UnlistedDependency`] finding. Carries an
3364/// `install-dependency` primary (non-auto-fixable) plus the standard
3365/// `ignoreDependencies` config suppress.
3366#[derive(Debug, Clone, Serialize, Deserialize)]
3367#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3368pub struct UnlistedDependencyFinding {
3369    /// The underlying dead-code entry.
3370    #[serde(flatten)]
3371    pub dep: UnlistedDependency,
3372    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3373    /// `~<k>` suffix when several findings of one type share an identity.
3374    /// Line and column are not inputs, so the id survives line shifts,
3375    /// reformats and reorders. A rename of the file or the symbol gives a
3376    /// new id. Absent in output from older versions.
3377    #[serde(default, skip_serializing_if = "Option::is_none")]
3378    pub finding_id: Option<String>,
3379    /// Suggested next steps. Always emitted (possibly empty for
3380    /// forward-compat).
3381    pub actions: Vec<IssueAction>,
3382    /// Set by the audit pass when this finding is introduced relative to
3383    /// the merge-base.
3384    #[serde(default, skip_serializing_if = "Option::is_none")]
3385    pub introduced: Option<AuditIntroduced>,
3386    /// Gate severity of this finding after `rules` and `overrides[].rules`
3387    /// resolve for its path. CI formats read it for the annotation, SARIF
3388    /// and CodeClimate level. Absent in output from older versions. Not
3389    /// part of the finding identity, baseline keys or fingerprints.
3390    #[serde(
3391        default,
3392        skip_serializing_if = "Option::is_none",
3393        deserialize_with = "deserialize_effective_severity"
3394    )]
3395    pub effective_severity: Option<EffectiveSeverity>,
3396}
3397
3398impl UnlistedDependencyFinding {
3399    /// Build the wrapper.
3400    #[must_use]
3401    pub fn with_actions(dep: UnlistedDependency) -> Self {
3402        let actions = vec![
3403            IssueAction::Fix(FixAction {
3404                kind: FixActionType::InstallDependency,
3405                auto_fixable: false,
3406                description: "Add this package to dependencies in package.json".to_string(),
3407                note: Some(
3408                    "Verify this package should be a direct dependency before adding".to_string(),
3409                ),
3410                available_in_catalogs: None,
3411                suggested_target: None,
3412            }),
3413            build_ignore_dependencies_suppress_action(&dep.package_name, "unlisted-dependency"),
3414        ];
3415        Self {
3416            finding_id: None,
3417            dep,
3418            actions,
3419            introduced: None,
3420            effective_severity: None,
3421        }
3422    }
3423}
3424
3425/// Wire-shape envelope for a [`TypeOnlyDependency`] finding. Carries a
3426/// `move-to-dev` primary plus the standard `ignoreDependencies` config
3427/// suppress.
3428#[derive(Debug, Clone, Serialize, Deserialize)]
3429#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3430pub struct TypeOnlyDependencyFinding {
3431    /// The underlying dead-code entry.
3432    #[serde(flatten)]
3433    pub dep: TypeOnlyDependency,
3434    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3435    /// `~<k>` suffix when several findings of one type share an identity.
3436    /// Line and column are not inputs, so the id survives line shifts,
3437    /// reformats and reorders. A rename of the file or the symbol gives a
3438    /// new id. Absent in output from older versions.
3439    #[serde(default, skip_serializing_if = "Option::is_none")]
3440    pub finding_id: Option<String>,
3441    /// Suggested next steps. Always emitted (possibly empty for
3442    /// forward-compat).
3443    pub actions: Vec<IssueAction>,
3444    /// Set by the audit pass when this finding is introduced relative to
3445    /// the merge-base.
3446    #[serde(default, skip_serializing_if = "Option::is_none")]
3447    pub introduced: Option<AuditIntroduced>,
3448    /// Gate severity of this finding after `rules` and `overrides[].rules`
3449    /// resolve for its path. CI formats read it for the annotation, SARIF
3450    /// and CodeClimate level. Absent in output from older versions. Not
3451    /// part of the finding identity, baseline keys or fingerprints.
3452    #[serde(
3453        default,
3454        skip_serializing_if = "Option::is_none",
3455        deserialize_with = "deserialize_effective_severity"
3456    )]
3457    pub effective_severity: Option<EffectiveSeverity>,
3458}
3459
3460impl TypeOnlyDependencyFinding {
3461    /// Build the wrapper.
3462    #[must_use]
3463    pub fn with_actions(dep: TypeOnlyDependency) -> Self {
3464        let actions = vec![
3465            IssueAction::Fix(FixAction {
3466                kind: FixActionType::MoveToDev,
3467                auto_fixable: false,
3468                description: "Move to devDependencies (only type imports are used)".to_string(),
3469                note: Some(
3470                    "Type imports are erased at runtime so this dependency is not needed in production"
3471                        .to_string(),
3472                ),
3473                available_in_catalogs: None,
3474                suggested_target: None,
3475            }),
3476            build_ignore_dependencies_suppress_action(&dep.package_name, "type-only-dependency"),
3477        ];
3478        Self {
3479            finding_id: None,
3480            dep,
3481            actions,
3482            introduced: None,
3483            effective_severity: None,
3484        }
3485    }
3486}
3487
3488/// Wire-shape envelope for a [`TestOnlyDependency`] finding. Carries a
3489/// `move-to-dev` primary (different prose than [`TypeOnlyDependencyFinding`])
3490/// plus the standard `ignoreDependencies` config suppress.
3491#[derive(Debug, Clone, Serialize, Deserialize)]
3492#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3493pub struct TestOnlyDependencyFinding {
3494    /// The underlying dead-code entry.
3495    #[serde(flatten)]
3496    pub dep: TestOnlyDependency,
3497    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3498    /// `~<k>` suffix when several findings of one type share an identity.
3499    /// Line and column are not inputs, so the id survives line shifts,
3500    /// reformats and reorders. A rename of the file or the symbol gives a
3501    /// new id. Absent in output from older versions.
3502    #[serde(default, skip_serializing_if = "Option::is_none")]
3503    pub finding_id: Option<String>,
3504    /// Suggested next steps. Always emitted (possibly empty for
3505    /// forward-compat).
3506    pub actions: Vec<IssueAction>,
3507    /// Set by the audit pass when this finding is introduced relative to
3508    /// the merge-base.
3509    #[serde(default, skip_serializing_if = "Option::is_none")]
3510    pub introduced: Option<AuditIntroduced>,
3511    /// Gate severity of this finding after `rules` and `overrides[].rules`
3512    /// resolve for its path. CI formats read it for the annotation, SARIF
3513    /// and CodeClimate level. Absent in output from older versions. Not
3514    /// part of the finding identity, baseline keys or fingerprints.
3515    #[serde(
3516        default,
3517        skip_serializing_if = "Option::is_none",
3518        deserialize_with = "deserialize_effective_severity"
3519    )]
3520    pub effective_severity: Option<EffectiveSeverity>,
3521}
3522
3523impl TestOnlyDependencyFinding {
3524    /// Build the wrapper.
3525    #[must_use]
3526    pub fn with_actions(dep: TestOnlyDependency) -> Self {
3527        let actions = vec![
3528            IssueAction::Fix(FixAction {
3529                kind: FixActionType::MoveToDev,
3530                auto_fixable: false,
3531                description: "Move to devDependencies (only test files import this)".to_string(),
3532                note: Some(
3533                    "Only test files import this package so it does not need to be a production dependency"
3534                        .to_string(),
3535                ),
3536                available_in_catalogs: None,
3537                suggested_target: None,
3538            }),
3539            build_ignore_dependencies_suppress_action(&dep.package_name, "test-only-dependency"),
3540        ];
3541        Self {
3542            finding_id: None,
3543            dep,
3544            actions,
3545            introduced: None,
3546            effective_severity: None,
3547        }
3548    }
3549}
3550
3551/// Wire-shape envelope for a [`DevDependencyInProduction`] finding. Carries a
3552/// `move-to-prod` primary (the promote-side mirror of
3553/// [`TestOnlyDependencyFinding`]'s `move-to-dev`) plus the standard
3554/// `ignoreDependencies` config suppress.
3555#[derive(Debug, Clone, Serialize, Deserialize)]
3556#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3557pub struct DevDependencyInProductionFinding {
3558    /// The underlying dead-code entry.
3559    #[serde(flatten)]
3560    pub dep: DevDependencyInProduction,
3561    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3562    /// `~<k>` suffix when several findings of one type share an identity.
3563    /// Line and column are not inputs, so the id survives line shifts,
3564    /// reformats and reorders. A rename of the file or the symbol gives a
3565    /// new id. Absent in output from older versions.
3566    #[serde(default, skip_serializing_if = "Option::is_none")]
3567    pub finding_id: Option<String>,
3568    /// Suggested next steps. Always emitted (possibly empty for
3569    /// forward-compat).
3570    pub actions: Vec<IssueAction>,
3571    /// Set by the audit pass when this finding is introduced relative to
3572    /// the merge-base.
3573    #[serde(default, skip_serializing_if = "Option::is_none")]
3574    pub introduced: Option<AuditIntroduced>,
3575    /// Gate severity of this finding after `rules` and `overrides[].rules`
3576    /// resolve for its path. CI formats read it for the annotation, SARIF
3577    /// and CodeClimate level. Absent in output from older versions. Not
3578    /// part of the finding identity, baseline keys or fingerprints.
3579    #[serde(
3580        default,
3581        skip_serializing_if = "Option::is_none",
3582        deserialize_with = "deserialize_effective_severity"
3583    )]
3584    pub effective_severity: Option<EffectiveSeverity>,
3585}
3586
3587impl DevDependencyInProductionFinding {
3588    /// Build the wrapper.
3589    #[must_use]
3590    pub fn with_actions(dep: DevDependencyInProduction) -> Self {
3591        let actions = vec![
3592            IssueAction::Fix(FixAction {
3593                kind: FixActionType::MoveToProd,
3594                auto_fixable: false,
3595                description:
3596                    "Move to dependencies if the deployment installs them (production code imports this)"
3597                        .to_string(),
3598                note: Some(
3599                    "A production-only install (`pnpm install --prod`) omits devDependencies, so an import resolved at runtime breaks. A build that inlines the package into its output resolves nothing at runtime, and moving it there can instead make the deployment require an install it did not need"
3600                        .to_string(),
3601                ),
3602                available_in_catalogs: None,
3603                suggested_target: None,
3604            }),
3605            build_ignore_dependencies_suppress_action(
3606                &dep.package_name,
3607                "dev-dependency-in-production",
3608            ),
3609        ];
3610        Self {
3611            finding_id: None,
3612            dep,
3613            actions,
3614            introduced: None,
3615            effective_severity: None,
3616        }
3617    }
3618}
3619
3620// ── Catalog / dep-override family ───────────────────────────────
3621//
3622// These six wrappers replace the legacy `inject_actions` post-pass in
3623// `crates/cli/src/report/json.rs` for the catalog and dependency-override
3624// findings. Each `with_actions(...)` builds the typed `actions` array
3625// directly from the inner struct (and any per-call context such as
3626// `config_fixable`), so the wire shape is identical to the pre-2.76
3627// post-pass output but the Rust compiler now owns the action contract.
3628
3629/// Wire-shape envelope for a [`DuplicateExport`] finding. Carries up to
3630/// three actions in position-locked order: an `add-to-config` `ignoreExports`
3631/// snippet (only when `locations[]` carries at least one path) followed by
3632/// the `remove-duplicate` fix and the multi-location suppress.
3633///
3634/// The `add-to-config` action sits at position 0 because the documented
3635/// primary slot points at the safe, non-destructive path: the shadcn /
3636/// Radix / bits-ui namespace-barrel case where every `index.*` reexports
3637/// the directory's neighbours. The `remove-duplicate` fix stays as the
3638/// secondary so consumers that pattern-match on `actions[0].type` for
3639/// "primary fix" never propose deletion of an intentional barrel surface.
3640#[derive(Debug, Clone, Serialize, Deserialize)]
3641#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3642pub struct DuplicateExportFinding {
3643    /// The underlying finding.
3644    #[serde(flatten)]
3645    pub export: DuplicateExport,
3646    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3647    /// `~<k>` suffix when several findings of one type share an identity.
3648    /// Line and column are not inputs, so the id survives line shifts,
3649    /// reformats and reorders. A rename of the file or the symbol gives a
3650    /// new id. Absent in output from older versions.
3651    #[serde(default, skip_serializing_if = "Option::is_none")]
3652    pub finding_id: Option<String>,
3653    /// Suggested next steps. Always emitted (possibly empty for
3654    /// forward-compat).
3655    pub actions: Vec<IssueAction>,
3656    /// Set by the audit pass when this finding is introduced relative to
3657    /// the merge-base.
3658    #[serde(default, skip_serializing_if = "Option::is_none")]
3659    pub introduced: Option<AuditIntroduced>,
3660    /// Gate severity of this finding after `rules` and `overrides[].rules`
3661    /// resolve for its path. CI formats read it for the annotation, SARIF
3662    /// and CodeClimate level. Absent in output from older versions. Not
3663    /// part of the finding identity, baseline keys or fingerprints.
3664    #[serde(
3665        default,
3666        skip_serializing_if = "Option::is_none",
3667        deserialize_with = "deserialize_effective_severity"
3668    )]
3669    pub effective_severity: Option<EffectiveSeverity>,
3670}
3671
3672impl DuplicateExportFinding {
3673    /// Build the wrapper with the `add-to-config` action's `auto_fixable`
3674    /// defaulting to `false`. The CLI's `build_json_with_config_fixable`
3675    /// path layers the actual `config_fixable` signal via
3676    /// [`Self::set_config_fixable`] right before serialization (the
3677    /// fix-applier readiness check lives in `fallow-cli::fix` and is not
3678    /// reachable from the analyzer layer where wrappers are first built).
3679    /// Embedders that build `AnalysisResults` directly and never route
3680    /// through the CLI's JSON path keep the conservative default.
3681    #[must_use]
3682    pub fn with_actions(export: DuplicateExport) -> Self {
3683        let mut actions: Vec<IssueAction> = Vec::with_capacity(3);
3684
3685        if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
3686            actions.push(IssueAction::AddToConfig(AddToConfigAction {
3687                kind: AddToConfigKind::AddToConfig,
3688                auto_fixable: false,
3689                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(),
3690                config_key: "ignoreExports".to_string(),
3691                value: AddToConfigValue::ExportsRules(rules),
3692                value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
3693            }));
3694        }
3695
3696        actions.push(IssueAction::Fix(FixAction {
3697            kind: FixActionType::RemoveDuplicate,
3698            auto_fixable: false,
3699            description: "Keep one canonical export location and remove the others".to_string(),
3700            note: Some(NAMESPACE_BARREL_HINT.to_string()),
3701            available_in_catalogs: None,
3702            suggested_target: None,
3703        }));
3704
3705        actions.push(IssueAction::SuppressLine(SuppressLineAction {
3706            kind: SuppressLineKind::SuppressLine,
3707            auto_fixable: false,
3708            description: "Suppress with an inline comment above the line".to_string(),
3709            comment: "// fallow-ignore-next-line duplicate-export".to_string(),
3710            scope: Some(SuppressLineScope::PerLocation),
3711        }));
3712
3713        Self {
3714            finding_id: None,
3715            export,
3716            actions,
3717            introduced: None,
3718            effective_severity: None,
3719        }
3720    }
3721
3722    /// Update the position-0 `add-to-config` action's `auto_fixable` flag.
3723    /// Idempotent and a no-op when position 0 is not an `add-to-config`
3724    /// action (happens when the finding has no locations). Called by the
3725    /// CLI's JSON serializer with the result of
3726    /// `crate::fix::is_config_fixable` before emitting bytes.
3727    pub fn set_config_fixable(&mut self, fixable: bool) {
3728        if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
3729            action.auto_fixable = fixable;
3730        }
3731    }
3732}
3733
3734/// Build a paste-ready `ignoreExports` config value from a duplicate-export
3735/// finding's locations. Returns one `{ file, exports: ["*"] }` entry per
3736/// distinct file in insertion order. `None` when no locations carry a path.
3737fn build_duplicate_exports_ignore_rules(
3738    export: &DuplicateExport,
3739) -> Option<Vec<IgnoreExportsRule>> {
3740    let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
3741    for loc in &export.locations {
3742        // Normalize separators to forward slashes so pasting the action value
3743        // into `.fallowrc.json` produces a portable rule. On Windows
3744        // `to_string_lossy` preserves backslashes, which the old
3745        // `inject_actions` post-pass implicitly normalized because it read
3746        // the path AFTER `strip_root_prefix` had already run through
3747        // `normalize_uri`; the typed wrapper builds the value before
3748        // serialization, so the normalization has to be explicit here.
3749        let path = loc.path.to_string_lossy().replace('\\', "/");
3750        if path.is_empty() {
3751            continue;
3752        }
3753        if entries.iter().any(|existing| existing.file == path) {
3754            continue;
3755        }
3756        entries.push(IgnoreExportsRule {
3757            file: path,
3758            exports: vec!["*".to_string()],
3759        });
3760    }
3761    if entries.is_empty() {
3762        None
3763    } else {
3764        Some(entries)
3765    }
3766}
3767
3768/// Wire-shape envelope for an [`UnusedCatalogEntry`] finding. Per-instance
3769/// `auto_fixable` flips to `false` when `hardcoded_consumers` is non-empty or
3770/// the source is not `pnpm-workspace.yaml`.
3771#[derive(Debug, Clone, Serialize, Deserialize)]
3772#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3773pub struct UnusedCatalogEntryFinding {
3774    /// The underlying finding.
3775    #[serde(flatten)]
3776    pub entry: UnusedCatalogEntry,
3777    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3778    /// `~<k>` suffix when several findings of one type share an identity.
3779    /// Line and column are not inputs, so the id survives line shifts,
3780    /// reformats and reorders. A rename of the file or the symbol gives a
3781    /// new id. Absent in output from older versions.
3782    #[serde(default, skip_serializing_if = "Option::is_none")]
3783    pub finding_id: Option<String>,
3784    /// Suggested next steps. Always emitted.
3785    pub actions: Vec<IssueAction>,
3786    /// Set by the audit pass when this finding is introduced relative to
3787    /// the merge-base.
3788    #[serde(default, skip_serializing_if = "Option::is_none")]
3789    pub introduced: Option<AuditIntroduced>,
3790    /// Gate severity of this finding after `rules` and `overrides[].rules`
3791    /// resolve for its path. CI formats read it for the annotation, SARIF
3792    /// and CodeClimate level. Absent in output from older versions. Not
3793    /// part of the finding identity, baseline keys or fingerprints.
3794    #[serde(
3795        default,
3796        skip_serializing_if = "Option::is_none",
3797        deserialize_with = "deserialize_effective_severity"
3798    )]
3799    pub effective_severity: Option<EffectiveSeverity>,
3800}
3801
3802impl UnusedCatalogEntryFinding {
3803    /// Build the wrapper. Per-instance `auto_fixable` is `true` only when
3804    /// `hardcoded_consumers` is empty and the source is `pnpm-workspace.yaml`;
3805    /// otherwise `fallow fix` skips the entry to avoid breaking installs or
3806    /// applying YAML edits to Bun `package.json` catalogs.
3807    #[must_use]
3808    pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
3809        let is_pnpm_source = is_pnpm_catalog_source(&entry.path);
3810        let auto_fixable = entry.hardcoded_consumers.is_empty() && is_pnpm_source;
3811        let note = if is_pnpm_source {
3812            Some(
3813                "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing"
3814                    .to_string(),
3815            )
3816        } else {
3817            Some(
3818                "fallow fix only edits pnpm-workspace.yaml catalog entries. Edit Bun package.json catalogs manually."
3819                    .to_string(),
3820            )
3821        };
3822        let mut actions = vec![IssueAction::Fix(FixAction {
3823            kind: FixActionType::RemoveCatalogEntry,
3824            auto_fixable,
3825            description: if is_pnpm_source {
3826                "Remove the entry from pnpm-workspace.yaml".to_string()
3827            } else {
3828                "Remove the entry from the catalog source file manually".to_string()
3829            },
3830            note,
3831            available_in_catalogs: None,
3832            suggested_target: None,
3833        })];
3834        if is_pnpm_source {
3835            actions.push(IssueAction::SuppressLine(SuppressLineAction {
3836                kind: SuppressLineKind::SuppressLine,
3837                auto_fixable: false,
3838                description: "Suppress with a YAML comment above the line".to_string(),
3839                comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
3840                scope: None,
3841            }));
3842        }
3843        Self {
3844            finding_id: None,
3845            entry,
3846            actions,
3847            introduced: None,
3848            effective_severity: None,
3849        }
3850    }
3851}
3852
3853/// Wire-shape envelope for an [`EmptyCatalogGroup`] finding. Carries a
3854/// `remove-empty-catalog-group` primary. YAML-sourced findings also include a
3855/// YAML-comment suppress action.
3856#[derive(Debug, Clone, Serialize, Deserialize)]
3857#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3858pub struct EmptyCatalogGroupFinding {
3859    /// The underlying finding.
3860    #[serde(flatten)]
3861    pub group: EmptyCatalogGroup,
3862    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3863    /// `~<k>` suffix when several findings of one type share an identity.
3864    /// Line and column are not inputs, so the id survives line shifts,
3865    /// reformats and reorders. A rename of the file or the symbol gives a
3866    /// new id. Absent in output from older versions.
3867    #[serde(default, skip_serializing_if = "Option::is_none")]
3868    pub finding_id: Option<String>,
3869    /// Suggested next steps. Always emitted.
3870    pub actions: Vec<IssueAction>,
3871    /// Set by the audit pass when this finding is introduced relative to
3872    /// the merge-base.
3873    #[serde(default, skip_serializing_if = "Option::is_none")]
3874    pub introduced: Option<AuditIntroduced>,
3875    /// Gate severity of this finding after `rules` and `overrides[].rules`
3876    /// resolve for its path. CI formats read it for the annotation, SARIF
3877    /// and CodeClimate level. Absent in output from older versions. Not
3878    /// part of the finding identity, baseline keys or fingerprints.
3879    #[serde(
3880        default,
3881        skip_serializing_if = "Option::is_none",
3882        deserialize_with = "deserialize_effective_severity"
3883    )]
3884    pub effective_severity: Option<EffectiveSeverity>,
3885}
3886
3887impl EmptyCatalogGroupFinding {
3888    /// Build the wrapper.
3889    #[must_use]
3890    pub fn with_actions(group: EmptyCatalogGroup) -> Self {
3891        let auto_fixable = is_pnpm_catalog_source(&group.path);
3892        let mut actions = vec![IssueAction::Fix(FixAction {
3893            kind: FixActionType::RemoveEmptyCatalogGroup,
3894            auto_fixable,
3895            description: if auto_fixable {
3896                "Remove the empty named catalog group from pnpm-workspace.yaml".to_string()
3897            } else {
3898                "Remove the empty named catalog group from the catalog source file manually"
3899                    .to_string()
3900            },
3901            note: Some(if auto_fixable {
3902                "Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
3903                    .to_string()
3904            } else {
3905                "fallow fix only edits pnpm-workspace.yaml catalog groups. Edit Bun package.json catalogs manually."
3906                    .to_string()
3907            }),
3908            available_in_catalogs: None,
3909            suggested_target: None,
3910        })];
3911        if auto_fixable {
3912            actions.push(IssueAction::SuppressLine(SuppressLineAction {
3913                kind: SuppressLineKind::SuppressLine,
3914                auto_fixable: false,
3915                description: "Suppress with a YAML comment above the line".to_string(),
3916                comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
3917                scope: None,
3918            }));
3919        }
3920        Self {
3921            finding_id: None,
3922            group,
3923            actions,
3924            introduced: None,
3925            effective_severity: None,
3926        }
3927    }
3928}
3929
3930fn is_pnpm_catalog_source(path: &Path) -> bool {
3931    path == Path::new(PNPM_WORKSPACE_FILE)
3932}
3933
3934/// Wire-shape envelope for an [`UnresolvedCatalogReference`] finding. The
3935/// primary action at position 0 discriminates on `available_in_catalogs`:
3936/// `add-catalog-entry` when the array is empty (no other catalog declares
3937/// the package), or `update-catalog-reference` when at least one
3938/// alternative exists. When exactly one alternative exists, the action
3939/// also carries `suggested_target` so deterministic agents can land the
3940/// edit without picking from a list.
3941#[derive(Debug, Clone, Serialize, Deserialize)]
3942#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3943pub struct UnresolvedCatalogReferenceFinding {
3944    /// The underlying finding.
3945    #[serde(flatten)]
3946    pub reference: UnresolvedCatalogReference,
3947    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3948    /// `~<k>` suffix when several findings of one type share an identity.
3949    /// Line and column are not inputs, so the id survives line shifts,
3950    /// reformats and reorders. A rename of the file or the symbol gives a
3951    /// new id. Absent in output from older versions.
3952    #[serde(default, skip_serializing_if = "Option::is_none")]
3953    pub finding_id: Option<String>,
3954    /// Suggested next steps. Always emitted; position 0 is the discriminated
3955    /// primary (see struct docs).
3956    pub actions: Vec<IssueAction>,
3957    /// Set by the audit pass when this finding is introduced relative to
3958    /// the merge-base.
3959    #[serde(default, skip_serializing_if = "Option::is_none")]
3960    pub introduced: Option<AuditIntroduced>,
3961    /// Gate severity of this finding after `rules` and `overrides[].rules`
3962    /// resolve for its path. CI formats read it for the annotation, SARIF
3963    /// and CodeClimate level. Absent in output from older versions. Not
3964    /// part of the finding identity, baseline keys or fingerprints.
3965    #[serde(
3966        default,
3967        skip_serializing_if = "Option::is_none",
3968        deserialize_with = "deserialize_effective_severity"
3969    )]
3970    pub effective_severity: Option<EffectiveSeverity>,
3971}
3972
3973impl UnresolvedCatalogReferenceFinding {
3974    /// Build the wrapper. The discriminator at position 0 is the
3975    /// `add-catalog-entry` vs `update-catalog-reference` pick documented on
3976    /// the struct.
3977    #[must_use]
3978    pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
3979        // Normalize separators to forward slashes so the
3980        // `ignoreCatalogReferences.consumer` action value is portable when
3981        // pasted into a Windows-authored config. See
3982        // `build_duplicate_exports_ignore_rules` for the same pattern.
3983        let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
3984        let primary = catalog_reference_primary_action(&reference);
3985        let fallback = remove_catalog_reference_action();
3986        let suppress = suppress_catalog_reference_action(&reference, consumer_path);
3987
3988        Self {
3989            finding_id: None,
3990            reference,
3991            actions: vec![primary, fallback, suppress],
3992            introduced: None,
3993            effective_severity: None,
3994        }
3995    }
3996}
3997
3998fn catalog_reference_primary_action(reference: &UnresolvedCatalogReference) -> IssueAction {
3999    if reference.available_in_catalogs.is_empty() {
4000        return IssueAction::Fix(FixAction {
4001            kind: FixActionType::AddCatalogEntry,
4002            auto_fixable: false,
4003            description: format!(
4004                "Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
4005                reference.entry_name, reference.catalog_name
4006            ),
4007            note: Some(
4008                "Pin a version that satisfies the consumer's import; no other catalog declares this package today"
4009                    .to_string(),
4010            ),
4011            available_in_catalogs: None,
4012            suggested_target: None,
4013        });
4014    }
4015
4016    let available = reference.available_in_catalogs.clone();
4017    let suggested_target = (available.len() == 1).then(|| available[0].clone());
4018    IssueAction::Fix(FixAction {
4019        kind: FixActionType::UpdateCatalogReference,
4020        auto_fixable: false,
4021        description: format!(
4022            "Switch the reference from `catalog:{}` to a catalog that declares `{}`",
4023            reference.catalog_name, reference.entry_name
4024        ),
4025        note: None,
4026        available_in_catalogs: Some(available),
4027        suggested_target,
4028    })
4029}
4030
4031fn remove_catalog_reference_action() -> IssueAction {
4032    IssueAction::Fix(FixAction {
4033        kind: FixActionType::RemoveCatalogReference,
4034        auto_fixable: false,
4035        description: "Remove the catalog reference and pin a hardcoded version in its place"
4036            .to_string(),
4037        note: Some(
4038            "Use only when neither another catalog declares the package nor the named catalog should grow to include it"
4039                .to_string(),
4040        ),
4041        available_in_catalogs: None,
4042        suggested_target: None,
4043    })
4044}
4045
4046fn suppress_catalog_reference_action(
4047    reference: &UnresolvedCatalogReference,
4048    consumer_path: String,
4049) -> IssueAction {
4050    let mut suppress_value = serde_json::Map::new();
4051    suppress_value.insert(
4052        "package".to_string(),
4053        serde_json::Value::String(reference.entry_name.clone()),
4054    );
4055    suppress_value.insert(
4056        "catalog".to_string(),
4057        serde_json::Value::String(reference.catalog_name.clone()),
4058    );
4059    suppress_value.insert(
4060        "consumer".to_string(),
4061        serde_json::Value::String(consumer_path),
4062    );
4063    IssueAction::AddToConfig(AddToConfigAction {
4064        kind: AddToConfigKind::AddToConfig,
4065        auto_fixable: false,
4066        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(),
4067        config_key: "ignoreCatalogReferences".to_string(),
4068        value: AddToConfigValue::RuleObject(suppress_value),
4069        value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
4070    })
4071}
4072
4073/// Wire-shape envelope for an [`UnusedDependencyOverride`] finding. Carries
4074/// a `remove-dependency-override` primary plus an `add-to-config`
4075/// `ignoreDependencyOverrides` suppress scoped to the target package and
4076/// declaration source.
4077#[derive(Debug, Clone, Serialize, Deserialize)]
4078#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
4079pub struct UnusedDependencyOverrideFinding {
4080    /// The underlying finding.
4081    #[serde(flatten)]
4082    pub entry: UnusedDependencyOverride,
4083    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
4084    /// `~<k>` suffix when several findings of one type share an identity.
4085    /// Line and column are not inputs, so the id survives line shifts,
4086    /// reformats and reorders. A rename of the file or the symbol gives a
4087    /// new id. Absent in output from older versions.
4088    #[serde(default, skip_serializing_if = "Option::is_none")]
4089    pub finding_id: Option<String>,
4090    /// Suggested next steps. Always emitted.
4091    pub actions: Vec<IssueAction>,
4092    /// Set by the audit pass when this finding is introduced relative to
4093    /// the merge-base.
4094    #[serde(default, skip_serializing_if = "Option::is_none")]
4095    pub introduced: Option<AuditIntroduced>,
4096    /// Gate severity of this finding after `rules` and `overrides[].rules`
4097    /// resolve for its path. CI formats read it for the annotation, SARIF
4098    /// and CodeClimate level. Absent in output from older versions. Not
4099    /// part of the finding identity, baseline keys or fingerprints.
4100    #[serde(
4101        default,
4102        skip_serializing_if = "Option::is_none",
4103        deserialize_with = "deserialize_effective_severity"
4104    )]
4105    pub effective_severity: Option<EffectiveSeverity>,
4106}
4107
4108impl UnusedDependencyOverrideFinding {
4109    /// Build the wrapper.
4110    #[must_use]
4111    pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
4112        let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
4113        actions.push(IssueAction::Fix(FixAction {
4114            kind: FixActionType::RemoveDependencyOverride,
4115            auto_fixable: false,
4116            description: "Remove the package-manager override entry from its declaration source"
4117                .to_string(),
4118            note: Some(
4119                "Conservative static check; verify against the active package manager's frozen-lockfile install before removing in case the override targets a transitive dependency (CVE-fix pattern)"
4120                    .to_string(),
4121            ),
4122            available_in_catalogs: None,
4123            suggested_target: None,
4124        }));
4125
4126        if let Some(suppress) = build_ignore_dependency_overrides_suppress(
4127            Some(&entry.target_package),
4128            &entry.raw_key,
4129            entry.source,
4130        ) {
4131            actions.push(suppress);
4132        }
4133
4134        Self {
4135            finding_id: None,
4136            entry,
4137            actions,
4138            introduced: None,
4139            effective_severity: None,
4140        }
4141    }
4142}
4143
4144/// Wire-shape envelope for a [`MisconfiguredDependencyOverride`] finding.
4145/// Carries a `fix-dependency-override` primary plus the conditional
4146/// `add-to-config` `ignoreDependencyOverrides` suppress (skipped when both
4147/// `target_package` and `raw_key` are empty, since the rule matcher keys on
4148/// a non-empty package name).
4149#[derive(Debug, Clone, Serialize, Deserialize)]
4150#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
4151pub struct MisconfiguredDependencyOverrideFinding {
4152    /// The underlying finding.
4153    #[serde(flatten)]
4154    pub entry: MisconfiguredDependencyOverride,
4155    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
4156    /// `~<k>` suffix when several findings of one type share an identity.
4157    /// Line and column are not inputs, so the id survives line shifts,
4158    /// reformats and reorders. A rename of the file or the symbol gives a
4159    /// new id. Absent in output from older versions.
4160    #[serde(default, skip_serializing_if = "Option::is_none")]
4161    pub finding_id: Option<String>,
4162    /// Suggested next steps. Always emitted.
4163    pub actions: Vec<IssueAction>,
4164    /// Set by the audit pass when this finding is introduced relative to
4165    /// the merge-base.
4166    #[serde(default, skip_serializing_if = "Option::is_none")]
4167    pub introduced: Option<AuditIntroduced>,
4168    /// Gate severity of this finding after `rules` and `overrides[].rules`
4169    /// resolve for its path. CI formats read it for the annotation, SARIF
4170    /// and CodeClimate level. Absent in output from older versions. Not
4171    /// part of the finding identity, baseline keys or fingerprints.
4172    #[serde(
4173        default,
4174        skip_serializing_if = "Option::is_none",
4175        deserialize_with = "deserialize_effective_severity"
4176    )]
4177    pub effective_severity: Option<EffectiveSeverity>,
4178}
4179
4180impl MisconfiguredDependencyOverrideFinding {
4181    /// Build the wrapper. The suppress action is omitted when neither
4182    /// `target_package` (set on `EmptyValue` cases) nor `raw_key` provides a
4183    /// non-empty package name; an `ignoreDependencyOverrides` entry with
4184    /// `package: ""` would be silently ignored by the config parser.
4185    #[must_use]
4186    pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
4187        let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
4188        actions.push(IssueAction::Fix(FixAction {
4189            kind: FixActionType::FixDependencyOverride,
4190            auto_fixable: false,
4191            description:
4192                "Fix the package-manager override key or value: invalid entries are rejected or ignored"
4193                    .to_string(),
4194            note: Some(
4195                "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`."
4196                    .to_string(),
4197            ),
4198            available_in_catalogs: None,
4199            suggested_target: None,
4200        }));
4201
4202        if let Some(suppress) = build_ignore_dependency_overrides_suppress(
4203            entry.target_package.as_deref(),
4204            &entry.raw_key,
4205            entry.source,
4206        ) {
4207            actions.push(suppress);
4208        }
4209
4210        Self {
4211            finding_id: None,
4212            entry,
4213            actions,
4214            introduced: None,
4215            effective_severity: None,
4216        }
4217    }
4218}
4219
4220/// Shared `add-to-config` `ignoreDependencyOverrides` builder for the two
4221/// override findings. Returns `None` when no non-empty package name is
4222/// available; the config parser silently drops entries with an empty
4223/// `package` field, so emitting one would be a no-op that misleads agents.
4224fn build_ignore_dependency_overrides_suppress(
4225    target_package: Option<&str>,
4226    raw_key: &str,
4227    source: DependencyOverrideSource,
4228) -> Option<IssueAction> {
4229    let package = target_package
4230        .filter(|s| !s.is_empty())
4231        .or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
4232        .to_string();
4233    let mut value = serde_json::Map::new();
4234    value.insert("package".to_string(), serde_json::Value::String(package));
4235    value.insert(
4236        "source".to_string(),
4237        serde_json::Value::String(source.as_label().to_string()),
4238    );
4239    Some(IssueAction::AddToConfig(AddToConfigAction {
4240        kind: AddToConfigKind::AddToConfig,
4241        auto_fixable: false,
4242        description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
4243        config_key: "ignoreDependencyOverrides".to_string(),
4244        value: AddToConfigValue::RuleObject(value),
4245        value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
4246    }))
4247}
4248
4249// ── The mutation gate, registered once ──────────────────────────
4250//
4251// Every finding whose reachability verdict a lost import edge can distort.
4252// The analysis layer stamps caveats through `set_reachability_caveats`, which
4253// enforces the gate on the finding's actions in the same call; every mutation
4254// surface reads the answer back through `may_auto_apply_mutation`.
4255impl_caveated_finding!(
4256    UnusedFileFinding,
4257    UnusedExportFinding,
4258    UnusedTypeFinding,
4259    UnusedEnumMemberFinding,
4260    UnusedClassMemberFinding,
4261    UnusedStoreMemberFinding,
4262    UnusedDependencyFinding,
4263    UnusedDevDependencyFinding,
4264    UnusedOptionalDependencyFinding,
4265);
4266
4267/// Gate severity of one finding after rule resolution.
4268///
4269/// It is the severity that `rules` and the matching `overrides[].rules` give
4270/// the finding for its path. `--fail-on-issues` raises `warn` to `error`. A
4271/// finding whose rule is `off` is not reported, so there is no `off` value.
4272/// The type is separate from the health `severity` band, which ranks a
4273/// finding and does not gate it.
4274///
4275/// The `fallow dead-code` findings gate fails when a finding is `error`.
4276/// Other gates (regression, stale baseline) decide on their own inputs. Two
4277/// commands differ: the `fallow audit` `new-only` gate fails only on introduced
4278/// findings, so an inherited `error` finding does not fail the audit, and the
4279/// combined command (`fallow` without a subcommand) exits 0 for machine
4280/// formats unless `--fail-on-issues` or `--ci` is set.
4281///
4282/// Complexity findings carry the same type. The `complexity-cyclomatic`,
4283/// `complexity-cognitive` and `complexity-crap` rules set it, and the
4284/// `fallow health` findings gate and the audit verdict read it.
4285#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4286#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
4287#[serde(rename_all = "lowercase")]
4288pub enum EffectiveSeverity {
4289    /// The finding fails the run.
4290    Error,
4291    /// The finding is reported and does not fail the run.
4292    Warn,
4293}
4294
4295/// Read an optional [`EffectiveSeverity`] and treat an unknown value as absent.
4296///
4297/// A saved report from a newer version can carry a value this version does not
4298/// know. The renderers then use the rule-based level, as for a report without
4299/// the field, and the envelope still loads.
4300///
4301/// # Errors
4302///
4303/// Returns an error only when the input is not valid JSON-like data.
4304pub fn deserialize_effective_severity<'de, D>(
4305    deserializer: D,
4306) -> Result<Option<EffectiveSeverity>, D::Error>
4307where
4308    D: serde::Deserializer<'de>,
4309{
4310    #[derive(Deserialize)]
4311    #[serde(untagged)]
4312    enum Tolerant {
4313        Known(EffectiveSeverity),
4314        Unknown(serde::de::IgnoredAny),
4315    }
4316    Ok(match Option::<Tolerant>::deserialize(deserializer)? {
4317        Some(Tolerant::Known(severity)) => Some(severity),
4318        Some(Tolerant::Unknown(_)) | None => None,
4319    })
4320}
4321
4322/// A finding wrapper that carries an [`EffectiveSeverity`].
4323///
4324/// The analysis layer writes the value one time after rule resolution. CI
4325/// renderers read it and fall back to the rule-level severity when it is
4326/// absent, for example in a saved report from an older version.
4327pub trait GatedFinding {
4328    /// The gate severity, or `None` when no value was written.
4329    fn effective_severity(&self) -> Option<EffectiveSeverity>;
4330
4331    /// Write the gate severity.
4332    fn set_effective_severity(&mut self, severity: Option<EffectiveSeverity>);
4333}
4334
4335/// Implement [`GatedFinding`] for wrappers with an `effective_severity` field.
4336macro_rules! impl_gated_finding {
4337    ($($finding:ty),+ $(,)?) => {
4338        $(
4339            impl GatedFinding for $finding {
4340                fn effective_severity(&self) -> Option<EffectiveSeverity> {
4341                    self.effective_severity
4342                }
4343
4344                fn set_effective_severity(&mut self, severity: Option<EffectiveSeverity>) {
4345                    self.effective_severity = severity;
4346                }
4347            }
4348        )+
4349    };
4350}
4351
4352impl_gated_finding!(
4353    UnusedFileFinding,
4354    PrivateTypeLeakFinding,
4355    DeprecatedExportInUseFinding,
4356    UnresolvedImportFinding,
4357    CircularDependencyFinding,
4358    ReExportCycleFinding,
4359    PackageCycleFinding,
4360    BoundaryViolationFinding,
4361    BoundaryCoverageViolationFinding,
4362    BoundaryCallViolationFinding,
4363    UnusedExportFinding,
4364    UnusedTypeFinding,
4365    InvalidClientExportFinding,
4366    MixedClientServerBarrelFinding,
4367    MisplacedDirectiveFinding,
4368    UnprovidedInjectFinding,
4369    UnusedServerActionFinding,
4370    UnusedLoadDataKeyFinding,
4371    UnrenderedComponentFinding,
4372    UnusedComponentPropFinding,
4373    AbsentComponentPropFinding,
4374    UnusedComponentEmitFinding,
4375    UnusedSvelteEventFinding,
4376    UnusedEffectLayerFinding,
4377    UnusedTaggedErrorFinding,
4378    UnproducedCatchTagFinding,
4379    UnusedComponentInputFinding,
4380    UnusedComponentOutputFinding,
4381    RouteCollisionFinding,
4382    DynamicSegmentNameConflictFinding,
4383    UnusedEnumMemberFinding,
4384    UnusedClassMemberFinding,
4385    UnusedStoreMemberFinding,
4386    UnusedDependencyFinding,
4387    UnusedDevDependencyFinding,
4388    UnusedOptionalDependencyFinding,
4389    UnlistedDependencyFinding,
4390    TypeOnlyDependencyFinding,
4391    TestOnlyDependencyFinding,
4392    DevDependencyInProductionFinding,
4393    DuplicateExportFinding,
4394    UnusedCatalogEntryFinding,
4395    EmptyCatalogGroupFinding,
4396    UnresolvedCatalogReferenceFinding,
4397    UnusedDependencyOverrideFinding,
4398    MisconfiguredDependencyOverrideFinding,
4399    PropDrillingChainFinding,
4400    ThinWrapperFinding,
4401    DuplicatePropShapeFinding,
4402    crate::results::StaleSuppression,
4403);
4404
4405// ── Position-0 invariant golden tests ───────────────────────────
4406//
4407// These tests document the load-bearing position-0 semantics that flow
4408// downstream into the GitHub Action / GitLab CI jq scripts, the MCP server
4409// `actions[0].type` pattern-match, and the VS Code LSP code-action
4410// rendering. Snapshot tests assert structural equality; these named tests
4411// document WHY position 0 has a specific value, so a future refactor that
4412// re-orders actions tells you what broke instead of just "the snapshot
4413// changed".
4414#[cfg(test)]
4415mod caveat_tokens {
4416    use super::*;
4417
4418    /// The token-side helpers must render the same words as the typed ones, so
4419    /// one finding reads identically whether a surface holds the findings or
4420    /// re-reads them off a serialized envelope.
4421    #[test]
4422    fn token_labels_match_the_typed_labels() {
4423        let typed = [
4424            ReachabilityCaveat::IncompleteFileAnalysis,
4425            ReachabilityCaveat::IncompleteImportGraph,
4426        ];
4427        let tokens: Vec<&str> = typed.iter().map(|c| c.token()).collect();
4428
4429        assert_eq!(
4430            caveat_labels_for_tokens(tokens.iter().copied()),
4431            caveat_labels(&typed)
4432        );
4433        assert_eq!(
4434            caveat_suffix_for_tokens(tokens.iter().copied()),
4435            caveat_suffix(&typed)
4436        );
4437    }
4438
4439    #[test]
4440    fn no_tokens_means_nothing_to_say() {
4441        assert_eq!(caveat_labels_for_tokens(std::iter::empty()), None);
4442        assert_eq!(caveat_suffix_for_tokens(std::iter::empty()), None);
4443    }
4444
4445    /// The CI review formats can only see the rendered description, so the
4446    /// recogniser and the renderer have to stay one pair. A description with
4447    /// no caveat must not match, or the review formats would withhold the
4448    /// suggestion block on every finding in a clean run.
4449    #[test]
4450    fn a_rendered_suffix_is_recognised_by_the_marker() {
4451        for caveats in [
4452            &[ReachabilityCaveat::IncompleteImportGraph][..],
4453            &[
4454                ReachabilityCaveat::IncompleteFileAnalysis,
4455                ReachabilityCaveat::IncompleteImportGraph,
4456            ][..],
4457        ] {
4458            let suffix = caveat_suffix(caveats).expect("a caveat renders a suffix");
4459            assert!(
4460                description_carries_caveat(&format!("Something is never referenced{suffix}")),
4461                "the marker must match what caveat_suffix writes: {suffix}"
4462            );
4463        }
4464        assert!(
4465            description_carries_caveat(&format!(
4466                "Something is never referenced{}",
4467                caveat_suffix_for_tokens(["some-future-cause"]).expect("token suffix")
4468            )),
4469            "the token-side renderer writes the same marker"
4470        );
4471        assert!(
4472            !description_carries_caveat("Class member 'Widget.helper' is never referenced"),
4473            "a clean description must not read as caveated"
4474        );
4475    }
4476
4477    /// A caveat is a RUN-level condition covering several ways a file goes
4478    /// unread: a degraded parse, an unreadable file, and three kinds of file
4479    /// discovery skipped before opening. A message naming only the parse case
4480    /// told a reader whose run was degraded by the size guard to go fix parse
4481    /// errors that do not exist, which is the same overclaiming the caveat
4482    /// itself exists to prevent.
4483    #[test]
4484    fn no_caveat_message_names_a_single_cause() {
4485        for caveat in [
4486            ReachabilityCaveat::IncompleteFileAnalysis,
4487            ReachabilityCaveat::IncompleteImportGraph,
4488        ] {
4489            let message = caveat.message();
4490            assert!(
4491                !message.contains("parse cleanly") && !message.contains("parse error"),
4492                "{} names the parse cause alone, but a size-skipped or unreadable \
4493                 file reaches the same caveat: {message}",
4494                caveat.token()
4495            );
4496            assert!(
4497                message.contains("workspace_diagnostics"),
4498                "{} must point at the list that names the actual files: {message}",
4499                caveat.token()
4500            );
4501        }
4502    }
4503
4504    /// The value set is open. A token a consumer build does not recognise still
4505    /// means the evidence is incomplete, so it must survive into the rendered
4506    /// hedge rather than being dropped back into a confident-looking finding.
4507    #[test]
4508    fn an_unrecognised_token_still_renders_as_a_caveat() {
4509        let suffix = caveat_suffix_for_tokens(["some-future-cause"])
4510            .expect("an unknown token is still a caveat");
4511
4512        assert_eq!(suffix, " (caveat: some future cause)");
4513    }
4514}
4515
4516/// The gate, pinned as one property across every finding type rather than as
4517/// one test per mutation surface.
4518///
4519/// Three separate reviewers found three separate mutation paths that had never
4520/// learned about the caveat, because each earlier round fixed the door it
4521/// found. These tests assert the invariant itself: for every dead-code finding
4522/// that can carry a caveat, a caveated finding exposes NO auto-fixable action,
4523/// and an uncaveated one is untouched. Adding a caveated finding type without
4524/// registering it in `impl_caveated_finding!` fails to compile at the
4525/// `set_reachability_caveats` call the annotation pass makes; adding one that
4526/// exposes an auto-fixable mutation and never gets annotated is what
4527/// `every_auto_fixable_dead_code_mutation_is_gated` catches.
4528#[cfg(test)]
4529mod mutation_gate {
4530    use super::*;
4531    use crate::extract::MemberKind;
4532    use crate::results::DependencyLocation;
4533    use std::path::PathBuf;
4534
4535    const BOTH: [ReachabilityCaveat; 2] = [
4536        ReachabilityCaveat::IncompleteFileAnalysis,
4537        ReachabilityCaveat::IncompleteImportGraph,
4538    ];
4539
4540    fn export(name: &str) -> UnusedExport {
4541        UnusedExport {
4542            path: PathBuf::from("/p/src/mod.ts"),
4543            export_name: name.to_string(),
4544            is_type_only: false,
4545            line: 1,
4546            col: 0,
4547            span_start: 0,
4548            is_re_export: false,
4549            deprecated: false,
4550            deprecated_reason: None,
4551        }
4552    }
4553
4554    fn member(name: &str) -> UnusedMember {
4555        UnusedMember {
4556            path: PathBuf::from("/p/src/mod.ts"),
4557            parent_name: "Color".to_string(),
4558            member_name: name.to_string(),
4559            kind: MemberKind::EnumMember,
4560            line: 2,
4561            col: 2,
4562        }
4563    }
4564
4565    fn class_member(name: &str) -> UnusedMember {
4566        UnusedMember {
4567            parent_name: "Widget".to_string(),
4568            kind: MemberKind::ClassMethod,
4569            ..member(name)
4570        }
4571    }
4572
4573    fn store_member(name: &str) -> UnusedMember {
4574        UnusedMember {
4575            parent_name: "useCounterStore".to_string(),
4576            kind: MemberKind::StoreMember,
4577            ..member(name)
4578        }
4579    }
4580
4581    fn dependency(name: &str) -> UnusedDependency {
4582        UnusedDependency {
4583            package_name: name.to_string(),
4584            location: DependencyLocation::Dependencies,
4585            path: PathBuf::from("/p/package.json"),
4586            line: 5,
4587            used_in_workspaces: Vec::new(),
4588            declared_and_imported_in: Vec::new(),
4589        }
4590    }
4591
4592    /// One finding type: its name, the uncaveated finding, and the same
4593    /// finding after the annotation pass stamped a caveat on it.
4594    type GatedPair = (&'static str, Box<dyn Gated>, Box<dyn Gated>);
4595
4596    /// Every caveated finding type, boxed behind the one question the mutation
4597    /// surfaces ask.
4598    fn every_finding_type() -> Vec<GatedPair> {
4599        fn pair<T: Gated + Clone + 'static>(name: &'static str, clean: T) -> GatedPair {
4600            let mut caveated = clean.clone();
4601            caveated.stamp(BOTH.to_vec());
4602            (name, Box::new(clean), Box::new(caveated))
4603        }
4604        vec![
4605            pair(
4606                "unused_files",
4607                UnusedFileFinding::with_actions(UnusedFile {
4608                    path: PathBuf::from("/p/src/orphan.ts"),
4609                }),
4610            ),
4611            pair(
4612                "unused_exports",
4613                UnusedExportFinding::with_actions(export("helper")),
4614            ),
4615            pair(
4616                "unused_types",
4617                UnusedTypeFinding::with_actions(export("Shape")),
4618            ),
4619            pair(
4620                "unused_enum_members",
4621                UnusedEnumMemberFinding::with_actions(member("Blue")),
4622            ),
4623            pair(
4624                "unused_class_members",
4625                UnusedClassMemberFinding::with_actions(class_member("legacyMethod")),
4626            ),
4627            pair(
4628                "unused_store_members",
4629                UnusedStoreMemberFinding::with_actions(store_member("onlyUsedInBigFile")),
4630            ),
4631            pair(
4632                "unused_dependencies",
4633                UnusedDependencyFinding::with_actions(dependency("lodash")),
4634            ),
4635            pair(
4636                "unused_dev_dependencies",
4637                UnusedDevDependencyFinding::with_actions(dependency("vitest")),
4638            ),
4639            pair(
4640                "unused_optional_dependencies",
4641                UnusedOptionalDependencyFinding::with_actions(dependency("fsevents")),
4642            ),
4643        ]
4644    }
4645
4646    /// Erases the finding type down to what a mutation surface needs: the gate,
4647    /// the actions it gates, and the annotation-pass write.
4648    trait Gated {
4649        fn actions(&self) -> &[IssueAction];
4650        fn gate_allows_mutation(&self) -> bool;
4651        fn stamp(&mut self, caveats: Vec<ReachabilityCaveat>);
4652    }
4653
4654    impl<T: MutationEvidence + CaveatedFinding + HasActions> Gated for T {
4655        fn actions(&self) -> &[IssueAction] {
4656            HasActions::actions(self)
4657        }
4658        fn gate_allows_mutation(&self) -> bool {
4659            self.may_auto_apply_mutation()
4660        }
4661        fn stamp(&mut self, caveats: Vec<ReachabilityCaveat>) {
4662            self.set_reachability_caveats(caveats);
4663        }
4664    }
4665
4666    trait HasActions {
4667        fn actions(&self) -> &[IssueAction];
4668    }
4669
4670    macro_rules! has_actions {
4671        ($($ty:ty),+ $(,)?) => { $( impl HasActions for $ty {
4672            fn actions(&self) -> &[IssueAction] { &self.actions }
4673        } )+ };
4674    }
4675    has_actions!(
4676        UnusedFileFinding,
4677        UnusedExportFinding,
4678        UnusedTypeFinding,
4679        UnusedEnumMemberFinding,
4680        UnusedClassMemberFinding,
4681        UnusedStoreMemberFinding,
4682        UnusedDependencyFinding,
4683        UnusedDevDependencyFinding,
4684        UnusedOptionalDependencyFinding,
4685    );
4686
4687    /// THE property. Not "the CLI withholds it" or "the LSP hides it": no
4688    /// finding whose evidence the run itself flagged may advertise an
4689    /// automatically applicable mutation, whichever surface is reading.
4690    #[test]
4691    fn every_auto_fixable_dead_code_mutation_is_gated() {
4692        for (name, _clean, caveated) in every_finding_type() {
4693            assert!(
4694                !caveated.gate_allows_mutation(),
4695                "{name}: a stamped finding must fail the gate"
4696            );
4697            for action in caveated.actions() {
4698                assert!(
4699                    !action.is_auto_fixable(),
4700                    "{name}: a caveated finding still advertises an auto-fixable action, so an \
4701                     agent following the documented actions contract would plan a removal \
4702                     `fallow fix` refuses"
4703                );
4704            }
4705        }
4706    }
4707
4708    /// The other half, and the one a blunt fix breaks: the gate must not turn
4709    /// every finding into a manual one. A run that read every file it
4710    /// discovered keeps exactly the behavior it had.
4711    #[test]
4712    fn an_uncaveated_finding_keeps_its_auto_fix() {
4713        let auto_fixable_types = [
4714            "unused_exports",
4715            "unused_types",
4716            "unused_enum_members",
4717            "unused_dependencies",
4718            "unused_dev_dependencies",
4719            "unused_optional_dependencies",
4720        ];
4721        for (name, clean, _caveated) in every_finding_type() {
4722            assert!(
4723                clean.gate_allows_mutation(),
4724                "{name}: a finding with no caveat must pass the gate"
4725            );
4726            if auto_fixable_types.contains(&name) {
4727                assert!(
4728                    clean.actions().iter().any(IssueAction::is_auto_fixable),
4729                    "{name}: the gate must not withhold a mutation the run has the evidence for"
4730                );
4731            }
4732        }
4733    }
4734
4735    /// The caveat is advisory about the FINDING and decisive only about the
4736    /// MUTATION: the actions array keeps its shape so a consumer reading
4737    /// `actions[0].type` is unaffected, and the suppress alternative stays.
4738    #[test]
4739    fn the_gate_downgrades_a_mutation_without_removing_it() {
4740        let clean = UnusedExportFinding::with_actions(export("helper"));
4741        let mut caveated = clean.clone();
4742        caveated.set_reachability_caveats(BOTH.to_vec());
4743
4744        assert_eq!(caveated.actions.len(), clean.actions.len());
4745        let IssueAction::Fix(fix) = &caveated.actions[0] else {
4746            panic!("position 0 stays the fix action");
4747        };
4748        assert!(!fix.auto_fixable);
4749        assert_eq!(
4750            fix.note.as_deref(),
4751            Some(INCOMPLETE_EVIDENCE_NOTE),
4752            "the withheld action says why in its own note, not only in a sibling array"
4753        );
4754    }
4755
4756    /// A pre-existing note is context the user still needs (the re-export
4757    /// warning names a public-API risk the caveat says nothing about), so the
4758    /// gate appends rather than overwrites.
4759    #[test]
4760    fn a_gated_mutation_keeps_the_note_it_already_had() {
4761        let mut re_export = export("helper");
4762        re_export.is_re_export = true;
4763        let mut finding = UnusedExportFinding::with_actions(re_export);
4764        finding.set_reachability_caveats(vec![ReachabilityCaveat::IncompleteImportGraph]);
4765
4766        let IssueAction::Fix(fix) = &finding.actions[0] else {
4767            panic!("position 0 stays the fix action");
4768        };
4769        let note = fix.note.as_deref().expect("note present");
4770        assert!(note.contains("public API"), "the original note survives");
4771        assert!(
4772            note.contains("Evidence is incomplete"),
4773            "the caveat is added"
4774        );
4775    }
4776
4777    /// The gate is complete only if every finding type that can ever expose an
4778    /// auto-fixable mutation is in it. The one member type deliberately left
4779    /// out is safe for a different reason, and this pins that reason rather
4780    /// than trusting a comment: a store member exposes no fix action at all,
4781    /// because reflective access via a Pinia plugin or `$onAction` is
4782    /// invisible to syntactic analysis, so no evidence this run could gather
4783    /// would open the removal. If it ever ships one, it needs
4784    /// `reachability_caveats` and a row in `impl_caveated_finding!` first.
4785    ///
4786    /// A class member used to sit here on the weaker argument that its removal
4787    /// STARTS withheld. That argument covered only the syntactic finding: the
4788    /// type-aware sidecar reopens the removal through
4789    /// [`UnusedClassMemberFinding::set_semantic_decision`], and the review
4790    /// formats rendered a one-click commit for it regardless of
4791    /// `auto_fixable`. It is inside the gate now, so the assertion here is
4792    /// only that the SYNTACTIC finding still ships no auto-fix; the reopening
4793    /// path is pinned by `a_complete_semantic_verdict_cannot_reopen_a_
4794    /// caveated_class_member`.
4795    #[test]
4796    fn a_store_member_exposes_no_mutation_at_all() {
4797        let store = UnusedStoreMemberFinding::with_actions(member("total"));
4798        assert!(
4799            !store.actions.iter().any(IssueAction::is_auto_fixable),
4800            "a store member must expose no automatically applicable mutation"
4801        );
4802        assert!(
4803            !store
4804                .actions
4805                .iter()
4806                .any(|action| matches!(action, IssueAction::Fix(_))),
4807            "and no fix action at all"
4808        );
4809
4810        let class = UnusedClassMemberFinding::with_actions(class_member("helper"));
4811        assert!(
4812            !class.actions.iter().any(IssueAction::is_auto_fixable),
4813            "a class member's syntactic removal stays withheld until semantic evidence opens it"
4814        );
4815    }
4816
4817    /// The semantic pass runs after the annotation pass and is the only code
4818    /// path that RAISES `auto_fixable`. A `Complete` verdict must not re-open a
4819    /// mutation the incomplete run already withheld.
4820    #[test]
4821    fn a_complete_semantic_verdict_cannot_reopen_a_caveated_mutation() {
4822        use crate::semantic::{
4823            SemanticCandidateDecision, SemanticCandidateDecisionKind, SemanticCompleteness,
4824            SemanticNamespace, SemanticSymbol,
4825        };
4826
4827        let complete_negative = || SemanticCandidateDecision {
4828            query_id: 0,
4829            subject: SemanticSymbol {
4830                path: PathBuf::from("/p/src/mod.ts"),
4831                namespace: SemanticNamespace::Value,
4832                declaration_kind: "function".to_string(),
4833                exported_name: "helper".to_string(),
4834                local_name: "helper".to_string(),
4835                owner: None,
4836                line: 1,
4837                col: 0,
4838            },
4839            decision: SemanticCandidateDecisionKind::ConfirmedNoStaticReferences,
4840            status: SemanticCompleteness::Complete,
4841            owning_projects: Vec::new(),
4842            evidence: Vec::new(),
4843            contract: None,
4844            framework_contract: None,
4845            closed_world_eligible: false,
4846            edit_guard: None,
4847            reason_code: None,
4848            explanation: String::new(),
4849            actions: Vec::new(),
4850            total_evidence_count: 0,
4851            truncated: false,
4852            omissions: Vec::new(),
4853        };
4854
4855        let mut clean = UnusedExportFinding::with_actions(export("helper"));
4856        clean.set_semantic_decision(complete_negative());
4857        assert!(
4858            clean.actions.iter().any(IssueAction::is_auto_fixable),
4859            "a complete negative verdict on a clean run still enables the fix"
4860        );
4861
4862        let mut caveated = UnusedExportFinding::with_actions(export("helper"));
4863        caveated.set_reachability_caveats(vec![ReachabilityCaveat::IncompleteImportGraph]);
4864        caveated.set_semantic_decision(complete_negative());
4865        assert!(
4866            !caveated.actions.iter().any(IssueAction::is_auto_fixable),
4867            "the semantic pass must ask the gate too"
4868        );
4869    }
4870
4871    /// The class-member twin, on its own eligibility flag. `closed_world_
4872    /// eligible` is proved over the program the sidecar could see, which is
4873    /// the program this run parsed; a member whose only call site sits in a
4874    /// file the run never opened is absent from that world for the same reason
4875    /// it is absent from the syntactic verdict, so a `true` here is not
4876    /// evidence the run lacks.
4877    #[test]
4878    fn a_complete_semantic_verdict_cannot_reopen_a_caveated_class_member() {
4879        use crate::semantic::{
4880            SemanticCandidateDecision, SemanticCandidateDecisionKind, SemanticCompleteness,
4881            SemanticNamespace, SemanticSymbol,
4882        };
4883
4884        let eligible = || SemanticCandidateDecision {
4885            query_id: 0,
4886            subject: SemanticSymbol {
4887                path: PathBuf::from("/p/src/mod.ts"),
4888                namespace: SemanticNamespace::Value,
4889                declaration_kind: "method".to_string(),
4890                exported_name: "Widget".to_string(),
4891                local_name: "legacyMethod".to_string(),
4892                owner: Some("Widget".to_string()),
4893                line: 2,
4894                col: 2,
4895            },
4896            decision: SemanticCandidateDecisionKind::ConfirmedNoStaticReferences,
4897            status: SemanticCompleteness::Complete,
4898            owning_projects: Vec::new(),
4899            evidence: Vec::new(),
4900            contract: None,
4901            framework_contract: None,
4902            closed_world_eligible: true,
4903            edit_guard: None,
4904            reason_code: None,
4905            explanation: "closed world proved".to_string(),
4906            actions: Vec::new(),
4907            total_evidence_count: 0,
4908            truncated: false,
4909            omissions: Vec::new(),
4910        };
4911
4912        let mut clean = UnusedClassMemberFinding::with_actions(class_member("legacyMethod"));
4913        clean.set_semantic_decision(eligible());
4914        assert!(
4915            clean.actions.iter().any(IssueAction::is_auto_fixable),
4916            "a closed-world verdict on a run that read every file still opens the removal"
4917        );
4918
4919        let mut caveated = UnusedClassMemberFinding::with_actions(class_member("legacyMethod"));
4920        caveated.set_reachability_caveats(vec![ReachabilityCaveat::IncompleteImportGraph]);
4921        caveated.set_semantic_decision(eligible());
4922        assert!(
4923            !caveated.actions.iter().any(IssueAction::is_auto_fixable),
4924            "the class-member semantic pass must ask the gate too"
4925        );
4926        let IssueAction::Fix(fix) = &caveated.actions[0] else {
4927            panic!("position 0 stays the fix action");
4928        };
4929        assert_eq!(
4930            fix.note.as_deref(),
4931            Some(INCOMPLETE_EVIDENCE_NOTE),
4932            "the withheld action says why, rather than repeating a closed-world explanation \
4933             computed over a program the run did not fully read"
4934        );
4935    }
4936}
4937
4938#[cfg(test)]
4939mod position_0_invariants {
4940    use super::*;
4941    use crate::output::FixActionType;
4942    use crate::results::{DependencyOverrideSource, DuplicateLocation};
4943    use std::path::PathBuf;
4944
4945    /// Helper: extract the kebab-case `type` discriminant from an
4946    /// [`IssueAction`] at a specific position. Returns `None` when the
4947    /// position is out of bounds or the action shape lacks a discriminant
4948    /// (today every variant has one).
4949    fn action_type(action: &IssueAction) -> &'static str {
4950        match action {
4951            IssueAction::Fix(fix) => match fix.kind {
4952                FixActionType::RemoveExport => "remove-export",
4953                FixActionType::DeleteFile => "delete-file",
4954                FixActionType::RemoveDependency => "remove-dependency",
4955                FixActionType::MoveDependency => "move-dependency",
4956                FixActionType::RemoveEnumMember => "remove-enum-member",
4957                FixActionType::RemoveClassMember => "remove-class-member",
4958                FixActionType::ResolveImport => "resolve-import",
4959                FixActionType::InstallDependency => "install-dependency",
4960                FixActionType::RemoveDuplicate => "remove-duplicate",
4961                FixActionType::MoveToDev => "move-to-dev",
4962                FixActionType::MoveToProd => "move-to-prod",
4963                FixActionType::RefactorCycle => "refactor-cycle",
4964                FixActionType::RefactorReExportCycle => "refactor-re-export-cycle",
4965                FixActionType::RefactorBoundary => "refactor-boundary",
4966                FixActionType::ExportType => "export-type",
4967                FixActionType::MigrateDeprecatedExport => "migrate-deprecated-export",
4968                FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
4969                FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
4970                FixActionType::UpdateCatalogReference => "update-catalog-reference",
4971                FixActionType::AddCatalogEntry => "add-catalog-entry",
4972                FixActionType::RemoveCatalogReference => "remove-catalog-reference",
4973                FixActionType::RemoveDependencyOverride => "remove-dependency-override",
4974                FixActionType::FixDependencyOverride => "fix-dependency-override",
4975                FixActionType::ResolvePolicyViolation => "resolve-policy-violation",
4976                FixActionType::MoveToServerModule => "move-to-server-module",
4977                FixActionType::SplitMixedBarrel => "split-mixed-barrel",
4978                FixActionType::HoistDirective => "hoist-directive",
4979                FixActionType::WireServerAction => "wire-server-action",
4980                FixActionType::ProvideInject => "provide-inject",
4981                FixActionType::UseLoadData => "use-load-data",
4982                FixActionType::RenderComponent => "render-component",
4983                FixActionType::UseComponentProp => "use-component-prop",
4984                FixActionType::ReviewComponentProp => "review-component-prop",
4985                FixActionType::EmitComponentEvent => "emit-component-event",
4986                FixActionType::WireSvelteEvent => "wire-svelte-event",
4987                FixActionType::ComposeEffectLayer => "compose-effect-layer",
4988                FixActionType::FailTaggedError => "fail-tagged-error",
4989                FixActionType::RemoveCatchTagHandler => "remove-catch-tag-handler",
4990                FixActionType::ResolveRouteCollision => "resolve-route-collision",
4991                FixActionType::ResolveDynamicSegmentNameConflict => {
4992                    "resolve-dynamic-segment-name-conflict"
4993                }
4994                FixActionType::AddSuppressionReason => "add-suppression-reason",
4995                FixActionType::RemoveStaleSuppression => "remove-stale-suppression",
4996            },
4997            IssueAction::SuppressLine(_) => "suppress-line",
4998            IssueAction::SuppressFile(_) => "suppress-file",
4999            IssueAction::AddToConfig(_) => "add-to-config",
5000        }
5001    }
5002
5003    fn assert_manual_fix_then_suppress(
5004        actions: &[IssueAction],
5005        primary_type: &str,
5006        suppress_comment: &str,
5007    ) {
5008        assert_eq!(actions.len(), 2);
5009        assert_eq!(action_type(&actions[0]), primary_type);
5010        let IssueAction::Fix(primary) = &actions[0] else {
5011            panic!("position-0 should be a manual fix action");
5012        };
5013        assert!(!primary.auto_fixable);
5014        assert!(primary.note.is_some());
5015        assert_eq!(action_type(&actions[1]), "suppress-line");
5016        let IssueAction::SuppressLine(suppress) = &actions[1] else {
5017            panic!("position-1 should be a suppress-line action");
5018        };
5019        assert_eq!(suppress.comment, suppress_comment);
5020    }
5021
5022    #[test]
5023    fn pnpm_catalog_entry_action_is_auto_fixable() {
5024        let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
5025            entry_name: "unused".to_string(),
5026            catalog_name: "default".to_string(),
5027            path: PathBuf::from("pnpm-workspace.yaml"),
5028            line: 3,
5029            hardcoded_consumers: vec![],
5030        });
5031
5032        let IssueAction::Fix(fix) = &finding.actions[0] else {
5033            panic!("position-0 should be a fix action");
5034        };
5035        assert!(fix.auto_fixable);
5036        assert_eq!(finding.actions.len(), 2);
5037        assert_eq!(action_type(&finding.actions[1]), "suppress-line");
5038    }
5039
5040    #[test]
5041    fn bun_package_json_catalog_entry_action_is_manual_only() {
5042        let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
5043            entry_name: "unused".to_string(),
5044            catalog_name: "default".to_string(),
5045            path: PathBuf::from("package.json"),
5046            line: 4,
5047            hardcoded_consumers: vec![],
5048        });
5049
5050        let IssueAction::Fix(fix) = &finding.actions[0] else {
5051            panic!("position-0 should be a fix action");
5052        };
5053        assert!(!fix.auto_fixable);
5054        assert!(fix.description.contains("manually"));
5055        assert_eq!(finding.actions.len(), 1);
5056    }
5057
5058    #[test]
5059    fn bun_package_json_empty_catalog_group_action_is_manual_only() {
5060        let finding = EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
5061            catalog_name: "empty".to_string(),
5062            path: PathBuf::from("package.json"),
5063            line: 4,
5064        });
5065
5066        let IssueAction::Fix(fix) = &finding.actions[0] else {
5067            panic!("position-0 should be a fix action");
5068        };
5069        assert!(!fix.auto_fixable);
5070        assert!(fix.description.contains("manually"));
5071        assert_eq!(finding.actions.len(), 1);
5072    }
5073
5074    #[test]
5075    fn unprovided_inject_primary_action_is_provide_inject() {
5076        let finding = UnprovidedInjectFinding::with_actions(UnprovidedInject {
5077            path: PathBuf::from("src/context.ts"),
5078            key_name: "userKey".to_string(),
5079            framework: "svelte".to_string(),
5080            line: 7,
5081            col: 12,
5082        });
5083
5084        assert_manual_fix_then_suppress(
5085            &finding.actions,
5086            "provide-inject",
5087            "// fallow-ignore-next-line unprovided-inject",
5088        );
5089    }
5090
5091    #[test]
5092    fn unused_server_action_primary_action_is_wire_server_action() {
5093        let finding = UnusedServerActionFinding::with_actions(UnusedServerAction {
5094            path: PathBuf::from("app/actions.ts"),
5095            action_name: "saveDraft".to_string(),
5096            line: 3,
5097            col: 13,
5098        });
5099
5100        assert_manual_fix_then_suppress(
5101            &finding.actions,
5102            "wire-server-action",
5103            "// fallow-ignore-next-line unused-server-action",
5104        );
5105    }
5106
5107    #[test]
5108    fn unused_load_data_key_primary_action_is_use_load_data() {
5109        let finding = UnusedLoadDataKeyFinding::with_actions(UnusedLoadDataKey {
5110            path: PathBuf::from("src/routes/+page.server.ts"),
5111            key_name: "profile".to_string(),
5112            line: 12,
5113            col: 6,
5114            route_dir: Some("src/routes".to_string()),
5115        });
5116
5117        assert_manual_fix_then_suppress(
5118            &finding.actions,
5119            "use-load-data",
5120            "// fallow-ignore-next-line unused-load-data-key",
5121        );
5122    }
5123
5124    #[test]
5125    fn unrendered_component_primary_action_is_render_component() {
5126        let finding = UnrenderedComponentFinding::with_actions(UnrenderedComponent {
5127            path: PathBuf::from("src/components/EmptyState.vue"),
5128            component_name: "EmptyState".to_string(),
5129            framework: "vue".to_string(),
5130            reachable_via: None,
5131            line: 1,
5132            col: 0,
5133        });
5134
5135        assert_manual_fix_then_suppress(
5136            &finding.actions,
5137            "render-component",
5138            "// fallow-ignore-next-line unrendered-component",
5139        );
5140    }
5141
5142    #[test]
5143    fn unused_component_prop_primary_action_is_use_component_prop() {
5144        let finding = UnusedComponentPropFinding::with_actions(UnusedComponentProp {
5145            path: PathBuf::from("src/components/Card.vue"),
5146            component_name: "Card".to_string(),
5147            prop_name: "variant".to_string(),
5148            line: 5,
5149            col: 10,
5150        });
5151
5152        assert_manual_fix_then_suppress(
5153            &finding.actions,
5154            "use-component-prop",
5155            "// fallow-ignore-next-line unused-component-prop",
5156        );
5157    }
5158
5159    #[test]
5160    fn unused_component_emit_primary_action_is_emit_component_event() {
5161        let finding = UnusedComponentEmitFinding::with_actions(UnusedComponentEmit {
5162            path: PathBuf::from("src/components/Picker.vue"),
5163            component_name: "Picker".to_string(),
5164            emit_name: "focus".to_string(),
5165            line: 6,
5166            col: 14,
5167        });
5168
5169        assert_manual_fix_then_suppress(
5170            &finding.actions,
5171            "emit-component-event",
5172            "// fallow-ignore-next-line unused-component-emit",
5173        );
5174    }
5175
5176    #[test]
5177    fn unused_svelte_event_primary_action_is_wire_svelte_event() {
5178        let finding = UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
5179            path: PathBuf::from("src/Dialog.svelte"),
5180            component_name: "Dialog".to_string(),
5181            event_name: "closed".to_string(),
5182            line: 19,
5183            col: 8,
5184        });
5185
5186        assert_manual_fix_then_suppress(
5187            &finding.actions,
5188            "wire-svelte-event",
5189            "// fallow-ignore-next-line unused-svelte-event",
5190        );
5191    }
5192
5193    #[test]
5194    fn unresolved_import_actions_include_ignore_unresolved_imports_config_suppress() {
5195        let inner = UnresolvedImport {
5196            specifier: "@example/icons".to_string(),
5197            path: PathBuf::from("src/index.ts"),
5198            line: 4,
5199            col: 12,
5200            specifier_col: 18,
5201        };
5202        let finding = UnresolvedImportFinding::with_actions(inner);
5203
5204        assert_eq!(action_type(&finding.actions[0]), "resolve-import");
5205        assert_eq!(action_type(&finding.actions[1]), "add-to-config");
5206        let IssueAction::AddToConfig(action) = &finding.actions[1] else {
5207            panic!("position-1 should be AddToConfig");
5208        };
5209        assert!(!action.auto_fixable);
5210        assert_eq!(action.config_key, "ignoreUnresolvedImports");
5211        let AddToConfigValue::Scalar(value) = &action.value else {
5212            panic!("ignoreUnresolvedImports action should carry a scalar value");
5213        };
5214        assert_eq!(value, "@example/icons");
5215        assert_eq!(
5216            action.value_schema.as_deref(),
5217            Some(
5218                "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
5219            )
5220        );
5221    }
5222
5223    /// Invariant: when no other catalog declares the package, position 0
5224    /// of `unresolved_catalog_references[].actions` is `add-catalog-entry`,
5225    /// directing the agent to grow the targeted catalog.
5226    ///
5227    /// Downstream consumers (MCP `actions[0].type` dispatch and JSON
5228    /// consumers that read the first action) pattern-match on this string. A future refactor that puts the
5229    /// generic `remove-catalog-reference` fallback at position 0 would
5230    /// flip every CI annotation from "add this entry" to "remove this
5231    /// reference", reversing the recommended action.
5232    #[test]
5233    fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
5234        let inner = UnresolvedCatalogReference {
5235            entry_name: "react".to_string(),
5236            catalog_name: "default".to_string(),
5237            path: PathBuf::from("apps/web/package.json"),
5238            line: 7,
5239            available_in_catalogs: Vec::new(),
5240        };
5241        let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
5242        assert_eq!(
5243            action_type(&finding.actions[0]),
5244            "add-catalog-entry",
5245            "position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
5246        );
5247        let IssueAction::Fix(fix) = &finding.actions[0] else {
5248            panic!("position-0 should be an IssueAction::Fix");
5249        };
5250        assert!(
5251            fix.available_in_catalogs.is_none(),
5252            "add-catalog-entry must NOT carry available_in_catalogs"
5253        );
5254        assert!(
5255            fix.suggested_target.is_none(),
5256            "add-catalog-entry must NOT carry suggested_target"
5257        );
5258    }
5259
5260    /// Invariant: when at least one alternative catalog declares the
5261    /// package, position 0 flips to `update-catalog-reference` and carries
5262    /// the alternative list. When exactly one alternative exists, the
5263    /// action also carries `suggested_target` so deterministic agents can
5264    /// land the edit without picking from the list. This is the
5265    /// counterpart to `unresolved_catalog_position_0_is_add_when_no_alternatives`.
5266    #[test]
5267    fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
5268        let inner = UnresolvedCatalogReference {
5269            entry_name: "react".to_string(),
5270            catalog_name: "default".to_string(),
5271            path: PathBuf::from("apps/web/package.json"),
5272            line: 7,
5273            available_in_catalogs: vec!["react18".to_string()],
5274        };
5275        let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
5276        assert_eq!(
5277            action_type(&finding.actions[0]),
5278            "update-catalog-reference",
5279            "position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
5280        );
5281        let IssueAction::Fix(fix) = &finding.actions[0] else {
5282            panic!("position-0 should be an IssueAction::Fix");
5283        };
5284        assert_eq!(
5285            fix.available_in_catalogs.as_deref(),
5286            Some(&["react18".to_string()][..]),
5287            "update-catalog-reference must carry the alternative list"
5288        );
5289        assert_eq!(
5290            fix.suggested_target.as_deref(),
5291            Some("react18"),
5292            "single-alternative case must surface `suggested_target` for deterministic agents"
5293        );
5294
5295        // Two alternatives: still update, but no unambiguous target.
5296        let inner_two = UnresolvedCatalogReference {
5297            entry_name: "react".to_string(),
5298            catalog_name: "default".to_string(),
5299            path: PathBuf::from("apps/web/package.json"),
5300            line: 7,
5301            available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
5302        };
5303        let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
5304        assert_eq!(
5305            action_type(&finding_two.actions[0]),
5306            "update-catalog-reference"
5307        );
5308        let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
5309            panic!("position-0 should be an IssueAction::Fix");
5310        };
5311        assert!(
5312            fix_two.suggested_target.is_none(),
5313            "multi-alternative case must NOT carry `suggested_target` (agent must pick)"
5314        );
5315    }
5316
5317    /// Invariant: position 0 of `duplicate_exports[].actions` is
5318    /// `add-to-config` (the safe `ignoreExports` rule for the
5319    /// namespace-barrel case), NOT the destructive `remove-duplicate`.
5320    ///
5321    /// This protects the shadcn / Radix / bits-ui pattern where every
5322    /// `components/ui/<name>/index.ts` intentionally re-exports the same
5323    /// short names. Any consumer that reads `actions[0].type` as "the
5324    /// recommended fix" must see the non-destructive path first; flipping
5325    /// position 0 to `remove-duplicate` would propose deleting an
5326    /// intentional API surface.
5327    ///
5328    /// This test pins position 0 across both possible auto_fixable values
5329    /// for the add-to-config action (the per-instance flip flag handled
5330    /// by `set_config_fixable`).
5331    #[test]
5332    fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
5333        let inner = DuplicateExport {
5334            export_name: "Root".to_string(),
5335            locations: vec![
5336                DuplicateLocation {
5337                    path: PathBuf::from("components/ui/accordion/index.ts"),
5338                    line: 1,
5339                    col: 0,
5340                },
5341                DuplicateLocation {
5342                    path: PathBuf::from("components/ui/dialog/index.ts"),
5343                    line: 1,
5344                    col: 0,
5345                },
5346            ],
5347        };
5348        let finding = DuplicateExportFinding::with_actions(inner);
5349        assert_eq!(
5350            action_type(&finding.actions[0]),
5351            "add-to-config",
5352            "position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
5353        );
5354        assert_eq!(
5355            action_type(&finding.actions[1]),
5356            "remove-duplicate",
5357            "position-1 must be the destructive `remove-duplicate` fallback"
5358        );
5359
5360        // `set_config_fixable(true)` flips the position-0 add-to-config
5361        // bool but must NOT re-order positions.
5362        let mut promoted = finding;
5363        promoted.set_config_fixable(true);
5364        assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
5365        let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
5366            panic!("position-0 should still be AddToConfig after set_config_fixable");
5367        };
5368        assert!(
5369            action.auto_fixable,
5370            "set_config_fixable(true) must flip auto_fixable"
5371        );
5372    }
5373
5374    /// Invariant: a duplicate-exports finding with empty `locations`
5375    /// degenerate input drops the `add-to-config` action entirely, so
5376    /// position 0 falls through to `remove-duplicate`. Documents the
5377    /// degenerate-case contract.
5378    #[test]
5379    fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
5380        let inner = DuplicateExport {
5381            export_name: "Root".to_string(),
5382            locations: Vec::new(),
5383        };
5384        let finding = DuplicateExportFinding::with_actions(inner);
5385        assert_eq!(
5386            action_type(&finding.actions[0]),
5387            "remove-duplicate",
5388            "with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
5389        );
5390
5391        // `set_config_fixable(true)` is a no-op on this shape.
5392        let mut promoted = finding;
5393        promoted.set_config_fixable(true);
5394        assert_eq!(
5395            action_type(&promoted.actions[0]),
5396            "remove-duplicate",
5397            "set_config_fixable is a no-op when position-0 is not add-to-config"
5398        );
5399    }
5400
5401    /// Invariant: misconfigured-dependency-override with empty
5402    /// `target_package` AND empty `raw_key` drops the suppress action
5403    /// (no usable package name for the `ignoreDependencyOverrides`
5404    /// matcher; emitting `package: ""` would be silently dropped by the
5405    /// config parser). Documents the suppress-omission contract.
5406    #[test]
5407    fn misconfigured_override_drops_suppress_when_no_package_name() {
5408        let inner = MisconfiguredDependencyOverride {
5409            raw_key: String::new(),
5410            target_package: None,
5411            raw_value: String::new(),
5412            reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
5413            source: DependencyOverrideSource::PnpmWorkspaceYaml,
5414            path: PathBuf::from("pnpm-workspace.yaml"),
5415            line: 12,
5416        };
5417        let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
5418        // Only the primary fix-dependency-override action: no suppress.
5419        assert_eq!(finding.actions.len(), 1);
5420        assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
5421    }
5422}