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