Skip to main content

fallow_engine/
error_severity.rs

1//! The error-severity rule: whether a dead-code result holds a finding whose
2//! effective severity is `error`.
3//!
4//! This rule decides the exit code of `dead-code` and `check`, the
5//! `error-severity-findings` gate entry, the combined verdict and the audit
6//! summary. Every caller reads it from here, so a finding cannot fail one
7//! command and pass another.
8
9use fallow_config::{ResolvedConfig, RulesConfig, Severity};
10
11use crate::effective_severity::{SeveritySource, any_finding_with_severity};
12
13/// Check whether any issue type with `Severity::Error` has remaining issues.
14///
15/// The severity of each finding comes from the rule table in
16/// `crate::effective_severity`, the same table that writes the
17/// `effective_severity` of each finding for the CI formats and that the audit
18/// ledger reads. When overrides are configured, file-scoped findings resolve
19/// the rules for their own path. Circular dependencies resolve against every
20/// file in the cycle. Project-level findings read `rules`.
21///
22/// `promote_warns` mirrors `--fail-on-issues`: `rules` is expected to arrive
23/// already promoted, and the per-file override path promotes each resolved
24/// severity after override resolution so an explicit per-path `warn` fails
25/// the run just like the base rules would.
26pub fn has_error_severity_issues(
27    results: &crate::dead_code::AnalysisResults,
28    rules: &RulesConfig,
29    config: Option<&ResolvedConfig>,
30    promote_warns: bool,
31) -> bool {
32    let source = SeveritySource::new(rules, config, promote_warns);
33    any_finding_with_severity(results, &source, Severity::Error)
34}
35
36/// Promote all `Warn` severities to `Error` for a single run.
37pub fn promote_warns_to_errors(rules: &mut RulesConfig) {
38    for rule in [
39        &mut rules.unused_files,
40        &mut rules.unused_exports,
41        &mut rules.unused_types,
42        &mut rules.private_type_leaks,
43        &mut rules.deprecated_exports_in_use,
44        &mut rules.unused_dependencies,
45        &mut rules.unused_dev_dependencies,
46        &mut rules.unused_optional_dependencies,
47        &mut rules.unused_enum_members,
48        &mut rules.unused_class_members,
49        &mut rules.unused_store_members,
50        &mut rules.unprovided_injects,
51        &mut rules.unrendered_components,
52        &mut rules.unused_component_props,
53        &mut rules.unused_component_emits,
54        &mut rules.unused_component_inputs,
55        &mut rules.unused_component_outputs,
56        &mut rules.unused_svelte_events,
57        &mut rules.unused_server_actions,
58        &mut rules.unused_load_data_keys,
59        &mut rules.unresolved_imports,
60        &mut rules.unlisted_dependencies,
61        &mut rules.duplicate_exports,
62        &mut rules.type_only_dependencies,
63        &mut rules.test_only_dependencies,
64        &mut rules.dev_dependencies_in_production,
65        &mut rules.circular_dependencies,
66        &mut rules.re_export_cycle,
67        &mut rules.boundary_violation,
68        &mut rules.coverage_gaps,
69        &mut rules.stale_suppressions,
70        &mut rules.require_suppression_reason,
71        &mut rules.unused_catalog_entries,
72        &mut rules.empty_catalog_groups,
73        &mut rules.unresolved_catalog_references,
74        &mut rules.unused_dependency_overrides,
75        &mut rules.misconfigured_dependency_overrides,
76        &mut rules.policy_violation,
77        &mut rules.invalid_client_export,
78        &mut rules.mixed_client_server_barrel,
79        &mut rules.misplaced_directive,
80        &mut rules.route_collision,
81        &mut rules.dynamic_segment_name_conflict,
82    ] {
83        promote_warn_to_error(rule);
84    }
85}
86
87fn promote_warn_to_error(rule: &mut Severity) {
88    if *rule == Severity::Warn {
89        *rule = Severity::Error;
90    }
91}
92
93/// Promote per-finding `warn` policy-violation severities to `error` for a
94/// strict (fail-on-issues) run. Policy findings carry their effective
95/// severity baked by the evaluator, so the rule-level promotion in
96/// [`promote_warns_to_errors`] alone would not flip findings whose rule
97/// explicitly opted down to `warn`; under strict mode every warning fails.
98pub fn promote_policy_finding_warns(results: &mut crate::dead_code::AnalysisResults) {
99    use fallow_types::results::PolicyViolationSeverity;
100    for finding in &mut results.policy_violations {
101        if finding.violation.severity == PolicyViolationSeverity::Warn {
102            finding.violation.severity = PolicyViolationSeverity::Error;
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use std::path::PathBuf;
110
111    use fallow_config::{ConfigOverride, FallowConfig, PartialRulesConfig, RulesConfig, Severity};
112    use fallow_types::output_dead_code::{
113        EmptyCatalogGroupFinding, MisconfiguredDependencyOverrideFinding,
114        UnusedDependencyOverrideFinding,
115    };
116    use fallow_types::results::{
117        DependencyOverrideMisconfigReason, DependencyOverrideSource, EmptyCatalogGroup,
118        MisconfiguredDependencyOverride, UnusedDependencyOverride,
119    };
120
121    use super::has_error_severity_issues;
122    use crate::dead_code::AnalysisResults;
123
124    /// One finding of each manifest-level kind that per-file `overrides` can
125    /// change: an unused and a misconfigured dependency override in
126    /// `package.json`, and an empty catalog group in `pnpm-workspace.yaml`.
127    fn manifest_findings() -> [(&'static str, AnalysisResults); 3] {
128        let mut unused = AnalysisResults::default();
129        unused
130            .unused_dependency_overrides
131            .push(UnusedDependencyOverrideFinding::with_actions(
132                UnusedDependencyOverride {
133                    raw_key: "old-dep".to_string(),
134                    target_package: "old-dep".to_string(),
135                    parent_package: None,
136                    version_constraint: None,
137                    version_range: "^1.0.0".to_string(),
138                    source: DependencyOverrideSource::PnpmPackageJson,
139                    path: PathBuf::from("/project/package.json"),
140                    line: 7,
141                    hint: None,
142                },
143            ));
144        let mut misconfigured = AnalysisResults::default();
145        misconfigured.misconfigured_dependency_overrides.push(
146            MisconfiguredDependencyOverrideFinding::with_actions(MisconfiguredDependencyOverride {
147                raw_key: "bad>".to_string(),
148                target_package: None,
149                raw_value: "1.0.0".to_string(),
150                reason: DependencyOverrideMisconfigReason::UnparsableKey,
151                source: DependencyOverrideSource::PnpmPackageJson,
152                path: PathBuf::from("/project/package.json"),
153                line: 4,
154            }),
155        );
156        let mut empty_group = AnalysisResults::default();
157        empty_group
158            .empty_catalog_groups
159            .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
160                catalog_name: "legacy".to_string(),
161                path: PathBuf::from("/project/pnpm-workspace.yaml"),
162                line: 3,
163            }));
164        [
165            ("unused-dependency-overrides", unused),
166            ("misconfigured-dependency-overrides", misconfigured),
167            ("empty-catalog-groups", empty_group),
168        ]
169    }
170
171    fn config_with_manifest_override(
172        base: Severity,
173        manifest: Severity,
174    ) -> fallow_config::ResolvedConfig {
175        FallowConfig {
176            rules: RulesConfig {
177                unused_dependency_overrides: base,
178                misconfigured_dependency_overrides: base,
179                empty_catalog_groups: base,
180                ..RulesConfig::default()
181            },
182            overrides: vec![ConfigOverride {
183                files: vec![
184                    "package.json".to_string(),
185                    "pnpm-workspace.yaml".to_string(),
186                ],
187                rules: PartialRulesConfig {
188                    unused_dependency_overrides: Some(manifest),
189                    misconfigured_dependency_overrides: Some(manifest),
190                    empty_catalog_groups: Some(manifest),
191                    ..PartialRulesConfig::default()
192                },
193            }],
194            ..FallowConfig::default()
195        }
196        .resolve(
197            PathBuf::from("/project"),
198            fallow_config::OutputFormat::Human,
199            1,
200            true,
201            true,
202            None,
203        )
204    }
205
206    #[test]
207    fn a_manifest_override_to_warn_clears_a_base_error() {
208        let config = config_with_manifest_override(Severity::Error, Severity::Warn);
209        for (kind, results) in manifest_findings() {
210            assert!(
211                !has_error_severity_issues(&results, &config.rules, Some(&config), false),
212                "the `warn` override for the manifest must win over the base `error` for {kind}"
213            );
214        }
215    }
216
217    #[test]
218    fn a_manifest_override_to_error_raises_a_base_warn() {
219        let config = config_with_manifest_override(Severity::Warn, Severity::Error);
220        for (kind, results) in manifest_findings() {
221            assert!(
222                has_error_severity_issues(&results, &config.rules, Some(&config), false),
223                "the `error` override for the manifest must win over the base `warn` for {kind}"
224            );
225        }
226    }
227}