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