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