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, 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#[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 #[must_use]
75 pub fn from_config(config: &'a ResolvedConfig) -> Self {
76 Self::new(&config.rules, Some(config), false)
77 }
78
79 #[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 fn uniform(&self, rule: fn(&RulesConfig) -> Severity) -> Option<Severity> {
114 self.overrides.is_none().then(|| rule(self.base))
115 }
116}
117
118pub trait RuleSeverity {
120 fn rule_severity(&self, source: &SeveritySource<'_>) -> Severity;
122
123 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
290trait GatedRuleFinding: GatedFinding + RuleSeverity {}
292
293impl<T: GatedFinding + RuleSeverity> GatedRuleFinding for T {}
294
295pub 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
319fn 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#[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
360pub 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#[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#[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
397fn 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
419struct SeverityTally<'s, 'a> {
422 source: &'s SeveritySource<'a>,
423 severity: Severity,
424 limit: usize,
425 total: usize,
426}
427
428impl SeverityTally<'_, '_> {
429 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#[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: _,
522 prop_drilling_chains: _,
524 thin_wrappers: _,
525 duplicate_prop_shapes: _,
526 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: _,
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#[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 #[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 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}