Skip to main content

fallow_engine/
effective_severity.rs

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