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