use serde::{Deserialize, Serialize};
use std::path::Path;
use crate::envelope::AuditIntroduced;
use crate::output::{
AddToConfigAction, AddToConfigKind, AddToConfigValue, FixAction, FixActionType,
IgnoreExportsRule, IssueAction, SuppressFileAction, SuppressFileKind, SuppressLineAction,
SuppressLineKind, SuppressLineScope,
};
use crate::results::{
BoundaryCallViolation, BoundaryCoverageViolation, BoundaryViolation, CircularDependency,
DependencyOverrideSource, DevDependencyInProduction, DuplicateExport, DuplicatePropShape,
DynamicSegmentNameConflict, EmptyCatalogGroup, InvalidClientExport,
MisconfiguredDependencyOverride, MisplacedDirective, MixedClientServerBarrel, PolicyViolation,
PrivateTypeLeak, PropDrillingChain, ReExportCycle, ReExportCycleKind, RouteCollision,
TestOnlyDependency, ThinWrapper, TypeOnlyDependency, UnlistedDependency, UnprovidedInject,
UnrenderedComponent, UnresolvedCatalogReference, UnresolvedImport, UnusedCatalogEntry,
UnusedComponentEmit, UnusedComponentInput, UnusedComponentOutput, UnusedComponentProp,
UnusedDependency, UnusedDependencyOverride, UnusedExport, UnusedFile, UnusedLoadDataKey,
UnusedMember, UnusedServerAction, UnusedSvelteEvent,
};
use crate::semantic::{
SemanticCandidateDecision, SemanticCandidateDecisionKind, SemanticCompleteness,
};
pub 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.";
const IGNORE_EXPORTS_VALUE_SCHEMA: &str =
"https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreExports";
const IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreCatalogReferences/items";
const IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencyOverrides/items";
const PNPM_WORKSPACE_FILE: &str = "pnpm-workspace.yaml";
fn manual_framework_fix(kind: FixActionType, description: &str, note: &str) -> IssueAction {
IssueAction::Fix(FixAction {
kind,
auto_fixable: false,
description: description.to_string(),
note: Some(note.to_string()),
available_in_catalogs: None,
suggested_target: None,
})
}
fn suppress_line(comment: &str) -> IssueAction {
IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with an inline comment above the line".to_string(),
comment: comment.to_string(),
scope: None,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum ReachabilityCaveat {
IncompleteFileAnalysis,
IncompleteImportGraph,
}
impl ReachabilityCaveat {
#[must_use]
pub const fn token(self) -> &'static str {
match self {
Self::IncompleteFileAnalysis => "incomplete-file-analysis",
Self::IncompleteImportGraph => "incomplete-import-graph",
}
}
#[must_use]
pub const fn message(self) -> &'static str {
match self {
Self::IncompleteFileAnalysis => {
"low: this file was not fully analyzed, so its extracted exports and imports may be incomplete; see workspace_diagnostics[]"
}
Self::IncompleteImportGraph => {
"low: a module this run did not fully read may hold an import that would credit this; see workspace_diagnostics[]"
}
}
}
#[must_use]
pub const fn short_label(self) -> &'static str {
match self {
Self::IncompleteFileAnalysis => "incomplete file analysis",
Self::IncompleteImportGraph => "incomplete import graph",
}
}
}
#[must_use]
pub fn caveat_labels(caveats: &[ReachabilityCaveat]) -> Option<String> {
if caveats.is_empty() {
return None;
}
let labels: Vec<&str> = caveats
.iter()
.map(|caveat| ReachabilityCaveat::short_label(*caveat))
.collect();
Some(labels.join(", "))
}
#[must_use]
pub fn caveat_suffix(caveats: &[ReachabilityCaveat]) -> Option<String> {
caveat_labels(caveats).map(|labels| format!("{CAVEAT_SUFFIX_MARKER}{labels})"))
}
pub const CAVEAT_SUFFIX_MARKER: &str = " (caveat: ";
#[must_use]
pub fn description_carries_caveat(description: &str) -> bool {
description.contains(CAVEAT_SUFFIX_MARKER)
}
#[must_use]
pub fn caveat_label_for_token(token: &str) -> String {
match token {
"incomplete-file-analysis" => {
ReachabilityCaveat::short_label(ReachabilityCaveat::IncompleteFileAnalysis).to_owned()
}
"incomplete-import-graph" => {
ReachabilityCaveat::short_label(ReachabilityCaveat::IncompleteImportGraph).to_owned()
}
other => other.replace('-', " "),
}
}
#[must_use]
pub fn caveat_labels_for_tokens<'a>(tokens: impl IntoIterator<Item = &'a str>) -> Option<String> {
let labels: Vec<String> = tokens.into_iter().map(caveat_label_for_token).collect();
if labels.is_empty() {
return None;
}
Some(labels.join(", "))
}
#[must_use]
pub fn caveat_suffix_for_tokens<'a>(tokens: impl IntoIterator<Item = &'a str>) -> Option<String> {
caveat_labels_for_tokens(tokens).map(|labels| format!("{CAVEAT_SUFFIX_MARKER}{labels})"))
}
pub const INCOMPLETE_EVIDENCE_NOTE: &str = "Evidence is incomplete: a file this run did not fully analyze may hold the reference that \
credits this finding, so this mutation is not applied automatically. Resolve the files named \
in workspace_diagnostics[] and re-run, or confirm and remove it by hand.";
pub trait MutationEvidence {
fn reachability_caveats(&self) -> &[ReachabilityCaveat];
fn may_auto_apply_mutation(&self) -> bool {
self.reachability_caveats().is_empty()
}
}
pub trait CaveatedFinding: MutationEvidence {
fn set_reachability_caveats(&mut self, caveats: Vec<ReachabilityCaveat>);
}
fn withhold_caveated_mutations(actions: &mut [IssueAction], caveats: &[ReachabilityCaveat]) {
if caveats.is_empty() {
return;
}
for action in actions {
let IssueAction::Fix(fix) = action else {
continue;
};
fix.auto_fixable = false;
fix.note = Some(match fix.note.take() {
Some(existing) => format!("{existing}. {INCOMPLETE_EVIDENCE_NOTE}"),
None => INCOMPLETE_EVIDENCE_NOTE.to_string(),
});
}
}
macro_rules! impl_caveated_finding {
($($finding:ty),+ $(,)?) => {
$(
impl MutationEvidence for $finding {
fn reachability_caveats(&self) -> &[ReachabilityCaveat] {
&self.reachability_caveats
}
}
impl CaveatedFinding for $finding {
fn set_reachability_caveats(&mut self, caveats: Vec<ReachabilityCaveat>) {
withhold_caveated_mutations(&mut self.actions, &caveats);
self.reachability_caveats = caveats;
}
}
)+
};
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedFileFinding {
#[serde(flatten)]
pub file: UnusedFile,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reachability_caveats: Vec<ReachabilityCaveat>,
}
impl UnusedFileFinding {
#[must_use]
pub fn with_actions(file: UnusedFile) -> Self {
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::DeleteFile,
auto_fixable: false,
description: "Delete this file".to_string(),
note: Some(
"File deletion may remove runtime functionality not visible to static analysis"
.to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}),
IssueAction::SuppressFile(SuppressFileAction {
kind: SuppressFileKind::SuppressFile,
auto_fixable: false,
description: "Suppress with a file-level comment at the top of the file"
.to_string(),
comment: "// fallow-ignore-file unused-file".to_string(),
}),
];
Self {
file,
actions,
introduced: None,
reachability_caveats: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct PrivateTypeLeakFinding {
#[serde(flatten)]
pub leak: PrivateTypeLeak,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl PrivateTypeLeakFinding {
#[must_use]
pub fn with_actions(leak: PrivateTypeLeak) -> Self {
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::ExportType,
auto_fixable: false,
description: "Export the referenced private type by name".to_string(),
note: Some(
"Keep the type exported while it is part of a public signature".to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}),
IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with an inline comment above the line".to_string(),
comment: "// fallow-ignore-next-line private-type-leak".to_string(),
scope: None,
}),
];
Self {
leak,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnresolvedImportFinding {
#[serde(flatten)]
pub import: UnresolvedImport,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl UnresolvedImportFinding {
#[must_use]
pub fn with_actions(import: UnresolvedImport) -> Self {
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::ResolveImport,
auto_fixable: false,
description: "Fix the import specifier or install the missing module".to_string(),
note: Some(
"Verify the module path and check tsconfig paths configuration".to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}),
IssueAction::AddToConfig(AddToConfigAction {
kind: AddToConfigKind::AddToConfig,
auto_fixable: false,
description: format!(
"Add \"{}\" to ignoreUnresolvedImports in fallow config",
import.specifier
),
config_key: "ignoreUnresolvedImports".to_string(),
value: AddToConfigValue::Scalar(import.specifier.clone()),
value_schema: Some(
"https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
.to_string(),
),
}),
IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with an inline comment above the line".to_string(),
comment: "// fallow-ignore-next-line unresolved-import".to_string(),
scope: None,
}),
];
Self {
import,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CircularDependencyFinding {
#[serde(flatten)]
pub cycle: CircularDependency,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl CircularDependencyFinding {
#[must_use]
pub fn with_actions(cycle: CircularDependency) -> Self {
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::RefactorCycle,
auto_fixable: false,
description: "Extract shared logic into a separate module to break the cycle"
.to_string(),
note: Some(
"Circular imports can cause initialization issues and make code harder to reason about"
.to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}),
IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with an inline comment above the line".to_string(),
comment: "// fallow-ignore-next-line circular-dependency".to_string(),
scope: None,
}),
];
Self {
cycle,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ReExportCycleFinding {
#[serde(flatten)]
pub cycle: ReExportCycle,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl ReExportCycleFinding {
#[must_use]
pub fn with_actions(cycle: ReExportCycle) -> Self {
let suppress_description = match cycle.kind {
ReExportCycleKind::SelfLoop => {
"Suppress with a file-level comment at the top of this file. \
The cycle is a self-loop, so the suppression covers the entire finding."
.to_string()
}
ReExportCycleKind::MultiNode => {
"Suppress with a file-level comment at the top of this file. \
One suppression on any member breaks the cycle for every member \
(see the sibling `files` array)."
.to_string()
}
};
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::RefactorReExportCycle,
auto_fixable: false,
description: "Remove one `export * from` (or `export { ... } from`) \
statement on any one member to break the cycle"
.to_string(),
note: Some(
"Re-export cycles are structurally a no-op: chain propagation through \
the loop never reaches a terminating module, so imports from any member \
may silently come up empty."
.to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}),
IssueAction::SuppressFile(SuppressFileAction {
kind: SuppressFileKind::SuppressFile,
auto_fixable: false,
description: suppress_description,
comment: "// fallow-ignore-file re-export-cycle".to_string(),
}),
];
Self {
cycle,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct BoundaryViolationFinding {
#[serde(flatten)]
pub violation: BoundaryViolation,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl BoundaryViolationFinding {
#[must_use]
pub fn with_actions(violation: BoundaryViolation) -> Self {
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::RefactorBoundary,
auto_fixable: false,
description: "Move the import through an allowed zone or restructure the dependency"
.to_string(),
note: Some(
"This import crosses an architecture boundary that is not permitted by the configured rules"
.to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}),
IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with an inline comment above the line".to_string(),
comment: "// fallow-ignore-next-line boundary-violation".to_string(),
scope: None,
}),
];
Self {
violation,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct BoundaryCoverageViolationFinding {
#[serde(flatten)]
pub violation: BoundaryCoverageViolation,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl BoundaryCoverageViolationFinding {
#[must_use]
pub fn with_actions(violation: BoundaryCoverageViolation) -> Self {
let path = violation.path.to_string_lossy().replace('\\', "/");
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::RefactorBoundary,
auto_fixable: false,
description: "Add this file to a boundary zone pattern or move it under an existing zone"
.to_string(),
note: Some(
"Boundary coverage is enabled, so every analyzed source file must match a zone unless allow-listed"
.to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}),
IssueAction::AddToConfig(AddToConfigAction {
kind: AddToConfigKind::AddToConfig,
auto_fixable: false,
description: format!(
"Add \"{path}\" to boundaries.coverage.allowUnmatched in fallow config"
),
config_key: "boundaries.coverage.allowUnmatched".to_string(),
value: AddToConfigValue::Scalar(path),
value_schema: Some(
"https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/boundaries/properties/coverage/properties/allowUnmatched/items"
.to_string(),
),
}),
IssueAction::SuppressFile(SuppressFileAction {
kind: SuppressFileKind::SuppressFile,
auto_fixable: false,
description: "Suppress with a file-level comment at the top of the file"
.to_string(),
comment: "// fallow-ignore-file boundary-violation".to_string(),
}),
];
Self {
violation,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct BoundaryCallViolationFinding {
#[serde(flatten)]
pub violation: BoundaryCallViolation,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl BoundaryCallViolationFinding {
#[must_use]
pub fn with_actions(violation: BoundaryCallViolation) -> Self {
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::RefactorBoundary,
auto_fixable: false,
description: format!(
"Move the `{}` call out of zone '{}' or behind an allowed abstraction",
violation.callee, violation.zone,
),
note: Some(format!(
"`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",
violation.pattern, violation.zone,
)),
available_in_catalogs: None,
suggested_target: None,
}),
IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with an inline comment above the line".to_string(),
comment: "// fallow-ignore-next-line boundary-violation".to_string(),
scope: None,
}),
IssueAction::SuppressFile(SuppressFileAction {
kind: SuppressFileKind::SuppressFile,
auto_fixable: false,
description: "Suppress with a file-level comment at the top of the file"
.to_string(),
comment: "// fallow-ignore-file boundary-violation".to_string(),
}),
];
Self {
violation,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct PolicyViolationFinding {
#[serde(flatten)]
pub violation: PolicyViolation,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl PolicyViolationFinding {
#[must_use]
pub fn with_actions(violation: PolicyViolation) -> Self {
let what = match violation.kind {
crate::results::PolicyRuleKind::BannedCall => "call",
crate::results::PolicyRuleKind::BannedImport => "import",
crate::results::PolicyRuleKind::BannedEffect => "effect",
crate::results::PolicyRuleKind::BannedExport => "export",
};
let description = match &violation.message {
Some(message) => format!("Replace the `{}` {what}: {message}", violation.matched),
None => format!("Replace the `{}` {what}", violation.matched),
};
let suppress_token = format!("policy-violation:{}/{}", violation.pack, violation.rule_id);
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::ResolvePolicyViolation,
auto_fixable: false,
description,
note: Some(format!(
"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",
violation.pack, violation.rule_id,
)),
available_in_catalogs: None,
suggested_target: None,
}),
IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress this rule-pack rule with an inline comment above the line"
.to_string(),
comment: format!("// fallow-ignore-next-line {suppress_token}"),
scope: None,
}),
IssueAction::SuppressFile(SuppressFileAction {
kind: SuppressFileKind::SuppressFile,
auto_fixable: false,
description:
"Suppress this rule-pack rule with a file-level comment at the top of the file"
.to_string(),
comment: format!("// fallow-ignore-file {suppress_token}"),
}),
];
Self {
violation,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedExportFinding {
#[serde(flatten)]
pub export: UnusedExport,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub semantic: Option<SemanticCandidateDecision>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reachability_caveats: Vec<ReachabilityCaveat>,
}
impl UnusedExportFinding {
#[must_use]
pub fn with_actions(export: UnusedExport) -> Self {
let note = if export.is_re_export {
Some(
"This finding originates from a re-export; verify it is not part of your public API before removing"
.to_string(),
)
} else {
None
};
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::RemoveExport,
auto_fixable: true,
description: "Remove the unused export from the public API".to_string(),
note,
available_in_catalogs: None,
suggested_target: None,
}),
IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with an inline comment above the line".to_string(),
comment: "// fallow-ignore-next-line unused-export".to_string(),
scope: None,
}),
];
Self {
export,
actions,
semantic: None,
introduced: None,
reachability_caveats: Vec::new(),
}
}
pub fn set_semantic_decision(&mut self, decision: SemanticCandidateDecision) {
set_export_semantic_action(&mut self.actions, &decision, &self.reachability_caveats);
self.semantic = Some(decision);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedTypeFinding {
#[serde(flatten)]
pub export: UnusedExport,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub semantic: Option<SemanticCandidateDecision>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reachability_caveats: Vec<ReachabilityCaveat>,
}
impl UnusedTypeFinding {
#[must_use]
pub fn with_actions(export: UnusedExport) -> Self {
let note = if export.is_re_export {
Some(
"This finding originates from a re-export; verify it is not part of your public API before removing"
.to_string(),
)
} else {
None
};
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::RemoveExport,
auto_fixable: true,
description:
"Remove the `export` (or `export type`) keyword from the type declaration"
.to_string(),
note,
available_in_catalogs: None,
suggested_target: None,
}),
IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with an inline comment above the line".to_string(),
comment: "// fallow-ignore-next-line unused-type".to_string(),
scope: None,
}),
];
Self {
export,
actions,
semantic: None,
introduced: None,
reachability_caveats: Vec::new(),
}
}
pub fn set_semantic_decision(&mut self, decision: SemanticCandidateDecision) {
set_export_semantic_action(&mut self.actions, &decision, &self.reachability_caveats);
self.semantic = Some(decision);
}
}
fn set_export_semantic_action(
actions: &mut [IssueAction],
decision: &SemanticCandidateDecision,
caveats: &[ReachabilityCaveat],
) {
let complete_negative = decision.decision
== SemanticCandidateDecisionKind::ConfirmedNoStaticReferences
&& decision.status == SemanticCompleteness::Complete;
let Some(IssueAction::Fix(action)) = actions.first_mut() else {
return;
};
action.auto_fixable = complete_negative && caveats.is_empty();
if !complete_negative {
action.note = Some(
"Type-aware analysis retained this candidate because complete negative evidence was not available"
.to_string(),
);
}
if !caveats.is_empty() {
action.note = Some(INCOMPLETE_EVIDENCE_NOTE.to_string());
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct InvalidClientExportFinding {
#[serde(flatten)]
pub export: InvalidClientExport,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl InvalidClientExportFinding {
#[must_use]
pub fn with_actions(export: InvalidClientExport) -> Self {
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::MoveToServerModule,
auto_fixable: false,
description: "Move the server-only export to a non-client module and import it from there"
.to_string(),
note: Some(
"A \"use client\" file cannot export a Next.js server-only or route-config name; Next.js rejects it at build time"
.to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}),
IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with an inline comment above the line".to_string(),
comment: "// fallow-ignore-next-line invalid-client-export".to_string(),
scope: None,
}),
];
Self {
export,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct MixedClientServerBarrelFinding {
#[serde(flatten)]
pub barrel: MixedClientServerBarrel,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl MixedClientServerBarrelFinding {
#[must_use]
pub fn with_actions(barrel: MixedClientServerBarrel) -> Self {
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::SplitMixedBarrel,
auto_fixable: false,
description: "Split the barrel so client and server-only modules are re-exported from separate files"
.to_string(),
note: Some(
"Importing one name from this barrel drags the other's directive across the client/server boundary"
.to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}),
IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with an inline comment above the line".to_string(),
comment: "// fallow-ignore-next-line mixed-client-server-barrel".to_string(),
scope: None,
}),
];
Self {
barrel,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct MisplacedDirectiveFinding {
#[serde(flatten)]
pub directive_site: MisplacedDirective,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl MisplacedDirectiveFinding {
#[must_use]
pub fn with_actions(directive_site: MisplacedDirective) -> Self {
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::HoistDirective,
auto_fixable: false,
description: "Move the directive to the very top of the file, above all imports and statements"
.to_string(),
note: Some(
"An RSC bundler honors the directive only in the leading prologue; here it precedes other statements and is silently ignored"
.to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}),
IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with an inline comment above the line".to_string(),
comment: "// fallow-ignore-next-line misplaced-directive".to_string(),
scope: None,
}),
];
Self {
directive_site,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnprovidedInjectFinding {
#[serde(flatten)]
pub inject: UnprovidedInject,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl UnprovidedInjectFinding {
#[must_use]
pub fn with_actions(inject: UnprovidedInject) -> Self {
let actions = vec![
manual_framework_fix(
FixActionType::ProvideInject,
"Provide this injected key, or remove the inject / getContext call",
"Manual review required: dependency-injection keys can be provided by framework wiring, tests, or package consumers outside this project.",
),
suppress_line("// fallow-ignore-next-line unprovided-inject"),
];
Self {
inject,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedServerActionFinding {
#[serde(flatten)]
pub action: UnusedServerAction,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl UnusedServerActionFinding {
#[must_use]
pub fn with_actions(action: UnusedServerAction) -> Self {
let actions = vec![
manual_framework_fix(
FixActionType::WireServerAction,
"Wire the server action to a caller or form action, or remove it",
"Manual review required: server actions may still be POST-able by action id or invoked reflectively outside the static project graph.",
),
suppress_line("// fallow-ignore-next-line unused-server-action"),
];
Self {
action,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedLoadDataKeyFinding {
#[serde(flatten)]
pub key: UnusedLoadDataKey,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl UnusedLoadDataKeyFinding {
#[must_use]
pub fn with_actions(key: UnusedLoadDataKey) -> Self {
let actions = vec![
manual_framework_fix(
FixActionType::UseLoadData,
"Read this load data key from the route UI, or remove it from the load return",
"Manual review required: load functions can perform real server or database work, so verify side effects before deleting the producer.",
),
suppress_line("// fallow-ignore-next-line unused-load-data-key"),
];
Self {
key,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnrenderedComponentFinding {
#[serde(flatten)]
pub component: UnrenderedComponent,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl UnrenderedComponentFinding {
#[must_use]
pub fn with_actions(component: UnrenderedComponent) -> Self {
let actions = vec![
manual_framework_fix(
FixActionType::RenderComponent,
"Render the reachable component from project code, or remove it",
"Manual review required: exported library components and dynamic render registries can be intentionally reachable without static template usage.",
),
suppress_line("// fallow-ignore-next-line unrendered-component"),
];
Self {
component,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedComponentPropFinding {
#[serde(flatten)]
pub prop: UnusedComponentProp,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl UnusedComponentPropFinding {
#[must_use]
pub fn with_actions(prop: UnusedComponentProp) -> Self {
let actions = vec![
manual_framework_fix(
FixActionType::UseComponentProp,
"Use the declared prop in the component, or remove it from the component API",
"Manual review required: public component APIs can intentionally keep stable props for external consumers.",
),
suppress_line("// fallow-ignore-next-line unused-component-prop"),
];
Self {
prop,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedComponentEmitFinding {
#[serde(flatten)]
pub emit: UnusedComponentEmit,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl UnusedComponentEmitFinding {
#[must_use]
pub fn with_actions(emit: UnusedComponentEmit) -> Self {
let actions = vec![
manual_framework_fix(
FixActionType::EmitComponentEvent,
"Emit the declared event from the component, or remove it from the component API",
"Manual review required: public component APIs can intentionally keep stable events for external listeners.",
),
suppress_line("// fallow-ignore-next-line unused-component-emit"),
];
Self {
emit,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedSvelteEventFinding {
#[serde(flatten)]
pub event: UnusedSvelteEvent,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl UnusedSvelteEventFinding {
#[must_use]
pub fn with_actions(event: UnusedSvelteEvent) -> Self {
let actions = vec![
manual_framework_fix(
FixActionType::WireSvelteEvent,
"Add or forward a listener for this custom event, or remove the dispatch",
"Manual review required: public Svelte component APIs can intentionally dispatch events for package consumers outside this project.",
),
suppress_line("// fallow-ignore-next-line unused-svelte-event"),
];
Self {
event,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct PropDrillingChainFinding {
#[serde(flatten)]
pub chain: PropDrillingChain,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl PropDrillingChainFinding {
#[must_use]
pub fn with_actions(chain: PropDrillingChain) -> Self {
let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with an inline comment above the source prop declaration"
.to_string(),
comment: "// fallow-ignore-next-line prop-drilling".to_string(),
scope: None,
})];
Self {
chain,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ThinWrapperFinding {
#[serde(flatten)]
pub wrapper: ThinWrapper,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl ThinWrapperFinding {
#[must_use]
pub fn with_actions(wrapper: ThinWrapper) -> Self {
let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with an inline comment above the component definition"
.to_string(),
comment: "// fallow-ignore-next-line thin-wrapper".to_string(),
scope: None,
})];
Self {
wrapper,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DuplicatePropShapeFinding {
#[serde(flatten)]
pub shape: DuplicatePropShape,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl DuplicatePropShapeFinding {
#[must_use]
pub fn with_actions(shape: DuplicatePropShape) -> Self {
let actions = vec![
IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Three or more components share this exact prop shape. Extract one \
shared `Props` type (or a base component) that every member reuses, \
or keep them separate if a per-variant divergence is planned. \
Suppress one member with an inline comment above the component \
definition."
.to_string(),
comment: "// fallow-ignore-next-line duplicate-prop-shape".to_string(),
scope: None,
}),
IssueAction::SuppressFile(SuppressFileAction {
kind: SuppressFileKind::SuppressFile,
auto_fixable: false,
description: "Escape hatch: a file-level suppress silences this member but it \
still appears in its siblings' `sharing_components` (the group is \
real regardless of suppression)."
.to_string(),
comment: "// fallow-ignore-file duplicate-prop-shape".to_string(),
}),
];
Self {
shape,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedComponentInputFinding {
#[serde(flatten)]
pub input: UnusedComponentInput,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl UnusedComponentInputFinding {
#[must_use]
pub fn with_actions(input: UnusedComponentInput) -> Self {
let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with an inline comment above the line".to_string(),
comment: "// fallow-ignore-next-line unused-component-input".to_string(),
scope: None,
})];
Self {
input,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedComponentOutputFinding {
#[serde(flatten)]
pub output: UnusedComponentOutput,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl UnusedComponentOutputFinding {
#[must_use]
pub fn with_actions(output: UnusedComponentOutput) -> Self {
let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with an inline comment above the line".to_string(),
comment: "// fallow-ignore-next-line unused-component-output".to_string(),
scope: None,
})];
Self {
output,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct RouteCollisionFinding {
#[serde(flatten)]
pub collision: RouteCollision,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl RouteCollisionFinding {
#[must_use]
pub fn with_actions(collision: RouteCollision) -> Self {
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::ResolveRouteCollision,
auto_fixable: false,
description: "Two or more files resolve to the same URL. Move or merge one so \
each URL has a single owner. Route groups `(name)` and parallel \
slots `@name` are the only legal same-URL shapes."
.to_string(),
note: Some(
"Next.js fails the build with \"You cannot have two parallel pages that \
resolve to the same path\". See the sibling `conflicting_paths` array for \
the other files that own this URL."
.to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}),
IssueAction::SuppressFile(SuppressFileAction {
kind: SuppressFileKind::SuppressFile,
auto_fixable: false,
description: "Escape hatch only: a file-level suppress silences the finding but \
does NOT make `next build` pass. Prefer moving or merging a file."
.to_string(),
comment: "// fallow-ignore-file route-collision".to_string(),
}),
];
Self {
collision,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DynamicSegmentNameConflictFinding {
#[serde(flatten)]
pub conflict: DynamicSegmentNameConflict,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl DynamicSegmentNameConflictFinding {
#[must_use]
pub fn with_actions(conflict: DynamicSegmentNameConflict) -> Self {
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::ResolveDynamicSegmentNameConflict,
auto_fixable: false,
description: "Sibling dynamic segments at the same position use different param \
names. Rename them to one consistent slug name (e.g. pick `[id]` \
or `[slug]` for both)."
.to_string(),
note: Some(
"Next.js throws \"You cannot use different slug names for the same dynamic \
path\" at dev / runtime when the position is hit; `next build` does not \
catch it. See the sibling `conflicting_segments` array."
.to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}),
IssueAction::SuppressFile(SuppressFileAction {
kind: SuppressFileKind::SuppressFile,
auto_fixable: false,
description: "Escape hatch only: a file-level suppress silences the finding but \
does NOT stop Next.js from throwing at dev / runtime. Prefer \
renaming the segments."
.to_string(),
comment: "// fallow-ignore-file dynamic-segment-name-conflict".to_string(),
}),
];
Self {
conflict,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedEnumMemberFinding {
#[serde(flatten)]
pub member: UnusedMember,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reachability_caveats: Vec<ReachabilityCaveat>,
}
impl UnusedEnumMemberFinding {
#[must_use]
pub fn with_actions(member: UnusedMember) -> Self {
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::RemoveEnumMember,
auto_fixable: true,
description: "Remove this enum member".to_string(),
note: None,
available_in_catalogs: None,
suggested_target: None,
}),
IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with an inline comment above the line".to_string(),
comment: "// fallow-ignore-next-line unused-enum-member".to_string(),
scope: None,
}),
];
Self {
member,
actions,
introduced: None,
reachability_caveats: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedClassMemberFinding {
#[serde(flatten)]
pub member: UnusedMember,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub semantic: Option<SemanticCandidateDecision>,
#[serde(skip)]
#[cfg_attr(feature = "schema", schemars(skip))]
pub semantic_only_candidate: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reachability_caveats: Vec<ReachabilityCaveat>,
}
impl UnusedClassMemberFinding {
#[must_use]
pub fn with_actions(member: UnusedMember) -> Self {
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::RemoveClassMember,
auto_fixable: false,
description: "Remove this class member".to_string(),
note: Some(
"Class member may be used via dependency injection or decorators".to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}),
IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with an inline comment above the line".to_string(),
comment: "// fallow-ignore-next-line unused-class-member".to_string(),
scope: None,
}),
];
Self {
member,
actions,
semantic: None,
semantic_only_candidate: false,
introduced: None,
reachability_caveats: Vec::new(),
}
}
#[must_use]
pub const fn semantic_only_candidate(mut self) -> Self {
self.semantic_only_candidate = true;
self
}
pub fn set_semantic_decision(&mut self, decision: SemanticCandidateDecision) {
let evidence_complete = self.reachability_caveats.is_empty();
if let Some(IssueAction::Fix(action)) = self.actions.first_mut() {
action.auto_fixable = decision.closed_world_eligible && evidence_complete;
action.note = Some(if evidence_complete {
decision.explanation.clone()
} else {
INCOMPLETE_EVIDENCE_NOTE.to_string()
});
}
self.semantic = Some(decision);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedStoreMemberFinding {
#[serde(flatten)]
pub member: UnusedMember,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reachability_caveats: Vec<ReachabilityCaveat>,
}
impl UnusedStoreMemberFinding {
#[must_use]
pub fn with_actions(member: UnusedMember) -> Self {
let actions = vec![IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with an inline comment above the line".to_string(),
comment: "// fallow-ignore-next-line unused-store-member".to_string(),
scope: None,
})];
Self {
member,
actions,
introduced: None,
reachability_caveats: Vec::new(),
}
}
}
fn build_unused_dependency_actions(
dep: &UnusedDependency,
package_json_location: &str,
suppress_issue_kind: &str,
) -> Vec<IssueAction> {
let mut actions = Vec::with_capacity(2);
let cross_workspace = !dep.used_in_workspaces.is_empty();
actions.push(if cross_workspace {
IssueAction::Fix(FixAction {
kind: FixActionType::MoveDependency,
auto_fixable: false,
description: "Move this dependency to the workspace package.json that imports it"
.to_string(),
note: Some(
"fallow fix will not remove dependencies that are imported by another workspace"
.to_string(),
),
available_in_catalogs: None,
suggested_target: None,
})
} else {
IssueAction::Fix(FixAction {
kind: FixActionType::RemoveDependency,
auto_fixable: true,
description: format!("Remove from {package_json_location} in package.json"),
note: None,
available_in_catalogs: None,
suggested_target: None,
})
});
actions.push(build_ignore_dependencies_suppress_action(
&dep.package_name,
suppress_issue_kind,
));
actions
}
fn build_ignore_dependencies_suppress_action(
package_name: &str,
_suppress_issue_kind: &str,
) -> IssueAction {
IssueAction::AddToConfig(AddToConfigAction {
kind: AddToConfigKind::AddToConfig,
auto_fixable: false,
description: format!("Add \"{package_name}\" to ignoreDependencies in fallow config"),
config_key: "ignoreDependencies".to_string(),
value: AddToConfigValue::Scalar(package_name.to_string()),
value_schema: Some(
"https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items"
.to_string(),
),
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedDependencyFinding {
#[serde(flatten)]
pub dep: UnusedDependency,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reachability_caveats: Vec<ReachabilityCaveat>,
}
impl UnusedDependencyFinding {
#[must_use]
pub fn with_actions(dep: UnusedDependency) -> Self {
let actions = build_unused_dependency_actions(&dep, "dependencies", "unused-dependency");
Self {
dep,
actions,
introduced: None,
reachability_caveats: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedDevDependencyFinding {
#[serde(flatten)]
pub dep: UnusedDependency,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reachability_caveats: Vec<ReachabilityCaveat>,
}
impl UnusedDevDependencyFinding {
#[must_use]
pub fn with_actions(dep: UnusedDependency) -> Self {
let actions =
build_unused_dependency_actions(&dep, "devDependencies", "unused-dev-dependency");
Self {
dep,
actions,
introduced: None,
reachability_caveats: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedOptionalDependencyFinding {
#[serde(flatten)]
pub dep: UnusedDependency,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reachability_caveats: Vec<ReachabilityCaveat>,
}
impl UnusedOptionalDependencyFinding {
#[must_use]
pub fn with_actions(dep: UnusedDependency) -> Self {
let actions =
build_unused_dependency_actions(&dep, "optionalDependencies", "unused-dependency");
Self {
dep,
actions,
introduced: None,
reachability_caveats: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnlistedDependencyFinding {
#[serde(flatten)]
pub dep: UnlistedDependency,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl UnlistedDependencyFinding {
#[must_use]
pub fn with_actions(dep: UnlistedDependency) -> Self {
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::InstallDependency,
auto_fixable: false,
description: "Add this package to dependencies in package.json".to_string(),
note: Some(
"Verify this package should be a direct dependency before adding".to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}),
build_ignore_dependencies_suppress_action(&dep.package_name, "unlisted-dependency"),
];
Self {
dep,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct TypeOnlyDependencyFinding {
#[serde(flatten)]
pub dep: TypeOnlyDependency,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl TypeOnlyDependencyFinding {
#[must_use]
pub fn with_actions(dep: TypeOnlyDependency) -> Self {
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::MoveToDev,
auto_fixable: false,
description: "Move to devDependencies (only type imports are used)".to_string(),
note: Some(
"Type imports are erased at runtime so this dependency is not needed in production"
.to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}),
build_ignore_dependencies_suppress_action(&dep.package_name, "type-only-dependency"),
];
Self {
dep,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct TestOnlyDependencyFinding {
#[serde(flatten)]
pub dep: TestOnlyDependency,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl TestOnlyDependencyFinding {
#[must_use]
pub fn with_actions(dep: TestOnlyDependency) -> Self {
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::MoveToDev,
auto_fixable: false,
description: "Move to devDependencies (only test files import this)".to_string(),
note: Some(
"Only test files import this package so it does not need to be a production dependency"
.to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}),
build_ignore_dependencies_suppress_action(&dep.package_name, "test-only-dependency"),
];
Self {
dep,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DevDependencyInProductionFinding {
#[serde(flatten)]
pub dep: DevDependencyInProduction,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl DevDependencyInProductionFinding {
#[must_use]
pub fn with_actions(dep: DevDependencyInProduction) -> Self {
let actions = vec![
IssueAction::Fix(FixAction {
kind: FixActionType::MoveToProd,
auto_fixable: false,
description:
"Move to dependencies if the deployment installs them (production code imports this)"
.to_string(),
note: Some(
"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"
.to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}),
build_ignore_dependencies_suppress_action(
&dep.package_name,
"dev-dependency-in-production",
),
];
Self {
dep,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DuplicateExportFinding {
#[serde(flatten)]
pub export: DuplicateExport,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl DuplicateExportFinding {
#[must_use]
pub fn with_actions(export: DuplicateExport) -> Self {
let mut actions: Vec<IssueAction> = Vec::with_capacity(3);
if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
actions.push(IssueAction::AddToConfig(AddToConfigAction {
kind: AddToConfigKind::AddToConfig,
auto_fixable: false,
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(),
config_key: "ignoreExports".to_string(),
value: AddToConfigValue::ExportsRules(rules),
value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
}));
}
actions.push(IssueAction::Fix(FixAction {
kind: FixActionType::RemoveDuplicate,
auto_fixable: false,
description: "Keep one canonical export location and remove the others".to_string(),
note: Some(NAMESPACE_BARREL_HINT.to_string()),
available_in_catalogs: None,
suggested_target: None,
}));
actions.push(IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with an inline comment above the line".to_string(),
comment: "// fallow-ignore-next-line duplicate-export".to_string(),
scope: Some(SuppressLineScope::PerLocation),
}));
Self {
export,
actions,
introduced: None,
}
}
pub fn set_config_fixable(&mut self, fixable: bool) {
if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
action.auto_fixable = fixable;
}
}
}
fn build_duplicate_exports_ignore_rules(
export: &DuplicateExport,
) -> Option<Vec<IgnoreExportsRule>> {
let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
for loc in &export.locations {
let path = loc.path.to_string_lossy().replace('\\', "/");
if path.is_empty() {
continue;
}
if entries.iter().any(|existing| existing.file == path) {
continue;
}
entries.push(IgnoreExportsRule {
file: path,
exports: vec!["*".to_string()],
});
}
if entries.is_empty() {
None
} else {
Some(entries)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedCatalogEntryFinding {
#[serde(flatten)]
pub entry: UnusedCatalogEntry,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl UnusedCatalogEntryFinding {
#[must_use]
pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
let is_pnpm_source = is_pnpm_catalog_source(&entry.path);
let auto_fixable = entry.hardcoded_consumers.is_empty() && is_pnpm_source;
let note = if is_pnpm_source {
Some(
"If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing"
.to_string(),
)
} else {
Some(
"fallow fix only edits pnpm-workspace.yaml catalog entries. Edit Bun package.json catalogs manually."
.to_string(),
)
};
let mut actions = vec![IssueAction::Fix(FixAction {
kind: FixActionType::RemoveCatalogEntry,
auto_fixable,
description: if is_pnpm_source {
"Remove the entry from pnpm-workspace.yaml".to_string()
} else {
"Remove the entry from the catalog source file manually".to_string()
},
note,
available_in_catalogs: None,
suggested_target: None,
})];
if is_pnpm_source {
actions.push(IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with a YAML comment above the line".to_string(),
comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
scope: None,
}));
}
Self {
entry,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct EmptyCatalogGroupFinding {
#[serde(flatten)]
pub group: EmptyCatalogGroup,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl EmptyCatalogGroupFinding {
#[must_use]
pub fn with_actions(group: EmptyCatalogGroup) -> Self {
let auto_fixable = is_pnpm_catalog_source(&group.path);
let mut actions = vec![IssueAction::Fix(FixAction {
kind: FixActionType::RemoveEmptyCatalogGroup,
auto_fixable,
description: if auto_fixable {
"Remove the empty named catalog group from pnpm-workspace.yaml".to_string()
} else {
"Remove the empty named catalog group from the catalog source file manually"
.to_string()
},
note: Some(if auto_fixable {
"Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
.to_string()
} else {
"fallow fix only edits pnpm-workspace.yaml catalog groups. Edit Bun package.json catalogs manually."
.to_string()
}),
available_in_catalogs: None,
suggested_target: None,
})];
if auto_fixable {
actions.push(IssueAction::SuppressLine(SuppressLineAction {
kind: SuppressLineKind::SuppressLine,
auto_fixable: false,
description: "Suppress with a YAML comment above the line".to_string(),
comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
scope: None,
}));
}
Self {
group,
actions,
introduced: None,
}
}
}
fn is_pnpm_catalog_source(path: &Path) -> bool {
path == Path::new(PNPM_WORKSPACE_FILE)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnresolvedCatalogReferenceFinding {
#[serde(flatten)]
pub reference: UnresolvedCatalogReference,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl UnresolvedCatalogReferenceFinding {
#[must_use]
pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
let primary = catalog_reference_primary_action(&reference);
let fallback = remove_catalog_reference_action();
let suppress = suppress_catalog_reference_action(&reference, consumer_path);
Self {
reference,
actions: vec![primary, fallback, suppress],
introduced: None,
}
}
}
fn catalog_reference_primary_action(reference: &UnresolvedCatalogReference) -> IssueAction {
if reference.available_in_catalogs.is_empty() {
return IssueAction::Fix(FixAction {
kind: FixActionType::AddCatalogEntry,
auto_fixable: false,
description: format!(
"Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
reference.entry_name, reference.catalog_name
),
note: Some(
"Pin a version that satisfies the consumer's import; no other catalog declares this package today"
.to_string(),
),
available_in_catalogs: None,
suggested_target: None,
});
}
let available = reference.available_in_catalogs.clone();
let suggested_target = (available.len() == 1).then(|| available[0].clone());
IssueAction::Fix(FixAction {
kind: FixActionType::UpdateCatalogReference,
auto_fixable: false,
description: format!(
"Switch the reference from `catalog:{}` to a catalog that declares `{}`",
reference.catalog_name, reference.entry_name
),
note: None,
available_in_catalogs: Some(available),
suggested_target,
})
}
fn remove_catalog_reference_action() -> IssueAction {
IssueAction::Fix(FixAction {
kind: FixActionType::RemoveCatalogReference,
auto_fixable: false,
description: "Remove the catalog reference and pin a hardcoded version in package.json"
.to_string(),
note: Some(
"Use only when neither another catalog declares the package nor the named catalog should grow to include it"
.to_string(),
),
available_in_catalogs: None,
suggested_target: None,
})
}
fn suppress_catalog_reference_action(
reference: &UnresolvedCatalogReference,
consumer_path: String,
) -> IssueAction {
let mut suppress_value = serde_json::Map::new();
suppress_value.insert(
"package".to_string(),
serde_json::Value::String(reference.entry_name.clone()),
);
suppress_value.insert(
"catalog".to_string(),
serde_json::Value::String(reference.catalog_name.clone()),
);
suppress_value.insert(
"consumer".to_string(),
serde_json::Value::String(consumer_path),
);
IssueAction::AddToConfig(AddToConfigAction {
kind: AddToConfigKind::AddToConfig,
auto_fixable: false,
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(),
config_key: "ignoreCatalogReferences".to_string(),
value: AddToConfigValue::RuleObject(suppress_value),
value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedDependencyOverrideFinding {
#[serde(flatten)]
pub entry: UnusedDependencyOverride,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl UnusedDependencyOverrideFinding {
#[must_use]
pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
actions.push(IssueAction::Fix(FixAction {
kind: FixActionType::RemoveDependencyOverride,
auto_fixable: false,
description: "Remove the package-manager override entry from its declaration source"
.to_string(),
note: Some(
"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)"
.to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}));
if let Some(suppress) = build_ignore_dependency_overrides_suppress(
Some(&entry.target_package),
&entry.raw_key,
entry.source,
) {
actions.push(suppress);
}
Self {
entry,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct MisconfiguredDependencyOverrideFinding {
#[serde(flatten)]
pub entry: MisconfiguredDependencyOverride,
pub actions: Vec<IssueAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl MisconfiguredDependencyOverrideFinding {
#[must_use]
pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
actions.push(IssueAction::Fix(FixAction {
kind: FixActionType::FixDependencyOverride,
auto_fixable: false,
description:
"Fix the package-manager override key or value: invalid entries are rejected or ignored"
.to_string(),
note: Some(
"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`."
.to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}));
if let Some(suppress) = build_ignore_dependency_overrides_suppress(
entry.target_package.as_deref(),
&entry.raw_key,
entry.source,
) {
actions.push(suppress);
}
Self {
entry,
actions,
introduced: None,
}
}
}
fn build_ignore_dependency_overrides_suppress(
target_package: Option<&str>,
raw_key: &str,
source: DependencyOverrideSource,
) -> Option<IssueAction> {
let package = target_package
.filter(|s| !s.is_empty())
.or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
.to_string();
let mut value = serde_json::Map::new();
value.insert("package".to_string(), serde_json::Value::String(package));
value.insert(
"source".to_string(),
serde_json::Value::String(source.as_label().to_string()),
);
Some(IssueAction::AddToConfig(AddToConfigAction {
kind: AddToConfigKind::AddToConfig,
auto_fixable: false,
description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
config_key: "ignoreDependencyOverrides".to_string(),
value: AddToConfigValue::RuleObject(value),
value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
}))
}
impl_caveated_finding!(
UnusedFileFinding,
UnusedExportFinding,
UnusedTypeFinding,
UnusedEnumMemberFinding,
UnusedClassMemberFinding,
UnusedStoreMemberFinding,
UnusedDependencyFinding,
UnusedDevDependencyFinding,
UnusedOptionalDependencyFinding,
);
#[cfg(test)]
mod caveat_tokens {
use super::*;
#[test]
fn token_labels_match_the_typed_labels() {
let typed = [
ReachabilityCaveat::IncompleteFileAnalysis,
ReachabilityCaveat::IncompleteImportGraph,
];
let tokens: Vec<&str> = typed.iter().map(|c| c.token()).collect();
assert_eq!(
caveat_labels_for_tokens(tokens.iter().copied()),
caveat_labels(&typed)
);
assert_eq!(
caveat_suffix_for_tokens(tokens.iter().copied()),
caveat_suffix(&typed)
);
}
#[test]
fn no_tokens_means_nothing_to_say() {
assert_eq!(caveat_labels_for_tokens(std::iter::empty()), None);
assert_eq!(caveat_suffix_for_tokens(std::iter::empty()), None);
}
#[test]
fn a_rendered_suffix_is_recognised_by_the_marker() {
for caveats in [
&[ReachabilityCaveat::IncompleteImportGraph][..],
&[
ReachabilityCaveat::IncompleteFileAnalysis,
ReachabilityCaveat::IncompleteImportGraph,
][..],
] {
let suffix = caveat_suffix(caveats).expect("a caveat renders a suffix");
assert!(
description_carries_caveat(&format!("Something is never referenced{suffix}")),
"the marker must match what caveat_suffix writes: {suffix}"
);
}
assert!(
description_carries_caveat(&format!(
"Something is never referenced{}",
caveat_suffix_for_tokens(["some-future-cause"]).expect("token suffix")
)),
"the token-side renderer writes the same marker"
);
assert!(
!description_carries_caveat("Class member 'Widget.helper' is never referenced"),
"a clean description must not read as caveated"
);
}
#[test]
fn no_caveat_message_names_a_single_cause() {
for caveat in [
ReachabilityCaveat::IncompleteFileAnalysis,
ReachabilityCaveat::IncompleteImportGraph,
] {
let message = caveat.message();
assert!(
!message.contains("parse cleanly") && !message.contains("parse error"),
"{} names the parse cause alone, but a size-skipped or unreadable \
file reaches the same caveat: {message}",
caveat.token()
);
assert!(
message.contains("workspace_diagnostics"),
"{} must point at the list that names the actual files: {message}",
caveat.token()
);
}
}
#[test]
fn an_unrecognised_token_still_renders_as_a_caveat() {
let suffix = caveat_suffix_for_tokens(["some-future-cause"])
.expect("an unknown token is still a caveat");
assert_eq!(suffix, " (caveat: some future cause)");
}
}
#[cfg(test)]
mod mutation_gate {
use super::*;
use crate::extract::MemberKind;
use crate::results::DependencyLocation;
use std::path::PathBuf;
const BOTH: [ReachabilityCaveat; 2] = [
ReachabilityCaveat::IncompleteFileAnalysis,
ReachabilityCaveat::IncompleteImportGraph,
];
fn export(name: &str) -> UnusedExport {
UnusedExport {
path: PathBuf::from("/p/src/mod.ts"),
export_name: name.to_string(),
is_type_only: false,
line: 1,
col: 0,
span_start: 0,
is_re_export: false,
}
}
fn member(name: &str) -> UnusedMember {
UnusedMember {
path: PathBuf::from("/p/src/mod.ts"),
parent_name: "Color".to_string(),
member_name: name.to_string(),
kind: MemberKind::EnumMember,
line: 2,
col: 2,
}
}
fn class_member(name: &str) -> UnusedMember {
UnusedMember {
parent_name: "Widget".to_string(),
kind: MemberKind::ClassMethod,
..member(name)
}
}
fn store_member(name: &str) -> UnusedMember {
UnusedMember {
parent_name: "useCounterStore".to_string(),
kind: MemberKind::StoreMember,
..member(name)
}
}
fn dependency(name: &str) -> UnusedDependency {
UnusedDependency {
package_name: name.to_string(),
location: DependencyLocation::Dependencies,
path: PathBuf::from("/p/package.json"),
line: 5,
used_in_workspaces: Vec::new(),
}
}
type GatedPair = (&'static str, Box<dyn Gated>, Box<dyn Gated>);
fn every_finding_type() -> Vec<GatedPair> {
fn pair<T: Gated + Clone + 'static>(name: &'static str, clean: T) -> GatedPair {
let mut caveated = clean.clone();
caveated.stamp(BOTH.to_vec());
(name, Box::new(clean), Box::new(caveated))
}
vec![
pair(
"unused_files",
UnusedFileFinding::with_actions(UnusedFile {
path: PathBuf::from("/p/src/orphan.ts"),
}),
),
pair(
"unused_exports",
UnusedExportFinding::with_actions(export("helper")),
),
pair(
"unused_types",
UnusedTypeFinding::with_actions(export("Shape")),
),
pair(
"unused_enum_members",
UnusedEnumMemberFinding::with_actions(member("Blue")),
),
pair(
"unused_class_members",
UnusedClassMemberFinding::with_actions(class_member("legacyMethod")),
),
pair(
"unused_store_members",
UnusedStoreMemberFinding::with_actions(store_member("onlyUsedInBigFile")),
),
pair(
"unused_dependencies",
UnusedDependencyFinding::with_actions(dependency("lodash")),
),
pair(
"unused_dev_dependencies",
UnusedDevDependencyFinding::with_actions(dependency("vitest")),
),
pair(
"unused_optional_dependencies",
UnusedOptionalDependencyFinding::with_actions(dependency("fsevents")),
),
]
}
trait Gated {
fn actions(&self) -> &[IssueAction];
fn gate_allows_mutation(&self) -> bool;
fn stamp(&mut self, caveats: Vec<ReachabilityCaveat>);
}
impl<T: MutationEvidence + CaveatedFinding + HasActions> Gated for T {
fn actions(&self) -> &[IssueAction] {
HasActions::actions(self)
}
fn gate_allows_mutation(&self) -> bool {
self.may_auto_apply_mutation()
}
fn stamp(&mut self, caveats: Vec<ReachabilityCaveat>) {
self.set_reachability_caveats(caveats);
}
}
trait HasActions {
fn actions(&self) -> &[IssueAction];
}
macro_rules! has_actions {
($($ty:ty),+ $(,)?) => { $( impl HasActions for $ty {
fn actions(&self) -> &[IssueAction] { &self.actions }
} )+ };
}
has_actions!(
UnusedFileFinding,
UnusedExportFinding,
UnusedTypeFinding,
UnusedEnumMemberFinding,
UnusedClassMemberFinding,
UnusedStoreMemberFinding,
UnusedDependencyFinding,
UnusedDevDependencyFinding,
UnusedOptionalDependencyFinding,
);
#[test]
fn every_auto_fixable_dead_code_mutation_is_gated() {
for (name, _clean, caveated) in every_finding_type() {
assert!(
!caveated.gate_allows_mutation(),
"{name}: a stamped finding must fail the gate"
);
for action in caveated.actions() {
assert!(
!action.is_auto_fixable(),
"{name}: a caveated finding still advertises an auto-fixable action, so an \
agent following the documented actions contract would plan a removal \
`fallow fix` refuses"
);
}
}
}
#[test]
fn an_uncaveated_finding_keeps_its_auto_fix() {
let auto_fixable_types = [
"unused_exports",
"unused_types",
"unused_enum_members",
"unused_dependencies",
"unused_dev_dependencies",
"unused_optional_dependencies",
];
for (name, clean, _caveated) in every_finding_type() {
assert!(
clean.gate_allows_mutation(),
"{name}: a finding with no caveat must pass the gate"
);
if auto_fixable_types.contains(&name) {
assert!(
clean.actions().iter().any(IssueAction::is_auto_fixable),
"{name}: the gate must not withhold a mutation the run has the evidence for"
);
}
}
}
#[test]
fn the_gate_downgrades_a_mutation_without_removing_it() {
let clean = UnusedExportFinding::with_actions(export("helper"));
let mut caveated = clean.clone();
caveated.set_reachability_caveats(BOTH.to_vec());
assert_eq!(caveated.actions.len(), clean.actions.len());
let IssueAction::Fix(fix) = &caveated.actions[0] else {
panic!("position 0 stays the fix action");
};
assert!(!fix.auto_fixable);
assert_eq!(
fix.note.as_deref(),
Some(INCOMPLETE_EVIDENCE_NOTE),
"the withheld action says why in its own note, not only in a sibling array"
);
}
#[test]
fn a_gated_mutation_keeps_the_note_it_already_had() {
let mut re_export = export("helper");
re_export.is_re_export = true;
let mut finding = UnusedExportFinding::with_actions(re_export);
finding.set_reachability_caveats(vec![ReachabilityCaveat::IncompleteImportGraph]);
let IssueAction::Fix(fix) = &finding.actions[0] else {
panic!("position 0 stays the fix action");
};
let note = fix.note.as_deref().expect("note present");
assert!(note.contains("public API"), "the original note survives");
assert!(
note.contains("Evidence is incomplete"),
"the caveat is added"
);
}
#[test]
fn a_store_member_exposes_no_mutation_at_all() {
let store = UnusedStoreMemberFinding::with_actions(member("total"));
assert!(
!store.actions.iter().any(IssueAction::is_auto_fixable),
"a store member must expose no automatically applicable mutation"
);
assert!(
!store
.actions
.iter()
.any(|action| matches!(action, IssueAction::Fix(_))),
"and no fix action at all"
);
let class = UnusedClassMemberFinding::with_actions(class_member("helper"));
assert!(
!class.actions.iter().any(IssueAction::is_auto_fixable),
"a class member's syntactic removal stays withheld until semantic evidence opens it"
);
}
#[test]
fn a_complete_semantic_verdict_cannot_reopen_a_caveated_mutation() {
use crate::semantic::{
SemanticCandidateDecision, SemanticCandidateDecisionKind, SemanticCompleteness,
SemanticNamespace, SemanticSymbol,
};
let complete_negative = || SemanticCandidateDecision {
query_id: 0,
subject: SemanticSymbol {
path: PathBuf::from("/p/src/mod.ts"),
namespace: SemanticNamespace::Value,
declaration_kind: "function".to_string(),
exported_name: "helper".to_string(),
local_name: "helper".to_string(),
owner: None,
line: 1,
col: 0,
},
decision: SemanticCandidateDecisionKind::ConfirmedNoStaticReferences,
status: SemanticCompleteness::Complete,
owning_projects: Vec::new(),
evidence: Vec::new(),
contract: None,
framework_contract: None,
closed_world_eligible: false,
edit_guard: None,
reason_code: None,
explanation: String::new(),
actions: Vec::new(),
total_evidence_count: 0,
truncated: false,
omissions: Vec::new(),
};
let mut clean = UnusedExportFinding::with_actions(export("helper"));
clean.set_semantic_decision(complete_negative());
assert!(
clean.actions.iter().any(IssueAction::is_auto_fixable),
"a complete negative verdict on a clean run still enables the fix"
);
let mut caveated = UnusedExportFinding::with_actions(export("helper"));
caveated.set_reachability_caveats(vec![ReachabilityCaveat::IncompleteImportGraph]);
caveated.set_semantic_decision(complete_negative());
assert!(
!caveated.actions.iter().any(IssueAction::is_auto_fixable),
"the semantic pass must ask the gate too"
);
}
#[test]
fn a_complete_semantic_verdict_cannot_reopen_a_caveated_class_member() {
use crate::semantic::{
SemanticCandidateDecision, SemanticCandidateDecisionKind, SemanticCompleteness,
SemanticNamespace, SemanticSymbol,
};
let eligible = || SemanticCandidateDecision {
query_id: 0,
subject: SemanticSymbol {
path: PathBuf::from("/p/src/mod.ts"),
namespace: SemanticNamespace::Value,
declaration_kind: "method".to_string(),
exported_name: "Widget".to_string(),
local_name: "legacyMethod".to_string(),
owner: Some("Widget".to_string()),
line: 2,
col: 2,
},
decision: SemanticCandidateDecisionKind::ConfirmedNoStaticReferences,
status: SemanticCompleteness::Complete,
owning_projects: Vec::new(),
evidence: Vec::new(),
contract: None,
framework_contract: None,
closed_world_eligible: true,
edit_guard: None,
reason_code: None,
explanation: "closed world proved".to_string(),
actions: Vec::new(),
total_evidence_count: 0,
truncated: false,
omissions: Vec::new(),
};
let mut clean = UnusedClassMemberFinding::with_actions(class_member("legacyMethod"));
clean.set_semantic_decision(eligible());
assert!(
clean.actions.iter().any(IssueAction::is_auto_fixable),
"a closed-world verdict on a run that read every file still opens the removal"
);
let mut caveated = UnusedClassMemberFinding::with_actions(class_member("legacyMethod"));
caveated.set_reachability_caveats(vec![ReachabilityCaveat::IncompleteImportGraph]);
caveated.set_semantic_decision(eligible());
assert!(
!caveated.actions.iter().any(IssueAction::is_auto_fixable),
"the class-member semantic pass must ask the gate too"
);
let IssueAction::Fix(fix) = &caveated.actions[0] else {
panic!("position 0 stays the fix action");
};
assert_eq!(
fix.note.as_deref(),
Some(INCOMPLETE_EVIDENCE_NOTE),
"the withheld action says why, rather than repeating a closed-world explanation \
computed over a program the run did not fully read"
);
}
}
#[cfg(test)]
mod position_0_invariants {
use super::*;
use crate::output::FixActionType;
use crate::results::{DependencyOverrideSource, DuplicateLocation};
use std::path::PathBuf;
fn action_type(action: &IssueAction) -> &'static str {
match action {
IssueAction::Fix(fix) => match fix.kind {
FixActionType::RemoveExport => "remove-export",
FixActionType::DeleteFile => "delete-file",
FixActionType::RemoveDependency => "remove-dependency",
FixActionType::MoveDependency => "move-dependency",
FixActionType::RemoveEnumMember => "remove-enum-member",
FixActionType::RemoveClassMember => "remove-class-member",
FixActionType::ResolveImport => "resolve-import",
FixActionType::InstallDependency => "install-dependency",
FixActionType::RemoveDuplicate => "remove-duplicate",
FixActionType::MoveToDev => "move-to-dev",
FixActionType::MoveToProd => "move-to-prod",
FixActionType::RefactorCycle => "refactor-cycle",
FixActionType::RefactorReExportCycle => "refactor-re-export-cycle",
FixActionType::RefactorBoundary => "refactor-boundary",
FixActionType::ExportType => "export-type",
FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
FixActionType::UpdateCatalogReference => "update-catalog-reference",
FixActionType::AddCatalogEntry => "add-catalog-entry",
FixActionType::RemoveCatalogReference => "remove-catalog-reference",
FixActionType::RemoveDependencyOverride => "remove-dependency-override",
FixActionType::FixDependencyOverride => "fix-dependency-override",
FixActionType::ResolvePolicyViolation => "resolve-policy-violation",
FixActionType::MoveToServerModule => "move-to-server-module",
FixActionType::SplitMixedBarrel => "split-mixed-barrel",
FixActionType::HoistDirective => "hoist-directive",
FixActionType::WireServerAction => "wire-server-action",
FixActionType::ProvideInject => "provide-inject",
FixActionType::UseLoadData => "use-load-data",
FixActionType::RenderComponent => "render-component",
FixActionType::UseComponentProp => "use-component-prop",
FixActionType::EmitComponentEvent => "emit-component-event",
FixActionType::WireSvelteEvent => "wire-svelte-event",
FixActionType::ResolveRouteCollision => "resolve-route-collision",
FixActionType::ResolveDynamicSegmentNameConflict => {
"resolve-dynamic-segment-name-conflict"
}
FixActionType::AddSuppressionReason => "add-suppression-reason",
FixActionType::RemoveStaleSuppression => "remove-stale-suppression",
},
IssueAction::SuppressLine(_) => "suppress-line",
IssueAction::SuppressFile(_) => "suppress-file",
IssueAction::AddToConfig(_) => "add-to-config",
}
}
fn assert_manual_fix_then_suppress(
actions: &[IssueAction],
primary_type: &str,
suppress_comment: &str,
) {
assert_eq!(actions.len(), 2);
assert_eq!(action_type(&actions[0]), primary_type);
let IssueAction::Fix(primary) = &actions[0] else {
panic!("position-0 should be a manual fix action");
};
assert!(!primary.auto_fixable);
assert!(primary.note.is_some());
assert_eq!(action_type(&actions[1]), "suppress-line");
let IssueAction::SuppressLine(suppress) = &actions[1] else {
panic!("position-1 should be a suppress-line action");
};
assert_eq!(suppress.comment, suppress_comment);
}
#[test]
fn pnpm_catalog_entry_action_is_auto_fixable() {
let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
entry_name: "unused".to_string(),
catalog_name: "default".to_string(),
path: PathBuf::from("pnpm-workspace.yaml"),
line: 3,
hardcoded_consumers: vec![],
});
let IssueAction::Fix(fix) = &finding.actions[0] else {
panic!("position-0 should be a fix action");
};
assert!(fix.auto_fixable);
assert_eq!(finding.actions.len(), 2);
assert_eq!(action_type(&finding.actions[1]), "suppress-line");
}
#[test]
fn bun_package_json_catalog_entry_action_is_manual_only() {
let finding = UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
entry_name: "unused".to_string(),
catalog_name: "default".to_string(),
path: PathBuf::from("package.json"),
line: 4,
hardcoded_consumers: vec![],
});
let IssueAction::Fix(fix) = &finding.actions[0] else {
panic!("position-0 should be a fix action");
};
assert!(!fix.auto_fixable);
assert!(fix.description.contains("manually"));
assert_eq!(finding.actions.len(), 1);
}
#[test]
fn bun_package_json_empty_catalog_group_action_is_manual_only() {
let finding = EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
catalog_name: "empty".to_string(),
path: PathBuf::from("package.json"),
line: 4,
});
let IssueAction::Fix(fix) = &finding.actions[0] else {
panic!("position-0 should be a fix action");
};
assert!(!fix.auto_fixable);
assert!(fix.description.contains("manually"));
assert_eq!(finding.actions.len(), 1);
}
#[test]
fn unprovided_inject_primary_action_is_provide_inject() {
let finding = UnprovidedInjectFinding::with_actions(UnprovidedInject {
path: PathBuf::from("src/context.ts"),
key_name: "userKey".to_string(),
framework: "svelte".to_string(),
line: 7,
col: 12,
});
assert_manual_fix_then_suppress(
&finding.actions,
"provide-inject",
"// fallow-ignore-next-line unprovided-inject",
);
}
#[test]
fn unused_server_action_primary_action_is_wire_server_action() {
let finding = UnusedServerActionFinding::with_actions(UnusedServerAction {
path: PathBuf::from("app/actions.ts"),
action_name: "saveDraft".to_string(),
line: 3,
col: 13,
});
assert_manual_fix_then_suppress(
&finding.actions,
"wire-server-action",
"// fallow-ignore-next-line unused-server-action",
);
}
#[test]
fn unused_load_data_key_primary_action_is_use_load_data() {
let finding = UnusedLoadDataKeyFinding::with_actions(UnusedLoadDataKey {
path: PathBuf::from("src/routes/+page.server.ts"),
key_name: "profile".to_string(),
line: 12,
col: 6,
route_dir: Some("src/routes".to_string()),
});
assert_manual_fix_then_suppress(
&finding.actions,
"use-load-data",
"// fallow-ignore-next-line unused-load-data-key",
);
}
#[test]
fn unrendered_component_primary_action_is_render_component() {
let finding = UnrenderedComponentFinding::with_actions(UnrenderedComponent {
path: PathBuf::from("src/components/EmptyState.vue"),
component_name: "EmptyState".to_string(),
framework: "vue".to_string(),
reachable_via: None,
line: 1,
col: 0,
});
assert_manual_fix_then_suppress(
&finding.actions,
"render-component",
"// fallow-ignore-next-line unrendered-component",
);
}
#[test]
fn unused_component_prop_primary_action_is_use_component_prop() {
let finding = UnusedComponentPropFinding::with_actions(UnusedComponentProp {
path: PathBuf::from("src/components/Card.vue"),
component_name: "Card".to_string(),
prop_name: "variant".to_string(),
line: 5,
col: 10,
});
assert_manual_fix_then_suppress(
&finding.actions,
"use-component-prop",
"// fallow-ignore-next-line unused-component-prop",
);
}
#[test]
fn unused_component_emit_primary_action_is_emit_component_event() {
let finding = UnusedComponentEmitFinding::with_actions(UnusedComponentEmit {
path: PathBuf::from("src/components/Picker.vue"),
component_name: "Picker".to_string(),
emit_name: "focus".to_string(),
line: 6,
col: 14,
});
assert_manual_fix_then_suppress(
&finding.actions,
"emit-component-event",
"// fallow-ignore-next-line unused-component-emit",
);
}
#[test]
fn unused_svelte_event_primary_action_is_wire_svelte_event() {
let finding = UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
path: PathBuf::from("src/Dialog.svelte"),
component_name: "Dialog".to_string(),
event_name: "closed".to_string(),
line: 19,
col: 8,
});
assert_manual_fix_then_suppress(
&finding.actions,
"wire-svelte-event",
"// fallow-ignore-next-line unused-svelte-event",
);
}
#[test]
fn unresolved_import_actions_include_ignore_unresolved_imports_config_suppress() {
let inner = UnresolvedImport {
specifier: "@example/icons".to_string(),
path: PathBuf::from("src/index.ts"),
line: 4,
col: 12,
specifier_col: 18,
};
let finding = UnresolvedImportFinding::with_actions(inner);
assert_eq!(action_type(&finding.actions[0]), "resolve-import");
assert_eq!(action_type(&finding.actions[1]), "add-to-config");
let IssueAction::AddToConfig(action) = &finding.actions[1] else {
panic!("position-1 should be AddToConfig");
};
assert!(!action.auto_fixable);
assert_eq!(action.config_key, "ignoreUnresolvedImports");
let AddToConfigValue::Scalar(value) = &action.value else {
panic!("ignoreUnresolvedImports action should carry a scalar value");
};
assert_eq!(value, "@example/icons");
assert_eq!(
action.value_schema.as_deref(),
Some(
"https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreUnresolvedImports/items"
)
);
}
#[test]
fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
let inner = UnresolvedCatalogReference {
entry_name: "react".to_string(),
catalog_name: "default".to_string(),
path: PathBuf::from("apps/web/package.json"),
line: 7,
available_in_catalogs: Vec::new(),
};
let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
assert_eq!(
action_type(&finding.actions[0]),
"add-catalog-entry",
"position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
);
let IssueAction::Fix(fix) = &finding.actions[0] else {
panic!("position-0 should be an IssueAction::Fix");
};
assert!(
fix.available_in_catalogs.is_none(),
"add-catalog-entry must NOT carry available_in_catalogs"
);
assert!(
fix.suggested_target.is_none(),
"add-catalog-entry must NOT carry suggested_target"
);
}
#[test]
fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
let inner = UnresolvedCatalogReference {
entry_name: "react".to_string(),
catalog_name: "default".to_string(),
path: PathBuf::from("apps/web/package.json"),
line: 7,
available_in_catalogs: vec!["react18".to_string()],
};
let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
assert_eq!(
action_type(&finding.actions[0]),
"update-catalog-reference",
"position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
);
let IssueAction::Fix(fix) = &finding.actions[0] else {
panic!("position-0 should be an IssueAction::Fix");
};
assert_eq!(
fix.available_in_catalogs.as_deref(),
Some(&["react18".to_string()][..]),
"update-catalog-reference must carry the alternative list"
);
assert_eq!(
fix.suggested_target.as_deref(),
Some("react18"),
"single-alternative case must surface `suggested_target` for deterministic agents"
);
let inner_two = UnresolvedCatalogReference {
entry_name: "react".to_string(),
catalog_name: "default".to_string(),
path: PathBuf::from("apps/web/package.json"),
line: 7,
available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
};
let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
assert_eq!(
action_type(&finding_two.actions[0]),
"update-catalog-reference"
);
let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
panic!("position-0 should be an IssueAction::Fix");
};
assert!(
fix_two.suggested_target.is_none(),
"multi-alternative case must NOT carry `suggested_target` (agent must pick)"
);
}
#[test]
fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
let inner = DuplicateExport {
export_name: "Root".to_string(),
locations: vec![
DuplicateLocation {
path: PathBuf::from("components/ui/accordion/index.ts"),
line: 1,
col: 0,
},
DuplicateLocation {
path: PathBuf::from("components/ui/dialog/index.ts"),
line: 1,
col: 0,
},
],
};
let finding = DuplicateExportFinding::with_actions(inner);
assert_eq!(
action_type(&finding.actions[0]),
"add-to-config",
"position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
);
assert_eq!(
action_type(&finding.actions[1]),
"remove-duplicate",
"position-1 must be the destructive `remove-duplicate` fallback"
);
let mut promoted = finding;
promoted.set_config_fixable(true);
assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
panic!("position-0 should still be AddToConfig after set_config_fixable");
};
assert!(
action.auto_fixable,
"set_config_fixable(true) must flip auto_fixable"
);
}
#[test]
fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
let inner = DuplicateExport {
export_name: "Root".to_string(),
locations: Vec::new(),
};
let finding = DuplicateExportFinding::with_actions(inner);
assert_eq!(
action_type(&finding.actions[0]),
"remove-duplicate",
"with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
);
let mut promoted = finding;
promoted.set_config_fixable(true);
assert_eq!(
action_type(&promoted.actions[0]),
"remove-duplicate",
"set_config_fixable is a no-op when position-0 is not add-to-config"
);
}
#[test]
fn misconfigured_override_drops_suppress_when_no_package_name() {
let inner = MisconfiguredDependencyOverride {
raw_key: String::new(),
target_package: None,
raw_value: String::new(),
reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
source: DependencyOverrideSource::PnpmWorkspaceYaml,
path: PathBuf::from("pnpm-workspace.yaml"),
line: 12,
};
let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
assert_eq!(finding.actions.len(), 1);
assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
}
}