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