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