1use 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#[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 #[must_use]
76 pub fn from_config(config: &'a ResolvedConfig) -> Self {
77 Self::new(&config.rules, Some(config), false)
78 }
79
80 #[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 fn uniform(&self, rule: fn(&RulesConfig) -> Severity) -> Option<Severity> {
115 self.overrides.is_none().then(|| rule(self.base))
116 }
117}
118
119pub trait RuleSeverity {
121 fn rule_severity(&self, source: &SeveritySource<'_>) -> Severity;
123
124 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
293trait GatedRuleFinding: GatedFinding + RuleSeverity {}
295
296impl<T: GatedFinding + RuleSeverity> GatedRuleFinding for T {}
297
298pub 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
322fn 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#[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
370pub 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#[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#[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
407fn 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
433struct SeverityTally<'s, 'a> {
436 source: &'s SeveritySource<'a>,
437 severity: Severity,
438 limit: usize,
439 total: usize,
440}
441
442impl SeverityTally<'_, '_> {
443 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#[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: _,
534 prop_drilling_chains: _,
536 thin_wrappers: _,
537 duplicate_prop_shapes: _,
538 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: _,
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#[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 #[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 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}