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