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