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