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