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