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