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