1use std::path::Path;
31
32use fallow_config::{ResolvedConfig, RulesConfig, Severity};
33use fallow_types::output_dead_code::{
34 AbsentComponentPropFinding, BoundaryCallViolationFinding, BoundaryCoverageViolationFinding,
35 BoundaryViolationFinding, CircularDependencyFinding, DeprecatedExportInUseFinding,
36 DevDependencyInProductionFinding, DuplicateExportFinding, DynamicSegmentNameConflictFinding,
37 EffectiveSeverity, EmptyCatalogGroupFinding, GatedFinding, InvalidClientExportFinding,
38 MisconfiguredDependencyOverrideFinding, MisplacedDirectiveFinding,
39 MixedClientServerBarrelFinding, PackageCycleFinding, PolicyViolationFinding,
40 PrivateTypeLeakFinding, 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#[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 #[must_use]
73 pub fn from_config(config: &'a ResolvedConfig) -> Self {
74 Self::new(&config.rules, Some(config), false)
75 }
76
77 #[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 fn uniform(&self, rule: fn(&RulesConfig) -> Severity) -> Option<Severity> {
112 self.overrides.is_none().then(|| rule(self.base))
113 }
114}
115
116pub trait RuleSeverity {
118 fn rule_severity(&self, source: &SeveritySource<'_>) -> Severity;
120
121 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 AbsentComponentPropFinding => prop.path, absent_component_props;
180 UnusedComponentEmitFinding => emit.path, unused_component_emits;
181 UnusedComponentInputFinding => input.path, unused_component_inputs;
182 UnusedComponentOutputFinding => output.path, unused_component_outputs;
183 UnusedSvelteEventFinding => event.path, unused_svelte_events;
184 UnusedServerActionFinding => action.path, unused_server_actions;
185 UnusedLoadDataKeyFinding => key.path, unused_load_data_keys;
186 InvalidClientExportFinding => export.path, invalid_client_export;
187 MixedClientServerBarrelFinding => barrel.path, mixed_client_server_barrel;
188 MisplacedDirectiveFinding => directive_site.path, misplaced_directive;
189 RouteCollisionFinding => collision.path, route_collision;
190 DynamicSegmentNameConflictFinding => conflict.path, dynamic_segment_name_conflict;
191 BoundaryViolationFinding => violation.from_path, boundary_violation;
192 BoundaryCoverageViolationFinding => violation.path, boundary_violation;
193 BoundaryCallViolationFinding => violation.path, boundary_violation;
194 UnresolvedCatalogReferenceFinding => reference.path, unresolved_catalog_references;
195 EmptyCatalogGroupFinding => group.path, empty_catalog_groups;
196 UnusedDependencyOverrideFinding => entry.path, unused_dependency_overrides;
197 MisconfiguredDependencyOverrideFinding => entry.path, misconfigured_dependency_overrides;
198}
199
200project_level! {
201 UnusedDependencyFinding => unused_dependencies;
202 UnusedDevDependencyFinding => unused_dev_dependencies;
203 UnusedOptionalDependencyFinding => unused_optional_dependencies;
204 UnlistedDependencyFinding => unlisted_dependencies;
205 DuplicateExportFinding => duplicate_exports;
206 TypeOnlyDependencyFinding => type_only_dependencies;
207 TestOnlyDependencyFinding => test_only_dependencies;
208 DevDependencyInProductionFinding => dev_dependencies_in_production;
209 ReExportCycleFinding => re_export_cycle;
210 UnusedCatalogEntryFinding => unused_catalog_entries;
211}
212
213impl RuleSeverity for CircularDependencyFinding {
214 fn rule_severity(&self, source: &SeveritySource<'_>) -> Severity {
215 self.cycle
216 .files
217 .iter()
218 .map(|path| source.for_path(path, |rules| rules.circular_dependencies))
219 .max_by_key(|severity| severity_rank(*severity))
220 .unwrap_or_else(|| source.project(|rules| rules.circular_dependencies))
221 }
222
223 fn uniform_severity(source: &SeveritySource<'_>) -> Option<Severity> {
224 source.uniform(|rules| rules.circular_dependencies)
225 }
226}
227
228impl RuleSeverity for PackageCycleFinding {
229 fn rule_severity(&self, source: &SeveritySource<'_>) -> Severity {
230 self.cycle
231 .edges
232 .iter()
233 .map(|edge| source.for_path(&edge.path, |rules| rules.package_cycle))
234 .max_by_key(|severity| severity_rank(*severity))
235 .unwrap_or_else(|| source.project(|rules| rules.package_cycle))
236 }
237
238 fn uniform_severity(source: &SeveritySource<'_>) -> Option<Severity> {
239 source.uniform(|rules| rules.package_cycle)
240 }
241}
242
243impl RuleSeverity for StaleSuppression {
244 fn rule_severity(&self, source: &SeveritySource<'_>) -> Severity {
245 if self.missing_reason {
246 source.for_path(&self.path, |rules| rules.require_suppression_reason)
247 } else {
248 source.for_path(&self.path, |rules| rules.stale_suppressions)
249 }
250 }
251
252 fn uniform_severity(source: &SeveritySource<'_>) -> Option<Severity> {
253 let stale = source.uniform(|rules| rules.stale_suppressions)?;
254 let missing_reason = source.uniform(|rules| rules.require_suppression_reason)?;
255 (stale == missing_reason).then_some(stale)
256 }
257}
258
259impl RuleSeverity for PolicyViolationFinding {
260 fn rule_severity(&self, _source: &SeveritySource<'_>) -> Severity {
261 match self.violation.severity {
262 PolicyViolationSeverity::Error => Severity::Error,
263 PolicyViolationSeverity::Warn => Severity::Warn,
264 }
265 }
266}
267
268const fn severity_rank(severity: Severity) -> u8 {
269 match severity {
270 Severity::Off => 0,
271 Severity::Warn => 1,
272 Severity::Error => 2,
273 }
274}
275
276trait GatedRuleFinding: GatedFinding + RuleSeverity {}
278
279impl<T: GatedFinding + RuleSeverity> GatedRuleFinding for T {}
280
281pub fn apply_effective_severities(results: &mut AnalysisResults, config: &ResolvedConfig) {
287 let source = SeveritySource::from_config(config);
288 for_each_gated_finding(results, &mut |finding| {
289 let severity = finding.rule_severity(&source);
290 finding.set_effective_severity(gate(severity));
291 });
292 apply_non_gating_severities(results, &config.rules);
293}
294
295fn apply_non_gating_severities(results: &mut AnalysisResults, rules: &RulesConfig) {
302 set_all(&mut results.prop_drilling_chains, rules.prop_drilling);
303 set_all(&mut results.thin_wrappers, rules.thin_wrapper);
304 set_all(
305 &mut results.duplicate_prop_shapes,
306 rules.duplicate_prop_shape,
307 );
308}
309
310fn set_all<T: GatedFinding>(findings: &mut [T], rule: Severity) {
311 for finding in findings {
312 finding.set_effective_severity(gate(rule));
313 }
314}
315
316#[must_use]
325pub fn findings_without_severity(mut results: AnalysisResults) -> usize {
326 let mut missing = 0;
327 for_each_gated_finding(&mut results, &mut |finding| {
328 if finding.effective_severity().is_none() {
329 missing += 1;
330 }
331 });
332 missing
333}
334
335pub fn promote_effective_warns(results: &mut AnalysisResults) {
340 for_each_gated_finding(results, &mut |finding| {
341 if finding.effective_severity() == Some(EffectiveSeverity::Warn) {
342 finding.set_effective_severity(Some(EffectiveSeverity::Error));
343 }
344 });
345}
346
347#[must_use]
351pub fn any_finding_with_severity(
352 results: &AnalysisResults,
353 source: &SeveritySource<'_>,
354 severity: Severity,
355) -> bool {
356 count_findings_up_to(results, source, severity, 1) > 0
357}
358
359#[must_use]
364pub fn count_findings_with_severity(
365 results: &AnalysisResults,
366 source: &SeveritySource<'_>,
367 severity: Severity,
368) -> usize {
369 count_findings_up_to(results, source, severity, usize::MAX)
370}
371
372fn count_findings_up_to(
374 results: &AnalysisResults,
375 source: &SeveritySource<'_>,
376 severity: Severity,
377 limit: usize,
378) -> usize {
379 let mut tally = SeverityTally {
380 source,
381 severity,
382 limit,
383 total: 0,
384 };
385 if !tally.add(&results.policy_violations) {
386 tally_gated_findings(results, &mut tally);
387 }
388 tally.total
389}
390
391struct SeverityTally<'s, 'a> {
394 source: &'s SeveritySource<'a>,
395 severity: Severity,
396 limit: usize,
397 total: usize,
398}
399
400impl SeverityTally<'_, '_> {
401 fn add<T: RuleSeverity>(&mut self, findings: &[T]) -> bool {
407 let remaining = self.limit - self.total;
408 let found = if findings.is_empty() || remaining == 0 {
409 0
410 } else {
411 match T::uniform_severity(self.source) {
412 Some(uniform) if uniform == self.severity => findings.len().min(remaining),
413 Some(_) => 0,
414 None => findings
415 .iter()
416 .filter(|finding| finding.rule_severity(self.source) == self.severity)
417 .take(remaining)
418 .count(),
419 }
420 };
421 self.total += found;
422 self.total >= self.limit
423 }
424}
425
426fn visit<T: GatedRuleFinding>(findings: &mut [T], f: &mut dyn FnMut(&mut dyn GatedRuleFinding)) {
427 for finding in findings {
428 f(finding);
429 }
430}
431
432#[expect(
437 clippy::too_many_lines,
438 reason = "one exhaustive list of finding collections; splitting it would lose the compile-time guard"
439)]
440fn for_each_gated_finding(
441 results: &mut AnalysisResults,
442 f: &mut dyn FnMut(&mut dyn GatedRuleFinding),
443) {
444 let AnalysisResults {
445 unused_files,
446 unused_exports,
447 unused_types,
448 private_type_leaks,
449 deprecated_exports_in_use,
450 unused_dependencies,
451 unused_dev_dependencies,
452 unused_optional_dependencies,
453 unused_enum_members,
454 unused_class_members,
455 unused_store_members,
456 unresolved_imports,
457 unlisted_dependencies,
458 duplicate_exports,
459 type_only_dependencies,
460 test_only_dependencies,
461 dev_dependencies_in_production,
462 circular_dependencies,
463 re_export_cycles,
464 package_cycles,
465 boundary_violations,
466 boundary_coverage_violations,
467 boundary_call_violations,
468 stale_suppressions,
469 unused_catalog_entries,
470 empty_catalog_groups,
471 unresolved_catalog_references,
472 unused_dependency_overrides,
473 misconfigured_dependency_overrides,
474 invalid_client_exports,
475 mixed_client_server_barrels,
476 misplaced_directives,
477 unprovided_injects,
478 unrendered_components,
479 route_collisions,
480 dynamic_segment_name_conflicts,
481 unused_component_props,
482 absent_component_props,
483 unused_component_emits,
484 unused_component_inputs,
485 unused_component_outputs,
486 unused_svelte_events,
487 unused_server_actions,
488 unused_load_data_keys,
489 policy_violations: _,
491 prop_drilling_chains: _,
493 thin_wrappers: _,
494 duplicate_prop_shapes: _,
495 unused_load_data_keys_global_abstain: _,
497 suppression_count: _,
498 unused_component_props_exempted: _,
499 active_suppressions: _,
500 feature_flags: _,
501 export_usages: _,
502 entry_point_summary: _,
503 render_fan_in: _,
504 react_component_intel: _,
505 semantic_framework_contracts: _,
506 security_findings: _,
508 security_unresolved_edge_files: _,
509 security_unresolved_callee_sites: _,
510 security_unresolved_callee_diagnostics: _,
511 } = results;
512 visit(unused_files, f);
513 visit(unused_exports, f);
514 visit(unused_types, f);
515 visit(private_type_leaks, f);
516 visit(deprecated_exports_in_use, f);
517 visit(unused_dependencies, f);
518 visit(unused_dev_dependencies, f);
519 visit(unused_optional_dependencies, f);
520 visit(unused_enum_members, f);
521 visit(unused_class_members, f);
522 visit(unused_store_members, f);
523 visit(unresolved_imports, f);
524 visit(unlisted_dependencies, f);
525 visit(duplicate_exports, f);
526 visit(type_only_dependencies, f);
527 visit(test_only_dependencies, f);
528 visit(dev_dependencies_in_production, f);
529 visit(circular_dependencies, f);
530 visit(re_export_cycles, f);
531 visit(package_cycles, f);
532 visit(boundary_violations, f);
533 visit(boundary_coverage_violations, f);
534 visit(boundary_call_violations, f);
535 visit(stale_suppressions, f);
536 visit(unused_catalog_entries, f);
537 visit(empty_catalog_groups, f);
538 visit(unresolved_catalog_references, f);
539 visit(unused_dependency_overrides, f);
540 visit(misconfigured_dependency_overrides, f);
541 visit(invalid_client_exports, f);
542 visit(mixed_client_server_barrels, f);
543 visit(misplaced_directives, f);
544 visit(unprovided_injects, f);
545 visit(unrendered_components, f);
546 visit(route_collisions, f);
547 visit(dynamic_segment_name_conflicts, f);
548 visit(unused_component_props, f);
549 visit(absent_component_props, f);
550 visit(unused_component_emits, f);
551 visit(unused_component_inputs, f);
552 visit(unused_component_outputs, f);
553 visit(unused_svelte_events, f);
554 visit(unused_server_actions, f);
555 visit(unused_load_data_keys, f);
556}
557
558#[expect(
564 clippy::too_many_lines,
565 reason = "one exhaustive list of finding collections; splitting it would lose the compile-time guard"
566)]
567fn tally_gated_findings(results: &AnalysisResults, tally: &mut SeverityTally<'_, '_>) {
568 let AnalysisResults {
569 unused_files,
570 unused_exports,
571 unused_types,
572 private_type_leaks,
573 deprecated_exports_in_use,
574 unused_dependencies,
575 unused_dev_dependencies,
576 unused_optional_dependencies,
577 unused_enum_members,
578 unused_class_members,
579 unused_store_members,
580 unresolved_imports,
581 unlisted_dependencies,
582 duplicate_exports,
583 type_only_dependencies,
584 test_only_dependencies,
585 dev_dependencies_in_production,
586 circular_dependencies,
587 re_export_cycles,
588 package_cycles,
589 boundary_violations,
590 boundary_coverage_violations,
591 boundary_call_violations,
592 stale_suppressions,
593 unused_catalog_entries,
594 empty_catalog_groups,
595 unresolved_catalog_references,
596 unused_dependency_overrides,
597 misconfigured_dependency_overrides,
598 invalid_client_exports,
599 mixed_client_server_barrels,
600 misplaced_directives,
601 unprovided_injects,
602 unrendered_components,
603 route_collisions,
604 dynamic_segment_name_conflicts,
605 unused_component_props,
606 absent_component_props,
607 unused_component_emits,
608 unused_component_inputs,
609 unused_component_outputs,
610 unused_svelte_events,
611 unused_server_actions,
612 unused_load_data_keys,
613 policy_violations: _,
614 prop_drilling_chains: _,
615 thin_wrappers: _,
616 duplicate_prop_shapes: _,
617 unused_load_data_keys_global_abstain: _,
618 suppression_count: _,
619 unused_component_props_exempted: _,
620 active_suppressions: _,
621 feature_flags: _,
622 export_usages: _,
623 entry_point_summary: _,
624 render_fan_in: _,
625 react_component_intel: _,
626 semantic_framework_contracts: _,
627 security_findings: _,
628 security_unresolved_edge_files: _,
629 security_unresolved_callee_sites: _,
630 security_unresolved_callee_diagnostics: _,
631 } = results;
632 let _reached_limit = tally.add(unused_files)
633 || tally.add(unused_exports)
634 || tally.add(unused_types)
635 || tally.add(private_type_leaks)
636 || tally.add(deprecated_exports_in_use)
637 || tally.add(unused_dependencies)
638 || tally.add(unused_dev_dependencies)
639 || tally.add(unused_optional_dependencies)
640 || tally.add(unused_enum_members)
641 || tally.add(unused_class_members)
642 || tally.add(unused_store_members)
643 || tally.add(unresolved_imports)
644 || tally.add(unlisted_dependencies)
645 || tally.add(duplicate_exports)
646 || tally.add(type_only_dependencies)
647 || tally.add(test_only_dependencies)
648 || tally.add(dev_dependencies_in_production)
649 || tally.add(circular_dependencies)
650 || tally.add(re_export_cycles)
651 || tally.add(package_cycles)
652 || tally.add(boundary_violations)
653 || tally.add(boundary_coverage_violations)
654 || tally.add(boundary_call_violations)
655 || tally.add(stale_suppressions)
656 || tally.add(unused_catalog_entries)
657 || tally.add(empty_catalog_groups)
658 || tally.add(unresolved_catalog_references)
659 || tally.add(unused_dependency_overrides)
660 || tally.add(misconfigured_dependency_overrides)
661 || tally.add(invalid_client_exports)
662 || tally.add(mixed_client_server_barrels)
663 || tally.add(misplaced_directives)
664 || tally.add(unprovided_injects)
665 || tally.add(unrendered_components)
666 || tally.add(route_collisions)
667 || tally.add(dynamic_segment_name_conflicts)
668 || tally.add(unused_component_props)
669 || tally.add(absent_component_props)
670 || tally.add(unused_component_emits)
671 || tally.add(unused_component_inputs)
672 || tally.add(unused_component_outputs)
673 || tally.add(unused_svelte_events)
674 || tally.add(unused_server_actions)
675 || tally.add(unused_load_data_keys);
676}
677
678#[cfg(test)]
679mod tests {
680 use std::path::PathBuf;
681
682 use fallow_types::output_dead_code::{
683 CircularDependencyFinding, MisconfiguredDependencyOverrideFinding, UnusedDependencyFinding,
684 UnusedExportFinding,
685 };
686 use fallow_types::results::StaleSuppression;
687 use serde_json::json;
688
689 use super::*;
690
691 const ROOT: &str = "/project";
692
693 fn config(json: &str) -> ResolvedConfig {
694 serde_json::from_str::<fallow_config::FallowConfig>(json)
695 .expect("config parses")
696 .resolve(
697 PathBuf::from(ROOT),
698 fallow_config::OutputFormat::Human,
699 1,
700 true,
701 true,
702 None,
703 )
704 }
705
706 fn legacy_warn_config() -> ResolvedConfig {
707 config(
708 r#"{
709 "rules": {
710 "unused-exports": "error",
711 "circular-dependencies": "error",
712 "unused-dependencies": "error",
713 "stale-suppressions": "warn",
714 "require-suppression-reason": "error"
715 },
716 "overrides": [{
717 "files": ["src/legacy/**", "package.json"],
718 "rules": {
719 "unused-exports": "warn",
720 "circular-dependencies": "warn",
721 "unused-dependencies": "warn",
722 "require-suppression-reason": "warn"
723 }
724 }]
725 }"#,
726 )
727 }
728
729 fn export(path: &str) -> UnusedExportFinding {
730 serde_json::from_value(json!({
731 "path": format!("{ROOT}/{path}"),
732 "export_name": "unused",
733 "is_type_only": false,
734 "line": 1,
735 "col": 0,
736 "span_start": 0,
737 "is_re_export": false,
738 "actions": [],
739 }))
740 .expect("export finding")
741 }
742
743 fn cycle(files: &[&str]) -> CircularDependencyFinding {
744 serde_json::from_value(json!({
745 "files": files.iter().map(|file| format!("{ROOT}/{file}")).collect::<Vec<_>>(),
746 "length": files.len(),
747 "line": 1,
748 "col": 0,
749 "actions": [],
750 }))
751 .expect("cycle finding")
752 }
753
754 fn stale(path: &str, missing_reason: bool) -> StaleSuppression {
755 serde_json::from_value(json!({
756 "path": format!("{ROOT}/{path}"),
757 "line": 1,
758 "col": 0,
759 "origin": { "type": "comment", "is_file_level": false },
760 "missing_reason": missing_reason,
761 "actions": [],
762 }))
763 .expect("stale suppression")
764 }
765
766 #[test]
767 fn file_scoped_findings_follow_the_override_for_their_path() {
768 let mut results = AnalysisResults::default();
769 results.unused_exports.push(export("src/app.ts"));
770 results.unused_exports.push(export("src/legacy/old.ts"));
771
772 apply_effective_severities(&mut results, &legacy_warn_config());
773
774 let severities: Vec<_> = results
775 .unused_exports
776 .iter()
777 .map(|finding| finding.effective_severity)
778 .collect();
779 assert_eq!(
780 severities,
781 vec![
782 Some(EffectiveSeverity::Error),
783 Some(EffectiveSeverity::Warn)
784 ]
785 );
786 }
787
788 #[test]
789 fn a_cycle_is_error_when_any_file_in_it_resolves_to_error() {
790 let mut results = AnalysisResults::default();
791 results
792 .circular_dependencies
793 .push(cycle(&["src/legacy/a.ts", "src/b.ts"]));
794 results
795 .circular_dependencies
796 .push(cycle(&["src/legacy/a.ts", "src/legacy/b.ts"]));
797
798 apply_effective_severities(&mut results, &legacy_warn_config());
799
800 assert_eq!(
801 results.circular_dependencies[0].effective_severity,
802 Some(EffectiveSeverity::Error)
803 );
804 assert_eq!(
805 results.circular_dependencies[1].effective_severity,
806 Some(EffectiveSeverity::Warn)
807 );
808 }
809
810 #[test]
811 fn project_level_findings_use_the_base_rules() {
812 let mut results = AnalysisResults::default();
813 results.unused_dependencies.push(
814 serde_json::from_value::<UnusedDependencyFinding>(json!({
815 "package_name": "left-pad",
816 "location": "dependencies",
817 "path": format!("{ROOT}/package.json"),
818 "line": 3,
819 "actions": [],
820 }))
821 .expect("dependency finding"),
822 );
823
824 apply_effective_severities(&mut results, &legacy_warn_config());
825
826 assert_eq!(
827 results.unused_dependencies[0].effective_severity,
828 Some(EffectiveSeverity::Error)
829 );
830 }
831
832 #[test]
833 fn a_dependency_override_follows_the_override_for_its_file() {
834 let config = config(
835 r#"{
836 "rules": { "misconfigured-dependency-overrides": "error" },
837 "overrides": [{
838 "files": ["pnpm-workspace.yaml"],
839 "rules": { "misconfigured-dependency-overrides": "warn" }
840 }]
841 }"#,
842 );
843 let mut results = AnalysisResults::default();
844 for file in ["pnpm-workspace.yaml", "package.json"] {
845 results.misconfigured_dependency_overrides.push(
846 serde_json::from_value::<MisconfiguredDependencyOverrideFinding>(json!({
847 "raw_key": "",
848 "raw_value": "^1.0.0",
849 "reason": "empty-value",
850 "source": file,
851 "path": format!("{ROOT}/{file}"),
852 "line": 2,
853 "actions": [],
854 }))
855 .expect("override finding"),
856 );
857 }
858
859 apply_effective_severities(&mut results, &config);
860
861 let severities: Vec<_> = results
862 .misconfigured_dependency_overrides
863 .iter()
864 .map(|finding| finding.effective_severity)
865 .collect();
866 assert_eq!(
867 severities,
868 vec![
869 Some(EffectiveSeverity::Warn),
870 Some(EffectiveSeverity::Error)
871 ]
872 );
873 }
874
875 #[test]
876 fn a_stale_suppression_reads_the_rule_for_its_kind() {
877 let mut results = AnalysisResults::default();
878 results.stale_suppressions.push(stale("src/app.ts", false));
879 results.stale_suppressions.push(stale("src/app.ts", true));
880 results
881 .stale_suppressions
882 .push(stale("src/legacy/old.ts", true));
883
884 apply_effective_severities(&mut results, &legacy_warn_config());
885
886 let severities: Vec<_> = results
887 .stale_suppressions
888 .iter()
889 .map(|finding| finding.effective_severity)
890 .collect();
891 assert_eq!(
892 severities,
893 vec![
894 Some(EffectiveSeverity::Warn),
895 Some(EffectiveSeverity::Error),
896 Some(EffectiveSeverity::Warn),
897 ]
898 );
899 }
900
901 #[test]
902 fn fail_on_issues_promotion_raises_warn_and_keeps_error() {
903 let mut results = AnalysisResults::default();
904 results.unused_exports.push(export("src/app.ts"));
905 results.unused_exports.push(export("src/legacy/old.ts"));
906 apply_effective_severities(&mut results, &legacy_warn_config());
907
908 promote_effective_warns(&mut results);
909
910 assert!(
911 results
912 .unused_exports
913 .iter()
914 .all(|finding| finding.effective_severity == Some(EffectiveSeverity::Error))
915 );
916 }
917
918 #[test]
922 fn a_cycle_whose_files_all_resolve_to_off_gets_no_severity() {
923 let config = config(
924 r#"{
925 "rules": { "circular-dependencies": "error" },
926 "overrides": [{
927 "files": ["src/legacy/**"],
928 "rules": { "circular-dependencies": "off" }
929 }]
930 }"#,
931 );
932 let mut results = AnalysisResults::default();
933 results
934 .circular_dependencies
935 .push(cycle(&["src/legacy/a.ts", "src/legacy/b.ts"]));
936
937 apply_effective_severities(&mut results, &config);
938
939 assert_eq!(results.circular_dependencies[0].effective_severity, None);
940 assert!(!crate::error_severity::has_error_severity_issues(
941 &results,
942 &config.rules,
943 Some(&config),
944 false
945 ));
946 assert_eq!(
948 results.circular_dependencies[0].rule_severity(&SeveritySource::from_config(&config)),
949 Severity::Off
950 );
951 }
952
953 #[test]
954 fn the_collection_check_agrees_with_the_per_finding_check_without_overrides() {
955 let configs = [
956 r#"{ "rules": { "unused-exports": "error", "circular-dependencies": "warn",
957 "unused-dependencies": "off", "stale-suppressions": "warn",
958 "require-suppression-reason": "warn" } }"#,
959 r#"{ "rules": { "unused-exports": "warn", "circular-dependencies": "error",
960 "unused-dependencies": "error", "stale-suppressions": "warn",
961 "require-suppression-reason": "error" } }"#,
962 r#"{ "rules": { "unused-exports": "off", "circular-dependencies": "off",
963 "unused-dependencies": "warn", "stale-suppressions": "error",
964 "require-suppression-reason": "off" } }"#,
965 ];
966 let mut results = AnalysisResults::default();
967 results.unused_exports.push(export("src/app.ts"));
968 results
969 .circular_dependencies
970 .push(cycle(&["src/a.ts", "src/b.ts"]));
971 results.circular_dependencies.push(cycle(&[]));
972 results.unused_dependencies.push(
973 serde_json::from_value::<UnusedDependencyFinding>(json!({
974 "package_name": "left-pad",
975 "location": "dependencies",
976 "path": format!("{ROOT}/package.json"),
977 "line": 3,
978 "actions": [],
979 }))
980 .expect("dependency finding"),
981 );
982 results.stale_suppressions.push(stale("src/app.ts", false));
983 results.stale_suppressions.push(stale("src/app.ts", true));
984
985 for json in configs {
986 let config = config(json);
987 for promote in [false, true] {
988 let mut rules = config.rules.clone();
989 if promote {
990 promote_warns_to_errors(&mut rules);
991 }
992 let source = SeveritySource::new(&rules, Some(&config), promote);
993 assert!(source.overrides.is_none());
994 for severity in [Severity::Error, Severity::Warn, Severity::Off] {
995 let mut per_finding = 0_usize;
996 for_each_gated_finding(&mut results, &mut |finding| {
997 per_finding += usize::from(finding.rule_severity(&source) == severity);
998 });
999 assert_eq!(
1000 count_findings_with_severity(&results, &source, severity),
1001 per_finding,
1002 "{json} promote={promote} {severity:?}"
1003 );
1004 assert_eq!(
1005 any_finding_with_severity(&results, &source, severity),
1006 per_finding > 0,
1007 "{json} promote={promote} {severity:?}"
1008 );
1009 }
1010 }
1011 }
1012 }
1013
1014 type AddFinding = fn(&mut AnalysisResults);
1015
1016 #[test]
1017 fn the_exit_code_rule_fails_exactly_when_a_finding_is_stamped_error() {
1018 let config = legacy_warn_config();
1019 let cases: [(&str, AddFinding); 4] = [
1020 ("legacy export", |r| {
1021 r.unused_exports.push(export("src/legacy/old.ts"));
1022 }),
1023 ("app export", |r| {
1024 r.unused_exports.push(export("src/app.ts"));
1025 }),
1026 ("legacy cycle", |r| {
1027 r.circular_dependencies
1028 .push(cycle(&["src/legacy/a.ts", "src/legacy/b.ts"]));
1029 }),
1030 ("stale suppression", |r| {
1031 r.stale_suppressions.push(stale("src/app.ts", false));
1032 }),
1033 ];
1034 for (name, add) in cases {
1035 let mut results = AnalysisResults::default();
1036 add(&mut results);
1037 apply_effective_severities(&mut results, &config);
1038 let mut stamped_error = false;
1039 for_each_gated_finding(&mut results, &mut |finding| {
1040 stamped_error |= finding.effective_severity() == Some(EffectiveSeverity::Error);
1041 });
1042 assert_eq!(
1043 crate::error_severity::has_error_severity_issues(
1044 &results,
1045 &config.rules,
1046 Some(&config),
1047 false
1048 ),
1049 stamped_error,
1050 "{name}"
1051 );
1052 }
1053 }
1054}