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