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        suppression_count: _,
498        unused_component_props_exempted: _,
499        active_suppressions: _,
500        feature_flags: _,
501        export_usages: _,
502        entry_point_summary: _,
503        render_fan_in: _,
504        react_component_intel: _,
505        semantic_framework_contracts: _,
506        // Security findings belong to `fallow security` and its own gate.
507        security_findings: _,
508        security_unresolved_edge_files: _,
509        security_unresolved_callee_sites: _,
510        security_unresolved_callee_diagnostics: _,
511    } = results;
512    visit(unused_files, f);
513    visit(unused_exports, f);
514    visit(unused_types, f);
515    visit(private_type_leaks, f);
516    visit(deprecated_exports_in_use, f);
517    visit(unused_dependencies, f);
518    visit(unused_dev_dependencies, f);
519    visit(unused_optional_dependencies, f);
520    visit(unused_enum_members, f);
521    visit(unused_class_members, f);
522    visit(unused_store_members, f);
523    visit(unresolved_imports, f);
524    visit(unlisted_dependencies, f);
525    visit(duplicate_exports, f);
526    visit(type_only_dependencies, f);
527    visit(test_only_dependencies, f);
528    visit(dev_dependencies_in_production, f);
529    visit(circular_dependencies, f);
530    visit(re_export_cycles, f);
531    visit(package_cycles, f);
532    visit(boundary_violations, f);
533    visit(boundary_coverage_violations, f);
534    visit(boundary_call_violations, f);
535    visit(stale_suppressions, f);
536    visit(unused_catalog_entries, f);
537    visit(empty_catalog_groups, f);
538    visit(unresolved_catalog_references, f);
539    visit(unused_dependency_overrides, f);
540    visit(misconfigured_dependency_overrides, f);
541    visit(invalid_client_exports, f);
542    visit(mixed_client_server_barrels, f);
543    visit(misplaced_directives, f);
544    visit(unprovided_injects, f);
545    visit(unrendered_components, f);
546    visit(route_collisions, f);
547    visit(dynamic_segment_name_conflicts, f);
548    visit(unused_component_props, f);
549    visit(absent_component_props, f);
550    visit(unused_component_emits, f);
551    visit(unused_component_inputs, f);
552    visit(unused_component_outputs, f);
553    visit(unused_svelte_events, f);
554    visit(unused_server_actions, f);
555    visit(unused_load_data_keys, f);
556}
557
558/// Add every finding that carries a gate severity to `tally`, until it
559/// reaches its limit.
560///
561/// Exhaustive like [`for_each_gated_finding`]: a new field on
562/// [`AnalysisResults`] fails to compile here until it is listed.
563#[expect(
564    clippy::too_many_lines,
565    reason = "one exhaustive list of finding collections; splitting it would lose the compile-time guard"
566)]
567fn tally_gated_findings(results: &AnalysisResults, tally: &mut SeverityTally<'_, '_>) {
568    let AnalysisResults {
569        unused_files,
570        unused_exports,
571        unused_types,
572        private_type_leaks,
573        deprecated_exports_in_use,
574        unused_dependencies,
575        unused_dev_dependencies,
576        unused_optional_dependencies,
577        unused_enum_members,
578        unused_class_members,
579        unused_store_members,
580        unresolved_imports,
581        unlisted_dependencies,
582        duplicate_exports,
583        type_only_dependencies,
584        test_only_dependencies,
585        dev_dependencies_in_production,
586        circular_dependencies,
587        re_export_cycles,
588        package_cycles,
589        boundary_violations,
590        boundary_coverage_violations,
591        boundary_call_violations,
592        stale_suppressions,
593        unused_catalog_entries,
594        empty_catalog_groups,
595        unresolved_catalog_references,
596        unused_dependency_overrides,
597        misconfigured_dependency_overrides,
598        invalid_client_exports,
599        mixed_client_server_barrels,
600        misplaced_directives,
601        unprovided_injects,
602        unrendered_components,
603        route_collisions,
604        dynamic_segment_name_conflicts,
605        unused_component_props,
606        absent_component_props,
607        unused_component_emits,
608        unused_component_inputs,
609        unused_component_outputs,
610        unused_svelte_events,
611        unused_server_actions,
612        unused_load_data_keys,
613        policy_violations: _,
614        prop_drilling_chains: _,
615        thin_wrappers: _,
616        duplicate_prop_shapes: _,
617        unused_load_data_keys_global_abstain: _,
618        suppression_count: _,
619        unused_component_props_exempted: _,
620        active_suppressions: _,
621        feature_flags: _,
622        export_usages: _,
623        entry_point_summary: _,
624        render_fan_in: _,
625        react_component_intel: _,
626        semantic_framework_contracts: _,
627        security_findings: _,
628        security_unresolved_edge_files: _,
629        security_unresolved_callee_sites: _,
630        security_unresolved_callee_diagnostics: _,
631    } = results;
632    let _reached_limit = tally.add(unused_files)
633        || tally.add(unused_exports)
634        || tally.add(unused_types)
635        || tally.add(private_type_leaks)
636        || tally.add(deprecated_exports_in_use)
637        || tally.add(unused_dependencies)
638        || tally.add(unused_dev_dependencies)
639        || tally.add(unused_optional_dependencies)
640        || tally.add(unused_enum_members)
641        || tally.add(unused_class_members)
642        || tally.add(unused_store_members)
643        || tally.add(unresolved_imports)
644        || tally.add(unlisted_dependencies)
645        || tally.add(duplicate_exports)
646        || tally.add(type_only_dependencies)
647        || tally.add(test_only_dependencies)
648        || tally.add(dev_dependencies_in_production)
649        || tally.add(circular_dependencies)
650        || tally.add(re_export_cycles)
651        || tally.add(package_cycles)
652        || tally.add(boundary_violations)
653        || tally.add(boundary_coverage_violations)
654        || tally.add(boundary_call_violations)
655        || tally.add(stale_suppressions)
656        || tally.add(unused_catalog_entries)
657        || tally.add(empty_catalog_groups)
658        || tally.add(unresolved_catalog_references)
659        || tally.add(unused_dependency_overrides)
660        || tally.add(misconfigured_dependency_overrides)
661        || tally.add(invalid_client_exports)
662        || tally.add(mixed_client_server_barrels)
663        || tally.add(misplaced_directives)
664        || tally.add(unprovided_injects)
665        || tally.add(unrendered_components)
666        || tally.add(route_collisions)
667        || tally.add(dynamic_segment_name_conflicts)
668        || tally.add(unused_component_props)
669        || tally.add(absent_component_props)
670        || tally.add(unused_component_emits)
671        || tally.add(unused_component_inputs)
672        || tally.add(unused_component_outputs)
673        || tally.add(unused_svelte_events)
674        || tally.add(unused_server_actions)
675        || tally.add(unused_load_data_keys);
676}
677
678#[cfg(test)]
679mod tests {
680    use std::path::PathBuf;
681
682    use fallow_types::output_dead_code::{
683        CircularDependencyFinding, MisconfiguredDependencyOverrideFinding, UnusedDependencyFinding,
684        UnusedExportFinding,
685    };
686    use fallow_types::results::StaleSuppression;
687    use serde_json::json;
688
689    use super::*;
690
691    const ROOT: &str = "/project";
692
693    fn config(json: &str) -> ResolvedConfig {
694        serde_json::from_str::<fallow_config::FallowConfig>(json)
695            .expect("config parses")
696            .resolve(
697                PathBuf::from(ROOT),
698                fallow_config::OutputFormat::Human,
699                1,
700                true,
701                true,
702                None,
703            )
704    }
705
706    fn legacy_warn_config() -> ResolvedConfig {
707        config(
708            r#"{
709                "rules": {
710                    "unused-exports": "error",
711                    "circular-dependencies": "error",
712                    "unused-dependencies": "error",
713                    "stale-suppressions": "warn",
714                    "require-suppression-reason": "error"
715                },
716                "overrides": [{
717                    "files": ["src/legacy/**", "package.json"],
718                    "rules": {
719                        "unused-exports": "warn",
720                        "circular-dependencies": "warn",
721                        "unused-dependencies": "warn",
722                        "require-suppression-reason": "warn"
723                    }
724                }]
725            }"#,
726        )
727    }
728
729    fn export(path: &str) -> UnusedExportFinding {
730        serde_json::from_value(json!({
731            "path": format!("{ROOT}/{path}"),
732            "export_name": "unused",
733            "is_type_only": false,
734            "line": 1,
735            "col": 0,
736            "span_start": 0,
737            "is_re_export": false,
738            "actions": [],
739        }))
740        .expect("export finding")
741    }
742
743    fn cycle(files: &[&str]) -> CircularDependencyFinding {
744        serde_json::from_value(json!({
745            "files": files.iter().map(|file| format!("{ROOT}/{file}")).collect::<Vec<_>>(),
746            "length": files.len(),
747            "line": 1,
748            "col": 0,
749            "actions": [],
750        }))
751        .expect("cycle finding")
752    }
753
754    fn stale(path: &str, missing_reason: bool) -> StaleSuppression {
755        serde_json::from_value(json!({
756            "path": format!("{ROOT}/{path}"),
757            "line": 1,
758            "col": 0,
759            "origin": { "type": "comment", "is_file_level": false },
760            "missing_reason": missing_reason,
761            "actions": [],
762        }))
763        .expect("stale suppression")
764    }
765
766    #[test]
767    fn file_scoped_findings_follow_the_override_for_their_path() {
768        let mut results = AnalysisResults::default();
769        results.unused_exports.push(export("src/app.ts"));
770        results.unused_exports.push(export("src/legacy/old.ts"));
771
772        apply_effective_severities(&mut results, &legacy_warn_config());
773
774        let severities: Vec<_> = results
775            .unused_exports
776            .iter()
777            .map(|finding| finding.effective_severity)
778            .collect();
779        assert_eq!(
780            severities,
781            vec![
782                Some(EffectiveSeverity::Error),
783                Some(EffectiveSeverity::Warn)
784            ]
785        );
786    }
787
788    #[test]
789    fn a_cycle_is_error_when_any_file_in_it_resolves_to_error() {
790        let mut results = AnalysisResults::default();
791        results
792            .circular_dependencies
793            .push(cycle(&["src/legacy/a.ts", "src/b.ts"]));
794        results
795            .circular_dependencies
796            .push(cycle(&["src/legacy/a.ts", "src/legacy/b.ts"]));
797
798        apply_effective_severities(&mut results, &legacy_warn_config());
799
800        assert_eq!(
801            results.circular_dependencies[0].effective_severity,
802            Some(EffectiveSeverity::Error)
803        );
804        assert_eq!(
805            results.circular_dependencies[1].effective_severity,
806            Some(EffectiveSeverity::Warn)
807        );
808    }
809
810    #[test]
811    fn project_level_findings_use_the_base_rules() {
812        let mut results = AnalysisResults::default();
813        results.unused_dependencies.push(
814            serde_json::from_value::<UnusedDependencyFinding>(json!({
815                "package_name": "left-pad",
816                "location": "dependencies",
817                "path": format!("{ROOT}/package.json"),
818                "line": 3,
819                "actions": [],
820            }))
821            .expect("dependency finding"),
822        );
823
824        apply_effective_severities(&mut results, &legacy_warn_config());
825
826        assert_eq!(
827            results.unused_dependencies[0].effective_severity,
828            Some(EffectiveSeverity::Error)
829        );
830    }
831
832    #[test]
833    fn a_dependency_override_follows_the_override_for_its_file() {
834        let config = config(
835            r#"{
836                "rules": { "misconfigured-dependency-overrides": "error" },
837                "overrides": [{
838                    "files": ["pnpm-workspace.yaml"],
839                    "rules": { "misconfigured-dependency-overrides": "warn" }
840                }]
841            }"#,
842        );
843        let mut results = AnalysisResults::default();
844        for file in ["pnpm-workspace.yaml", "package.json"] {
845            results.misconfigured_dependency_overrides.push(
846                serde_json::from_value::<MisconfiguredDependencyOverrideFinding>(json!({
847                    "raw_key": "",
848                    "raw_value": "^1.0.0",
849                    "reason": "empty-value",
850                    "source": file,
851                    "path": format!("{ROOT}/{file}"),
852                    "line": 2,
853                    "actions": [],
854                }))
855                .expect("override finding"),
856            );
857        }
858
859        apply_effective_severities(&mut results, &config);
860
861        let severities: Vec<_> = results
862            .misconfigured_dependency_overrides
863            .iter()
864            .map(|finding| finding.effective_severity)
865            .collect();
866        assert_eq!(
867            severities,
868            vec![
869                Some(EffectiveSeverity::Warn),
870                Some(EffectiveSeverity::Error)
871            ]
872        );
873    }
874
875    #[test]
876    fn a_stale_suppression_reads_the_rule_for_its_kind() {
877        let mut results = AnalysisResults::default();
878        results.stale_suppressions.push(stale("src/app.ts", false));
879        results.stale_suppressions.push(stale("src/app.ts", true));
880        results
881            .stale_suppressions
882            .push(stale("src/legacy/old.ts", true));
883
884        apply_effective_severities(&mut results, &legacy_warn_config());
885
886        let severities: Vec<_> = results
887            .stale_suppressions
888            .iter()
889            .map(|finding| finding.effective_severity)
890            .collect();
891        assert_eq!(
892            severities,
893            vec![
894                Some(EffectiveSeverity::Warn),
895                Some(EffectiveSeverity::Error),
896                Some(EffectiveSeverity::Warn),
897            ]
898        );
899    }
900
901    #[test]
902    fn fail_on_issues_promotion_raises_warn_and_keeps_error() {
903        let mut results = AnalysisResults::default();
904        results.unused_exports.push(export("src/app.ts"));
905        results.unused_exports.push(export("src/legacy/old.ts"));
906        apply_effective_severities(&mut results, &legacy_warn_config());
907
908        promote_effective_warns(&mut results);
909
910        assert!(
911            results
912                .unused_exports
913                .iter()
914                .all(|finding| finding.effective_severity == Some(EffectiveSeverity::Error))
915        );
916    }
917
918    /// `apply_rule_severities` removes such a cycle before it writes the
919    /// severities. The table must still agree with itself when a caller
920    /// skips that filter.
921    #[test]
922    fn a_cycle_whose_files_all_resolve_to_off_gets_no_severity() {
923        let config = config(
924            r#"{
925                "rules": { "circular-dependencies": "error" },
926                "overrides": [{
927                    "files": ["src/legacy/**"],
928                    "rules": { "circular-dependencies": "off" }
929                }]
930            }"#,
931        );
932        let mut results = AnalysisResults::default();
933        results
934            .circular_dependencies
935            .push(cycle(&["src/legacy/a.ts", "src/legacy/b.ts"]));
936
937        apply_effective_severities(&mut results, &config);
938
939        assert_eq!(results.circular_dependencies[0].effective_severity, None);
940        assert!(!crate::error_severity::has_error_severity_issues(
941            &results,
942            &config.rules,
943            Some(&config),
944            false
945        ));
946        // The audit ledger reads this value for each finding.
947        assert_eq!(
948            results.circular_dependencies[0].rule_severity(&SeveritySource::from_config(&config)),
949            Severity::Off
950        );
951    }
952
953    #[test]
954    fn the_collection_check_agrees_with_the_per_finding_check_without_overrides() {
955        let configs = [
956            r#"{ "rules": { "unused-exports": "error", "circular-dependencies": "warn",
957                 "unused-dependencies": "off", "stale-suppressions": "warn",
958                 "require-suppression-reason": "warn" } }"#,
959            r#"{ "rules": { "unused-exports": "warn", "circular-dependencies": "error",
960                 "unused-dependencies": "error", "stale-suppressions": "warn",
961                 "require-suppression-reason": "error" } }"#,
962            r#"{ "rules": { "unused-exports": "off", "circular-dependencies": "off",
963                 "unused-dependencies": "warn", "stale-suppressions": "error",
964                 "require-suppression-reason": "off" } }"#,
965        ];
966        let mut results = AnalysisResults::default();
967        results.unused_exports.push(export("src/app.ts"));
968        results
969            .circular_dependencies
970            .push(cycle(&["src/a.ts", "src/b.ts"]));
971        results.circular_dependencies.push(cycle(&[]));
972        results.unused_dependencies.push(
973            serde_json::from_value::<UnusedDependencyFinding>(json!({
974                "package_name": "left-pad",
975                "location": "dependencies",
976                "path": format!("{ROOT}/package.json"),
977                "line": 3,
978                "actions": [],
979            }))
980            .expect("dependency finding"),
981        );
982        results.stale_suppressions.push(stale("src/app.ts", false));
983        results.stale_suppressions.push(stale("src/app.ts", true));
984
985        for json in configs {
986            let config = config(json);
987            for promote in [false, true] {
988                let mut rules = config.rules.clone();
989                if promote {
990                    promote_warns_to_errors(&mut rules);
991                }
992                let source = SeveritySource::new(&rules, Some(&config), promote);
993                assert!(source.overrides.is_none());
994                for severity in [Severity::Error, Severity::Warn, Severity::Off] {
995                    let mut per_finding = 0_usize;
996                    for_each_gated_finding(&mut results, &mut |finding| {
997                        per_finding += usize::from(finding.rule_severity(&source) == severity);
998                    });
999                    assert_eq!(
1000                        count_findings_with_severity(&results, &source, severity),
1001                        per_finding,
1002                        "{json} promote={promote} {severity:?}"
1003                    );
1004                    assert_eq!(
1005                        any_finding_with_severity(&results, &source, severity),
1006                        per_finding > 0,
1007                        "{json} promote={promote} {severity:?}"
1008                    );
1009                }
1010            }
1011        }
1012    }
1013
1014    type AddFinding = fn(&mut AnalysisResults);
1015
1016    #[test]
1017    fn the_exit_code_rule_fails_exactly_when_a_finding_is_stamped_error() {
1018        let config = legacy_warn_config();
1019        let cases: [(&str, AddFinding); 4] = [
1020            ("legacy export", |r| {
1021                r.unused_exports.push(export("src/legacy/old.ts"));
1022            }),
1023            ("app export", |r| {
1024                r.unused_exports.push(export("src/app.ts"));
1025            }),
1026            ("legacy cycle", |r| {
1027                r.circular_dependencies
1028                    .push(cycle(&["src/legacy/a.ts", "src/legacy/b.ts"]));
1029            }),
1030            ("stale suppression", |r| {
1031                r.stale_suppressions.push(stale("src/app.ts", false));
1032            }),
1033        ];
1034        for (name, add) in cases {
1035            let mut results = AnalysisResults::default();
1036            add(&mut results);
1037            apply_effective_severities(&mut results, &config);
1038            let mut stamped_error = false;
1039            for_each_gated_finding(&mut results, &mut |finding| {
1040                stamped_error |= finding.effective_severity() == Some(EffectiveSeverity::Error);
1041            });
1042            assert_eq!(
1043                crate::error_severity::has_error_severity_issues(
1044                    &results,
1045                    &config.rules,
1046                    Some(&config),
1047                    false
1048                ),
1049                stamped_error,
1050                "{name}"
1051            );
1052        }
1053    }
1054}