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