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/// Wire-shape envelope for an [`UnusedFile`] finding. The bare finding
99/// flattens in via `#[serde(flatten)]`, with a typed `actions` array
100/// populated at construction time and the audit-pass `introduced` flag
101/// attached as an optional sibling.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104pub struct UnusedFileFinding {
105    /// The underlying dead-code entry.
106    #[serde(flatten)]
107    pub file: UnusedFile,
108    /// Suggested next steps: a `delete-file` primary and a `suppress-file`
109    /// secondary. Always emitted (possibly empty for forward-compat).
110    pub actions: Vec<IssueAction>,
111    /// Set by the audit pass when this finding is introduced relative to
112    /// the merge-base. `None` when serialized directly from Rust.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub introduced: Option<AuditIntroduced>,
115}
116
117impl UnusedFileFinding {
118    /// Build the wrapper from a raw [`UnusedFile`], computing the typed
119    /// `actions` array inline. `introduced` stays `None` and is set later
120    /// by `annotate_dead_code_json` if the audit pass runs.
121    #[must_use]
122    pub fn with_actions(file: UnusedFile) -> Self {
123        let actions = vec![
124            IssueAction::Fix(FixAction {
125                kind: FixActionType::DeleteFile,
126                auto_fixable: false,
127                description: "Delete this file".to_string(),
128                note: Some(
129                    "File deletion may remove runtime functionality not visible to static analysis"
130                        .to_string(),
131                ),
132                available_in_catalogs: None,
133                suggested_target: None,
134            }),
135            IssueAction::SuppressFile(SuppressFileAction {
136                kind: SuppressFileKind::SuppressFile,
137                auto_fixable: false,
138                description: "Suppress with a file-level comment at the top of the file"
139                    .to_string(),
140                comment: "// fallow-ignore-file unused-file".to_string(),
141            }),
142        ];
143        Self {
144            file,
145            actions,
146            introduced: None,
147        }
148    }
149}
150
151/// Wire-shape envelope for a [`PrivateTypeLeak`] finding. Mirrors
152/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
153/// `actions` array (`export-type` primary plus `suppress-line` secondary).
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
156pub struct PrivateTypeLeakFinding {
157    /// The underlying dead-code entry.
158    #[serde(flatten)]
159    pub leak: PrivateTypeLeak,
160    /// Suggested next steps. Always emitted (possibly empty for
161    /// forward-compat).
162    pub actions: Vec<IssueAction>,
163    /// Set by the audit pass when this finding is introduced relative to
164    /// the merge-base.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub introduced: Option<AuditIntroduced>,
167}
168
169impl PrivateTypeLeakFinding {
170    /// Build the wrapper from a raw [`PrivateTypeLeak`].
171    #[must_use]
172    pub fn with_actions(leak: PrivateTypeLeak) -> Self {
173        let actions = vec![
174            IssueAction::Fix(FixAction {
175                kind: FixActionType::ExportType,
176                auto_fixable: false,
177                description: "Export the referenced private type by name".to_string(),
178                note: Some(
179                    "Keep the type exported while it is part of a public signature".to_string(),
180                ),
181                available_in_catalogs: None,
182                suggested_target: None,
183            }),
184            IssueAction::SuppressLine(SuppressLineAction {
185                kind: SuppressLineKind::SuppressLine,
186                auto_fixable: false,
187                description: "Suppress with an inline comment above the line".to_string(),
188                comment: "// fallow-ignore-next-line private-type-leak".to_string(),
189                scope: None,
190            }),
191        ];
192        Self {
193            leak,
194            actions,
195            introduced: None,
196        }
197    }
198}
199
200/// Wire-shape envelope for an [`UnresolvedImport`] finding. Mirrors
201/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
202/// `actions` array (`resolve-import` primary plus config and inline
203/// suppression actions).
204#[derive(Debug, Clone, Serialize, Deserialize)]
205#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
206pub struct UnresolvedImportFinding {
207    /// The underlying dead-code entry.
208    #[serde(flatten)]
209    pub import: UnresolvedImport,
210    /// Suggested next steps. Always emitted (possibly empty for
211    /// forward-compat).
212    pub actions: Vec<IssueAction>,
213    /// Set by the audit pass when this finding is introduced relative to
214    /// the merge-base.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub introduced: Option<AuditIntroduced>,
217}
218
219impl UnresolvedImportFinding {
220    /// Build the wrapper from a raw [`UnresolvedImport`].
221    #[must_use]
222    pub fn with_actions(import: UnresolvedImport) -> Self {
223        let actions = vec![
224            IssueAction::Fix(FixAction {
225                kind: FixActionType::ResolveImport,
226                auto_fixable: false,
227                description: "Fix the import specifier or install the missing module".to_string(),
228                note: Some(
229                    "Verify the module path and check tsconfig paths configuration".to_string(),
230                ),
231                available_in_catalogs: None,
232                suggested_target: None,
233            }),
234            IssueAction::AddToConfig(AddToConfigAction {
235                kind: AddToConfigKind::AddToConfig,
236                auto_fixable: false,
237                description: format!(
238                    "Add \"{}\" to ignoreUnresolvedImports in fallow config",
239                    import.specifier
240                ),
241                config_key: "ignoreUnresolvedImports".to_string(),
242                value: AddToConfigValue::Scalar(import.specifier.clone()),
243                value_schema: Some(
244                    "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
245                        .to_string(),
246                ),
247            }),
248            IssueAction::SuppressLine(SuppressLineAction {
249                kind: SuppressLineKind::SuppressLine,
250                auto_fixable: false,
251                description: "Suppress with an inline comment above the line".to_string(),
252                comment: "// fallow-ignore-next-line unresolved-import".to_string(),
253                scope: None,
254            }),
255        ];
256        Self {
257            import,
258            actions,
259            introduced: None,
260        }
261    }
262}
263
264/// Wire-shape envelope for a [`CircularDependency`] finding. Mirrors
265/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
266/// `actions` array (`refactor-cycle` primary plus `suppress-line`
267/// secondary).
268#[derive(Debug, Clone, Serialize, Deserialize)]
269#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
270pub struct CircularDependencyFinding {
271    /// The underlying dead-code entry.
272    #[serde(flatten)]
273    pub cycle: CircularDependency,
274    /// Suggested next steps. Always emitted (possibly empty for
275    /// forward-compat).
276    pub actions: Vec<IssueAction>,
277    /// Set by the audit pass when this finding is introduced relative to
278    /// the merge-base.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub introduced: Option<AuditIntroduced>,
281}
282
283impl CircularDependencyFinding {
284    /// Build the wrapper from a raw [`CircularDependency`].
285    #[must_use]
286    pub fn with_actions(cycle: CircularDependency) -> Self {
287        let actions = vec![
288            IssueAction::Fix(FixAction {
289                kind: FixActionType::RefactorCycle,
290                auto_fixable: false,
291                description: "Extract shared logic into a separate module to break the cycle"
292                    .to_string(),
293                note: Some(
294                    "Circular imports can cause initialization issues and make code harder to reason about"
295                        .to_string(),
296                ),
297                available_in_catalogs: None,
298                suggested_target: None,
299            }),
300            IssueAction::SuppressLine(SuppressLineAction {
301                kind: SuppressLineKind::SuppressLine,
302                auto_fixable: false,
303                description: "Suppress with an inline comment above the line".to_string(),
304                comment: "// fallow-ignore-next-line circular-dependency".to_string(),
305                scope: None,
306            }),
307        ];
308        Self {
309            cycle,
310            actions,
311            introduced: None,
312        }
313    }
314}
315
316/// Wire-shape envelope for a [`ReExportCycle`] finding. Mirrors
317/// [`CircularDependencyFinding`]: flattens the bare finding and carries a
318/// typed `actions` array (`refactor-re-export-cycle` informational primary
319/// plus `suppress-file` secondary; cycles are file-scoped so a single
320/// file-level suppression on the alphabetically-first member breaks the
321/// cycle, and no `// fallow-ignore-next-line` form makes sense because the
322/// diagnostic is anchored at line 1 col 0 of each member).
323#[derive(Debug, Clone, Serialize, Deserialize)]
324#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
325pub struct ReExportCycleFinding {
326    /// The underlying dead-code entry.
327    #[serde(flatten)]
328    pub cycle: ReExportCycle,
329    /// Suggested next steps. Always emitted (possibly empty for
330    /// forward-compat).
331    pub actions: Vec<IssueAction>,
332    /// Set by the audit pass when this finding is introduced relative to
333    /// the merge-base.
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub introduced: Option<AuditIntroduced>,
336}
337
338impl ReExportCycleFinding {
339    /// Build the wrapper from a raw [`ReExportCycle`].
340    ///
341    /// The `SuppressFile` action targets the alphabetically-first member
342    /// (`cycle.files[0]`; the `files` Vec is already sorted at graph layer);
343    /// for multi-node cycles the description names the other members so
344    /// consumers see context for why one file-level suppression suffices.
345    #[must_use]
346    pub fn with_actions(cycle: ReExportCycle) -> Self {
347        // The description is a path-free hint about the suppression's
348        // structural effect; the cycle's member list already ships in the
349        // sibling `files` field, so consumers can correlate without
350        // re-reading the description (and absolute paths cannot leak in
351        // here, which the wrapper has no root-prefix context to strip).
352        let suppress_description = match cycle.kind {
353            ReExportCycleKind::SelfLoop => {
354                "Suppress with a file-level comment at the top of this file. \
355                 The cycle is a self-loop, so the suppression covers the entire finding."
356                    .to_string()
357            }
358            ReExportCycleKind::MultiNode => {
359                "Suppress with a file-level comment at the top of this file. \
360                 One suppression on any member breaks the cycle for every member \
361                 (see the sibling `files` array)."
362                    .to_string()
363            }
364        };
365        let actions = vec![
366            IssueAction::Fix(FixAction {
367                kind: FixActionType::RefactorReExportCycle,
368                auto_fixable: false,
369                description: "Remove one `export * from` (or `export { ... } from`) \
370                              statement on any one member to break the cycle"
371                    .to_string(),
372                note: Some(
373                    "Re-export cycles are structurally a no-op: chain propagation through \
374                     the loop never reaches a terminating module, so imports from any member \
375                     may silently come up empty."
376                        .to_string(),
377                ),
378                available_in_catalogs: None,
379                suggested_target: None,
380            }),
381            IssueAction::SuppressFile(SuppressFileAction {
382                kind: SuppressFileKind::SuppressFile,
383                auto_fixable: false,
384                description: suppress_description,
385                comment: "// fallow-ignore-file re-export-cycle".to_string(),
386            }),
387        ];
388        Self {
389            cycle,
390            actions,
391            introduced: None,
392        }
393    }
394}
395
396/// Wire-shape envelope for a [`BoundaryViolation`] finding. Mirrors
397/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
398/// `actions` array (`refactor-boundary` primary plus `suppress-line`
399/// secondary).
400#[derive(Debug, Clone, Serialize, Deserialize)]
401#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
402pub struct BoundaryViolationFinding {
403    /// The underlying dead-code entry.
404    #[serde(flatten)]
405    pub violation: BoundaryViolation,
406    /// Suggested next steps. Always emitted (possibly empty for
407    /// forward-compat).
408    pub actions: Vec<IssueAction>,
409    /// Set by the audit pass when this finding is introduced relative to
410    /// the merge-base.
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub introduced: Option<AuditIntroduced>,
413}
414
415impl BoundaryViolationFinding {
416    /// Build the wrapper from a raw [`BoundaryViolation`].
417    #[must_use]
418    pub fn with_actions(violation: BoundaryViolation) -> Self {
419        let actions = vec![
420            IssueAction::Fix(FixAction {
421                kind: FixActionType::RefactorBoundary,
422                auto_fixable: false,
423                description: "Move the import through an allowed zone or restructure the dependency"
424                    .to_string(),
425                note: Some(
426                    "This import crosses an architecture boundary that is not permitted by the configured rules"
427                        .to_string(),
428                ),
429                available_in_catalogs: None,
430                suggested_target: None,
431            }),
432            IssueAction::SuppressLine(SuppressLineAction {
433                kind: SuppressLineKind::SuppressLine,
434                auto_fixable: false,
435                description: "Suppress with an inline comment above the line".to_string(),
436                comment: "// fallow-ignore-next-line boundary-violation".to_string(),
437                scope: None,
438            }),
439        ];
440        Self {
441            violation,
442            actions,
443            introduced: None,
444        }
445    }
446}
447
448/// Wire-shape envelope for a [`BoundaryCoverageViolation`] finding. Carries
449/// actions for assigning the file to a zone or explicitly allowing it to stay
450/// unmatched.
451#[derive(Debug, Clone, Serialize, Deserialize)]
452#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
453pub struct BoundaryCoverageViolationFinding {
454    /// The underlying coverage entry.
455    #[serde(flatten)]
456    pub violation: BoundaryCoverageViolation,
457    /// Suggested next steps.
458    pub actions: Vec<IssueAction>,
459    /// Set by the audit pass when this finding is introduced relative to
460    /// the merge-base.
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub introduced: Option<AuditIntroduced>,
463}
464
465impl BoundaryCoverageViolationFinding {
466    /// Build the wrapper from a raw [`BoundaryCoverageViolation`].
467    #[must_use]
468    pub fn with_actions(violation: BoundaryCoverageViolation) -> Self {
469        let path = violation.path.to_string_lossy().replace('\\', "/");
470        let actions = vec![
471            IssueAction::Fix(FixAction {
472                kind: FixActionType::RefactorBoundary,
473                auto_fixable: false,
474                description: "Add this file to a boundary zone pattern or move it under an existing zone"
475                    .to_string(),
476                note: Some(
477                    "Boundary coverage is enabled, so every analyzed source file must match a zone unless allow-listed"
478                        .to_string(),
479                ),
480                available_in_catalogs: None,
481                suggested_target: None,
482            }),
483            IssueAction::AddToConfig(AddToConfigAction {
484                kind: AddToConfigKind::AddToConfig,
485                auto_fixable: false,
486                description: format!(
487                    "Add \"{path}\" to boundaries.coverage.allowUnmatched in fallow config"
488                ),
489                config_key: "boundaries.coverage.allowUnmatched".to_string(),
490                value: AddToConfigValue::Scalar(path),
491                value_schema: Some(
492                    "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/boundaries/properties/coverage/properties/allowUnmatched/items"
493                        .to_string(),
494                ),
495            }),
496            IssueAction::SuppressFile(SuppressFileAction {
497                kind: SuppressFileKind::SuppressFile,
498                auto_fixable: false,
499                description: "Suppress with a file-level comment at the top of the file"
500                    .to_string(),
501                comment: "// fallow-ignore-file boundary-violation".to_string(),
502            }),
503        ];
504        Self {
505            violation,
506            actions,
507            introduced: None,
508        }
509    }
510}
511
512/// Wire-shape envelope for a [`BoundaryCallViolation`] finding. Carries
513/// actions for refactoring the forbidden call out of the zone or suppressing
514/// it with the shared `boundary-violation` token.
515#[derive(Debug, Clone, Serialize, Deserialize)]
516#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
517pub struct BoundaryCallViolationFinding {
518    /// The underlying forbidden-call entry.
519    #[serde(flatten)]
520    pub violation: BoundaryCallViolation,
521    /// Suggested next steps.
522    pub actions: Vec<IssueAction>,
523    /// Set by the audit pass when this finding is introduced relative to
524    /// the merge-base.
525    #[serde(default, skip_serializing_if = "Option::is_none")]
526    pub introduced: Option<AuditIntroduced>,
527}
528
529impl BoundaryCallViolationFinding {
530    /// Build the wrapper from a raw [`BoundaryCallViolation`].
531    #[must_use]
532    pub fn with_actions(violation: BoundaryCallViolation) -> Self {
533        let actions = vec![
534            IssueAction::Fix(FixAction {
535                kind: FixActionType::RefactorBoundary,
536                auto_fixable: false,
537                description: format!(
538                    "Move the `{}` call out of zone '{}' or behind an allowed abstraction",
539                    violation.callee, violation.zone,
540                ),
541                note: Some(format!(
542                    "`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",
543                    violation.pattern, violation.zone,
544                )),
545                available_in_catalogs: None,
546                suggested_target: None,
547            }),
548            IssueAction::SuppressLine(SuppressLineAction {
549                kind: SuppressLineKind::SuppressLine,
550                auto_fixable: false,
551                description: "Suppress with an inline comment above the line".to_string(),
552                comment: "// fallow-ignore-next-line boundary-violation".to_string(),
553                scope: None,
554            }),
555            IssueAction::SuppressFile(SuppressFileAction {
556                kind: SuppressFileKind::SuppressFile,
557                auto_fixable: false,
558                description: "Suppress with a file-level comment at the top of the file"
559                    .to_string(),
560                comment: "// fallow-ignore-file boundary-violation".to_string(),
561            }),
562        ];
563        Self {
564            violation,
565            actions,
566            introduced: None,
567        }
568    }
569}
570
571/// Wire-shape envelope for a [`PolicyViolation`] finding. Carries actions for
572/// replacing the banned call, import, or effect, or suppressing it with a scoped
573/// `policy-violation:<pack>/<rule-id>` token.
574#[derive(Debug, Clone, Serialize, Deserialize)]
575#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
576pub struct PolicyViolationFinding {
577    /// The underlying rule-pack policy entry.
578    #[serde(flatten)]
579    pub violation: PolicyViolation,
580    /// Suggested next steps.
581    pub actions: Vec<IssueAction>,
582    /// Set by the audit pass when this finding is introduced relative to
583    /// the merge-base.
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    pub introduced: Option<AuditIntroduced>,
586}
587
588impl PolicyViolationFinding {
589    /// Build the wrapper from a raw [`PolicyViolation`].
590    #[must_use]
591    pub fn with_actions(violation: PolicyViolation) -> Self {
592        let what = match violation.kind {
593            crate::results::PolicyRuleKind::BannedCall => "call",
594            crate::results::PolicyRuleKind::BannedImport => "import",
595            crate::results::PolicyRuleKind::BannedEffect => "effect",
596            crate::results::PolicyRuleKind::BannedExport => "export",
597        };
598        let description = match &violation.message {
599            Some(message) => format!("Replace the `{}` {what}: {message}", violation.matched),
600            None => format!("Replace the `{}` {what}", violation.matched),
601        };
602        let suppress_token = format!("policy-violation:{}/{}", violation.pack, violation.rule_id);
603        let actions = vec![
604            IssueAction::Fix(FixAction {
605                kind: FixActionType::ResolvePolicyViolation,
606                auto_fixable: false,
607                description,
608                note: Some(format!(
609                    "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",
610                    violation.pack, violation.rule_id,
611                )),
612                available_in_catalogs: None,
613                suggested_target: None,
614            }),
615            IssueAction::SuppressLine(SuppressLineAction {
616                kind: SuppressLineKind::SuppressLine,
617                auto_fixable: false,
618                description: "Suppress this rule-pack rule with an inline comment above the line"
619                    .to_string(),
620                comment: format!("// fallow-ignore-next-line {suppress_token}"),
621                scope: None,
622            }),
623            IssueAction::SuppressFile(SuppressFileAction {
624                kind: SuppressFileKind::SuppressFile,
625                auto_fixable: false,
626                description:
627                    "Suppress this rule-pack rule with a file-level comment at the top of the file"
628                        .to_string(),
629                comment: format!("// fallow-ignore-file {suppress_token}"),
630            }),
631        ];
632        Self {
633            violation,
634            actions,
635            introduced: None,
636        }
637    }
638}
639
640/// Wire-shape envelope for an [`UnusedExport`] finding consumed under the
641/// `unused_exports` key. Same Rust struct as [`UnusedTypeFinding`], with a
642/// different fix description so consumers can tell value-export from
643/// type-export removal at the action level.
644#[derive(Debug, Clone, Serialize, Deserialize)]
645#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
646pub struct UnusedExportFinding {
647    /// The underlying dead-code entry.
648    #[serde(flatten)]
649    pub export: UnusedExport,
650    /// Suggested next steps. Always emitted (possibly empty for
651    /// forward-compat).
652    pub actions: Vec<IssueAction>,
653    /// Type-aware evidence for this exact candidate when requested.
654    #[serde(default, skip_serializing_if = "Option::is_none")]
655    pub semantic: Option<SemanticCandidateDecision>,
656    /// Set by the audit pass when this finding is introduced relative to
657    /// the merge-base.
658    #[serde(default, skip_serializing_if = "Option::is_none")]
659    pub introduced: Option<AuditIntroduced>,
660}
661
662impl UnusedExportFinding {
663    /// Build the wrapper. When `export.is_re_export` is true, the fix
664    /// action's `note` warns about possible public-API surface; otherwise
665    /// `note` is absent on the fix action.
666    #[must_use]
667    pub fn with_actions(export: UnusedExport) -> Self {
668        let note = if export.is_re_export {
669            Some(
670                "This finding originates from a re-export; verify it is not part of your public API before removing"
671                    .to_string(),
672            )
673        } else {
674            None
675        };
676        let actions = vec![
677            IssueAction::Fix(FixAction {
678                kind: FixActionType::RemoveExport,
679                auto_fixable: true,
680                description: "Remove the unused export from the public API".to_string(),
681                note,
682                available_in_catalogs: None,
683                suggested_target: None,
684            }),
685            IssueAction::SuppressLine(SuppressLineAction {
686                kind: SuppressLineKind::SuppressLine,
687                auto_fixable: false,
688                description: "Suppress with an inline comment above the line".to_string(),
689                comment: "// fallow-ignore-next-line unused-export".to_string(),
690                scope: None,
691            }),
692        ];
693        Self {
694            export,
695            actions,
696            semantic: None,
697            introduced: None,
698        }
699    }
700
701    /// Attach type-aware evidence and disable the syntactic fix when semantic
702    /// analysis could not establish complete negative evidence.
703    pub fn set_semantic_decision(&mut self, decision: SemanticCandidateDecision) {
704        set_export_semantic_action(&mut self.actions, &decision);
705        self.semantic = Some(decision);
706    }
707}
708
709/// Wire-shape envelope for an [`UnusedExport`] finding consumed under the
710/// `unused_types` key. Wraps the same bare [`UnusedExport`] struct as
711/// [`UnusedExportFinding`] but emits a fix action targeted at type-only
712/// declarations, with the same `is_re_export`-aware note swap.
713#[derive(Debug, Clone, Serialize, Deserialize)]
714#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
715pub struct UnusedTypeFinding {
716    /// The underlying dead-code entry.
717    #[serde(flatten)]
718    pub export: UnusedExport,
719    /// Suggested next steps. Always emitted (possibly empty for
720    /// forward-compat).
721    pub actions: Vec<IssueAction>,
722    /// Type-aware evidence for this exact candidate when requested.
723    #[serde(default, skip_serializing_if = "Option::is_none")]
724    pub semantic: Option<SemanticCandidateDecision>,
725    /// Set by the audit pass when this finding is introduced relative to
726    /// the merge-base.
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub introduced: Option<AuditIntroduced>,
729}
730
731impl UnusedTypeFinding {
732    /// Build the wrapper. `is_re_export` swaps the fix note the same way as
733    /// [`UnusedExportFinding::with_actions`].
734    #[must_use]
735    pub fn with_actions(export: UnusedExport) -> Self {
736        let note = if export.is_re_export {
737            Some(
738                "This finding originates from a re-export; verify it is not part of your public API before removing"
739                    .to_string(),
740            )
741        } else {
742            None
743        };
744        let actions = vec![
745            IssueAction::Fix(FixAction {
746                kind: FixActionType::RemoveExport,
747                auto_fixable: true,
748                description:
749                    "Remove the `export` (or `export type`) keyword from the type declaration"
750                        .to_string(),
751                note,
752                available_in_catalogs: None,
753                suggested_target: None,
754            }),
755            IssueAction::SuppressLine(SuppressLineAction {
756                kind: SuppressLineKind::SuppressLine,
757                auto_fixable: false,
758                description: "Suppress with an inline comment above the line".to_string(),
759                comment: "// fallow-ignore-next-line unused-type".to_string(),
760                scope: None,
761            }),
762        ];
763        Self {
764            export,
765            actions,
766            semantic: None,
767            introduced: None,
768        }
769    }
770
771    /// Attach type-aware evidence and disable the syntactic fix when semantic
772    /// analysis could not establish complete negative evidence.
773    pub fn set_semantic_decision(&mut self, decision: SemanticCandidateDecision) {
774        set_export_semantic_action(&mut self.actions, &decision);
775        self.semantic = Some(decision);
776    }
777}
778
779fn set_export_semantic_action(actions: &mut [IssueAction], decision: &SemanticCandidateDecision) {
780    let complete_negative = decision.decision
781        == SemanticCandidateDecisionKind::ConfirmedNoStaticReferences
782        && decision.status == SemanticCompleteness::Complete;
783    let Some(IssueAction::Fix(action)) = actions.first_mut() else {
784        return;
785    };
786    action.auto_fixable = complete_negative;
787    if !complete_negative {
788        action.note = Some(
789            "Type-aware analysis retained this candidate because complete negative evidence was not available"
790                .to_string(),
791        );
792    }
793}
794
795/// Wire-shape envelope for an [`InvalidClientExport`] finding. There is no safe
796/// auto-fix: the export itself may be a legitimate client-component value
797/// export that happens to collide with a Next.js server-only name, so removing
798/// it could break the component. Actions are a manual `move-to-server-module`
799/// fix (the real remediation) plus a line-level suppress.
800#[derive(Debug, Clone, Serialize, Deserialize)]
801#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
802pub struct InvalidClientExportFinding {
803    /// The underlying dead-code entry.
804    #[serde(flatten)]
805    pub export: InvalidClientExport,
806    /// Suggested next steps. Always emitted (possibly empty for
807    /// forward-compat).
808    pub actions: Vec<IssueAction>,
809    /// Set by the audit pass when this finding is introduced relative to
810    /// the merge-base.
811    #[serde(default, skip_serializing_if = "Option::is_none")]
812    pub introduced: Option<AuditIntroduced>,
813}
814
815impl InvalidClientExportFinding {
816    /// Build the wrapper from a raw [`InvalidClientExport`]. Emits a manual
817    /// fix action (move the server-only export to a non-client module) plus a
818    /// line-level suppress: there is no safe auto-fix because removing the
819    /// export could break a legitimate client component.
820    #[must_use]
821    pub fn with_actions(export: InvalidClientExport) -> Self {
822        let actions = vec![
823            IssueAction::Fix(FixAction {
824                kind: FixActionType::MoveToServerModule,
825                auto_fixable: false,
826                description: "Move the server-only export to a non-client module and import it from there"
827                    .to_string(),
828                note: Some(
829                    "A \"use client\" file cannot export a Next.js server-only or route-config name; Next.js rejects it at build time"
830                        .to_string(),
831                ),
832                available_in_catalogs: None,
833                suggested_target: None,
834            }),
835            IssueAction::SuppressLine(SuppressLineAction {
836                kind: SuppressLineKind::SuppressLine,
837                auto_fixable: false,
838                description: "Suppress with an inline comment above the line".to_string(),
839                comment: "// fallow-ignore-next-line invalid-client-export".to_string(),
840                scope: None,
841            }),
842        ];
843        Self {
844            export,
845            actions,
846            introduced: None,
847        }
848    }
849}
850
851/// Wire-shape envelope for a [`MixedClientServerBarrel`] finding. There is no
852/// safe auto-fix: splitting a barrel into separate client and server modules is
853/// a human decision (the barrel may intentionally aggregate both surfaces).
854/// Actions are a manual `split-mixed-barrel` fix (the real remediation) plus a
855/// line-level suppress.
856#[derive(Debug, Clone, Serialize, Deserialize)]
857#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
858pub struct MixedClientServerBarrelFinding {
859    /// The underlying dead-code entry.
860    #[serde(flatten)]
861    pub barrel: MixedClientServerBarrel,
862    /// Suggested next steps. Always emitted (possibly empty for
863    /// forward-compat).
864    pub actions: Vec<IssueAction>,
865    /// Set by the audit pass when this finding is introduced relative to
866    /// the merge-base.
867    #[serde(default, skip_serializing_if = "Option::is_none")]
868    pub introduced: Option<AuditIntroduced>,
869}
870
871impl MixedClientServerBarrelFinding {
872    /// Build the wrapper from a raw [`MixedClientServerBarrel`]. Emits a manual
873    /// fix action (split the barrel into separate client and server halves)
874    /// plus a line-level suppress: there is no safe auto-fix because splitting
875    /// the barrel is a human decision.
876    #[must_use]
877    pub fn with_actions(barrel: MixedClientServerBarrel) -> Self {
878        let actions = vec![
879            IssueAction::Fix(FixAction {
880                kind: FixActionType::SplitMixedBarrel,
881                auto_fixable: false,
882                description: "Split the barrel so client and server-only modules are re-exported from separate files"
883                    .to_string(),
884                note: Some(
885                    "Importing one name from this barrel drags the other's directive across the client/server boundary"
886                        .to_string(),
887                ),
888                available_in_catalogs: None,
889                suggested_target: None,
890            }),
891            IssueAction::SuppressLine(SuppressLineAction {
892                kind: SuppressLineKind::SuppressLine,
893                auto_fixable: false,
894                description: "Suppress with an inline comment above the line".to_string(),
895                comment: "// fallow-ignore-next-line mixed-client-server-barrel".to_string(),
896                scope: None,
897            }),
898        ];
899        Self {
900            barrel,
901            actions,
902            introduced: None,
903        }
904    }
905}
906
907/// Wire-shape envelope for a [`MisplacedDirective`] finding. There is no safe
908/// auto-fix: moving a directive to the leading prologue is a small but
909/// judgement-bearing edit (the author may have intended the file to be a
910/// server module after all). Actions are a manual `hoist-directive` fix (the
911/// real remediation) plus a line-level suppress.
912#[derive(Debug, Clone, Serialize, Deserialize)]
913#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
914pub struct MisplacedDirectiveFinding {
915    /// The underlying dead-code entry.
916    #[serde(flatten)]
917    pub directive_site: MisplacedDirective,
918    /// Suggested next steps. Always emitted (possibly empty for
919    /// forward-compat).
920    pub actions: Vec<IssueAction>,
921    /// Set by the audit pass when this finding is introduced relative to
922    /// the merge-base.
923    #[serde(default, skip_serializing_if = "Option::is_none")]
924    pub introduced: Option<AuditIntroduced>,
925}
926
927impl MisplacedDirectiveFinding {
928    /// Build the wrapper from a raw [`MisplacedDirective`]. Emits a manual fix
929    /// action (hoist the directive to the leading prologue) plus a line-level
930    /// suppress: there is no safe auto-fix because moving a directive can
931    /// change module semantics and is a human decision.
932    #[must_use]
933    pub fn with_actions(directive_site: MisplacedDirective) -> Self {
934        let actions = vec![
935            IssueAction::Fix(FixAction {
936                kind: FixActionType::HoistDirective,
937                auto_fixable: false,
938                description: "Move the directive to the very top of the file, above all imports and statements"
939                    .to_string(),
940                note: Some(
941                    "An RSC bundler honors the directive only in the leading prologue; here it precedes other statements and is silently ignored"
942                        .to_string(),
943                ),
944                available_in_catalogs: None,
945                suggested_target: None,
946            }),
947            IssueAction::SuppressLine(SuppressLineAction {
948                kind: SuppressLineKind::SuppressLine,
949                auto_fixable: false,
950                description: "Suppress with an inline comment above the line".to_string(),
951                comment: "// fallow-ignore-next-line misplaced-directive".to_string(),
952                scope: None,
953            }),
954        ];
955        Self {
956            directive_site,
957            actions,
958            introduced: None,
959        }
960    }
961}
962
963/// Wire-shape envelope for an [`UnprovidedInject`] finding. There is no safe
964/// auto-fix: the fix is binary but judgement-bearing (add a `provide` for the
965/// key, or delete the dead inject). Actions are manual remediation guidance
966/// plus a line-level suppress.
967#[derive(Debug, Clone, Serialize, Deserialize)]
968#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
969pub struct UnprovidedInjectFinding {
970    /// The underlying finding.
971    #[serde(flatten)]
972    pub inject: UnprovidedInject,
973    /// Suggested next steps. Always emitted (possibly empty for
974    /// forward-compat).
975    pub actions: Vec<IssueAction>,
976    /// Set by the audit pass when this finding is introduced relative to
977    /// the merge-base.
978    #[serde(default, skip_serializing_if = "Option::is_none")]
979    pub introduced: Option<AuditIntroduced>,
980}
981
982impl UnprovidedInjectFinding {
983    /// Build the wrapper from a raw [`UnprovidedInject`]. Emits a manual fix
984    /// action plus a line-level suppress.
985    #[must_use]
986    pub fn with_actions(inject: UnprovidedInject) -> Self {
987        let actions = vec![
988            manual_framework_fix(
989                FixActionType::ProvideInject,
990                "Provide this injected key, or remove the inject / getContext call",
991                "Manual review required: dependency-injection keys can be provided by framework wiring, tests, or package consumers outside this project.",
992            ),
993            suppress_line("// fallow-ignore-next-line unprovided-inject"),
994        ];
995        Self {
996            inject,
997            actions,
998            introduced: None,
999        }
1000    }
1001}
1002
1003/// Wire-shape envelope for an [`UnusedServerAction`] finding. There is no safe
1004/// auto-fix: the fix is binary but judgement-bearing (wire the action up to a
1005/// consumer, or delete it). Actions are manual remediation guidance plus a
1006/// line-level suppress.
1007#[derive(Debug, Clone, Serialize, Deserialize)]
1008#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1009pub struct UnusedServerActionFinding {
1010    /// The underlying finding.
1011    #[serde(flatten)]
1012    pub action: UnusedServerAction,
1013    /// Suggested next steps. Always emitted (possibly empty for
1014    /// forward-compat).
1015    pub actions: Vec<IssueAction>,
1016    /// Set by the audit pass when this finding is introduced relative to
1017    /// the merge-base.
1018    #[serde(default, skip_serializing_if = "Option::is_none")]
1019    pub introduced: Option<AuditIntroduced>,
1020}
1021
1022impl UnusedServerActionFinding {
1023    /// Build the wrapper from a raw [`UnusedServerAction`]. Emits a manual fix
1024    /// action plus a line-level suppress.
1025    #[must_use]
1026    pub fn with_actions(action: UnusedServerAction) -> Self {
1027        let actions = vec![
1028            manual_framework_fix(
1029                FixActionType::WireServerAction,
1030                "Wire the server action to a caller or form action, or remove it",
1031                "Manual review required: server actions may still be POST-able by action id or invoked reflectively outside the static project graph.",
1032            ),
1033            suppress_line("// fallow-ignore-next-line unused-server-action"),
1034        ];
1035        Self {
1036            action,
1037            actions,
1038            introduced: None,
1039        }
1040    }
1041}
1042
1043/// Wire-shape envelope for an [`UnusedLoadDataKey`] finding. There is no safe
1044/// auto-fix: a `load()` fetch can have side effects, so deleting the key is a
1045/// human call. Actions are manual remediation guidance plus a line-level
1046/// suppress.
1047#[derive(Debug, Clone, Serialize, Deserialize)]
1048#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1049pub struct UnusedLoadDataKeyFinding {
1050    /// The underlying finding.
1051    #[serde(flatten)]
1052    pub key: UnusedLoadDataKey,
1053    /// Suggested next steps. Always emitted (possibly empty for
1054    /// forward-compat).
1055    pub actions: Vec<IssueAction>,
1056    /// Set by the audit pass when this finding is introduced relative to
1057    /// the merge-base.
1058    #[serde(default, skip_serializing_if = "Option::is_none")]
1059    pub introduced: Option<AuditIntroduced>,
1060}
1061
1062impl UnusedLoadDataKeyFinding {
1063    /// Build the wrapper from a raw [`UnusedLoadDataKey`]. Emits a manual fix
1064    /// action plus a line-level suppress.
1065    #[must_use]
1066    pub fn with_actions(key: UnusedLoadDataKey) -> Self {
1067        let actions = vec![
1068            manual_framework_fix(
1069                FixActionType::UseLoadData,
1070                "Read this load data key from the route UI, or remove it from the load return",
1071                "Manual review required: load functions can perform real server or database work, so verify side effects before deleting the producer.",
1072            ),
1073            suppress_line("// fallow-ignore-next-line unused-load-data-key"),
1074        ];
1075        Self {
1076            key,
1077            actions,
1078            introduced: None,
1079        }
1080    }
1081}
1082
1083/// Wire-shape envelope for an [`UnrenderedComponent`] finding. There is no safe
1084/// auto-fix: the fix is binary but judgement-bearing (render the component
1085/// somewhere, or delete the dead component). Actions are manual remediation
1086/// guidance plus a line-level suppress.
1087#[derive(Debug, Clone, Serialize, Deserialize)]
1088#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1089pub struct UnrenderedComponentFinding {
1090    /// The underlying finding.
1091    #[serde(flatten)]
1092    pub component: UnrenderedComponent,
1093    /// Suggested next steps. Always emitted (possibly empty for
1094    /// forward-compat).
1095    pub actions: Vec<IssueAction>,
1096    /// Set by the audit pass when this finding is introduced relative to
1097    /// the merge-base.
1098    #[serde(default, skip_serializing_if = "Option::is_none")]
1099    pub introduced: Option<AuditIntroduced>,
1100}
1101
1102impl UnrenderedComponentFinding {
1103    /// Build the wrapper from a raw [`UnrenderedComponent`]. Emits a manual
1104    /// fix action plus a line-level suppress.
1105    #[must_use]
1106    pub fn with_actions(component: UnrenderedComponent) -> Self {
1107        let actions = vec![
1108            manual_framework_fix(
1109                FixActionType::RenderComponent,
1110                "Render the reachable component from project code, or remove it",
1111                "Manual review required: exported library components and dynamic render registries can be intentionally reachable without static template usage.",
1112            ),
1113            suppress_line("// fallow-ignore-next-line unrendered-component"),
1114        ];
1115        Self {
1116            component,
1117            actions,
1118            introduced: None,
1119        }
1120    }
1121}
1122
1123/// Wire-shape envelope for an [`UnusedComponentProp`] finding. There is no safe
1124/// auto-fix: removing a declared prop is judgement-bearing (the prop may be part
1125/// of a deliberately-stable public component API). Actions are manual
1126/// remediation guidance plus a line-level suppress at the prop declaration.
1127#[derive(Debug, Clone, Serialize, Deserialize)]
1128#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1129pub struct UnusedComponentPropFinding {
1130    /// The underlying finding.
1131    #[serde(flatten)]
1132    pub prop: UnusedComponentProp,
1133    /// Suggested next steps. Always emitted (possibly empty for
1134    /// forward-compat).
1135    pub actions: Vec<IssueAction>,
1136    /// Set by the audit pass when this finding is introduced relative to
1137    /// the merge-base.
1138    #[serde(default, skip_serializing_if = "Option::is_none")]
1139    pub introduced: Option<AuditIntroduced>,
1140}
1141
1142impl UnusedComponentPropFinding {
1143    /// Build the wrapper from a raw [`UnusedComponentProp`]. Emits a manual
1144    /// fix action plus a line-level suppress.
1145    #[must_use]
1146    pub fn with_actions(prop: UnusedComponentProp) -> Self {
1147        let actions = vec![
1148            manual_framework_fix(
1149                FixActionType::UseComponentProp,
1150                "Use the declared prop in the component, or remove it from the component API",
1151                "Manual review required: public component APIs can intentionally keep stable props for external consumers.",
1152            ),
1153            suppress_line("// fallow-ignore-next-line unused-component-prop"),
1154        ];
1155        Self {
1156            prop,
1157            actions,
1158            introduced: None,
1159        }
1160    }
1161}
1162
1163/// Wire-shape envelope for an [`UnusedComponentEmit`] finding. There is no safe
1164/// auto-fix: removing a declared emit is judgement-bearing (the event may be
1165/// part of a deliberately-stable public component API). Actions are manual
1166/// remediation guidance plus a line-level suppress at the emit declaration.
1167#[derive(Debug, Clone, Serialize, Deserialize)]
1168#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1169pub struct UnusedComponentEmitFinding {
1170    /// The underlying finding.
1171    #[serde(flatten)]
1172    pub emit: UnusedComponentEmit,
1173    /// Suggested next steps. Always emitted (possibly empty for
1174    /// forward-compat).
1175    pub actions: Vec<IssueAction>,
1176    /// Set by the audit pass when this finding is introduced relative to
1177    /// the merge-base.
1178    #[serde(default, skip_serializing_if = "Option::is_none")]
1179    pub introduced: Option<AuditIntroduced>,
1180}
1181
1182impl UnusedComponentEmitFinding {
1183    /// Build the wrapper from a raw [`UnusedComponentEmit`]. Emits a manual
1184    /// fix action plus a line-level suppress.
1185    #[must_use]
1186    pub fn with_actions(emit: UnusedComponentEmit) -> Self {
1187        let actions = vec![
1188            manual_framework_fix(
1189                FixActionType::EmitComponentEvent,
1190                "Emit the declared event from the component, or remove it from the component API",
1191                "Manual review required: public component APIs can intentionally keep stable events for external listeners.",
1192            ),
1193            suppress_line("// fallow-ignore-next-line unused-component-emit"),
1194        ];
1195        Self {
1196            emit,
1197            actions,
1198            introduced: None,
1199        }
1200    }
1201}
1202
1203/// Wire-shape envelope for an [`UnusedSvelteEvent`] finding. There is no safe
1204/// auto-fix: removing a dispatched event is judgement-bearing (the event may be
1205/// part of a deliberately-stable public component API, or a listener may be
1206/// added later). Actions are manual remediation guidance plus a line-level
1207/// suppress at the `dispatch` call.
1208#[derive(Debug, Clone, Serialize, Deserialize)]
1209#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1210pub struct UnusedSvelteEventFinding {
1211    /// The underlying finding.
1212    #[serde(flatten)]
1213    pub event: UnusedSvelteEvent,
1214    /// Suggested next steps. Always emitted (possibly empty for
1215    /// forward-compat).
1216    pub actions: Vec<IssueAction>,
1217    /// Set by the audit pass when this finding is introduced relative to
1218    /// the merge-base.
1219    #[serde(default, skip_serializing_if = "Option::is_none")]
1220    pub introduced: Option<AuditIntroduced>,
1221}
1222
1223impl UnusedSvelteEventFinding {
1224    /// Build the wrapper from a raw [`UnusedSvelteEvent`]. Emits a manual fix
1225    /// action plus a line-level suppress.
1226    #[must_use]
1227    pub fn with_actions(event: UnusedSvelteEvent) -> Self {
1228        let actions = vec![
1229            manual_framework_fix(
1230                FixActionType::WireSvelteEvent,
1231                "Add or forward a listener for this custom event, or remove the dispatch",
1232                "Manual review required: public Svelte component APIs can intentionally dispatch events for package consumers outside this project.",
1233            ),
1234            suppress_line("// fallow-ignore-next-line unused-svelte-event"),
1235        ];
1236        Self {
1237            event,
1238            actions,
1239            introduced: None,
1240        }
1241    }
1242}
1243
1244/// Wire-shape envelope for a [`PropDrillingChain`] finding. There is no safe
1245/// auto-fix: collapsing a drilling chain (colocate the consumer, lift to a
1246/// context, or compose the component) is a design decision. The only action is a
1247/// line-level suppress at the source hop's prop declaration. The rule defaults
1248/// to `off` (opt-in health signal), so this finding is dormant by default.
1249#[derive(Debug, Clone, Serialize, Deserialize)]
1250#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1251pub struct PropDrillingChainFinding {
1252    /// The underlying located chain.
1253    #[serde(flatten)]
1254    pub chain: PropDrillingChain,
1255    /// Suggested next steps. Always emitted (possibly empty for
1256    /// forward-compat).
1257    pub actions: Vec<IssueAction>,
1258    /// Set by the audit pass when this finding is introduced relative to
1259    /// the merge-base.
1260    #[serde(default, skip_serializing_if = "Option::is_none")]
1261    pub introduced: Option<AuditIntroduced>,
1262}
1263
1264impl PropDrillingChainFinding {
1265    /// Build the wrapper from a raw [`PropDrillingChain`]. Emits only a
1266    /// line-level suppress action anchored at the source hop: there is no safe
1267    /// auto-fix because collapsing the chain is a design decision (colocate,
1268    /// lift to context, or compose).
1269    #[must_use]
1270    pub fn with_actions(chain: PropDrillingChain) -> Self {
1271        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1272            kind: SuppressLineKind::SuppressLine,
1273            auto_fixable: false,
1274            description: "Suppress with an inline comment above the source prop declaration"
1275                .to_string(),
1276            comment: "// fallow-ignore-next-line prop-drilling".to_string(),
1277            scope: None,
1278        })];
1279        Self {
1280            chain,
1281            actions,
1282            introduced: None,
1283        }
1284    }
1285}
1286
1287/// Wire-shape envelope for a [`ThinWrapper`] finding. There is no safe
1288/// auto-fix: inlining a thin wrapper at its call sites (or deleting it) is a
1289/// design decision. The only action is a line-level suppress at the wrapper's
1290/// definition. The rule defaults to `off` (opt-in health signal), so this
1291/// finding is dormant by default.
1292#[derive(Debug, Clone, Serialize, Deserialize)]
1293#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1294pub struct ThinWrapperFinding {
1295    /// The underlying located thin wrapper.
1296    #[serde(flatten)]
1297    pub wrapper: ThinWrapper,
1298    /// Suggested next steps. Always emitted (possibly empty for
1299    /// forward-compat).
1300    pub actions: Vec<IssueAction>,
1301    /// Set by the audit pass when this finding is introduced relative to
1302    /// the merge-base.
1303    #[serde(default, skip_serializing_if = "Option::is_none")]
1304    pub introduced: Option<AuditIntroduced>,
1305}
1306
1307impl ThinWrapperFinding {
1308    /// Build the wrapper from a raw [`ThinWrapper`]. Emits only a line-level
1309    /// suppress action anchored at the wrapper definition: there is no safe
1310    /// auto-fix because inlining or deleting the wrapper is a design decision.
1311    #[must_use]
1312    pub fn with_actions(wrapper: ThinWrapper) -> Self {
1313        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1314            kind: SuppressLineKind::SuppressLine,
1315            auto_fixable: false,
1316            description: "Suppress with an inline comment above the component definition"
1317                .to_string(),
1318            comment: "// fallow-ignore-next-line thin-wrapper".to_string(),
1319            scope: None,
1320        })];
1321        Self {
1322            wrapper,
1323            actions,
1324            introduced: None,
1325        }
1326    }
1327}
1328
1329/// Wire-shape envelope for a [`DuplicatePropShape`] finding. There is no safe
1330/// auto-fix: extracting a shared `Props` type or a base component for a group of
1331/// same-shaped components is a design decision. The actions are manual guidance
1332/// (extract the shared shape) plus a line-level suppress at the component
1333/// definition and a file-level suppress escape hatch (mirroring the
1334/// route-collision multi-file model). The rule defaults to `off` (opt-in health
1335/// signal), so this finding is dormant by default.
1336#[derive(Debug, Clone, Serialize, Deserialize)]
1337#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1338pub struct DuplicatePropShapeFinding {
1339    /// The underlying duplicate-prop-shape entry.
1340    #[serde(flatten)]
1341    pub shape: DuplicatePropShape,
1342    /// Suggested next steps. Always emitted (possibly empty for
1343    /// forward-compat).
1344    pub actions: Vec<IssueAction>,
1345    /// Set by the audit pass when this finding is introduced relative to
1346    /// the merge-base.
1347    #[serde(default, skip_serializing_if = "Option::is_none")]
1348    pub introduced: Option<AuditIntroduced>,
1349}
1350
1351impl DuplicatePropShapeFinding {
1352    /// Build the wrapper from a raw [`DuplicatePropShape`]. Manual guidance is
1353    /// the primary action (extract a shared shape); a line-level suppress at the
1354    /// component definition and a file-level suppress escape hatch follow,
1355    /// mirroring the multi-file route-collision suppress model. There is no safe
1356    /// auto-fix because extracting a shared type or base component is a design
1357    /// decision.
1358    #[must_use]
1359    pub fn with_actions(shape: DuplicatePropShape) -> Self {
1360        let actions = vec![
1361            IssueAction::SuppressLine(SuppressLineAction {
1362                kind: SuppressLineKind::SuppressLine,
1363                auto_fixable: false,
1364                description: "Three or more components share this exact prop shape. Extract one \
1365                              shared `Props` type (or a base component) that every member reuses, \
1366                              or keep them separate if a per-variant divergence is planned. \
1367                              Suppress one member with an inline comment above the component \
1368                              definition."
1369                    .to_string(),
1370                comment: "// fallow-ignore-next-line duplicate-prop-shape".to_string(),
1371                scope: None,
1372            }),
1373            IssueAction::SuppressFile(SuppressFileAction {
1374                kind: SuppressFileKind::SuppressFile,
1375                auto_fixable: false,
1376                description: "Escape hatch: a file-level suppress silences this member but it \
1377                              still appears in its siblings' `sharing_components` (the group is \
1378                              real regardless of suppression)."
1379                    .to_string(),
1380                comment: "// fallow-ignore-file duplicate-prop-shape".to_string(),
1381            }),
1382        ];
1383        Self {
1384            shape,
1385            actions,
1386            introduced: None,
1387        }
1388    }
1389}
1390
1391/// Wire-shape envelope for an [`UnusedComponentInput`] finding. There is no safe
1392/// auto-fix: removing a declared input is judgement-bearing (the input may be
1393/// part of a deliberately-stable public component API). The only action is a
1394/// line-level suppress at the input declaration.
1395#[derive(Debug, Clone, Serialize, Deserialize)]
1396#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1397pub struct UnusedComponentInputFinding {
1398    /// The underlying finding.
1399    #[serde(flatten)]
1400    pub input: UnusedComponentInput,
1401    /// Suggested next steps. Always emitted (possibly empty for
1402    /// forward-compat).
1403    pub actions: Vec<IssueAction>,
1404    /// Set by the audit pass when this finding is introduced relative to
1405    /// the merge-base.
1406    #[serde(default, skip_serializing_if = "Option::is_none")]
1407    pub introduced: Option<AuditIntroduced>,
1408}
1409
1410impl UnusedComponentInputFinding {
1411    /// Build the wrapper from a raw [`UnusedComponentInput`]. Emits only a
1412    /// line-level suppress action: there is no safe auto-fix because removing an
1413    /// input is a human decision (it may be part of a stable component API).
1414    #[must_use]
1415    pub fn with_actions(input: UnusedComponentInput) -> Self {
1416        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1417            kind: SuppressLineKind::SuppressLine,
1418            auto_fixable: false,
1419            description: "Suppress with an inline comment above the line".to_string(),
1420            comment: "// fallow-ignore-next-line unused-component-input".to_string(),
1421            scope: None,
1422        })];
1423        Self {
1424            input,
1425            actions,
1426            introduced: None,
1427        }
1428    }
1429}
1430
1431/// Wire-shape envelope for an [`UnusedComponentOutput`] finding. There is no safe
1432/// auto-fix: removing a declared output is judgement-bearing (the event may be
1433/// part of a deliberately-stable public component API). The only action is a
1434/// line-level suppress at the output declaration.
1435#[derive(Debug, Clone, Serialize, Deserialize)]
1436#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1437pub struct UnusedComponentOutputFinding {
1438    /// The underlying finding.
1439    #[serde(flatten)]
1440    pub output: UnusedComponentOutput,
1441    /// Suggested next steps. Always emitted (possibly empty for
1442    /// forward-compat).
1443    pub actions: Vec<IssueAction>,
1444    /// Set by the audit pass when this finding is introduced relative to
1445    /// the merge-base.
1446    #[serde(default, skip_serializing_if = "Option::is_none")]
1447    pub introduced: Option<AuditIntroduced>,
1448}
1449
1450impl UnusedComponentOutputFinding {
1451    /// Build the wrapper from a raw [`UnusedComponentOutput`]. Emits only a
1452    /// line-level suppress action: there is no safe auto-fix because removing an
1453    /// output is a human decision (it may be part of a stable component API).
1454    #[must_use]
1455    pub fn with_actions(output: UnusedComponentOutput) -> Self {
1456        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1457            kind: SuppressLineKind::SuppressLine,
1458            auto_fixable: false,
1459            description: "Suppress with an inline comment above the line".to_string(),
1460            comment: "// fallow-ignore-next-line unused-component-output".to_string(),
1461            scope: None,
1462        })];
1463        Self {
1464            output,
1465            actions,
1466            introduced: None,
1467        }
1468    }
1469}
1470
1471/// Wire-shape envelope for a [`RouteCollision`] finding. A route collision is a
1472/// guaranteed `next build` failure, so the PRIMARY action is manual guidance
1473/// (move or merge one of the colliding files), NOT a suppress: suppressing a
1474/// build error never makes the build pass. A file-level suppress is offered as
1475/// an escape hatch only.
1476#[derive(Debug, Clone, Serialize, Deserialize)]
1477#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1478pub struct RouteCollisionFinding {
1479    /// The underlying route-collision entry.
1480    #[serde(flatten)]
1481    pub collision: RouteCollision,
1482    /// Suggested next steps. Always emitted (possibly empty for
1483    /// forward-compat).
1484    pub actions: Vec<IssueAction>,
1485    /// Set by the audit pass when this finding is introduced relative to
1486    /// the merge-base.
1487    #[serde(default, skip_serializing_if = "Option::is_none")]
1488    pub introduced: Option<AuditIntroduced>,
1489}
1490
1491impl RouteCollisionFinding {
1492    /// Build the wrapper from a raw [`RouteCollision`]. The primary action is
1493    /// manual guidance because suppressing a guaranteed build error is never
1494    /// the right fix; a file-level suppress is the escape hatch only.
1495    #[must_use]
1496    pub fn with_actions(collision: RouteCollision) -> Self {
1497        let actions = vec![
1498            IssueAction::Fix(FixAction {
1499                kind: FixActionType::ResolveRouteCollision,
1500                auto_fixable: false,
1501                description: "Two or more files resolve to the same URL. Move or merge one so \
1502                              each URL has a single owner. Route groups `(name)` and parallel \
1503                              slots `@name` are the only legal same-URL shapes."
1504                    .to_string(),
1505                note: Some(
1506                    "Next.js fails the build with \"You cannot have two parallel pages that \
1507                     resolve to the same path\". See the sibling `conflicting_paths` array for \
1508                     the other files that own this URL."
1509                        .to_string(),
1510                ),
1511                available_in_catalogs: None,
1512                suggested_target: None,
1513            }),
1514            IssueAction::SuppressFile(SuppressFileAction {
1515                kind: SuppressFileKind::SuppressFile,
1516                auto_fixable: false,
1517                description: "Escape hatch only: a file-level suppress silences the finding but \
1518                              does NOT make `next build` pass. Prefer moving or merging a file."
1519                    .to_string(),
1520                comment: "// fallow-ignore-file route-collision".to_string(),
1521            }),
1522        ];
1523        Self {
1524            collision,
1525            actions,
1526            introduced: None,
1527        }
1528    }
1529}
1530
1531/// Wire-shape envelope for a [`DynamicSegmentNameConflict`] finding. The
1532/// conflict is a Next.js dev / runtime error (`next build` does NOT catch it),
1533/// so the primary action is manual guidance (rename the dynamic segments to a
1534/// single consistent slug name), with a file-level suppress as escape hatch.
1535#[derive(Debug, Clone, Serialize, Deserialize)]
1536#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1537pub struct DynamicSegmentNameConflictFinding {
1538    /// The underlying dynamic-segment-name-conflict entry.
1539    #[serde(flatten)]
1540    pub conflict: DynamicSegmentNameConflict,
1541    /// Suggested next steps. Always emitted (possibly empty for
1542    /// forward-compat).
1543    pub actions: Vec<IssueAction>,
1544    /// Set by the audit pass when this finding is introduced relative to
1545    /// the merge-base.
1546    #[serde(default, skip_serializing_if = "Option::is_none")]
1547    pub introduced: Option<AuditIntroduced>,
1548}
1549
1550impl DynamicSegmentNameConflictFinding {
1551    /// Build the wrapper from a raw [`DynamicSegmentNameConflict`]. Manual
1552    /// guidance primary action; file-level suppress escape hatch only.
1553    #[must_use]
1554    pub fn with_actions(conflict: DynamicSegmentNameConflict) -> Self {
1555        let actions = vec![
1556            IssueAction::Fix(FixAction {
1557                kind: FixActionType::ResolveDynamicSegmentNameConflict,
1558                auto_fixable: false,
1559                description: "Sibling dynamic segments at the same position use different param \
1560                              names. Rename them to one consistent slug name (e.g. pick `[id]` \
1561                              or `[slug]` for both)."
1562                    .to_string(),
1563                note: Some(
1564                    "Next.js throws \"You cannot use different slug names for the same dynamic \
1565                     path\" at dev / runtime when the position is hit; `next build` does not \
1566                     catch it. See the sibling `conflicting_segments` array."
1567                        .to_string(),
1568                ),
1569                available_in_catalogs: None,
1570                suggested_target: None,
1571            }),
1572            IssueAction::SuppressFile(SuppressFileAction {
1573                kind: SuppressFileKind::SuppressFile,
1574                auto_fixable: false,
1575                description: "Escape hatch only: a file-level suppress silences the finding but \
1576                              does NOT stop Next.js from throwing at dev / runtime. Prefer \
1577                              renaming the segments."
1578                    .to_string(),
1579                comment: "// fallow-ignore-file dynamic-segment-name-conflict".to_string(),
1580            }),
1581        ];
1582        Self {
1583            conflict,
1584            actions,
1585            introduced: None,
1586        }
1587    }
1588}
1589
1590/// Wire-shape envelope for an [`UnusedMember`] finding consumed under the
1591/// `unused_enum_members` key.
1592#[derive(Debug, Clone, Serialize, Deserialize)]
1593#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1594pub struct UnusedEnumMemberFinding {
1595    /// The underlying dead-code entry.
1596    #[serde(flatten)]
1597    pub member: UnusedMember,
1598    /// Suggested next steps. Always emitted (possibly empty for
1599    /// forward-compat).
1600    pub actions: Vec<IssueAction>,
1601    /// Set by the audit pass when this finding is introduced relative to
1602    /// the merge-base.
1603    #[serde(default, skip_serializing_if = "Option::is_none")]
1604    pub introduced: Option<AuditIntroduced>,
1605}
1606
1607impl UnusedEnumMemberFinding {
1608    /// Build the wrapper from a raw [`UnusedMember`].
1609    #[must_use]
1610    pub fn with_actions(member: UnusedMember) -> Self {
1611        let actions = vec![
1612            IssueAction::Fix(FixAction {
1613                kind: FixActionType::RemoveEnumMember,
1614                auto_fixable: true,
1615                description: "Remove this enum member".to_string(),
1616                note: None,
1617                available_in_catalogs: None,
1618                suggested_target: None,
1619            }),
1620            IssueAction::SuppressLine(SuppressLineAction {
1621                kind: SuppressLineKind::SuppressLine,
1622                auto_fixable: false,
1623                description: "Suppress with an inline comment above the line".to_string(),
1624                comment: "// fallow-ignore-next-line unused-enum-member".to_string(),
1625                scope: None,
1626            }),
1627        ];
1628        Self {
1629            member,
1630            actions,
1631            introduced: None,
1632        }
1633    }
1634}
1635
1636/// Wire-shape envelope for an [`UnusedMember`] finding consumed under the
1637/// `unused_class_members` key. Same Rust struct as
1638/// [`UnusedEnumMemberFinding`]; the fix action and suppress comment carry
1639/// the class-member kebab-case identifier instead.
1640#[derive(Debug, Clone, Serialize, Deserialize)]
1641#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1642pub struct UnusedClassMemberFinding {
1643    /// The underlying dead-code entry.
1644    #[serde(flatten)]
1645    pub member: UnusedMember,
1646    /// Suggested next steps. Always emitted (possibly empty for
1647    /// forward-compat).
1648    pub actions: Vec<IssueAction>,
1649    /// Type-aware evidence for this exact candidate when requested.
1650    #[serde(default, skip_serializing_if = "Option::is_none")]
1651    pub semantic: Option<SemanticCandidateDecision>,
1652    /// Internal marker for a framework member that the syntactic analysis
1653    /// suppresses, but the semantic pass may promote after proving complete
1654    /// closed-world absence. Never serialized as part of the public finding.
1655    #[serde(skip)]
1656    #[cfg_attr(feature = "schema", schemars(skip))]
1657    pub semantic_only_candidate: bool,
1658    /// Set by the audit pass when this finding is introduced relative to
1659    /// the merge-base.
1660    #[serde(default, skip_serializing_if = "Option::is_none")]
1661    pub introduced: Option<AuditIntroduced>,
1662}
1663
1664impl UnusedClassMemberFinding {
1665    /// Build the wrapper from a raw [`UnusedMember`]. Class-member fixes
1666    /// are not auto-applied (members can be used via dependency injection
1667    /// or decorators), so `auto_fixable` is `false` and a context note is
1668    /// attached.
1669    #[must_use]
1670    pub fn with_actions(member: UnusedMember) -> Self {
1671        let actions = vec![
1672            IssueAction::Fix(FixAction {
1673                kind: FixActionType::RemoveClassMember,
1674                auto_fixable: false,
1675                description: "Remove this class member".to_string(),
1676                note: Some(
1677                    "Class member may be used via dependency injection or decorators".to_string(),
1678                ),
1679                available_in_catalogs: None,
1680                suggested_target: None,
1681            }),
1682            IssueAction::SuppressLine(SuppressLineAction {
1683                kind: SuppressLineKind::SuppressLine,
1684                auto_fixable: false,
1685                description: "Suppress with an inline comment above the line".to_string(),
1686                comment: "// fallow-ignore-next-line unused-class-member".to_string(),
1687                scope: None,
1688            }),
1689        ];
1690        Self {
1691            member,
1692            actions,
1693            semantic: None,
1694            semantic_only_candidate: false,
1695            introduced: None,
1696        }
1697    }
1698
1699    /// Mark this finding as latent until semantic analysis proves that the
1700    /// framework contract does not apply and no static references exist.
1701    #[must_use]
1702    pub const fn semantic_only_candidate(mut self) -> Self {
1703        self.semantic_only_candidate = true;
1704        self
1705    }
1706
1707    /// Attach the canonical semantic decision and expose the class-member fix
1708    /// only when the API policy granted closed-world eligibility.
1709    pub fn set_semantic_decision(&mut self, decision: SemanticCandidateDecision) {
1710        if let Some(IssueAction::Fix(action)) = self.actions.first_mut() {
1711            action.auto_fixable = decision.closed_world_eligible;
1712            action.note = Some(decision.explanation.clone());
1713        }
1714        self.semantic = Some(decision);
1715    }
1716}
1717
1718/// Wire-shape envelope for an [`UnusedMember`] finding consumed under the
1719/// `unused_store_members` key (a Pinia `state` / `getters` / `actions` key, or
1720/// a setup-store returned key, declared but never accessed by any consumer
1721/// project-wide). Same Rust struct as [`UnusedClassMemberFinding`]. Emits only
1722/// a line-level suppress action: there is no safe auto-fix because a store
1723/// member can be accessed reflectively (a Pinia plugin, `store.$onAction`, or
1724/// dynamic dispatch) in ways syntactic analysis cannot see, so removal is a
1725/// behavioral change the user must own.
1726#[derive(Debug, Clone, Serialize, Deserialize)]
1727#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1728pub struct UnusedStoreMemberFinding {
1729    /// The underlying dead-code entry.
1730    #[serde(flatten)]
1731    pub member: UnusedMember,
1732    /// Suggested next steps. Always emitted (possibly empty for
1733    /// forward-compat).
1734    pub actions: Vec<IssueAction>,
1735    /// Set by the audit pass when this finding is introduced relative to
1736    /// the merge-base.
1737    #[serde(default, skip_serializing_if = "Option::is_none")]
1738    pub introduced: Option<AuditIntroduced>,
1739}
1740
1741impl UnusedStoreMemberFinding {
1742    /// Build the wrapper from a raw [`UnusedMember`]. Emits only a line-level
1743    /// suppress action (no auto-fix: store members can be accessed
1744    /// reflectively, so removal is never provably safe).
1745    #[must_use]
1746    pub fn with_actions(member: UnusedMember) -> Self {
1747        let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
1748            kind: SuppressLineKind::SuppressLine,
1749            auto_fixable: false,
1750            description: "Suppress with an inline comment above the line".to_string(),
1751            comment: "// fallow-ignore-next-line unused-store-member".to_string(),
1752            scope: None,
1753        })];
1754        Self {
1755            member,
1756            actions,
1757            introduced: None,
1758        }
1759    }
1760}
1761
1762/// Build the `IssueAction` vec for the three `unused_dependencies`,
1763/// `unused_dev_dependencies`, `unused_optional_dependencies` views over the
1764/// same bare [`UnusedDependency`] struct. Each wrapper differs only in the
1765/// `package_json_location` string (`"dependencies"` / `"devDependencies"` /
1766/// `"optionalDependencies"`) baked into the fix-action description and in
1767/// the `suppress_issue_kind` used by the inline-suppress comment. All three
1768/// share the cross-workspace swap (when `dep.used_in_workspaces` is
1769/// non-empty the primary fix flips from `remove-dependency` to
1770/// `move-dependency` because the dep is imported by ANOTHER workspace and
1771/// `fallow fix` cannot safely remove it).
1772fn build_unused_dependency_actions(
1773    dep: &UnusedDependency,
1774    package_json_location: &str,
1775    suppress_issue_kind: &str,
1776) -> Vec<IssueAction> {
1777    let mut actions = Vec::with_capacity(2);
1778    let cross_workspace = !dep.used_in_workspaces.is_empty();
1779    actions.push(if cross_workspace {
1780        IssueAction::Fix(FixAction {
1781            kind: FixActionType::MoveDependency,
1782            auto_fixable: false,
1783            description: "Move this dependency to the workspace package.json that imports it"
1784                .to_string(),
1785            note: Some(
1786                "fallow fix will not remove dependencies that are imported by another workspace"
1787                    .to_string(),
1788            ),
1789            available_in_catalogs: None,
1790            suggested_target: None,
1791        })
1792    } else {
1793        IssueAction::Fix(FixAction {
1794            kind: FixActionType::RemoveDependency,
1795            auto_fixable: true,
1796            description: format!("Remove from {package_json_location} in package.json"),
1797            note: None,
1798            available_in_catalogs: None,
1799            suggested_target: None,
1800        })
1801    });
1802    actions.push(build_ignore_dependencies_suppress_action(
1803        &dep.package_name,
1804        suppress_issue_kind,
1805    ));
1806    actions
1807}
1808
1809/// Build the standard `add-to-config` `ignoreDependencies` suppress action
1810/// for any finding whose primary key is a package name. Used by the four
1811/// dependency-family wrappers (unused / unlisted / type-only / test-only).
1812/// The `_suppress_issue_kind` argument is currently unused; the pre-2.76
1813/// `inject_actions` post-pass also did not embed the issue kind in this
1814/// shape (no inline `// fallow-ignore-next-line ...` comment because the
1815/// finding is anchored at a package.json line, not at a source-file line).
1816fn build_ignore_dependencies_suppress_action(
1817    package_name: &str,
1818    _suppress_issue_kind: &str,
1819) -> IssueAction {
1820    IssueAction::AddToConfig(AddToConfigAction {
1821        kind: AddToConfigKind::AddToConfig,
1822        auto_fixable: false,
1823        description: format!("Add \"{package_name}\" to ignoreDependencies in fallow config"),
1824        config_key: "ignoreDependencies".to_string(),
1825        value: AddToConfigValue::Scalar(package_name.to_string()),
1826        value_schema: Some(
1827            "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items"
1828                .to_string(),
1829        ),
1830    })
1831}
1832
1833/// Wire-shape envelope for an [`UnusedDependency`] finding consumed under
1834/// the `unused_dependencies` key (production deps). Flattens the bare
1835/// finding; the typed `actions` array carries either a `remove-dependency`
1836/// or `move-dependency` primary depending on
1837/// `inner.used_in_workspaces`.
1838#[derive(Debug, Clone, Serialize, Deserialize)]
1839#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1840pub struct UnusedDependencyFinding {
1841    /// The underlying dead-code entry.
1842    #[serde(flatten)]
1843    pub dep: UnusedDependency,
1844    /// Suggested next steps. Always emitted (possibly empty for
1845    /// forward-compat).
1846    pub actions: Vec<IssueAction>,
1847    /// Set by the audit pass when this finding is introduced relative to
1848    /// the merge-base.
1849    #[serde(default, skip_serializing_if = "Option::is_none")]
1850    pub introduced: Option<AuditIntroduced>,
1851}
1852
1853impl UnusedDependencyFinding {
1854    /// Build the wrapper. Switches the primary fix from `remove-dependency`
1855    /// to `move-dependency` when the dep is imported by another workspace.
1856    #[must_use]
1857    pub fn with_actions(dep: UnusedDependency) -> Self {
1858        let actions = build_unused_dependency_actions(&dep, "dependencies", "unused-dependency");
1859        Self {
1860            dep,
1861            actions,
1862            introduced: None,
1863        }
1864    }
1865}
1866
1867/// Wire-shape envelope for an [`UnusedDependency`] finding consumed under
1868/// the `unused_dev_dependencies` key. Same bare struct as
1869/// [`UnusedDependencyFinding`]; the fix description points at
1870/// `devDependencies` and the suppress comment uses
1871/// `unused-dev-dependency`.
1872#[derive(Debug, Clone, Serialize, Deserialize)]
1873#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1874pub struct UnusedDevDependencyFinding {
1875    /// The underlying dead-code entry.
1876    #[serde(flatten)]
1877    pub dep: UnusedDependency,
1878    /// Suggested next steps. Always emitted (possibly empty for
1879    /// forward-compat).
1880    pub actions: Vec<IssueAction>,
1881    /// Set by the audit pass when this finding is introduced relative to
1882    /// the merge-base.
1883    #[serde(default, skip_serializing_if = "Option::is_none")]
1884    pub introduced: Option<AuditIntroduced>,
1885}
1886
1887impl UnusedDevDependencyFinding {
1888    /// Build the wrapper.
1889    #[must_use]
1890    pub fn with_actions(dep: UnusedDependency) -> Self {
1891        let actions =
1892            build_unused_dependency_actions(&dep, "devDependencies", "unused-dev-dependency");
1893        Self {
1894            dep,
1895            actions,
1896            introduced: None,
1897        }
1898    }
1899}
1900
1901/// Wire-shape envelope for an [`UnusedDependency`] finding consumed under
1902/// the `unused_optional_dependencies` key. Same bare struct as
1903/// [`UnusedDependencyFinding`]; the fix description points at
1904/// `optionalDependencies`. Reuses the `unused-dependency` suppress
1905/// `IssueKind` because there is no dedicated variant for optional deps.
1906#[derive(Debug, Clone, Serialize, Deserialize)]
1907#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1908pub struct UnusedOptionalDependencyFinding {
1909    /// The underlying dead-code entry.
1910    #[serde(flatten)]
1911    pub dep: UnusedDependency,
1912    /// Suggested next steps. Always emitted (possibly empty for
1913    /// forward-compat).
1914    pub actions: Vec<IssueAction>,
1915    /// Set by the audit pass when this finding is introduced relative to
1916    /// the merge-base.
1917    #[serde(default, skip_serializing_if = "Option::is_none")]
1918    pub introduced: Option<AuditIntroduced>,
1919}
1920
1921impl UnusedOptionalDependencyFinding {
1922    /// Build the wrapper.
1923    #[must_use]
1924    pub fn with_actions(dep: UnusedDependency) -> Self {
1925        let actions =
1926            build_unused_dependency_actions(&dep, "optionalDependencies", "unused-dependency");
1927        Self {
1928            dep,
1929            actions,
1930            introduced: None,
1931        }
1932    }
1933}
1934
1935/// Wire-shape envelope for an [`UnlistedDependency`] finding. Carries an
1936/// `install-dependency` primary (non-auto-fixable) plus the standard
1937/// `ignoreDependencies` config suppress.
1938#[derive(Debug, Clone, Serialize, Deserialize)]
1939#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1940pub struct UnlistedDependencyFinding {
1941    /// The underlying dead-code entry.
1942    #[serde(flatten)]
1943    pub dep: UnlistedDependency,
1944    /// Suggested next steps. Always emitted (possibly empty for
1945    /// forward-compat).
1946    pub actions: Vec<IssueAction>,
1947    /// Set by the audit pass when this finding is introduced relative to
1948    /// the merge-base.
1949    #[serde(default, skip_serializing_if = "Option::is_none")]
1950    pub introduced: Option<AuditIntroduced>,
1951}
1952
1953impl UnlistedDependencyFinding {
1954    /// Build the wrapper.
1955    #[must_use]
1956    pub fn with_actions(dep: UnlistedDependency) -> Self {
1957        let actions = vec![
1958            IssueAction::Fix(FixAction {
1959                kind: FixActionType::InstallDependency,
1960                auto_fixable: false,
1961                description: "Add this package to dependencies in package.json".to_string(),
1962                note: Some(
1963                    "Verify this package should be a direct dependency before adding".to_string(),
1964                ),
1965                available_in_catalogs: None,
1966                suggested_target: None,
1967            }),
1968            build_ignore_dependencies_suppress_action(&dep.package_name, "unlisted-dependency"),
1969        ];
1970        Self {
1971            dep,
1972            actions,
1973            introduced: None,
1974        }
1975    }
1976}
1977
1978/// Wire-shape envelope for a [`TypeOnlyDependency`] finding. Carries a
1979/// `move-to-dev` primary plus the standard `ignoreDependencies` config
1980/// suppress.
1981#[derive(Debug, Clone, Serialize, Deserialize)]
1982#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1983pub struct TypeOnlyDependencyFinding {
1984    /// The underlying dead-code entry.
1985    #[serde(flatten)]
1986    pub dep: TypeOnlyDependency,
1987    /// Suggested next steps. Always emitted (possibly empty for
1988    /// forward-compat).
1989    pub actions: Vec<IssueAction>,
1990    /// Set by the audit pass when this finding is introduced relative to
1991    /// the merge-base.
1992    #[serde(default, skip_serializing_if = "Option::is_none")]
1993    pub introduced: Option<AuditIntroduced>,
1994}
1995
1996impl TypeOnlyDependencyFinding {
1997    /// Build the wrapper.
1998    #[must_use]
1999    pub fn with_actions(dep: TypeOnlyDependency) -> Self {
2000        let actions = vec![
2001            IssueAction::Fix(FixAction {
2002                kind: FixActionType::MoveToDev,
2003                auto_fixable: false,
2004                description: "Move to devDependencies (only type imports are used)".to_string(),
2005                note: Some(
2006                    "Type imports are erased at runtime so this dependency is not needed in production"
2007                        .to_string(),
2008                ),
2009                available_in_catalogs: None,
2010                suggested_target: None,
2011            }),
2012            build_ignore_dependencies_suppress_action(&dep.package_name, "type-only-dependency"),
2013        ];
2014        Self {
2015            dep,
2016            actions,
2017            introduced: None,
2018        }
2019    }
2020}
2021
2022/// Wire-shape envelope for a [`TestOnlyDependency`] finding. Carries a
2023/// `move-to-dev` primary (different prose than [`TypeOnlyDependencyFinding`])
2024/// plus the standard `ignoreDependencies` config suppress.
2025#[derive(Debug, Clone, Serialize, Deserialize)]
2026#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2027pub struct TestOnlyDependencyFinding {
2028    /// The underlying dead-code entry.
2029    #[serde(flatten)]
2030    pub dep: TestOnlyDependency,
2031    /// Suggested next steps. Always emitted (possibly empty for
2032    /// forward-compat).
2033    pub actions: Vec<IssueAction>,
2034    /// Set by the audit pass when this finding is introduced relative to
2035    /// the merge-base.
2036    #[serde(default, skip_serializing_if = "Option::is_none")]
2037    pub introduced: Option<AuditIntroduced>,
2038}
2039
2040impl TestOnlyDependencyFinding {
2041    /// Build the wrapper.
2042    #[must_use]
2043    pub fn with_actions(dep: TestOnlyDependency) -> Self {
2044        let actions = vec![
2045            IssueAction::Fix(FixAction {
2046                kind: FixActionType::MoveToDev,
2047                auto_fixable: false,
2048                description: "Move to devDependencies (only test files import this)".to_string(),
2049                note: Some(
2050                    "Only test files import this package so it does not need to be a production dependency"
2051                        .to_string(),
2052                ),
2053                available_in_catalogs: None,
2054                suggested_target: None,
2055            }),
2056            build_ignore_dependencies_suppress_action(&dep.package_name, "test-only-dependency"),
2057        ];
2058        Self {
2059            dep,
2060            actions,
2061            introduced: None,
2062        }
2063    }
2064}
2065
2066/// Wire-shape envelope for a [`DevDependencyInProduction`] finding. Carries a
2067/// `move-to-prod` primary (the promote-side mirror of
2068/// [`TestOnlyDependencyFinding`]'s `move-to-dev`) plus the standard
2069/// `ignoreDependencies` config suppress.
2070#[derive(Debug, Clone, Serialize, Deserialize)]
2071#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2072pub struct DevDependencyInProductionFinding {
2073    /// The underlying dead-code entry.
2074    #[serde(flatten)]
2075    pub dep: DevDependencyInProduction,
2076    /// Suggested next steps. Always emitted (possibly empty for
2077    /// forward-compat).
2078    pub actions: Vec<IssueAction>,
2079    /// Set by the audit pass when this finding is introduced relative to
2080    /// the merge-base.
2081    #[serde(default, skip_serializing_if = "Option::is_none")]
2082    pub introduced: Option<AuditIntroduced>,
2083}
2084
2085impl DevDependencyInProductionFinding {
2086    /// Build the wrapper.
2087    #[must_use]
2088    pub fn with_actions(dep: DevDependencyInProduction) -> Self {
2089        let actions = vec![
2090            IssueAction::Fix(FixAction {
2091                kind: FixActionType::MoveToProd,
2092                auto_fixable: false,
2093                description:
2094                    "Move to dependencies if the deployment installs them (production code imports this)"
2095                        .to_string(),
2096                note: Some(
2097                    "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"
2098                        .to_string(),
2099                ),
2100                available_in_catalogs: None,
2101                suggested_target: None,
2102            }),
2103            build_ignore_dependencies_suppress_action(
2104                &dep.package_name,
2105                "dev-dependency-in-production",
2106            ),
2107        ];
2108        Self {
2109            dep,
2110            actions,
2111            introduced: None,
2112        }
2113    }
2114}
2115
2116// ── Catalog / dep-override family ───────────────────────────────
2117//
2118// These six wrappers replace the legacy `inject_actions` post-pass in
2119// `crates/cli/src/report/json.rs` for the catalog and dependency-override
2120// findings. Each `with_actions(...)` builds the typed `actions` array
2121// directly from the inner struct (and any per-call context such as
2122// `config_fixable`), so the wire shape is identical to the pre-2.76
2123// post-pass output but the Rust compiler now owns the action contract.
2124
2125/// Wire-shape envelope for a [`DuplicateExport`] finding. Carries up to
2126/// three actions in position-locked order: an `add-to-config` `ignoreExports`
2127/// snippet (only when `locations[]` carries at least one path) followed by
2128/// the `remove-duplicate` fix and the multi-location suppress.
2129///
2130/// The `add-to-config` action sits at position 0 because the documented
2131/// primary slot points at the safe, non-destructive path: the shadcn /
2132/// Radix / bits-ui namespace-barrel case where every `index.*` reexports
2133/// the directory's neighbours. The `remove-duplicate` fix stays as the
2134/// secondary so consumers that pattern-match on `actions[0].type` for
2135/// "primary fix" never propose deletion of an intentional barrel surface.
2136#[derive(Debug, Clone, Serialize, Deserialize)]
2137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2138pub struct DuplicateExportFinding {
2139    /// The underlying finding.
2140    #[serde(flatten)]
2141    pub export: DuplicateExport,
2142    /// Suggested next steps. Always emitted (possibly empty for
2143    /// forward-compat).
2144    pub actions: Vec<IssueAction>,
2145    /// Set by the audit pass when this finding is introduced relative to
2146    /// the merge-base.
2147    #[serde(default, skip_serializing_if = "Option::is_none")]
2148    pub introduced: Option<AuditIntroduced>,
2149}
2150
2151impl DuplicateExportFinding {
2152    /// Build the wrapper with the `add-to-config` action's `auto_fixable`
2153    /// defaulting to `false`. The CLI's `build_json_with_config_fixable`
2154    /// path layers the actual `config_fixable` signal via
2155    /// [`Self::set_config_fixable`] right before serialization (the
2156    /// fix-applier readiness check lives in `fallow-cli::fix` and is not
2157    /// reachable from the analyzer layer where wrappers are first built).
2158    /// Embedders that build `AnalysisResults` directly and never route
2159    /// through the CLI's JSON path keep the conservative default.
2160    #[must_use]
2161    pub fn with_actions(export: DuplicateExport) -> Self {
2162        let mut actions: Vec<IssueAction> = Vec::with_capacity(3);
2163
2164        if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
2165            actions.push(IssueAction::AddToConfig(AddToConfigAction {
2166                kind: AddToConfigKind::AddToConfig,
2167                auto_fixable: false,
2168                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(),
2169                config_key: "ignoreExports".to_string(),
2170                value: AddToConfigValue::ExportsRules(rules),
2171                value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
2172            }));
2173        }
2174
2175        actions.push(IssueAction::Fix(FixAction {
2176            kind: FixActionType::RemoveDuplicate,
2177            auto_fixable: false,
2178            description: "Keep one canonical export location and remove the others".to_string(),
2179            note: Some(NAMESPACE_BARREL_HINT.to_string()),
2180            available_in_catalogs: None,
2181            suggested_target: None,
2182        }));
2183
2184        actions.push(IssueAction::SuppressLine(SuppressLineAction {
2185            kind: SuppressLineKind::SuppressLine,
2186            auto_fixable: false,
2187            description: "Suppress with an inline comment above the line".to_string(),
2188            comment: "// fallow-ignore-next-line duplicate-export".to_string(),
2189            scope: Some(SuppressLineScope::PerLocation),
2190        }));
2191
2192        Self {
2193            export,
2194            actions,
2195            introduced: None,
2196        }
2197    }
2198
2199    /// Update the position-0 `add-to-config` action's `auto_fixable` flag.
2200    /// Idempotent and a no-op when position 0 is not an `add-to-config`
2201    /// action (happens when the finding has no locations). Called by the
2202    /// CLI's JSON serializer with the result of
2203    /// `crate::fix::is_config_fixable` before emitting bytes.
2204    pub fn set_config_fixable(&mut self, fixable: bool) {
2205        if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
2206            action.auto_fixable = fixable;
2207        }
2208    }
2209}
2210
2211/// Build a paste-ready `ignoreExports` config value from a duplicate-export
2212/// finding's locations. Returns one `{ file, exports: ["*"] }` entry per
2213/// distinct file in insertion order. `None` when no locations carry a path.
2214fn build_duplicate_exports_ignore_rules(
2215    export: &DuplicateExport,
2216) -> Option<Vec<IgnoreExportsRule>> {
2217    let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
2218    for loc in &export.locations {
2219        // Normalize separators to forward slashes so pasting the action value
2220        // into `.fallowrc.json` produces a portable rule. On Windows
2221        // `to_string_lossy` preserves backslashes, which the old
2222        // `inject_actions` post-pass implicitly normalized because it read
2223        // the path AFTER `strip_root_prefix` had already run through
2224        // `normalize_uri`; the typed wrapper builds the value before
2225        // serialization, so the normalization has to be explicit here.
2226        let path = loc.path.to_string_lossy().replace('\\', "/");
2227        if path.is_empty() {
2228            continue;
2229        }
2230        if entries.iter().any(|existing| existing.file == path) {
2231            continue;
2232        }
2233        entries.push(IgnoreExportsRule {
2234            file: path,
2235            exports: vec!["*".to_string()],
2236        });
2237    }
2238    if entries.is_empty() {
2239        None
2240    } else {
2241        Some(entries)
2242    }
2243}
2244
2245/// Wire-shape envelope for an [`UnusedCatalogEntry`] finding. Per-instance
2246/// `auto_fixable` flips to `false` when `hardcoded_consumers` is non-empty or
2247/// the source is not `pnpm-workspace.yaml`.
2248#[derive(Debug, Clone, Serialize, Deserialize)]
2249#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2250pub struct UnusedCatalogEntryFinding {
2251    /// The underlying finding.
2252    #[serde(flatten)]
2253    pub entry: UnusedCatalogEntry,
2254    /// Suggested next steps. Always emitted.
2255    pub actions: Vec<IssueAction>,
2256    /// Set by the audit pass when this finding is introduced relative to
2257    /// the merge-base.
2258    #[serde(default, skip_serializing_if = "Option::is_none")]
2259    pub introduced: Option<AuditIntroduced>,
2260}
2261
2262impl UnusedCatalogEntryFinding {
2263    /// Build the wrapper. Per-instance `auto_fixable` is `true` only when
2264    /// `hardcoded_consumers` is empty and the source is `pnpm-workspace.yaml`;
2265    /// otherwise `fallow fix` skips the entry to avoid breaking installs or
2266    /// applying YAML edits to Bun `package.json` catalogs.
2267    #[must_use]
2268    pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
2269        let is_pnpm_source = is_pnpm_catalog_source(&entry.path);
2270        let auto_fixable = entry.hardcoded_consumers.is_empty() && is_pnpm_source;
2271        let note = if is_pnpm_source {
2272            Some(
2273                "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing"
2274                    .to_string(),
2275            )
2276        } else {
2277            Some(
2278                "fallow fix only edits pnpm-workspace.yaml catalog entries. Edit Bun package.json catalogs manually."
2279                    .to_string(),
2280            )
2281        };
2282        let mut actions = vec![IssueAction::Fix(FixAction {
2283            kind: FixActionType::RemoveCatalogEntry,
2284            auto_fixable,
2285            description: if is_pnpm_source {
2286                "Remove the entry from pnpm-workspace.yaml".to_string()
2287            } else {
2288                "Remove the entry from the catalog source file manually".to_string()
2289            },
2290            note,
2291            available_in_catalogs: None,
2292            suggested_target: None,
2293        })];
2294        if is_pnpm_source {
2295            actions.push(IssueAction::SuppressLine(SuppressLineAction {
2296                kind: SuppressLineKind::SuppressLine,
2297                auto_fixable: false,
2298                description: "Suppress with a YAML comment above the line".to_string(),
2299                comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
2300                scope: None,
2301            }));
2302        }
2303        Self {
2304            entry,
2305            actions,
2306            introduced: None,
2307        }
2308    }
2309}
2310
2311/// Wire-shape envelope for an [`EmptyCatalogGroup`] finding. Carries a
2312/// `remove-empty-catalog-group` primary. YAML-sourced findings also include a
2313/// YAML-comment suppress action.
2314#[derive(Debug, Clone, Serialize, Deserialize)]
2315#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2316pub struct EmptyCatalogGroupFinding {
2317    /// The underlying finding.
2318    #[serde(flatten)]
2319    pub group: EmptyCatalogGroup,
2320    /// Suggested next steps. Always emitted.
2321    pub actions: Vec<IssueAction>,
2322    /// Set by the audit pass when this finding is introduced relative to
2323    /// the merge-base.
2324    #[serde(default, skip_serializing_if = "Option::is_none")]
2325    pub introduced: Option<AuditIntroduced>,
2326}
2327
2328impl EmptyCatalogGroupFinding {
2329    /// Build the wrapper.
2330    #[must_use]
2331    pub fn with_actions(group: EmptyCatalogGroup) -> Self {
2332        let auto_fixable = is_pnpm_catalog_source(&group.path);
2333        let mut actions = vec![IssueAction::Fix(FixAction {
2334            kind: FixActionType::RemoveEmptyCatalogGroup,
2335            auto_fixable,
2336            description: if auto_fixable {
2337                "Remove the empty named catalog group from pnpm-workspace.yaml".to_string()
2338            } else {
2339                "Remove the empty named catalog group from the catalog source file manually"
2340                    .to_string()
2341            },
2342            note: Some(if auto_fixable {
2343                "Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
2344                    .to_string()
2345            } else {
2346                "fallow fix only edits pnpm-workspace.yaml catalog groups. Edit Bun package.json catalogs manually."
2347                    .to_string()
2348            }),
2349            available_in_catalogs: None,
2350            suggested_target: None,
2351        })];
2352        if auto_fixable {
2353            actions.push(IssueAction::SuppressLine(SuppressLineAction {
2354                kind: SuppressLineKind::SuppressLine,
2355                auto_fixable: false,
2356                description: "Suppress with a YAML comment above the line".to_string(),
2357                comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
2358                scope: None,
2359            }));
2360        }
2361        Self {
2362            group,
2363            actions,
2364            introduced: None,
2365        }
2366    }
2367}
2368
2369fn is_pnpm_catalog_source(path: &Path) -> bool {
2370    path == Path::new(PNPM_WORKSPACE_FILE)
2371}
2372
2373/// Wire-shape envelope for an [`UnresolvedCatalogReference`] finding. The
2374/// primary action at position 0 discriminates on `available_in_catalogs`:
2375/// `add-catalog-entry` when the array is empty (no other catalog declares
2376/// the package), or `update-catalog-reference` when at least one
2377/// alternative exists. When exactly one alternative exists, the action
2378/// also carries `suggested_target` so deterministic agents can land the
2379/// edit without picking from a list.
2380#[derive(Debug, Clone, Serialize, Deserialize)]
2381#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2382pub struct UnresolvedCatalogReferenceFinding {
2383    /// The underlying finding.
2384    #[serde(flatten)]
2385    pub reference: UnresolvedCatalogReference,
2386    /// Suggested next steps. Always emitted; position 0 is the discriminated
2387    /// primary (see struct docs).
2388    pub actions: Vec<IssueAction>,
2389    /// Set by the audit pass when this finding is introduced relative to
2390    /// the merge-base.
2391    #[serde(default, skip_serializing_if = "Option::is_none")]
2392    pub introduced: Option<AuditIntroduced>,
2393}
2394
2395impl UnresolvedCatalogReferenceFinding {
2396    /// Build the wrapper. The discriminator at position 0 is the
2397    /// `add-catalog-entry` vs `update-catalog-reference` pick documented on
2398    /// the struct.
2399    #[must_use]
2400    pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
2401        // Normalize separators to forward slashes so the
2402        // `ignoreCatalogReferences.consumer` action value is portable when
2403        // pasted into a Windows-authored config. See
2404        // `build_duplicate_exports_ignore_rules` for the same pattern.
2405        let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
2406        let primary = catalog_reference_primary_action(&reference);
2407        let fallback = remove_catalog_reference_action();
2408        let suppress = suppress_catalog_reference_action(&reference, consumer_path);
2409
2410        Self {
2411            reference,
2412            actions: vec![primary, fallback, suppress],
2413            introduced: None,
2414        }
2415    }
2416}
2417
2418fn catalog_reference_primary_action(reference: &UnresolvedCatalogReference) -> IssueAction {
2419    if reference.available_in_catalogs.is_empty() {
2420        return IssueAction::Fix(FixAction {
2421            kind: FixActionType::AddCatalogEntry,
2422            auto_fixable: false,
2423            description: format!(
2424                "Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
2425                reference.entry_name, reference.catalog_name
2426            ),
2427            note: Some(
2428                "Pin a version that satisfies the consumer's import; no other catalog declares this package today"
2429                    .to_string(),
2430            ),
2431            available_in_catalogs: None,
2432            suggested_target: None,
2433        });
2434    }
2435
2436    let available = reference.available_in_catalogs.clone();
2437    let suggested_target = (available.len() == 1).then(|| available[0].clone());
2438    IssueAction::Fix(FixAction {
2439        kind: FixActionType::UpdateCatalogReference,
2440        auto_fixable: false,
2441        description: format!(
2442            "Switch the reference from `catalog:{}` to a catalog that declares `{}`",
2443            reference.catalog_name, reference.entry_name
2444        ),
2445        note: None,
2446        available_in_catalogs: Some(available),
2447        suggested_target,
2448    })
2449}
2450
2451fn remove_catalog_reference_action() -> IssueAction {
2452    IssueAction::Fix(FixAction {
2453        kind: FixActionType::RemoveCatalogReference,
2454        auto_fixable: false,
2455        description: "Remove the catalog reference and pin a hardcoded version in package.json"
2456            .to_string(),
2457        note: Some(
2458            "Use only when neither another catalog declares the package nor the named catalog should grow to include it"
2459                .to_string(),
2460        ),
2461        available_in_catalogs: None,
2462        suggested_target: None,
2463    })
2464}
2465
2466fn suppress_catalog_reference_action(
2467    reference: &UnresolvedCatalogReference,
2468    consumer_path: String,
2469) -> IssueAction {
2470    let mut suppress_value = serde_json::Map::new();
2471    suppress_value.insert(
2472        "package".to_string(),
2473        serde_json::Value::String(reference.entry_name.clone()),
2474    );
2475    suppress_value.insert(
2476        "catalog".to_string(),
2477        serde_json::Value::String(reference.catalog_name.clone()),
2478    );
2479    suppress_value.insert(
2480        "consumer".to_string(),
2481        serde_json::Value::String(consumer_path),
2482    );
2483    IssueAction::AddToConfig(AddToConfigAction {
2484        kind: AddToConfigKind::AddToConfig,
2485        auto_fixable: false,
2486        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(),
2487        config_key: "ignoreCatalogReferences".to_string(),
2488        value: AddToConfigValue::RuleObject(suppress_value),
2489        value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
2490    })
2491}
2492
2493/// Wire-shape envelope for an [`UnusedDependencyOverride`] finding. Carries
2494/// a `remove-dependency-override` primary plus an `add-to-config`
2495/// `ignoreDependencyOverrides` suppress scoped to the target package and
2496/// declaration source.
2497#[derive(Debug, Clone, Serialize, Deserialize)]
2498#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2499pub struct UnusedDependencyOverrideFinding {
2500    /// The underlying finding.
2501    #[serde(flatten)]
2502    pub entry: UnusedDependencyOverride,
2503    /// Suggested next steps. Always emitted.
2504    pub actions: Vec<IssueAction>,
2505    /// Set by the audit pass when this finding is introduced relative to
2506    /// the merge-base.
2507    #[serde(default, skip_serializing_if = "Option::is_none")]
2508    pub introduced: Option<AuditIntroduced>,
2509}
2510
2511impl UnusedDependencyOverrideFinding {
2512    /// Build the wrapper.
2513    #[must_use]
2514    pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
2515        let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
2516        actions.push(IssueAction::Fix(FixAction {
2517            kind: FixActionType::RemoveDependencyOverride,
2518            auto_fixable: false,
2519            description: "Remove the package-manager override entry from its declaration source"
2520                .to_string(),
2521            note: Some(
2522                "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)"
2523                    .to_string(),
2524            ),
2525            available_in_catalogs: None,
2526            suggested_target: None,
2527        }));
2528
2529        if let Some(suppress) = build_ignore_dependency_overrides_suppress(
2530            Some(&entry.target_package),
2531            &entry.raw_key,
2532            entry.source,
2533        ) {
2534            actions.push(suppress);
2535        }
2536
2537        Self {
2538            entry,
2539            actions,
2540            introduced: None,
2541        }
2542    }
2543}
2544
2545/// Wire-shape envelope for a [`MisconfiguredDependencyOverride`] finding.
2546/// Carries a `fix-dependency-override` primary plus the conditional
2547/// `add-to-config` `ignoreDependencyOverrides` suppress (skipped when both
2548/// `target_package` and `raw_key` are empty, since the rule matcher keys on
2549/// a non-empty package name).
2550#[derive(Debug, Clone, Serialize, Deserialize)]
2551#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2552pub struct MisconfiguredDependencyOverrideFinding {
2553    /// The underlying finding.
2554    #[serde(flatten)]
2555    pub entry: MisconfiguredDependencyOverride,
2556    /// Suggested next steps. Always emitted.
2557    pub actions: Vec<IssueAction>,
2558    /// Set by the audit pass when this finding is introduced relative to
2559    /// the merge-base.
2560    #[serde(default, skip_serializing_if = "Option::is_none")]
2561    pub introduced: Option<AuditIntroduced>,
2562}
2563
2564impl MisconfiguredDependencyOverrideFinding {
2565    /// Build the wrapper. The suppress action is omitted when neither
2566    /// `target_package` (set on `EmptyValue` cases) nor `raw_key` provides a
2567    /// non-empty package name; an `ignoreDependencyOverrides` entry with
2568    /// `package: ""` would be silently ignored by the config parser.
2569    #[must_use]
2570    pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
2571        let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
2572        actions.push(IssueAction::Fix(FixAction {
2573            kind: FixActionType::FixDependencyOverride,
2574            auto_fixable: false,
2575            description:
2576                "Fix the package-manager override key or value: invalid entries are rejected or ignored"
2577                    .to_string(),
2578            note: Some(
2579                "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`."
2580                    .to_string(),
2581            ),
2582            available_in_catalogs: None,
2583            suggested_target: None,
2584        }));
2585
2586        if let Some(suppress) = build_ignore_dependency_overrides_suppress(
2587            entry.target_package.as_deref(),
2588            &entry.raw_key,
2589            entry.source,
2590        ) {
2591            actions.push(suppress);
2592        }
2593
2594        Self {
2595            entry,
2596            actions,
2597            introduced: None,
2598        }
2599    }
2600}
2601
2602/// Shared `add-to-config` `ignoreDependencyOverrides` builder for the two
2603/// override findings. Returns `None` when no non-empty package name is
2604/// available; the config parser silently drops entries with an empty
2605/// `package` field, so emitting one would be a no-op that misleads agents.
2606fn build_ignore_dependency_overrides_suppress(
2607    target_package: Option<&str>,
2608    raw_key: &str,
2609    source: DependencyOverrideSource,
2610) -> Option<IssueAction> {
2611    let package = target_package
2612        .filter(|s| !s.is_empty())
2613        .or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
2614        .to_string();
2615    let mut value = serde_json::Map::new();
2616    value.insert("package".to_string(), serde_json::Value::String(package));
2617    value.insert(
2618        "source".to_string(),
2619        serde_json::Value::String(source.as_label().to_string()),
2620    );
2621    Some(IssueAction::AddToConfig(AddToConfigAction {
2622        kind: AddToConfigKind::AddToConfig,
2623        auto_fixable: false,
2624        description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
2625        config_key: "ignoreDependencyOverrides".to_string(),
2626        value: AddToConfigValue::RuleObject(value),
2627        value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
2628    }))
2629}
2630
2631// ── Position-0 invariant golden tests ───────────────────────────
2632//
2633// These tests document the load-bearing position-0 semantics that flow
2634// downstream into the GitHub Action / GitLab CI jq scripts, the MCP server
2635// `actions[0].type` pattern-match, and the VS Code LSP code-action
2636// rendering. Snapshot tests assert structural equality; these named tests
2637// document WHY position 0 has a specific value, so a future refactor that
2638// re-orders actions tells you what broke instead of just "the snapshot
2639// changed".
2640#[cfg(test)]
2641mod position_0_invariants {
2642    use super::*;
2643    use crate::output::FixActionType;
2644    use crate::results::{DependencyOverrideSource, DuplicateLocation};
2645    use std::path::PathBuf;
2646
2647    /// Helper: extract the kebab-case `type` discriminant from an
2648    /// [`IssueAction`] at a specific position. Returns `None` when the
2649    /// position is out of bounds or the action shape lacks a discriminant
2650    /// (today every variant has one).
2651    fn action_type(action: &IssueAction) -> &'static str {
2652        match action {
2653            IssueAction::Fix(fix) => match fix.kind {
2654                FixActionType::RemoveExport => "remove-export",
2655                FixActionType::DeleteFile => "delete-file",
2656                FixActionType::RemoveDependency => "remove-dependency",
2657                FixActionType::MoveDependency => "move-dependency",
2658                FixActionType::RemoveEnumMember => "remove-enum-member",
2659                FixActionType::RemoveClassMember => "remove-class-member",
2660                FixActionType::ResolveImport => "resolve-import",
2661                FixActionType::InstallDependency => "install-dependency",
2662                FixActionType::RemoveDuplicate => "remove-duplicate",
2663                FixActionType::MoveToDev => "move-to-dev",
2664                FixActionType::MoveToProd => "move-to-prod",
2665                FixActionType::RefactorCycle => "refactor-cycle",
2666                FixActionType::RefactorReExportCycle => "refactor-re-export-cycle",
2667                FixActionType::RefactorBoundary => "refactor-boundary",
2668                FixActionType::ExportType => "export-type",
2669                FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
2670                FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
2671                FixActionType::UpdateCatalogReference => "update-catalog-reference",
2672                FixActionType::AddCatalogEntry => "add-catalog-entry",
2673                FixActionType::RemoveCatalogReference => "remove-catalog-reference",
2674                FixActionType::RemoveDependencyOverride => "remove-dependency-override",
2675                FixActionType::FixDependencyOverride => "fix-dependency-override",
2676                FixActionType::ResolvePolicyViolation => "resolve-policy-violation",
2677                FixActionType::MoveToServerModule => "move-to-server-module",
2678                FixActionType::SplitMixedBarrel => "split-mixed-barrel",
2679                FixActionType::HoistDirective => "hoist-directive",
2680                FixActionType::WireServerAction => "wire-server-action",
2681                FixActionType::ProvideInject => "provide-inject",
2682                FixActionType::UseLoadData => "use-load-data",
2683                FixActionType::RenderComponent => "render-component",
2684                FixActionType::UseComponentProp => "use-component-prop",
2685                FixActionType::EmitComponentEvent => "emit-component-event",
2686                FixActionType::WireSvelteEvent => "wire-svelte-event",
2687                FixActionType::ResolveRouteCollision => "resolve-route-collision",
2688                FixActionType::ResolveDynamicSegmentNameConflict => {
2689                    "resolve-dynamic-segment-name-conflict"
2690                }
2691                FixActionType::AddSuppressionReason => "add-suppression-reason",
2692                FixActionType::RemoveStaleSuppression => "remove-stale-suppression",
2693            },
2694            IssueAction::SuppressLine(_) => "suppress-line",
2695            IssueAction::SuppressFile(_) => "suppress-file",
2696            IssueAction::AddToConfig(_) => "add-to-config",
2697        }
2698    }
2699
2700    fn assert_manual_fix_then_suppress(
2701        actions: &[IssueAction],
2702        primary_type: &str,
2703        suppress_comment: &str,
2704    ) {
2705        assert_eq!(actions.len(), 2);
2706        assert_eq!(action_type(&actions[0]), primary_type);
2707        let IssueAction::Fix(primary) = &actions[0] else {
2708            panic!("position-0 should be a manual fix action");
2709        };
2710        assert!(!primary.auto_fixable);
2711        assert!(primary.note.is_some());
2712        assert_eq!(action_type(&actions[1]), "suppress-line");
2713        let IssueAction::SuppressLine(suppress) = &actions[1] else {
2714            panic!("position-1 should be a suppress-line action");
2715        };
2716        assert_eq!(suppress.comment, suppress_comment);
2717    }
2718
2719    #[test]
2720    fn pnpm_catalog_entry_action_is_auto_fixable() {
2721        let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
2722            entry_name: "unused".to_string(),
2723            catalog_name: "default".to_string(),
2724            path: PathBuf::from("pnpm-workspace.yaml"),
2725            line: 3,
2726            hardcoded_consumers: vec![],
2727        });
2728
2729        let IssueAction::Fix(fix) = &finding.actions[0] else {
2730            panic!("position-0 should be a fix action");
2731        };
2732        assert!(fix.auto_fixable);
2733        assert_eq!(finding.actions.len(), 2);
2734        assert_eq!(action_type(&finding.actions[1]), "suppress-line");
2735    }
2736
2737    #[test]
2738    fn bun_package_json_catalog_entry_action_is_manual_only() {
2739        let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
2740            entry_name: "unused".to_string(),
2741            catalog_name: "default".to_string(),
2742            path: PathBuf::from("package.json"),
2743            line: 4,
2744            hardcoded_consumers: vec![],
2745        });
2746
2747        let IssueAction::Fix(fix) = &finding.actions[0] else {
2748            panic!("position-0 should be a fix action");
2749        };
2750        assert!(!fix.auto_fixable);
2751        assert!(fix.description.contains("manually"));
2752        assert_eq!(finding.actions.len(), 1);
2753    }
2754
2755    #[test]
2756    fn bun_package_json_empty_catalog_group_action_is_manual_only() {
2757        let finding = EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
2758            catalog_name: "empty".to_string(),
2759            path: PathBuf::from("package.json"),
2760            line: 4,
2761        });
2762
2763        let IssueAction::Fix(fix) = &finding.actions[0] else {
2764            panic!("position-0 should be a fix action");
2765        };
2766        assert!(!fix.auto_fixable);
2767        assert!(fix.description.contains("manually"));
2768        assert_eq!(finding.actions.len(), 1);
2769    }
2770
2771    #[test]
2772    fn unprovided_inject_primary_action_is_provide_inject() {
2773        let finding = UnprovidedInjectFinding::with_actions(UnprovidedInject {
2774            path: PathBuf::from("src/context.ts"),
2775            key_name: "userKey".to_string(),
2776            framework: "svelte".to_string(),
2777            line: 7,
2778            col: 12,
2779        });
2780
2781        assert_manual_fix_then_suppress(
2782            &finding.actions,
2783            "provide-inject",
2784            "// fallow-ignore-next-line unprovided-inject",
2785        );
2786    }
2787
2788    #[test]
2789    fn unused_server_action_primary_action_is_wire_server_action() {
2790        let finding = UnusedServerActionFinding::with_actions(UnusedServerAction {
2791            path: PathBuf::from("app/actions.ts"),
2792            action_name: "saveDraft".to_string(),
2793            line: 3,
2794            col: 13,
2795        });
2796
2797        assert_manual_fix_then_suppress(
2798            &finding.actions,
2799            "wire-server-action",
2800            "// fallow-ignore-next-line unused-server-action",
2801        );
2802    }
2803
2804    #[test]
2805    fn unused_load_data_key_primary_action_is_use_load_data() {
2806        let finding = UnusedLoadDataKeyFinding::with_actions(UnusedLoadDataKey {
2807            path: PathBuf::from("src/routes/+page.server.ts"),
2808            key_name: "profile".to_string(),
2809            line: 12,
2810            col: 6,
2811            route_dir: Some("src/routes".to_string()),
2812        });
2813
2814        assert_manual_fix_then_suppress(
2815            &finding.actions,
2816            "use-load-data",
2817            "// fallow-ignore-next-line unused-load-data-key",
2818        );
2819    }
2820
2821    #[test]
2822    fn unrendered_component_primary_action_is_render_component() {
2823        let finding = UnrenderedComponentFinding::with_actions(UnrenderedComponent {
2824            path: PathBuf::from("src/components/EmptyState.vue"),
2825            component_name: "EmptyState".to_string(),
2826            framework: "vue".to_string(),
2827            reachable_via: None,
2828            line: 1,
2829            col: 0,
2830        });
2831
2832        assert_manual_fix_then_suppress(
2833            &finding.actions,
2834            "render-component",
2835            "// fallow-ignore-next-line unrendered-component",
2836        );
2837    }
2838
2839    #[test]
2840    fn unused_component_prop_primary_action_is_use_component_prop() {
2841        let finding = UnusedComponentPropFinding::with_actions(UnusedComponentProp {
2842            path: PathBuf::from("src/components/Card.vue"),
2843            component_name: "Card".to_string(),
2844            prop_name: "variant".to_string(),
2845            line: 5,
2846            col: 10,
2847        });
2848
2849        assert_manual_fix_then_suppress(
2850            &finding.actions,
2851            "use-component-prop",
2852            "// fallow-ignore-next-line unused-component-prop",
2853        );
2854    }
2855
2856    #[test]
2857    fn unused_component_emit_primary_action_is_emit_component_event() {
2858        let finding = UnusedComponentEmitFinding::with_actions(UnusedComponentEmit {
2859            path: PathBuf::from("src/components/Picker.vue"),
2860            component_name: "Picker".to_string(),
2861            emit_name: "focus".to_string(),
2862            line: 6,
2863            col: 14,
2864        });
2865
2866        assert_manual_fix_then_suppress(
2867            &finding.actions,
2868            "emit-component-event",
2869            "// fallow-ignore-next-line unused-component-emit",
2870        );
2871    }
2872
2873    #[test]
2874    fn unused_svelte_event_primary_action_is_wire_svelte_event() {
2875        let finding = UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
2876            path: PathBuf::from("src/Dialog.svelte"),
2877            component_name: "Dialog".to_string(),
2878            event_name: "closed".to_string(),
2879            line: 19,
2880            col: 8,
2881        });
2882
2883        assert_manual_fix_then_suppress(
2884            &finding.actions,
2885            "wire-svelte-event",
2886            "// fallow-ignore-next-line unused-svelte-event",
2887        );
2888    }
2889
2890    #[test]
2891    fn unresolved_import_actions_include_ignore_unresolved_imports_config_suppress() {
2892        let inner = UnresolvedImport {
2893            specifier: "@example/icons".to_string(),
2894            path: PathBuf::from("src/index.ts"),
2895            line: 4,
2896            col: 12,
2897            specifier_col: 18,
2898        };
2899        let finding = UnresolvedImportFinding::with_actions(inner);
2900
2901        assert_eq!(action_type(&finding.actions[0]), "resolve-import");
2902        assert_eq!(action_type(&finding.actions[1]), "add-to-config");
2903        let IssueAction::AddToConfig(action) = &finding.actions[1] else {
2904            panic!("position-1 should be AddToConfig");
2905        };
2906        assert!(!action.auto_fixable);
2907        assert_eq!(action.config_key, "ignoreUnresolvedImports");
2908        let AddToConfigValue::Scalar(value) = &action.value else {
2909            panic!("ignoreUnresolvedImports action should carry a scalar value");
2910        };
2911        assert_eq!(value, "@example/icons");
2912        assert_eq!(
2913            action.value_schema.as_deref(),
2914            Some(
2915                "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
2916            )
2917        );
2918    }
2919
2920    /// Invariant: when no other catalog declares the package, position 0
2921    /// of `unresolved_catalog_references[].actions` is `add-catalog-entry`,
2922    /// directing the agent to grow the targeted catalog.
2923    ///
2924    /// Downstream consumers (MCP `actions[0].type` dispatch, jq scripts in
2925    /// `action/jq/review-comments-check.jq` and `ci/jq/review-check.jq`)
2926    /// pattern-match on this string. A future refactor that puts the
2927    /// generic `remove-catalog-reference` fallback at position 0 would
2928    /// flip every CI annotation from "add this entry" to "remove this
2929    /// reference", reversing the recommended action.
2930    #[test]
2931    fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
2932        let inner = UnresolvedCatalogReference {
2933            entry_name: "react".to_string(),
2934            catalog_name: "default".to_string(),
2935            path: PathBuf::from("apps/web/package.json"),
2936            line: 7,
2937            available_in_catalogs: Vec::new(),
2938        };
2939        let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
2940        assert_eq!(
2941            action_type(&finding.actions[0]),
2942            "add-catalog-entry",
2943            "position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
2944        );
2945        let IssueAction::Fix(fix) = &finding.actions[0] else {
2946            panic!("position-0 should be an IssueAction::Fix");
2947        };
2948        assert!(
2949            fix.available_in_catalogs.is_none(),
2950            "add-catalog-entry must NOT carry available_in_catalogs"
2951        );
2952        assert!(
2953            fix.suggested_target.is_none(),
2954            "add-catalog-entry must NOT carry suggested_target"
2955        );
2956    }
2957
2958    /// Invariant: when at least one alternative catalog declares the
2959    /// package, position 0 flips to `update-catalog-reference` and carries
2960    /// the alternative list. When exactly one alternative exists, the
2961    /// action also carries `suggested_target` so deterministic agents can
2962    /// land the edit without picking from the list. This is the
2963    /// counterpart to `unresolved_catalog_position_0_is_add_when_no_alternatives`.
2964    #[test]
2965    fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
2966        let inner = UnresolvedCatalogReference {
2967            entry_name: "react".to_string(),
2968            catalog_name: "default".to_string(),
2969            path: PathBuf::from("apps/web/package.json"),
2970            line: 7,
2971            available_in_catalogs: vec!["react18".to_string()],
2972        };
2973        let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
2974        assert_eq!(
2975            action_type(&finding.actions[0]),
2976            "update-catalog-reference",
2977            "position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
2978        );
2979        let IssueAction::Fix(fix) = &finding.actions[0] else {
2980            panic!("position-0 should be an IssueAction::Fix");
2981        };
2982        assert_eq!(
2983            fix.available_in_catalogs.as_deref(),
2984            Some(&["react18".to_string()][..]),
2985            "update-catalog-reference must carry the alternative list"
2986        );
2987        assert_eq!(
2988            fix.suggested_target.as_deref(),
2989            Some("react18"),
2990            "single-alternative case must surface `suggested_target` for deterministic agents"
2991        );
2992
2993        // Two alternatives: still update, but no unambiguous target.
2994        let inner_two = UnresolvedCatalogReference {
2995            entry_name: "react".to_string(),
2996            catalog_name: "default".to_string(),
2997            path: PathBuf::from("apps/web/package.json"),
2998            line: 7,
2999            available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
3000        };
3001        let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
3002        assert_eq!(
3003            action_type(&finding_two.actions[0]),
3004            "update-catalog-reference"
3005        );
3006        let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
3007            panic!("position-0 should be an IssueAction::Fix");
3008        };
3009        assert!(
3010            fix_two.suggested_target.is_none(),
3011            "multi-alternative case must NOT carry `suggested_target` (agent must pick)"
3012        );
3013    }
3014
3015    /// Invariant: position 0 of `duplicate_exports[].actions` is
3016    /// `add-to-config` (the safe `ignoreExports` rule for the
3017    /// namespace-barrel case), NOT the destructive `remove-duplicate`.
3018    ///
3019    /// This protects the shadcn / Radix / bits-ui pattern where every
3020    /// `components/ui/<name>/index.ts` intentionally re-exports the same
3021    /// short names. Any consumer that reads `actions[0].type` as "the
3022    /// recommended fix" must see the non-destructive path first; flipping
3023    /// position 0 to `remove-duplicate` would propose deleting an
3024    /// intentional API surface.
3025    ///
3026    /// This test pins position 0 across both possible auto_fixable values
3027    /// for the add-to-config action (the per-instance flip flag handled
3028    /// by `set_config_fixable`).
3029    #[test]
3030    fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
3031        let inner = DuplicateExport {
3032            export_name: "Root".to_string(),
3033            locations: vec![
3034                DuplicateLocation {
3035                    path: PathBuf::from("components/ui/accordion/index.ts"),
3036                    line: 1,
3037                    col: 0,
3038                },
3039                DuplicateLocation {
3040                    path: PathBuf::from("components/ui/dialog/index.ts"),
3041                    line: 1,
3042                    col: 0,
3043                },
3044            ],
3045        };
3046        let finding = DuplicateExportFinding::with_actions(inner);
3047        assert_eq!(
3048            action_type(&finding.actions[0]),
3049            "add-to-config",
3050            "position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
3051        );
3052        assert_eq!(
3053            action_type(&finding.actions[1]),
3054            "remove-duplicate",
3055            "position-1 must be the destructive `remove-duplicate` fallback"
3056        );
3057
3058        // `set_config_fixable(true)` flips the position-0 add-to-config
3059        // bool but must NOT re-order positions.
3060        let mut promoted = finding;
3061        promoted.set_config_fixable(true);
3062        assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
3063        let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
3064            panic!("position-0 should still be AddToConfig after set_config_fixable");
3065        };
3066        assert!(
3067            action.auto_fixable,
3068            "set_config_fixable(true) must flip auto_fixable"
3069        );
3070    }
3071
3072    /// Invariant: a duplicate-exports finding with empty `locations`
3073    /// degenerate input drops the `add-to-config` action entirely, so
3074    /// position 0 falls through to `remove-duplicate`. Documents the
3075    /// degenerate-case contract.
3076    #[test]
3077    fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
3078        let inner = DuplicateExport {
3079            export_name: "Root".to_string(),
3080            locations: Vec::new(),
3081        };
3082        let finding = DuplicateExportFinding::with_actions(inner);
3083        assert_eq!(
3084            action_type(&finding.actions[0]),
3085            "remove-duplicate",
3086            "with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
3087        );
3088
3089        // `set_config_fixable(true)` is a no-op on this shape.
3090        let mut promoted = finding;
3091        promoted.set_config_fixable(true);
3092        assert_eq!(
3093            action_type(&promoted.actions[0]),
3094            "remove-duplicate",
3095            "set_config_fixable is a no-op when position-0 is not add-to-config"
3096        );
3097    }
3098
3099    /// Invariant: misconfigured-dependency-override with empty
3100    /// `target_package` AND empty `raw_key` drops the suppress action
3101    /// (no usable package name for the `ignoreDependencyOverrides`
3102    /// matcher; emitting `package: ""` would be silently dropped by the
3103    /// config parser). Documents the suppress-omission contract.
3104    #[test]
3105    fn misconfigured_override_drops_suppress_when_no_package_name() {
3106        let inner = MisconfiguredDependencyOverride {
3107            raw_key: String::new(),
3108            target_package: None,
3109            raw_value: String::new(),
3110            reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
3111            source: DependencyOverrideSource::PnpmWorkspaceYaml,
3112            path: PathBuf::from("pnpm-workspace.yaml"),
3113            line: 12,
3114        };
3115        let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
3116        // Only the primary fix-dependency-override action: no suppress.
3117        assert_eq!(finding.actions.len(), 1);
3118        assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
3119    }
3120}