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    AbsentComponentPropFinding, BoundaryCallViolationFinding, BoundaryCoverageViolationFinding,
35    BoundaryViolationFinding, CircularDependencyFinding, DeprecatedExportInUseFinding,
36    DevDependencyInProductionFinding, DuplicateExportFinding, DynamicSegmentNameConflictFinding,
37    EffectiveSeverity, 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    AbsentComponentPropFinding => prop.path, absent_component_props;
180    UnusedComponentEmitFinding => emit.path, unused_component_emits;
181    UnusedComponentInputFinding => input.path, unused_component_inputs;
182    UnusedComponentOutputFinding => output.path, unused_component_outputs;
183    UnusedSvelteEventFinding => event.path, unused_svelte_events;
184    UnusedServerActionFinding => action.path, unused_server_actions;
185    UnusedLoadDataKeyFinding => key.path, unused_load_data_keys;
186    InvalidClientExportFinding => export.path, invalid_client_export;
187    MixedClientServerBarrelFinding => barrel.path, mixed_client_server_barrel;
188    MisplacedDirectiveFinding => directive_site.path, misplaced_directive;
189    RouteCollisionFinding => collision.path, route_collision;
190    DynamicSegmentNameConflictFinding => conflict.path, dynamic_segment_name_conflict;
191    BoundaryViolationFinding => violation.from_path, boundary_violation;
192    BoundaryCoverageViolationFinding => violation.path, boundary_violation;
193    BoundaryCallViolationFinding => violation.path, boundary_violation;
194    UnresolvedCatalogReferenceFinding => reference.path, unresolved_catalog_references;
195    EmptyCatalogGroupFinding => group.path, empty_catalog_groups;
196    UnusedDependencyOverrideFinding => entry.path, unused_dependency_overrides;
197    MisconfiguredDependencyOverrideFinding => entry.path, misconfigured_dependency_overrides;
198}
199
200project_level! {
201    UnusedDependencyFinding => unused_dependencies;
202    UnusedDevDependencyFinding => unused_dev_dependencies;
203    UnusedOptionalDependencyFinding => unused_optional_dependencies;
204    UnlistedDependencyFinding => unlisted_dependencies;
205    DuplicateExportFinding => duplicate_exports;
206    TypeOnlyDependencyFinding => type_only_dependencies;
207    TestOnlyDependencyFinding => test_only_dependencies;
208    DevDependencyInProductionFinding => dev_dependencies_in_production;
209    ReExportCycleFinding => re_export_cycle;
210    UnusedCatalogEntryFinding => unused_catalog_entries;
211}
212
213impl RuleSeverity for CircularDependencyFinding {
214    fn rule_severity(&self, source: &SeveritySource<'_>) -> Severity {
215        self.cycle
216            .files
217            .iter()
218            .map(|path| source.for_path(path, |rules| rules.circular_dependencies))
219            .max_by_key(|severity| severity_rank(*severity))
220            .unwrap_or_else(|| source.project(|rules| rules.circular_dependencies))
221    }
222
223    fn uniform_severity(source: &SeveritySource<'_>) -> Option<Severity> {
224        source.uniform(|rules| rules.circular_dependencies)
225    }
226}
227
228impl RuleSeverity for PackageCycleFinding {
229    fn rule_severity(&self, source: &SeveritySource<'_>) -> Severity {
230        self.cycle
231            .edges
232            .iter()
233            .map(|edge| source.for_path(&edge.path, |rules| rules.package_cycle))
234            .max_by_key(|severity| severity_rank(*severity))
235            .unwrap_or_else(|| source.project(|rules| rules.package_cycle))
236    }
237
238    fn uniform_severity(source: &SeveritySource<'_>) -> Option<Severity> {
239        source.uniform(|rules| rules.package_cycle)
240    }
241}
242
243impl RuleSeverity for StaleSuppression {
244    fn rule_severity(&self, source: &SeveritySource<'_>) -> Severity {
245        if self.missing_reason {
246            source.for_path(&self.path, |rules| rules.require_suppression_reason)
247        } else {
248            source.for_path(&self.path, |rules| rules.stale_suppressions)
249        }
250    }
251
252    fn uniform_severity(source: &SeveritySource<'_>) -> Option<Severity> {
253        let stale = source.uniform(|rules| rules.stale_suppressions)?;
254        let missing_reason = source.uniform(|rules| rules.require_suppression_reason)?;
255        (stale == missing_reason).then_some(stale)
256    }
257}
258
259impl RuleSeverity for PolicyViolationFinding {
260    fn rule_severity(&self, _source: &SeveritySource<'_>) -> Severity {
261        match self.violation.severity {
262            PolicyViolationSeverity::Error => Severity::Error,
263            PolicyViolationSeverity::Warn => Severity::Warn,
264        }
265    }
266}
267
268const fn severity_rank(severity: Severity) -> u8 {
269    match severity {
270        Severity::Off => 0,
271        Severity::Warn => 1,
272        Severity::Error => 2,
273    }
274}
275
276/// A finding that has a rule severity and carries a gate severity.
277trait GatedRuleFinding: GatedFinding + RuleSeverity {}
278
279impl<T: GatedFinding + RuleSeverity> GatedRuleFinding for T {}
280
281/// Write the gate severity onto each dead-code finding in `results`.
282///
283/// Call this after the findings whose rule is `off` are removed. The function
284/// overwrites any earlier value, so a second call with the same config gives
285/// the same result.
286pub fn apply_effective_severities(results: &mut AnalysisResults, config: &ResolvedConfig) {
287    let source = SeveritySource::from_config(config);
288    for_each_gated_finding(results, &mut |finding| {
289        let severity = finding.rule_severity(&source);
290        finding.set_effective_severity(gate(severity));
291    });
292    apply_non_gating_severities(results, &config.rules);
293}
294
295/// Write the rule severity onto each prop-drilling, thin-wrapper and
296/// duplicate-prop-shape finding.
297///
298/// The analysis reads only the base rules for these types, so the value is
299/// the base rule. These findings never gate the run: the exit-code check and
300/// `--fail-on-issues` skip them.
301fn apply_non_gating_severities(results: &mut AnalysisResults, rules: &RulesConfig) {
302    set_all(&mut results.prop_drilling_chains, rules.prop_drilling);
303    set_all(&mut results.thin_wrappers, rules.thin_wrapper);
304    set_all(
305        &mut results.duplicate_prop_shapes,
306        rules.duplicate_prop_shape,
307    );
308}
309
310fn set_all<T: GatedFinding>(findings: &mut [T], rule: Severity) {
311    for finding in findings {
312        finding.set_effective_severity(gate(rule));
313    }
314}
315
316/// The number of gated dead-code findings in `results` that carry no saved
317/// severity, for example in a report from an older version. A renderer then
318/// takes their level from the configured rules.
319///
320/// Policy violations carry their own severity and do not count. Neither do
321/// prop-drilling, thin-wrapper and duplicate-prop-shape findings: only SARIF
322/// renders them, always at level `warning`, so the rules never change their
323/// level.
324#[must_use]
325pub fn findings_without_severity(mut results: AnalysisResults) -> usize {
326    let mut missing = 0;
327    for_each_gated_finding(&mut results, &mut |finding| {
328        if finding.effective_severity().is_none() {
329            missing += 1;
330        }
331    });
332    missing
333}
334
335/// Raise every `warn` gate severity to `error`, for `--fail-on-issues`.
336///
337/// Under that flag every reported finding fails the run, so every CI format
338/// must state `error` too.
339pub fn promote_effective_warns(results: &mut AnalysisResults) {
340    for_each_gated_finding(results, &mut |finding| {
341        if finding.effective_severity() == Some(EffectiveSeverity::Warn) {
342            finding.set_effective_severity(Some(EffectiveSeverity::Error));
343        }
344    });
345}
346
347/// Whether any dead-code finding in `results` has `severity` under `source`.
348///
349/// Policy violations count with their own severity.
350#[must_use]
351pub fn any_finding_with_severity(
352    results: &AnalysisResults,
353    source: &SeveritySource<'_>,
354    severity: Severity,
355) -> bool {
356    count_findings_up_to(results, source, severity, 1) > 0
357}
358
359/// How many dead-code findings in `results` have `severity` under `source`.
360///
361/// The same rule table as [`any_finding_with_severity`], so the count is
362/// zero exactly when that function returns false.
363#[must_use]
364pub fn count_findings_with_severity(
365    results: &AnalysisResults,
366    source: &SeveritySource<'_>,
367    severity: Severity,
368) -> usize {
369    count_findings_up_to(results, source, severity, usize::MAX)
370}
371
372/// Count the findings with `severity`, and stop at `limit`.
373fn count_findings_up_to(
374    results: &AnalysisResults,
375    source: &SeveritySource<'_>,
376    severity: Severity,
377    limit: usize,
378) -> usize {
379    let mut tally = SeverityTally {
380        source,
381        severity,
382        limit,
383        total: 0,
384    };
385    if !tally.add(&results.policy_violations) {
386        tally_gated_findings(results, &mut tally);
387    }
388    tally.total
389}
390
391/// A running count of the findings with one severity. It stops at `limit`,
392/// so the yes or no question reads no more findings than it needs.
393struct SeverityTally<'s, 'a> {
394    source: &'s SeveritySource<'a>,
395    severity: Severity,
396    limit: usize,
397    total: usize,
398}
399
400impl SeverityTally<'_, '_> {
401    /// Add the matching findings of one collection. Returns true when the
402    /// count reached `limit`.
403    ///
404    /// When every finding of the kind has the same severity, one table lookup
405    /// answers for the whole collection.
406    fn add<T: RuleSeverity>(&mut self, findings: &[T]) -> bool {
407        let remaining = self.limit - self.total;
408        let found = if findings.is_empty() || remaining == 0 {
409            0
410        } else {
411            match T::uniform_severity(self.source) {
412                Some(uniform) if uniform == self.severity => findings.len().min(remaining),
413                Some(_) => 0,
414                None => findings
415                    .iter()
416                    .filter(|finding| finding.rule_severity(self.source) == self.severity)
417                    .take(remaining)
418                    .count(),
419            }
420        };
421        self.total += found;
422        self.total >= self.limit
423    }
424}
425
426fn visit<T: GatedRuleFinding>(findings: &mut [T], f: &mut dyn FnMut(&mut dyn GatedRuleFinding)) {
427    for finding in findings {
428        f(finding);
429    }
430}
431
432/// Visit every finding that carries a gate severity.
433///
434/// The destructure has no `..`, so a new field on [`AnalysisResults`] fails to
435/// compile here until it is listed.
436#[expect(
437    clippy::too_many_lines,
438    reason = "one exhaustive list of finding collections; splitting it would lose the compile-time guard"
439)]
440fn for_each_gated_finding(
441    results: &mut AnalysisResults,
442    f: &mut dyn FnMut(&mut dyn GatedRuleFinding),
443) {
444    let AnalysisResults {
445        unused_files,
446        unused_exports,
447        unused_types,
448        private_type_leaks,
449        deprecated_exports_in_use,
450        unused_dependencies,
451        unused_dev_dependencies,
452        unused_optional_dependencies,
453        unused_enum_members,
454        unused_class_members,
455        unused_store_members,
456        unresolved_imports,
457        unlisted_dependencies,
458        duplicate_exports,
459        type_only_dependencies,
460        test_only_dependencies,
461        dev_dependencies_in_production,
462        circular_dependencies,
463        re_export_cycles,
464        package_cycles,
465        boundary_violations,
466        boundary_coverage_violations,
467        boundary_call_violations,
468        stale_suppressions,
469        unused_catalog_entries,
470        empty_catalog_groups,
471        unresolved_catalog_references,
472        unused_dependency_overrides,
473        misconfigured_dependency_overrides,
474        invalid_client_exports,
475        mixed_client_server_barrels,
476        misplaced_directives,
477        unprovided_injects,
478        unrendered_components,
479        route_collisions,
480        dynamic_segment_name_conflicts,
481        unused_component_props,
482        absent_component_props,
483        unused_component_emits,
484        unused_component_inputs,
485        unused_component_outputs,
486        unused_svelte_events,
487        unused_server_actions,
488        unused_load_data_keys,
489        // Policy violations carry their own evaluated `severity`.
490        policy_violations: _,
491        // Health signals that never gate the run.
492        prop_drilling_chains: _,
493        thin_wrappers: _,
494        duplicate_prop_shapes: _,
495        // Not findings: counts, flags and metadata.
496        unused_load_data_keys_global_abstain: _,
497        cascade_hidden: _,
498        cascade: _,
499        suppression_count: _,
500        unused_component_props_exempted: _,
501        active_suppressions: _,
502        feature_flags: _,
503        export_usages: _,
504        entry_point_summary: _,
505        render_fan_in: _,
506        react_component_intel: _,
507        semantic_framework_contracts: _,
508        // Security findings belong to `fallow security` and its own gate.
509        security_findings: _,
510        security_unresolved_edge_files: _,
511        security_unresolved_callee_sites: _,
512        security_unresolved_callee_diagnostics: _,
513    } = results;
514    visit(unused_files, f);
515    visit(unused_exports, f);
516    visit(unused_types, f);
517    visit(private_type_leaks, f);
518    visit(deprecated_exports_in_use, f);
519    visit(unused_dependencies, f);
520    visit(unused_dev_dependencies, f);
521    visit(unused_optional_dependencies, f);
522    visit(unused_enum_members, f);
523    visit(unused_class_members, f);
524    visit(unused_store_members, f);
525    visit(unresolved_imports, f);
526    visit(unlisted_dependencies, f);
527    visit(duplicate_exports, f);
528    visit(type_only_dependencies, f);
529    visit(test_only_dependencies, f);
530    visit(dev_dependencies_in_production, f);
531    visit(circular_dependencies, f);
532    visit(re_export_cycles, f);
533    visit(package_cycles, f);
534    visit(boundary_violations, f);
535    visit(boundary_coverage_violations, f);
536    visit(boundary_call_violations, f);
537    visit(stale_suppressions, f);
538    visit(unused_catalog_entries, f);
539    visit(empty_catalog_groups, f);
540    visit(unresolved_catalog_references, f);
541    visit(unused_dependency_overrides, f);
542    visit(misconfigured_dependency_overrides, f);
543    visit(invalid_client_exports, f);
544    visit(mixed_client_server_barrels, f);
545    visit(misplaced_directives, f);
546    visit(unprovided_injects, f);
547    visit(unrendered_components, f);
548    visit(route_collisions, f);
549    visit(dynamic_segment_name_conflicts, f);
550    visit(unused_component_props, f);
551    visit(absent_component_props, f);
552    visit(unused_component_emits, f);
553    visit(unused_component_inputs, f);
554    visit(unused_component_outputs, f);
555    visit(unused_svelte_events, f);
556    visit(unused_server_actions, f);
557    visit(unused_load_data_keys, f);
558}
559
560/// Add every finding that carries a gate severity to `tally`, until it
561/// reaches its limit.
562///
563/// Exhaustive like [`for_each_gated_finding`]: a new field on
564/// [`AnalysisResults`] fails to compile here until it is listed.
565#[expect(
566    clippy::too_many_lines,
567    reason = "one exhaustive list of finding collections; splitting it would lose the compile-time guard"
568)]
569fn tally_gated_findings(results: &AnalysisResults, tally: &mut SeverityTally<'_, '_>) {
570    let AnalysisResults {
571        unused_files,
572        unused_exports,
573        unused_types,
574        private_type_leaks,
575        deprecated_exports_in_use,
576        unused_dependencies,
577        unused_dev_dependencies,
578        unused_optional_dependencies,
579        unused_enum_members,
580        unused_class_members,
581        unused_store_members,
582        unresolved_imports,
583        unlisted_dependencies,
584        duplicate_exports,
585        type_only_dependencies,
586        test_only_dependencies,
587        dev_dependencies_in_production,
588        circular_dependencies,
589        re_export_cycles,
590        package_cycles,
591        boundary_violations,
592        boundary_coverage_violations,
593        boundary_call_violations,
594        stale_suppressions,
595        unused_catalog_entries,
596        empty_catalog_groups,
597        unresolved_catalog_references,
598        unused_dependency_overrides,
599        misconfigured_dependency_overrides,
600        invalid_client_exports,
601        mixed_client_server_barrels,
602        misplaced_directives,
603        unprovided_injects,
604        unrendered_components,
605        route_collisions,
606        dynamic_segment_name_conflicts,
607        unused_component_props,
608        absent_component_props,
609        unused_component_emits,
610        unused_component_inputs,
611        unused_component_outputs,
612        unused_svelte_events,
613        unused_server_actions,
614        unused_load_data_keys,
615        policy_violations: _,
616        prop_drilling_chains: _,
617        thin_wrappers: _,
618        duplicate_prop_shapes: _,
619        unused_load_data_keys_global_abstain: _,
620        cascade_hidden: _,
621        cascade: _,
622        suppression_count: _,
623        unused_component_props_exempted: _,
624        active_suppressions: _,
625        feature_flags: _,
626        export_usages: _,
627        entry_point_summary: _,
628        render_fan_in: _,
629        react_component_intel: _,
630        semantic_framework_contracts: _,
631        security_findings: _,
632        security_unresolved_edge_files: _,
633        security_unresolved_callee_sites: _,
634        security_unresolved_callee_diagnostics: _,
635    } = results;
636    let _reached_limit = tally.add(unused_files)
637        || tally.add(unused_exports)
638        || tally.add(unused_types)
639        || tally.add(private_type_leaks)
640        || tally.add(deprecated_exports_in_use)
641        || tally.add(unused_dependencies)
642        || tally.add(unused_dev_dependencies)
643        || tally.add(unused_optional_dependencies)
644        || tally.add(unused_enum_members)
645        || tally.add(unused_class_members)
646        || tally.add(unused_store_members)
647        || tally.add(unresolved_imports)
648        || tally.add(unlisted_dependencies)
649        || tally.add(duplicate_exports)
650        || tally.add(type_only_dependencies)
651        || tally.add(test_only_dependencies)
652        || tally.add(dev_dependencies_in_production)
653        || tally.add(circular_dependencies)
654        || tally.add(re_export_cycles)
655        || tally.add(package_cycles)
656        || tally.add(boundary_violations)
657        || tally.add(boundary_coverage_violations)
658        || tally.add(boundary_call_violations)
659        || tally.add(stale_suppressions)
660        || tally.add(unused_catalog_entries)
661        || tally.add(empty_catalog_groups)
662        || tally.add(unresolved_catalog_references)
663        || tally.add(unused_dependency_overrides)
664        || tally.add(misconfigured_dependency_overrides)
665        || tally.add(invalid_client_exports)
666        || tally.add(mixed_client_server_barrels)
667        || tally.add(misplaced_directives)
668        || tally.add(unprovided_injects)
669        || tally.add(unrendered_components)
670        || tally.add(route_collisions)
671        || tally.add(dynamic_segment_name_conflicts)
672        || tally.add(unused_component_props)
673        || tally.add(absent_component_props)
674        || tally.add(unused_component_emits)
675        || tally.add(unused_component_inputs)
676        || tally.add(unused_component_outputs)
677        || tally.add(unused_svelte_events)
678        || tally.add(unused_server_actions)
679        || tally.add(unused_load_data_keys);
680}
681
682#[cfg(test)]
683mod tests {
684    use std::path::PathBuf;
685
686    use fallow_types::output_dead_code::{
687        CircularDependencyFinding, MisconfiguredDependencyOverrideFinding, UnusedDependencyFinding,
688        UnusedExportFinding,
689    };
690    use fallow_types::results::StaleSuppression;
691    use serde_json::json;
692
693    use super::*;
694
695    const ROOT: &str = "/project";
696
697    fn config(json: &str) -> ResolvedConfig {
698        serde_json::from_str::<fallow_config::FallowConfig>(json)
699            .expect("config parses")
700            .resolve(
701                PathBuf::from(ROOT),
702                fallow_config::OutputFormat::Human,
703                1,
704                true,
705                true,
706                None,
707            )
708    }
709
710    fn legacy_warn_config() -> ResolvedConfig {
711        config(
712            r#"{
713                "rules": {
714                    "unused-exports": "error",
715                    "circular-dependencies": "error",
716                    "unused-dependencies": "error",
717                    "stale-suppressions": "warn",
718                    "require-suppression-reason": "error"
719                },
720                "overrides": [{
721                    "files": ["src/legacy/**", "package.json"],
722                    "rules": {
723                        "unused-exports": "warn",
724                        "circular-dependencies": "warn",
725                        "unused-dependencies": "warn",
726                        "require-suppression-reason": "warn"
727                    }
728                }]
729            }"#,
730        )
731    }
732
733    fn export(path: &str) -> UnusedExportFinding {
734        serde_json::from_value(json!({
735            "path": format!("{ROOT}/{path}"),
736            "export_name": "unused",
737            "is_type_only": false,
738            "line": 1,
739            "col": 0,
740            "span_start": 0,
741            "is_re_export": false,
742            "actions": [],
743        }))
744        .expect("export finding")
745    }
746
747    fn cycle(files: &[&str]) -> CircularDependencyFinding {
748        serde_json::from_value(json!({
749            "files": files.iter().map(|file| format!("{ROOT}/{file}")).collect::<Vec<_>>(),
750            "length": files.len(),
751            "line": 1,
752            "col": 0,
753            "actions": [],
754        }))
755        .expect("cycle finding")
756    }
757
758    fn stale(path: &str, missing_reason: bool) -> StaleSuppression {
759        serde_json::from_value(json!({
760            "path": format!("{ROOT}/{path}"),
761            "line": 1,
762            "col": 0,
763            "origin": { "type": "comment", "is_file_level": false },
764            "missing_reason": missing_reason,
765            "actions": [],
766        }))
767        .expect("stale suppression")
768    }
769
770    #[test]
771    fn file_scoped_findings_follow_the_override_for_their_path() {
772        let mut results = AnalysisResults::default();
773        results.unused_exports.push(export("src/app.ts"));
774        results.unused_exports.push(export("src/legacy/old.ts"));
775
776        apply_effective_severities(&mut results, &legacy_warn_config());
777
778        let severities: Vec<_> = results
779            .unused_exports
780            .iter()
781            .map(|finding| finding.effective_severity)
782            .collect();
783        assert_eq!(
784            severities,
785            vec![
786                Some(EffectiveSeverity::Error),
787                Some(EffectiveSeverity::Warn)
788            ]
789        );
790    }
791
792    #[test]
793    fn a_cycle_is_error_when_any_file_in_it_resolves_to_error() {
794        let mut results = AnalysisResults::default();
795        results
796            .circular_dependencies
797            .push(cycle(&["src/legacy/a.ts", "src/b.ts"]));
798        results
799            .circular_dependencies
800            .push(cycle(&["src/legacy/a.ts", "src/legacy/b.ts"]));
801
802        apply_effective_severities(&mut results, &legacy_warn_config());
803
804        assert_eq!(
805            results.circular_dependencies[0].effective_severity,
806            Some(EffectiveSeverity::Error)
807        );
808        assert_eq!(
809            results.circular_dependencies[1].effective_severity,
810            Some(EffectiveSeverity::Warn)
811        );
812    }
813
814    #[test]
815    fn project_level_findings_use_the_base_rules() {
816        let mut results = AnalysisResults::default();
817        results.unused_dependencies.push(
818            serde_json::from_value::<UnusedDependencyFinding>(json!({
819                "package_name": "left-pad",
820                "location": "dependencies",
821                "path": format!("{ROOT}/package.json"),
822                "line": 3,
823                "actions": [],
824            }))
825            .expect("dependency finding"),
826        );
827
828        apply_effective_severities(&mut results, &legacy_warn_config());
829
830        assert_eq!(
831            results.unused_dependencies[0].effective_severity,
832            Some(EffectiveSeverity::Error)
833        );
834    }
835
836    #[test]
837    fn a_dependency_override_follows_the_override_for_its_file() {
838        let config = config(
839            r#"{
840                "rules": { "misconfigured-dependency-overrides": "error" },
841                "overrides": [{
842                    "files": ["pnpm-workspace.yaml"],
843                    "rules": { "misconfigured-dependency-overrides": "warn" }
844                }]
845            }"#,
846        );
847        let mut results = AnalysisResults::default();
848        for file in ["pnpm-workspace.yaml", "package.json"] {
849            results.misconfigured_dependency_overrides.push(
850                serde_json::from_value::<MisconfiguredDependencyOverrideFinding>(json!({
851                    "raw_key": "",
852                    "raw_value": "^1.0.0",
853                    "reason": "empty-value",
854                    "source": file,
855                    "path": format!("{ROOT}/{file}"),
856                    "line": 2,
857                    "actions": [],
858                }))
859                .expect("override finding"),
860            );
861        }
862
863        apply_effective_severities(&mut results, &config);
864
865        let severities: Vec<_> = results
866            .misconfigured_dependency_overrides
867            .iter()
868            .map(|finding| finding.effective_severity)
869            .collect();
870        assert_eq!(
871            severities,
872            vec![
873                Some(EffectiveSeverity::Warn),
874                Some(EffectiveSeverity::Error)
875            ]
876        );
877    }
878
879    #[test]
880    fn a_stale_suppression_reads_the_rule_for_its_kind() {
881        let mut results = AnalysisResults::default();
882        results.stale_suppressions.push(stale("src/app.ts", false));
883        results.stale_suppressions.push(stale("src/app.ts", true));
884        results
885            .stale_suppressions
886            .push(stale("src/legacy/old.ts", true));
887
888        apply_effective_severities(&mut results, &legacy_warn_config());
889
890        let severities: Vec<_> = results
891            .stale_suppressions
892            .iter()
893            .map(|finding| finding.effective_severity)
894            .collect();
895        assert_eq!(
896            severities,
897            vec![
898                Some(EffectiveSeverity::Warn),
899                Some(EffectiveSeverity::Error),
900                Some(EffectiveSeverity::Warn),
901            ]
902        );
903    }
904
905    #[test]
906    fn fail_on_issues_promotion_raises_warn_and_keeps_error() {
907        let mut results = AnalysisResults::default();
908        results.unused_exports.push(export("src/app.ts"));
909        results.unused_exports.push(export("src/legacy/old.ts"));
910        apply_effective_severities(&mut results, &legacy_warn_config());
911
912        promote_effective_warns(&mut results);
913
914        assert!(
915            results
916                .unused_exports
917                .iter()
918                .all(|finding| finding.effective_severity == Some(EffectiveSeverity::Error))
919        );
920    }
921
922    /// `apply_rule_severities` removes such a cycle before it writes the
923    /// severities. The table must still agree with itself when a caller
924    /// skips that filter.
925    #[test]
926    fn a_cycle_whose_files_all_resolve_to_off_gets_no_severity() {
927        let config = config(
928            r#"{
929                "rules": { "circular-dependencies": "error" },
930                "overrides": [{
931                    "files": ["src/legacy/**"],
932                    "rules": { "circular-dependencies": "off" }
933                }]
934            }"#,
935        );
936        let mut results = AnalysisResults::default();
937        results
938            .circular_dependencies
939            .push(cycle(&["src/legacy/a.ts", "src/legacy/b.ts"]));
940
941        apply_effective_severities(&mut results, &config);
942
943        assert_eq!(results.circular_dependencies[0].effective_severity, None);
944        assert!(!crate::error_severity::has_error_severity_issues(
945            &results,
946            &config.rules,
947            Some(&config),
948            false
949        ));
950        // The audit ledger reads this value for each finding.
951        assert_eq!(
952            results.circular_dependencies[0].rule_severity(&SeveritySource::from_config(&config)),
953            Severity::Off
954        );
955    }
956
957    #[test]
958    fn the_collection_check_agrees_with_the_per_finding_check_without_overrides() {
959        let configs = [
960            r#"{ "rules": { "unused-exports": "error", "circular-dependencies": "warn",
961                 "unused-dependencies": "off", "stale-suppressions": "warn",
962                 "require-suppression-reason": "warn" } }"#,
963            r#"{ "rules": { "unused-exports": "warn", "circular-dependencies": "error",
964                 "unused-dependencies": "error", "stale-suppressions": "warn",
965                 "require-suppression-reason": "error" } }"#,
966            r#"{ "rules": { "unused-exports": "off", "circular-dependencies": "off",
967                 "unused-dependencies": "warn", "stale-suppressions": "error",
968                 "require-suppression-reason": "off" } }"#,
969        ];
970        let mut results = AnalysisResults::default();
971        results.unused_exports.push(export("src/app.ts"));
972        results
973            .circular_dependencies
974            .push(cycle(&["src/a.ts", "src/b.ts"]));
975        results.circular_dependencies.push(cycle(&[]));
976        results.unused_dependencies.push(
977            serde_json::from_value::<UnusedDependencyFinding>(json!({
978                "package_name": "left-pad",
979                "location": "dependencies",
980                "path": format!("{ROOT}/package.json"),
981                "line": 3,
982                "actions": [],
983            }))
984            .expect("dependency finding"),
985        );
986        results.stale_suppressions.push(stale("src/app.ts", false));
987        results.stale_suppressions.push(stale("src/app.ts", true));
988
989        for json in configs {
990            let config = config(json);
991            for promote in [false, true] {
992                let mut rules = config.rules.clone();
993                if promote {
994                    promote_warns_to_errors(&mut rules);
995                }
996                let source = SeveritySource::new(&rules, Some(&config), promote);
997                assert!(source.overrides.is_none());
998                for severity in [Severity::Error, Severity::Warn, Severity::Off] {
999                    let mut per_finding = 0_usize;
1000                    for_each_gated_finding(&mut results, &mut |finding| {
1001                        per_finding += usize::from(finding.rule_severity(&source) == severity);
1002                    });
1003                    assert_eq!(
1004                        count_findings_with_severity(&results, &source, severity),
1005                        per_finding,
1006                        "{json} promote={promote} {severity:?}"
1007                    );
1008                    assert_eq!(
1009                        any_finding_with_severity(&results, &source, severity),
1010                        per_finding > 0,
1011                        "{json} promote={promote} {severity:?}"
1012                    );
1013                }
1014            }
1015        }
1016    }
1017
1018    type AddFinding = fn(&mut AnalysisResults);
1019
1020    #[test]
1021    fn the_exit_code_rule_fails_exactly_when_a_finding_is_stamped_error() {
1022        let config = legacy_warn_config();
1023        let cases: [(&str, AddFinding); 4] = [
1024            ("legacy export", |r| {
1025                r.unused_exports.push(export("src/legacy/old.ts"));
1026            }),
1027            ("app export", |r| {
1028                r.unused_exports.push(export("src/app.ts"));
1029            }),
1030            ("legacy cycle", |r| {
1031                r.circular_dependencies
1032                    .push(cycle(&["src/legacy/a.ts", "src/legacy/b.ts"]));
1033            }),
1034            ("stale suppression", |r| {
1035                r.stale_suppressions.push(stale("src/app.ts", false));
1036            }),
1037        ];
1038        for (name, add) in cases {
1039            let mut results = AnalysisResults::default();
1040            add(&mut results);
1041            apply_effective_severities(&mut results, &config);
1042            let mut stamped_error = false;
1043            for_each_gated_finding(&mut results, &mut |finding| {
1044                stamped_error |= finding.effective_severity() == Some(EffectiveSeverity::Error);
1045            });
1046            assert_eq!(
1047                crate::error_severity::has_error_severity_issues(
1048                    &results,
1049                    &config.rules,
1050                    Some(&config),
1051                    false
1052                ),
1053                stamped_error,
1054                "{name}"
1055            );
1056        }
1057    }
1058}