Skip to main content

fallow_engine/
effective_severity.rs

1//! Per-finding rule severity for dead-code results.
2//!
3//! One table in this module maps each dead-code finding to the rule that
4//! decides its severity. Three consumers read it:
5//!
6//! - [`apply_effective_severities`] writes the severity onto each finding for
7//!   the CI formats (SARIF, CodeClimate, GitHub annotations);
8//! - `has_error_severity_issues` in `crates/engine/src/error_severity.rs`
9//!   decides the exit code, the combined verdict and the audit `all` gate;
10//! - the audit ledger in `crates/api/src/audit_keys.rs` decides the audit
11//!   `new-only` gate.
12//!
13//! The rules of the table:
14//!
15//! - a file-scoped finding resolves `overrides[].rules` for its own path;
16//! - a circular dependency takes the highest severity of the files in the
17//!   cycle;
18//! - a project-level finding (dependencies, catalog entries, duplicate
19//!   exports, re-export cycles) uses the base rules.
20//!
21//! Empty catalog groups and dependency overrides are file-scoped: they sit on
22//! the file that declares them (`pnpm-workspace.yaml` or a `package.json`), so
23//! an override for that file decides.
24//!
25//! Policy violations carry their own `severity`. Prop-drilling, thin-wrapper
26//! and duplicate-prop-shape records are health signals that never gate the
27//! run. They carry their base rule severity, so `fallow report --from` can
28//! render their level without the config, but the exit-code check skips them.
29
30use std::path::Path;
31
32use fallow_config::{ResolvedConfig, RulesConfig, Severity};
33use fallow_types::output_dead_code::{
34    BoundaryCallViolationFinding, BoundaryCoverageViolationFinding, BoundaryViolationFinding,
35    CircularDependencyFinding, DeprecatedExportInUseFinding, DevDependencyInProductionFinding,
36    DuplicateExportFinding, DynamicSegmentNameConflictFinding, EffectiveSeverity,
37    EmptyCatalogGroupFinding, GatedFinding, InvalidClientExportFinding,
38    MisconfiguredDependencyOverrideFinding, MisplacedDirectiveFinding,
39    MixedClientServerBarrelFinding, PackageCycleFinding, PolicyViolationFinding,
40    PrivateTypeLeakFinding, ReExportCycleFinding, RouteCollisionFinding, TestOnlyDependencyFinding,
41    TypeOnlyDependencyFinding, UnlistedDependencyFinding, UnprovidedInjectFinding,
42    UnrenderedComponentFinding, UnresolvedCatalogReferenceFinding, UnresolvedImportFinding,
43    UnusedCatalogEntryFinding, UnusedClassMemberFinding, UnusedComponentEmitFinding,
44    UnusedComponentInputFinding, UnusedComponentOutputFinding, UnusedComponentPropFinding,
45    UnusedDependencyFinding, UnusedDependencyOverrideFinding, UnusedDevDependencyFinding,
46    UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding, UnusedLoadDataKeyFinding,
47    UnusedOptionalDependencyFinding, UnusedServerActionFinding, UnusedStoreMemberFinding,
48    UnusedSvelteEventFinding, UnusedTypeFinding,
49};
50use fallow_types::results::{AnalysisResults, PolicyViolationSeverity, StaleSuppression};
51
52use crate::error_severity::promote_warns_to_errors;
53
54fn gate(severity: Severity) -> Option<EffectiveSeverity> {
55    match severity {
56        Severity::Error => Some(EffectiveSeverity::Error),
57        Severity::Warn => Some(EffectiveSeverity::Warn),
58        Severity::Off => None,
59    }
60}
61
62/// The rules that give a finding its severity.
63#[derive(Clone, Copy)]
64pub struct SeveritySource<'a> {
65    base: &'a RulesConfig,
66    overrides: Option<&'a ResolvedConfig>,
67    promote_warns: bool,
68}
69
70impl<'a> SeveritySource<'a> {
71    /// The rules of `config`, with its `overrides` for file-scoped findings.
72    #[must_use]
73    pub fn from_config(config: &'a ResolvedConfig) -> Self {
74        Self::new(&config.rules, Some(config), false)
75    }
76
77    /// Explicit base rules, with the `overrides` of `config` when it has any.
78    ///
79    /// `promote_warns` raises a `warn` that an override resolves to `error`.
80    /// The caller promotes `base` itself.
81    #[must_use]
82    pub fn new(
83        base: &'a RulesConfig,
84        config: Option<&'a ResolvedConfig>,
85        promote_warns: bool,
86    ) -> Self {
87        Self {
88            base,
89            overrides: config.filter(|config| !config.overrides.is_empty()),
90            promote_warns,
91        }
92    }
93
94    fn for_path(&self, path: &Path, rule: fn(&RulesConfig) -> Severity) -> Severity {
95        let Some(config) = self.overrides else {
96            return rule(self.base);
97        };
98        let mut rules = config.resolve_rules_for_path(path);
99        if self.promote_warns {
100            promote_warns_to_errors(&mut rules);
101        }
102        rule(&rules)
103    }
104
105    fn project(&self, rule: fn(&RulesConfig) -> Severity) -> Severity {
106        rule(self.base)
107    }
108
109    /// The base rule when no `overrides` apply, so every finding of a
110    /// file-scoped kind has the same severity.
111    fn uniform(&self, rule: fn(&RulesConfig) -> Severity) -> Option<Severity> {
112        self.overrides.is_none().then(|| rule(self.base))
113    }
114}
115
116/// A dead-code finding whose severity comes from the configured rules.
117pub trait RuleSeverity {
118    /// The severity of this finding under `source`.
119    fn rule_severity(&self, source: &SeveritySource<'_>) -> Severity;
120
121    /// The severity that every finding of this kind has under `source`, or
122    /// `None` when the severity can differ from finding to finding.
123    ///
124    /// The exit-code check reads this once per collection instead of once
125    /// per finding.
126    fn uniform_severity(_source: &SeveritySource<'_>) -> Option<Severity>
127    where
128        Self: Sized,
129    {
130        None
131    }
132}
133
134macro_rules! file_scoped {
135    ($($finding:ty => $path:ident . $field:ident, $rule:ident;)+) => {
136        $(
137            impl RuleSeverity for $finding {
138                fn rule_severity(&self, source: &SeveritySource<'_>) -> Severity {
139                    source.for_path(&self.$path.$field, |rules| rules.$rule)
140                }
141
142                fn uniform_severity(source: &SeveritySource<'_>) -> Option<Severity> {
143                    source.uniform(|rules| rules.$rule)
144                }
145            }
146        )+
147    };
148}
149
150macro_rules! project_level {
151    ($($finding:ty => $rule:ident;)+) => {
152        $(
153            impl RuleSeverity for $finding {
154                fn rule_severity(&self, source: &SeveritySource<'_>) -> Severity {
155                    source.project(|rules| rules.$rule)
156                }
157
158                fn uniform_severity(source: &SeveritySource<'_>) -> Option<Severity> {
159                    Some(source.project(|rules| rules.$rule))
160                }
161            }
162        )+
163    };
164}
165
166file_scoped! {
167    UnusedFileFinding => file.path, unused_files;
168    UnusedExportFinding => export.path, unused_exports;
169    UnusedTypeFinding => export.path, unused_types;
170    PrivateTypeLeakFinding => leak.path, private_type_leaks;
171    DeprecatedExportInUseFinding => export.path, deprecated_exports_in_use;
172    UnusedEnumMemberFinding => member.path, unused_enum_members;
173    UnusedClassMemberFinding => member.path, unused_class_members;
174    UnusedStoreMemberFinding => member.path, unused_store_members;
175    UnprovidedInjectFinding => inject.path, unprovided_injects;
176    UnresolvedImportFinding => import.path, unresolved_imports;
177    UnrenderedComponentFinding => component.path, unrendered_components;
178    UnusedComponentPropFinding => prop.path, unused_component_props;
179    UnusedComponentEmitFinding => emit.path, unused_component_emits;
180    UnusedComponentInputFinding => input.path, unused_component_inputs;
181    UnusedComponentOutputFinding => output.path, unused_component_outputs;
182    UnusedSvelteEventFinding => event.path, unused_svelte_events;
183    UnusedServerActionFinding => action.path, unused_server_actions;
184    UnusedLoadDataKeyFinding => key.path, unused_load_data_keys;
185    InvalidClientExportFinding => export.path, invalid_client_export;
186    MixedClientServerBarrelFinding => barrel.path, mixed_client_server_barrel;
187    MisplacedDirectiveFinding => directive_site.path, misplaced_directive;
188    RouteCollisionFinding => collision.path, route_collision;
189    DynamicSegmentNameConflictFinding => conflict.path, dynamic_segment_name_conflict;
190    BoundaryViolationFinding => violation.from_path, boundary_violation;
191    BoundaryCoverageViolationFinding => violation.path, boundary_violation;
192    BoundaryCallViolationFinding => violation.path, boundary_violation;
193    UnresolvedCatalogReferenceFinding => reference.path, unresolved_catalog_references;
194    EmptyCatalogGroupFinding => group.path, empty_catalog_groups;
195    UnusedDependencyOverrideFinding => entry.path, unused_dependency_overrides;
196    MisconfiguredDependencyOverrideFinding => entry.path, misconfigured_dependency_overrides;
197}
198
199project_level! {
200    UnusedDependencyFinding => unused_dependencies;
201    UnusedDevDependencyFinding => unused_dev_dependencies;
202    UnusedOptionalDependencyFinding => unused_optional_dependencies;
203    UnlistedDependencyFinding => unlisted_dependencies;
204    DuplicateExportFinding => duplicate_exports;
205    TypeOnlyDependencyFinding => type_only_dependencies;
206    TestOnlyDependencyFinding => test_only_dependencies;
207    DevDependencyInProductionFinding => dev_dependencies_in_production;
208    ReExportCycleFinding => re_export_cycle;
209    UnusedCatalogEntryFinding => unused_catalog_entries;
210}
211
212impl RuleSeverity for CircularDependencyFinding {
213    fn rule_severity(&self, source: &SeveritySource<'_>) -> Severity {
214        self.cycle
215            .files
216            .iter()
217            .map(|path| source.for_path(path, |rules| rules.circular_dependencies))
218            .max_by_key(|severity| severity_rank(*severity))
219            .unwrap_or_else(|| source.project(|rules| rules.circular_dependencies))
220    }
221
222    fn uniform_severity(source: &SeveritySource<'_>) -> Option<Severity> {
223        source.uniform(|rules| rules.circular_dependencies)
224    }
225}
226
227impl RuleSeverity for PackageCycleFinding {
228    fn rule_severity(&self, source: &SeveritySource<'_>) -> Severity {
229        self.cycle
230            .edges
231            .iter()
232            .map(|edge| source.for_path(&edge.path, |rules| rules.package_cycle))
233            .max_by_key(|severity| severity_rank(*severity))
234            .unwrap_or_else(|| source.project(|rules| rules.package_cycle))
235    }
236
237    fn uniform_severity(source: &SeveritySource<'_>) -> Option<Severity> {
238        source.uniform(|rules| rules.package_cycle)
239    }
240}
241
242impl RuleSeverity for StaleSuppression {
243    fn rule_severity(&self, source: &SeveritySource<'_>) -> Severity {
244        if self.missing_reason {
245            source.for_path(&self.path, |rules| rules.require_suppression_reason)
246        } else {
247            source.for_path(&self.path, |rules| rules.stale_suppressions)
248        }
249    }
250
251    fn uniform_severity(source: &SeveritySource<'_>) -> Option<Severity> {
252        let stale = source.uniform(|rules| rules.stale_suppressions)?;
253        let missing_reason = source.uniform(|rules| rules.require_suppression_reason)?;
254        (stale == missing_reason).then_some(stale)
255    }
256}
257
258impl RuleSeverity for PolicyViolationFinding {
259    fn rule_severity(&self, _source: &SeveritySource<'_>) -> Severity {
260        match self.violation.severity {
261            PolicyViolationSeverity::Error => Severity::Error,
262            PolicyViolationSeverity::Warn => Severity::Warn,
263        }
264    }
265}
266
267const fn severity_rank(severity: Severity) -> u8 {
268    match severity {
269        Severity::Off => 0,
270        Severity::Warn => 1,
271        Severity::Error => 2,
272    }
273}
274
275/// A finding that has a rule severity and carries a gate severity.
276trait GatedRuleFinding: GatedFinding + RuleSeverity {}
277
278impl<T: GatedFinding + RuleSeverity> GatedRuleFinding for T {}
279
280/// Write the gate severity onto each dead-code finding in `results`.
281///
282/// Call this after the findings whose rule is `off` are removed. The function
283/// overwrites any earlier value, so a second call with the same config gives
284/// the same result.
285pub fn apply_effective_severities(results: &mut AnalysisResults, config: &ResolvedConfig) {
286    let source = SeveritySource::from_config(config);
287    for_each_gated_finding(results, &mut |finding| {
288        let severity = finding.rule_severity(&source);
289        finding.set_effective_severity(gate(severity));
290    });
291    apply_non_gating_severities(results, &config.rules);
292}
293
294/// Write the rule severity onto each prop-drilling, thin-wrapper and
295/// duplicate-prop-shape finding.
296///
297/// The analysis reads only the base rules for these types, so the value is
298/// the base rule. These findings never gate the run: the exit-code check and
299/// `--fail-on-issues` skip them.
300fn apply_non_gating_severities(results: &mut AnalysisResults, rules: &RulesConfig) {
301    set_all(&mut results.prop_drilling_chains, rules.prop_drilling);
302    set_all(&mut results.thin_wrappers, rules.thin_wrapper);
303    set_all(
304        &mut results.duplicate_prop_shapes,
305        rules.duplicate_prop_shape,
306    );
307}
308
309fn set_all<T: GatedFinding>(findings: &mut [T], rule: Severity) {
310    for finding in findings {
311        finding.set_effective_severity(gate(rule));
312    }
313}
314
315/// The number of gated dead-code findings in `results` that carry no saved
316/// severity, for example in a report from an older version. A renderer then
317/// takes their level from the configured rules.
318///
319/// Policy violations carry their own severity and do not count. Neither do
320/// prop-drilling, thin-wrapper and duplicate-prop-shape findings: only SARIF
321/// renders them, always at level `warning`, so the rules never change their
322/// level.
323#[must_use]
324pub fn findings_without_severity(mut results: AnalysisResults) -> usize {
325    let mut missing = 0;
326    for_each_gated_finding(&mut results, &mut |finding| {
327        if finding.effective_severity().is_none() {
328            missing += 1;
329        }
330    });
331    missing
332}
333
334/// Raise every `warn` gate severity to `error`, for `--fail-on-issues`.
335///
336/// Under that flag every reported finding fails the run, so every CI format
337/// must state `error` too.
338pub fn promote_effective_warns(results: &mut AnalysisResults) {
339    for_each_gated_finding(results, &mut |finding| {
340        if finding.effective_severity() == Some(EffectiveSeverity::Warn) {
341            finding.set_effective_severity(Some(EffectiveSeverity::Error));
342        }
343    });
344}
345
346/// Whether any dead-code finding in `results` has `severity` under `source`.
347///
348/// Policy violations count with their own severity.
349#[must_use]
350pub fn any_finding_with_severity(
351    results: &AnalysisResults,
352    source: &SeveritySource<'_>,
353    severity: Severity,
354) -> bool {
355    results
356        .policy_violations
357        .iter()
358        .any(|finding| finding.rule_severity(source) == severity)
359        || any_gated_finding(results, source, severity)
360}
361
362fn visit<T: GatedRuleFinding>(findings: &mut [T], f: &mut dyn FnMut(&mut dyn GatedRuleFinding)) {
363    for finding in findings {
364        f(finding);
365    }
366}
367
368/// Whether any finding in `findings` has `severity` under `source`.
369///
370/// When every finding of the kind has the same severity, one table lookup
371/// answers for the whole collection.
372fn any<T: RuleSeverity>(findings: &[T], source: &SeveritySource<'_>, severity: Severity) -> bool {
373    if findings.is_empty() {
374        return false;
375    }
376    match T::uniform_severity(source) {
377        Some(uniform) => uniform == severity,
378        None => findings
379            .iter()
380            .any(|finding| finding.rule_severity(source) == severity),
381    }
382}
383
384/// Visit every finding that carries a gate severity.
385///
386/// The destructure has no `..`, so a new field on [`AnalysisResults`] fails to
387/// compile here until it is listed.
388#[expect(
389    clippy::too_many_lines,
390    reason = "one exhaustive list of finding collections; splitting it would lose the compile-time guard"
391)]
392fn for_each_gated_finding(
393    results: &mut AnalysisResults,
394    f: &mut dyn FnMut(&mut dyn GatedRuleFinding),
395) {
396    let AnalysisResults {
397        unused_files,
398        unused_exports,
399        unused_types,
400        private_type_leaks,
401        deprecated_exports_in_use,
402        unused_dependencies,
403        unused_dev_dependencies,
404        unused_optional_dependencies,
405        unused_enum_members,
406        unused_class_members,
407        unused_store_members,
408        unresolved_imports,
409        unlisted_dependencies,
410        duplicate_exports,
411        type_only_dependencies,
412        test_only_dependencies,
413        dev_dependencies_in_production,
414        circular_dependencies,
415        re_export_cycles,
416        package_cycles,
417        boundary_violations,
418        boundary_coverage_violations,
419        boundary_call_violations,
420        stale_suppressions,
421        unused_catalog_entries,
422        empty_catalog_groups,
423        unresolved_catalog_references,
424        unused_dependency_overrides,
425        misconfigured_dependency_overrides,
426        invalid_client_exports,
427        mixed_client_server_barrels,
428        misplaced_directives,
429        unprovided_injects,
430        unrendered_components,
431        route_collisions,
432        dynamic_segment_name_conflicts,
433        unused_component_props,
434        unused_component_emits,
435        unused_component_inputs,
436        unused_component_outputs,
437        unused_svelte_events,
438        unused_server_actions,
439        unused_load_data_keys,
440        // Policy violations carry their own evaluated `severity`.
441        policy_violations: _,
442        // Health signals that never gate the run.
443        prop_drilling_chains: _,
444        thin_wrappers: _,
445        duplicate_prop_shapes: _,
446        // Not findings: counts, flags and metadata.
447        unused_load_data_keys_global_abstain: _,
448        suppression_count: _,
449        unused_component_props_exempted: _,
450        active_suppressions: _,
451        feature_flags: _,
452        export_usages: _,
453        entry_point_summary: _,
454        render_fan_in: _,
455        react_component_intel: _,
456        semantic_framework_contracts: _,
457        // Security findings belong to `fallow security` and its own gate.
458        security_findings: _,
459        security_unresolved_edge_files: _,
460        security_unresolved_callee_sites: _,
461        security_unresolved_callee_diagnostics: _,
462    } = results;
463    visit(unused_files, f);
464    visit(unused_exports, f);
465    visit(unused_types, f);
466    visit(private_type_leaks, f);
467    visit(deprecated_exports_in_use, f);
468    visit(unused_dependencies, f);
469    visit(unused_dev_dependencies, f);
470    visit(unused_optional_dependencies, f);
471    visit(unused_enum_members, f);
472    visit(unused_class_members, f);
473    visit(unused_store_members, f);
474    visit(unresolved_imports, f);
475    visit(unlisted_dependencies, f);
476    visit(duplicate_exports, f);
477    visit(type_only_dependencies, f);
478    visit(test_only_dependencies, f);
479    visit(dev_dependencies_in_production, f);
480    visit(circular_dependencies, f);
481    visit(re_export_cycles, f);
482    visit(package_cycles, f);
483    visit(boundary_violations, f);
484    visit(boundary_coverage_violations, f);
485    visit(boundary_call_violations, f);
486    visit(stale_suppressions, f);
487    visit(unused_catalog_entries, f);
488    visit(empty_catalog_groups, f);
489    visit(unresolved_catalog_references, f);
490    visit(unused_dependency_overrides, f);
491    visit(misconfigured_dependency_overrides, f);
492    visit(invalid_client_exports, f);
493    visit(mixed_client_server_barrels, f);
494    visit(misplaced_directives, f);
495    visit(unprovided_injects, f);
496    visit(unrendered_components, f);
497    visit(route_collisions, f);
498    visit(dynamic_segment_name_conflicts, f);
499    visit(unused_component_props, f);
500    visit(unused_component_emits, f);
501    visit(unused_component_inputs, f);
502    visit(unused_component_outputs, f);
503    visit(unused_svelte_events, f);
504    visit(unused_server_actions, f);
505    visit(unused_load_data_keys, f);
506}
507
508/// Whether any finding that carries a gate severity has `severity` under
509/// `source`.
510///
511/// Exhaustive like [`for_each_gated_finding`]: a new field on
512/// [`AnalysisResults`] fails to compile here until it is listed.
513#[expect(
514    clippy::too_many_lines,
515    reason = "one exhaustive list of finding collections; splitting it would lose the compile-time guard"
516)]
517fn any_gated_finding(
518    results: &AnalysisResults,
519    source: &SeveritySource<'_>,
520    severity: Severity,
521) -> bool {
522    let AnalysisResults {
523        unused_files,
524        unused_exports,
525        unused_types,
526        private_type_leaks,
527        deprecated_exports_in_use,
528        unused_dependencies,
529        unused_dev_dependencies,
530        unused_optional_dependencies,
531        unused_enum_members,
532        unused_class_members,
533        unused_store_members,
534        unresolved_imports,
535        unlisted_dependencies,
536        duplicate_exports,
537        type_only_dependencies,
538        test_only_dependencies,
539        dev_dependencies_in_production,
540        circular_dependencies,
541        re_export_cycles,
542        package_cycles,
543        boundary_violations,
544        boundary_coverage_violations,
545        boundary_call_violations,
546        stale_suppressions,
547        unused_catalog_entries,
548        empty_catalog_groups,
549        unresolved_catalog_references,
550        unused_dependency_overrides,
551        misconfigured_dependency_overrides,
552        invalid_client_exports,
553        mixed_client_server_barrels,
554        misplaced_directives,
555        unprovided_injects,
556        unrendered_components,
557        route_collisions,
558        dynamic_segment_name_conflicts,
559        unused_component_props,
560        unused_component_emits,
561        unused_component_inputs,
562        unused_component_outputs,
563        unused_svelte_events,
564        unused_server_actions,
565        unused_load_data_keys,
566        policy_violations: _,
567        prop_drilling_chains: _,
568        thin_wrappers: _,
569        duplicate_prop_shapes: _,
570        unused_load_data_keys_global_abstain: _,
571        suppression_count: _,
572        unused_component_props_exempted: _,
573        active_suppressions: _,
574        feature_flags: _,
575        export_usages: _,
576        entry_point_summary: _,
577        render_fan_in: _,
578        react_component_intel: _,
579        semantic_framework_contracts: _,
580        security_findings: _,
581        security_unresolved_edge_files: _,
582        security_unresolved_callee_sites: _,
583        security_unresolved_callee_diagnostics: _,
584    } = results;
585    any(unused_files, source, severity)
586        || any(unused_exports, source, severity)
587        || any(unused_types, source, severity)
588        || any(private_type_leaks, source, severity)
589        || any(deprecated_exports_in_use, source, severity)
590        || any(unused_dependencies, source, severity)
591        || any(unused_dev_dependencies, source, severity)
592        || any(unused_optional_dependencies, source, severity)
593        || any(unused_enum_members, source, severity)
594        || any(unused_class_members, source, severity)
595        || any(unused_store_members, source, severity)
596        || any(unresolved_imports, source, severity)
597        || any(unlisted_dependencies, source, severity)
598        || any(duplicate_exports, source, severity)
599        || any(type_only_dependencies, source, severity)
600        || any(test_only_dependencies, source, severity)
601        || any(dev_dependencies_in_production, source, severity)
602        || any(circular_dependencies, source, severity)
603        || any(re_export_cycles, source, severity)
604        || any(package_cycles, source, severity)
605        || any(boundary_violations, source, severity)
606        || any(boundary_coverage_violations, source, severity)
607        || any(boundary_call_violations, source, severity)
608        || any(stale_suppressions, source, severity)
609        || any(unused_catalog_entries, source, severity)
610        || any(empty_catalog_groups, source, severity)
611        || any(unresolved_catalog_references, source, severity)
612        || any(unused_dependency_overrides, source, severity)
613        || any(misconfigured_dependency_overrides, source, severity)
614        || any(invalid_client_exports, source, severity)
615        || any(mixed_client_server_barrels, source, severity)
616        || any(misplaced_directives, source, severity)
617        || any(unprovided_injects, source, severity)
618        || any(unrendered_components, source, severity)
619        || any(route_collisions, source, severity)
620        || any(dynamic_segment_name_conflicts, source, severity)
621        || any(unused_component_props, source, severity)
622        || any(unused_component_emits, source, severity)
623        || any(unused_component_inputs, source, severity)
624        || any(unused_component_outputs, source, severity)
625        || any(unused_svelte_events, source, severity)
626        || any(unused_server_actions, source, severity)
627        || any(unused_load_data_keys, source, severity)
628}
629
630#[cfg(test)]
631mod tests {
632    use std::path::PathBuf;
633
634    use fallow_types::output_dead_code::{
635        CircularDependencyFinding, MisconfiguredDependencyOverrideFinding, UnusedDependencyFinding,
636        UnusedExportFinding,
637    };
638    use fallow_types::results::StaleSuppression;
639    use serde_json::json;
640
641    use super::*;
642
643    const ROOT: &str = "/project";
644
645    fn config(json: &str) -> ResolvedConfig {
646        serde_json::from_str::<fallow_config::FallowConfig>(json)
647            .expect("config parses")
648            .resolve(
649                PathBuf::from(ROOT),
650                fallow_config::OutputFormat::Human,
651                1,
652                true,
653                true,
654                None,
655            )
656    }
657
658    fn legacy_warn_config() -> ResolvedConfig {
659        config(
660            r#"{
661                "rules": {
662                    "unused-exports": "error",
663                    "circular-dependencies": "error",
664                    "unused-dependencies": "error",
665                    "stale-suppressions": "warn",
666                    "require-suppression-reason": "error"
667                },
668                "overrides": [{
669                    "files": ["src/legacy/**", "package.json"],
670                    "rules": {
671                        "unused-exports": "warn",
672                        "circular-dependencies": "warn",
673                        "unused-dependencies": "warn",
674                        "require-suppression-reason": "warn"
675                    }
676                }]
677            }"#,
678        )
679    }
680
681    fn export(path: &str) -> UnusedExportFinding {
682        serde_json::from_value(json!({
683            "path": format!("{ROOT}/{path}"),
684            "export_name": "unused",
685            "is_type_only": false,
686            "line": 1,
687            "col": 0,
688            "span_start": 0,
689            "is_re_export": false,
690            "actions": [],
691        }))
692        .expect("export finding")
693    }
694
695    fn cycle(files: &[&str]) -> CircularDependencyFinding {
696        serde_json::from_value(json!({
697            "files": files.iter().map(|file| format!("{ROOT}/{file}")).collect::<Vec<_>>(),
698            "length": files.len(),
699            "line": 1,
700            "col": 0,
701            "actions": [],
702        }))
703        .expect("cycle finding")
704    }
705
706    fn stale(path: &str, missing_reason: bool) -> StaleSuppression {
707        serde_json::from_value(json!({
708            "path": format!("{ROOT}/{path}"),
709            "line": 1,
710            "col": 0,
711            "origin": { "type": "comment", "is_file_level": false },
712            "missing_reason": missing_reason,
713            "actions": [],
714        }))
715        .expect("stale suppression")
716    }
717
718    #[test]
719    fn file_scoped_findings_follow_the_override_for_their_path() {
720        let mut results = AnalysisResults::default();
721        results.unused_exports.push(export("src/app.ts"));
722        results.unused_exports.push(export("src/legacy/old.ts"));
723
724        apply_effective_severities(&mut results, &legacy_warn_config());
725
726        let severities: Vec<_> = results
727            .unused_exports
728            .iter()
729            .map(|finding| finding.effective_severity)
730            .collect();
731        assert_eq!(
732            severities,
733            vec![
734                Some(EffectiveSeverity::Error),
735                Some(EffectiveSeverity::Warn)
736            ]
737        );
738    }
739
740    #[test]
741    fn a_cycle_is_error_when_any_file_in_it_resolves_to_error() {
742        let mut results = AnalysisResults::default();
743        results
744            .circular_dependencies
745            .push(cycle(&["src/legacy/a.ts", "src/b.ts"]));
746        results
747            .circular_dependencies
748            .push(cycle(&["src/legacy/a.ts", "src/legacy/b.ts"]));
749
750        apply_effective_severities(&mut results, &legacy_warn_config());
751
752        assert_eq!(
753            results.circular_dependencies[0].effective_severity,
754            Some(EffectiveSeverity::Error)
755        );
756        assert_eq!(
757            results.circular_dependencies[1].effective_severity,
758            Some(EffectiveSeverity::Warn)
759        );
760    }
761
762    #[test]
763    fn project_level_findings_use_the_base_rules() {
764        let mut results = AnalysisResults::default();
765        results.unused_dependencies.push(
766            serde_json::from_value::<UnusedDependencyFinding>(json!({
767                "package_name": "left-pad",
768                "location": "dependencies",
769                "path": format!("{ROOT}/package.json"),
770                "line": 3,
771                "actions": [],
772            }))
773            .expect("dependency finding"),
774        );
775
776        apply_effective_severities(&mut results, &legacy_warn_config());
777
778        assert_eq!(
779            results.unused_dependencies[0].effective_severity,
780            Some(EffectiveSeverity::Error)
781        );
782    }
783
784    #[test]
785    fn a_dependency_override_follows_the_override_for_its_file() {
786        let config = config(
787            r#"{
788                "rules": { "misconfigured-dependency-overrides": "error" },
789                "overrides": [{
790                    "files": ["pnpm-workspace.yaml"],
791                    "rules": { "misconfigured-dependency-overrides": "warn" }
792                }]
793            }"#,
794        );
795        let mut results = AnalysisResults::default();
796        for file in ["pnpm-workspace.yaml", "package.json"] {
797            results.misconfigured_dependency_overrides.push(
798                serde_json::from_value::<MisconfiguredDependencyOverrideFinding>(json!({
799                    "raw_key": "",
800                    "raw_value": "^1.0.0",
801                    "reason": "empty-value",
802                    "source": file,
803                    "path": format!("{ROOT}/{file}"),
804                    "line": 2,
805                    "actions": [],
806                }))
807                .expect("override finding"),
808            );
809        }
810
811        apply_effective_severities(&mut results, &config);
812
813        let severities: Vec<_> = results
814            .misconfigured_dependency_overrides
815            .iter()
816            .map(|finding| finding.effective_severity)
817            .collect();
818        assert_eq!(
819            severities,
820            vec![
821                Some(EffectiveSeverity::Warn),
822                Some(EffectiveSeverity::Error)
823            ]
824        );
825    }
826
827    #[test]
828    fn a_stale_suppression_reads_the_rule_for_its_kind() {
829        let mut results = AnalysisResults::default();
830        results.stale_suppressions.push(stale("src/app.ts", false));
831        results.stale_suppressions.push(stale("src/app.ts", true));
832        results
833            .stale_suppressions
834            .push(stale("src/legacy/old.ts", true));
835
836        apply_effective_severities(&mut results, &legacy_warn_config());
837
838        let severities: Vec<_> = results
839            .stale_suppressions
840            .iter()
841            .map(|finding| finding.effective_severity)
842            .collect();
843        assert_eq!(
844            severities,
845            vec![
846                Some(EffectiveSeverity::Warn),
847                Some(EffectiveSeverity::Error),
848                Some(EffectiveSeverity::Warn),
849            ]
850        );
851    }
852
853    #[test]
854    fn fail_on_issues_promotion_raises_warn_and_keeps_error() {
855        let mut results = AnalysisResults::default();
856        results.unused_exports.push(export("src/app.ts"));
857        results.unused_exports.push(export("src/legacy/old.ts"));
858        apply_effective_severities(&mut results, &legacy_warn_config());
859
860        promote_effective_warns(&mut results);
861
862        assert!(
863            results
864                .unused_exports
865                .iter()
866                .all(|finding| finding.effective_severity == Some(EffectiveSeverity::Error))
867        );
868    }
869
870    /// `apply_rule_severities` removes such a cycle before it writes the
871    /// severities. The table must still agree with itself when a caller
872    /// skips that filter.
873    #[test]
874    fn a_cycle_whose_files_all_resolve_to_off_gets_no_severity() {
875        let config = config(
876            r#"{
877                "rules": { "circular-dependencies": "error" },
878                "overrides": [{
879                    "files": ["src/legacy/**"],
880                    "rules": { "circular-dependencies": "off" }
881                }]
882            }"#,
883        );
884        let mut results = AnalysisResults::default();
885        results
886            .circular_dependencies
887            .push(cycle(&["src/legacy/a.ts", "src/legacy/b.ts"]));
888
889        apply_effective_severities(&mut results, &config);
890
891        assert_eq!(results.circular_dependencies[0].effective_severity, None);
892        assert!(!crate::error_severity::has_error_severity_issues(
893            &results,
894            &config.rules,
895            Some(&config),
896            false
897        ));
898        // The audit ledger reads this value for each finding.
899        assert_eq!(
900            results.circular_dependencies[0].rule_severity(&SeveritySource::from_config(&config)),
901            Severity::Off
902        );
903    }
904
905    #[test]
906    fn the_collection_check_agrees_with_the_per_finding_check_without_overrides() {
907        let configs = [
908            r#"{ "rules": { "unused-exports": "error", "circular-dependencies": "warn",
909                 "unused-dependencies": "off", "stale-suppressions": "warn",
910                 "require-suppression-reason": "warn" } }"#,
911            r#"{ "rules": { "unused-exports": "warn", "circular-dependencies": "error",
912                 "unused-dependencies": "error", "stale-suppressions": "warn",
913                 "require-suppression-reason": "error" } }"#,
914            r#"{ "rules": { "unused-exports": "off", "circular-dependencies": "off",
915                 "unused-dependencies": "warn", "stale-suppressions": "error",
916                 "require-suppression-reason": "off" } }"#,
917        ];
918        let mut results = AnalysisResults::default();
919        results.unused_exports.push(export("src/app.ts"));
920        results
921            .circular_dependencies
922            .push(cycle(&["src/a.ts", "src/b.ts"]));
923        results.circular_dependencies.push(cycle(&[]));
924        results.unused_dependencies.push(
925            serde_json::from_value::<UnusedDependencyFinding>(json!({
926                "package_name": "left-pad",
927                "location": "dependencies",
928                "path": format!("{ROOT}/package.json"),
929                "line": 3,
930                "actions": [],
931            }))
932            .expect("dependency finding"),
933        );
934        results.stale_suppressions.push(stale("src/app.ts", false));
935        results.stale_suppressions.push(stale("src/app.ts", true));
936
937        for json in configs {
938            let config = config(json);
939            for promote in [false, true] {
940                let mut rules = config.rules.clone();
941                if promote {
942                    promote_warns_to_errors(&mut rules);
943                }
944                let source = SeveritySource::new(&rules, Some(&config), promote);
945                assert!(source.overrides.is_none());
946                for severity in [Severity::Error, Severity::Warn, Severity::Off] {
947                    let mut per_finding = false;
948                    for_each_gated_finding(&mut results, &mut |finding| {
949                        per_finding |= finding.rule_severity(&source) == severity;
950                    });
951                    assert_eq!(
952                        any_gated_finding(&results, &source, severity),
953                        per_finding,
954                        "{json} promote={promote} {severity:?}"
955                    );
956                }
957            }
958        }
959    }
960
961    type AddFinding = fn(&mut AnalysisResults);
962
963    #[test]
964    fn the_exit_code_rule_fails_exactly_when_a_finding_is_stamped_error() {
965        let config = legacy_warn_config();
966        let cases: [(&str, AddFinding); 4] = [
967            ("legacy export", |r| {
968                r.unused_exports.push(export("src/legacy/old.ts"));
969            }),
970            ("app export", |r| {
971                r.unused_exports.push(export("src/app.ts"));
972            }),
973            ("legacy cycle", |r| {
974                r.circular_dependencies
975                    .push(cycle(&["src/legacy/a.ts", "src/legacy/b.ts"]));
976            }),
977            ("stale suppression", |r| {
978                r.stale_suppressions.push(stale("src/app.ts", false));
979            }),
980        ];
981        for (name, add) in cases {
982            let mut results = AnalysisResults::default();
983            add(&mut results);
984            apply_effective_severities(&mut results, &config);
985            let mut stamped_error = false;
986            for_each_gated_finding(&mut results, &mut |finding| {
987                stamped_error |= finding.effective_severity() == Some(EffectiveSeverity::Error);
988            });
989            assert_eq!(
990                crate::error_severity::has_error_severity_issues(
991                    &results,
992                    &config.rules,
993                    Some(&config),
994                    false
995                ),
996                stamped_error,
997                "{name}"
998            );
999        }
1000    }
1001}