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_effect_layers,
77        &mut rules.unused_tagged_errors,
78        &mut rules.unproduced_catch_tags,
79        &mut rules.unused_server_actions,
80        &mut rules.unused_load_data_keys,
81        &mut rules.unresolved_imports,
82        &mut rules.unlisted_dependencies,
83        &mut rules.duplicate_exports,
84        &mut rules.type_only_dependencies,
85        &mut rules.test_only_dependencies,
86        &mut rules.dev_dependencies_in_production,
87        &mut rules.circular_dependencies,
88        &mut rules.re_export_cycle,
89        &mut rules.package_cycle,
90        &mut rules.boundary_violation,
91        &mut rules.coverage_gaps,
92        &mut rules.stale_suppressions,
93        &mut rules.require_suppression_reason,
94        &mut rules.unused_catalog_entries,
95        &mut rules.empty_catalog_groups,
96        &mut rules.unresolved_catalog_references,
97        &mut rules.unused_dependency_overrides,
98        &mut rules.misconfigured_dependency_overrides,
99        &mut rules.policy_violation,
100        &mut rules.invalid_client_export,
101        &mut rules.mixed_client_server_barrel,
102        &mut rules.misplaced_directive,
103        &mut rules.route_collision,
104        &mut rules.dynamic_segment_name_conflict,
105    ] {
106        promote_warn_to_error(rule);
107    }
108}
109
110fn promote_warn_to_error(rule: &mut Severity) {
111    if *rule == Severity::Warn {
112        *rule = Severity::Error;
113    }
114}
115
116/// Promote per-finding `warn` policy-violation severities to `error` for a
117/// strict (fail-on-issues) run. Policy findings carry their effective
118/// severity baked by the evaluator, so the rule-level promotion in
119/// [`promote_warns_to_errors`] alone would not flip findings whose rule
120/// explicitly opted down to `warn`; under strict mode every warning fails.
121pub fn promote_policy_finding_warns(results: &mut crate::dead_code::AnalysisResults) {
122    use fallow_types::results::PolicyViolationSeverity;
123    for finding in &mut results.policy_violations {
124        if finding.violation.severity == PolicyViolationSeverity::Warn {
125            finding.violation.severity = PolicyViolationSeverity::Error;
126        }
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use std::path::PathBuf;
133
134    use fallow_config::{ConfigOverride, FallowConfig, PartialRulesConfig, RulesConfig, Severity};
135    use fallow_types::output_dead_code::{
136        EmptyCatalogGroupFinding, MisconfiguredDependencyOverrideFinding,
137        UnusedDependencyOverrideFinding,
138    };
139    use fallow_types::results::{
140        DependencyOverrideMisconfigReason, DependencyOverrideSource, EmptyCatalogGroup,
141        MisconfiguredDependencyOverride, UnusedDependencyOverride,
142    };
143
144    use super::{count_error_severity_issues, has_error_severity_issues};
145    use crate::dead_code::AnalysisResults;
146
147    /// One finding of each manifest-level kind that per-file `overrides` can
148    /// change: an unused and a misconfigured dependency override in
149    /// `package.json`, and an empty catalog group in `pnpm-workspace.yaml`.
150    fn manifest_findings() -> [(&'static str, AnalysisResults); 3] {
151        let mut unused = AnalysisResults::default();
152        unused
153            .unused_dependency_overrides
154            .push(UnusedDependencyOverrideFinding::with_actions(
155                UnusedDependencyOverride {
156                    raw_key: "old-dep".to_string(),
157                    target_package: "old-dep".to_string(),
158                    parent_package: None,
159                    version_constraint: None,
160                    version_range: "^1.0.0".to_string(),
161                    source: DependencyOverrideSource::PnpmPackageJson,
162                    path: PathBuf::from("/project/package.json"),
163                    line: 7,
164                    hint: None,
165                },
166            ));
167        let mut misconfigured = AnalysisResults::default();
168        misconfigured.misconfigured_dependency_overrides.push(
169            MisconfiguredDependencyOverrideFinding::with_actions(MisconfiguredDependencyOverride {
170                raw_key: "bad>".to_string(),
171                target_package: None,
172                raw_value: "1.0.0".to_string(),
173                reason: DependencyOverrideMisconfigReason::UnparsableKey,
174                source: DependencyOverrideSource::PnpmPackageJson,
175                path: PathBuf::from("/project/package.json"),
176                line: 4,
177            }),
178        );
179        let mut empty_group = AnalysisResults::default();
180        empty_group
181            .empty_catalog_groups
182            .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
183                catalog_name: "legacy".to_string(),
184                path: PathBuf::from("/project/pnpm-workspace.yaml"),
185                line: 3,
186            }));
187        [
188            ("unused-dependency-overrides", unused),
189            ("misconfigured-dependency-overrides", misconfigured),
190            ("empty-catalog-groups", empty_group),
191        ]
192    }
193
194    fn config_with_manifest_override(
195        base: Severity,
196        manifest: Severity,
197    ) -> fallow_config::ResolvedConfig {
198        FallowConfig {
199            rules: RulesConfig {
200                unused_dependency_overrides: base,
201                misconfigured_dependency_overrides: base,
202                empty_catalog_groups: base,
203                ..RulesConfig::default()
204            },
205            overrides: vec![ConfigOverride {
206                files: vec![
207                    "package.json".to_string(),
208                    "pnpm-workspace.yaml".to_string(),
209                ],
210                rules: PartialRulesConfig {
211                    unused_dependency_overrides: Some(manifest),
212                    misconfigured_dependency_overrides: Some(manifest),
213                    empty_catalog_groups: Some(manifest),
214                    ..PartialRulesConfig::default()
215                },
216            }],
217            ..FallowConfig::default()
218        }
219        .resolve(
220            PathBuf::from("/project"),
221            fallow_config::OutputFormat::Human,
222            1,
223            true,
224            true,
225            None,
226        )
227    }
228
229    #[test]
230    fn a_manifest_override_to_warn_clears_a_base_error() {
231        let config = config_with_manifest_override(Severity::Error, Severity::Warn);
232        for (kind, results) in manifest_findings() {
233            assert!(
234                !has_error_severity_issues(&results, &config.rules, Some(&config), false),
235                "the `warn` override for the manifest must win over the base `error` for {kind}"
236            );
237        }
238    }
239
240    #[test]
241    fn a_manifest_override_to_error_raises_a_base_warn() {
242        let config = config_with_manifest_override(Severity::Warn, Severity::Error);
243        for (kind, results) in manifest_findings() {
244            assert!(
245                has_error_severity_issues(&results, &config.rules, Some(&config), false),
246                "the `error` override for the manifest must win over the base `warn` for {kind}"
247            );
248        }
249    }
250
251    /// The count agrees with the yes or no answer, and per-file overrides
252    /// count each finding with its own severity.
253    #[test]
254    fn the_count_holds_each_finding_at_its_own_severity() {
255        let config = config_with_manifest_override(Severity::Error, Severity::Error);
256        let mut all = AnalysisResults::default();
257        for (_, results) in manifest_findings() {
258            all.unused_dependency_overrides
259                .extend(results.unused_dependency_overrides);
260            all.misconfigured_dependency_overrides
261                .extend(results.misconfigured_dependency_overrides);
262            all.empty_catalog_groups
263                .extend(results.empty_catalog_groups);
264        }
265        assert_eq!(
266            count_error_severity_issues(&all, &config.rules, Some(&config), false),
267            3
268        );
269
270        let mixed = config_with_manifest_override(Severity::Error, Severity::Warn);
271        assert_eq!(
272            count_error_severity_issues(&all, &mixed.rules, Some(&mixed), false),
273            0
274        );
275        assert_eq!(
276            count_error_severity_issues(&all, &mixed.rules, Some(&mixed), true),
277            3,
278            "--fail-on-issues promotes the per-file `warn`"
279        );
280        assert_eq!(
281            count_error_severity_issues(&AnalysisResults::default(), &config.rules, None, false),
282            0
283        );
284    }
285}