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::{FallowConfig, 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 duplicates: DuplicatesConfig,
181 pub health: HealthConfig,
182 pub rules: RulesConfig,
183 pub boundaries: ResolvedBoundaryConfig,
184 pub production: bool,
185 pub quiet: bool,
186 pub external_plugins: Vec<ExternalPluginDef>,
187 pub dynamically_loaded: Vec<String>,
188 pub overrides: Vec<ResolvedOverride>,
189 pub regression: Option<super::RegressionConfig>,
190 pub audit: super::AuditConfig,
191 pub codeowners: Option<String>,
192 pub public_packages: Vec<String>,
193 pub flags: FlagsConfig,
194 pub security: SecurityConfig,
195 pub fix: super::FixConfig,
196 pub resolve: ResolveConfig,
197 pub include_entry_exports: bool,
198 pub auto_imports: bool,
199 pub max_file_size_bytes: Option<u64>,
208}
209
210pub const DEFAULT_MAX_FILE_SIZE_MB: u32 = 5;
214
215pub const DEFAULT_MAX_FILE_SIZE_BYTES: u64 = DEFAULT_MAX_FILE_SIZE_MB as u64 * 1024 * 1024;
217
218#[must_use]
223pub fn resolve_max_file_size_bytes(max_file_size_mb: Option<u32>) -> Option<u64> {
224 match max_file_size_mb {
225 None => Some(DEFAULT_MAX_FILE_SIZE_BYTES),
226 Some(0) => None,
227 Some(mb) => Some(u64::from(mb) * 1024 * 1024),
228 }
229}
230
231fn compute_cache_config_hash(external_plugins: &[ExternalPluginDef]) -> u64 {
233 let mut names: Vec<&str> = external_plugins.iter().map(|p| p.name.as_str()).collect();
234 names.sort_unstable();
235 let mut hasher = xxhash_rust::xxh3::Xxh3::new();
236 for name in names {
237 hasher.update(&(name.len() as u32).to_le_bytes());
238 hasher.update(name.as_bytes());
239 }
240 hasher.digest()
241}
242
243fn resolve_cache_dir(root: &Path, configured: Option<PathBuf>) -> PathBuf {
244 let Some(dir) = configured else {
245 return root.join(".fallow");
246 };
247 if dir.is_absolute() {
248 dir
249 } else {
250 root.join(dir)
251 }
252}
253
254impl FallowConfig {
255 #[expect(
257 clippy::expect_used,
258 reason = "user glob patterns are validated before config resolution"
259 )]
260 pub fn resolve(
261 self,
262 root: PathBuf,
263 output: OutputFormat,
264 threads: usize,
265 no_cache: bool,
266 quiet: bool,
267 cache_max_size_mb: Option<u32>,
268 ) -> ResolvedConfig {
269 let mut ignore_builder = GlobSetBuilder::new();
270 for pattern in &self.ignore_patterns {
271 ignore_builder.add(
272 Glob::new(pattern).expect("ignorePatterns entry was validated at config load time"),
273 );
274 }
275
276 let default_ignores = [
277 "**/node_modules/**",
278 "**/dist/**",
279 "build/**",
280 "**/.git/**",
281 "**/coverage/**",
282 "**/*.min.js",
283 "**/*.min.mjs",
284 "**/*.min.cjs",
285 "**/*.bundle.js",
286 ];
287 for pattern in &default_ignores {
288 ignore_builder.add(Glob::new(pattern).expect("default ignore pattern is valid"));
289 }
290
291 let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
292 let ignore_unresolved_imports: Vec<GlobMatcher> = self
293 .ignore_unresolved_imports
294 .iter()
295 .map(|pattern| {
296 Glob::new(pattern)
297 .expect("ignoreUnresolvedImports entry was validated at config load time")
298 .compile_matcher()
299 })
300 .collect();
301 let cache_dir = resolve_cache_dir(&root, self.cache.dir.clone());
302
303 let mut rules = self.rules;
304
305 let production = self.production.global();
306 if production {
307 rules.unused_dev_dependencies = Severity::Off;
308 rules.unused_optional_dependencies = Severity::Off;
309 }
310
311 let mut external_plugins = discover_external_plugins(&root, &self.plugins);
312 external_plugins.extend(self.framework);
313
314 let mut boundaries = self.boundaries;
315 if boundaries.preset.is_some() {
316 let source_root = crate::workspace::parse_tsconfig_root_dir(&root)
317 .filter(|r| {
318 r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute()
319 })
320 .unwrap_or_else(|| "src".to_owned());
321 if source_root != "src" {
322 tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
323 }
324 boundaries.expand(&source_root);
325 }
326 let logical_groups = boundaries.expand_auto_discover(&root);
327
328 let mut boundaries = boundaries.resolve();
329 boundaries.logical_groups = logical_groups;
330
331 let overrides = self
332 .overrides
333 .into_iter()
334 .filter_map(|o| {
335 if o.rules.duplicate_exports.is_some()
336 && record_inter_file_warn_seen("duplicate-exports", &o.files)
337 {
338 let files = o.files.join(", ");
339 tracing::warn!(
340 "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."
341 );
342 }
343 if o.rules.circular_dependencies.is_some()
344 && record_inter_file_warn_seen("circular-dependency", &o.files)
345 {
346 let files = o.files.join(", ");
347 tracing::warn!(
348 "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."
349 );
350 }
351 if o.rules.re_export_cycle.is_some()
352 && record_inter_file_warn_seen("re-export-cycle", &o.files)
353 {
354 let files = o.files.join(", ");
355 tracing::warn!(
356 "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."
357 );
358 }
359 let matchers: Vec<globset::GlobMatcher> = o
360 .files
361 .iter()
362 .map(|pattern| {
363 Glob::new(pattern)
364 .expect("overrides[].files pattern was validated at config load time")
365 .compile_matcher()
366 })
367 .collect();
368 if matchers.is_empty() {
369 None
370 } else {
371 Some(ResolvedOverride {
372 matchers,
373 rules: o.rules,
374 })
375 }
376 })
377 .collect();
378
379 let compiled_ignore_exports: Vec<CompiledIgnoreExportRule> = self
380 .ignore_exports
381 .iter()
382 .map(|rule| CompiledIgnoreExportRule {
383 matcher: Glob::new(&rule.file)
384 .expect("ignoreExports[].file was validated at config load time")
385 .compile_matcher(),
386 exports: rule.exports.clone(),
387 })
388 .collect();
389
390 let compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule> = self
391 .ignore_catalog_references
392 .iter()
393 .map(|rule| CompiledIgnoreCatalogReferenceRule {
394 package: rule.package.clone(),
395 catalog: rule.catalog.clone(),
396 consumer_matcher: rule.consumer.as_ref().map(|pattern| {
397 Glob::new(pattern)
398 .expect(
399 "ignoreCatalogReferences[].consumer was validated at config load time",
400 )
401 .compile_matcher()
402 }),
403 })
404 .collect();
405
406 let compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule> = self
407 .ignore_dependency_overrides
408 .iter()
409 .map(|rule| CompiledIgnoreDependencyOverrideRule {
410 package: rule.package.clone(),
411 source: rule.source.clone(),
412 })
413 .collect();
414
415 let cache_max_size_mb = cache_max_size_mb.or(self.cache.max_size_mb);
416
417 let cache_config_hash = if no_cache {
418 0
419 } else {
420 compute_cache_config_hash(&external_plugins)
421 };
422
423 ResolvedConfig {
424 root,
425 entry_patterns: self.entry,
426 ignore_patterns: compiled_ignore_patterns,
427 output,
428 cache_dir,
429 threads,
430 no_cache,
431 cache_max_size_mb,
432 cache_config_hash,
433 ignore_dependencies: self.ignore_dependencies,
434 ignore_unresolved_imports,
435 ignore_export_rules: self.ignore_exports,
436 compiled_ignore_exports,
437 compiled_ignore_catalog_references,
438 compiled_ignore_dependency_overrides,
439 ignore_exports_used_in_file: self.ignore_exports_used_in_file,
440 used_class_members: self.used_class_members,
441 ignore_decorators: self.ignore_decorators,
442 duplicates: self.duplicates,
443 health: self.health,
444 rules,
445 boundaries,
446 production,
447 quiet,
448 external_plugins,
449 dynamically_loaded: self.dynamically_loaded,
450 overrides,
451 regression: self.regression,
452 audit: self.audit,
453 codeowners: self.codeowners,
454 public_packages: self.public_packages,
455 flags: self.flags,
456 security: self.security,
457 fix: self.fix,
458 resolve: self.resolve,
459 include_entry_exports: self.include_entry_exports,
460 auto_imports: self.auto_imports,
461 max_file_size_bytes: Some(DEFAULT_MAX_FILE_SIZE_BYTES),
462 }
463 }
464}
465
466impl ResolvedConfig {
467 #[must_use]
470 pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
471 if self.overrides.is_empty() {
472 return self.rules.clone();
473 }
474
475 let relative = path.strip_prefix(&self.root).unwrap_or(path);
476 let relative_str = relative.to_string_lossy();
477
478 let mut rules = self.rules.clone();
479 for override_entry in &self.overrides {
480 let matches = override_entry
481 .matchers
482 .iter()
483 .any(|m| m.is_match(relative_str.as_ref()));
484 if matches {
485 rules.apply_partial(&override_entry.rules);
486 }
487 }
488 rules
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use crate::CacheConfig;
496 use crate::config::boundaries::BoundaryConfig;
497 use crate::config::health::HealthConfig;
498
499 #[test]
500 fn overrides_deserialize() {
501 let json_str = r#"{
502 "overrides": [{
503 "files": ["*.test.ts"],
504 "rules": {
505 "unused-exports": "off"
506 }
507 }]
508 }"#;
509 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
510 assert_eq!(config.overrides.len(), 1);
511 assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
512 assert_eq!(
513 config.overrides[0].rules.unused_exports,
514 Some(Severity::Off)
515 );
516 assert_eq!(config.overrides[0].rules.unused_files, None);
517 }
518
519 #[test]
520 fn resolve_rules_for_path_no_overrides() {
521 let config = FallowConfig {
522 schema: None,
523 extends: vec![],
524 entry: vec![],
525 ignore_patterns: vec![],
526 framework: vec![],
527 workspaces: None,
528 ignore_dependencies: vec![],
529 ignore_unresolved_imports: vec![],
530 ignore_exports: vec![],
531 ignore_catalog_references: vec![],
532 ignore_dependency_overrides: vec![],
533 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
534 used_class_members: vec![],
535 ignore_decorators: vec![],
536 duplicates: DuplicatesConfig::default(),
537 health: HealthConfig::default(),
538 rules: RulesConfig::default(),
539 boundaries: BoundaryConfig::default(),
540 production: false.into(),
541 plugins: vec![],
542 dynamically_loaded: vec![],
543 overrides: vec![],
544 regression: None,
545 audit: crate::config::AuditConfig::default(),
546 codeowners: None,
547 public_packages: vec![],
548 flags: FlagsConfig::default(),
549 security: SecurityConfig::default(),
550 fix: crate::config::FixConfig::default(),
551 resolve: ResolveConfig::default(),
552 sealed: false,
553 include_entry_exports: false,
554 auto_imports: false,
555 cache: CacheConfig::default(),
556 };
557 let resolved = config.resolve(
558 PathBuf::from("/project"),
559 OutputFormat::Human,
560 1,
561 true,
562 true,
563 None,
564 );
565 let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
566 assert_eq!(rules.unused_files, Severity::Error);
567 }
568
569 #[test]
570 fn resolve_rules_for_path_with_matching_override() {
571 let config = FallowConfig {
572 schema: None,
573 extends: vec![],
574 entry: vec![],
575 ignore_patterns: vec![],
576 framework: vec![],
577 workspaces: None,
578 ignore_dependencies: vec![],
579 ignore_unresolved_imports: vec![],
580 ignore_exports: vec![],
581 ignore_catalog_references: vec![],
582 ignore_dependency_overrides: vec![],
583 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
584 used_class_members: vec![],
585 ignore_decorators: vec![],
586 duplicates: DuplicatesConfig::default(),
587 health: HealthConfig::default(),
588 rules: RulesConfig::default(),
589 boundaries: BoundaryConfig::default(),
590 production: false.into(),
591 plugins: vec![],
592 dynamically_loaded: vec![],
593 overrides: vec![ConfigOverride {
594 files: vec!["*.test.ts".to_string()],
595 rules: PartialRulesConfig {
596 unused_exports: Some(Severity::Off),
597 ..Default::default()
598 },
599 }],
600 regression: None,
601 audit: crate::config::AuditConfig::default(),
602 codeowners: None,
603 public_packages: vec![],
604 flags: FlagsConfig::default(),
605 security: SecurityConfig::default(),
606 fix: crate::config::FixConfig::default(),
607 resolve: ResolveConfig::default(),
608 sealed: false,
609 include_entry_exports: false,
610 auto_imports: false,
611 cache: CacheConfig::default(),
612 };
613 let resolved = config.resolve(
614 PathBuf::from("/project"),
615 OutputFormat::Human,
616 1,
617 true,
618 true,
619 None,
620 );
621
622 let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
623 assert_eq!(test_rules.unused_exports, Severity::Off);
624 assert_eq!(test_rules.unused_files, Severity::Error); let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
627 assert_eq!(src_rules.unused_exports, Severity::Error);
628 }
629
630 #[test]
631 fn resolve_rules_for_path_later_override_wins() {
632 let config = FallowConfig {
633 schema: None,
634 extends: vec![],
635 entry: vec![],
636 ignore_patterns: vec![],
637 framework: vec![],
638 workspaces: None,
639 ignore_dependencies: vec![],
640 ignore_unresolved_imports: vec![],
641 ignore_exports: vec![],
642 ignore_catalog_references: vec![],
643 ignore_dependency_overrides: vec![],
644 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
645 used_class_members: vec![],
646 ignore_decorators: vec![],
647 duplicates: DuplicatesConfig::default(),
648 health: HealthConfig::default(),
649 rules: RulesConfig::default(),
650 boundaries: BoundaryConfig::default(),
651 production: false.into(),
652 plugins: vec![],
653 dynamically_loaded: vec![],
654 overrides: vec![
655 ConfigOverride {
656 files: vec!["*.ts".to_string()],
657 rules: PartialRulesConfig {
658 unused_files: Some(Severity::Warn),
659 ..Default::default()
660 },
661 },
662 ConfigOverride {
663 files: vec!["*.test.ts".to_string()],
664 rules: PartialRulesConfig {
665 unused_files: Some(Severity::Off),
666 ..Default::default()
667 },
668 },
669 ],
670 regression: None,
671 audit: crate::config::AuditConfig::default(),
672 codeowners: None,
673 public_packages: vec![],
674 flags: FlagsConfig::default(),
675 security: SecurityConfig::default(),
676 fix: crate::config::FixConfig::default(),
677 resolve: ResolveConfig::default(),
678 sealed: false,
679 include_entry_exports: false,
680 auto_imports: false,
681 cache: CacheConfig::default(),
682 };
683 let resolved = config.resolve(
684 PathBuf::from("/project"),
685 OutputFormat::Human,
686 1,
687 true,
688 true,
689 None,
690 );
691
692 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
693 assert_eq!(rules.unused_files, Severity::Off);
694
695 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
696 assert_eq!(rules2.unused_files, Severity::Warn);
697 }
698
699 #[test]
700 fn resolve_keeps_inter_file_rule_override_after_warning() {
701 let config = FallowConfig {
702 schema: None,
703 extends: vec![],
704 entry: vec![],
705 ignore_patterns: vec![],
706 framework: vec![],
707 workspaces: None,
708 ignore_dependencies: vec![],
709 ignore_unresolved_imports: vec![],
710 ignore_exports: vec![],
711 ignore_catalog_references: vec![],
712 ignore_dependency_overrides: vec![],
713 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
714 used_class_members: vec![],
715 ignore_decorators: vec![],
716 duplicates: DuplicatesConfig::default(),
717 health: HealthConfig::default(),
718 rules: RulesConfig::default(),
719 boundaries: BoundaryConfig::default(),
720 production: false.into(),
721 plugins: vec![],
722 dynamically_loaded: vec![],
723 overrides: vec![ConfigOverride {
724 files: vec!["**/ui/**".to_string()],
725 rules: PartialRulesConfig {
726 duplicate_exports: Some(Severity::Off),
727 unused_files: Some(Severity::Warn),
728 ..Default::default()
729 },
730 }],
731 regression: None,
732 audit: crate::config::AuditConfig::default(),
733 codeowners: None,
734 public_packages: vec![],
735 flags: FlagsConfig::default(),
736 security: SecurityConfig::default(),
737 fix: crate::config::FixConfig::default(),
738 resolve: ResolveConfig::default(),
739 sealed: false,
740 include_entry_exports: false,
741 auto_imports: false,
742 cache: CacheConfig::default(),
743 };
744 let resolved = config.resolve(
745 PathBuf::from("/project"),
746 OutputFormat::Human,
747 1,
748 true,
749 true,
750 None,
751 );
752 assert_eq!(
753 resolved.overrides.len(),
754 1,
755 "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
756 );
757 let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
758 assert_eq!(rules.unused_files, Severity::Warn);
759 }
760
761 #[test]
762 fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
763 reset_inter_file_warn_dedup_for_test();
764 let files_a = vec!["__test_dedup_a/*".to_string()];
765 let files_b = vec!["__test_dedup_b/*".to_string()];
766
767 assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
768 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
769 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
770
771 assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
772 assert!(!record_inter_file_warn_seen(
773 "circular-dependency",
774 &files_a
775 ));
776
777 assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
778
779 let files_reordered = vec![
780 "__test_dedup_b/*".to_string(),
781 "__test_dedup_a/*".to_string(),
782 ];
783 let files_natural = vec![
784 "__test_dedup_a/*".to_string(),
785 "__test_dedup_b/*".to_string(),
786 ];
787 reset_inter_file_warn_dedup_for_test();
788 assert!(record_inter_file_warn_seen(
789 "duplicate-exports",
790 &files_natural
791 ));
792 assert!(!record_inter_file_warn_seen(
793 "duplicate-exports",
794 &files_reordered
795 ));
796 }
797
798 #[test]
799 fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
800 reset_inter_file_warn_dedup_for_test();
801 let files = vec!["__test_resolve_dedup/**".to_string()];
802 let build_config = || FallowConfig {
803 schema: None,
804 extends: vec![],
805 entry: vec![],
806 ignore_patterns: vec![],
807 framework: vec![],
808 workspaces: None,
809 ignore_dependencies: vec![],
810 ignore_unresolved_imports: vec![],
811 ignore_exports: vec![],
812 ignore_catalog_references: vec![],
813 ignore_dependency_overrides: vec![],
814 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
815 used_class_members: vec![],
816 ignore_decorators: vec![],
817 duplicates: DuplicatesConfig::default(),
818 health: HealthConfig::default(),
819 rules: RulesConfig::default(),
820 boundaries: BoundaryConfig::default(),
821 production: false.into(),
822 plugins: vec![],
823 dynamically_loaded: vec![],
824 overrides: vec![ConfigOverride {
825 files: files.clone(),
826 rules: PartialRulesConfig {
827 duplicate_exports: Some(Severity::Off),
828 ..Default::default()
829 },
830 }],
831 regression: None,
832 audit: crate::config::AuditConfig::default(),
833 codeowners: None,
834 public_packages: vec![],
835 flags: FlagsConfig::default(),
836 security: SecurityConfig::default(),
837 fix: crate::config::FixConfig::default(),
838 resolve: ResolveConfig::default(),
839 sealed: false,
840 include_entry_exports: false,
841 auto_imports: false,
842 cache: CacheConfig::default(),
843 };
844 for _ in 0..10 {
845 let _ = build_config().resolve(
846 PathBuf::from("/project"),
847 OutputFormat::Human,
848 1,
849 true,
850 true,
851 None,
852 );
853 }
854 assert!(
855 !record_inter_file_warn_seen("duplicate-exports", &files),
856 "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
857 );
858 }
859
860 fn make_config(production: bool) -> FallowConfig {
862 FallowConfig {
863 schema: None,
864 extends: vec![],
865 entry: vec![],
866 ignore_patterns: vec![],
867 framework: vec![],
868 workspaces: None,
869 ignore_dependencies: vec![],
870 ignore_unresolved_imports: vec![],
871 ignore_exports: vec![],
872 ignore_catalog_references: vec![],
873 ignore_dependency_overrides: vec![],
874 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
875 used_class_members: vec![],
876 ignore_decorators: vec![],
877 duplicates: DuplicatesConfig::default(),
878 health: HealthConfig::default(),
879 rules: RulesConfig::default(),
880 boundaries: BoundaryConfig::default(),
881 production: production.into(),
882 plugins: vec![],
883 dynamically_loaded: vec![],
884 overrides: vec![],
885 regression: None,
886 audit: crate::config::AuditConfig::default(),
887 codeowners: None,
888 public_packages: vec![],
889 flags: FlagsConfig::default(),
890 security: SecurityConfig::default(),
891 fix: crate::config::FixConfig::default(),
892 resolve: ResolveConfig::default(),
893 sealed: false,
894 include_entry_exports: false,
895 auto_imports: false,
896 cache: CacheConfig::default(),
897 }
898 }
899
900 #[test]
901 fn resolve_production_forces_dev_deps_off() {
902 let resolved = make_config(true).resolve(
903 PathBuf::from("/project"),
904 OutputFormat::Human,
905 1,
906 true,
907 true,
908 None,
909 );
910 assert_eq!(
911 resolved.rules.unused_dev_dependencies,
912 Severity::Off,
913 "production mode should force unused_dev_dependencies to off"
914 );
915 }
916
917 #[test]
918 fn resolve_production_forces_optional_deps_off() {
919 let resolved = make_config(true).resolve(
920 PathBuf::from("/project"),
921 OutputFormat::Human,
922 1,
923 true,
924 true,
925 None,
926 );
927 assert_eq!(
928 resolved.rules.unused_optional_dependencies,
929 Severity::Off,
930 "production mode should force unused_optional_dependencies to off"
931 );
932 }
933
934 #[test]
935 fn resolve_production_preserves_other_rules() {
936 let resolved = make_config(true).resolve(
937 PathBuf::from("/project"),
938 OutputFormat::Human,
939 1,
940 true,
941 true,
942 None,
943 );
944 assert_eq!(resolved.rules.unused_files, Severity::Error);
945 assert_eq!(resolved.rules.unused_exports, Severity::Error);
946 assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
947 }
948
949 #[test]
950 fn resolve_non_production_keeps_dev_deps_default() {
951 let resolved = make_config(false).resolve(
952 PathBuf::from("/project"),
953 OutputFormat::Human,
954 1,
955 true,
956 true,
957 None,
958 );
959 assert_eq!(
960 resolved.rules.unused_dev_dependencies,
961 Severity::Warn,
962 "non-production should keep default severity"
963 );
964 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
965 }
966
967 #[test]
968 fn resolve_production_flag_stored() {
969 let resolved = make_config(true).resolve(
970 PathBuf::from("/project"),
971 OutputFormat::Human,
972 1,
973 true,
974 true,
975 None,
976 );
977 assert!(resolved.production);
978
979 let resolved2 = make_config(false).resolve(
980 PathBuf::from("/project"),
981 OutputFormat::Human,
982 1,
983 true,
984 true,
985 None,
986 );
987 assert!(!resolved2.production);
988 }
989
990 #[test]
991 fn resolve_default_ignores_node_modules() {
992 let resolved = make_config(false).resolve(
993 PathBuf::from("/project"),
994 OutputFormat::Human,
995 1,
996 true,
997 true,
998 None,
999 );
1000 assert!(
1001 resolved
1002 .ignore_patterns
1003 .is_match("node_modules/lodash/index.js")
1004 );
1005 assert!(
1006 resolved
1007 .ignore_patterns
1008 .is_match("packages/a/node_modules/react/index.js")
1009 );
1010 }
1011
1012 #[test]
1013 fn resolve_default_ignores_dist() {
1014 let resolved = make_config(false).resolve(
1015 PathBuf::from("/project"),
1016 OutputFormat::Human,
1017 1,
1018 true,
1019 true,
1020 None,
1021 );
1022 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1023 assert!(
1024 resolved
1025 .ignore_patterns
1026 .is_match("packages/ui/dist/index.js")
1027 );
1028 }
1029
1030 #[test]
1031 fn resolve_default_ignores_root_build_only() {
1032 let resolved = make_config(false).resolve(
1033 PathBuf::from("/project"),
1034 OutputFormat::Human,
1035 1,
1036 true,
1037 true,
1038 None,
1039 );
1040 assert!(
1041 resolved.ignore_patterns.is_match("build/output.js"),
1042 "root build/ should be ignored"
1043 );
1044 assert!(
1045 !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1046 "nested build/ should NOT be ignored by default"
1047 );
1048 }
1049
1050 #[test]
1051 fn resolve_default_ignores_minified_files() {
1052 let resolved = make_config(false).resolve(
1053 PathBuf::from("/project"),
1054 OutputFormat::Human,
1055 1,
1056 true,
1057 true,
1058 None,
1059 );
1060 assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1061 assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1062 assert!(resolved.ignore_patterns.is_match("lib/legacy.min.cjs"));
1063 assert!(resolved.ignore_patterns.is_match("public/app.bundle.js"));
1064 assert!(
1065 resolved
1066 .ignore_patterns
1067 .is_match("src/vendor/app.bundle.js")
1068 );
1069 assert!(!resolved.ignore_patterns.is_match("src/bundle.ts"));
1071 assert!(!resolved.ignore_patterns.is_match("src/app.cjs"));
1072 }
1073
1074 #[test]
1075 fn resolve_max_file_size_bytes_default_and_unlimited() {
1076 assert_eq!(
1078 resolve_max_file_size_bytes(None),
1079 Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1080 );
1081 assert_eq!(resolve_max_file_size_bytes(Some(0)), None);
1083 assert_eq!(resolve_max_file_size_bytes(Some(2)), Some(2 * 1024 * 1024));
1085 assert_eq!(DEFAULT_MAX_FILE_SIZE_MB, 5);
1086 }
1087
1088 #[test]
1089 fn resolve_sets_default_max_file_size() {
1090 let resolved = make_config(false).resolve(
1091 PathBuf::from("/project"),
1092 OutputFormat::Human,
1093 1,
1094 true,
1095 true,
1096 None,
1097 );
1098 assert_eq!(
1099 resolved.max_file_size_bytes,
1100 Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1101 );
1102 }
1103
1104 #[test]
1105 fn resolve_default_ignores_git() {
1106 let resolved = make_config(false).resolve(
1107 PathBuf::from("/project"),
1108 OutputFormat::Human,
1109 1,
1110 true,
1111 true,
1112 None,
1113 );
1114 assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1115 }
1116
1117 #[test]
1118 fn resolve_default_ignores_coverage() {
1119 let resolved = make_config(false).resolve(
1120 PathBuf::from("/project"),
1121 OutputFormat::Human,
1122 1,
1123 true,
1124 true,
1125 None,
1126 );
1127 assert!(
1128 resolved
1129 .ignore_patterns
1130 .is_match("coverage/lcov-report/index.js")
1131 );
1132 }
1133
1134 #[test]
1135 fn resolve_source_files_not_ignored_by_default() {
1136 let resolved = make_config(false).resolve(
1137 PathBuf::from("/project"),
1138 OutputFormat::Human,
1139 1,
1140 true,
1141 true,
1142 None,
1143 );
1144 assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1145 assert!(
1146 !resolved
1147 .ignore_patterns
1148 .is_match("src/components/Button.tsx")
1149 );
1150 assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1151 }
1152
1153 #[test]
1154 fn resolve_custom_ignore_patterns_merged_with_defaults() {
1155 let mut config = make_config(false);
1156 config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1157 let resolved = config.resolve(
1158 PathBuf::from("/project"),
1159 OutputFormat::Human,
1160 1,
1161 true,
1162 true,
1163 None,
1164 );
1165 assert!(
1166 resolved
1167 .ignore_patterns
1168 .is_match("src/__generated__/types.ts")
1169 );
1170 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1171 }
1172
1173 #[test]
1174 fn resolve_passes_through_entry_patterns() {
1175 let mut config = make_config(false);
1176 config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1177 let resolved = config.resolve(
1178 PathBuf::from("/project"),
1179 OutputFormat::Human,
1180 1,
1181 true,
1182 true,
1183 None,
1184 );
1185 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1186 }
1187
1188 #[test]
1189 fn resolve_passes_through_ignore_dependencies() {
1190 let mut config = make_config(false);
1191 config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1192 let resolved = config.resolve(
1193 PathBuf::from("/project"),
1194 OutputFormat::Human,
1195 1,
1196 true,
1197 true,
1198 None,
1199 );
1200 assert_eq!(
1201 resolved.ignore_dependencies,
1202 vec!["postcss", "autoprefixer"]
1203 );
1204 }
1205
1206 #[test]
1207 fn resolve_compiles_ignore_unresolved_imports_as_raw_specifier_globs() {
1208 let mut config = make_config(false);
1209 config.ignore_unresolved_imports = vec![
1210 "@example/icons".to_string(),
1211 "@example/icons/**".to_string(),
1212 "../generated/**".to_string(),
1213 ];
1214 let resolved = config.resolve(
1215 PathBuf::from("/project"),
1216 OutputFormat::Human,
1217 1,
1218 true,
1219 true,
1220 None,
1221 );
1222
1223 assert!(
1224 resolved
1225 .ignore_unresolved_imports
1226 .iter()
1227 .any(|matcher| matcher.is_match("@example/icons"))
1228 );
1229 assert!(
1230 resolved
1231 .ignore_unresolved_imports
1232 .iter()
1233 .any(|matcher| matcher.is_match("@example/icons/metadata"))
1234 );
1235 assert!(
1236 resolved
1237 .ignore_unresolved_imports
1238 .iter()
1239 .any(|matcher| matcher.is_match("../generated/client"))
1240 );
1241 }
1242
1243 #[test]
1244 fn ignore_unresolved_imports_subpath_glob_does_not_match_bare_specifier() {
1245 let mut config = make_config(false);
1246 config.ignore_unresolved_imports = vec!["@example/icons/**".to_string()];
1247 let resolved = config.resolve(
1248 PathBuf::from("/project"),
1249 OutputFormat::Human,
1250 1,
1251 true,
1252 true,
1253 None,
1254 );
1255
1256 assert!(
1257 !resolved.ignore_unresolved_imports[0].is_match("@example/icons"),
1258 "globset treats @example/icons/** as subpaths only; list the bare specifier separately"
1259 );
1260 assert!(resolved.ignore_unresolved_imports[0].is_match("@example/icons/metadata"));
1261 }
1262
1263 #[test]
1264 fn resolve_sets_cache_dir() {
1265 let resolved = make_config(false).resolve(
1266 PathBuf::from("/my/project"),
1267 OutputFormat::Human,
1268 1,
1269 true,
1270 true,
1271 None,
1272 );
1273 assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1274 }
1275
1276 #[test]
1277 fn resolve_uses_relative_configured_cache_dir_from_root() {
1278 let config = FallowConfig {
1279 cache: crate::CacheConfig {
1280 dir: Some(PathBuf::from(".cache/fallow")),
1281 ..Default::default()
1282 },
1283 ..make_config(false)
1284 };
1285 let resolved = config.resolve(
1286 PathBuf::from("/my/project"),
1287 OutputFormat::Human,
1288 1,
1289 false,
1290 true,
1291 None,
1292 );
1293 assert_eq!(
1294 resolved.cache_dir,
1295 PathBuf::from("/my/project/.cache/fallow")
1296 );
1297 }
1298
1299 #[test]
1300 fn resolve_keeps_absolute_configured_cache_dir() {
1301 let config = FallowConfig {
1302 cache: crate::CacheConfig {
1303 dir: Some(PathBuf::from("/tmp/fallow-cache")),
1304 ..Default::default()
1305 },
1306 ..make_config(false)
1307 };
1308 let resolved = config.resolve(
1309 PathBuf::from("/my/project"),
1310 OutputFormat::Human,
1311 1,
1312 false,
1313 true,
1314 None,
1315 );
1316 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/fallow-cache"));
1317 }
1318
1319 #[test]
1320 fn resolve_passes_through_thread_count() {
1321 let resolved = make_config(false).resolve(
1322 PathBuf::from("/project"),
1323 OutputFormat::Human,
1324 8,
1325 true,
1326 true,
1327 None,
1328 );
1329 assert_eq!(resolved.threads, 8);
1330 }
1331
1332 #[test]
1333 fn resolve_passes_through_quiet_flag() {
1334 let resolved = make_config(false).resolve(
1335 PathBuf::from("/project"),
1336 OutputFormat::Human,
1337 1,
1338 true,
1339 false,
1340 None,
1341 );
1342 assert!(!resolved.quiet);
1343
1344 let resolved2 = make_config(false).resolve(
1345 PathBuf::from("/project"),
1346 OutputFormat::Human,
1347 1,
1348 true,
1349 true,
1350 None,
1351 );
1352 assert!(resolved2.quiet);
1353 }
1354
1355 #[test]
1356 fn resolve_passes_through_no_cache_flag() {
1357 let resolved_no_cache = make_config(false).resolve(
1358 PathBuf::from("/project"),
1359 OutputFormat::Human,
1360 1,
1361 true,
1362 true,
1363 None,
1364 );
1365 assert!(resolved_no_cache.no_cache);
1366
1367 let resolved_with_cache = make_config(false).resolve(
1368 PathBuf::from("/project"),
1369 OutputFormat::Human,
1370 1,
1371 false,
1372 true,
1373 None,
1374 );
1375 assert!(!resolved_with_cache.no_cache);
1376 }
1377
1378 #[test]
1379 #[should_panic(expected = "validated at config load time")]
1380 fn resolve_panics_on_unvalidated_invalid_override_glob() {
1381 let mut config = make_config(false);
1382 config.overrides = vec![ConfigOverride {
1383 files: vec!["[invalid".to_string()],
1384 rules: PartialRulesConfig {
1385 unused_files: Some(Severity::Off),
1386 ..Default::default()
1387 },
1388 }];
1389 let _ = config.resolve(
1390 PathBuf::from("/project"),
1391 OutputFormat::Human,
1392 1,
1393 true,
1394 true,
1395 None,
1396 );
1397 }
1398
1399 #[test]
1400 fn resolve_override_with_empty_files_skipped() {
1401 let mut config = make_config(false);
1402 config.overrides = vec![ConfigOverride {
1403 files: vec![],
1404 rules: PartialRulesConfig {
1405 unused_files: Some(Severity::Off),
1406 ..Default::default()
1407 },
1408 }];
1409 let resolved = config.resolve(
1410 PathBuf::from("/project"),
1411 OutputFormat::Human,
1412 1,
1413 true,
1414 true,
1415 None,
1416 );
1417 assert!(
1418 resolved.overrides.is_empty(),
1419 "override with no file patterns should be skipped"
1420 );
1421 }
1422
1423 #[test]
1424 fn resolve_multiple_valid_overrides() {
1425 let mut config = make_config(false);
1426 config.overrides = vec![
1427 ConfigOverride {
1428 files: vec!["*.test.ts".to_string()],
1429 rules: PartialRulesConfig {
1430 unused_exports: Some(Severity::Off),
1431 ..Default::default()
1432 },
1433 },
1434 ConfigOverride {
1435 files: vec!["*.stories.tsx".to_string()],
1436 rules: PartialRulesConfig {
1437 unused_files: Some(Severity::Off),
1438 ..Default::default()
1439 },
1440 },
1441 ];
1442 let resolved = config.resolve(
1443 PathBuf::from("/project"),
1444 OutputFormat::Human,
1445 1,
1446 true,
1447 true,
1448 None,
1449 );
1450 assert_eq!(resolved.overrides.len(), 2);
1451 }
1452
1453 #[test]
1454 fn ignore_export_rule_deserialize() {
1455 let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1456 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1457 assert_eq!(rule.file, "src/types/*.ts");
1458 assert_eq!(rule.exports, vec!["*"]);
1459 }
1460
1461 #[test]
1462 fn ignore_export_rule_specific_exports() {
1463 let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1464 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1465 assert_eq!(rule.exports.len(), 3);
1466 assert!(rule.exports.contains(&"FOO".to_string()));
1467 }
1468
1469 mod proptests {
1470 use super::*;
1471 use proptest::prelude::*;
1472
1473 fn arb_resolved_config(production: bool) -> ResolvedConfig {
1474 make_config(production).resolve(
1475 PathBuf::from("/project"),
1476 OutputFormat::Human,
1477 1,
1478 true,
1479 true,
1480 None,
1481 )
1482 }
1483
1484 proptest! {
1485 #[test]
1487 fn resolved_config_has_default_ignores(production in any::<bool>()) {
1488 let resolved = arb_resolved_config(production);
1489 prop_assert!(
1490 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1491 "Default ignore should match node_modules"
1492 );
1493 prop_assert!(
1494 resolved.ignore_patterns.is_match("dist/bundle.js"),
1495 "Default ignore should match dist"
1496 );
1497 }
1498
1499 #[test]
1501 fn production_forces_dev_deps_off(_unused in Just(())) {
1502 let resolved = arb_resolved_config(true);
1503 prop_assert_eq!(
1504 resolved.rules.unused_dev_dependencies,
1505 Severity::Off,
1506 "Production should force unused_dev_dependencies off"
1507 );
1508 prop_assert_eq!(
1509 resolved.rules.unused_optional_dependencies,
1510 Severity::Off,
1511 "Production should force unused_optional_dependencies off"
1512 );
1513 }
1514
1515 #[test]
1517 fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1518 let resolved = arb_resolved_config(false);
1519 prop_assert_eq!(
1520 resolved.rules.unused_dev_dependencies,
1521 Severity::Warn,
1522 "Non-production should keep default dev dep severity"
1523 );
1524 }
1525
1526 #[test]
1528 fn cache_dir_defaults_to_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1529 let root = PathBuf::from(format!("/project/{dir_suffix}"));
1530 let expected_cache = root.join(".fallow");
1531 let resolved = make_config(false).resolve(
1532 root,
1533 OutputFormat::Human,
1534 1,
1535 true,
1536 true,
1537 None,
1538 );
1539 prop_assert_eq!(
1540 resolved.cache_dir, expected_cache,
1541 "Default cache dir should be root/.fallow"
1542 );
1543 }
1544
1545 #[test]
1547 fn threads_passed_through(threads in 1..64usize) {
1548 let resolved = make_config(false).resolve(
1549 PathBuf::from("/project"),
1550 OutputFormat::Human,
1551 threads,
1552 true,
1553 true, None,
1554 );
1555 prop_assert_eq!(
1556 resolved.threads, threads,
1557 "Thread count should be passed through"
1558 );
1559 }
1560
1561 #[test]
1565 fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
1566 let mut config = make_config(false);
1567 config.ignore_patterns = vec![pattern];
1568 let resolved = config.resolve(
1569 PathBuf::from("/project"),
1570 OutputFormat::Human,
1571 1,
1572 true,
1573 true, None,
1574 );
1575 prop_assert!(
1576 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1577 "Default node_modules ignore should still be active"
1578 );
1579 }
1580 }
1581 }
1582
1583 #[test]
1584 fn resolve_expands_boundary_preset() {
1585 use crate::config::boundaries::BoundaryPreset;
1586
1587 let mut config = make_config(false);
1588 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1589 let resolved = config.resolve(
1590 PathBuf::from("/project"),
1591 OutputFormat::Human,
1592 1,
1593 true,
1594 true,
1595 None,
1596 );
1597 assert_eq!(resolved.boundaries.zones.len(), 3);
1598 assert_eq!(resolved.boundaries.rules.len(), 3);
1599 assert_eq!(resolved.boundaries.zones[0].name, "adapters");
1600 assert_eq!(
1601 resolved.boundaries.classify_zone("src/adapters/http.ts"),
1602 Some("adapters")
1603 );
1604 }
1605
1606 #[test]
1607 fn resolve_boundary_preset_with_user_override() {
1608 use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
1609
1610 let mut config = make_config(false);
1611 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1612 config.boundaries.zones = vec![BoundaryZone {
1613 name: "domain".to_string(),
1614 patterns: vec!["src/core/**".to_string()],
1615 auto_discover: vec![],
1616 root: None,
1617 }];
1618 let resolved = config.resolve(
1619 PathBuf::from("/project"),
1620 OutputFormat::Human,
1621 1,
1622 true,
1623 true,
1624 None,
1625 );
1626 assert_eq!(resolved.boundaries.zones.len(), 3);
1627 assert_eq!(
1628 resolved.boundaries.classify_zone("src/core/user.ts"),
1629 Some("domain")
1630 );
1631 assert_eq!(
1632 resolved.boundaries.classify_zone("src/domain/user.ts"),
1633 None
1634 );
1635 }
1636
1637 #[test]
1638 fn resolve_no_preset_unchanged() {
1639 let config = make_config(false);
1640 let resolved = config.resolve(
1641 PathBuf::from("/project"),
1642 OutputFormat::Human,
1643 1,
1644 true,
1645 true,
1646 None,
1647 );
1648 assert!(resolved.boundaries.is_empty());
1649 }
1650}