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