1use std::collections::hash_map::DefaultHasher;
2use std::hash::{Hash, Hasher};
3use std::path::{Path, PathBuf};
4use std::sync::{Mutex, OnceLock};
5
6use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder};
7use rustc_hash::FxHashSet;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use super::boundaries::ResolvedBoundaryConfig;
12use super::duplicates_config::DuplicatesConfig;
13use super::flags::FlagsConfig;
14use super::format::OutputFormat;
15use super::health::HealthConfig;
16use super::resolve::ResolveConfig;
17use super::rules::{PartialRulesConfig, RulesConfig, Severity};
18use super::used_class_members::UsedClassMemberRule;
19use crate::external_plugin::{ExternalPluginDef, discover_external_plugins};
20
21use super::{
22 BoundaryConfig, FallowConfig, FindingIgnoreMatcher, IgnoreExportsUsedInFileConfig,
23 ProductionConfig, SecurityConfig, TypeAwareConfig,
24};
25
26static INTER_FILE_WARN_SEEN: OnceLock<Mutex<FxHashSet<u64>>> = OnceLock::new();
28
29fn inter_file_warn_key(rule_name: &str, files: &[String]) -> u64 {
31 let mut sorted: Vec<&str> = files.iter().map(String::as_str).collect();
32 sorted.sort_unstable();
33 let mut hasher = DefaultHasher::new();
34 rule_name.hash(&mut hasher);
35 for s in &sorted {
36 s.hash(&mut hasher);
37 }
38 hasher.finish()
39}
40
41fn record_inter_file_warn_seen(rule_name: &str, files: &[String]) -> bool {
43 let seen = INTER_FILE_WARN_SEEN.get_or_init(|| Mutex::new(FxHashSet::default()));
44 let key = inter_file_warn_key(rule_name, files);
45 seen.lock().map_or(true, |mut set| set.insert(key))
46}
47
48#[cfg(test)]
49fn reset_inter_file_warn_dedup_for_test() {
50 if let Some(seen) = INTER_FILE_WARN_SEEN.get()
51 && let Ok(mut set) = seen.lock()
52 {
53 set.clear();
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
59#[serde(deny_unknown_fields)]
60pub struct IgnoreExportRule {
61 pub file: String,
63 pub exports: Vec<String>,
65}
66
67#[derive(Debug, Clone)]
69pub struct CompiledIgnoreExportRule {
70 pub matcher: globset::GlobMatcher,
72 pub exports: Vec<String>,
74}
75
76#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
78#[serde(deny_unknown_fields)]
79pub struct IgnoreCatalogReferenceRule {
80 pub package: String,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub catalog: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub consumer: Option<String>,
88}
89
90#[derive(Debug, Clone)]
92pub struct CompiledIgnoreCatalogReferenceRule {
93 pub package: String,
95 pub catalog: Option<String>,
97 pub consumer_matcher: Option<globset::GlobMatcher>,
100}
101
102impl CompiledIgnoreCatalogReferenceRule {
103 #[must_use]
105 pub fn matches(&self, package: &str, catalog: &str, consumer_path: &str) -> bool {
106 if self.package != package {
107 return false;
108 }
109 if let Some(catalog_filter) = &self.catalog
110 && catalog_filter != catalog
111 {
112 return false;
113 }
114 if let Some(matcher) = &self.consumer_matcher
115 && !matcher.is_match(consumer_path)
116 {
117 return false;
118 }
119 true
120 }
121}
122
123#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
125#[serde(deny_unknown_fields)]
126pub struct IgnoreDependencyOverrideRule {
127 pub package: String,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub source: Option<String>,
132}
133
134#[derive(Debug, Clone)]
136pub struct CompiledIgnoreDependencyOverrideRule {
137 pub package: String,
139 pub source: Option<String>,
142}
143
144impl CompiledIgnoreDependencyOverrideRule {
145 #[must_use]
147 pub fn matches(&self, package: &str, source_label: &str) -> bool {
148 if self.package != package {
149 return false;
150 }
151 if let Some(source_filter) = &self.source
152 && source_filter != source_label
153 {
154 return false;
155 }
156 true
157 }
158}
159
160#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
162#[serde(rename_all = "camelCase", deny_unknown_fields)]
163pub struct ConfigOverride {
164 pub files: Vec<String>,
166 #[serde(default)]
168 pub rules: PartialRulesConfig,
169}
170
171#[derive(Debug, Clone)]
173pub struct ResolvedOverride {
174 pub matchers: Vec<globset::GlobMatcher>,
177 pub rules: PartialRulesConfig,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
188pub enum AnalysisSnapshot {
189 #[default]
191 Current,
192 Base,
194}
195
196impl AnalysisSnapshot {
197 #[must_use]
199 pub const fn is_base(self) -> bool {
200 matches!(self, Self::Base)
201 }
202}
203
204#[derive(Debug, Clone)]
206pub struct ResolvedConfig {
207 pub root: PathBuf,
209 pub entry_patterns: Vec<String>,
212 pub ignore_patterns: GlobSet,
216 pub ignore_findings: FindingIgnoreMatcher,
219 pub output: OutputFormat,
221 pub cache_dir: PathBuf,
224 pub threads: usize,
226 pub no_cache: bool,
228 pub cache_max_size_mb: Option<u32>,
231 pub cache_config_hash: u64,
235 pub ignore_dependencies: Vec<String>,
238 pub ignore_unresolved_imports: Vec<GlobMatcher>,
241 pub ignore_export_rules: Vec<IgnoreExportRule>,
245 pub compiled_ignore_exports: Vec<CompiledIgnoreExportRule>,
247 pub compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
249 pub compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
251 pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
253 pub used_class_members: Vec<UsedClassMemberRule>,
256 pub ignore_decorators: Vec<String>,
259 pub unused_component_props_ignore: Option<regex::Regex>,
264 pub duplicates: DuplicatesConfig,
266 pub health: HealthConfig,
268 pub type_aware: TypeAwareConfig,
270 pub rules: RulesConfig,
274 pub boundaries: ResolvedBoundaryConfig,
277 pub rule_packs: Vec<crate::rule_pack::RulePackDef>,
282 pub rule_pack_sources: Vec<PathBuf>,
285 pub production: bool,
290 pub quiet: bool,
293 pub external_plugins: Vec<ExternalPluginDef>,
297 pub dynamically_loaded: Vec<String>,
300 pub overrides: Vec<ResolvedOverride>,
302 pub regression: Option<super::RegressionConfig>,
304 pub audit: super::AuditConfig,
306 pub codeowners: Option<String>,
309 pub public_packages: Vec<String>,
313 pub flags: FlagsConfig,
315 pub security: SecurityConfig,
318 pub fix: super::FixConfig,
320 pub resolve: ResolveConfig,
323 pub include_entry_exports: bool,
326 pub auto_imports: bool,
330 pub max_file_size_bytes: Option<u64>,
339 pub analysis_snapshot: AnalysisSnapshot,
344}
345
346pub const DEFAULT_MAX_FILE_SIZE_MB: u32 = 5;
350
351pub const DEFAULT_MAX_FILE_SIZE_BYTES: u64 = DEFAULT_MAX_FILE_SIZE_MB as u64 * 1024 * 1024;
353
354#[must_use]
359pub fn resolve_max_file_size_bytes(max_file_size_mb: Option<u32>) -> Option<u64> {
360 match max_file_size_mb {
361 None => Some(DEFAULT_MAX_FILE_SIZE_BYTES),
362 Some(0) => None,
363 Some(mb) => Some(u64::from(mb) * 1024 * 1024),
364 }
365}
366
367fn compute_cache_config_hash(external_plugins: &[ExternalPluginDef]) -> u64 {
369 let mut names: Vec<&str> = external_plugins.iter().map(|p| p.name.as_str()).collect();
370 names.sort_unstable();
371 let mut hasher = xxhash_rust::xxh3::Xxh3::new();
372 for name in names {
373 hasher.update(&(name.len() as u32).to_le_bytes());
374 hasher.update(name.as_bytes());
375 }
376 hasher.digest()
377}
378
379fn resolve_cache_dir(root: &Path, configured: Option<PathBuf>) -> PathBuf {
380 let Some(dir) = configured else {
381 return root.join(".fallow");
382 };
383 if dir.is_absolute() {
384 dir
385 } else {
386 root.join(dir)
387 }
388}
389
390fn normalize_user_glob_pattern(pattern: &str) -> &str {
391 pattern.strip_prefix("./").unwrap_or(pattern)
392}
393
394#[expect(
395 clippy::expect_used,
396 reason = "user glob patterns are validated before config resolution"
397)]
398fn compile_ignore_patterns(ignore_patterns: &[String]) -> GlobSet {
399 let mut ignore_builder = GlobSetBuilder::new();
400 for pattern in ignore_patterns {
401 let normalized = normalize_user_glob_pattern(pattern);
402 ignore_builder.add(
403 Glob::new(normalized).expect("ignorePatterns entry was validated at config load time"),
404 );
405 }
406
407 let default_ignores = [
408 "**/node_modules/**",
409 "**/dist/**",
410 "build/**",
411 "**/.git/**",
412 "**/coverage/**",
413 "**/*.min.js",
414 "**/*.min.mjs",
415 "**/*.min.cjs",
416 "**/*.bundle.js",
417 ];
418 for pattern in &default_ignores {
419 ignore_builder.add(Glob::new(pattern).expect("default ignore pattern is valid"));
420 }
421
422 ignore_builder.build().unwrap_or_default()
423}
424
425#[expect(
426 clippy::expect_used,
427 reason = "user glob patterns are validated before config resolution"
428)]
429fn compile_ignore_unresolved_imports(patterns: &[String]) -> Vec<GlobMatcher> {
430 patterns
431 .iter()
432 .map(|pattern| {
433 let normalized = normalize_user_glob_pattern(pattern);
434 Glob::new(normalized)
435 .expect("ignoreUnresolvedImports entry was validated at config load time")
436 .compile_matcher()
437 })
438 .collect()
439}
440
441fn resolve_rules_for_production(mut rules: RulesConfig, production: bool) -> RulesConfig {
442 if production {
443 rules.unused_dev_dependencies = Severity::Off;
444 rules.unused_optional_dependencies = Severity::Off;
445 }
446 rules
447}
448
449fn resolve_boundaries(
450 mut boundaries: super::boundaries::BoundaryConfig,
451 root: &Path,
452) -> ResolvedBoundaryConfig {
453 if boundaries.preset.is_some() {
454 let source_root = crate::workspace::parse_tsconfig_root_dir(root)
455 .filter(|r| r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute())
456 .unwrap_or_else(|| "src".to_owned());
457 if source_root != "src" {
458 tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
459 }
460 boundaries.expand(&source_root);
461 }
462 let logical_groups = boundaries.expand_auto_discover(root);
463 let mut resolved = boundaries.resolve();
464 resolved.logical_groups = logical_groups;
465 resolved
466}
467
468fn warn_inter_file_overrides(rules: &PartialRulesConfig, files: &[String]) {
469 if rules.duplicate_exports.is_some() && record_inter_file_warn_seen("duplicate-exports", files)
470 {
471 let files = files.join(", ");
472 tracing::warn!(
473 "overrides.rules.duplicate-exports has no effect for files matching [{files}]: duplicate-exports is an inter-file rule. Use top-level `ignoreExports` to exclude these files from duplicate-export grouping."
474 );
475 }
476 if rules.circular_dependencies.is_some()
477 && record_inter_file_warn_seen("circular-dependency", files)
478 {
479 let files = files.join(", ");
480 tracing::warn!(
481 "overrides.rules.circular-dependency has no effect for files matching [{files}]: circular-dependency is an inter-file rule. Use a file-level `// fallow-ignore-file circular-dependency` comment in one participating file instead."
482 );
483 }
484 if rules.re_export_cycle.is_some() && record_inter_file_warn_seen("re-export-cycle", files) {
485 let files = files.join(", ");
486 tracing::warn!(
487 "overrides.rules.re-export-cycle has no effect for files matching [{files}]: re-export-cycle is an inter-file rule (the cycle spans multiple barrels). Use a file-level `// fallow-ignore-file re-export-cycle` comment in one participating file instead, or set `rules.re-export-cycle: off` at the top level."
488 );
489 }
490}
491
492#[expect(
493 clippy::expect_used,
494 reason = "override glob patterns are validated before config resolution"
495)]
496fn compile_overrides(overrides: Vec<ConfigOverride>) -> Vec<ResolvedOverride> {
497 overrides
498 .into_iter()
499 .filter_map(|override_entry| {
500 warn_inter_file_overrides(&override_entry.rules, &override_entry.files);
501 let matchers: Vec<globset::GlobMatcher> = override_entry
502 .files
503 .iter()
504 .map(|pattern| {
505 Glob::new(pattern)
506 .expect("overrides[].files pattern was validated at config load time")
507 .compile_matcher()
508 })
509 .collect();
510 if matchers.is_empty() {
511 None
512 } else {
513 Some(ResolvedOverride {
514 matchers,
515 rules: override_entry.rules,
516 })
517 }
518 })
519 .collect()
520}
521
522#[expect(
524 clippy::expect_used,
525 reason = "user glob patterns are validated before config resolution"
526)]
527fn compile_ignore_export_rules(rules: &[IgnoreExportRule]) -> Vec<CompiledIgnoreExportRule> {
528 rules
529 .iter()
530 .map(|rule| CompiledIgnoreExportRule {
531 matcher: Glob::new(&rule.file)
532 .expect("ignoreExports[].file was validated at config load time")
533 .compile_matcher(),
534 exports: rule.exports.clone(),
535 })
536 .collect()
537}
538
539#[expect(
541 clippy::expect_used,
542 reason = "user glob patterns are validated before config resolution"
543)]
544fn compile_ignore_catalog_reference_rules(
545 rules: &[IgnoreCatalogReferenceRule],
546) -> Vec<CompiledIgnoreCatalogReferenceRule> {
547 rules
548 .iter()
549 .map(|rule| CompiledIgnoreCatalogReferenceRule {
550 package: rule.package.clone(),
551 catalog: rule.catalog.clone(),
552 consumer_matcher: rule.consumer.as_ref().map(|pattern| {
553 Glob::new(pattern)
554 .expect("ignoreCatalogReferences[].consumer was validated at config load time")
555 .compile_matcher()
556 }),
557 })
558 .collect()
559}
560
561fn compile_ignore_dependency_override_rules(
563 rules: &[IgnoreDependencyOverrideRule],
564) -> Vec<CompiledIgnoreDependencyOverrideRule> {
565 rules
566 .iter()
567 .map(|rule| CompiledIgnoreDependencyOverrideRule {
568 package: rule.package.clone(),
569 source: rule.source.clone(),
570 })
571 .collect()
572}
573
574struct CompiledIgnoreSettings {
575 patterns: GlobSet,
576 findings: FindingIgnoreMatcher,
577 unresolved_imports: Vec<GlobMatcher>,
578 exports: Vec<CompiledIgnoreExportRule>,
579 catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
580 dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
581}
582
583fn compile_ignore_settings(config: &FallowConfig) -> CompiledIgnoreSettings {
584 CompiledIgnoreSettings {
585 patterns: compile_ignore_patterns(&config.ignore_patterns),
586 findings: FindingIgnoreMatcher::compile(&config.ignore_findings),
587 unresolved_imports: compile_ignore_unresolved_imports(&config.ignore_unresolved_imports),
588 exports: compile_ignore_export_rules(&config.ignore_exports),
589 catalog_references: compile_ignore_catalog_reference_rules(
590 &config.ignore_catalog_references,
591 ),
592 dependency_overrides: compile_ignore_dependency_override_rules(
593 &config.ignore_dependency_overrides,
594 ),
595 }
596}
597
598struct ResolvedPluginSettings {
599 external_plugins: Vec<ExternalPluginDef>,
600 rule_packs: Vec<crate::rule_pack::RulePackDef>,
601 rule_pack_sources: Vec<PathBuf>,
602}
603
604fn resolve_plugin_settings(
605 root: &Path,
606 configured_plugins: &[String],
607 framework: Vec<ExternalPluginDef>,
608 rule_packs: &[String],
609) -> ResolvedPluginSettings {
610 let mut external_plugins = discover_external_plugins(root, configured_plugins);
611 external_plugins.extend(framework);
612
613 let configured_rule_packs = rule_packs;
614 let rule_packs =
615 crate::rule_pack::load_rule_packs(root, configured_rule_packs).unwrap_or_else(|errors| {
616 for error in &errors {
617 tracing::error!("invalid rule pack: {error}");
618 }
619 Vec::new()
620 });
621 let rule_pack_sources = if rule_packs.len() == configured_rule_packs.len() {
622 configured_rule_packs.iter().map(PathBuf::from).collect()
623 } else {
624 Vec::new()
625 };
626
627 ResolvedPluginSettings {
628 external_plugins,
629 rule_packs,
630 rule_pack_sources,
631 }
632}
633
634struct ResolvedCacheSettings {
635 dir: PathBuf,
636 max_size_mb: Option<u32>,
637 config_hash: u64,
638}
639
640struct ResolvedProductionRules {
641 production: bool,
642 rules: RulesConfig,
643}
644
645fn resolve_production_rules(
646 production_config: ProductionConfig,
647 rules: RulesConfig,
648) -> ResolvedProductionRules {
649 let production = production_config.global();
650 ResolvedProductionRules {
651 production,
652 rules: resolve_rules_for_production(rules, production),
653 }
654}
655
656fn resolve_cache_settings(
657 root: &Path,
658 configured_dir: Option<PathBuf>,
659 configured_max_size_mb: Option<u32>,
660 override_max_size_mb: Option<u32>,
661 no_cache: bool,
662 external_plugins: &[ExternalPluginDef],
663) -> ResolvedCacheSettings {
664 ResolvedCacheSettings {
665 dir: resolve_cache_dir(root, configured_dir),
666 max_size_mb: override_max_size_mb.or(configured_max_size_mb),
667 config_hash: if no_cache {
668 0
669 } else {
670 compute_cache_config_hash(external_plugins)
671 },
672 }
673}
674
675fn normalize_security_config(security: SecurityConfig) -> SecurityConfig {
676 SecurityConfig {
677 request_receivers: security.normalized_request_receivers(),
678 ..security
679 }
680}
681
682struct ResolvedPathPolicySettings {
683 boundaries: ResolvedBoundaryConfig,
684 overrides: Vec<ResolvedOverride>,
685}
686
687fn resolve_path_policy_settings(
688 boundaries: BoundaryConfig,
689 overrides: Vec<ConfigOverride>,
690 root: &Path,
691) -> ResolvedPathPolicySettings {
692 ResolvedPathPolicySettings {
693 boundaries: resolve_boundaries(boundaries, root),
694 overrides: compile_overrides(overrides),
695 }
696}
697
698fn compile_unused_component_props_ignore(pattern: Option<&str>) -> Option<regex::Regex> {
699 pattern.and_then(|pattern| match regex::Regex::new(pattern) {
700 Ok(re) => Some(re),
701 Err(error) => {
702 tracing::warn!(
703 %error,
704 "ignoring invalid unusedComponentProps.ignorePattern; this config was \
705 not validated through FallowConfig::load"
706 );
707 None
708 }
709 })
710}
711
712impl FallowConfig {
713 #[expect(
715 clippy::too_many_arguments,
716 reason = "public cross-crate API: ResolvedConfig builder whose runtime-override parameters (root, output, threads, no_cache, quiet, cache_max_size_mb) are an established stable signature; bundling them would break callers"
717 )]
718 pub fn resolve(
719 self,
720 root: PathBuf,
721 output: OutputFormat,
722 threads: usize,
723 no_cache: bool,
724 quiet: bool,
725 cache_max_size_mb: Option<u32>,
726 ) -> ResolvedConfig {
727 let compiled_ignores = compile_ignore_settings(&self);
728
729 let production_rules = resolve_production_rules(self.production, self.rules);
730
731 let plugins =
732 resolve_plugin_settings(&root, &self.plugins, self.framework, &self.rule_packs);
733
734 let cache = resolve_cache_settings(
735 &root,
736 self.cache.dir,
737 self.cache.max_size_mb,
738 cache_max_size_mb,
739 no_cache,
740 &plugins.external_plugins,
741 );
742
743 let path_policy = resolve_path_policy_settings(self.boundaries, self.overrides, &root);
744
745 let unused_component_props_ignore = compile_unused_component_props_ignore(
746 self.unused_component_props.ignore_pattern.as_deref(),
747 );
748
749 ResolvedConfig {
750 root,
751 entry_patterns: self.entry,
752 ignore_patterns: compiled_ignores.patterns,
753 ignore_findings: compiled_ignores.findings,
754 output,
755 cache_dir: cache.dir,
756 threads,
757 no_cache,
758 cache_max_size_mb: cache.max_size_mb,
759 cache_config_hash: cache.config_hash,
760 ignore_dependencies: self.ignore_dependencies,
761 ignore_unresolved_imports: compiled_ignores.unresolved_imports,
762 ignore_export_rules: self.ignore_exports,
763 compiled_ignore_exports: compiled_ignores.exports,
764 compiled_ignore_catalog_references: compiled_ignores.catalog_references,
765 compiled_ignore_dependency_overrides: compiled_ignores.dependency_overrides,
766 ignore_exports_used_in_file: self.ignore_exports_used_in_file,
767 used_class_members: self.used_class_members,
768 ignore_decorators: self.ignore_decorators,
769 unused_component_props_ignore,
770 duplicates: self.duplicates,
771 health: self.health,
772 type_aware: self.type_aware,
773 rules: production_rules.rules,
774 boundaries: path_policy.boundaries,
775 rule_packs: plugins.rule_packs,
776 rule_pack_sources: plugins.rule_pack_sources,
777 production: production_rules.production,
778 quiet,
779 external_plugins: plugins.external_plugins,
780 dynamically_loaded: self.dynamically_loaded,
781 overrides: path_policy.overrides,
782 regression: self.regression,
783 audit: self.audit,
784 codeowners: self.codeowners,
785 public_packages: self.public_packages,
786 flags: self.flags,
787 security: normalize_security_config(self.security),
788 fix: self.fix,
789 resolve: self.resolve,
790 include_entry_exports: self.include_entry_exports,
791 auto_imports: self.auto_imports,
792 max_file_size_bytes: Some(DEFAULT_MAX_FILE_SIZE_BYTES),
793 analysis_snapshot: AnalysisSnapshot::Current,
794 }
795 }
796}
797
798impl ResolvedConfig {
799 #[must_use]
802 pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
803 if self.overrides.is_empty() {
804 return self.rules.clone();
805 }
806
807 let relative = path.strip_prefix(&self.root).unwrap_or(path);
808 let relative_str = relative.to_string_lossy();
809
810 let mut rules = self.rules.clone();
811 for override_entry in &self.overrides {
812 let matches = override_entry
813 .matchers
814 .iter()
815 .any(|m| m.is_match(relative_str.as_ref()));
816 if matches {
817 rules.apply_partial(&override_entry.rules);
818 }
819 }
820 rules
821 }
822}
823
824#[cfg(test)]
825mod tests {
826 use super::*;
827 use crate::CacheConfig;
828 use crate::config::boundaries::BoundaryConfig;
829 use crate::config::health::HealthConfig;
830
831 #[test]
832 fn overrides_deserialize() {
833 let json_str = r#"{
834 "overrides": [{
835 "files": ["*.test.ts"],
836 "rules": {
837 "unused-exports": "off"
838 }
839 }]
840 }"#;
841 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
842 assert_eq!(config.overrides.len(), 1);
843 assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
844 assert_eq!(
845 config.overrides[0].rules.unused_exports,
846 Some(Severity::Off)
847 );
848 assert_eq!(config.overrides[0].rules.unused_files, None);
849 }
850
851 #[test]
852 fn resolve_rules_for_path_no_overrides() {
853 let config = FallowConfig {
854 schema: None,
855 extends: vec![],
856 entry: vec![],
857 ignore_patterns: vec![],
858 ignore_findings: vec![],
859 framework: vec![],
860 workspaces: None,
861 ignore_dependencies: vec![],
862 ignore_unresolved_imports: vec![],
863 ignore_exports: vec![],
864 ignore_catalog_references: vec![],
865 ignore_dependency_overrides: vec![],
866 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
867 used_class_members: vec![],
868 ignore_decorators: vec![],
869 unused_component_props: crate::UnusedComponentPropsConfig::default(),
870 duplicates: DuplicatesConfig::default(),
871 health: HealthConfig::default(),
872 rules: RulesConfig::default(),
873 boundaries: BoundaryConfig::default(),
874 production: false.into(),
875 plugins: vec![],
876 rule_packs: vec![],
877 dynamically_loaded: vec![],
878 overrides: vec![],
879 regression: None,
880 type_aware: crate::TypeAwareConfig::default(),
881 audit: crate::config::AuditConfig::default(),
882 codeowners: None,
883 public_packages: vec![],
884 flags: FlagsConfig::default(),
885 security: SecurityConfig::default(),
886 fix: crate::config::FixConfig::default(),
887 resolve: ResolveConfig::default(),
888 sealed: false,
889 include_entry_exports: false,
890 auto_imports: false,
891 cache: CacheConfig::default(),
892 };
893 let resolved = config.resolve(
894 PathBuf::from("/project"),
895 OutputFormat::Human,
896 1,
897 true,
898 true,
899 None,
900 );
901 let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
902 assert_eq!(rules.unused_files, Severity::Error);
903 }
904
905 #[test]
906 fn resolve_rules_for_path_with_matching_override() {
907 let config = FallowConfig {
908 schema: None,
909 extends: vec![],
910 entry: vec![],
911 ignore_patterns: vec![],
912 ignore_findings: vec![],
913 framework: vec![],
914 workspaces: None,
915 ignore_dependencies: vec![],
916 ignore_unresolved_imports: vec![],
917 ignore_exports: vec![],
918 ignore_catalog_references: vec![],
919 ignore_dependency_overrides: vec![],
920 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
921 used_class_members: vec![],
922 ignore_decorators: vec![],
923 unused_component_props: crate::UnusedComponentPropsConfig::default(),
924 duplicates: DuplicatesConfig::default(),
925 health: HealthConfig::default(),
926 rules: RulesConfig::default(),
927 boundaries: BoundaryConfig::default(),
928 production: false.into(),
929 plugins: vec![],
930 rule_packs: vec![],
931 dynamically_loaded: vec![],
932 overrides: vec![ConfigOverride {
933 files: vec!["*.test.ts".to_string()],
934 rules: PartialRulesConfig {
935 unused_exports: Some(Severity::Off),
936 ..Default::default()
937 },
938 }],
939 regression: None,
940 type_aware: crate::TypeAwareConfig::default(),
941 audit: crate::config::AuditConfig::default(),
942 codeowners: None,
943 public_packages: vec![],
944 flags: FlagsConfig::default(),
945 security: SecurityConfig::default(),
946 fix: crate::config::FixConfig::default(),
947 resolve: ResolveConfig::default(),
948 sealed: false,
949 include_entry_exports: false,
950 auto_imports: false,
951 cache: CacheConfig::default(),
952 };
953 let resolved = config.resolve(
954 PathBuf::from("/project"),
955 OutputFormat::Human,
956 1,
957 true,
958 true,
959 None,
960 );
961
962 let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
963 assert_eq!(test_rules.unused_exports, Severity::Off);
964 assert_eq!(test_rules.unused_files, Severity::Error); let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
967 assert_eq!(src_rules.unused_exports, Severity::Error);
968 }
969
970 #[test]
971 fn resolve_rules_for_path_later_override_wins() {
972 let config = FallowConfig {
973 schema: None,
974 extends: vec![],
975 entry: vec![],
976 ignore_patterns: vec![],
977 ignore_findings: vec![],
978 framework: vec![],
979 workspaces: None,
980 ignore_dependencies: vec![],
981 ignore_unresolved_imports: vec![],
982 ignore_exports: vec![],
983 ignore_catalog_references: vec![],
984 ignore_dependency_overrides: vec![],
985 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
986 used_class_members: vec![],
987 ignore_decorators: vec![],
988 unused_component_props: crate::UnusedComponentPropsConfig::default(),
989 duplicates: DuplicatesConfig::default(),
990 health: HealthConfig::default(),
991 rules: RulesConfig::default(),
992 boundaries: BoundaryConfig::default(),
993 production: false.into(),
994 plugins: vec![],
995 rule_packs: vec![],
996 dynamically_loaded: vec![],
997 overrides: vec![
998 ConfigOverride {
999 files: vec!["*.ts".to_string()],
1000 rules: PartialRulesConfig {
1001 unused_files: Some(Severity::Warn),
1002 ..Default::default()
1003 },
1004 },
1005 ConfigOverride {
1006 files: vec!["*.test.ts".to_string()],
1007 rules: PartialRulesConfig {
1008 unused_files: Some(Severity::Off),
1009 ..Default::default()
1010 },
1011 },
1012 ],
1013 regression: None,
1014 type_aware: crate::TypeAwareConfig::default(),
1015 audit: crate::config::AuditConfig::default(),
1016 codeowners: None,
1017 public_packages: vec![],
1018 flags: FlagsConfig::default(),
1019 security: SecurityConfig::default(),
1020 fix: crate::config::FixConfig::default(),
1021 resolve: ResolveConfig::default(),
1022 sealed: false,
1023 include_entry_exports: false,
1024 auto_imports: false,
1025 cache: CacheConfig::default(),
1026 };
1027 let resolved = config.resolve(
1028 PathBuf::from("/project"),
1029 OutputFormat::Human,
1030 1,
1031 true,
1032 true,
1033 None,
1034 );
1035
1036 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
1037 assert_eq!(rules.unused_files, Severity::Off);
1038
1039 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
1040 assert_eq!(rules2.unused_files, Severity::Warn);
1041 }
1042
1043 #[test]
1044 fn resolve_keeps_inter_file_rule_override_after_warning() {
1045 let config = FallowConfig {
1046 schema: None,
1047 extends: vec![],
1048 entry: vec![],
1049 ignore_patterns: vec![],
1050 ignore_findings: vec![],
1051 framework: vec![],
1052 workspaces: None,
1053 ignore_dependencies: vec![],
1054 ignore_unresolved_imports: vec![],
1055 ignore_exports: vec![],
1056 ignore_catalog_references: vec![],
1057 ignore_dependency_overrides: vec![],
1058 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1059 used_class_members: vec![],
1060 ignore_decorators: vec![],
1061 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1062 duplicates: DuplicatesConfig::default(),
1063 health: HealthConfig::default(),
1064 rules: RulesConfig::default(),
1065 boundaries: BoundaryConfig::default(),
1066 production: false.into(),
1067 plugins: vec![],
1068 rule_packs: vec![],
1069 dynamically_loaded: vec![],
1070 overrides: vec![ConfigOverride {
1071 files: vec!["**/ui/**".to_string()],
1072 rules: PartialRulesConfig {
1073 duplicate_exports: Some(Severity::Off),
1074 unused_files: Some(Severity::Warn),
1075 ..Default::default()
1076 },
1077 }],
1078 regression: None,
1079 type_aware: crate::TypeAwareConfig::default(),
1080 audit: crate::config::AuditConfig::default(),
1081 codeowners: None,
1082 public_packages: vec![],
1083 flags: FlagsConfig::default(),
1084 security: SecurityConfig::default(),
1085 fix: crate::config::FixConfig::default(),
1086 resolve: ResolveConfig::default(),
1087 sealed: false,
1088 include_entry_exports: false,
1089 auto_imports: false,
1090 cache: CacheConfig::default(),
1091 };
1092 let resolved = config.resolve(
1093 PathBuf::from("/project"),
1094 OutputFormat::Human,
1095 1,
1096 true,
1097 true,
1098 None,
1099 );
1100 assert_eq!(
1101 resolved.overrides.len(),
1102 1,
1103 "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
1104 );
1105 let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
1106 assert_eq!(rules.unused_files, Severity::Warn);
1107 }
1108
1109 #[test]
1110 fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
1111 reset_inter_file_warn_dedup_for_test();
1112 let files_a = vec!["__test_dedup_a/*".to_string()];
1113 let files_b = vec!["__test_dedup_b/*".to_string()];
1114
1115 assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
1116 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
1117 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
1118
1119 assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
1120 assert!(!record_inter_file_warn_seen(
1121 "circular-dependency",
1122 &files_a
1123 ));
1124
1125 assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
1126
1127 let files_reordered = vec![
1128 "__test_dedup_b/*".to_string(),
1129 "__test_dedup_a/*".to_string(),
1130 ];
1131 let files_natural = vec![
1132 "__test_dedup_a/*".to_string(),
1133 "__test_dedup_b/*".to_string(),
1134 ];
1135 reset_inter_file_warn_dedup_for_test();
1136 assert!(record_inter_file_warn_seen(
1137 "duplicate-exports",
1138 &files_natural
1139 ));
1140 assert!(!record_inter_file_warn_seen(
1141 "duplicate-exports",
1142 &files_reordered
1143 ));
1144 }
1145
1146 #[test]
1147 fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
1148 reset_inter_file_warn_dedup_for_test();
1149 let files = vec!["__test_resolve_dedup/**".to_string()];
1150 let build_config = || FallowConfig {
1151 schema: None,
1152 extends: vec![],
1153 entry: vec![],
1154 ignore_patterns: vec![],
1155 ignore_findings: vec![],
1156 framework: vec![],
1157 workspaces: None,
1158 ignore_dependencies: vec![],
1159 ignore_unresolved_imports: vec![],
1160 ignore_exports: vec![],
1161 ignore_catalog_references: vec![],
1162 ignore_dependency_overrides: vec![],
1163 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1164 used_class_members: vec![],
1165 ignore_decorators: vec![],
1166 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1167 duplicates: DuplicatesConfig::default(),
1168 health: HealthConfig::default(),
1169 rules: RulesConfig::default(),
1170 boundaries: BoundaryConfig::default(),
1171 production: false.into(),
1172 plugins: vec![],
1173 rule_packs: vec![],
1174 dynamically_loaded: vec![],
1175 overrides: vec![ConfigOverride {
1176 files: files.clone(),
1177 rules: PartialRulesConfig {
1178 duplicate_exports: Some(Severity::Off),
1179 ..Default::default()
1180 },
1181 }],
1182 regression: None,
1183 type_aware: crate::TypeAwareConfig::default(),
1184 audit: crate::config::AuditConfig::default(),
1185 codeowners: None,
1186 public_packages: vec![],
1187 flags: FlagsConfig::default(),
1188 security: SecurityConfig::default(),
1189 fix: crate::config::FixConfig::default(),
1190 resolve: ResolveConfig::default(),
1191 sealed: false,
1192 include_entry_exports: false,
1193 auto_imports: false,
1194 cache: CacheConfig::default(),
1195 };
1196 for _ in 0..10 {
1197 let _ = build_config().resolve(
1198 PathBuf::from("/project"),
1199 OutputFormat::Human,
1200 1,
1201 true,
1202 true,
1203 None,
1204 );
1205 }
1206 assert!(
1207 !record_inter_file_warn_seen("duplicate-exports", &files),
1208 "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
1209 );
1210 }
1211
1212 fn make_config(production: bool) -> FallowConfig {
1214 FallowConfig {
1215 schema: None,
1216 extends: vec![],
1217 entry: vec![],
1218 ignore_patterns: vec![],
1219 ignore_findings: vec![],
1220 framework: vec![],
1221 workspaces: None,
1222 ignore_dependencies: vec![],
1223 ignore_unresolved_imports: vec![],
1224 ignore_exports: vec![],
1225 ignore_catalog_references: vec![],
1226 ignore_dependency_overrides: vec![],
1227 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1228 used_class_members: vec![],
1229 ignore_decorators: vec![],
1230 unused_component_props: crate::UnusedComponentPropsConfig::default(),
1231 duplicates: DuplicatesConfig::default(),
1232 health: HealthConfig::default(),
1233 rules: RulesConfig::default(),
1234 boundaries: BoundaryConfig::default(),
1235 production: production.into(),
1236 plugins: vec![],
1237 rule_packs: vec![],
1238 dynamically_loaded: vec![],
1239 overrides: vec![],
1240 regression: None,
1241 type_aware: crate::TypeAwareConfig::default(),
1242 audit: crate::config::AuditConfig::default(),
1243 codeowners: None,
1244 public_packages: vec![],
1245 flags: FlagsConfig::default(),
1246 security: SecurityConfig::default(),
1247 fix: crate::config::FixConfig::default(),
1248 resolve: ResolveConfig::default(),
1249 sealed: false,
1250 include_entry_exports: false,
1251 auto_imports: false,
1252 cache: CacheConfig::default(),
1253 }
1254 }
1255
1256 #[test]
1257 fn resolve_tracks_rule_pack_sources_in_config_order() {
1258 let dir = tempfile::tempdir().unwrap();
1259 std::fs::create_dir_all(dir.path().join("rule-packs")).unwrap();
1260 std::fs::write(
1261 dir.path().join("rule-packs/team-policy.jsonc"),
1262 r#"{
1263 "version": 1,
1264 "name": "team-policy",
1265 "rules": [
1266 {
1267 "id": "no-moment",
1268 "kind": "banned-import",
1269 "specifiers": ["moment"]
1270 }
1271 ]
1272}
1273"#,
1274 )
1275 .unwrap();
1276
1277 let mut config = make_config(false);
1278 config.rule_packs = vec!["rule-packs/team-policy.jsonc".to_string()];
1279
1280 let resolved = config.resolve(
1281 dir.path().to_path_buf(),
1282 OutputFormat::Human,
1283 1,
1284 true,
1285 true,
1286 None,
1287 );
1288
1289 assert_eq!(resolved.rule_packs.len(), 1);
1290 assert_eq!(resolved.rule_packs[0].name, "team-policy");
1291 assert_eq!(
1292 resolved.rule_pack_sources,
1293 vec![PathBuf::from("rule-packs/team-policy.jsonc")]
1294 );
1295 }
1296
1297 #[test]
1298 fn resolve_production_forces_dev_deps_off() {
1299 let resolved = make_config(true).resolve(
1300 PathBuf::from("/project"),
1301 OutputFormat::Human,
1302 1,
1303 true,
1304 true,
1305 None,
1306 );
1307 assert_eq!(
1308 resolved.rules.unused_dev_dependencies,
1309 Severity::Off,
1310 "production mode should force unused_dev_dependencies to off"
1311 );
1312 }
1313
1314 #[test]
1315 fn resolve_production_forces_optional_deps_off() {
1316 let resolved = make_config(true).resolve(
1317 PathBuf::from("/project"),
1318 OutputFormat::Human,
1319 1,
1320 true,
1321 true,
1322 None,
1323 );
1324 assert_eq!(
1325 resolved.rules.unused_optional_dependencies,
1326 Severity::Off,
1327 "production mode should force unused_optional_dependencies to off"
1328 );
1329 }
1330
1331 #[test]
1332 fn resolve_production_preserves_other_rules() {
1333 let resolved = make_config(true).resolve(
1334 PathBuf::from("/project"),
1335 OutputFormat::Human,
1336 1,
1337 true,
1338 true,
1339 None,
1340 );
1341 assert_eq!(resolved.rules.unused_files, Severity::Error);
1342 assert_eq!(resolved.rules.unused_exports, Severity::Error);
1343 assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
1344 }
1345
1346 #[test]
1347 fn resolve_non_production_keeps_dev_deps_default() {
1348 let resolved = make_config(false).resolve(
1349 PathBuf::from("/project"),
1350 OutputFormat::Human,
1351 1,
1352 true,
1353 true,
1354 None,
1355 );
1356 assert_eq!(
1357 resolved.rules.unused_dev_dependencies,
1358 Severity::Warn,
1359 "non-production should keep default severity"
1360 );
1361 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
1362 }
1363
1364 #[test]
1365 fn resolve_production_flag_stored() {
1366 let resolved = make_config(true).resolve(
1367 PathBuf::from("/project"),
1368 OutputFormat::Human,
1369 1,
1370 true,
1371 true,
1372 None,
1373 );
1374 assert!(resolved.production);
1375
1376 let resolved2 = make_config(false).resolve(
1377 PathBuf::from("/project"),
1378 OutputFormat::Human,
1379 1,
1380 true,
1381 true,
1382 None,
1383 );
1384 assert!(!resolved2.production);
1385 }
1386
1387 #[test]
1388 fn resolve_default_ignores_node_modules() {
1389 let resolved = make_config(false).resolve(
1390 PathBuf::from("/project"),
1391 OutputFormat::Human,
1392 1,
1393 true,
1394 true,
1395 None,
1396 );
1397 assert!(
1398 resolved
1399 .ignore_patterns
1400 .is_match("node_modules/lodash/index.js")
1401 );
1402 assert!(
1403 resolved
1404 .ignore_patterns
1405 .is_match("packages/a/node_modules/react/index.js")
1406 );
1407 }
1408
1409 #[test]
1410 fn resolve_default_ignores_dist() {
1411 let resolved = make_config(false).resolve(
1412 PathBuf::from("/project"),
1413 OutputFormat::Human,
1414 1,
1415 true,
1416 true,
1417 None,
1418 );
1419 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1420 assert!(
1421 resolved
1422 .ignore_patterns
1423 .is_match("packages/ui/dist/index.js")
1424 );
1425 }
1426
1427 #[test]
1428 fn resolve_default_ignores_root_build_only() {
1429 let resolved = make_config(false).resolve(
1430 PathBuf::from("/project"),
1431 OutputFormat::Human,
1432 1,
1433 true,
1434 true,
1435 None,
1436 );
1437 assert!(
1438 resolved.ignore_patterns.is_match("build/output.js"),
1439 "root build/ should be ignored"
1440 );
1441 assert!(
1442 !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1443 "nested build/ should NOT be ignored by default"
1444 );
1445 }
1446
1447 #[test]
1448 fn resolve_default_ignores_minified_files() {
1449 let resolved = make_config(false).resolve(
1450 PathBuf::from("/project"),
1451 OutputFormat::Human,
1452 1,
1453 true,
1454 true,
1455 None,
1456 );
1457 assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1458 assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1459 assert!(resolved.ignore_patterns.is_match("lib/legacy.min.cjs"));
1460 assert!(resolved.ignore_patterns.is_match("public/app.bundle.js"));
1461 assert!(
1462 resolved
1463 .ignore_patterns
1464 .is_match("src/vendor/app.bundle.js")
1465 );
1466 assert!(!resolved.ignore_patterns.is_match("src/bundle.ts"));
1468 assert!(!resolved.ignore_patterns.is_match("src/app.cjs"));
1469 }
1470
1471 #[test]
1472 fn resolve_max_file_size_bytes_default_and_unlimited() {
1473 assert_eq!(
1475 resolve_max_file_size_bytes(None),
1476 Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1477 );
1478 assert_eq!(resolve_max_file_size_bytes(Some(0)), None);
1480 assert_eq!(resolve_max_file_size_bytes(Some(2)), Some(2 * 1024 * 1024));
1482 assert_eq!(DEFAULT_MAX_FILE_SIZE_MB, 5);
1483 }
1484
1485 #[test]
1486 fn resolve_sets_default_max_file_size() {
1487 let resolved = make_config(false).resolve(
1488 PathBuf::from("/project"),
1489 OutputFormat::Human,
1490 1,
1491 true,
1492 true,
1493 None,
1494 );
1495 assert_eq!(
1496 resolved.max_file_size_bytes,
1497 Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1498 );
1499 }
1500
1501 #[test]
1502 fn resolve_default_ignores_git() {
1503 let resolved = make_config(false).resolve(
1504 PathBuf::from("/project"),
1505 OutputFormat::Human,
1506 1,
1507 true,
1508 true,
1509 None,
1510 );
1511 assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1512 }
1513
1514 #[test]
1515 fn resolve_default_ignores_coverage() {
1516 let resolved = make_config(false).resolve(
1517 PathBuf::from("/project"),
1518 OutputFormat::Human,
1519 1,
1520 true,
1521 true,
1522 None,
1523 );
1524 assert!(
1525 resolved
1526 .ignore_patterns
1527 .is_match("coverage/lcov-report/index.js")
1528 );
1529 }
1530
1531 #[test]
1532 fn resolve_source_files_not_ignored_by_default() {
1533 let resolved = make_config(false).resolve(
1534 PathBuf::from("/project"),
1535 OutputFormat::Human,
1536 1,
1537 true,
1538 true,
1539 None,
1540 );
1541 assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1542 assert!(
1543 !resolved
1544 .ignore_patterns
1545 .is_match("src/components/Button.tsx")
1546 );
1547 assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1548 }
1549
1550 #[test]
1551 fn resolve_custom_ignore_patterns_merged_with_defaults() {
1552 let mut config = make_config(false);
1553 config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1554 let resolved = config.resolve(
1555 PathBuf::from("/project"),
1556 OutputFormat::Human,
1557 1,
1558 true,
1559 true,
1560 None,
1561 );
1562 assert!(
1563 resolved
1564 .ignore_patterns
1565 .is_match("src/__generated__/types.ts")
1566 );
1567 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1568 }
1569
1570 #[test]
1571 fn resolve_normalizes_leading_dot_ignore_patterns() {
1572 let mut config = make_config(false);
1573 config.ignore_patterns = vec!["./src/generated/**".to_string()];
1574 let resolved = config.resolve(
1575 PathBuf::from("/project"),
1576 OutputFormat::Human,
1577 1,
1578 true,
1579 true,
1580 None,
1581 );
1582
1583 assert!(resolved.ignore_patterns.is_match("src/generated/client.ts"));
1584 assert!(
1585 !resolved
1586 .ignore_patterns
1587 .is_match("./src/generated/client.ts")
1588 );
1589 }
1590
1591 #[test]
1592 fn resolve_normalizes_leading_dot_ignore_unresolved_imports() {
1593 let mut config = make_config(false);
1594 config.ignore_unresolved_imports = vec!["./src/generated/**".to_string()];
1595 let resolved = config.resolve(
1596 PathBuf::from("/project"),
1597 OutputFormat::Human,
1598 1,
1599 true,
1600 true,
1601 None,
1602 );
1603
1604 assert!(
1605 resolved
1606 .ignore_unresolved_imports
1607 .iter()
1608 .any(|matcher| matcher.is_match("src/generated/client"))
1609 );
1610 assert!(
1611 !resolved
1612 .ignore_unresolved_imports
1613 .iter()
1614 .any(|matcher| matcher.is_match("./src/generated/client"))
1615 );
1616 }
1617
1618 #[test]
1619 fn resolve_passes_through_entry_patterns() {
1620 let mut config = make_config(false);
1621 config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1622 let resolved = config.resolve(
1623 PathBuf::from("/project"),
1624 OutputFormat::Human,
1625 1,
1626 true,
1627 true,
1628 None,
1629 );
1630 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1631 }
1632
1633 #[test]
1634 fn resolve_passes_through_ignore_dependencies() {
1635 let mut config = make_config(false);
1636 config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1637 let resolved = config.resolve(
1638 PathBuf::from("/project"),
1639 OutputFormat::Human,
1640 1,
1641 true,
1642 true,
1643 None,
1644 );
1645 assert_eq!(
1646 resolved.ignore_dependencies,
1647 vec!["postcss", "autoprefixer"]
1648 );
1649 }
1650
1651 #[test]
1652 fn resolve_compiles_ignore_unresolved_imports_as_raw_specifier_globs() {
1653 let mut config = make_config(false);
1654 config.ignore_unresolved_imports = vec![
1655 "@example/icons".to_string(),
1656 "@example/icons/**".to_string(),
1657 "../generated/**".to_string(),
1658 ];
1659 let resolved = config.resolve(
1660 PathBuf::from("/project"),
1661 OutputFormat::Human,
1662 1,
1663 true,
1664 true,
1665 None,
1666 );
1667
1668 assert!(
1669 resolved
1670 .ignore_unresolved_imports
1671 .iter()
1672 .any(|matcher| matcher.is_match("@example/icons"))
1673 );
1674 assert!(
1675 resolved
1676 .ignore_unresolved_imports
1677 .iter()
1678 .any(|matcher| matcher.is_match("@example/icons/metadata"))
1679 );
1680 assert!(
1681 resolved
1682 .ignore_unresolved_imports
1683 .iter()
1684 .any(|matcher| matcher.is_match("../generated/client"))
1685 );
1686 }
1687
1688 #[test]
1689 fn ignore_unresolved_imports_subpath_glob_does_not_match_bare_specifier() {
1690 let mut config = make_config(false);
1691 config.ignore_unresolved_imports = vec!["@example/icons/**".to_string()];
1692 let resolved = config.resolve(
1693 PathBuf::from("/project"),
1694 OutputFormat::Human,
1695 1,
1696 true,
1697 true,
1698 None,
1699 );
1700
1701 assert!(
1702 !resolved.ignore_unresolved_imports[0].is_match("@example/icons"),
1703 "globset treats @example/icons/** as subpaths only; list the bare specifier separately"
1704 );
1705 assert!(resolved.ignore_unresolved_imports[0].is_match("@example/icons/metadata"));
1706 }
1707
1708 #[test]
1709 fn resolve_sets_cache_dir() {
1710 let resolved = make_config(false).resolve(
1711 PathBuf::from("/my/project"),
1712 OutputFormat::Human,
1713 1,
1714 true,
1715 true,
1716 None,
1717 );
1718 assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1719 }
1720
1721 #[test]
1722 fn resolve_uses_relative_configured_cache_dir_from_root() {
1723 let config = FallowConfig {
1724 cache: crate::CacheConfig {
1725 dir: Some(PathBuf::from(".cache/fallow")),
1726 ..Default::default()
1727 },
1728 ..make_config(false)
1729 };
1730 let resolved = config.resolve(
1731 PathBuf::from("/my/project"),
1732 OutputFormat::Human,
1733 1,
1734 false,
1735 true,
1736 None,
1737 );
1738 assert_eq!(
1739 resolved.cache_dir,
1740 PathBuf::from("/my/project/.cache/fallow")
1741 );
1742 }
1743
1744 #[test]
1745 fn resolve_keeps_absolute_configured_cache_dir() {
1746 let config = FallowConfig {
1747 cache: crate::CacheConfig {
1748 dir: Some(PathBuf::from("/tmp/fallow-cache")),
1749 ..Default::default()
1750 },
1751 ..make_config(false)
1752 };
1753 let resolved = config.resolve(
1754 PathBuf::from("/my/project"),
1755 OutputFormat::Human,
1756 1,
1757 false,
1758 true,
1759 None,
1760 );
1761 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/fallow-cache"));
1762 }
1763
1764 #[test]
1765 fn resolve_passes_through_thread_count() {
1766 let resolved = make_config(false).resolve(
1767 PathBuf::from("/project"),
1768 OutputFormat::Human,
1769 8,
1770 true,
1771 true,
1772 None,
1773 );
1774 assert_eq!(resolved.threads, 8);
1775 }
1776
1777 #[test]
1778 fn resolve_passes_through_quiet_flag() {
1779 let resolved = make_config(false).resolve(
1780 PathBuf::from("/project"),
1781 OutputFormat::Human,
1782 1,
1783 true,
1784 false,
1785 None,
1786 );
1787 assert!(!resolved.quiet);
1788
1789 let resolved2 = make_config(false).resolve(
1790 PathBuf::from("/project"),
1791 OutputFormat::Human,
1792 1,
1793 true,
1794 true,
1795 None,
1796 );
1797 assert!(resolved2.quiet);
1798 }
1799
1800 #[test]
1801 fn resolve_passes_through_no_cache_flag() {
1802 let resolved_no_cache = make_config(false).resolve(
1803 PathBuf::from("/project"),
1804 OutputFormat::Human,
1805 1,
1806 true,
1807 true,
1808 None,
1809 );
1810 assert!(resolved_no_cache.no_cache);
1811
1812 let resolved_with_cache = make_config(false).resolve(
1813 PathBuf::from("/project"),
1814 OutputFormat::Human,
1815 1,
1816 false,
1817 true,
1818 None,
1819 );
1820 assert!(!resolved_with_cache.no_cache);
1821 }
1822
1823 #[test]
1824 #[should_panic(expected = "validated at config load time")]
1825 fn resolve_panics_on_unvalidated_invalid_override_glob() {
1826 let mut config = make_config(false);
1827 config.overrides = vec![ConfigOverride {
1828 files: vec!["[invalid".to_string()],
1829 rules: PartialRulesConfig {
1830 unused_files: Some(Severity::Off),
1831 ..Default::default()
1832 },
1833 }];
1834 let _ = config.resolve(
1835 PathBuf::from("/project"),
1836 OutputFormat::Human,
1837 1,
1838 true,
1839 true,
1840 None,
1841 );
1842 }
1843
1844 #[test]
1845 fn resolve_override_with_empty_files_skipped() {
1846 let mut config = make_config(false);
1847 config.overrides = vec![ConfigOverride {
1848 files: vec![],
1849 rules: PartialRulesConfig {
1850 unused_files: Some(Severity::Off),
1851 ..Default::default()
1852 },
1853 }];
1854 let resolved = config.resolve(
1855 PathBuf::from("/project"),
1856 OutputFormat::Human,
1857 1,
1858 true,
1859 true,
1860 None,
1861 );
1862 assert!(
1863 resolved.overrides.is_empty(),
1864 "override with no file patterns should be skipped"
1865 );
1866 }
1867
1868 #[test]
1869 fn resolve_multiple_valid_overrides() {
1870 let mut config = make_config(false);
1871 config.overrides = vec![
1872 ConfigOverride {
1873 files: vec!["*.test.ts".to_string()],
1874 rules: PartialRulesConfig {
1875 unused_exports: Some(Severity::Off),
1876 ..Default::default()
1877 },
1878 },
1879 ConfigOverride {
1880 files: vec!["*.stories.tsx".to_string()],
1881 rules: PartialRulesConfig {
1882 unused_files: Some(Severity::Off),
1883 ..Default::default()
1884 },
1885 },
1886 ];
1887 let resolved = config.resolve(
1888 PathBuf::from("/project"),
1889 OutputFormat::Human,
1890 1,
1891 true,
1892 true,
1893 None,
1894 );
1895 assert_eq!(resolved.overrides.len(), 2);
1896 }
1897
1898 #[test]
1899 fn ignore_export_rule_deserialize() {
1900 let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1901 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1902 assert_eq!(rule.file, "src/types/*.ts");
1903 assert_eq!(rule.exports, vec!["*"]);
1904 }
1905
1906 #[test]
1907 fn ignore_export_rule_specific_exports() {
1908 let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1909 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1910 assert_eq!(rule.exports.len(), 3);
1911 assert!(rule.exports.contains(&"FOO".to_string()));
1912 }
1913
1914 mod proptests {
1915 use super::*;
1916 use proptest::prelude::*;
1917
1918 fn arb_resolved_config(production: bool) -> ResolvedConfig {
1919 make_config(production).resolve(
1920 PathBuf::from("/project"),
1921 OutputFormat::Human,
1922 1,
1923 true,
1924 true,
1925 None,
1926 )
1927 }
1928
1929 proptest! {
1930 #[test]
1932 fn resolved_config_has_default_ignores(production in any::<bool>()) {
1933 let resolved = arb_resolved_config(production);
1934 prop_assert!(
1935 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1936 "Default ignore should match node_modules"
1937 );
1938 prop_assert!(
1939 resolved.ignore_patterns.is_match("dist/bundle.js"),
1940 "Default ignore should match dist"
1941 );
1942 }
1943
1944 #[test]
1946 fn production_forces_dev_deps_off(_unused in Just(())) {
1947 let resolved = arb_resolved_config(true);
1948 prop_assert_eq!(
1949 resolved.rules.unused_dev_dependencies,
1950 Severity::Off,
1951 "Production should force unused_dev_dependencies off"
1952 );
1953 prop_assert_eq!(
1954 resolved.rules.unused_optional_dependencies,
1955 Severity::Off,
1956 "Production should force unused_optional_dependencies off"
1957 );
1958 }
1959
1960 #[test]
1962 fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1963 let resolved = arb_resolved_config(false);
1964 prop_assert_eq!(
1965 resolved.rules.unused_dev_dependencies,
1966 Severity::Warn,
1967 "Non-production should keep default dev dep severity"
1968 );
1969 }
1970
1971 #[test]
1973 fn cache_dir_defaults_to_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1974 let root = PathBuf::from(format!("/project/{dir_suffix}"));
1975 let expected_cache = root.join(".fallow");
1976 let resolved = make_config(false).resolve(
1977 root,
1978 OutputFormat::Human,
1979 1,
1980 true,
1981 true,
1982 None,
1983 );
1984 prop_assert_eq!(
1985 resolved.cache_dir, expected_cache,
1986 "Default cache dir should be root/.fallow"
1987 );
1988 }
1989
1990 #[test]
1992 fn threads_passed_through(threads in 1..64usize) {
1993 let resolved = make_config(false).resolve(
1994 PathBuf::from("/project"),
1995 OutputFormat::Human,
1996 threads,
1997 true,
1998 true, None,
1999 );
2000 prop_assert_eq!(
2001 resolved.threads, threads,
2002 "Thread count should be passed through"
2003 );
2004 }
2005
2006 #[test]
2010 fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
2011 let mut config = make_config(false);
2012 config.ignore_patterns = vec![pattern];
2013 let resolved = config.resolve(
2014 PathBuf::from("/project"),
2015 OutputFormat::Human,
2016 1,
2017 true,
2018 true, None,
2019 );
2020 prop_assert!(
2021 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
2022 "Default node_modules ignore should still be active"
2023 );
2024 }
2025 }
2026 }
2027
2028 #[test]
2029 fn resolve_expands_boundary_preset() {
2030 use crate::config::boundaries::BoundaryPreset;
2031
2032 let mut config = make_config(false);
2033 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
2034 let resolved = config.resolve(
2035 PathBuf::from("/project"),
2036 OutputFormat::Human,
2037 1,
2038 true,
2039 true,
2040 None,
2041 );
2042 assert_eq!(resolved.boundaries.zones.len(), 3);
2043 assert_eq!(resolved.boundaries.rules.len(), 3);
2044 assert_eq!(resolved.boundaries.zones[0].name, "adapters");
2045 assert_eq!(
2046 resolved.boundaries.classify_zone("src/adapters/http.ts"),
2047 Some("adapters")
2048 );
2049 }
2050
2051 #[test]
2052 fn resolve_boundary_preset_with_user_override() {
2053 use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
2054
2055 let mut config = make_config(false);
2056 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
2057 config.boundaries.zones = vec![BoundaryZone {
2058 name: "domain".to_string(),
2059 patterns: vec!["src/core/**".to_string()],
2060 auto_discover: vec![],
2061 root: None,
2062 }];
2063 let resolved = config.resolve(
2064 PathBuf::from("/project"),
2065 OutputFormat::Human,
2066 1,
2067 true,
2068 true,
2069 None,
2070 );
2071 assert_eq!(resolved.boundaries.zones.len(), 3);
2072 assert_eq!(
2073 resolved.boundaries.classify_zone("src/core/user.ts"),
2074 Some("domain")
2075 );
2076 assert_eq!(
2077 resolved.boundaries.classify_zone("src/domain/user.ts"),
2078 None
2079 );
2080 }
2081
2082 #[test]
2083 fn resolve_no_preset_unchanged() {
2084 let config = make_config(false);
2085 let resolved = config.resolve(
2086 PathBuf::from("/project"),
2087 OutputFormat::Human,
2088 1,
2089 true,
2090 true,
2091 None,
2092 );
2093 assert!(resolved.boundaries.is_empty());
2094 }
2095}