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, UnprovidedInject, UnrenderedComponent,
47    UnresolvedCatalogReference, UnresolvedImport, UnusedCatalogEntry, UnusedComponentEmit,
48    UnusedComponentInput, UnusedComponentOutput, UnusedComponentProp, UnusedDependency,
49    UnusedDependencyOverride, UnusedExport, UnusedFile, UnusedLoadDataKey, UnusedMember,
50    UnusedServerAction, UnusedSvelteEvent,
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 a [`PropDrillingChain`] finding. There is no safe
2181/// auto-fix: collapsing a drilling chain (colocate the consumer, lift to a
2182/// context, or compose the component) is a design decision. The only action is a
2183/// line-level suppress at the source hop's prop declaration. The rule defaults
2184/// to `off` (opt-in health signal), so this finding is dormant by default.
2185#[derive(Debug, Clone, Serialize, Deserialize)]
2186#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2187pub struct PropDrillingChainFinding {
2188    /// The underlying located chain.
2189    #[serde(flatten)]
2190    pub chain: PropDrillingChain,
2191    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2192    /// `~<k>` suffix when several findings of one type share an identity.
2193    /// Line and column are not inputs, so the id survives line shifts,
2194    /// reformats and reorders. A rename of the file or the symbol gives a
2195    /// new id. Absent in output from older versions.
2196    #[serde(default, skip_serializing_if = "Option::is_none")]
2197    pub finding_id: Option<String>,
2198    /// Suggested next steps. Always emitted (possibly empty for
2199    /// forward-compat).
2200    pub actions: Vec<IssueAction>,
2201    /// Set by the audit pass when this finding is introduced relative to
2202    /// the merge-base.
2203    #[serde(default, skip_serializing_if = "Option::is_none")]
2204    pub introduced: Option<AuditIntroduced>,
2205    /// Rule severity of this finding. This type never gates the run, so the
2206    /// value does not change the exit code. `fallow report --from` reads it
2207    /// for the SARIF level, so the level does not depend on the config at
2208    /// render time. Absent in output from older versions. Not part of the
2209    /// finding identity, baseline keys or fingerprints.
2210    #[serde(
2211        default,
2212        skip_serializing_if = "Option::is_none",
2213        deserialize_with = "deserialize_effective_severity"
2214    )]
2215    pub effective_severity: Option<EffectiveSeverity>,
2216}
2217
2218impl PropDrillingChainFinding {
2219    /// Build the wrapper from a raw [`PropDrillingChain`]. Emits only a
2220    /// line-level suppress action anchored at the source hop: there is no safe
2221    /// auto-fix because collapsing the chain is a design decision (colocate,
2222    /// lift to context, or compose).
2223    #[must_use]
2224    pub fn with_actions(chain: PropDrillingChain) -> Self {
2225        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
2226            kind: SuppressLineKind::SuppressLine,
2227            auto_fixable: false,
2228            description: "Suppress with an inline comment above the source prop declaration"
2229                .to_string(),
2230            comment: "// fallow-ignore-next-line prop-drilling".to_string(),
2231            scope: None,
2232        })];
2233        Self {
2234            finding_id: None,
2235            chain,
2236            actions,
2237            introduced: None,
2238            effective_severity: None,
2239        }
2240    }
2241}
2242
2243/// Wire-shape envelope for a [`ThinWrapper`] finding. There is no safe
2244/// auto-fix: inlining a thin wrapper at its call sites (or deleting it) is a
2245/// design decision. The only action is a line-level suppress at the wrapper's
2246/// definition. The rule defaults to `off` (opt-in health signal), so this
2247/// finding is dormant by default.
2248#[derive(Debug, Clone, Serialize, Deserialize)]
2249#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2250pub struct ThinWrapperFinding {
2251    /// The underlying located thin wrapper.
2252    #[serde(flatten)]
2253    pub wrapper: ThinWrapper,
2254    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2255    /// `~<k>` suffix when several findings of one type share an identity.
2256    /// Line and column are not inputs, so the id survives line shifts,
2257    /// reformats and reorders. A rename of the file or the symbol gives a
2258    /// new id. Absent in output from older versions.
2259    #[serde(default, skip_serializing_if = "Option::is_none")]
2260    pub finding_id: Option<String>,
2261    /// Suggested next steps. Always emitted (possibly empty for
2262    /// forward-compat).
2263    pub actions: Vec<IssueAction>,
2264    /// Set by the audit pass when this finding is introduced relative to
2265    /// the merge-base.
2266    #[serde(default, skip_serializing_if = "Option::is_none")]
2267    pub introduced: Option<AuditIntroduced>,
2268    /// Rule severity of this finding. This type never gates the run, so the
2269    /// value does not change the exit code. `fallow report --from` reads it
2270    /// for the SARIF level, so the level does not depend on the config at
2271    /// render time. Absent in output from older versions. Not part of the
2272    /// finding identity, baseline keys or fingerprints.
2273    #[serde(
2274        default,
2275        skip_serializing_if = "Option::is_none",
2276        deserialize_with = "deserialize_effective_severity"
2277    )]
2278    pub effective_severity: Option<EffectiveSeverity>,
2279}
2280
2281impl ThinWrapperFinding {
2282    /// Build the wrapper from a raw [`ThinWrapper`]. Emits only a line-level
2283    /// suppress action anchored at the wrapper definition: there is no safe
2284    /// auto-fix because inlining or deleting the wrapper is a design decision.
2285    #[must_use]
2286    pub fn with_actions(wrapper: ThinWrapper) -> Self {
2287        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
2288            kind: SuppressLineKind::SuppressLine,
2289            auto_fixable: false,
2290            description: "Suppress with an inline comment above the component definition"
2291                .to_string(),
2292            comment: "// fallow-ignore-next-line thin-wrapper".to_string(),
2293            scope: None,
2294        })];
2295        Self {
2296            finding_id: None,
2297            wrapper,
2298            actions,
2299            introduced: None,
2300            effective_severity: None,
2301        }
2302    }
2303}
2304
2305/// Wire-shape envelope for a [`DuplicatePropShape`] finding. There is no safe
2306/// auto-fix: extracting a shared `Props` type or a base component for a group of
2307/// same-shaped components is a design decision. The actions are manual guidance
2308/// (extract the shared shape) plus a line-level suppress at the component
2309/// definition and a file-level suppress escape hatch (mirroring the
2310/// route-collision multi-file model). The rule defaults to `off` (opt-in health
2311/// signal), so this finding is dormant by default.
2312#[derive(Debug, Clone, Serialize, Deserialize)]
2313#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2314pub struct DuplicatePropShapeFinding {
2315    /// The underlying duplicate-prop-shape entry.
2316    #[serde(flatten)]
2317    pub shape: DuplicatePropShape,
2318    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2319    /// `~<k>` suffix when several findings of one type share an identity.
2320    /// Line and column are not inputs, so the id survives line shifts,
2321    /// reformats and reorders. A rename of the file or the symbol gives a
2322    /// new id. Absent in output from older versions.
2323    #[serde(default, skip_serializing_if = "Option::is_none")]
2324    pub finding_id: Option<String>,
2325    /// Suggested next steps. Always emitted (possibly empty for
2326    /// forward-compat).
2327    pub actions: Vec<IssueAction>,
2328    /// Set by the audit pass when this finding is introduced relative to
2329    /// the merge-base.
2330    #[serde(default, skip_serializing_if = "Option::is_none")]
2331    pub introduced: Option<AuditIntroduced>,
2332    /// Rule severity of this finding. This type never gates the run, so the
2333    /// value does not change the exit code. `fallow report --from` reads it
2334    /// for the SARIF level, so the level does not depend on the config at
2335    /// render time. Absent in output from older versions. Not part of the
2336    /// finding identity, baseline keys or fingerprints.
2337    #[serde(
2338        default,
2339        skip_serializing_if = "Option::is_none",
2340        deserialize_with = "deserialize_effective_severity"
2341    )]
2342    pub effective_severity: Option<EffectiveSeverity>,
2343}
2344
2345impl DuplicatePropShapeFinding {
2346    /// Build the wrapper from a raw [`DuplicatePropShape`]. Manual guidance is
2347    /// the primary action (extract a shared shape); a line-level suppress at the
2348    /// component definition and a file-level suppress escape hatch follow,
2349    /// mirroring the multi-file route-collision suppress model. There is no safe
2350    /// auto-fix because extracting a shared type or base component is a design
2351    /// decision.
2352    #[must_use]
2353    pub fn with_actions(shape: DuplicatePropShape) -> Self {
2354        let actions = vec![
2355            IssueAction::SuppressLine(SuppressLineAction {
2356                kind: SuppressLineKind::SuppressLine,
2357                auto_fixable: false,
2358                description: "Three or more components share this exact prop shape. Extract one \
2359                              shared `Props` type (or a base component) that every member reuses, \
2360                              or keep them separate if a per-variant divergence is planned. \
2361                              Suppress one member with an inline comment above the component \
2362                              definition."
2363                    .to_string(),
2364                comment: "// fallow-ignore-next-line duplicate-prop-shape".to_string(),
2365                scope: None,
2366            }),
2367            IssueAction::SuppressFile(SuppressFileAction {
2368                kind: SuppressFileKind::SuppressFile,
2369                auto_fixable: false,
2370                description: "Escape hatch: a file-level suppress silences this member but it \
2371                              still appears in its siblings' `sharing_components` (the group is \
2372                              real regardless of suppression)."
2373                    .to_string(),
2374                comment: "// fallow-ignore-file duplicate-prop-shape".to_string(),
2375            }),
2376        ];
2377        Self {
2378            finding_id: None,
2379            shape,
2380            actions,
2381            introduced: None,
2382            effective_severity: None,
2383        }
2384    }
2385}
2386
2387/// Wire-shape envelope for an [`UnusedComponentInput`] finding. There is no safe
2388/// auto-fix: removing a declared input is judgement-bearing (the input may be
2389/// part of a deliberately-stable public component API). The only action is a
2390/// line-level suppress at the input declaration.
2391#[derive(Debug, Clone, Serialize, Deserialize)]
2392#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2393pub struct UnusedComponentInputFinding {
2394    /// The underlying finding.
2395    #[serde(flatten)]
2396    pub input: UnusedComponentInput,
2397    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2398    /// `~<k>` suffix when several findings of one type share an identity.
2399    /// Line and column are not inputs, so the id survives line shifts,
2400    /// reformats and reorders. A rename of the file or the symbol gives a
2401    /// new id. Absent in output from older versions.
2402    #[serde(default, skip_serializing_if = "Option::is_none")]
2403    pub finding_id: Option<String>,
2404    /// Suggested next steps. Always emitted (possibly empty for
2405    /// forward-compat).
2406    pub actions: Vec<IssueAction>,
2407    /// Set by the audit pass when this finding is introduced relative to
2408    /// the merge-base.
2409    #[serde(default, skip_serializing_if = "Option::is_none")]
2410    pub introduced: Option<AuditIntroduced>,
2411    /// Gate severity of this finding after `rules` and `overrides[].rules`
2412    /// resolve for its path. CI formats read it for the annotation, SARIF
2413    /// and CodeClimate level. Absent in output from older versions. Not
2414    /// part of the finding identity, baseline keys or fingerprints.
2415    #[serde(
2416        default,
2417        skip_serializing_if = "Option::is_none",
2418        deserialize_with = "deserialize_effective_severity"
2419    )]
2420    pub effective_severity: Option<EffectiveSeverity>,
2421}
2422
2423impl UnusedComponentInputFinding {
2424    /// Build the wrapper from a raw [`UnusedComponentInput`]. Emits only a
2425    /// line-level suppress action: there is no safe auto-fix because removing an
2426    /// input is a human decision (it may be part of a stable component API).
2427    #[must_use]
2428    pub fn with_actions(input: UnusedComponentInput) -> Self {
2429        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
2430            kind: SuppressLineKind::SuppressLine,
2431            auto_fixable: false,
2432            description: "Suppress with an inline comment above the line".to_string(),
2433            comment: "// fallow-ignore-next-line unused-component-input".to_string(),
2434            scope: None,
2435        })];
2436        Self {
2437            finding_id: None,
2438            input,
2439            actions,
2440            introduced: None,
2441            effective_severity: None,
2442        }
2443    }
2444}
2445
2446/// Wire-shape envelope for an [`UnusedComponentOutput`] finding. There is no safe
2447/// auto-fix: removing a declared output is judgement-bearing (the event may be
2448/// part of a deliberately-stable public component API). The only action is a
2449/// line-level suppress at the output declaration.
2450#[derive(Debug, Clone, Serialize, Deserialize)]
2451#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2452pub struct UnusedComponentOutputFinding {
2453    /// The underlying finding.
2454    #[serde(flatten)]
2455    pub output: UnusedComponentOutput,
2456    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2457    /// `~<k>` suffix when several findings of one type share an identity.
2458    /// Line and column are not inputs, so the id survives line shifts,
2459    /// reformats and reorders. A rename of the file or the symbol gives a
2460    /// new id. Absent in output from older versions.
2461    #[serde(default, skip_serializing_if = "Option::is_none")]
2462    pub finding_id: Option<String>,
2463    /// Suggested next steps. Always emitted (possibly empty for
2464    /// forward-compat).
2465    pub actions: Vec<IssueAction>,
2466    /// Set by the audit pass when this finding is introduced relative to
2467    /// the merge-base.
2468    #[serde(default, skip_serializing_if = "Option::is_none")]
2469    pub introduced: Option<AuditIntroduced>,
2470    /// Gate severity of this finding after `rules` and `overrides[].rules`
2471    /// resolve for its path. CI formats read it for the annotation, SARIF
2472    /// and CodeClimate level. Absent in output from older versions. Not
2473    /// part of the finding identity, baseline keys or fingerprints.
2474    #[serde(
2475        default,
2476        skip_serializing_if = "Option::is_none",
2477        deserialize_with = "deserialize_effective_severity"
2478    )]
2479    pub effective_severity: Option<EffectiveSeverity>,
2480}
2481
2482impl UnusedComponentOutputFinding {
2483    /// Build the wrapper from a raw [`UnusedComponentOutput`]. Emits only a
2484    /// line-level suppress action: there is no safe auto-fix because removing an
2485    /// output is a human decision (it may be part of a stable component API).
2486    #[must_use]
2487    pub fn with_actions(output: UnusedComponentOutput) -> Self {
2488        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
2489            kind: SuppressLineKind::SuppressLine,
2490            auto_fixable: false,
2491            description: "Suppress with an inline comment above the line".to_string(),
2492            comment: "// fallow-ignore-next-line unused-component-output".to_string(),
2493            scope: None,
2494        })];
2495        Self {
2496            finding_id: None,
2497            output,
2498            actions,
2499            introduced: None,
2500            effective_severity: None,
2501        }
2502    }
2503}
2504
2505/// Wire-shape envelope for a [`RouteCollision`] finding. A route collision is a
2506/// guaranteed `next build` failure, so the PRIMARY action is manual guidance
2507/// (move or merge one of the colliding files), NOT a suppress: suppressing a
2508/// build error never makes the build pass. A file-level suppress is offered as
2509/// an escape hatch only.
2510#[derive(Debug, Clone, Serialize, Deserialize)]
2511#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2512pub struct RouteCollisionFinding {
2513    /// The underlying route-collision entry.
2514    #[serde(flatten)]
2515    pub collision: RouteCollision,
2516    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2517    /// `~<k>` suffix when several findings of one type share an identity.
2518    /// Line and column are not inputs, so the id survives line shifts,
2519    /// reformats and reorders. A rename of the file or the symbol gives a
2520    /// new id. Absent in output from older versions.
2521    #[serde(default, skip_serializing_if = "Option::is_none")]
2522    pub finding_id: Option<String>,
2523    /// Suggested next steps. Always emitted (possibly empty for
2524    /// forward-compat).
2525    pub actions: Vec<IssueAction>,
2526    /// Set by the audit pass when this finding is introduced relative to
2527    /// the merge-base.
2528    #[serde(default, skip_serializing_if = "Option::is_none")]
2529    pub introduced: Option<AuditIntroduced>,
2530    /// Gate severity of this finding after `rules` and `overrides[].rules`
2531    /// resolve for its path. CI formats read it for the annotation, SARIF
2532    /// and CodeClimate level. Absent in output from older versions. Not
2533    /// part of the finding identity, baseline keys or fingerprints.
2534    #[serde(
2535        default,
2536        skip_serializing_if = "Option::is_none",
2537        deserialize_with = "deserialize_effective_severity"
2538    )]
2539    pub effective_severity: Option<EffectiveSeverity>,
2540}
2541
2542impl RouteCollisionFinding {
2543    /// Build the wrapper from a raw [`RouteCollision`]. The primary action is
2544    /// manual guidance because suppressing a guaranteed build error is never
2545    /// the right fix; a file-level suppress is the escape hatch only.
2546    #[must_use]
2547    pub fn with_actions(collision: RouteCollision) -> Self {
2548        let actions = vec![
2549            IssueAction::Fix(FixAction {
2550                kind: FixActionType::ResolveRouteCollision,
2551                auto_fixable: false,
2552                description: "Two or more files resolve to the same URL. Move or merge one so \
2553                              each URL has a single owner. Route groups `(name)` and parallel \
2554                              slots `@name` are the only legal same-URL shapes."
2555                    .to_string(),
2556                note: Some(
2557                    "Next.js fails the build with \"You cannot have two parallel pages that \
2558                     resolve to the same path\". See the sibling `conflicting_paths` array for \
2559                     the other files that own this URL."
2560                        .to_string(),
2561                ),
2562                available_in_catalogs: None,
2563                suggested_target: None,
2564            }),
2565            IssueAction::SuppressFile(SuppressFileAction {
2566                kind: SuppressFileKind::SuppressFile,
2567                auto_fixable: false,
2568                description: "Escape hatch only: a file-level suppress silences the finding but \
2569                              does NOT make `next build` pass. Prefer moving or merging a file."
2570                    .to_string(),
2571                comment: "// fallow-ignore-file route-collision".to_string(),
2572            }),
2573        ];
2574        Self {
2575            finding_id: None,
2576            collision,
2577            actions,
2578            introduced: None,
2579            effective_severity: None,
2580        }
2581    }
2582}
2583
2584/// Wire-shape envelope for a [`DynamicSegmentNameConflict`] finding. The
2585/// conflict is a Next.js dev / runtime error (`next build` does NOT catch it),
2586/// so the primary action is manual guidance (rename the dynamic segments to a
2587/// single consistent slug name), with a file-level suppress as escape hatch.
2588#[derive(Debug, Clone, Serialize, Deserialize)]
2589#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2590pub struct DynamicSegmentNameConflictFinding {
2591    /// The underlying dynamic-segment-name-conflict entry.
2592    #[serde(flatten)]
2593    pub conflict: DynamicSegmentNameConflict,
2594    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2595    /// `~<k>` suffix when several findings of one type share an identity.
2596    /// Line and column are not inputs, so the id survives line shifts,
2597    /// reformats and reorders. A rename of the file or the symbol gives a
2598    /// new id. Absent in output from older versions.
2599    #[serde(default, skip_serializing_if = "Option::is_none")]
2600    pub finding_id: Option<String>,
2601    /// Suggested next steps. Always emitted (possibly empty for
2602    /// forward-compat).
2603    pub actions: Vec<IssueAction>,
2604    /// Set by the audit pass when this finding is introduced relative to
2605    /// the merge-base.
2606    #[serde(default, skip_serializing_if = "Option::is_none")]
2607    pub introduced: Option<AuditIntroduced>,
2608    /// Gate severity of this finding after `rules` and `overrides[].rules`
2609    /// resolve for its path. CI formats read it for the annotation, SARIF
2610    /// and CodeClimate level. Absent in output from older versions. Not
2611    /// part of the finding identity, baseline keys or fingerprints.
2612    #[serde(
2613        default,
2614        skip_serializing_if = "Option::is_none",
2615        deserialize_with = "deserialize_effective_severity"
2616    )]
2617    pub effective_severity: Option<EffectiveSeverity>,
2618}
2619
2620impl DynamicSegmentNameConflictFinding {
2621    /// Build the wrapper from a raw [`DynamicSegmentNameConflict`]. Manual
2622    /// guidance primary action; file-level suppress escape hatch only.
2623    #[must_use]
2624    pub fn with_actions(conflict: DynamicSegmentNameConflict) -> Self {
2625        let actions = vec![
2626            IssueAction::Fix(FixAction {
2627                kind: FixActionType::ResolveDynamicSegmentNameConflict,
2628                auto_fixable: false,
2629                description: "Sibling dynamic segments at the same position use different param \
2630                              names. Rename them to one consistent slug name (e.g. pick `[id]` \
2631                              or `[slug]` for both)."
2632                    .to_string(),
2633                note: Some(
2634                    "Next.js throws \"You cannot use different slug names for the same dynamic \
2635                     path\" at dev / runtime when the position is hit; `next build` does not \
2636                     catch it. See the sibling `conflicting_segments` array."
2637                        .to_string(),
2638                ),
2639                available_in_catalogs: None,
2640                suggested_target: None,
2641            }),
2642            IssueAction::SuppressFile(SuppressFileAction {
2643                kind: SuppressFileKind::SuppressFile,
2644                auto_fixable: false,
2645                description: "Escape hatch only: a file-level suppress silences the finding but \
2646                              does NOT stop Next.js from throwing at dev / runtime. Prefer \
2647                              renaming the segments."
2648                    .to_string(),
2649                comment: "// fallow-ignore-file dynamic-segment-name-conflict".to_string(),
2650            }),
2651        ];
2652        Self {
2653            finding_id: None,
2654            conflict,
2655            actions,
2656            introduced: None,
2657            effective_severity: None,
2658        }
2659    }
2660}
2661
2662/// Wire-shape envelope for an [`UnusedMember`] finding consumed under the
2663/// `unused_enum_members` key.
2664#[derive(Debug, Clone, Serialize, Deserialize)]
2665#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2666pub struct UnusedEnumMemberFinding {
2667    /// The underlying dead-code entry.
2668    #[serde(flatten)]
2669    pub member: UnusedMember,
2670    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2671    /// `~<k>` suffix when several findings of one type share an identity.
2672    /// Line and column are not inputs, so the id survives line shifts,
2673    /// reformats and reorders. A rename of the file or the symbol gives a
2674    /// new id. Absent in output from older versions.
2675    #[serde(default, skip_serializing_if = "Option::is_none")]
2676    pub finding_id: Option<String>,
2677    /// Suggested next steps. Always emitted (possibly empty for
2678    /// forward-compat).
2679    pub actions: Vec<IssueAction>,
2680    /// Set by the audit pass when this finding is introduced relative to
2681    /// the merge-base.
2682    #[serde(default, skip_serializing_if = "Option::is_none")]
2683    pub introduced: Option<AuditIntroduced>,
2684    /// Gate severity of this finding after `rules` and `overrides[].rules`
2685    /// resolve for its path. CI formats read it for the annotation, SARIF
2686    /// and CodeClimate level. Absent in output from older versions. Not
2687    /// part of the finding identity, baseline keys or fingerprints.
2688    #[serde(
2689        default,
2690        skip_serializing_if = "Option::is_none",
2691        deserialize_with = "deserialize_effective_severity"
2692    )]
2693    pub effective_severity: Option<EffectiveSeverity>,
2694    /// Advisory caveats on the verdict behind this finding. A member's usage
2695    /// is collected by walking the member accesses of every module the run
2696    /// parsed, so a member whose only reference lives in a file the run never
2697    /// read reads as unused exactly like an export does. Sorted,
2698    /// deduplicated, and omitted from the wire when empty. Never gates the
2699    /// finding; it does withhold the `remove-enum-member` mutation.
2700    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2701    pub reachability_caveats: Vec<ReachabilityCaveat>,
2702}
2703
2704impl UnusedEnumMemberFinding {
2705    /// Build the wrapper from a raw [`UnusedMember`].
2706    #[must_use]
2707    pub fn with_actions(member: UnusedMember) -> Self {
2708        let actions = vec![
2709            IssueAction::Fix(FixAction {
2710                kind: FixActionType::RemoveEnumMember,
2711                auto_fixable: true,
2712                description: "Remove this enum member".to_string(),
2713                note: None,
2714                available_in_catalogs: None,
2715                suggested_target: None,
2716            }),
2717            IssueAction::SuppressLine(SuppressLineAction {
2718                kind: SuppressLineKind::SuppressLine,
2719                auto_fixable: false,
2720                description: "Suppress with an inline comment above the line".to_string(),
2721                comment: "// fallow-ignore-next-line unused-enum-member".to_string(),
2722                scope: None,
2723            }),
2724        ];
2725        Self {
2726            finding_id: None,
2727            member,
2728            actions,
2729            introduced: None,
2730            effective_severity: None,
2731            reachability_caveats: Vec::new(),
2732        }
2733    }
2734}
2735
2736/// Wire-shape envelope for an [`UnusedMember`] finding consumed under the
2737/// `unused_class_members` key. Same Rust struct as
2738/// [`UnusedEnumMemberFinding`]; the fix action and suppress comment carry
2739/// the class-member kebab-case identifier instead.
2740#[derive(Debug, Clone, Serialize, Deserialize)]
2741#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2742pub struct UnusedClassMemberFinding {
2743    /// The underlying dead-code entry.
2744    #[serde(flatten)]
2745    pub member: UnusedMember,
2746    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2747    /// `~<k>` suffix when several findings of one type share an identity.
2748    /// Line and column are not inputs, so the id survives line shifts,
2749    /// reformats and reorders. A rename of the file or the symbol gives a
2750    /// new id. Absent in output from older versions.
2751    #[serde(default, skip_serializing_if = "Option::is_none")]
2752    pub finding_id: Option<String>,
2753    /// Suggested next steps. Always emitted (possibly empty for
2754    /// forward-compat).
2755    pub actions: Vec<IssueAction>,
2756    /// Type-aware evidence for this exact candidate when requested.
2757    #[serde(default, skip_serializing_if = "Option::is_none")]
2758    pub semantic: Option<SemanticCandidateDecision>,
2759    /// Internal marker for a framework member that the syntactic analysis
2760    /// suppresses, but the semantic pass may promote after proving complete
2761    /// closed-world absence. Never serialized as part of the public finding.
2762    #[serde(skip)]
2763    #[cfg_attr(feature = "schema", schemars(skip))]
2764    pub semantic_only_candidate: bool,
2765    /// Set by the audit pass when this finding is introduced relative to
2766    /// the merge-base.
2767    #[serde(default, skip_serializing_if = "Option::is_none")]
2768    pub introduced: Option<AuditIntroduced>,
2769    /// Gate severity of this finding after `rules` and `overrides[].rules`
2770    /// resolve for its path. CI formats read it for the annotation, SARIF
2771    /// and CodeClimate level. Absent in output from older versions. Not
2772    /// part of the finding identity, baseline keys or fingerprints.
2773    #[serde(
2774        default,
2775        skip_serializing_if = "Option::is_none",
2776        deserialize_with = "deserialize_effective_severity"
2777    )]
2778    pub effective_severity: Option<EffectiveSeverity>,
2779    /// Advisory caveats on the verdict behind this finding. A class member's
2780    /// usage is collected by the same reachability-free member-access walk an
2781    /// enum member's is, so it takes the enum-member rule unchanged: any module
2782    /// this run analyzed incompletely can hold the access that credits it.
2783    /// Sorted, deduplicated, and omitted from the wire when empty. Never gates
2784    /// the finding; it does withhold the `remove-class-member` mutation that
2785    /// the type-aware pass would otherwise open.
2786    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2787    pub reachability_caveats: Vec<ReachabilityCaveat>,
2788}
2789
2790impl UnusedClassMemberFinding {
2791    /// Build the wrapper from a raw [`UnusedMember`]. Class-member fixes
2792    /// are not auto-applied (members can be used via dependency injection
2793    /// or decorators), so `auto_fixable` is `false` and a context note is
2794    /// attached.
2795    #[must_use]
2796    pub fn with_actions(member: UnusedMember) -> Self {
2797        let actions = vec![
2798            IssueAction::Fix(FixAction {
2799                kind: FixActionType::RemoveClassMember,
2800                auto_fixable: false,
2801                description: "Remove this class member".to_string(),
2802                note: Some(
2803                    "Class member may be used via dependency injection or decorators".to_string(),
2804                ),
2805                available_in_catalogs: None,
2806                suggested_target: None,
2807            }),
2808            IssueAction::SuppressLine(SuppressLineAction {
2809                kind: SuppressLineKind::SuppressLine,
2810                auto_fixable: false,
2811                description: "Suppress with an inline comment above the line".to_string(),
2812                comment: "// fallow-ignore-next-line unused-class-member".to_string(),
2813                scope: None,
2814            }),
2815        ];
2816        Self {
2817            finding_id: None,
2818            member,
2819            actions,
2820            semantic: None,
2821            semantic_only_candidate: false,
2822            introduced: None,
2823            effective_severity: None,
2824            reachability_caveats: Vec::new(),
2825        }
2826    }
2827
2828    /// Mark this finding as latent until semantic analysis proves that the
2829    /// framework contract does not apply and no static references exist.
2830    #[must_use]
2831    pub const fn semantic_only_candidate(mut self) -> Self {
2832        self.semantic_only_candidate = true;
2833        self
2834    }
2835
2836    /// Attach the canonical semantic decision and expose the class-member fix
2837    /// only when the API policy granted closed-world eligibility AND this run
2838    /// holds the evidence for the mutation.
2839    ///
2840    /// This is the one code path that RAISES `auto_fixable` on a class member,
2841    /// and it runs in the API layer AFTER the analysis layer stamped the run's
2842    /// caveats, so it asks the gate for the same reason
2843    /// `set_export_semantic_action` does: a closed-world verdict computed
2844    /// over a program the run never fully read must not re-open a removal the
2845    /// incomplete run already withheld. The withheld note names the evidence
2846    /// gap rather than the semantic explanation, which stays readable on the
2847    /// finding's own `semantic` object.
2848    pub fn set_semantic_decision(&mut self, decision: SemanticCandidateDecision) {
2849        let evidence_complete = self.reachability_caveats.is_empty();
2850        if let Some(IssueAction::Fix(action)) = self.actions.first_mut() {
2851            action.auto_fixable = decision.closed_world_eligible && evidence_complete;
2852            action.note = Some(if evidence_complete {
2853                decision.explanation.clone()
2854            } else {
2855                INCOMPLETE_EVIDENCE_NOTE.to_string()
2856            });
2857        }
2858        self.semantic = Some(decision);
2859    }
2860}
2861
2862/// Wire-shape envelope for an [`UnusedMember`] finding consumed under the
2863/// `unused_store_members` key (a Pinia `state` / `getters` / `actions` key, or
2864/// a setup-store returned key, declared but never accessed by any consumer
2865/// project-wide). Same Rust struct as [`UnusedClassMemberFinding`]. Emits only
2866/// a line-level suppress action: there is no safe auto-fix because a store
2867/// member can be accessed reflectively (a Pinia plugin, `store.$onAction`, or
2868/// dynamic dispatch) in ways syntactic analysis cannot see, so removal is a
2869/// behavioral change the user must own.
2870#[derive(Debug, Clone, Serialize, Deserialize)]
2871#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2872pub struct UnusedStoreMemberFinding {
2873    /// The underlying dead-code entry.
2874    #[serde(flatten)]
2875    pub member: UnusedMember,
2876    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
2877    /// `~<k>` suffix when several findings of one type share an identity.
2878    /// Line and column are not inputs, so the id survives line shifts,
2879    /// reformats and reorders. A rename of the file or the symbol gives a
2880    /// new id. Absent in output from older versions.
2881    #[serde(default, skip_serializing_if = "Option::is_none")]
2882    pub finding_id: Option<String>,
2883    /// Suggested next steps. Always emitted (possibly empty for
2884    /// forward-compat).
2885    pub actions: Vec<IssueAction>,
2886    /// Set by the audit pass when this finding is introduced relative to
2887    /// the merge-base.
2888    #[serde(default, skip_serializing_if = "Option::is_none")]
2889    pub introduced: Option<AuditIntroduced>,
2890    /// Gate severity of this finding after `rules` and `overrides[].rules`
2891    /// resolve for its path. CI formats read it for the annotation, SARIF
2892    /// and CodeClimate level. Absent in output from older versions. Not
2893    /// part of the finding identity, baseline keys or fingerprints.
2894    #[serde(
2895        default,
2896        skip_serializing_if = "Option::is_none",
2897        deserialize_with = "deserialize_effective_severity"
2898    )]
2899    pub effective_severity: Option<EffectiveSeverity>,
2900    /// Advisory caveats on the verdict behind this finding. A store member's
2901    /// usage is collected by the same reachability-free member-access walk a
2902    /// class member's is, so it takes the member rule unchanged: any module
2903    /// this run analyzed incompletely can hold the access that credits it.
2904    /// Sorted, deduplicated, and omitted from the wire when empty. There is no
2905    /// mutation here to withhold, because a store member offers none on any
2906    /// surface; this is disclosure only, so a reader deciding by hand is told
2907    /// what the run did not see.
2908    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2909    pub reachability_caveats: Vec<ReachabilityCaveat>,
2910}
2911
2912impl UnusedStoreMemberFinding {
2913    /// Build the wrapper from a raw [`UnusedMember`]. Emits only a line-level
2914    /// suppress action (no auto-fix: store members can be accessed
2915    /// reflectively, so removal is never provably safe).
2916    #[must_use]
2917    pub fn with_actions(member: UnusedMember) -> Self {
2918        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
2919            kind: SuppressLineKind::SuppressLine,
2920            auto_fixable: false,
2921            description: "Suppress with an inline comment above the line".to_string(),
2922            comment: "// fallow-ignore-next-line unused-store-member".to_string(),
2923            scope: None,
2924        })];
2925        Self {
2926            finding_id: None,
2927            member,
2928            actions,
2929            introduced: None,
2930            effective_severity: None,
2931            reachability_caveats: Vec::new(),
2932        }
2933    }
2934}
2935
2936/// Build the `IssueAction` vec for the three `unused_dependencies`,
2937/// `unused_dev_dependencies`, `unused_optional_dependencies` views over the
2938/// same bare [`UnusedDependency`] struct. Each wrapper differs only in the
2939/// `package_json_location` string (`"dependencies"` / `"devDependencies"` /
2940/// `"optionalDependencies"`) baked into the fix-action description and in
2941/// the `suppress_issue_kind` used by the inline-suppress comment. All three
2942/// share the cross-workspace swap (when `dep.used_in_workspaces` is
2943/// non-empty the primary fix flips from `remove-dependency` to
2944/// `move-dependency` because the dep is imported by ANOTHER workspace and
2945/// `fallow fix` cannot safely remove it).
2946fn build_unused_dependency_actions(
2947    dep: &UnusedDependency,
2948    package_json_location: &str,
2949    suppress_issue_kind: &str,
2950) -> Vec<IssueAction> {
2951    let mut actions = Vec::with_capacity(2);
2952    let cross_workspace = !dep.used_in_workspaces.is_empty();
2953    actions.push(if cross_workspace {
2954        IssueAction::Fix(FixAction {
2955            kind: FixActionType::MoveDependency,
2956            auto_fixable: false,
2957            description: "Move this dependency to the workspace package.json that imports it"
2958                .to_string(),
2959            note: Some(
2960                "fallow fix will not remove dependencies that are imported by another workspace"
2961                    .to_string(),
2962            ),
2963            available_in_catalogs: None,
2964            suggested_target: None,
2965        })
2966    } else {
2967        IssueAction::Fix(FixAction {
2968            kind: FixActionType::RemoveDependency,
2969            auto_fixable: true,
2970            description: format!("Remove from {package_json_location} in package.json"),
2971            note: None,
2972            available_in_catalogs: None,
2973            suggested_target: None,
2974        })
2975    });
2976    actions.push(build_ignore_dependencies_suppress_action(
2977        &dep.package_name,
2978        suppress_issue_kind,
2979    ));
2980    actions
2981}
2982
2983/// Build the standard `add-to-config` `ignoreDependencies` suppress action
2984/// for any finding whose primary key is a package name. Used by the four
2985/// dependency-family wrappers (unused / unlisted / type-only / test-only).
2986/// The `_suppress_issue_kind` argument is currently unused; the pre-2.76
2987/// `inject_actions` post-pass also did not embed the issue kind in this
2988/// shape (no inline `// fallow-ignore-next-line ...` comment because the
2989/// finding is anchored at a package.json line, not at a source-file line).
2990fn build_ignore_dependencies_suppress_action(
2991    package_name: &str,
2992    _suppress_issue_kind: &str,
2993) -> IssueAction {
2994    IssueAction::AddToConfig(AddToConfigAction {
2995        kind: AddToConfigKind::AddToConfig,
2996        auto_fixable: false,
2997        description: format!("Add \"{package_name}\" to ignoreDependencies in fallow config"),
2998        config_key: "ignoreDependencies".to_string(),
2999        value: AddToConfigValue::Scalar(package_name.to_string()),
3000        value_schema: Some(
3001            "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items"
3002                .to_string(),
3003        ),
3004    })
3005}
3006
3007/// Wire-shape envelope for an [`UnusedDependency`] finding consumed under
3008/// the `unused_dependencies` key (production deps). Flattens the bare
3009/// finding; the typed `actions` array carries either a `remove-dependency`
3010/// or `move-dependency` primary depending on
3011/// `inner.used_in_workspaces`.
3012#[derive(Debug, Clone, Serialize, Deserialize)]
3013#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3014pub struct UnusedDependencyFinding {
3015    /// The underlying dead-code entry.
3016    #[serde(flatten)]
3017    pub dep: UnusedDependency,
3018    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3019    /// `~<k>` suffix when several findings of one type share an identity.
3020    /// Line and column are not inputs, so the id survives line shifts,
3021    /// reformats and reorders. A rename of the file or the symbol gives a
3022    /// new id. Absent in output from older versions.
3023    #[serde(default, skip_serializing_if = "Option::is_none")]
3024    pub finding_id: Option<String>,
3025    /// Suggested next steps. Always emitted (possibly empty for
3026    /// forward-compat).
3027    pub actions: Vec<IssueAction>,
3028    /// Set by the audit pass when this finding is introduced relative to
3029    /// the merge-base.
3030    #[serde(default, skip_serializing_if = "Option::is_none")]
3031    pub introduced: Option<AuditIntroduced>,
3032    /// Gate severity of this finding after `rules` and `overrides[].rules`
3033    /// resolve for its path. CI formats read it for the annotation, SARIF
3034    /// and CodeClimate level. Absent in output from older versions. Not
3035    /// part of the finding identity, baseline keys or fingerprints.
3036    #[serde(
3037        default,
3038        skip_serializing_if = "Option::is_none",
3039        deserialize_with = "deserialize_effective_severity"
3040    )]
3041    pub effective_severity: Option<EffectiveSeverity>,
3042    /// Advisory caveats on the verdict behind this finding. A dependency is
3043    /// reported unused when NO module in the project imports its specifier,
3044    /// so a module that parsed with errors can hide the import that would
3045    /// have credited the package. Sorted, deduplicated, and omitted from the
3046    /// wire when empty. Never gates the finding, though `fallow fix`
3047    /// withholds the `remove-dependency` write while a caveat stands.
3048    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3049    pub reachability_caveats: Vec<ReachabilityCaveat>,
3050}
3051
3052impl UnusedDependencyFinding {
3053    /// Build the wrapper. Switches the primary fix from `remove-dependency`
3054    /// to `move-dependency` when the dep is imported by another workspace.
3055    #[must_use]
3056    pub fn with_actions(dep: UnusedDependency) -> Self {
3057        let actions = build_unused_dependency_actions(&dep, "dependencies", "unused-dependency");
3058        Self {
3059            finding_id: None,
3060            dep,
3061            actions,
3062            introduced: None,
3063            effective_severity: None,
3064            reachability_caveats: Vec::new(),
3065        }
3066    }
3067}
3068
3069/// Wire-shape envelope for an [`UnusedDependency`] finding consumed under
3070/// the `unused_dev_dependencies` key. Same bare struct as
3071/// [`UnusedDependencyFinding`]; the fix description points at
3072/// `devDependencies` and the suppress comment uses
3073/// `unused-dev-dependency`.
3074#[derive(Debug, Clone, Serialize, Deserialize)]
3075#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3076pub struct UnusedDevDependencyFinding {
3077    /// The underlying dead-code entry.
3078    #[serde(flatten)]
3079    pub dep: UnusedDependency,
3080    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3081    /// `~<k>` suffix when several findings of one type share an identity.
3082    /// Line and column are not inputs, so the id survives line shifts,
3083    /// reformats and reorders. A rename of the file or the symbol gives a
3084    /// new id. Absent in output from older versions.
3085    #[serde(default, skip_serializing_if = "Option::is_none")]
3086    pub finding_id: Option<String>,
3087    /// Suggested next steps. Always emitted (possibly empty for
3088    /// forward-compat).
3089    pub actions: Vec<IssueAction>,
3090    /// Set by the audit pass when this finding is introduced relative to
3091    /// the merge-base.
3092    #[serde(default, skip_serializing_if = "Option::is_none")]
3093    pub introduced: Option<AuditIntroduced>,
3094    /// Gate severity of this finding after `rules` and `overrides[].rules`
3095    /// resolve for its path. CI formats read it for the annotation, SARIF
3096    /// and CodeClimate level. Absent in output from older versions. Not
3097    /// part of the finding identity, baseline keys or fingerprints.
3098    #[serde(
3099        default,
3100        skip_serializing_if = "Option::is_none",
3101        deserialize_with = "deserialize_effective_severity"
3102    )]
3103    pub effective_severity: Option<EffectiveSeverity>,
3104    /// Advisory caveats on the verdict behind this finding. A dependency is
3105    /// reported unused when NO module in the project imports its specifier,
3106    /// so a module that parsed with errors can hide the import that would
3107    /// have credited the package. Sorted, deduplicated, and omitted from the
3108    /// wire when empty. Never gates the finding, though `fallow fix`
3109    /// withholds the `remove-dependency` write while a caveat stands.
3110    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3111    pub reachability_caveats: Vec<ReachabilityCaveat>,
3112}
3113
3114impl UnusedDevDependencyFinding {
3115    /// Build the wrapper.
3116    #[must_use]
3117    pub fn with_actions(dep: UnusedDependency) -> Self {
3118        let actions =
3119            build_unused_dependency_actions(&dep, "devDependencies", "unused-dev-dependency");
3120        Self {
3121            finding_id: None,
3122            dep,
3123            actions,
3124            introduced: None,
3125            effective_severity: None,
3126            reachability_caveats: Vec::new(),
3127        }
3128    }
3129}
3130
3131/// Wire-shape envelope for an [`UnusedDependency`] finding consumed under
3132/// the `unused_optional_dependencies` key. Same bare struct as
3133/// [`UnusedDependencyFinding`]; the fix description points at
3134/// `optionalDependencies`. Reuses the `unused-dependency` suppress
3135/// `IssueKind` because there is no dedicated variant for optional deps.
3136#[derive(Debug, Clone, Serialize, Deserialize)]
3137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3138pub struct UnusedOptionalDependencyFinding {
3139    /// The underlying dead-code entry.
3140    #[serde(flatten)]
3141    pub dep: UnusedDependency,
3142    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3143    /// `~<k>` suffix when several findings of one type share an identity.
3144    /// Line and column are not inputs, so the id survives line shifts,
3145    /// reformats and reorders. A rename of the file or the symbol gives a
3146    /// new id. Absent in output from older versions.
3147    #[serde(default, skip_serializing_if = "Option::is_none")]
3148    pub finding_id: Option<String>,
3149    /// Suggested next steps. Always emitted (possibly empty for
3150    /// forward-compat).
3151    pub actions: Vec<IssueAction>,
3152    /// Set by the audit pass when this finding is introduced relative to
3153    /// the merge-base.
3154    #[serde(default, skip_serializing_if = "Option::is_none")]
3155    pub introduced: Option<AuditIntroduced>,
3156    /// Gate severity of this finding after `rules` and `overrides[].rules`
3157    /// resolve for its path. CI formats read it for the annotation, SARIF
3158    /// and CodeClimate level. Absent in output from older versions. Not
3159    /// part of the finding identity, baseline keys or fingerprints.
3160    #[serde(
3161        default,
3162        skip_serializing_if = "Option::is_none",
3163        deserialize_with = "deserialize_effective_severity"
3164    )]
3165    pub effective_severity: Option<EffectiveSeverity>,
3166    /// Advisory caveats on the verdict behind this finding. A dependency is
3167    /// reported unused when NO module in the project imports its specifier,
3168    /// so a module that parsed with errors can hide the import that would
3169    /// have credited the package. Sorted, deduplicated, and omitted from the
3170    /// wire when empty. Never gates the finding, though `fallow fix`
3171    /// withholds the `remove-dependency` write while a caveat stands.
3172    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3173    pub reachability_caveats: Vec<ReachabilityCaveat>,
3174}
3175
3176impl UnusedOptionalDependencyFinding {
3177    /// Build the wrapper.
3178    #[must_use]
3179    pub fn with_actions(dep: UnusedDependency) -> Self {
3180        let actions =
3181            build_unused_dependency_actions(&dep, "optionalDependencies", "unused-dependency");
3182        Self {
3183            finding_id: None,
3184            dep,
3185            actions,
3186            introduced: None,
3187            effective_severity: None,
3188            reachability_caveats: Vec::new(),
3189        }
3190    }
3191}
3192
3193/// Wire-shape envelope for an [`UnlistedDependency`] finding. Carries an
3194/// `install-dependency` primary (non-auto-fixable) plus the standard
3195/// `ignoreDependencies` config suppress.
3196#[derive(Debug, Clone, Serialize, Deserialize)]
3197#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3198pub struct UnlistedDependencyFinding {
3199    /// The underlying dead-code entry.
3200    #[serde(flatten)]
3201    pub dep: UnlistedDependency,
3202    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3203    /// `~<k>` suffix when several findings of one type share an identity.
3204    /// Line and column are not inputs, so the id survives line shifts,
3205    /// reformats and reorders. A rename of the file or the symbol gives a
3206    /// new id. Absent in output from older versions.
3207    #[serde(default, skip_serializing_if = "Option::is_none")]
3208    pub finding_id: Option<String>,
3209    /// Suggested next steps. Always emitted (possibly empty for
3210    /// forward-compat).
3211    pub actions: Vec<IssueAction>,
3212    /// Set by the audit pass when this finding is introduced relative to
3213    /// the merge-base.
3214    #[serde(default, skip_serializing_if = "Option::is_none")]
3215    pub introduced: Option<AuditIntroduced>,
3216    /// Gate severity of this finding after `rules` and `overrides[].rules`
3217    /// resolve for its path. CI formats read it for the annotation, SARIF
3218    /// and CodeClimate level. Absent in output from older versions. Not
3219    /// part of the finding identity, baseline keys or fingerprints.
3220    #[serde(
3221        default,
3222        skip_serializing_if = "Option::is_none",
3223        deserialize_with = "deserialize_effective_severity"
3224    )]
3225    pub effective_severity: Option<EffectiveSeverity>,
3226}
3227
3228impl UnlistedDependencyFinding {
3229    /// Build the wrapper.
3230    #[must_use]
3231    pub fn with_actions(dep: UnlistedDependency) -> Self {
3232        let actions = vec![
3233            IssueAction::Fix(FixAction {
3234                kind: FixActionType::InstallDependency,
3235                auto_fixable: false,
3236                description: "Add this package to dependencies in package.json".to_string(),
3237                note: Some(
3238                    "Verify this package should be a direct dependency before adding".to_string(),
3239                ),
3240                available_in_catalogs: None,
3241                suggested_target: None,
3242            }),
3243            build_ignore_dependencies_suppress_action(&dep.package_name, "unlisted-dependency"),
3244        ];
3245        Self {
3246            finding_id: None,
3247            dep,
3248            actions,
3249            introduced: None,
3250            effective_severity: None,
3251        }
3252    }
3253}
3254
3255/// Wire-shape envelope for a [`TypeOnlyDependency`] finding. Carries a
3256/// `move-to-dev` primary plus the standard `ignoreDependencies` config
3257/// suppress.
3258#[derive(Debug, Clone, Serialize, Deserialize)]
3259#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3260pub struct TypeOnlyDependencyFinding {
3261    /// The underlying dead-code entry.
3262    #[serde(flatten)]
3263    pub dep: TypeOnlyDependency,
3264    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3265    /// `~<k>` suffix when several findings of one type share an identity.
3266    /// Line and column are not inputs, so the id survives line shifts,
3267    /// reformats and reorders. A rename of the file or the symbol gives a
3268    /// new id. Absent in output from older versions.
3269    #[serde(default, skip_serializing_if = "Option::is_none")]
3270    pub finding_id: Option<String>,
3271    /// Suggested next steps. Always emitted (possibly empty for
3272    /// forward-compat).
3273    pub actions: Vec<IssueAction>,
3274    /// Set by the audit pass when this finding is introduced relative to
3275    /// the merge-base.
3276    #[serde(default, skip_serializing_if = "Option::is_none")]
3277    pub introduced: Option<AuditIntroduced>,
3278    /// Gate severity of this finding after `rules` and `overrides[].rules`
3279    /// resolve for its path. CI formats read it for the annotation, SARIF
3280    /// and CodeClimate level. Absent in output from older versions. Not
3281    /// part of the finding identity, baseline keys or fingerprints.
3282    #[serde(
3283        default,
3284        skip_serializing_if = "Option::is_none",
3285        deserialize_with = "deserialize_effective_severity"
3286    )]
3287    pub effective_severity: Option<EffectiveSeverity>,
3288}
3289
3290impl TypeOnlyDependencyFinding {
3291    /// Build the wrapper.
3292    #[must_use]
3293    pub fn with_actions(dep: TypeOnlyDependency) -> Self {
3294        let actions = vec![
3295            IssueAction::Fix(FixAction {
3296                kind: FixActionType::MoveToDev,
3297                auto_fixable: false,
3298                description: "Move to devDependencies (only type imports are used)".to_string(),
3299                note: Some(
3300                    "Type imports are erased at runtime so this dependency is not needed in production"
3301                        .to_string(),
3302                ),
3303                available_in_catalogs: None,
3304                suggested_target: None,
3305            }),
3306            build_ignore_dependencies_suppress_action(&dep.package_name, "type-only-dependency"),
3307        ];
3308        Self {
3309            finding_id: None,
3310            dep,
3311            actions,
3312            introduced: None,
3313            effective_severity: None,
3314        }
3315    }
3316}
3317
3318/// Wire-shape envelope for a [`TestOnlyDependency`] finding. Carries a
3319/// `move-to-dev` primary (different prose than [`TypeOnlyDependencyFinding`])
3320/// plus the standard `ignoreDependencies` config suppress.
3321#[derive(Debug, Clone, Serialize, Deserialize)]
3322#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3323pub struct TestOnlyDependencyFinding {
3324    /// The underlying dead-code entry.
3325    #[serde(flatten)]
3326    pub dep: TestOnlyDependency,
3327    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3328    /// `~<k>` suffix when several findings of one type share an identity.
3329    /// Line and column are not inputs, so the id survives line shifts,
3330    /// reformats and reorders. A rename of the file or the symbol gives a
3331    /// new id. Absent in output from older versions.
3332    #[serde(default, skip_serializing_if = "Option::is_none")]
3333    pub finding_id: Option<String>,
3334    /// Suggested next steps. Always emitted (possibly empty for
3335    /// forward-compat).
3336    pub actions: Vec<IssueAction>,
3337    /// Set by the audit pass when this finding is introduced relative to
3338    /// the merge-base.
3339    #[serde(default, skip_serializing_if = "Option::is_none")]
3340    pub introduced: Option<AuditIntroduced>,
3341    /// Gate severity of this finding after `rules` and `overrides[].rules`
3342    /// resolve for its path. CI formats read it for the annotation, SARIF
3343    /// and CodeClimate level. Absent in output from older versions. Not
3344    /// part of the finding identity, baseline keys or fingerprints.
3345    #[serde(
3346        default,
3347        skip_serializing_if = "Option::is_none",
3348        deserialize_with = "deserialize_effective_severity"
3349    )]
3350    pub effective_severity: Option<EffectiveSeverity>,
3351}
3352
3353impl TestOnlyDependencyFinding {
3354    /// Build the wrapper.
3355    #[must_use]
3356    pub fn with_actions(dep: TestOnlyDependency) -> Self {
3357        let actions = vec![
3358            IssueAction::Fix(FixAction {
3359                kind: FixActionType::MoveToDev,
3360                auto_fixable: false,
3361                description: "Move to devDependencies (only test files import this)".to_string(),
3362                note: Some(
3363                    "Only test files import this package so it does not need to be a production dependency"
3364                        .to_string(),
3365                ),
3366                available_in_catalogs: None,
3367                suggested_target: None,
3368            }),
3369            build_ignore_dependencies_suppress_action(&dep.package_name, "test-only-dependency"),
3370        ];
3371        Self {
3372            finding_id: None,
3373            dep,
3374            actions,
3375            introduced: None,
3376            effective_severity: None,
3377        }
3378    }
3379}
3380
3381/// Wire-shape envelope for a [`DevDependencyInProduction`] finding. Carries a
3382/// `move-to-prod` primary (the promote-side mirror of
3383/// [`TestOnlyDependencyFinding`]'s `move-to-dev`) plus the standard
3384/// `ignoreDependencies` config suppress.
3385#[derive(Debug, Clone, Serialize, Deserialize)]
3386#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3387pub struct DevDependencyInProductionFinding {
3388    /// The underlying dead-code entry.
3389    #[serde(flatten)]
3390    pub dep: DevDependencyInProduction,
3391    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3392    /// `~<k>` suffix when several findings of one type share an identity.
3393    /// Line and column are not inputs, so the id survives line shifts,
3394    /// reformats and reorders. A rename of the file or the symbol gives a
3395    /// new id. Absent in output from older versions.
3396    #[serde(default, skip_serializing_if = "Option::is_none")]
3397    pub finding_id: Option<String>,
3398    /// Suggested next steps. Always emitted (possibly empty for
3399    /// forward-compat).
3400    pub actions: Vec<IssueAction>,
3401    /// Set by the audit pass when this finding is introduced relative to
3402    /// the merge-base.
3403    #[serde(default, skip_serializing_if = "Option::is_none")]
3404    pub introduced: Option<AuditIntroduced>,
3405    /// Gate severity of this finding after `rules` and `overrides[].rules`
3406    /// resolve for its path. CI formats read it for the annotation, SARIF
3407    /// and CodeClimate level. Absent in output from older versions. Not
3408    /// part of the finding identity, baseline keys or fingerprints.
3409    #[serde(
3410        default,
3411        skip_serializing_if = "Option::is_none",
3412        deserialize_with = "deserialize_effective_severity"
3413    )]
3414    pub effective_severity: Option<EffectiveSeverity>,
3415}
3416
3417impl DevDependencyInProductionFinding {
3418    /// Build the wrapper.
3419    #[must_use]
3420    pub fn with_actions(dep: DevDependencyInProduction) -> Self {
3421        let actions = vec![
3422            IssueAction::Fix(FixAction {
3423                kind: FixActionType::MoveToProd,
3424                auto_fixable: false,
3425                description:
3426                    "Move to dependencies if the deployment installs them (production code imports this)"
3427                        .to_string(),
3428                note: Some(
3429                    "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"
3430                        .to_string(),
3431                ),
3432                available_in_catalogs: None,
3433                suggested_target: None,
3434            }),
3435            build_ignore_dependencies_suppress_action(
3436                &dep.package_name,
3437                "dev-dependency-in-production",
3438            ),
3439        ];
3440        Self {
3441            finding_id: None,
3442            dep,
3443            actions,
3444            introduced: None,
3445            effective_severity: None,
3446        }
3447    }
3448}
3449
3450// ── Catalog / dep-override family ───────────────────────────────
3451//
3452// These six wrappers replace the legacy `inject_actions` post-pass in
3453// `crates/cli/src/report/json.rs` for the catalog and dependency-override
3454// findings. Each `with_actions(...)` builds the typed `actions` array
3455// directly from the inner struct (and any per-call context such as
3456// `config_fixable`), so the wire shape is identical to the pre-2.76
3457// post-pass output but the Rust compiler now owns the action contract.
3458
3459/// Wire-shape envelope for a [`DuplicateExport`] finding. Carries up to
3460/// three actions in position-locked order: an `add-to-config` `ignoreExports`
3461/// snippet (only when `locations[]` carries at least one path) followed by
3462/// the `remove-duplicate` fix and the multi-location suppress.
3463///
3464/// The `add-to-config` action sits at position 0 because the documented
3465/// primary slot points at the safe, non-destructive path: the shadcn /
3466/// Radix / bits-ui namespace-barrel case where every `index.*` reexports
3467/// the directory's neighbours. The `remove-duplicate` fix stays as the
3468/// secondary so consumers that pattern-match on `actions[0].type` for
3469/// "primary fix" never propose deletion of an intentional barrel surface.
3470#[derive(Debug, Clone, Serialize, Deserialize)]
3471#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3472pub struct DuplicateExportFinding {
3473    /// The underlying finding.
3474    #[serde(flatten)]
3475    pub export: DuplicateExport,
3476    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3477    /// `~<k>` suffix when several findings of one type share an identity.
3478    /// Line and column are not inputs, so the id survives line shifts,
3479    /// reformats and reorders. A rename of the file or the symbol gives a
3480    /// new id. Absent in output from older versions.
3481    #[serde(default, skip_serializing_if = "Option::is_none")]
3482    pub finding_id: Option<String>,
3483    /// Suggested next steps. Always emitted (possibly empty for
3484    /// forward-compat).
3485    pub actions: Vec<IssueAction>,
3486    /// Set by the audit pass when this finding is introduced relative to
3487    /// the merge-base.
3488    #[serde(default, skip_serializing_if = "Option::is_none")]
3489    pub introduced: Option<AuditIntroduced>,
3490    /// Gate severity of this finding after `rules` and `overrides[].rules`
3491    /// resolve for its path. CI formats read it for the annotation, SARIF
3492    /// and CodeClimate level. Absent in output from older versions. Not
3493    /// part of the finding identity, baseline keys or fingerprints.
3494    #[serde(
3495        default,
3496        skip_serializing_if = "Option::is_none",
3497        deserialize_with = "deserialize_effective_severity"
3498    )]
3499    pub effective_severity: Option<EffectiveSeverity>,
3500}
3501
3502impl DuplicateExportFinding {
3503    /// Build the wrapper with the `add-to-config` action's `auto_fixable`
3504    /// defaulting to `false`. The CLI's `build_json_with_config_fixable`
3505    /// path layers the actual `config_fixable` signal via
3506    /// [`Self::set_config_fixable`] right before serialization (the
3507    /// fix-applier readiness check lives in `fallow-cli::fix` and is not
3508    /// reachable from the analyzer layer where wrappers are first built).
3509    /// Embedders that build `AnalysisResults` directly and never route
3510    /// through the CLI's JSON path keep the conservative default.
3511    #[must_use]
3512    pub fn with_actions(export: DuplicateExport) -> Self {
3513        let mut actions: Vec<IssueAction> = Vec::with_capacity(3);
3514
3515        if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
3516            actions.push(IssueAction::AddToConfig(AddToConfigAction {
3517                kind: AddToConfigKind::AddToConfig,
3518                auto_fixable: false,
3519                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(),
3520                config_key: "ignoreExports".to_string(),
3521                value: AddToConfigValue::ExportsRules(rules),
3522                value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
3523            }));
3524        }
3525
3526        actions.push(IssueAction::Fix(FixAction {
3527            kind: FixActionType::RemoveDuplicate,
3528            auto_fixable: false,
3529            description: "Keep one canonical export location and remove the others".to_string(),
3530            note: Some(NAMESPACE_BARREL_HINT.to_string()),
3531            available_in_catalogs: None,
3532            suggested_target: None,
3533        }));
3534
3535        actions.push(IssueAction::SuppressLine(SuppressLineAction {
3536            kind: SuppressLineKind::SuppressLine,
3537            auto_fixable: false,
3538            description: "Suppress with an inline comment above the line".to_string(),
3539            comment: "// fallow-ignore-next-line duplicate-export".to_string(),
3540            scope: Some(SuppressLineScope::PerLocation),
3541        }));
3542
3543        Self {
3544            finding_id: None,
3545            export,
3546            actions,
3547            introduced: None,
3548            effective_severity: None,
3549        }
3550    }
3551
3552    /// Update the position-0 `add-to-config` action's `auto_fixable` flag.
3553    /// Idempotent and a no-op when position 0 is not an `add-to-config`
3554    /// action (happens when the finding has no locations). Called by the
3555    /// CLI's JSON serializer with the result of
3556    /// `crate::fix::is_config_fixable` before emitting bytes.
3557    pub fn set_config_fixable(&mut self, fixable: bool) {
3558        if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
3559            action.auto_fixable = fixable;
3560        }
3561    }
3562}
3563
3564/// Build a paste-ready `ignoreExports` config value from a duplicate-export
3565/// finding's locations. Returns one `{ file, exports: ["*"] }` entry per
3566/// distinct file in insertion order. `None` when no locations carry a path.
3567fn build_duplicate_exports_ignore_rules(
3568    export: &DuplicateExport,
3569) -> Option<Vec<IgnoreExportsRule>> {
3570    let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
3571    for loc in &export.locations {
3572        // Normalize separators to forward slashes so pasting the action value
3573        // into `.fallowrc.json` produces a portable rule. On Windows
3574        // `to_string_lossy` preserves backslashes, which the old
3575        // `inject_actions` post-pass implicitly normalized because it read
3576        // the path AFTER `strip_root_prefix` had already run through
3577        // `normalize_uri`; the typed wrapper builds the value before
3578        // serialization, so the normalization has to be explicit here.
3579        let path = loc.path.to_string_lossy().replace('\\', "/");
3580        if path.is_empty() {
3581            continue;
3582        }
3583        if entries.iter().any(|existing| existing.file == path) {
3584            continue;
3585        }
3586        entries.push(IgnoreExportsRule {
3587            file: path,
3588            exports: vec!["*".to_string()],
3589        });
3590    }
3591    if entries.is_empty() {
3592        None
3593    } else {
3594        Some(entries)
3595    }
3596}
3597
3598/// Wire-shape envelope for an [`UnusedCatalogEntry`] finding. Per-instance
3599/// `auto_fixable` flips to `false` when `hardcoded_consumers` is non-empty or
3600/// the source is not `pnpm-workspace.yaml`.
3601#[derive(Debug, Clone, Serialize, Deserialize)]
3602#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3603pub struct UnusedCatalogEntryFinding {
3604    /// The underlying finding.
3605    #[serde(flatten)]
3606    pub entry: UnusedCatalogEntry,
3607    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3608    /// `~<k>` suffix when several findings of one type share an identity.
3609    /// Line and column are not inputs, so the id survives line shifts,
3610    /// reformats and reorders. A rename of the file or the symbol gives a
3611    /// new id. Absent in output from older versions.
3612    #[serde(default, skip_serializing_if = "Option::is_none")]
3613    pub finding_id: Option<String>,
3614    /// Suggested next steps. Always emitted.
3615    pub actions: Vec<IssueAction>,
3616    /// Set by the audit pass when this finding is introduced relative to
3617    /// the merge-base.
3618    #[serde(default, skip_serializing_if = "Option::is_none")]
3619    pub introduced: Option<AuditIntroduced>,
3620    /// Gate severity of this finding after `rules` and `overrides[].rules`
3621    /// resolve for its path. CI formats read it for the annotation, SARIF
3622    /// and CodeClimate level. Absent in output from older versions. Not
3623    /// part of the finding identity, baseline keys or fingerprints.
3624    #[serde(
3625        default,
3626        skip_serializing_if = "Option::is_none",
3627        deserialize_with = "deserialize_effective_severity"
3628    )]
3629    pub effective_severity: Option<EffectiveSeverity>,
3630}
3631
3632impl UnusedCatalogEntryFinding {
3633    /// Build the wrapper. Per-instance `auto_fixable` is `true` only when
3634    /// `hardcoded_consumers` is empty and the source is `pnpm-workspace.yaml`;
3635    /// otherwise `fallow fix` skips the entry to avoid breaking installs or
3636    /// applying YAML edits to Bun `package.json` catalogs.
3637    #[must_use]
3638    pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
3639        let is_pnpm_source = is_pnpm_catalog_source(&entry.path);
3640        let auto_fixable = entry.hardcoded_consumers.is_empty() && is_pnpm_source;
3641        let note = if is_pnpm_source {
3642            Some(
3643                "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing"
3644                    .to_string(),
3645            )
3646        } else {
3647            Some(
3648                "fallow fix only edits pnpm-workspace.yaml catalog entries. Edit Bun package.json catalogs manually."
3649                    .to_string(),
3650            )
3651        };
3652        let mut actions = vec![IssueAction::Fix(FixAction {
3653            kind: FixActionType::RemoveCatalogEntry,
3654            auto_fixable,
3655            description: if is_pnpm_source {
3656                "Remove the entry from pnpm-workspace.yaml".to_string()
3657            } else {
3658                "Remove the entry from the catalog source file manually".to_string()
3659            },
3660            note,
3661            available_in_catalogs: None,
3662            suggested_target: None,
3663        })];
3664        if is_pnpm_source {
3665            actions.push(IssueAction::SuppressLine(SuppressLineAction {
3666                kind: SuppressLineKind::SuppressLine,
3667                auto_fixable: false,
3668                description: "Suppress with a YAML comment above the line".to_string(),
3669                comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
3670                scope: None,
3671            }));
3672        }
3673        Self {
3674            finding_id: None,
3675            entry,
3676            actions,
3677            introduced: None,
3678            effective_severity: None,
3679        }
3680    }
3681}
3682
3683/// Wire-shape envelope for an [`EmptyCatalogGroup`] finding. Carries a
3684/// `remove-empty-catalog-group` primary. YAML-sourced findings also include a
3685/// YAML-comment suppress action.
3686#[derive(Debug, Clone, Serialize, Deserialize)]
3687#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3688pub struct EmptyCatalogGroupFinding {
3689    /// The underlying finding.
3690    #[serde(flatten)]
3691    pub group: EmptyCatalogGroup,
3692    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3693    /// `~<k>` suffix when several findings of one type share an identity.
3694    /// Line and column are not inputs, so the id survives line shifts,
3695    /// reformats and reorders. A rename of the file or the symbol gives a
3696    /// new id. Absent in output from older versions.
3697    #[serde(default, skip_serializing_if = "Option::is_none")]
3698    pub finding_id: Option<String>,
3699    /// Suggested next steps. Always emitted.
3700    pub actions: Vec<IssueAction>,
3701    /// Set by the audit pass when this finding is introduced relative to
3702    /// the merge-base.
3703    #[serde(default, skip_serializing_if = "Option::is_none")]
3704    pub introduced: Option<AuditIntroduced>,
3705    /// Gate severity of this finding after `rules` and `overrides[].rules`
3706    /// resolve for its path. CI formats read it for the annotation, SARIF
3707    /// and CodeClimate level. Absent in output from older versions. Not
3708    /// part of the finding identity, baseline keys or fingerprints.
3709    #[serde(
3710        default,
3711        skip_serializing_if = "Option::is_none",
3712        deserialize_with = "deserialize_effective_severity"
3713    )]
3714    pub effective_severity: Option<EffectiveSeverity>,
3715}
3716
3717impl EmptyCatalogGroupFinding {
3718    /// Build the wrapper.
3719    #[must_use]
3720    pub fn with_actions(group: EmptyCatalogGroup) -> Self {
3721        let auto_fixable = is_pnpm_catalog_source(&group.path);
3722        let mut actions = vec![IssueAction::Fix(FixAction {
3723            kind: FixActionType::RemoveEmptyCatalogGroup,
3724            auto_fixable,
3725            description: if auto_fixable {
3726                "Remove the empty named catalog group from pnpm-workspace.yaml".to_string()
3727            } else {
3728                "Remove the empty named catalog group from the catalog source file manually"
3729                    .to_string()
3730            },
3731            note: Some(if auto_fixable {
3732                "Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
3733                    .to_string()
3734            } else {
3735                "fallow fix only edits pnpm-workspace.yaml catalog groups. Edit Bun package.json catalogs manually."
3736                    .to_string()
3737            }),
3738            available_in_catalogs: None,
3739            suggested_target: None,
3740        })];
3741        if auto_fixable {
3742            actions.push(IssueAction::SuppressLine(SuppressLineAction {
3743                kind: SuppressLineKind::SuppressLine,
3744                auto_fixable: false,
3745                description: "Suppress with a YAML comment above the line".to_string(),
3746                comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
3747                scope: None,
3748            }));
3749        }
3750        Self {
3751            finding_id: None,
3752            group,
3753            actions,
3754            introduced: None,
3755            effective_severity: None,
3756        }
3757    }
3758}
3759
3760fn is_pnpm_catalog_source(path: &Path) -> bool {
3761    path == Path::new(PNPM_WORKSPACE_FILE)
3762}
3763
3764/// Wire-shape envelope for an [`UnresolvedCatalogReference`] finding. The
3765/// primary action at position 0 discriminates on `available_in_catalogs`:
3766/// `add-catalog-entry` when the array is empty (no other catalog declares
3767/// the package), or `update-catalog-reference` when at least one
3768/// alternative exists. When exactly one alternative exists, the action
3769/// also carries `suggested_target` so deterministic agents can land the
3770/// edit without picking from a list.
3771#[derive(Debug, Clone, Serialize, Deserialize)]
3772#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3773pub struct UnresolvedCatalogReferenceFinding {
3774    /// The underlying finding.
3775    #[serde(flatten)]
3776    pub reference: UnresolvedCatalogReference,
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; position 0 is the discriminated
3785    /// primary (see struct docs).
3786    pub actions: Vec<IssueAction>,
3787    /// Set by the audit pass when this finding is introduced relative to
3788    /// the merge-base.
3789    #[serde(default, skip_serializing_if = "Option::is_none")]
3790    pub introduced: Option<AuditIntroduced>,
3791    /// Gate severity of this finding after `rules` and `overrides[].rules`
3792    /// resolve for its path. CI formats read it for the annotation, SARIF
3793    /// and CodeClimate level. Absent in output from older versions. Not
3794    /// part of the finding identity, baseline keys or fingerprints.
3795    #[serde(
3796        default,
3797        skip_serializing_if = "Option::is_none",
3798        deserialize_with = "deserialize_effective_severity"
3799    )]
3800    pub effective_severity: Option<EffectiveSeverity>,
3801}
3802
3803impl UnresolvedCatalogReferenceFinding {
3804    /// Build the wrapper. The discriminator at position 0 is the
3805    /// `add-catalog-entry` vs `update-catalog-reference` pick documented on
3806    /// the struct.
3807    #[must_use]
3808    pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
3809        // Normalize separators to forward slashes so the
3810        // `ignoreCatalogReferences.consumer` action value is portable when
3811        // pasted into a Windows-authored config. See
3812        // `build_duplicate_exports_ignore_rules` for the same pattern.
3813        let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
3814        let primary = catalog_reference_primary_action(&reference);
3815        let fallback = remove_catalog_reference_action();
3816        let suppress = suppress_catalog_reference_action(&reference, consumer_path);
3817
3818        Self {
3819            finding_id: None,
3820            reference,
3821            actions: vec![primary, fallback, suppress],
3822            introduced: None,
3823            effective_severity: None,
3824        }
3825    }
3826}
3827
3828fn catalog_reference_primary_action(reference: &UnresolvedCatalogReference) -> IssueAction {
3829    if reference.available_in_catalogs.is_empty() {
3830        return IssueAction::Fix(FixAction {
3831            kind: FixActionType::AddCatalogEntry,
3832            auto_fixable: false,
3833            description: format!(
3834                "Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
3835                reference.entry_name, reference.catalog_name
3836            ),
3837            note: Some(
3838                "Pin a version that satisfies the consumer's import; no other catalog declares this package today"
3839                    .to_string(),
3840            ),
3841            available_in_catalogs: None,
3842            suggested_target: None,
3843        });
3844    }
3845
3846    let available = reference.available_in_catalogs.clone();
3847    let suggested_target = (available.len() == 1).then(|| available[0].clone());
3848    IssueAction::Fix(FixAction {
3849        kind: FixActionType::UpdateCatalogReference,
3850        auto_fixable: false,
3851        description: format!(
3852            "Switch the reference from `catalog:{}` to a catalog that declares `{}`",
3853            reference.catalog_name, reference.entry_name
3854        ),
3855        note: None,
3856        available_in_catalogs: Some(available),
3857        suggested_target,
3858    })
3859}
3860
3861fn remove_catalog_reference_action() -> IssueAction {
3862    IssueAction::Fix(FixAction {
3863        kind: FixActionType::RemoveCatalogReference,
3864        auto_fixable: false,
3865        description: "Remove the catalog reference and pin a hardcoded version in its place"
3866            .to_string(),
3867        note: Some(
3868            "Use only when neither another catalog declares the package nor the named catalog should grow to include it"
3869                .to_string(),
3870        ),
3871        available_in_catalogs: None,
3872        suggested_target: None,
3873    })
3874}
3875
3876fn suppress_catalog_reference_action(
3877    reference: &UnresolvedCatalogReference,
3878    consumer_path: String,
3879) -> IssueAction {
3880    let mut suppress_value = serde_json::Map::new();
3881    suppress_value.insert(
3882        "package".to_string(),
3883        serde_json::Value::String(reference.entry_name.clone()),
3884    );
3885    suppress_value.insert(
3886        "catalog".to_string(),
3887        serde_json::Value::String(reference.catalog_name.clone()),
3888    );
3889    suppress_value.insert(
3890        "consumer".to_string(),
3891        serde_json::Value::String(consumer_path),
3892    );
3893    IssueAction::AddToConfig(AddToConfigAction {
3894        kind: AddToConfigKind::AddToConfig,
3895        auto_fixable: false,
3896        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(),
3897        config_key: "ignoreCatalogReferences".to_string(),
3898        value: AddToConfigValue::RuleObject(suppress_value),
3899        value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
3900    })
3901}
3902
3903/// Wire-shape envelope for an [`UnusedDependencyOverride`] finding. Carries
3904/// a `remove-dependency-override` primary plus an `add-to-config`
3905/// `ignoreDependencyOverrides` suppress scoped to the target package and
3906/// declaration source.
3907#[derive(Debug, Clone, Serialize, Deserialize)]
3908#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3909pub struct UnusedDependencyOverrideFinding {
3910    /// The underlying finding.
3911    #[serde(flatten)]
3912    pub entry: UnusedDependencyOverride,
3913    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3914    /// `~<k>` suffix when several findings of one type share an identity.
3915    /// Line and column are not inputs, so the id survives line shifts,
3916    /// reformats and reorders. A rename of the file or the symbol gives a
3917    /// new id. Absent in output from older versions.
3918    #[serde(default, skip_serializing_if = "Option::is_none")]
3919    pub finding_id: Option<String>,
3920    /// Suggested next steps. Always emitted.
3921    pub actions: Vec<IssueAction>,
3922    /// Set by the audit pass when this finding is introduced relative to
3923    /// the merge-base.
3924    #[serde(default, skip_serializing_if = "Option::is_none")]
3925    pub introduced: Option<AuditIntroduced>,
3926    /// Gate severity of this finding after `rules` and `overrides[].rules`
3927    /// resolve for its path. CI formats read it for the annotation, SARIF
3928    /// and CodeClimate level. Absent in output from older versions. Not
3929    /// part of the finding identity, baseline keys or fingerprints.
3930    #[serde(
3931        default,
3932        skip_serializing_if = "Option::is_none",
3933        deserialize_with = "deserialize_effective_severity"
3934    )]
3935    pub effective_severity: Option<EffectiveSeverity>,
3936}
3937
3938impl UnusedDependencyOverrideFinding {
3939    /// Build the wrapper.
3940    #[must_use]
3941    pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
3942        let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
3943        actions.push(IssueAction::Fix(FixAction {
3944            kind: FixActionType::RemoveDependencyOverride,
3945            auto_fixable: false,
3946            description: "Remove the package-manager override entry from its declaration source"
3947                .to_string(),
3948            note: Some(
3949                "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)"
3950                    .to_string(),
3951            ),
3952            available_in_catalogs: None,
3953            suggested_target: None,
3954        }));
3955
3956        if let Some(suppress) = build_ignore_dependency_overrides_suppress(
3957            Some(&entry.target_package),
3958            &entry.raw_key,
3959            entry.source,
3960        ) {
3961            actions.push(suppress);
3962        }
3963
3964        Self {
3965            finding_id: None,
3966            entry,
3967            actions,
3968            introduced: None,
3969            effective_severity: None,
3970        }
3971    }
3972}
3973
3974/// Wire-shape envelope for a [`MisconfiguredDependencyOverride`] finding.
3975/// Carries a `fix-dependency-override` primary plus the conditional
3976/// `add-to-config` `ignoreDependencyOverrides` suppress (skipped when both
3977/// `target_package` and `raw_key` are empty, since the rule matcher keys on
3978/// a non-empty package name).
3979#[derive(Debug, Clone, Serialize, Deserialize)]
3980#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3981pub struct MisconfiguredDependencyOverrideFinding {
3982    /// The underlying finding.
3983    #[serde(flatten)]
3984    pub entry: MisconfiguredDependencyOverride,
3985    /// Stable id of this finding: `dc1:<rule>:<16 hex digits>`, with a
3986    /// `~<k>` suffix when several findings of one type share an identity.
3987    /// Line and column are not inputs, so the id survives line shifts,
3988    /// reformats and reorders. A rename of the file or the symbol gives a
3989    /// new id. Absent in output from older versions.
3990    #[serde(default, skip_serializing_if = "Option::is_none")]
3991    pub finding_id: Option<String>,
3992    /// Suggested next steps. Always emitted.
3993    pub actions: Vec<IssueAction>,
3994    /// Set by the audit pass when this finding is introduced relative to
3995    /// the merge-base.
3996    #[serde(default, skip_serializing_if = "Option::is_none")]
3997    pub introduced: Option<AuditIntroduced>,
3998    /// Gate severity of this finding after `rules` and `overrides[].rules`
3999    /// resolve for its path. CI formats read it for the annotation, SARIF
4000    /// and CodeClimate level. Absent in output from older versions. Not
4001    /// part of the finding identity, baseline keys or fingerprints.
4002    #[serde(
4003        default,
4004        skip_serializing_if = "Option::is_none",
4005        deserialize_with = "deserialize_effective_severity"
4006    )]
4007    pub effective_severity: Option<EffectiveSeverity>,
4008}
4009
4010impl MisconfiguredDependencyOverrideFinding {
4011    /// Build the wrapper. The suppress action is omitted when neither
4012    /// `target_package` (set on `EmptyValue` cases) nor `raw_key` provides a
4013    /// non-empty package name; an `ignoreDependencyOverrides` entry with
4014    /// `package: ""` would be silently ignored by the config parser.
4015    #[must_use]
4016    pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
4017        let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
4018        actions.push(IssueAction::Fix(FixAction {
4019            kind: FixActionType::FixDependencyOverride,
4020            auto_fixable: false,
4021            description:
4022                "Fix the package-manager override key or value: invalid entries are rejected or ignored"
4023                    .to_string(),
4024            note: Some(
4025                "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`."
4026                    .to_string(),
4027            ),
4028            available_in_catalogs: None,
4029            suggested_target: None,
4030        }));
4031
4032        if let Some(suppress) = build_ignore_dependency_overrides_suppress(
4033            entry.target_package.as_deref(),
4034            &entry.raw_key,
4035            entry.source,
4036        ) {
4037            actions.push(suppress);
4038        }
4039
4040        Self {
4041            finding_id: None,
4042            entry,
4043            actions,
4044            introduced: None,
4045            effective_severity: None,
4046        }
4047    }
4048}
4049
4050/// Shared `add-to-config` `ignoreDependencyOverrides` builder for the two
4051/// override findings. Returns `None` when no non-empty package name is
4052/// available; the config parser silently drops entries with an empty
4053/// `package` field, so emitting one would be a no-op that misleads agents.
4054fn build_ignore_dependency_overrides_suppress(
4055    target_package: Option<&str>,
4056    raw_key: &str,
4057    source: DependencyOverrideSource,
4058) -> Option<IssueAction> {
4059    let package = target_package
4060        .filter(|s| !s.is_empty())
4061        .or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
4062        .to_string();
4063    let mut value = serde_json::Map::new();
4064    value.insert("package".to_string(), serde_json::Value::String(package));
4065    value.insert(
4066        "source".to_string(),
4067        serde_json::Value::String(source.as_label().to_string()),
4068    );
4069    Some(IssueAction::AddToConfig(AddToConfigAction {
4070        kind: AddToConfigKind::AddToConfig,
4071        auto_fixable: false,
4072        description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
4073        config_key: "ignoreDependencyOverrides".to_string(),
4074        value: AddToConfigValue::RuleObject(value),
4075        value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
4076    }))
4077}
4078
4079// ── The mutation gate, registered once ──────────────────────────
4080//
4081// Every finding whose reachability verdict a lost import edge can distort.
4082// The analysis layer stamps caveats through `set_reachability_caveats`, which
4083// enforces the gate on the finding's actions in the same call; every mutation
4084// surface reads the answer back through `may_auto_apply_mutation`.
4085impl_caveated_finding!(
4086    UnusedFileFinding,
4087    UnusedExportFinding,
4088    UnusedTypeFinding,
4089    UnusedEnumMemberFinding,
4090    UnusedClassMemberFinding,
4091    UnusedStoreMemberFinding,
4092    UnusedDependencyFinding,
4093    UnusedDevDependencyFinding,
4094    UnusedOptionalDependencyFinding,
4095);
4096
4097/// Gate severity of one finding after rule resolution.
4098///
4099/// It is the severity that `rules` and the matching `overrides[].rules` give
4100/// the finding for its path. `--fail-on-issues` raises `warn` to `error`. A
4101/// finding whose rule is `off` is not reported, so there is no `off` value.
4102/// The type is separate from the health `severity` band, which ranks a
4103/// finding and does not gate it.
4104///
4105/// The `fallow dead-code` findings gate fails when a finding is `error`.
4106/// Other gates (regression, stale baseline) decide on their own inputs. Two
4107/// commands differ: the `fallow audit` `new-only` gate fails only on introduced
4108/// findings, so an inherited `error` finding does not fail the audit, and the
4109/// combined command (`fallow` without a subcommand) exits 0 for machine
4110/// formats unless `--fail-on-issues` or `--ci` is set.
4111///
4112/// Complexity findings carry the same type. The `complexity-cyclomatic`,
4113/// `complexity-cognitive` and `complexity-crap` rules set it, and the
4114/// `fallow health` findings gate and the audit verdict read it.
4115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4116#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
4117#[serde(rename_all = "lowercase")]
4118pub enum EffectiveSeverity {
4119    /// The finding fails the run.
4120    Error,
4121    /// The finding is reported and does not fail the run.
4122    Warn,
4123}
4124
4125/// Read an optional [`EffectiveSeverity`] and treat an unknown value as absent.
4126///
4127/// A saved report from a newer version can carry a value this version does not
4128/// know. The renderers then use the rule-based level, as for a report without
4129/// the field, and the envelope still loads.
4130///
4131/// # Errors
4132///
4133/// Returns an error only when the input is not valid JSON-like data.
4134pub fn deserialize_effective_severity<'de, D>(
4135    deserializer: D,
4136) -> Result<Option<EffectiveSeverity>, D::Error>
4137where
4138    D: serde::Deserializer<'de>,
4139{
4140    #[derive(Deserialize)]
4141    #[serde(untagged)]
4142    enum Tolerant {
4143        Known(EffectiveSeverity),
4144        Unknown(serde::de::IgnoredAny),
4145    }
4146    Ok(match Option::<Tolerant>::deserialize(deserializer)? {
4147        Some(Tolerant::Known(severity)) => Some(severity),
4148        Some(Tolerant::Unknown(_)) | None => None,
4149    })
4150}
4151
4152/// A finding wrapper that carries an [`EffectiveSeverity`].
4153///
4154/// The analysis layer writes the value one time after rule resolution. CI
4155/// renderers read it and fall back to the rule-level severity when it is
4156/// absent, for example in a saved report from an older version.
4157pub trait GatedFinding {
4158    /// The gate severity, or `None` when no value was written.
4159    fn effective_severity(&self) -> Option<EffectiveSeverity>;
4160
4161    /// Write the gate severity.
4162    fn set_effective_severity(&mut self, severity: Option<EffectiveSeverity>);
4163}
4164
4165/// Implement [`GatedFinding`] for wrappers with an `effective_severity` field.
4166macro_rules! impl_gated_finding {
4167    ($($finding:ty),+ $(,)?) => {
4168        $(
4169            impl GatedFinding for $finding {
4170                fn effective_severity(&self) -> Option<EffectiveSeverity> {
4171                    self.effective_severity
4172                }
4173
4174                fn set_effective_severity(&mut self, severity: Option<EffectiveSeverity>) {
4175                    self.effective_severity = severity;
4176                }
4177            }
4178        )+
4179    };
4180}
4181
4182impl_gated_finding!(
4183    UnusedFileFinding,
4184    PrivateTypeLeakFinding,
4185    DeprecatedExportInUseFinding,
4186    UnresolvedImportFinding,
4187    CircularDependencyFinding,
4188    ReExportCycleFinding,
4189    PackageCycleFinding,
4190    BoundaryViolationFinding,
4191    BoundaryCoverageViolationFinding,
4192    BoundaryCallViolationFinding,
4193    UnusedExportFinding,
4194    UnusedTypeFinding,
4195    InvalidClientExportFinding,
4196    MixedClientServerBarrelFinding,
4197    MisplacedDirectiveFinding,
4198    UnprovidedInjectFinding,
4199    UnusedServerActionFinding,
4200    UnusedLoadDataKeyFinding,
4201    UnrenderedComponentFinding,
4202    UnusedComponentPropFinding,
4203    AbsentComponentPropFinding,
4204    UnusedComponentEmitFinding,
4205    UnusedSvelteEventFinding,
4206    UnusedComponentInputFinding,
4207    UnusedComponentOutputFinding,
4208    RouteCollisionFinding,
4209    DynamicSegmentNameConflictFinding,
4210    UnusedEnumMemberFinding,
4211    UnusedClassMemberFinding,
4212    UnusedStoreMemberFinding,
4213    UnusedDependencyFinding,
4214    UnusedDevDependencyFinding,
4215    UnusedOptionalDependencyFinding,
4216    UnlistedDependencyFinding,
4217    TypeOnlyDependencyFinding,
4218    TestOnlyDependencyFinding,
4219    DevDependencyInProductionFinding,
4220    DuplicateExportFinding,
4221    UnusedCatalogEntryFinding,
4222    EmptyCatalogGroupFinding,
4223    UnresolvedCatalogReferenceFinding,
4224    UnusedDependencyOverrideFinding,
4225    MisconfiguredDependencyOverrideFinding,
4226    PropDrillingChainFinding,
4227    ThinWrapperFinding,
4228    DuplicatePropShapeFinding,
4229    crate::results::StaleSuppression,
4230);
4231
4232// ── Position-0 invariant golden tests ───────────────────────────
4233//
4234// These tests document the load-bearing position-0 semantics that flow
4235// downstream into the GitHub Action / GitLab CI jq scripts, the MCP server
4236// `actions[0].type` pattern-match, and the VS Code LSP code-action
4237// rendering. Snapshot tests assert structural equality; these named tests
4238// document WHY position 0 has a specific value, so a future refactor that
4239// re-orders actions tells you what broke instead of just "the snapshot
4240// changed".
4241#[cfg(test)]
4242mod caveat_tokens {
4243    use super::*;
4244
4245    /// The token-side helpers must render the same words as the typed ones, so
4246    /// one finding reads identically whether a surface holds the findings or
4247    /// re-reads them off a serialized envelope.
4248    #[test]
4249    fn token_labels_match_the_typed_labels() {
4250        let typed = [
4251            ReachabilityCaveat::IncompleteFileAnalysis,
4252            ReachabilityCaveat::IncompleteImportGraph,
4253        ];
4254        let tokens: Vec<&str> = typed.iter().map(|c| c.token()).collect();
4255
4256        assert_eq!(
4257            caveat_labels_for_tokens(tokens.iter().copied()),
4258            caveat_labels(&typed)
4259        );
4260        assert_eq!(
4261            caveat_suffix_for_tokens(tokens.iter().copied()),
4262            caveat_suffix(&typed)
4263        );
4264    }
4265
4266    #[test]
4267    fn no_tokens_means_nothing_to_say() {
4268        assert_eq!(caveat_labels_for_tokens(std::iter::empty()), None);
4269        assert_eq!(caveat_suffix_for_tokens(std::iter::empty()), None);
4270    }
4271
4272    /// The CI review formats can only see the rendered description, so the
4273    /// recogniser and the renderer have to stay one pair. A description with
4274    /// no caveat must not match, or the review formats would withhold the
4275    /// suggestion block on every finding in a clean run.
4276    #[test]
4277    fn a_rendered_suffix_is_recognised_by_the_marker() {
4278        for caveats in [
4279            &[ReachabilityCaveat::IncompleteImportGraph][..],
4280            &[
4281                ReachabilityCaveat::IncompleteFileAnalysis,
4282                ReachabilityCaveat::IncompleteImportGraph,
4283            ][..],
4284        ] {
4285            let suffix = caveat_suffix(caveats).expect("a caveat renders a suffix");
4286            assert!(
4287                description_carries_caveat(&format!("Something is never referenced{suffix}")),
4288                "the marker must match what caveat_suffix writes: {suffix}"
4289            );
4290        }
4291        assert!(
4292            description_carries_caveat(&format!(
4293                "Something is never referenced{}",
4294                caveat_suffix_for_tokens(["some-future-cause"]).expect("token suffix")
4295            )),
4296            "the token-side renderer writes the same marker"
4297        );
4298        assert!(
4299            !description_carries_caveat("Class member 'Widget.helper' is never referenced"),
4300            "a clean description must not read as caveated"
4301        );
4302    }
4303
4304    /// A caveat is a RUN-level condition covering several ways a file goes
4305    /// unread: a degraded parse, an unreadable file, and three kinds of file
4306    /// discovery skipped before opening. A message naming only the parse case
4307    /// told a reader whose run was degraded by the size guard to go fix parse
4308    /// errors that do not exist, which is the same overclaiming the caveat
4309    /// itself exists to prevent.
4310    #[test]
4311    fn no_caveat_message_names_a_single_cause() {
4312        for caveat in [
4313            ReachabilityCaveat::IncompleteFileAnalysis,
4314            ReachabilityCaveat::IncompleteImportGraph,
4315        ] {
4316            let message = caveat.message();
4317            assert!(
4318                !message.contains("parse cleanly") && !message.contains("parse error"),
4319                "{} names the parse cause alone, but a size-skipped or unreadable \
4320                 file reaches the same caveat: {message}",
4321                caveat.token()
4322            );
4323            assert!(
4324                message.contains("workspace_diagnostics"),
4325                "{} must point at the list that names the actual files: {message}",
4326                caveat.token()
4327            );
4328        }
4329    }
4330
4331    /// The value set is open. A token a consumer build does not recognise still
4332    /// means the evidence is incomplete, so it must survive into the rendered
4333    /// hedge rather than being dropped back into a confident-looking finding.
4334    #[test]
4335    fn an_unrecognised_token_still_renders_as_a_caveat() {
4336        let suffix = caveat_suffix_for_tokens(["some-future-cause"])
4337            .expect("an unknown token is still a caveat");
4338
4339        assert_eq!(suffix, " (caveat: some future cause)");
4340    }
4341}
4342
4343/// The gate, pinned as one property across every finding type rather than as
4344/// one test per mutation surface.
4345///
4346/// Three separate reviewers found three separate mutation paths that had never
4347/// learned about the caveat, because each earlier round fixed the door it
4348/// found. These tests assert the invariant itself: for every dead-code finding
4349/// that can carry a caveat, a caveated finding exposes NO auto-fixable action,
4350/// and an uncaveated one is untouched. Adding a caveated finding type without
4351/// registering it in `impl_caveated_finding!` fails to compile at the
4352/// `set_reachability_caveats` call the annotation pass makes; adding one that
4353/// exposes an auto-fixable mutation and never gets annotated is what
4354/// `every_auto_fixable_dead_code_mutation_is_gated` catches.
4355#[cfg(test)]
4356mod mutation_gate {
4357    use super::*;
4358    use crate::extract::MemberKind;
4359    use crate::results::DependencyLocation;
4360    use std::path::PathBuf;
4361
4362    const BOTH: [ReachabilityCaveat; 2] = [
4363        ReachabilityCaveat::IncompleteFileAnalysis,
4364        ReachabilityCaveat::IncompleteImportGraph,
4365    ];
4366
4367    fn export(name: &str) -> UnusedExport {
4368        UnusedExport {
4369            path: PathBuf::from("/p/src/mod.ts"),
4370            export_name: name.to_string(),
4371            is_type_only: false,
4372            line: 1,
4373            col: 0,
4374            span_start: 0,
4375            is_re_export: false,
4376            deprecated: false,
4377            deprecated_reason: None,
4378        }
4379    }
4380
4381    fn member(name: &str) -> UnusedMember {
4382        UnusedMember {
4383            path: PathBuf::from("/p/src/mod.ts"),
4384            parent_name: "Color".to_string(),
4385            member_name: name.to_string(),
4386            kind: MemberKind::EnumMember,
4387            line: 2,
4388            col: 2,
4389        }
4390    }
4391
4392    fn class_member(name: &str) -> UnusedMember {
4393        UnusedMember {
4394            parent_name: "Widget".to_string(),
4395            kind: MemberKind::ClassMethod,
4396            ..member(name)
4397        }
4398    }
4399
4400    fn store_member(name: &str) -> UnusedMember {
4401        UnusedMember {
4402            parent_name: "useCounterStore".to_string(),
4403            kind: MemberKind::StoreMember,
4404            ..member(name)
4405        }
4406    }
4407
4408    fn dependency(name: &str) -> UnusedDependency {
4409        UnusedDependency {
4410            package_name: name.to_string(),
4411            location: DependencyLocation::Dependencies,
4412            path: PathBuf::from("/p/package.json"),
4413            line: 5,
4414            used_in_workspaces: Vec::new(),
4415            declared_and_imported_in: Vec::new(),
4416        }
4417    }
4418
4419    /// One finding type: its name, the uncaveated finding, and the same
4420    /// finding after the annotation pass stamped a caveat on it.
4421    type GatedPair = (&'static str, Box<dyn Gated>, Box<dyn Gated>);
4422
4423    /// Every caveated finding type, boxed behind the one question the mutation
4424    /// surfaces ask.
4425    fn every_finding_type() -> Vec<GatedPair> {
4426        fn pair<T: Gated + Clone + 'static>(name: &'static str, clean: T) -> GatedPair {
4427            let mut caveated = clean.clone();
4428            caveated.stamp(BOTH.to_vec());
4429            (name, Box::new(clean), Box::new(caveated))
4430        }
4431        vec![
4432            pair(
4433                "unused_files",
4434                UnusedFileFinding::with_actions(UnusedFile {
4435                    path: PathBuf::from("/p/src/orphan.ts"),
4436                }),
4437            ),
4438            pair(
4439                "unused_exports",
4440                UnusedExportFinding::with_actions(export("helper")),
4441            ),
4442            pair(
4443                "unused_types",
4444                UnusedTypeFinding::with_actions(export("Shape")),
4445            ),
4446            pair(
4447                "unused_enum_members",
4448                UnusedEnumMemberFinding::with_actions(member("Blue")),
4449            ),
4450            pair(
4451                "unused_class_members",
4452                UnusedClassMemberFinding::with_actions(class_member("legacyMethod")),
4453            ),
4454            pair(
4455                "unused_store_members",
4456                UnusedStoreMemberFinding::with_actions(store_member("onlyUsedInBigFile")),
4457            ),
4458            pair(
4459                "unused_dependencies",
4460                UnusedDependencyFinding::with_actions(dependency("lodash")),
4461            ),
4462            pair(
4463                "unused_dev_dependencies",
4464                UnusedDevDependencyFinding::with_actions(dependency("vitest")),
4465            ),
4466            pair(
4467                "unused_optional_dependencies",
4468                UnusedOptionalDependencyFinding::with_actions(dependency("fsevents")),
4469            ),
4470        ]
4471    }
4472
4473    /// Erases the finding type down to what a mutation surface needs: the gate,
4474    /// the actions it gates, and the annotation-pass write.
4475    trait Gated {
4476        fn actions(&self) -> &[IssueAction];
4477        fn gate_allows_mutation(&self) -> bool;
4478        fn stamp(&mut self, caveats: Vec<ReachabilityCaveat>);
4479    }
4480
4481    impl<T: MutationEvidence + CaveatedFinding + HasActions> Gated for T {
4482        fn actions(&self) -> &[IssueAction] {
4483            HasActions::actions(self)
4484        }
4485        fn gate_allows_mutation(&self) -> bool {
4486            self.may_auto_apply_mutation()
4487        }
4488        fn stamp(&mut self, caveats: Vec<ReachabilityCaveat>) {
4489            self.set_reachability_caveats(caveats);
4490        }
4491    }
4492
4493    trait HasActions {
4494        fn actions(&self) -> &[IssueAction];
4495    }
4496
4497    macro_rules! has_actions {
4498        ($($ty:ty),+ $(,)?) => { $( impl HasActions for $ty {
4499            fn actions(&self) -> &[IssueAction] { &self.actions }
4500        } )+ };
4501    }
4502    has_actions!(
4503        UnusedFileFinding,
4504        UnusedExportFinding,
4505        UnusedTypeFinding,
4506        UnusedEnumMemberFinding,
4507        UnusedClassMemberFinding,
4508        UnusedStoreMemberFinding,
4509        UnusedDependencyFinding,
4510        UnusedDevDependencyFinding,
4511        UnusedOptionalDependencyFinding,
4512    );
4513
4514    /// THE property. Not "the CLI withholds it" or "the LSP hides it": no
4515    /// finding whose evidence the run itself flagged may advertise an
4516    /// automatically applicable mutation, whichever surface is reading.
4517    #[test]
4518    fn every_auto_fixable_dead_code_mutation_is_gated() {
4519        for (name, _clean, caveated) in every_finding_type() {
4520            assert!(
4521                !caveated.gate_allows_mutation(),
4522                "{name}: a stamped finding must fail the gate"
4523            );
4524            for action in caveated.actions() {
4525                assert!(
4526                    !action.is_auto_fixable(),
4527                    "{name}: a caveated finding still advertises an auto-fixable action, so an \
4528                     agent following the documented actions contract would plan a removal \
4529                     `fallow fix` refuses"
4530                );
4531            }
4532        }
4533    }
4534
4535    /// The other half, and the one a blunt fix breaks: the gate must not turn
4536    /// every finding into a manual one. A run that read every file it
4537    /// discovered keeps exactly the behavior it had.
4538    #[test]
4539    fn an_uncaveated_finding_keeps_its_auto_fix() {
4540        let auto_fixable_types = [
4541            "unused_exports",
4542            "unused_types",
4543            "unused_enum_members",
4544            "unused_dependencies",
4545            "unused_dev_dependencies",
4546            "unused_optional_dependencies",
4547        ];
4548        for (name, clean, _caveated) in every_finding_type() {
4549            assert!(
4550                clean.gate_allows_mutation(),
4551                "{name}: a finding with no caveat must pass the gate"
4552            );
4553            if auto_fixable_types.contains(&name) {
4554                assert!(
4555                    clean.actions().iter().any(IssueAction::is_auto_fixable),
4556                    "{name}: the gate must not withhold a mutation the run has the evidence for"
4557                );
4558            }
4559        }
4560    }
4561
4562    /// The caveat is advisory about the FINDING and decisive only about the
4563    /// MUTATION: the actions array keeps its shape so a consumer reading
4564    /// `actions[0].type` is unaffected, and the suppress alternative stays.
4565    #[test]
4566    fn the_gate_downgrades_a_mutation_without_removing_it() {
4567        let clean = UnusedExportFinding::with_actions(export("helper"));
4568        let mut caveated = clean.clone();
4569        caveated.set_reachability_caveats(BOTH.to_vec());
4570
4571        assert_eq!(caveated.actions.len(), clean.actions.len());
4572        let IssueAction::Fix(fix) = &caveated.actions[0] else {
4573            panic!("position 0 stays the fix action");
4574        };
4575        assert!(!fix.auto_fixable);
4576        assert_eq!(
4577            fix.note.as_deref(),
4578            Some(INCOMPLETE_EVIDENCE_NOTE),
4579            "the withheld action says why in its own note, not only in a sibling array"
4580        );
4581    }
4582
4583    /// A pre-existing note is context the user still needs (the re-export
4584    /// warning names a public-API risk the caveat says nothing about), so the
4585    /// gate appends rather than overwrites.
4586    #[test]
4587    fn a_gated_mutation_keeps_the_note_it_already_had() {
4588        let mut re_export = export("helper");
4589        re_export.is_re_export = true;
4590        let mut finding = UnusedExportFinding::with_actions(re_export);
4591        finding.set_reachability_caveats(vec![ReachabilityCaveat::IncompleteImportGraph]);
4592
4593        let IssueAction::Fix(fix) = &finding.actions[0] else {
4594            panic!("position 0 stays the fix action");
4595        };
4596        let note = fix.note.as_deref().expect("note present");
4597        assert!(note.contains("public API"), "the original note survives");
4598        assert!(
4599            note.contains("Evidence is incomplete"),
4600            "the caveat is added"
4601        );
4602    }
4603
4604    /// The gate is complete only if every finding type that can ever expose an
4605    /// auto-fixable mutation is in it. The one member type deliberately left
4606    /// out is safe for a different reason, and this pins that reason rather
4607    /// than trusting a comment: a store member exposes no fix action at all,
4608    /// because reflective access via a Pinia plugin or `$onAction` is
4609    /// invisible to syntactic analysis, so no evidence this run could gather
4610    /// would open the removal. If it ever ships one, it needs
4611    /// `reachability_caveats` and a row in `impl_caveated_finding!` first.
4612    ///
4613    /// A class member used to sit here on the weaker argument that its removal
4614    /// STARTS withheld. That argument covered only the syntactic finding: the
4615    /// type-aware sidecar reopens the removal through
4616    /// [`UnusedClassMemberFinding::set_semantic_decision`], and the review
4617    /// formats rendered a one-click commit for it regardless of
4618    /// `auto_fixable`. It is inside the gate now, so the assertion here is
4619    /// only that the SYNTACTIC finding still ships no auto-fix; the reopening
4620    /// path is pinned by `a_complete_semantic_verdict_cannot_reopen_a_
4621    /// caveated_class_member`.
4622    #[test]
4623    fn a_store_member_exposes_no_mutation_at_all() {
4624        let store = UnusedStoreMemberFinding::with_actions(member("total"));
4625        assert!(
4626            !store.actions.iter().any(IssueAction::is_auto_fixable),
4627            "a store member must expose no automatically applicable mutation"
4628        );
4629        assert!(
4630            !store
4631                .actions
4632                .iter()
4633                .any(|action| matches!(action, IssueAction::Fix(_))),
4634            "and no fix action at all"
4635        );
4636
4637        let class = UnusedClassMemberFinding::with_actions(class_member("helper"));
4638        assert!(
4639            !class.actions.iter().any(IssueAction::is_auto_fixable),
4640            "a class member's syntactic removal stays withheld until semantic evidence opens it"
4641        );
4642    }
4643
4644    /// The semantic pass runs after the annotation pass and is the only code
4645    /// path that RAISES `auto_fixable`. A `Complete` verdict must not re-open a
4646    /// mutation the incomplete run already withheld.
4647    #[test]
4648    fn a_complete_semantic_verdict_cannot_reopen_a_caveated_mutation() {
4649        use crate::semantic::{
4650            SemanticCandidateDecision, SemanticCandidateDecisionKind, SemanticCompleteness,
4651            SemanticNamespace, SemanticSymbol,
4652        };
4653
4654        let complete_negative = || SemanticCandidateDecision {
4655            query_id: 0,
4656            subject: SemanticSymbol {
4657                path: PathBuf::from("/p/src/mod.ts"),
4658                namespace: SemanticNamespace::Value,
4659                declaration_kind: "function".to_string(),
4660                exported_name: "helper".to_string(),
4661                local_name: "helper".to_string(),
4662                owner: None,
4663                line: 1,
4664                col: 0,
4665            },
4666            decision: SemanticCandidateDecisionKind::ConfirmedNoStaticReferences,
4667            status: SemanticCompleteness::Complete,
4668            owning_projects: Vec::new(),
4669            evidence: Vec::new(),
4670            contract: None,
4671            framework_contract: None,
4672            closed_world_eligible: false,
4673            edit_guard: None,
4674            reason_code: None,
4675            explanation: String::new(),
4676            actions: Vec::new(),
4677            total_evidence_count: 0,
4678            truncated: false,
4679            omissions: Vec::new(),
4680        };
4681
4682        let mut clean = UnusedExportFinding::with_actions(export("helper"));
4683        clean.set_semantic_decision(complete_negative());
4684        assert!(
4685            clean.actions.iter().any(IssueAction::is_auto_fixable),
4686            "a complete negative verdict on a clean run still enables the fix"
4687        );
4688
4689        let mut caveated = UnusedExportFinding::with_actions(export("helper"));
4690        caveated.set_reachability_caveats(vec![ReachabilityCaveat::IncompleteImportGraph]);
4691        caveated.set_semantic_decision(complete_negative());
4692        assert!(
4693            !caveated.actions.iter().any(IssueAction::is_auto_fixable),
4694            "the semantic pass must ask the gate too"
4695        );
4696    }
4697
4698    /// The class-member twin, on its own eligibility flag. `closed_world_
4699    /// eligible` is proved over the program the sidecar could see, which is
4700    /// the program this run parsed; a member whose only call site sits in a
4701    /// file the run never opened is absent from that world for the same reason
4702    /// it is absent from the syntactic verdict, so a `true` here is not
4703    /// evidence the run lacks.
4704    #[test]
4705    fn a_complete_semantic_verdict_cannot_reopen_a_caveated_class_member() {
4706        use crate::semantic::{
4707            SemanticCandidateDecision, SemanticCandidateDecisionKind, SemanticCompleteness,
4708            SemanticNamespace, SemanticSymbol,
4709        };
4710
4711        let eligible = || SemanticCandidateDecision {
4712            query_id: 0,
4713            subject: SemanticSymbol {
4714                path: PathBuf::from("/p/src/mod.ts"),
4715                namespace: SemanticNamespace::Value,
4716                declaration_kind: "method".to_string(),
4717                exported_name: "Widget".to_string(),
4718                local_name: "legacyMethod".to_string(),
4719                owner: Some("Widget".to_string()),
4720                line: 2,
4721                col: 2,
4722            },
4723            decision: SemanticCandidateDecisionKind::ConfirmedNoStaticReferences,
4724            status: SemanticCompleteness::Complete,
4725            owning_projects: Vec::new(),
4726            evidence: Vec::new(),
4727            contract: None,
4728            framework_contract: None,
4729            closed_world_eligible: true,
4730            edit_guard: None,
4731            reason_code: None,
4732            explanation: "closed world proved".to_string(),
4733            actions: Vec::new(),
4734            total_evidence_count: 0,
4735            truncated: false,
4736            omissions: Vec::new(),
4737        };
4738
4739        let mut clean = UnusedClassMemberFinding::with_actions(class_member("legacyMethod"));
4740        clean.set_semantic_decision(eligible());
4741        assert!(
4742            clean.actions.iter().any(IssueAction::is_auto_fixable),
4743            "a closed-world verdict on a run that read every file still opens the removal"
4744        );
4745
4746        let mut caveated = UnusedClassMemberFinding::with_actions(class_member("legacyMethod"));
4747        caveated.set_reachability_caveats(vec![ReachabilityCaveat::IncompleteImportGraph]);
4748        caveated.set_semantic_decision(eligible());
4749        assert!(
4750            !caveated.actions.iter().any(IssueAction::is_auto_fixable),
4751            "the class-member semantic pass must ask the gate too"
4752        );
4753        let IssueAction::Fix(fix) = &caveated.actions[0] else {
4754            panic!("position 0 stays the fix action");
4755        };
4756        assert_eq!(
4757            fix.note.as_deref(),
4758            Some(INCOMPLETE_EVIDENCE_NOTE),
4759            "the withheld action says why, rather than repeating a closed-world explanation \
4760             computed over a program the run did not fully read"
4761        );
4762    }
4763}
4764
4765#[cfg(test)]
4766mod position_0_invariants {
4767    use super::*;
4768    use crate::output::FixActionType;
4769    use crate::results::{DependencyOverrideSource, DuplicateLocation};
4770    use std::path::PathBuf;
4771
4772    /// Helper: extract the kebab-case `type` discriminant from an
4773    /// [`IssueAction`] at a specific position. Returns `None` when the
4774    /// position is out of bounds or the action shape lacks a discriminant
4775    /// (today every variant has one).
4776    fn action_type(action: &IssueAction) -> &'static str {
4777        match action {
4778            IssueAction::Fix(fix) => match fix.kind {
4779                FixActionType::RemoveExport => "remove-export",
4780                FixActionType::DeleteFile => "delete-file",
4781                FixActionType::RemoveDependency => "remove-dependency",
4782                FixActionType::MoveDependency => "move-dependency",
4783                FixActionType::RemoveEnumMember => "remove-enum-member",
4784                FixActionType::RemoveClassMember => "remove-class-member",
4785                FixActionType::ResolveImport => "resolve-import",
4786                FixActionType::InstallDependency => "install-dependency",
4787                FixActionType::RemoveDuplicate => "remove-duplicate",
4788                FixActionType::MoveToDev => "move-to-dev",
4789                FixActionType::MoveToProd => "move-to-prod",
4790                FixActionType::RefactorCycle => "refactor-cycle",
4791                FixActionType::RefactorReExportCycle => "refactor-re-export-cycle",
4792                FixActionType::RefactorBoundary => "refactor-boundary",
4793                FixActionType::ExportType => "export-type",
4794                FixActionType::MigrateDeprecatedExport => "migrate-deprecated-export",
4795                FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
4796                FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
4797                FixActionType::UpdateCatalogReference => "update-catalog-reference",
4798                FixActionType::AddCatalogEntry => "add-catalog-entry",
4799                FixActionType::RemoveCatalogReference => "remove-catalog-reference",
4800                FixActionType::RemoveDependencyOverride => "remove-dependency-override",
4801                FixActionType::FixDependencyOverride => "fix-dependency-override",
4802                FixActionType::ResolvePolicyViolation => "resolve-policy-violation",
4803                FixActionType::MoveToServerModule => "move-to-server-module",
4804                FixActionType::SplitMixedBarrel => "split-mixed-barrel",
4805                FixActionType::HoistDirective => "hoist-directive",
4806                FixActionType::WireServerAction => "wire-server-action",
4807                FixActionType::ProvideInject => "provide-inject",
4808                FixActionType::UseLoadData => "use-load-data",
4809                FixActionType::RenderComponent => "render-component",
4810                FixActionType::UseComponentProp => "use-component-prop",
4811                FixActionType::ReviewComponentProp => "review-component-prop",
4812                FixActionType::EmitComponentEvent => "emit-component-event",
4813                FixActionType::WireSvelteEvent => "wire-svelte-event",
4814                FixActionType::ResolveRouteCollision => "resolve-route-collision",
4815                FixActionType::ResolveDynamicSegmentNameConflict => {
4816                    "resolve-dynamic-segment-name-conflict"
4817                }
4818                FixActionType::AddSuppressionReason => "add-suppression-reason",
4819                FixActionType::RemoveStaleSuppression => "remove-stale-suppression",
4820            },
4821            IssueAction::SuppressLine(_) => "suppress-line",
4822            IssueAction::SuppressFile(_) => "suppress-file",
4823            IssueAction::AddToConfig(_) => "add-to-config",
4824        }
4825    }
4826
4827    fn assert_manual_fix_then_suppress(
4828        actions: &[IssueAction],
4829        primary_type: &str,
4830        suppress_comment: &str,
4831    ) {
4832        assert_eq!(actions.len(), 2);
4833        assert_eq!(action_type(&actions[0]), primary_type);
4834        let IssueAction::Fix(primary) = &actions[0] else {
4835            panic!("position-0 should be a manual fix action");
4836        };
4837        assert!(!primary.auto_fixable);
4838        assert!(primary.note.is_some());
4839        assert_eq!(action_type(&actions[1]), "suppress-line");
4840        let IssueAction::SuppressLine(suppress) = &actions[1] else {
4841            panic!("position-1 should be a suppress-line action");
4842        };
4843        assert_eq!(suppress.comment, suppress_comment);
4844    }
4845
4846    #[test]
4847    fn pnpm_catalog_entry_action_is_auto_fixable() {
4848        let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
4849            entry_name: "unused".to_string(),
4850            catalog_name: "default".to_string(),
4851            path: PathBuf::from("pnpm-workspace.yaml"),
4852            line: 3,
4853            hardcoded_consumers: vec![],
4854        });
4855
4856        let IssueAction::Fix(fix) = &finding.actions[0] else {
4857            panic!("position-0 should be a fix action");
4858        };
4859        assert!(fix.auto_fixable);
4860        assert_eq!(finding.actions.len(), 2);
4861        assert_eq!(action_type(&finding.actions[1]), "suppress-line");
4862    }
4863
4864    #[test]
4865    fn bun_package_json_catalog_entry_action_is_manual_only() {
4866        let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
4867            entry_name: "unused".to_string(),
4868            catalog_name: "default".to_string(),
4869            path: PathBuf::from("package.json"),
4870            line: 4,
4871            hardcoded_consumers: vec![],
4872        });
4873
4874        let IssueAction::Fix(fix) = &finding.actions[0] else {
4875            panic!("position-0 should be a fix action");
4876        };
4877        assert!(!fix.auto_fixable);
4878        assert!(fix.description.contains("manually"));
4879        assert_eq!(finding.actions.len(), 1);
4880    }
4881
4882    #[test]
4883    fn bun_package_json_empty_catalog_group_action_is_manual_only() {
4884        let finding = EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
4885            catalog_name: "empty".to_string(),
4886            path: PathBuf::from("package.json"),
4887            line: 4,
4888        });
4889
4890        let IssueAction::Fix(fix) = &finding.actions[0] else {
4891            panic!("position-0 should be a fix action");
4892        };
4893        assert!(!fix.auto_fixable);
4894        assert!(fix.description.contains("manually"));
4895        assert_eq!(finding.actions.len(), 1);
4896    }
4897
4898    #[test]
4899    fn unprovided_inject_primary_action_is_provide_inject() {
4900        let finding = UnprovidedInjectFinding::with_actions(UnprovidedInject {
4901            path: PathBuf::from("src/context.ts"),
4902            key_name: "userKey".to_string(),
4903            framework: "svelte".to_string(),
4904            line: 7,
4905            col: 12,
4906        });
4907
4908        assert_manual_fix_then_suppress(
4909            &finding.actions,
4910            "provide-inject",
4911            "// fallow-ignore-next-line unprovided-inject",
4912        );
4913    }
4914
4915    #[test]
4916    fn unused_server_action_primary_action_is_wire_server_action() {
4917        let finding = UnusedServerActionFinding::with_actions(UnusedServerAction {
4918            path: PathBuf::from("app/actions.ts"),
4919            action_name: "saveDraft".to_string(),
4920            line: 3,
4921            col: 13,
4922        });
4923
4924        assert_manual_fix_then_suppress(
4925            &finding.actions,
4926            "wire-server-action",
4927            "// fallow-ignore-next-line unused-server-action",
4928        );
4929    }
4930
4931    #[test]
4932    fn unused_load_data_key_primary_action_is_use_load_data() {
4933        let finding = UnusedLoadDataKeyFinding::with_actions(UnusedLoadDataKey {
4934            path: PathBuf::from("src/routes/+page.server.ts"),
4935            key_name: "profile".to_string(),
4936            line: 12,
4937            col: 6,
4938            route_dir: Some("src/routes".to_string()),
4939        });
4940
4941        assert_manual_fix_then_suppress(
4942            &finding.actions,
4943            "use-load-data",
4944            "// fallow-ignore-next-line unused-load-data-key",
4945        );
4946    }
4947
4948    #[test]
4949    fn unrendered_component_primary_action_is_render_component() {
4950        let finding = UnrenderedComponentFinding::with_actions(UnrenderedComponent {
4951            path: PathBuf::from("src/components/EmptyState.vue"),
4952            component_name: "EmptyState".to_string(),
4953            framework: "vue".to_string(),
4954            reachable_via: None,
4955            line: 1,
4956            col: 0,
4957        });
4958
4959        assert_manual_fix_then_suppress(
4960            &finding.actions,
4961            "render-component",
4962            "// fallow-ignore-next-line unrendered-component",
4963        );
4964    }
4965
4966    #[test]
4967    fn unused_component_prop_primary_action_is_use_component_prop() {
4968        let finding = UnusedComponentPropFinding::with_actions(UnusedComponentProp {
4969            path: PathBuf::from("src/components/Card.vue"),
4970            component_name: "Card".to_string(),
4971            prop_name: "variant".to_string(),
4972            line: 5,
4973            col: 10,
4974        });
4975
4976        assert_manual_fix_then_suppress(
4977            &finding.actions,
4978            "use-component-prop",
4979            "// fallow-ignore-next-line unused-component-prop",
4980        );
4981    }
4982
4983    #[test]
4984    fn unused_component_emit_primary_action_is_emit_component_event() {
4985        let finding = UnusedComponentEmitFinding::with_actions(UnusedComponentEmit {
4986            path: PathBuf::from("src/components/Picker.vue"),
4987            component_name: "Picker".to_string(),
4988            emit_name: "focus".to_string(),
4989            line: 6,
4990            col: 14,
4991        });
4992
4993        assert_manual_fix_then_suppress(
4994            &finding.actions,
4995            "emit-component-event",
4996            "// fallow-ignore-next-line unused-component-emit",
4997        );
4998    }
4999
5000    #[test]
5001    fn unused_svelte_event_primary_action_is_wire_svelte_event() {
5002        let finding = UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
5003            path: PathBuf::from("src/Dialog.svelte"),
5004            component_name: "Dialog".to_string(),
5005            event_name: "closed".to_string(),
5006            line: 19,
5007            col: 8,
5008        });
5009
5010        assert_manual_fix_then_suppress(
5011            &finding.actions,
5012            "wire-svelte-event",
5013            "// fallow-ignore-next-line unused-svelte-event",
5014        );
5015    }
5016
5017    #[test]
5018    fn unresolved_import_actions_include_ignore_unresolved_imports_config_suppress() {
5019        let inner = UnresolvedImport {
5020            specifier: "@example/icons".to_string(),
5021            path: PathBuf::from("src/index.ts"),
5022            line: 4,
5023            col: 12,
5024            specifier_col: 18,
5025        };
5026        let finding = UnresolvedImportFinding::with_actions(inner);
5027
5028        assert_eq!(action_type(&finding.actions[0]), "resolve-import");
5029        assert_eq!(action_type(&finding.actions[1]), "add-to-config");
5030        let IssueAction::AddToConfig(action) = &finding.actions[1] else {
5031            panic!("position-1 should be AddToConfig");
5032        };
5033        assert!(!action.auto_fixable);
5034        assert_eq!(action.config_key, "ignoreUnresolvedImports");
5035        let AddToConfigValue::Scalar(value) = &action.value else {
5036            panic!("ignoreUnresolvedImports action should carry a scalar value");
5037        };
5038        assert_eq!(value, "@example/icons");
5039        assert_eq!(
5040            action.value_schema.as_deref(),
5041            Some(
5042                "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
5043            )
5044        );
5045    }
5046
5047    /// Invariant: when no other catalog declares the package, position 0
5048    /// of `unresolved_catalog_references[].actions` is `add-catalog-entry`,
5049    /// directing the agent to grow the targeted catalog.
5050    ///
5051    /// Downstream consumers (MCP `actions[0].type` dispatch and JSON
5052    /// consumers that read the first action) pattern-match on this string. A future refactor that puts the
5053    /// generic `remove-catalog-reference` fallback at position 0 would
5054    /// flip every CI annotation from "add this entry" to "remove this
5055    /// reference", reversing the recommended action.
5056    #[test]
5057    fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
5058        let inner = UnresolvedCatalogReference {
5059            entry_name: "react".to_string(),
5060            catalog_name: "default".to_string(),
5061            path: PathBuf::from("apps/web/package.json"),
5062            line: 7,
5063            available_in_catalogs: Vec::new(),
5064        };
5065        let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
5066        assert_eq!(
5067            action_type(&finding.actions[0]),
5068            "add-catalog-entry",
5069            "position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
5070        );
5071        let IssueAction::Fix(fix) = &finding.actions[0] else {
5072            panic!("position-0 should be an IssueAction::Fix");
5073        };
5074        assert!(
5075            fix.available_in_catalogs.is_none(),
5076            "add-catalog-entry must NOT carry available_in_catalogs"
5077        );
5078        assert!(
5079            fix.suggested_target.is_none(),
5080            "add-catalog-entry must NOT carry suggested_target"
5081        );
5082    }
5083
5084    /// Invariant: when at least one alternative catalog declares the
5085    /// package, position 0 flips to `update-catalog-reference` and carries
5086    /// the alternative list. When exactly one alternative exists, the
5087    /// action also carries `suggested_target` so deterministic agents can
5088    /// land the edit without picking from the list. This is the
5089    /// counterpart to `unresolved_catalog_position_0_is_add_when_no_alternatives`.
5090    #[test]
5091    fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
5092        let inner = UnresolvedCatalogReference {
5093            entry_name: "react".to_string(),
5094            catalog_name: "default".to_string(),
5095            path: PathBuf::from("apps/web/package.json"),
5096            line: 7,
5097            available_in_catalogs: vec!["react18".to_string()],
5098        };
5099        let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
5100        assert_eq!(
5101            action_type(&finding.actions[0]),
5102            "update-catalog-reference",
5103            "position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
5104        );
5105        let IssueAction::Fix(fix) = &finding.actions[0] else {
5106            panic!("position-0 should be an IssueAction::Fix");
5107        };
5108        assert_eq!(
5109            fix.available_in_catalogs.as_deref(),
5110            Some(&["react18".to_string()][..]),
5111            "update-catalog-reference must carry the alternative list"
5112        );
5113        assert_eq!(
5114            fix.suggested_target.as_deref(),
5115            Some("react18"),
5116            "single-alternative case must surface `suggested_target` for deterministic agents"
5117        );
5118
5119        // Two alternatives: still update, but no unambiguous target.
5120        let inner_two = UnresolvedCatalogReference {
5121            entry_name: "react".to_string(),
5122            catalog_name: "default".to_string(),
5123            path: PathBuf::from("apps/web/package.json"),
5124            line: 7,
5125            available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
5126        };
5127        let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
5128        assert_eq!(
5129            action_type(&finding_two.actions[0]),
5130            "update-catalog-reference"
5131        );
5132        let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
5133            panic!("position-0 should be an IssueAction::Fix");
5134        };
5135        assert!(
5136            fix_two.suggested_target.is_none(),
5137            "multi-alternative case must NOT carry `suggested_target` (agent must pick)"
5138        );
5139    }
5140
5141    /// Invariant: position 0 of `duplicate_exports[].actions` is
5142    /// `add-to-config` (the safe `ignoreExports` rule for the
5143    /// namespace-barrel case), NOT the destructive `remove-duplicate`.
5144    ///
5145    /// This protects the shadcn / Radix / bits-ui pattern where every
5146    /// `components/ui/<name>/index.ts` intentionally re-exports the same
5147    /// short names. Any consumer that reads `actions[0].type` as "the
5148    /// recommended fix" must see the non-destructive path first; flipping
5149    /// position 0 to `remove-duplicate` would propose deleting an
5150    /// intentional API surface.
5151    ///
5152    /// This test pins position 0 across both possible auto_fixable values
5153    /// for the add-to-config action (the per-instance flip flag handled
5154    /// by `set_config_fixable`).
5155    #[test]
5156    fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
5157        let inner = DuplicateExport {
5158            export_name: "Root".to_string(),
5159            locations: vec![
5160                DuplicateLocation {
5161                    path: PathBuf::from("components/ui/accordion/index.ts"),
5162                    line: 1,
5163                    col: 0,
5164                },
5165                DuplicateLocation {
5166                    path: PathBuf::from("components/ui/dialog/index.ts"),
5167                    line: 1,
5168                    col: 0,
5169                },
5170            ],
5171        };
5172        let finding = DuplicateExportFinding::with_actions(inner);
5173        assert_eq!(
5174            action_type(&finding.actions[0]),
5175            "add-to-config",
5176            "position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
5177        );
5178        assert_eq!(
5179            action_type(&finding.actions[1]),
5180            "remove-duplicate",
5181            "position-1 must be the destructive `remove-duplicate` fallback"
5182        );
5183
5184        // `set_config_fixable(true)` flips the position-0 add-to-config
5185        // bool but must NOT re-order positions.
5186        let mut promoted = finding;
5187        promoted.set_config_fixable(true);
5188        assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
5189        let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
5190            panic!("position-0 should still be AddToConfig after set_config_fixable");
5191        };
5192        assert!(
5193            action.auto_fixable,
5194            "set_config_fixable(true) must flip auto_fixable"
5195        );
5196    }
5197
5198    /// Invariant: a duplicate-exports finding with empty `locations`
5199    /// degenerate input drops the `add-to-config` action entirely, so
5200    /// position 0 falls through to `remove-duplicate`. Documents the
5201    /// degenerate-case contract.
5202    #[test]
5203    fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
5204        let inner = DuplicateExport {
5205            export_name: "Root".to_string(),
5206            locations: Vec::new(),
5207        };
5208        let finding = DuplicateExportFinding::with_actions(inner);
5209        assert_eq!(
5210            action_type(&finding.actions[0]),
5211            "remove-duplicate",
5212            "with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
5213        );
5214
5215        // `set_config_fixable(true)` is a no-op on this shape.
5216        let mut promoted = finding;
5217        promoted.set_config_fixable(true);
5218        assert_eq!(
5219            action_type(&promoted.actions[0]),
5220            "remove-duplicate",
5221            "set_config_fixable is a no-op when position-0 is not add-to-config"
5222        );
5223    }
5224
5225    /// Invariant: misconfigured-dependency-override with empty
5226    /// `target_package` AND empty `raw_key` drops the suppress action
5227    /// (no usable package name for the `ignoreDependencyOverrides`
5228    /// matcher; emitting `package: ""` would be silently dropped by the
5229    /// config parser). Documents the suppress-omission contract.
5230    #[test]
5231    fn misconfigured_override_drops_suppress_when_no_package_name() {
5232        let inner = MisconfiguredDependencyOverride {
5233            raw_key: String::new(),
5234            target_package: None,
5235            raw_value: String::new(),
5236            reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
5237            source: DependencyOverrideSource::PnpmWorkspaceYaml,
5238            path: PathBuf::from("pnpm-workspace.yaml"),
5239            line: 12,
5240        };
5241        let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
5242        // Only the primary fix-dependency-override action: no suppress.
5243        assert_eq!(finding.actions.len(), 1);
5244        assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
5245    }
5246}