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