1use std::ffi::OsStr;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex, OnceLock};
4
5use fallow_config::{
6 DEFAULT_IGNORE_PATTERNS, IgnorePatternSet, ResolvedConfig, WorkspaceDiagnostic,
7 WorkspaceDiagnosticKind,
8};
9use fallow_types::discover::{DiscoveredFile, FileId};
10use fallow_types::path_util::display_relative;
11use fallow_types::workspace::glob_first_literal_segment;
12use ignore::WalkBuilder;
13use rustc_hash::{FxHashMap, FxHashSet};
14
15use super::{ALLOWED_HIDDEN_DIRS, SCRIPT_SCOPE_DENYLIST};
16
17fn should_emit_note_once(key: String) -> bool {
23 static EMITTED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
24 EMITTED
25 .get_or_init(|| Mutex::new(FxHashSet::default()))
26 .lock()
27 .map_or(true, |mut set| set.insert(key))
28}
29
30type SizedFile = (PathBuf, u64);
33
34type SkippedDotdirSink = Arc<Mutex<Vec<PathBuf>>>;
42
43#[derive(Default)]
60struct ExcludedByPattern {
61 file_count: u32,
63 scopes: FxHashMap<PathBuf, u32>,
65}
66
67impl ExcludedByPattern {
68 fn record(&mut self, scope: PathBuf) {
69 self.file_count = self.file_count.saturating_add(1);
70 *self.scopes.entry(scope).or_insert(0) += 1;
71 }
72
73 fn merge(&mut self, other: Self) {
74 self.file_count = self.file_count.saturating_add(other.file_count);
75 for (scope, count) in other.scopes {
76 *self.scopes.entry(scope).or_insert(0) += count;
77 }
78 }
79
80 fn directory_count(&self) -> u32 {
87 u32::try_from(self.scopes.len()).unwrap_or(u32::MAX)
88 }
89
90 fn anchor(&self) -> PathBuf {
94 self.scopes
95 .iter()
96 .max_by(|(left_path, left_count), (right_path, right_count)| {
97 left_count
98 .cmp(right_count)
99 .then_with(|| right_path.cmp(left_path))
100 })
101 .map(|(path, _)| path.clone())
102 .unwrap_or_default()
103 }
104}
105
106type ExclusionTally = FxHashMap<usize, ExcludedByPattern>;
109
110const UNREPORTED_DEFAULT_IGNORES: &[&str] = &["**/node_modules/**", "**/.git/**"];
121
122fn exclusion_scope(relative: &Path, pattern: &str) -> PathBuf {
136 let parent = relative.parent().unwrap_or_else(|| Path::new(""));
137 let Some(literal) = glob_first_literal_segment(pattern) else {
138 return parent.to_path_buf();
139 };
140 let mut prefix = PathBuf::new();
141 let mut deepest = None;
142 for component in parent.components() {
143 prefix.push(component);
144 if component.as_os_str() == OsStr::new(literal) {
145 deepest = Some(prefix.clone());
146 }
147 }
148 deepest.unwrap_or_else(|| parent.to_path_buf())
149}
150
151const NOTE_EXAMPLE_CAP: usize = 5;
155
156const DOTDIR_SCAN_MAX_DEPTH: usize = 2;
161
162const DOTDIR_SCAN_MAX_ENTRIES: usize = 256;
168
169const DOTDIR_SCAN_TOTAL_ENTRIES: usize = 1024;
180
181const DOTDIR_SCAN_MAX_CANDIDATES: usize = 64;
188
189const DOTDIR_MODULE_EXTENSIONS: &[&str] = &[
196 "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
197 "mdx",
198];
199
200const LARGE_SET_THRESHOLD: usize = 20_000;
204
205const LARGE_FILE_NOTE_BYTES: u64 = 4 * 1024 * 1024;
210
211const NOTE_FILE_FLOOR_BYTES: u64 = 256 * 1024;
215
216const MINIFIED_FILE_SKIP_BYTES: u64 = 1024 * 1024;
220
221const MINIFIED_SAMPLE_BYTES: usize = 256 * 1024;
223
224const MINIFIED_LONG_LINE_BYTES: usize = 128 * 1024;
227
228fn is_declaration_file(path: &Path) -> bool {
233 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
234 name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
235}
236
237fn is_plain_js_file(path: &Path) -> bool {
238 matches!(
239 path.extension().and_then(|ext| ext.to_str()),
240 Some("js" | "mjs" | "cjs")
241 )
242}
243
244fn has_minified_line_shape(path: &Path) -> bool {
245 use std::io::Read;
246
247 let Ok(mut file) = std::fs::File::open(path) else {
248 return false;
249 };
250 let mut sample = vec![0; MINIFIED_SAMPLE_BYTES];
251 let Ok(len) = file.read(&mut sample) else {
252 return false;
253 };
254 sample.truncate(len);
255 if sample.is_empty() {
256 return false;
257 }
258
259 let mut current_line = 0usize;
260 for byte in sample {
261 if byte == b'\n' || byte == b'\r' {
262 current_line = 0;
263 continue;
264 }
265 current_line += 1;
266 if current_line >= MINIFIED_LONG_LINE_BYTES {
267 return true;
268 }
269 }
270 false
271}
272
273fn is_probably_minified_generated_js(path: &Path, size_bytes: u64) -> bool {
274 size_bytes >= MINIFIED_FILE_SKIP_BYTES
275 && is_plain_js_file(path)
276 && !is_declaration_file(path)
277 && has_minified_line_shape(path)
278}
279
280fn format_size_mb(bytes: u64) -> String {
282 #[expect(
283 clippy::cast_precision_loss,
284 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
285 )]
286 let mb = bytes as f64 / (1024.0 * 1024.0);
287 format!("{mb:.1} MB")
288}
289
290fn summarize_examples(root: &Path, examples: &[SizedFile]) -> String {
293 let shown: Vec<String> = examples
294 .iter()
295 .take(NOTE_EXAMPLE_CAP)
296 .map(|(path, size)| {
297 let display = display_relative(root, path);
298 format!("{display} ({})", format_size_mb(*size))
299 })
300 .collect();
301 let remaining = examples.len().saturating_sub(NOTE_EXAMPLE_CAP);
302 if remaining > 0 {
303 format!("{}, and {remaining} more", shown.join(", "))
304 } else {
305 shown.join(", ")
306 }
307}
308
309fn partition_by_size(
312 raw: Vec<SizedFile>,
313 max_file_size_bytes: Option<u64>,
314) -> (Vec<SizedFile>, Vec<SizedFile>) {
315 let Some(limit) = max_file_size_bytes else {
316 return (raw, Vec::new());
317 };
318 raw.into_iter()
319 .partition(|(path, size)| *size <= limit || is_declaration_file(path))
320}
321
322fn partition_minified_generated_js(
325 raw: Vec<SizedFile>,
326 max_file_size_bytes: Option<u64>,
327) -> (Vec<SizedFile>, Vec<SizedFile>) {
328 if max_file_size_bytes.is_none() {
329 return (raw, Vec::new());
330 }
331 raw.into_iter()
332 .partition(|(path, size)| !is_probably_minified_generated_js(path, *size))
333}
334
335fn report_missing_node_modules(config: &ResolvedConfig) -> Vec<WorkspaceDiagnostic> {
346 let Some(diagnostic) = fallow_config::missing_node_modules_diagnostic(&config.root) else {
347 return Vec::new();
348 };
349 if !config.quiet
350 && should_emit_note_once(format!("node-modules-missing::{}", config.root.display()))
351 {
352 tracing::warn!("fallow: {}", diagnostic.message);
353 }
354 vec![diagnostic]
355}
356
357fn report_skipped_large_files(
358 config: &ResolvedConfig,
359 skipped: &[SizedFile],
360) -> Vec<WorkspaceDiagnostic> {
361 if skipped.is_empty() {
362 return Vec::new();
363 }
364 let diagnostics: Vec<WorkspaceDiagnostic> = skipped
365 .iter()
366 .map(|(path, size_bytes)| {
367 WorkspaceDiagnostic::new(
368 &config.root,
369 path.clone(),
370 WorkspaceDiagnosticKind::SkippedLargeFile {
371 size_bytes: *size_bytes,
372 },
373 )
374 })
375 .collect();
376
377 let mut sorted: Vec<SizedFile> = skipped.to_vec();
378 sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
379 let count = skipped.len();
380 if !config.quiet
381 && should_emit_note_once(format!(
382 "skip::{}::{count}::{}",
383 config.root.display(),
384 sorted.first().map_or(0, |f| f.1)
385 ))
386 {
387 let examples = summarize_examples(&config.root, &sorted);
388 let noun = if count == 1 { "file" } else { "files" };
389 tracing::warn!(
390 "fallow: skipped {count} {noun} over the max file size limit ({examples}). \
391 Raise the limit with --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add them to ignorePatterns."
392 );
393 }
394 diagnostics
395}
396
397fn report_skipped_minified_files(
400 config: &ResolvedConfig,
401 skipped: &[SizedFile],
402) -> Vec<WorkspaceDiagnostic> {
403 if skipped.is_empty() {
404 return Vec::new();
405 }
406 let diagnostics: Vec<WorkspaceDiagnostic> = skipped
407 .iter()
408 .map(|(path, size_bytes)| {
409 WorkspaceDiagnostic::new(
410 &config.root,
411 path.clone(),
412 WorkspaceDiagnosticKind::SkippedMinifiedFile {
413 size_bytes: *size_bytes,
414 },
415 )
416 })
417 .collect();
418
419 let mut sorted: Vec<SizedFile> = skipped.to_vec();
420 sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
421 let count = skipped.len();
422 if !config.quiet
423 && should_emit_note_once(format!(
424 "minified::{}::{count}::{}",
425 config.root.display(),
426 sorted.first().map_or(0, |f| f.1)
427 ))
428 {
429 let examples = summarize_examples(&config.root, &sorted);
430 let noun = if count == 1 { "file" } else { "files" };
431 let pronoun = if count == 1 { "it" } else { "them" };
432 tracing::warn!(
433 "fallow: skipped {count} minified generated JS {noun} ({examples}). \
434 Add {pronoun} to ignorePatterns, rename {pronoun} with a .min.js suffix, or use --max-file-size 0 to analyze {pronoun}."
435 );
436 }
437 diagnostics
438}
439
440fn summarize_paths(root: &Path, examples: &[&PathBuf]) -> String {
444 let shown: Vec<String> = examples
445 .iter()
446 .take(NOTE_EXAMPLE_CAP)
447 .map(|path| display_relative(root, path))
448 .collect();
449 let remaining = examples.len().saturating_sub(NOTE_EXAMPLE_CAP);
450 if remaining > 0 {
451 format!("{}, and {remaining} more", shown.join(", "))
452 } else {
453 shown.join(", ")
454 }
455}
456
457fn summarize_paths_open_ended(root: &Path, examples: &[&PathBuf]) -> String {
460 let shown: Vec<String> = examples
461 .iter()
462 .take(NOTE_EXAMPLE_CAP)
463 .map(|path| display_relative(root, path))
464 .collect();
465 if examples.len() > NOTE_EXAMPLE_CAP {
466 format!("{}, and more", shown.join(", "))
467 } else {
468 shown.join(", ")
469 }
470}
471
472fn is_excluded_from_analysis(
490 config: &ResolvedConfig,
491 production_excludes: Option<&globset::GlobSet>,
492 path: &Path,
493) -> bool {
494 let relative = path.strip_prefix(&config.root).unwrap_or(path);
495 config.ignore_patterns.is_match(relative)
496 || production_excludes.is_some_and(|excludes| excludes.is_match(relative))
497}
498
499fn has_module_extension(path: &Path) -> bool {
503 path.extension()
504 .and_then(OsStr::to_str)
505 .is_some_and(|ext| DOTDIR_MODULE_EXTENSIONS.contains(&ext))
506}
507
508fn scan_for_reportable_source(
531 config: &ResolvedConfig,
532 production_excludes: Option<&globset::GlobSet>,
533 dir: &Path,
534 budget: &mut usize,
535) -> bool {
536 let mut builder = WalkBuilder::new(dir);
537 builder
538 .hidden(false)
539 .git_ignore(true)
540 .git_global(true)
541 .git_exclude(true)
542 .follow_links(false)
543 .max_depth(Some(DOTDIR_SCAN_MAX_DEPTH + 1))
544 .threads(1);
545 builder.filter_entry(|entry| {
546 if entry.depth() == 0 || !entry.file_type().is_some_and(|ft| ft.is_dir()) {
547 return true;
548 }
549 entry
550 .file_name()
551 .to_str()
552 .is_none_or(|name| !SCRIPT_SCOPE_DENYLIST.contains(&name) && name != "node_modules")
553 });
554
555 let mut per_dotdir = DOTDIR_SCAN_MAX_ENTRIES;
556 for entry in builder.build() {
557 if per_dotdir == 0 || *budget == 0 {
558 return false;
559 }
560 per_dotdir -= 1;
561 *budget -= 1;
562 let Ok(entry) = entry else {
563 continue;
564 };
565 #[expect(
568 clippy::filetype_is_file,
569 reason = "regular files only is the point: !is_dir() would readmit fifos and sockets"
570 )]
571 let is_regular_file = entry
572 .file_type()
573 .is_some_and(|file_type| file_type.is_file());
574 if !is_regular_file {
575 continue;
576 }
577 if has_module_extension(entry.path())
578 && !is_excluded_from_analysis(config, production_excludes, entry.path())
579 {
580 return true;
581 }
582 }
583 false
584}
585
586const DOTDIR_NOISE_PATH_COMPONENTS: &[&str] = &[
590 "__fixtures__",
591 "__mocks__",
592 "__tests__",
593 "e2e",
594 "fixture",
595 "fixtures",
596 "playground",
597 "playgrounds",
598 "spec",
599 "test",
600 "tests",
601];
602
603fn dotdir_is_scan_candidate(config: &ResolvedConfig, dir: &Path) -> bool {
610 let Some(name) = dir.file_name().and_then(OsStr::to_str) else {
611 return false;
612 };
613 if SCRIPT_SCOPE_DENYLIST.contains(&name) {
614 return false;
615 }
616 let relative = dir.strip_prefix(&config.root).unwrap_or(dir);
617 if relative
621 .components()
622 .any(|component| component.as_os_str() == OsStr::new("node_modules"))
623 {
624 return false;
625 }
626 !relative.components().any(|component| {
635 DOTDIR_NOISE_PATH_COMPONENTS.contains(&component.as_os_str().to_string_lossy().as_ref())
636 })
637}
638
639fn report_default_ignore_exclusions(
648 config: &ResolvedConfig,
649 tally: &ExclusionTally,
650) -> Vec<WorkspaceDiagnostic> {
651 let mut indices: Vec<usize> = tally.keys().copied().collect();
652 indices.sort_unstable();
653 indices
654 .into_iter()
655 .filter_map(|index| {
656 let excluded = tally.get(&index)?;
657 let pattern = DEFAULT_IGNORE_PATTERNS.get(index)?;
658 Some(
659 WorkspaceDiagnostic::new(
660 &config.root,
661 config.root.join(excluded.anchor()),
662 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
663 pattern: (*pattern).to_owned(),
664 file_count: excluded.file_count,
665 directory_count: excluded.directory_count(),
666 },
667 )
668 .into_root_relative(&config.root),
675 )
676 })
677 .collect()
678}
679
680fn report_no_source_files_analyzed(
693 config: &ResolvedConfig,
694 analyzed_file_count: usize,
695 tally: &ExclusionTally,
696) -> Vec<WorkspaceDiagnostic> {
697 if analyzed_file_count > 0 {
698 return Vec::new();
699 }
700 let excluded_file_count = tally.values().map(|excluded| excluded.file_count).sum();
701 vec![
702 WorkspaceDiagnostic::new(
703 &config.root,
704 config.root.clone(),
705 WorkspaceDiagnosticKind::NoSourceFilesAnalyzed {
706 excluded_file_count,
707 },
708 )
709 .into_root_relative(&config.root),
710 ]
711}
712
713fn report_skipped_source_dotdirs(
719 config: &ResolvedConfig,
720 production_excludes: Option<&globset::GlobSet>,
721 candidates: &[PathBuf],
722) -> Vec<WorkspaceDiagnostic> {
723 if candidates.is_empty() {
724 return Vec::new();
725 }
726 let mut budget = DOTDIR_SCAN_TOTAL_ENTRIES;
729 let scannable: Vec<&PathBuf> = candidates
730 .iter()
731 .filter(|dir| dotdir_is_scan_candidate(config, dir))
732 .collect();
733 let reportable: Vec<&PathBuf> = scannable
734 .iter()
735 .copied()
736 .take(DOTDIR_SCAN_MAX_CANDIDATES)
737 .filter(|dir| scan_for_reportable_source(config, production_excludes, dir, &mut budget))
738 .collect();
739 let truncated = scannable.len() > DOTDIR_SCAN_MAX_CANDIDATES || budget == 0;
742 if reportable.is_empty() {
743 return Vec::new();
744 }
745
746 let diagnostics: Vec<WorkspaceDiagnostic> = reportable
747 .iter()
748 .map(|dir| {
749 WorkspaceDiagnostic::new(
750 &config.root,
751 (*dir).clone(),
752 WorkspaceDiagnosticKind::SkippedSourceDotdir,
753 )
754 })
755 .collect();
756
757 let count = reportable.len();
758 if !config.quiet
759 && should_emit_note_once(format!(
760 "dotdir::{}::{count}::{}",
761 config.root.display(),
762 reportable
763 .first()
764 .map_or_else(String::new, |dir| display_relative(&config.root, dir))
765 ))
766 {
767 tracing::warn!(
768 "{}",
769 build_skipped_dotdirs_note(&config.root, &reportable, truncated)
770 );
771 }
772 diagnostics
773}
774
775fn build_skipped_dotdirs_note(root: &Path, reportable: &[&PathBuf], truncated: bool) -> String {
785 let count = reportable.len();
786 let examples = if truncated {
790 summarize_paths_open_ended(root, reportable)
791 } else {
792 summarize_paths(root, reportable)
793 };
794 let noun = if count == 1 {
795 "directory"
796 } else {
797 "directories"
798 };
799 let verb = if count == 1 { "contains" } else { "contain" };
800 let at_least = if truncated { "at least " } else { "" };
801 let (target, pronoun) = match reportable {
802 [only] => (display_relative(root, only), "it"),
803 _ => ("<dir>".to_owned(), "one"),
804 };
805 format!(
806 "fallow: skipped {at_least}{count} hidden {noun} that {verb} source files ({examples}). \
807 Hidden directories are not traversed, so a file, export or dependency used only \
808 there can be reported as unused: add '!{target}/**' to ignorePatterns to analyze \
809 {pronoun}, or add it to entry, ignoreExports or ignoreDependencies, or add \
810 '{target}/**' to ignorePatterns to silence this. fallow --root {target} analyzes \
811 {pronoun} on its own and does not fix this run."
812 )
813}
814
815fn build_largest_files_note(root: &Path, files: &[DiscoveredFile]) -> Option<String> {
820 if files.is_empty() {
821 return None;
822 }
823 let largest = files.iter().map(|f| f.size_bytes).max().unwrap_or(0);
824 if files.len() <= LARGE_SET_THRESHOLD && largest < LARGE_FILE_NOTE_BYTES {
825 return None;
826 }
827 let count = files.len();
828 let noun = if count == 1 { "file" } else { "files" };
829 let mut by_size: Vec<SizedFile> = files
830 .iter()
831 .filter(|f| f.size_bytes >= NOTE_FILE_FLOOR_BYTES)
832 .map(|f| (f.path.clone(), f.size_bytes))
833 .collect();
834 by_size.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
835 if by_size.is_empty() {
836 return Some(format!(
839 "fallow: discovered {count} {noun}. If analysis stalls or runs out of memory, \
840 exclude large generated files via ignorePatterns or --max-file-size."
841 ));
842 }
843 let examples = summarize_examples(root, &by_size);
844 Some(format!(
845 "fallow: discovered {count} {noun}; largest: {examples}. If analysis stalls or runs out of memory, \
846 exclude large generated files via ignorePatterns or --max-file-size."
847 ))
848}
849
850fn note_largest_files(config: &ResolvedConfig, files: &[DiscoveredFile]) {
855 if config.quiet {
856 return;
857 }
858 if let Some(message) = build_largest_files_note(&config.root, files)
859 && should_emit_note_once(format!("note::{}::{}", config.root.display(), files.len()))
860 {
861 tracing::warn!("{message}");
862 }
863}
864
865#[derive(Debug, Clone, Copy, PartialEq, Eq)]
867pub enum HiddenDirMatch {
868 AnyDepth,
874 ExactPath,
881}
882
883#[derive(Debug, Clone, PartialEq, Eq)]
885pub struct HiddenDirScope {
886 root: PathBuf,
887 dirs: Vec<String>,
888 match_mode: HiddenDirMatch,
889}
890
891impl HiddenDirScope {
892 #[must_use]
899 pub fn new(root: PathBuf, dirs: Vec<String>) -> Self {
900 Self {
901 root,
902 dirs,
903 match_mode: HiddenDirMatch::AnyDepth,
904 }
905 }
906
907 #[must_use]
910 pub fn new_exact_paths(root: PathBuf, dirs: Vec<String>) -> Self {
911 Self {
912 root,
913 dirs,
914 match_mode: HiddenDirMatch::ExactPath,
915 }
916 }
917
918 #[must_use]
923 pub fn with_match_mode(root: PathBuf, dirs: Vec<String>, match_mode: HiddenDirMatch) -> Self {
924 Self {
925 root,
926 dirs,
927 match_mode,
928 }
929 }
930
931 #[must_use]
932 pub fn root(&self) -> &Path {
933 &self.root
934 }
935
936 #[must_use]
937 pub fn dirs(&self) -> &[String] {
938 &self.dirs
939 }
940
941 #[must_use]
942 pub fn match_mode(&self) -> HiddenDirMatch {
943 self.match_mode
944 }
945
946 fn allows(&self, path: &Path, name: &OsStr) -> bool {
947 match self.match_mode {
948 HiddenDirMatch::AnyDepth => {
949 path.starts_with(&self.root) && self.dirs.iter().any(|dir| OsStr::new(dir) == name)
950 }
951 HiddenDirMatch::ExactPath => {
952 let Ok(relative) = path.strip_prefix(&self.root) else {
955 return false;
956 };
957 self.dirs.iter().any(|dir| Path::new(dir) == relative)
958 }
959 }
960 }
961}
962
963struct FileVisitor<'a> {
970 root: &'a Path,
971 canonical_root: Option<&'a Path>,
972 ignore_patterns: &'a IgnorePatternSet,
973 user_ignore_pattern_count: usize,
976 hidden_dir_scopes: &'a [HiddenDirScope],
980 production_excludes: &'a Option<globset::GlobSet>,
981 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
982 config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
983 excluded_shared: &'a Mutex<ExclusionTally>,
984 local: Vec<(std::path::PathBuf, u64)>,
985 config_local: Vec<std::path::PathBuf>,
986 excluded_local: ExclusionTally,
987 match_buf: Vec<usize>,
990}
991
992impl FileVisitor<'_> {
993 fn record_default_ignore_exclusion(&mut self, relative: &Path) {
1000 if relative
1006 .components()
1007 .any(|component| component.as_os_str() == OsStr::new("node_modules"))
1008 {
1009 return;
1010 }
1011 self.match_buf.clear();
1012 self.ignore_patterns
1013 .matches_into(relative, &mut self.match_buf);
1014 let Some(&first) = self.match_buf.first() else {
1017 return;
1018 };
1019 if first < self.user_ignore_pattern_count {
1020 return;
1023 }
1024 let Some(pattern) = DEFAULT_IGNORE_PATTERNS.get(first - self.user_ignore_pattern_count)
1025 else {
1026 return;
1027 };
1028 if UNREPORTED_DEFAULT_IGNORES.contains(pattern) {
1029 return;
1030 }
1031 self.excluded_local
1032 .entry(first - self.user_ignore_pattern_count)
1033 .or_default()
1034 .record(exclusion_scope(relative, pattern));
1035 }
1036}
1037
1038impl ignore::ParallelVisitor for FileVisitor<'_> {
1039 fn visit(&mut self, result: Result<ignore::DirEntry, ignore::Error>) -> ignore::WalkState {
1040 let Ok(entry) = result else {
1041 return ignore::WalkState::Continue;
1042 };
1043 if entry.file_type().is_some_and(|ft| ft.is_dir()) {
1044 return ignore::WalkState::Continue;
1045 }
1046 let relative = entry
1047 .path()
1048 .strip_prefix(self.root)
1049 .unwrap_or_else(|_| entry.path());
1050 if self.ignore_patterns.is_match(relative) {
1051 if has_source_extension(entry.path()) {
1052 self.record_default_ignore_exclusion(relative);
1053 }
1054 return ignore::WalkState::Continue;
1055 }
1056 if self
1057 .production_excludes
1058 .as_ref()
1059 .is_some_and(|excludes| excludes.is_match(relative))
1060 {
1061 return ignore::WalkState::Continue;
1062 }
1063 if self.ignore_patterns.has_exceptions()
1064 && is_under_exception_only_hidden_dir(self.root, entry.path(), self.hidden_dir_scopes)
1065 && !self.ignore_patterns.is_lifted(relative)
1066 {
1067 return ignore::WalkState::Continue;
1068 }
1069 let symlink_size = if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
1070 let Some(size) = contained_symlink_file_size(entry.path(), self.canonical_root) else {
1071 tracing::debug!(
1072 path = %entry.path().display(),
1073 "skipping source symlink with a broken, non-file, or outside-root target"
1074 );
1075 return ignore::WalkState::Continue;
1076 };
1077 Some(size)
1078 } else {
1079 None
1080 };
1081 if has_source_extension(entry.path()) {
1082 let size_bytes =
1083 symlink_size.unwrap_or_else(|| entry.metadata().map_or(0, |m| m.len()));
1084 self.local.push((entry.into_path(), size_bytes));
1085 } else if self.config_shared.is_some() {
1086 self.config_local.push(entry.into_path());
1089 }
1090 ignore::WalkState::Continue
1091 }
1092}
1093
1094fn contained_symlink_file_size(path: &Path, canonical_root: Option<&Path>) -> Option<u64> {
1095 let root = canonical_root?;
1096 let target = path.canonicalize().ok()?;
1097 if !target.starts_with(root) {
1098 return None;
1099 }
1100 let metadata = target.metadata().ok()?;
1101 metadata.is_file().then_some(metadata.len())
1102}
1103
1104impl Drop for FileVisitor<'_> {
1105 #[expect(
1106 clippy::expect_used,
1107 reason = "poisoned walk collector lock means worker state is unrecoverable"
1108 )]
1109 fn drop(&mut self) {
1110 if !self.local.is_empty() {
1111 self.shared
1112 .lock()
1113 .expect("walk collector lock poisoned")
1114 .append(&mut self.local);
1115 }
1116 if let Some(config_shared) = self.config_shared
1117 && !self.config_local.is_empty()
1118 {
1119 config_shared
1120 .lock()
1121 .expect("walk config collector lock poisoned")
1122 .append(&mut self.config_local);
1123 }
1124 if !self.excluded_local.is_empty() {
1125 let mut shared = self
1126 .excluded_shared
1127 .lock()
1128 .expect("walk exclusion collector lock poisoned");
1129 for (index, tally) in std::mem::take(&mut self.excluded_local) {
1130 shared.entry(index).or_default().merge(tally);
1131 }
1132 }
1133 }
1134}
1135
1136struct FileVisitorBuilder<'a> {
1138 root: &'a Path,
1139 canonical_root: Option<&'a Path>,
1140 ignore_patterns: &'a IgnorePatternSet,
1141 user_ignore_pattern_count: usize,
1142 hidden_dir_scopes: &'a [HiddenDirScope],
1143 production_excludes: &'a Option<globset::GlobSet>,
1144 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
1145 config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
1146 excluded_shared: &'a Mutex<ExclusionTally>,
1147}
1148
1149impl<'s> ignore::ParallelVisitorBuilder<'s> for FileVisitorBuilder<'s> {
1150 fn build(&mut self) -> Box<dyn ignore::ParallelVisitor + 's> {
1151 Box::new(FileVisitor {
1152 root: self.root,
1153 canonical_root: self.canonical_root,
1154 ignore_patterns: self.ignore_patterns,
1155 user_ignore_pattern_count: self.user_ignore_pattern_count,
1156 hidden_dir_scopes: self.hidden_dir_scopes,
1157 production_excludes: self.production_excludes,
1158 shared: self.shared,
1159 config_shared: self.config_shared,
1160 excluded_shared: self.excluded_shared,
1161 local: Vec::new(),
1162 config_local: Vec::new(),
1163 excluded_local: ExclusionTally::default(),
1164 match_buf: Vec::new(),
1165 })
1166 }
1167}
1168
1169pub const SOURCE_EXTENSIONS: &[&str] = &[
1171 "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
1172 "mdx", "css", "scss", "sass", "less", "html", "graphql", "gql",
1173];
1174
1175pub const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
1177 "**/*.test.*",
1178 "**/*.spec.*",
1179 "**/*.e2e.*",
1180 "**/*.e2e-spec.*",
1181 "**/*.bench.*",
1182 "**/*.fixture.*",
1183 "**/*.stories.*",
1184 "**/*.story.*",
1185 "**/__tests__/**",
1186 "**/__mocks__/**",
1187 "**/__snapshots__/**",
1188 "**/__fixtures__/**",
1189 "**/test-d/**",
1190 "**/test/**",
1191 "**/tests/**",
1192 "*.config.*",
1193 "**/.*.js",
1194 "**/.*.ts",
1195 "**/.*.mjs",
1196 "**/.*.cjs",
1197];
1198
1199pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
1201 ALLOWED_HIDDEN_DIRS.iter().any(|&d| OsStr::new(d) == name)
1202}
1203
1204fn is_allowed_scoped_hidden_dir(
1205 name: &OsStr,
1206 path: &Path,
1207 additional_hidden_dir_scopes: &[HiddenDirScope],
1208) -> bool {
1209 additional_hidden_dir_scopes
1210 .iter()
1211 .any(|scope| scope.allows(path, name))
1212}
1213
1214const YARN_PNP_GENERATED_FILES: &[&str] = &[".pnp.cjs", ".pnp.loader.mjs"];
1219
1220fn is_yarn_pnp_generated_file(name: &OsStr) -> bool {
1221 YARN_PNP_GENERATED_FILES
1222 .iter()
1223 .any(|&f| OsStr::new(f) == name)
1224}
1225
1226fn is_allowed_hidden_with_scopes(
1232 entry: &ignore::DirEntry,
1233 additional_hidden_dir_scopes: &[HiddenDirScope],
1234) -> bool {
1235 let name = entry.file_name();
1236 let name_str = name.to_string_lossy();
1237
1238 if !name_str.starts_with('.') {
1239 return true;
1240 }
1241
1242 if entry.file_type().is_some_and(|ft| !ft.is_dir()) {
1243 return !is_yarn_pnp_generated_file(name);
1244 }
1245
1246 is_allowed_hidden_dir(name)
1247 || is_allowed_scoped_hidden_dir(name, entry.path(), additional_hidden_dir_scopes)
1248}
1249
1250fn is_under_exception_only_hidden_dir(
1255 root: &Path,
1256 path: &Path,
1257 hidden_dir_scopes: &[HiddenDirScope],
1258) -> bool {
1259 path.ancestors()
1260 .skip(1)
1261 .take_while(|dir| *dir != root && dir.starts_with(root))
1262 .any(|dir| {
1263 dir.file_name().is_some_and(|name| {
1264 name.to_string_lossy().starts_with('.')
1265 && !is_allowed_hidden_dir(name)
1266 && !is_allowed_scoped_hidden_dir(name, dir, hidden_dir_scopes)
1267 })
1268 })
1269}
1270
1271pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
1277 discover_files_with_additional_hidden_dirs(config, &[])
1278}
1279
1280fn config_candidate_basename_globs() -> &'static [String] {
1292 static GLOBS: OnceLock<Vec<String>> = OnceLock::new();
1293 GLOBS.get_or_init(|| {
1294 let mut set: FxHashSet<String> = FxHashSet::default();
1295 for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
1296 for pattern in plugin.config_patterns() {
1297 let basename = pattern.rsplit('/').next().unwrap_or(pattern);
1298 set.insert(basename.to_string());
1299 }
1300 }
1301 let mut globs: Vec<String> = set.into_iter().collect();
1302 globs.sort_unstable();
1303 globs
1304 })
1305}
1306
1307fn has_source_extension(path: &Path) -> bool {
1310 path.extension()
1311 .and_then(OsStr::to_str)
1312 .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
1313}
1314
1315#[expect(
1319 clippy::expect_used,
1320 reason = "source file globs are hard-coded compile-time constants"
1321)]
1322fn build_walk_types(capture_config: bool) -> ignore::types::Types {
1323 static SOURCE_TYPES: OnceLock<ignore::types::Types> = OnceLock::new();
1324 static SOURCE_AND_CONFIG_TYPES: OnceLock<ignore::types::Types> = OnceLock::new();
1325
1326 let cache = if capture_config {
1327 &SOURCE_AND_CONFIG_TYPES
1328 } else {
1329 &SOURCE_TYPES
1330 };
1331 cache
1332 .get_or_init(|| {
1333 let mut types_builder = ignore::types::TypesBuilder::new();
1334 let source_glob = format!("*.{{{}}}", SOURCE_EXTENSIONS.join(","));
1335 types_builder
1336 .add("source", &source_glob)
1337 .expect("valid glob");
1338 types_builder.select("source");
1339 if capture_config {
1340 for glob in config_candidate_basename_globs() {
1341 let _ = types_builder.add("config", glob);
1345 }
1346 types_builder.select("config");
1347 }
1348 types_builder.build().expect("valid types")
1349 })
1350 .clone()
1351}
1352
1353fn build_source_walk_builder(
1357 config: &ResolvedConfig,
1358 additional_hidden_dir_scopes: &[HiddenDirScope],
1359 capture_config: bool,
1360 skipped_dotdirs: &SkippedDotdirSink,
1361) -> WalkBuilder {
1362 let mut walk_builder = WalkBuilder::new(&config.root);
1363 walk_builder
1364 .hidden(false)
1365 .git_ignore(true)
1366 .git_global(true)
1367 .git_exclude(true)
1368 .types(build_walk_types(capture_config))
1369 .threads(config.threads);
1370 let scopes = additional_hidden_dir_scopes.to_vec();
1375 let sink = Arc::clone(skipped_dotdirs);
1376 let root = config.root.clone();
1377 let ignore_patterns = config.ignore_patterns.clone();
1378 walk_builder.filter_entry(move |entry| {
1379 if is_allowed_hidden_with_scopes(entry, &scopes) {
1380 return true;
1381 }
1382 if ignore_patterns.has_exceptions()
1386 && entry.file_type().is_some_and(|ft| ft.is_dir())
1387 && ignore_patterns.admits_hidden_dir(
1388 entry
1389 .path()
1390 .strip_prefix(&root)
1391 .unwrap_or_else(|_| entry.path()),
1392 )
1393 {
1394 return true;
1395 }
1396 if entry.file_type().is_some_and(|ft| ft.is_dir())
1397 && let Ok(mut collected) = sink.lock()
1398 {
1399 collected.push(entry.path().to_path_buf());
1400 }
1401 false
1402 });
1403 walk_builder
1404}
1405
1406fn build_production_excludes(config: &ResolvedConfig) -> Option<globset::GlobSet> {
1408 if !config.production {
1409 return None;
1410 }
1411 let mut builder = globset::GlobSetBuilder::new();
1412 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
1413 if let Ok(glob) = globset::GlobBuilder::new(pattern)
1414 .literal_separator(true)
1415 .build()
1416 {
1417 builder.add(glob);
1418 }
1419 }
1420 builder.build().ok()
1421}
1422
1423pub fn discover_files_with_additional_hidden_dirs(
1429 config: &ResolvedConfig,
1430 additional_hidden_dir_scopes: &[HiddenDirScope],
1431) -> Vec<DiscoveredFile> {
1432 discover_files_and_config_candidates(config, additional_hidden_dir_scopes).0
1433}
1434
1435pub fn discover_files_and_config_candidates(
1451 config: &ResolvedConfig,
1452 additional_hidden_dir_scopes: &[HiddenDirScope],
1453) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
1454 let discovered =
1455 discover_files_config_candidates_and_diagnostics(config, additional_hidden_dir_scopes);
1456 (discovered.files, discovered.config_candidates)
1457}
1458
1459pub struct DiscoveredSources {
1468 pub files: Vec<DiscoveredFile>,
1470 pub config_candidates: Vec<PathBuf>,
1472 pub diagnostics: Vec<WorkspaceDiagnostic>,
1475}
1476
1477#[expect(
1485 clippy::cast_possible_truncation,
1486 reason = "file count is bounded by project size, well under u32::MAX"
1487)]
1488#[expect(clippy::expect_used, reason = "the collector lock must remain usable")]
1489pub fn discover_files_config_candidates_and_diagnostics(
1490 config: &ResolvedConfig,
1491 additional_hidden_dir_scopes: &[HiddenDirScope],
1492) -> DiscoveredSources {
1493 let _span = tracing::info_span!("discover_files").entered();
1494
1495 let capture_config = !config.production;
1496 let skipped_dotdirs: SkippedDotdirSink = Arc::new(Mutex::new(Vec::new()));
1497 let walk_builder = build_source_walk_builder(
1498 config,
1499 additional_hidden_dir_scopes,
1500 capture_config,
1501 &skipped_dotdirs,
1502 );
1503 let production_excludes = build_production_excludes(config);
1504 let canonical_root = config.root.canonicalize().ok();
1505
1506 let collected: Mutex<Vec<(std::path::PathBuf, u64)>> = Mutex::new(Vec::new());
1507 let config_collected: Mutex<Vec<std::path::PathBuf>> = Mutex::new(Vec::new());
1508 let excluded_collected: Mutex<ExclusionTally> = Mutex::new(ExclusionTally::default());
1509 let mut visitor_builder = FileVisitorBuilder {
1510 root: &config.root,
1511 canonical_root: canonical_root.as_deref(),
1512 ignore_patterns: &config.ignore_patterns,
1513 user_ignore_pattern_count: config.user_ignore_pattern_count,
1514 hidden_dir_scopes: additional_hidden_dir_scopes,
1515 production_excludes: &production_excludes,
1516 shared: &collected,
1517 config_shared: capture_config.then_some(&config_collected),
1518 excluded_shared: &excluded_collected,
1519 };
1520 walk_builder.build_parallel().visit(&mut visitor_builder);
1521
1522 let mut raw = collected
1523 .into_inner()
1524 .expect("walk collector lock poisoned");
1525 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
1533
1534 let mut config_candidates = config_collected
1535 .into_inner()
1536 .expect("walk config collector lock poisoned");
1537 config_candidates.sort_unstable();
1538
1539 let excluded_by_default_ignore = excluded_collected
1540 .into_inner()
1541 .expect("walk exclusion collector lock poisoned");
1542
1543 let mut dotdir_candidates = skipped_dotdirs
1547 .lock()
1548 .map_or_else(|_| Vec::new(), |mut guard| std::mem::take(&mut *guard));
1549 dotdir_candidates.sort_unstable();
1550 dotdir_candidates.dedup();
1551
1552 let (kept, skipped) = partition_by_size(raw, config.max_file_size_bytes);
1553 let (kept, skipped_minified) =
1554 partition_minified_generated_js(kept, config.max_file_size_bytes);
1555 let diagnostics = fallow_config::replace_source_discovery_diagnostics(
1560 &config.root,
1561 report_skipped_large_files(config, &skipped)
1562 .into_iter()
1563 .chain(report_skipped_minified_files(config, &skipped_minified))
1564 .chain(report_skipped_source_dotdirs(
1565 config,
1566 production_excludes.as_ref(),
1567 &dotdir_candidates,
1568 ))
1569 .chain(report_default_ignore_exclusions(
1570 config,
1571 &excluded_by_default_ignore,
1572 ))
1573 .chain(report_missing_node_modules(config))
1574 .chain(report_no_source_files_analyzed(
1575 config,
1576 kept.len(),
1577 &excluded_by_default_ignore,
1578 ))
1579 .collect(),
1580 );
1581
1582 let files: Vec<DiscoveredFile> = kept
1583 .into_iter()
1584 .enumerate()
1585 .map(|(idx, (path, size_bytes))| DiscoveredFile {
1586 id: FileId(idx as u32),
1587 path,
1588 size_bytes,
1589 })
1590 .collect();
1591
1592 note_largest_files(config, &files);
1593
1594 DiscoveredSources {
1595 files,
1596 config_candidates,
1597 diagnostics,
1598 }
1599}
1600
1601#[cfg(test)]
1602mod tests {
1603 use std::ffi::OsStr;
1604 use std::path::MAIN_SEPARATOR;
1605
1606 use super::*;
1607
1608 #[test]
1612 fn exclusion_scope_stops_at_the_patterns_literal_directory_segment() {
1613 assert_eq!(
1614 exclusion_scope(
1615 Path::new("projects/app/build/static/js/main.js"),
1616 "**/build/**"
1617 ),
1618 PathBuf::from("projects/app/build")
1619 );
1620 assert_eq!(
1621 exclusion_scope(Path::new("dist/a.ts"), "**/dist/**"),
1622 PathBuf::from("dist")
1623 );
1624 assert_eq!(
1625 exclusion_scope(
1626 Path::new("node_modules/react/index.js"),
1627 "**/node_modules/**"
1628 ),
1629 PathBuf::from("node_modules"),
1630 "the helper is shape-only: `**/node_modules/**` is never tallied, \
1631 but a directory-shaped pattern still collapses to its literal segment"
1632 );
1633 }
1634
1635 #[test]
1641 fn exclusion_scope_takes_the_deepest_matching_segment() {
1642 assert_eq!(
1643 exclusion_scope(Path::new("build/tools/build/a.ts"), "**/build/**"),
1644 PathBuf::from("build/tools/build")
1645 );
1646 assert_eq!(
1647 exclusion_scope(Path::new("dist/pkg/dist/inner/a.ts"), "**/dist/**"),
1648 PathBuf::from("dist/pkg/dist")
1649 );
1650 }
1651
1652 #[test]
1655 fn exclusion_scope_falls_back_to_the_parent_for_a_file_shaped_pattern() {
1656 assert_eq!(
1657 exclusion_scope(Path::new("vendor/a.min.js"), "**/*.min.js"),
1658 PathBuf::from("vendor")
1659 );
1660 assert_eq!(
1661 exclusion_scope(Path::new("a.min.js"), "**/*.min.js"),
1662 PathBuf::new(),
1663 "a root-level match anchors at the root itself"
1664 );
1665 }
1666
1667 #[test]
1671 fn exclusion_scope_falls_back_when_the_literal_segment_is_absent() {
1672 assert_eq!(
1673 exclusion_scope(Path::new("src/nested/a.ts"), "**/build/**"),
1674 PathBuf::from("src/nested")
1675 );
1676 }
1677
1678 #[test]
1682 fn the_directory_count_is_the_number_of_distinct_scopes() {
1683 let mut one_tree = ExcludedByPattern::default();
1684 one_tree.record(PathBuf::from("packages/web/build"));
1685 one_tree.record(PathBuf::from("packages/web/build"));
1686 assert_eq!(one_tree.file_count, 2);
1687 assert_eq!(one_tree.directory_count(), 1);
1688
1689 let mut scattered = ExcludedByPattern::default();
1690 scattered.record(PathBuf::from("packages/a/dist"));
1691 scattered.record(PathBuf::from("packages/b/dist"));
1692 assert_eq!(scattered.directory_count(), 2);
1693 }
1694
1695 #[test]
1699 fn the_anchor_is_the_largest_group_with_ties_broken_by_path() {
1700 let mut tally = ExcludedByPattern::default();
1701 for _ in 0..3 {
1702 tally.record(PathBuf::from("packages/web/build"));
1703 }
1704 tally.record(PathBuf::from("packages/api/build"));
1705 assert_eq!(tally.file_count, 4);
1706 assert_eq!(tally.anchor(), PathBuf::from("packages/web/build"));
1707
1708 let mut tied = ExcludedByPattern::default();
1709 tied.record(PathBuf::from("z/build"));
1710 tied.record(PathBuf::from("a/build"));
1711 assert_eq!(tied.anchor(), PathBuf::from("a/build"));
1712 }
1713
1714 #[test]
1717 fn merging_two_thread_tallies_keeps_the_count_exact() {
1718 let mut left = ExcludedByPattern::default();
1719 left.record(PathBuf::from("dist"));
1720 left.record(PathBuf::from("dist"));
1721 let mut right = ExcludedByPattern::default();
1722 right.record(PathBuf::from("dist"));
1723 right.record(PathBuf::from("packages/ui/dist"));
1724
1725 left.merge(right);
1726 assert_eq!(left.file_count, 4);
1727 assert_eq!(left.scopes.get(Path::new("dist")), Some(&3));
1728 assert_eq!(left.anchor(), PathBuf::from("dist"));
1729 }
1730
1731 #[test]
1732 fn skipped_dotdirs_note_names_the_directory_when_there_is_one() {
1733 let root = Path::new("/repo");
1734 let only = PathBuf::from("/repo/.tooling");
1735 let note = build_skipped_dotdirs_note(root, &[&only], false);
1736 assert!(note.contains("skipped 1 hidden directory that contains source files"));
1737 assert!(note.contains("add it to entry, ignoreExports or ignoreDependencies"));
1738 assert!(note.contains("fallow --root .tooling analyzes it on its own"));
1739 assert!(note.contains("does not fix this run"));
1740 assert!(note.contains("add '.tooling/**' to"));
1741 assert!(note.contains("add '!.tooling/**' to ignorePatterns to analyze it"));
1742 assert!(
1743 !note.contains("<dir>"),
1744 "the single-directory remedy must be copy-pasteable: {note}"
1745 );
1746 }
1747
1748 #[test]
1749 fn skipped_dotdirs_note_pluralizes_and_keeps_the_placeholder() {
1750 let root = Path::new("/repo");
1751 let a = PathBuf::from("/repo/.a");
1752 let b = PathBuf::from("/repo/.b");
1753 let note = build_skipped_dotdirs_note(root, &[&a, &b], false);
1754 assert!(note.contains("skipped 2 hidden directories that contain source files"));
1755 assert!(note.contains("fallow --root <dir> analyzes one on its own"));
1756 }
1757
1758 #[test]
1759 fn skipped_dotdirs_note_drops_the_tail_count_when_truncated() {
1760 let root = Path::new("/repo");
1761 let owned: Vec<PathBuf> = (0..8)
1762 .map(|i| PathBuf::from(format!("/repo/.d{i}")))
1763 .collect();
1764 let reportable: Vec<&PathBuf> = owned.iter().collect();
1765
1766 let bounded = build_skipped_dotdirs_note(root, &reportable, true);
1767 assert!(bounded.contains("skipped at least 8 hidden directories"));
1768 assert!(
1769 bounded.contains("and more") && !bounded.contains("and 3 more"),
1770 "an inexact total must not carry an exact remainder: {bounded}"
1771 );
1772
1773 let complete = build_skipped_dotdirs_note(root, &reportable, false);
1774 assert!(!complete.contains("at least"));
1775 assert!(complete.contains("and 3 more"));
1776 }
1777
1778 #[test]
1779 fn dotdir_noise_path_components_stay_sorted_and_lowercase() {
1780 let mut sorted = DOTDIR_NOISE_PATH_COMPONENTS.to_vec();
1781 sorted.sort_unstable();
1782 assert_eq!(sorted, DOTDIR_NOISE_PATH_COMPONENTS);
1783 for component in DOTDIR_NOISE_PATH_COMPONENTS {
1784 assert!(!component.starts_with('.'), "'{component}' is not hidden");
1785 assert_eq!(
1786 *component,
1787 component.to_lowercase(),
1788 "'{component}' is matched verbatim against a path component"
1789 );
1790 }
1791 }
1792
1793 #[test]
1794 fn script_scope_denylist_stays_disjoint_and_sorted() {
1795 let mut sorted = SCRIPT_SCOPE_DENYLIST.to_vec();
1796 sorted.sort_unstable();
1797 assert_eq!(
1798 sorted, SCRIPT_SCOPE_DENYLIST,
1799 "keep the list sorted so additions stay reviewable"
1800 );
1801 for dir in SCRIPT_SCOPE_DENYLIST {
1802 assert!(dir.starts_with('.'), "'{dir}' is not a hidden directory");
1803 assert!(
1804 !ALLOWED_HIDDEN_DIRS.contains(dir),
1805 "'{dir}' is traversed, so it can never be a skipped candidate"
1806 );
1807 }
1808 }
1809
1810 #[test]
1811 fn dotdir_module_extensions_are_a_subset_of_source_extensions() {
1812 for ext in DOTDIR_MODULE_EXTENSIONS {
1813 assert!(
1814 SOURCE_EXTENSIONS.contains(ext),
1815 "'{ext}' is not discovered as source, so it cannot be a trigger"
1816 );
1817 }
1818 for ext in ["css", "scss", "sass", "less", "html", "graphql", "gql"] {
1819 assert!(
1820 !DOTDIR_MODULE_EXTENSIONS.contains(&ext),
1821 "'{ext}' carries no imports or exports for the message to be about"
1822 );
1823 }
1824 }
1825
1826 fn assign_file_ids(mut raw: Vec<(std::path::PathBuf, u64)>) -> Vec<DiscoveredFile> {
1829 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
1830 raw.into_iter()
1831 .enumerate()
1832 .map(|(idx, (path, size_bytes))| DiscoveredFile {
1833 id: FileId(idx as u32),
1834 path,
1835 size_bytes,
1836 })
1837 .collect()
1838 }
1839
1840 #[test]
1846 fn file_id_assignment_is_deterministic_for_identical_file_set() {
1847 let paths = [
1848 "/project/src/z.ts",
1849 "/project/src/a.ts",
1850 "/project/src/components/Button.tsx",
1851 "/project/src/components/Button.module.css",
1852 "/project/index.ts",
1853 ];
1854
1855 let walk_one: Vec<(std::path::PathBuf, u64)> = paths
1857 .iter()
1858 .map(|p| (std::path::PathBuf::from(p), 10))
1859 .collect();
1860 let mut walk_two = walk_one.clone();
1861 walk_two.reverse();
1862
1863 let files_one = assign_file_ids(walk_one);
1864 let files_two = assign_file_ids(walk_two);
1865
1866 assert_eq!(files_one.len(), files_two.len());
1868 for (a, b) in files_one.iter().zip(files_two.iter()) {
1869 assert_eq!(a.id, b.id);
1870 assert_eq!(a.path, b.path);
1871 }
1872
1873 for (idx, file) in files_one.iter().enumerate() {
1876 assert_eq!(file.id, FileId(idx as u32));
1877 }
1878 assert_eq!(
1879 files_one[0].path,
1880 std::path::PathBuf::from("/project/index.ts")
1881 );
1882 }
1883
1884 #[test]
1885 fn file_id_assignment_recomputes_after_rename_or_delete() {
1886 let before = assign_file_ids(vec![
1887 (std::path::PathBuf::from("/project/src/a.ts"), 10),
1888 (std::path::PathBuf::from("/project/src/b.ts"), 10),
1889 (std::path::PathBuf::from("/project/src/c.ts"), 10),
1890 ]);
1891 let after_delete = assign_file_ids(vec![
1892 (std::path::PathBuf::from("/project/src/a.ts"), 10),
1893 (std::path::PathBuf::from("/project/src/c.ts"), 10),
1894 ]);
1895 let after_rename = assign_file_ids(vec![
1896 (std::path::PathBuf::from("/project/src/a.ts"), 10),
1897 (std::path::PathBuf::from("/project/src/c.ts"), 10),
1898 (std::path::PathBuf::from("/project/src/d.ts"), 10),
1899 ]);
1900
1901 assert_eq!(before[0].id, FileId(0));
1902 assert_eq!(before[1].id, FileId(1));
1903 assert_eq!(before[2].id, FileId(2));
1904 assert_eq!(after_delete[0].id, FileId(0));
1905 assert_eq!(after_delete[1].id, FileId(1));
1906 assert_eq!(
1907 after_delete[1].path,
1908 std::path::PathBuf::from("/project/src/c.ts")
1909 );
1910 assert_eq!(after_rename[0].id, FileId(0));
1911 assert_eq!(after_rename[1].id, FileId(1));
1912 assert_eq!(
1913 after_rename[1].path,
1914 std::path::PathBuf::from("/project/src/c.ts")
1915 );
1916 assert_eq!(after_rename[2].id, FileId(2));
1917 assert_eq!(
1918 after_rename[2].path,
1919 std::path::PathBuf::from("/project/src/d.ts")
1920 );
1921 }
1922
1923 #[test]
1924 fn allowed_hidden_dirs() {
1925 assert!(is_allowed_hidden_dir(OsStr::new(".storybook")));
1926 assert!(is_allowed_hidden_dir(OsStr::new(".vitepress")));
1927 assert!(is_allowed_hidden_dir(OsStr::new(".well-known")));
1928 assert!(is_allowed_hidden_dir(OsStr::new(".changeset")));
1929 assert!(is_allowed_hidden_dir(OsStr::new(".github")));
1930 }
1931
1932 #[test]
1933 fn disallowed_hidden_dirs() {
1934 assert!(!is_allowed_hidden_dir(OsStr::new(".git")));
1935 assert!(!is_allowed_hidden_dir(OsStr::new(".cache")));
1936 assert!(!is_allowed_hidden_dir(OsStr::new(".vscode")));
1937 assert!(!is_allowed_hidden_dir(OsStr::new(".fallow")));
1938 assert!(!is_allowed_hidden_dir(OsStr::new(".next")));
1939 }
1940
1941 #[test]
1942 fn non_hidden_dirs_not_in_allowlist() {
1943 assert!(!is_allowed_hidden_dir(OsStr::new("src")));
1944 assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
1945 }
1946
1947 #[test]
1948 fn walk_types_match_every_supported_source_extension() {
1949 for capture_config in [false, true] {
1950 let types = build_walk_types(capture_config);
1951 for extension in SOURCE_EXTENSIONS {
1952 let path = format!("packages/ui/src/nested/component.{extension}");
1953 assert!(
1954 types.matched(&path, false).is_whitelist(),
1955 "expected source match for {path} with capture_config={capture_config}"
1956 );
1957 }
1958 }
1959 }
1960
1961 #[test]
1962 fn walk_types_match_typescript_declaration_files() {
1963 let types = build_walk_types(true);
1964 for path in [
1965 "src/env.d.ts",
1966 "packages/app/types/generated.d.mts",
1967 "packages/app/types/compat.d.cts",
1968 ] {
1969 assert!(
1970 types.matched(path, false).is_whitelist(),
1971 "expected declaration source match for {path}"
1972 );
1973 }
1974 }
1975
1976 #[test]
1977 fn walk_types_reject_source_extension_near_misses() {
1978 for capture_config in [false, true] {
1979 let types = build_walk_types(capture_config);
1980 for path in [
1981 "src/component.tsx.bak",
1982 "src/component.tsxmap",
1983 "src/component.TS",
1984 "src/component.gqlx",
1985 "src/component.htm",
1986 "src/component",
1987 "assets/component.png",
1988 ] {
1989 assert!(
1990 types.matched(path, false).is_ignore(),
1991 "expected non-source rejection for {path} with capture_config={capture_config}"
1992 );
1993 }
1994 }
1995 }
1996
1997 #[test]
1998 fn walk_types_keep_config_candidate_selection_separate() {
1999 assert!(
2000 build_walk_types(true)
2001 .matched("packages/app/tsconfig.json", false)
2002 .is_whitelist()
2003 );
2004 assert!(
2005 build_walk_types(false)
2006 .matched("packages/app/tsconfig.json", false)
2007 .is_ignore()
2008 );
2009 }
2010
2011 #[test]
2012 fn source_extensions_are_exactly_the_supported_set() {
2013 let mut actual = SOURCE_EXTENSIONS.to_vec();
2014 actual.sort_unstable();
2015 let mut expected = vec![
2016 "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte",
2017 "astro", "mdx", "css", "scss", "sass", "less", "html", "graphql", "gql",
2018 ];
2019 expected.sort_unstable();
2020 assert_eq!(actual, expected);
2021 }
2022
2023 fn production_excludes() -> globset::GlobSet {
2025 let config = fallow_config::FallowConfig {
2026 production: true.into(),
2027 ..Default::default()
2028 }
2029 .resolve(
2030 std::path::PathBuf::from("/project"),
2031 fallow_config::OutputFormat::Human,
2032 1,
2033 true,
2034 true,
2035 None,
2036 );
2037 build_production_excludes(&config).expect("production mode builds an exclude set")
2038 }
2039
2040 #[test]
2043 fn production_exclude_patterns_all_compile() {
2044 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
2045 assert!(
2046 globset::GlobBuilder::new(pattern)
2047 .literal_separator(true)
2048 .build()
2049 .is_ok(),
2050 "production exclude pattern does not compile: {pattern}"
2051 );
2052 }
2053 }
2054
2055 #[test]
2056 fn production_excludes_test_files() {
2057 let set = production_excludes();
2058 assert!(set.is_match("src/Button.test.ts"));
2059 assert!(set.is_match("src/utils.spec.tsx"));
2060 assert!(set.is_match("src/__tests__/helper.ts"));
2061 assert!(!set.is_match("src/Button.ts"));
2062 assert!(!set.is_match("src/utils.tsx"));
2063 }
2064
2065 #[test]
2066 fn production_excludes_story_files() {
2067 let set = production_excludes();
2068 assert!(set.is_match("src/Button.stories.tsx"));
2069 assert!(set.is_match("src/Card.story.ts"));
2070 assert!(!set.is_match("src/Button.tsx"));
2071 }
2072
2073 #[test]
2074 fn production_excludes_config_files_at_root_only() {
2075 let set = production_excludes();
2076 assert!(set.is_match("vitest.config.ts"));
2077 assert!(set.is_match("jest.config.js"));
2078 assert!(!set.is_match("src/app/app.config.ts"));
2079 assert!(!set.is_match("src/app/app.config.server.ts"));
2080 assert!(!set.is_match("packages/foo/vitest.config.ts"));
2081 assert!(!set.is_match("src/config.ts"));
2082 }
2083
2084 #[test]
2085 fn disallowed_hidden_dirs_idea() {
2086 assert!(!is_allowed_hidden_dir(OsStr::new(".idea")));
2087 }
2088
2089 #[test]
2090 fn is_declaration_file_matches_dts_variants() {
2091 assert!(is_declaration_file(Path::new("env.d.ts")));
2092 assert!(is_declaration_file(Path::new("src/auto-imports.d.ts")));
2093 assert!(is_declaration_file(Path::new("mod.d.mts")));
2094 assert!(is_declaration_file(Path::new("compat.d.cts")));
2095 assert!(!is_declaration_file(Path::new("index.ts")));
2096 assert!(!is_declaration_file(Path::new("component.tsx")));
2097 assert!(!is_declaration_file(Path::new("notes.d.txt")));
2098 }
2099
2100 #[test]
2101 fn format_size_mb_renders_one_decimal() {
2102 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
2103 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
2104 assert_eq!(format_size_mb(0), "0.0 MB");
2105 }
2106
2107 #[test]
2108 fn partition_by_size_no_limit_keeps_all() {
2109 let raw = vec![(PathBuf::from("a.ts"), 10), (PathBuf::from("b.ts"), 10_000)];
2110 let (kept, skipped) = partition_by_size(raw, None);
2111 assert_eq!(kept.len(), 2);
2112 assert!(skipped.is_empty());
2113 }
2114
2115 #[test]
2116 fn partition_by_size_skips_strictly_over_limit() {
2117 let raw = vec![
2118 (PathBuf::from("under.ts"), 99),
2119 (PathBuf::from("exact.ts"), 100),
2120 (PathBuf::from("over.ts"), 101),
2121 ];
2122 let (kept, skipped) = partition_by_size(raw, Some(100));
2123 let kept_has = |name: &str| kept.iter().any(|(p, _)| p.as_path() == Path::new(name));
2124 assert!(kept_has("under.ts"));
2125 assert!(
2126 kept_has("exact.ts"),
2127 "a file exactly at the limit is kept (skip is strictly-greater)"
2128 );
2129 assert_eq!(skipped.len(), 1);
2130 assert_eq!(skipped[0].0, PathBuf::from("over.ts"));
2131 }
2132
2133 #[test]
2134 fn partition_by_size_exempts_declaration_files() {
2135 let raw = vec![
2136 (PathBuf::from("huge.ts"), 10_000),
2137 (PathBuf::from("auto-imports.d.ts"), 10_000),
2138 ];
2139 let (kept, skipped) = partition_by_size(raw, Some(100));
2140 assert!(
2141 kept.iter()
2142 .any(|(p, _)| p.as_path() == Path::new("auto-imports.d.ts")),
2143 "declaration files are exempt from the size skip regardless of size"
2144 );
2145 assert_eq!(skipped.len(), 1);
2146 assert_eq!(skipped[0].0, PathBuf::from("huge.ts"));
2147 }
2148
2149 fn disco(path: &str, size_bytes: u64) -> DiscoveredFile {
2150 DiscoveredFile {
2151 id: FileId(0),
2152 path: PathBuf::from(path),
2153 size_bytes,
2154 }
2155 }
2156
2157 #[test]
2158 fn largest_files_note_below_threshold_is_none() {
2159 let files = [disco("a.ts", 100), disco("b.ts", 200)];
2160 assert!(build_largest_files_note(Path::new("/p"), &files).is_none());
2161 }
2162
2163 #[test]
2164 fn largest_files_note_single_file_uses_singular() {
2165 let files = [disco("big.ts", 5 * 1024 * 1024)];
2166 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
2167 assert!(
2168 note.contains("discovered 1 file;"),
2169 "singular noun on the single-big-file path (issue #1086 regression): {note}"
2170 );
2171 assert!(!note.contains("discovered 1 files"));
2172 assert!(note.contains("big.ts (5.0 MB)"));
2173 }
2174
2175 #[test]
2176 fn largest_files_note_filters_sub_floor_files() {
2177 let files = [disco("big.ts", 5 * 1024 * 1024), disco("tiny.ts", 10)];
2178 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
2179 assert!(note.contains("discovered 2 files;"));
2180 assert!(note.contains("big.ts (5.0 MB)"));
2181 assert!(
2182 !note.contains("tiny.ts"),
2183 "sub-floor files are not listed as `0.0 MB` chaff: {note}"
2184 );
2185 }
2186
2187 #[test]
2188 fn largest_files_note_large_set_no_big_file_omits_list() {
2189 let files: Vec<DiscoveredFile> = (0..=LARGE_SET_THRESHOLD)
2190 .map(|i| disco(&format!("f{i}.ts"), 100))
2191 .collect();
2192 let note = build_largest_files_note(Path::new("/p"), &files).expect("large set fires");
2193 assert!(note.contains(&format!("discovered {} files", LARGE_SET_THRESHOLD + 1)));
2194 assert!(
2195 !note.contains("largest:"),
2196 "no sub-floor `largest:` list when no file clears the floor: {note}"
2197 );
2198 }
2199
2200 mod discover_files_integration {
2201 use std::path::PathBuf;
2202
2203 use fallow_config::{
2204 DuplicatesConfig, FallowConfig, FlagsConfig, HealthConfig, OutputFormat, ResolveConfig,
2205 RulesConfig,
2206 };
2207
2208 use super::*;
2209
2210 fn make_config(root: PathBuf, production: bool) -> ResolvedConfig {
2212 FallowConfig {
2213 production: production.into(),
2214 ..Default::default()
2215 }
2216 .resolve(root, OutputFormat::Human, 1, true, true, None)
2217 }
2218
2219 fn file_names(files: &[DiscoveredFile], root: &std::path::Path) -> Vec<String> {
2222 files
2223 .iter()
2224 .map(|f| {
2225 f.path
2226 .strip_prefix(root)
2227 .unwrap_or(&f.path)
2228 .to_string_lossy()
2229 .replace('\\', "/")
2230 })
2231 .collect()
2232 }
2233
2234 #[cfg(unix)]
2235 fn symlink_file(target: &Path, link: &Path) {
2236 std::os::unix::fs::symlink(target, link).expect("create file symlink");
2237 }
2238
2239 #[cfg(windows)]
2240 fn symlink_file(target: &Path, link: &Path) {
2241 std::os::windows::fs::symlink_file(target, link).expect("create file symlink");
2242 }
2243
2244 #[cfg(unix)]
2245 fn symlink_dir(target: &Path, link: &Path) {
2246 std::os::unix::fs::symlink(target, link).expect("create directory symlink");
2247 }
2248
2249 #[cfg(windows)]
2250 fn symlink_dir(target: &Path, link: &Path) {
2251 std::os::windows::fs::symlink_dir(target, link).expect("create directory symlink");
2252 }
2253
2254 #[test]
2255 fn source_symlinks_must_target_regular_files_inside_root() {
2256 let dir = tempfile::tempdir().expect("create project");
2257 let outside = tempfile::tempdir().expect("create outside dir");
2258 let src = dir.path().join("src");
2259 std::fs::create_dir_all(&src).unwrap();
2260 std::fs::write(src.join("regular.ts"), "export const regular = 1;").unwrap();
2261 std::fs::write(src.join("inside-target.ts"), "export const inside = 1;").unwrap();
2262 std::fs::write(
2263 outside.path().join("outside-target.ts"),
2264 "export const outside = 1;",
2265 )
2266 .unwrap();
2267 std::fs::create_dir_all(src.join("directory-target")).unwrap();
2268
2269 symlink_file(&src.join("inside-target.ts"), &src.join("inside-link.ts"));
2270 symlink_file(
2271 &outside.path().join("outside-target.ts"),
2272 &src.join("outside-link.ts"),
2273 );
2274 symlink_file(&src.join("missing-target.ts"), &src.join("broken-link.ts"));
2275 symlink_dir(
2276 &src.join("directory-target"),
2277 &src.join("directory-link.ts"),
2278 );
2279
2280 let config = make_config(dir.path().to_path_buf(), false);
2281 let names = file_names(&discover_files(&config), dir.path());
2282
2283 assert!(names.contains(&"src/regular.ts".to_string()));
2284 assert!(names.contains(&"src/inside-target.ts".to_string()));
2285 assert!(names.contains(&"src/inside-link.ts".to_string()));
2286 assert!(!names.contains(&"src/outside-link.ts".to_string()));
2287 assert!(!names.contains(&"src/broken-link.ts".to_string()));
2288 assert!(!names.contains(&"src/directory-link.ts".to_string()));
2289 }
2290
2291 #[test]
2295 fn skips_yarn_pnp_generated_files() {
2296 let dir = tempfile::tempdir().expect("create temp dir");
2297 std::fs::write(dir.path().join(".pnp.cjs"), "module.exports = {};").unwrap();
2298 std::fs::write(dir.path().join(".pnp.loader.mjs"), "export {};").unwrap();
2299 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
2300
2301 let config = make_config(dir.path().to_path_buf(), false);
2302 let names = file_names(&discover_files(&config), dir.path());
2303
2304 assert_eq!(names, vec!["index.ts".to_string()]);
2305 }
2306
2307 #[test]
2308 fn discovers_source_files_with_valid_extensions() {
2309 let dir = tempfile::tempdir().expect("create temp dir");
2310 let src = dir.path().join("src");
2311 std::fs::create_dir_all(&src).unwrap();
2312
2313 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2314 std::fs::write(src.join("component.tsx"), "export default () => {};").unwrap();
2315 std::fs::write(src.join("utils.js"), "module.exports = {};").unwrap();
2316 std::fs::write(src.join("helper.jsx"), "export const h = 1;").unwrap();
2317 std::fs::write(src.join("config.mjs"), "export default {};").unwrap();
2318 std::fs::write(src.join("legacy.cjs"), "module.exports = {};").unwrap();
2319 std::fs::write(src.join("types.mts"), "export type T = string;").unwrap();
2320 std::fs::write(src.join("compat.cts"), "module.exports = {};").unwrap();
2321
2322 let config = make_config(dir.path().to_path_buf(), false);
2323 let files = discover_files(&config);
2324 let names = file_names(&files, dir.path());
2325
2326 assert!(names.contains(&"src/app.ts".to_string()));
2327 assert!(names.contains(&"src/component.tsx".to_string()));
2328 assert!(names.contains(&"src/utils.js".to_string()));
2329 assert!(names.contains(&"src/helper.jsx".to_string()));
2330 assert!(names.contains(&"src/config.mjs".to_string()));
2331 assert!(names.contains(&"src/legacy.cjs".to_string()));
2332 assert!(names.contains(&"src/types.mts".to_string()));
2333 assert!(names.contains(&"src/compat.cts".to_string()));
2334 }
2335
2336 #[test]
2337 fn compact_source_glob_preserves_discovered_file_inventory() {
2338 let dir = tempfile::tempdir().expect("create temp dir");
2339 let nested = dir.path().join("packages/ui/src/nested");
2340 std::fs::create_dir_all(&nested).unwrap();
2341
2342 let mut expected = Vec::new();
2343 for (index, extension) in SOURCE_EXTENSIONS.iter().enumerate() {
2344 let relative = format!("packages/ui/src/nested/source-{index}.{extension}");
2345 std::fs::write(dir.path().join(&relative), "export const value = 1;").unwrap();
2346 expected.push(relative);
2347 }
2348 for relative in [
2349 "packages/ui/src/nested/env.d.ts",
2350 "packages/ui/src/nested/generated.d.mts",
2351 "packages/ui/src/nested/compat.d.cts",
2352 ] {
2353 std::fs::write(dir.path().join(relative), "export type Value = string;").unwrap();
2354 expected.push(relative.to_string());
2355 }
2356 let rejected = [
2357 "packages/ui/src/nested/component.tsx.bak",
2358 "packages/ui/src/nested/component.tsxmap",
2359 "packages/ui/src/nested/component.TS",
2360 "packages/ui/src/nested/component.gqlx",
2361 "packages/ui/src/nested/component.htm",
2362 "packages/ui/src/nested/component",
2363 "packages/ui/src/nested/component.png",
2364 ];
2365 for relative in rejected {
2366 std::fs::write(dir.path().join(relative), "not source").unwrap();
2367 }
2368
2369 let config = make_config(dir.path().to_path_buf(), false);
2370 let names = file_names(&discover_files(&config), dir.path());
2371
2372 for relative in expected {
2373 assert!(
2374 names.contains(&relative),
2375 "missing supported source {relative}"
2376 );
2377 }
2378 for relative in rejected {
2379 assert!(
2380 !names.iter().any(|name| name == relative),
2381 "unexpected near-miss source {relative}"
2382 );
2383 }
2384 }
2385
2386 #[test]
2387 fn excludes_non_source_extensions() {
2388 let dir = tempfile::tempdir().expect("create temp dir");
2389 let src = dir.path().join("src");
2390 std::fs::create_dir_all(&src).unwrap();
2391
2392 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2393
2394 std::fs::write(src.join("data.json"), "{}").unwrap();
2395 std::fs::write(src.join("readme.md"), "# Hello").unwrap();
2396 std::fs::write(src.join("notes.txt"), "notes").unwrap();
2397 std::fs::write(src.join("logo.png"), [0u8; 8]).unwrap();
2398
2399 let config = make_config(dir.path().to_path_buf(), false);
2400 let files = discover_files(&config);
2401 let names = file_names(&files, dir.path());
2402
2403 assert_eq!(names.len(), 1, "only the .ts file should be discovered");
2404 assert!(names.contains(&"src/app.ts".to_string()));
2405 }
2406
2407 #[test]
2408 fn excludes_disallowed_hidden_directories() {
2409 let dir = tempfile::tempdir().expect("create temp dir");
2410
2411 let git_dir = dir.path().join(".git");
2412 std::fs::create_dir_all(&git_dir).unwrap();
2413 std::fs::write(git_dir.join("hooks.ts"), "// git hook").unwrap();
2414
2415 let idea_dir = dir.path().join(".idea");
2416 std::fs::create_dir_all(&idea_dir).unwrap();
2417 std::fs::write(idea_dir.join("workspace.ts"), "// idea").unwrap();
2418
2419 let cache_dir = dir.path().join(".cache");
2420 std::fs::create_dir_all(&cache_dir).unwrap();
2421 std::fs::write(cache_dir.join("cached.js"), "// cached").unwrap();
2422
2423 let src = dir.path().join("src");
2424 std::fs::create_dir_all(&src).unwrap();
2425 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2426
2427 let config = make_config(dir.path().to_path_buf(), false);
2428 let files = discover_files(&config);
2429 let names = file_names(&files, dir.path());
2430
2431 assert_eq!(names.len(), 1, "only src/app.ts should be discovered");
2432 assert!(names.contains(&"src/app.ts".to_string()));
2433 }
2434
2435 #[test]
2436 fn includes_allowed_hidden_directories() {
2437 let dir = tempfile::tempdir().expect("create temp dir");
2438
2439 let storybook = dir.path().join(".storybook");
2440 std::fs::create_dir_all(&storybook).unwrap();
2441 std::fs::write(storybook.join("main.ts"), "export default {};").unwrap();
2442
2443 let github = dir.path().join(".github");
2444 std::fs::create_dir_all(&github).unwrap();
2445 std::fs::write(github.join("actions.js"), "module.exports = {};").unwrap();
2446
2447 let changeset = dir.path().join(".changeset");
2448 std::fs::create_dir_all(&changeset).unwrap();
2449 std::fs::write(changeset.join("config.js"), "module.exports = {};").unwrap();
2450
2451 let config = make_config(dir.path().to_path_buf(), false);
2452 let files = discover_files(&config);
2453 let names = file_names(&files, dir.path());
2454
2455 assert!(
2456 names.contains(&".storybook/main.ts".to_string()),
2457 "files in .storybook should be discovered"
2458 );
2459 assert!(
2460 names.contains(&".github/actions.js".to_string()),
2461 "files in .github should be discovered"
2462 );
2463 assert!(
2464 names.contains(&".changeset/config.js".to_string()),
2465 "files in .changeset should be discovered"
2466 );
2467 }
2468
2469 #[test]
2470 fn default_discovery_excludes_client_and_server_hidden_directories() {
2471 let dir = tempfile::tempdir().expect("create temp dir");
2472 let app = dir.path().join("app");
2473 std::fs::create_dir_all(app.join(".client")).unwrap();
2474 std::fs::create_dir_all(app.join(".server")).unwrap();
2475 std::fs::write(app.join(".client/analytics.ts"), "export const a = 1;").unwrap();
2476 std::fs::write(app.join(".server/db.ts"), "export const db = {};").unwrap();
2477 std::fs::write(app.join("root.tsx"), "export default function Root() {}").unwrap();
2478
2479 let config = make_config(dir.path().to_path_buf(), false);
2480 let files = discover_files(&config);
2481 let names = file_names(&files, dir.path());
2482
2483 assert!(names.contains(&"app/root.tsx".to_string()));
2484 assert!(!names.contains(&"app/.client/analytics.ts".to_string()));
2485 assert!(!names.contains(&"app/.server/db.ts".to_string()));
2486 }
2487
2488 #[test]
2489 fn scoped_hidden_dirs_include_client_and_server_under_package_root() {
2490 let dir = tempfile::tempdir().expect("create temp dir");
2491 let package = dir.path().join("packages/app");
2492 std::fs::create_dir_all(package.join("app/.client")).unwrap();
2493 std::fs::create_dir_all(package.join("app/.server")).unwrap();
2494 std::fs::write(
2495 package.join("app/.client/analytics.ts"),
2496 "export const track = () => {};",
2497 )
2498 .unwrap();
2499 std::fs::write(package.join("app/.server/db.ts"), "export const db = {};").unwrap();
2500
2501 let config = make_config(dir.path().to_path_buf(), false);
2502 let scopes = [HiddenDirScope::new(
2503 package,
2504 vec![".client".to_string(), ".server".to_string()],
2505 )];
2506 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2507 let names = file_names(&files, dir.path());
2508
2509 assert!(names.contains(&"packages/app/app/.client/analytics.ts".to_string()));
2510 assert!(names.contains(&"packages/app/app/.server/db.ts".to_string()));
2511 }
2512
2513 #[test]
2514 fn scoped_hidden_dirs_do_not_include_unscoped_packages() {
2515 let dir = tempfile::tempdir().expect("create temp dir");
2516 let active = dir.path().join("packages/active");
2517 let inactive = dir.path().join("packages/inactive");
2518 std::fs::create_dir_all(active.join("app/.server")).unwrap();
2519 std::fs::create_dir_all(inactive.join("app/.server")).unwrap();
2520 std::fs::write(active.join("app/.server/db.ts"), "export const db = {};").unwrap();
2521 std::fs::write(inactive.join("app/.server/db.ts"), "export const db = {};").unwrap();
2522
2523 let config = make_config(dir.path().to_path_buf(), false);
2524 let scopes = [HiddenDirScope::new(active, vec![".server".to_string()])];
2525 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2526 let names = file_names(&files, dir.path());
2527
2528 assert!(names.contains(&"packages/active/app/.server/db.ts".to_string()));
2529 assert!(!names.contains(&"packages/inactive/app/.server/db.ts".to_string()));
2530 }
2531
2532 #[test]
2533 fn exact_path_scope_does_not_admit_the_same_name_elsewhere() {
2534 let dir = tempfile::tempdir().expect("create temp dir");
2538 std::fs::create_dir_all(dir.path().join(".a/.b")).unwrap();
2539 std::fs::create_dir_all(dir.path().join("elsewhere/.b")).unwrap();
2540 std::fs::create_dir_all(dir.path().join("unrelated/.a")).unwrap();
2541 std::fs::write(dir.path().join(".a/.b/deep.mjs"), "export const a = 1;").unwrap();
2542 std::fs::write(dir.path().join("elsewhere/.b/y.mjs"), "export const b = 1;").unwrap();
2543 std::fs::write(dir.path().join("unrelated/.a/u.mjs"), "export const c = 1;").unwrap();
2544
2545 let config = make_config(dir.path().to_path_buf(), false);
2546 let scopes = [HiddenDirScope::new_exact_paths(
2547 dir.path().to_path_buf(),
2548 vec![".a".to_string(), format!(".a{MAIN_SEPARATOR}.b")],
2549 )];
2550 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2551 let names = file_names(&files, dir.path());
2552
2553 assert!(names.contains(&".a/.b/deep.mjs".to_string()));
2554 assert!(!names.contains(&"elsewhere/.b/y.mjs".to_string()));
2555 assert!(!names.contains(&"unrelated/.a/u.mjs".to_string()));
2556 }
2557
2558 #[test]
2559 fn exact_path_scope_admits_a_hidden_dir_under_a_visible_parent() {
2560 let dir = tempfile::tempdir().expect("create temp dir");
2561 std::fs::create_dir_all(dir.path().join("tools/.config")).unwrap();
2562 std::fs::create_dir_all(dir.path().join("other/.config")).unwrap();
2563 std::fs::write(
2564 dir.path().join("tools/.config/eslint.config.js"),
2565 "export default [];",
2566 )
2567 .unwrap();
2568 std::fs::write(
2569 dir.path().join("other/.config/eslint.config.js"),
2570 "export default [];",
2571 )
2572 .unwrap();
2573
2574 let config = make_config(dir.path().to_path_buf(), false);
2575 let scopes = [HiddenDirScope::new_exact_paths(
2576 dir.path().to_path_buf(),
2577 vec![format!("tools{MAIN_SEPARATOR}.config")],
2578 )];
2579 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2580 let names = file_names(&files, dir.path());
2581
2582 assert!(names.contains(&"tools/.config/eslint.config.js".to_string()));
2583 assert!(!names.contains(&"other/.config/eslint.config.js".to_string()));
2584 }
2585
2586 #[test]
2587 fn any_depth_scope_keeps_matching_by_name_for_plugins() {
2588 let dir = tempfile::tempdir().expect("create temp dir");
2592 std::fs::create_dir_all(dir.path().join("app/routes/deep/.server")).unwrap();
2593 std::fs::write(
2594 dir.path().join("app/routes/deep/.server/db.ts"),
2595 "export const db = {};",
2596 )
2597 .unwrap();
2598
2599 let config = make_config(dir.path().to_path_buf(), false);
2600 let scopes = [HiddenDirScope::new(
2601 dir.path().to_path_buf(),
2602 vec![".server".to_string()],
2603 )];
2604 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2605 let names = file_names(&files, dir.path());
2606
2607 assert!(names.contains(&"app/routes/deep/.server/db.ts".to_string()));
2608 }
2609
2610 #[test]
2611 fn excludes_root_build_directory() {
2612 let dir = tempfile::tempdir().expect("create temp dir");
2613
2614 std::fs::write(dir.path().join(".ignore"), "/build/\n").unwrap();
2615
2616 let build_dir = dir.path().join("build");
2617 std::fs::create_dir_all(&build_dir).unwrap();
2618 std::fs::write(build_dir.join("output.js"), "// build output").unwrap();
2619
2620 let src = dir.path().join("src");
2621 std::fs::create_dir_all(&src).unwrap();
2622 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2623
2624 let config = make_config(dir.path().to_path_buf(), false);
2625 let files = discover_files(&config);
2626 let names = file_names(&files, dir.path());
2627
2628 assert_eq!(names.len(), 1, "root build/ should be excluded via .ignore");
2629 assert!(names.contains(&"src/app.ts".to_string()));
2630 }
2631
2632 #[test]
2633 fn excludes_nested_build_directory() {
2634 let dir = tempfile::tempdir().expect("create temp dir");
2635
2636 let nested_build = dir.path().join("src").join("build");
2637 std::fs::create_dir_all(&nested_build).unwrap();
2638 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
2639
2640 let config = make_config(dir.path().to_path_buf(), false);
2641 let files = discover_files(&config);
2642 let names = file_names(&files, dir.path());
2643
2644 assert!(
2645 !names.contains(&"src/build/helper.ts".to_string()),
2646 "build/ is treated as generated output at any depth: {names:?}"
2647 );
2648 }
2649
2650 #[test]
2651 #[expect(
2652 clippy::cast_possible_truncation,
2653 reason = "test file counts are trivially small"
2654 )]
2655 fn file_ids_are_sequential_after_sorting() {
2656 let dir = tempfile::tempdir().expect("create temp dir");
2657 let src = dir.path().join("src");
2658 std::fs::create_dir_all(&src).unwrap();
2659
2660 std::fs::write(src.join("z_last.ts"), "export const z = 1;").unwrap();
2661 std::fs::write(src.join("a_first.ts"), "export const a = 1;").unwrap();
2662 std::fs::write(src.join("m_middle.ts"), "export const m = 1;").unwrap();
2663
2664 let config = make_config(dir.path().to_path_buf(), false);
2665 let files = discover_files(&config);
2666
2667 for (idx, file) in files.iter().enumerate() {
2668 assert_eq!(file.id, FileId(idx as u32), "FileId should be sequential");
2669 }
2670
2671 for pair in files.windows(2) {
2672 assert!(
2673 pair[0].path < pair[1].path,
2674 "files should be sorted by path"
2675 );
2676 }
2677 }
2678
2679 #[test]
2680 fn production_mode_excludes_test_files() {
2681 let dir = tempfile::tempdir().expect("create temp dir");
2682 let src = dir.path().join("src");
2683 std::fs::create_dir_all(&src).unwrap();
2684
2685 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2686 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
2687 std::fs::write(src.join("app.spec.ts"), "describe('a', () => {});").unwrap();
2688 std::fs::write(src.join("app.stories.tsx"), "export default {};").unwrap();
2689
2690 let config = make_config(dir.path().to_path_buf(), true);
2691 let files = discover_files(&config);
2692 let names = file_names(&files, dir.path());
2693
2694 assert!(
2695 names.contains(&"src/app.ts".to_string()),
2696 "source files should be included in production mode"
2697 );
2698 assert!(
2699 !names.contains(&"src/app.test.ts".to_string()),
2700 "test files should be excluded in production mode"
2701 );
2702 assert!(
2703 !names.contains(&"src/app.spec.ts".to_string()),
2704 "spec files should be excluded in production mode"
2705 );
2706 assert!(
2707 !names.contains(&"src/app.stories.tsx".to_string()),
2708 "story files should be excluded in production mode"
2709 );
2710 }
2711
2712 #[test]
2713 fn non_production_mode_includes_test_files() {
2714 let dir = tempfile::tempdir().expect("create temp dir");
2715 let src = dir.path().join("src");
2716 std::fs::create_dir_all(&src).unwrap();
2717
2718 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2719 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
2720
2721 let config = make_config(dir.path().to_path_buf(), false);
2722 let files = discover_files(&config);
2723 let names = file_names(&files, dir.path());
2724
2725 assert!(names.contains(&"src/app.ts".to_string()));
2726 assert!(
2727 names.contains(&"src/app.test.ts".to_string()),
2728 "test files should be included in non-production mode"
2729 );
2730 }
2731
2732 #[test]
2733 fn empty_directory_returns_no_files() {
2734 let dir = tempfile::tempdir().expect("create temp dir");
2735 let config = make_config(dir.path().to_path_buf(), false);
2736 let files = discover_files(&config);
2737 assert!(files.is_empty(), "empty project should discover no files");
2738 }
2739
2740 #[test]
2741 fn hidden_files_not_discovered_as_source() {
2742 let dir = tempfile::tempdir().expect("create temp dir");
2743
2744 std::fs::write(dir.path().join(".env"), "SECRET=abc").unwrap();
2745 std::fs::write(dir.path().join(".gitignore"), "node_modules").unwrap();
2746 std::fs::write(dir.path().join(".eslintrc.js"), "module.exports = {};").unwrap();
2747
2748 let src = dir.path().join("src");
2749 std::fs::create_dir_all(&src).unwrap();
2750 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2751
2752 let config = make_config(dir.path().to_path_buf(), false);
2753 let files = discover_files(&config);
2754 let names = file_names(&files, dir.path());
2755
2756 assert!(
2757 !names.contains(&".env".to_string()),
2758 ".env should not be discovered"
2759 );
2760 assert!(
2761 !names.contains(&".gitignore".to_string()),
2762 ".gitignore should not be discovered"
2763 );
2764 }
2765
2766 fn make_config_with_ignores(root: PathBuf, ignores: Vec<String>) -> ResolvedConfig {
2768 FallowConfig {
2769 type_aware: fallow_config::TypeAwareConfig::default(),
2770 schema: None,
2771 minimum_version: None,
2772 extends: vec![],
2773 entry: vec![],
2774 ignore_patterns: ignores,
2775 ignore_findings: vec![],
2776 framework: vec![],
2777 workspaces: None,
2778 ignore_dependencies: vec![],
2779 ignore_command_entries: vec![],
2780 ignore_unresolved_imports: vec![],
2781 ignore_exports: vec![],
2782 ignore_catalog_references: vec![],
2783 ignore_dependency_overrides: vec![],
2784 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(
2785 ),
2786 used_class_members: vec![],
2787 ignore_decorators: vec![],
2788 unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
2789 circular_dependencies: fallow_config::CircularDependenciesConfig::default(),
2790 duplicates: DuplicatesConfig::default(),
2791 similar_code: fallow_config::SimilarCodeConfig::default(),
2792 health: HealthConfig::default(),
2793 rules: RulesConfig::default(),
2794 boundaries: fallow_config::BoundaryConfig::default(),
2795 production: false.into(),
2796 plugins: vec![],
2797 rule_packs: vec![],
2798 dynamically_loaded: vec![],
2799 overrides: vec![],
2800 regression: None,
2801 audit: fallow_config::AuditConfig::default(),
2802 codeowners: None,
2803 public_packages: vec![],
2804 flags: FlagsConfig::default(),
2805 security: fallow_config::SecurityConfig::default(),
2806 fix: fallow_config::FixConfig::default(),
2807 resolve: ResolveConfig::default(),
2808 sealed: false,
2809 include_entry_exports: false,
2810 auto_imports: false,
2811 fail_on_parse_error: false,
2812 cache: fallow_config::CacheConfig::default(),
2813 }
2814 .resolve(root, OutputFormat::Human, 1, true, true, None)
2815 }
2816
2817 #[test]
2818 fn custom_ignore_patterns_exclude_matching_files() {
2819 let dir = tempfile::tempdir().expect("create temp dir");
2820
2821 let generated = dir.path().join("src").join("api").join("generated");
2822 std::fs::create_dir_all(&generated).unwrap();
2823 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
2824
2825 let client = dir.path().join("src").join("api").join("client");
2826 std::fs::create_dir_all(&client).unwrap();
2827 std::fs::write(client.join("fetch.ts"), "export const fetch = {};").unwrap();
2828
2829 let src = dir.path().join("src");
2830 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2831
2832 let config = make_config_with_ignores(
2833 dir.path().to_path_buf(),
2834 vec![
2835 "src/api/generated/**".to_string(),
2836 "src/api/client/**".to_string(),
2837 ],
2838 );
2839 let files = discover_files(&config);
2840 let names = file_names(&files, dir.path());
2841
2842 assert_eq!(names.len(), 1, "only non-ignored files: {names:?}");
2843 assert!(names.contains(&"src/index.ts".to_string()));
2844 }
2845
2846 #[test]
2847 fn leading_dot_ignore_patterns_exclude_matching_files() {
2848 let dir = tempfile::tempdir().expect("create temp dir");
2849
2850 let generated = dir.path().join("src").join("generated");
2851 std::fs::create_dir_all(&generated).unwrap();
2852 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
2853
2854 let src = dir.path().join("src");
2855 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2856
2857 let config = make_config_with_ignores(
2858 dir.path().to_path_buf(),
2859 vec!["./src/generated/**".to_string()],
2860 );
2861 let files = discover_files(&config);
2862 let names = file_names(&files, dir.path());
2863
2864 assert_eq!(names, vec!["src/index.ts"]);
2865 }
2866
2867 #[test]
2868 fn default_ignore_patterns_exclude_node_modules_and_dist() {
2869 let dir = tempfile::tempdir().expect("create temp dir");
2870
2871 let nm = dir.path().join("node_modules").join("lodash");
2872 std::fs::create_dir_all(&nm).unwrap();
2873 std::fs::write(nm.join("lodash.js"), "module.exports = {};").unwrap();
2874
2875 let dist = dir.path().join("dist");
2876 std::fs::create_dir_all(&dist).unwrap();
2877 std::fs::write(dist.join("bundle.js"), "// bundled").unwrap();
2878
2879 let src = dir.path().join("src");
2880 std::fs::create_dir_all(&src).unwrap();
2881 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2882
2883 let config = make_config(dir.path().to_path_buf(), false);
2884 let files = discover_files(&config);
2885 let names = file_names(&files, dir.path());
2886
2887 assert_eq!(names.len(), 1);
2888 assert!(names.contains(&"src/index.ts".to_string()));
2889 }
2890
2891 #[test]
2892 fn default_ignore_patterns_exclude_build_at_any_depth() {
2893 let dir = tempfile::tempdir().expect("create temp dir");
2894
2895 let build = dir.path().join("build");
2896 std::fs::create_dir_all(&build).unwrap();
2897 std::fs::write(build.join("output.js"), "// built").unwrap();
2898
2899 let nested_build = dir.path().join("src").join("build");
2900 std::fs::create_dir_all(&nested_build).unwrap();
2901 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
2902
2903 let src = dir.path().join("src");
2904 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2905
2906 let config = make_config(dir.path().to_path_buf(), false);
2907 let files = discover_files(&config);
2908 let names = file_names(&files, dir.path());
2909
2910 assert_eq!(names, vec!["src/index.ts".to_string()]);
2911 }
2912
2913 #[test]
2916 fn default_ignore_patterns_exclude_nested_build() {
2917 let dir = tempfile::tempdir().expect("create temp dir");
2918
2919 let build = dir.path().join("build");
2920 std::fs::create_dir_all(&build).unwrap();
2921 std::fs::write(build.join("output.js"), "// built").unwrap();
2922
2923 let package_build = dir.path().join("projects").join("app").join("build");
2924 std::fs::create_dir_all(&package_build).unwrap();
2925 std::fs::write(package_build.join("index.js"), "// built").unwrap();
2926
2927 let src = dir.path().join("src");
2928 std::fs::create_dir_all(&src).unwrap();
2929 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2930
2931 let config = make_config(dir.path().to_path_buf(), false);
2932 let files = discover_files(&config);
2933 let names = file_names(&files, dir.path());
2934
2935 assert_eq!(names, vec!["src/index.ts".to_string()]);
2936 }
2937
2938 #[test]
2940 fn default_ignore_patterns_keep_paths_that_merely_contain_build() {
2941 let dir = tempfile::tempdir().expect("create temp dir");
2942
2943 let src = dir.path().join("src");
2944 std::fs::create_dir_all(src.join("rebuild")).unwrap();
2945 std::fs::create_dir_all(src.join("buildings")).unwrap();
2946 std::fs::write(src.join("build.ts"), "export const a = 1;").unwrap();
2947 std::fs::write(src.join("rebuild").join("helper.ts"), "export const b = 1;").unwrap();
2948 std::fs::write(src.join("buildings").join("a.ts"), "export const c = 1;").unwrap();
2949
2950 let config = make_config(dir.path().to_path_buf(), false);
2951 let files = discover_files(&config);
2952 let mut names = file_names(&files, dir.path());
2953 names.sort();
2954
2955 assert_eq!(
2956 names,
2957 vec![
2958 "src/build.ts".to_string(),
2959 "src/buildings/a.ts".to_string(),
2960 "src/rebuild/helper.ts".to_string(),
2961 ]
2962 );
2963 }
2964
2965 fn make_config_with_max_file_size(
2967 root: PathBuf,
2968 max_file_size_bytes: Option<u64>,
2969 ) -> ResolvedConfig {
2970 let mut config = make_config(root, false);
2971 config.max_file_size_bytes = max_file_size_bytes;
2972 config
2973 }
2974
2975 #[test]
2976 fn skips_files_over_max_file_size() {
2977 let dir = tempfile::tempdir().expect("create temp dir");
2978 let src = dir.path().join("src");
2979 std::fs::create_dir_all(&src).unwrap();
2980 std::fs::write(src.join("small.ts"), "export const a = 1;").unwrap();
2981 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2982
2983 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
2984 let files = discover_files(&config);
2985 let names = file_names(&files, dir.path());
2986
2987 assert!(names.contains(&"src/small.ts".to_string()));
2988 assert!(
2989 !names.contains(&"src/huge.ts".to_string()),
2990 "a file over the size limit must not be discovered"
2991 );
2992 }
2993
2994 #[test]
2995 fn declaration_files_exempt_from_size_skip() {
2996 let dir = tempfile::tempdir().expect("create temp dir");
2997 let src = dir.path().join("src");
2998 std::fs::create_dir_all(&src).unwrap();
2999 std::fs::write(src.join("auto-imports.d.ts"), "x".repeat(5_000)).unwrap();
3000 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
3001
3002 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
3003 let files = discover_files(&config);
3004 let names = file_names(&files, dir.path());
3005
3006 assert!(
3007 names.contains(&"src/auto-imports.d.ts".to_string()),
3008 "a large .d.ts is exempt from the skip (reachability root for global types)"
3009 );
3010 assert!(!names.contains(&"src/huge.ts".to_string()));
3011 }
3012
3013 #[test]
3014 fn unlimited_size_keeps_large_files() {
3015 let dir = tempfile::tempdir().expect("create temp dir");
3016 let src = dir.path().join("src");
3017 std::fs::create_dir_all(&src).unwrap();
3018 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
3019
3020 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
3021 let files = discover_files(&config);
3022 let names = file_names(&files, dir.path());
3023
3024 assert!(
3025 names.contains(&"src/huge.ts".to_string()),
3026 "no limit keeps every file"
3027 );
3028 }
3029
3030 #[test]
3031 fn skipped_file_recorded_in_workspace_diagnostics() {
3032 let dir = tempfile::tempdir().expect("create temp dir");
3033 let src = dir.path().join("src");
3034 std::fs::create_dir_all(&src).unwrap();
3035 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
3036
3037 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
3038 let _ = discover_files(&config);
3039
3040 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
3041 let skipped: Vec<_> = diagnostics
3042 .iter()
3043 .filter(|d| {
3044 matches!(
3045 d.kind,
3046 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { .. }
3047 )
3048 })
3049 .collect();
3050 assert_eq!(
3051 skipped.len(),
3052 1,
3053 "the skipped file is recorded in workspace diagnostics for JSON output"
3054 );
3055 assert!(skipped[0].path.ends_with("src/huge.ts"));
3056 assert!(
3057 matches!(
3058 skipped[0].kind,
3059 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes }
3060 if size_bytes == 5_000
3061 ),
3062 "the recorded diagnostic carries the on-disk byte size"
3063 );
3064 }
3065
3066 fn dotdir_diagnostics(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
3068 fallow_config::workspace_diagnostics_for(root)
3069 .into_iter()
3070 .filter(|d| {
3071 matches!(
3072 d.kind,
3073 fallow_config::WorkspaceDiagnosticKind::SkippedSourceDotdir
3074 )
3075 })
3076 .collect()
3077 }
3078
3079 fn write_at(root: &Path, relative: &str, contents: &str) {
3080 let path = root.join(relative);
3081 std::fs::create_dir_all(path.parent().expect("has a parent")).unwrap();
3082 std::fs::write(path, contents).unwrap();
3083 }
3084
3085 #[test]
3086 fn skipped_source_dotdir_recorded_in_workspace_diagnostics() {
3087 let dir = tempfile::tempdir().expect("create temp dir");
3088 write_at(
3089 dir.path(),
3090 ".claude/hooks/probe.mjs",
3091 "export const a = 1;\n",
3092 );
3093 write_at(dir.path(), "src/app.ts", "export const b = 2;\n");
3094
3095 let config = make_config(dir.path().to_path_buf(), false);
3096 let files = discover_files(&config);
3097 let names = file_names(&files, dir.path());
3098
3099 let reported = dotdir_diagnostics(dir.path());
3100 assert_eq!(reported.len(), 1, "one skipped dotdir holds source files");
3101 assert!(reported[0].path.ends_with(".claude"));
3102 assert_eq!(reported[0].kind.id(), "skipped-source-dotdir");
3103 assert!(
3104 reported[0].message.contains("--root"),
3105 "message names the real remedy: {}",
3106 reported[0].message
3107 );
3108 assert!(
3109 names.contains(&"src/app.ts".to_string()),
3110 "traversal is unchanged for ordinary directories"
3111 );
3112 assert!(
3113 !names.contains(&".claude/hooks/probe.mjs".to_string()),
3114 "the diagnostic reports the skip, it does not change traversal"
3115 );
3116 }
3117
3118 #[test]
3119 fn allowlisted_dotdir_is_not_reported() {
3120 let dir = tempfile::tempdir().expect("create temp dir");
3121 write_at(dir.path(), ".storybook/main.ts", "export const a = 1;\n");
3122
3123 let config = make_config(dir.path().to_path_buf(), false);
3124 let files = discover_files(&config);
3125 let names = file_names(&files, dir.path());
3126
3127 assert!(dotdir_diagnostics(dir.path()).is_empty());
3128 assert!(
3129 names.contains(&".storybook/main.ts".to_string()),
3130 "an allowlisted dotdir is still traversed"
3131 );
3132 }
3133
3134 #[test]
3135 fn denylisted_dotdir_is_not_reported() {
3136 let dir = tempfile::tempdir().expect("create temp dir");
3137 write_at(dir.path(), ".idea/workspace.ts", "export const a = 1;\n");
3138 write_at(dir.path(), ".husky/hook.js", "export const b = 2;\n");
3139 write_at(dir.path(), ".next/page.js", "export const c = 3;\n");
3140 write_at(dir.path(), ".pnpm/x.js", "export const d = 4;\n");
3141
3142 let config = make_config(dir.path().to_path_buf(), false);
3143 let _ = discover_files(&config);
3144
3145 assert!(
3146 dotdir_diagnostics(dir.path()).is_empty(),
3147 "build caches, VCS and package-manager state never advise"
3148 );
3149 }
3150
3151 #[test]
3152 fn scoped_dotdir_is_traversed_and_not_reported() {
3153 let dir = tempfile::tempdir().expect("create temp dir");
3154 write_at(
3155 dir.path(),
3156 ".claude/hooks/probe.mjs",
3157 "export const a = 1;\n",
3158 );
3159
3160 let config = make_config(dir.path().to_path_buf(), false);
3161 let scopes = [HiddenDirScope::new(
3162 dir.path().to_path_buf(),
3163 vec![".claude".to_owned()],
3164 )];
3165 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
3166 let names = file_names(&files, dir.path());
3167
3168 assert!(
3169 dotdir_diagnostics(dir.path()).is_empty(),
3170 "a plugin- or script-contributed scope is admitted, so nothing was skipped"
3171 );
3172 assert!(names.contains(&".claude/hooks/probe.mjs".to_string()));
3173 }
3174
3175 #[test]
3176 fn ignore_patterns_silence_the_skipped_source_dotdir() {
3177 let dir = tempfile::tempdir().expect("create temp dir");
3178 write_at(
3179 dir.path(),
3180 ".claude/hooks/probe.mjs",
3181 "export const a = 1;\n",
3182 );
3183
3184 let config =
3185 make_config_with_ignores(dir.path().to_path_buf(), vec![".claude/**".to_owned()]);
3186 let _ = discover_files(&config);
3187
3188 assert!(
3189 dotdir_diagnostics(dir.path()).is_empty(),
3190 "the documented silencing route works"
3191 );
3192 }
3193
3194 #[test]
3195 fn dotdir_without_source_files_is_not_reported() {
3196 let dir = tempfile::tempdir().expect("create temp dir");
3197 write_at(dir.path(), ".claude/settings.json", "{}\n");
3198 write_at(dir.path(), ".claude/README.md", "# notes\n");
3199
3200 let config = make_config(dir.path().to_path_buf(), false);
3201 let _ = discover_files(&config);
3202
3203 assert!(dotdir_diagnostics(dir.path()).is_empty());
3204 }
3205
3206 #[test]
3207 fn dotdir_source_at_scan_depth_limit_is_reported() {
3208 let dir = tempfile::tempdir().expect("create temp dir");
3209 write_at(dir.path(), ".claude/a/b/deep.ts", "export const a = 1;\n");
3210
3211 let config = make_config(dir.path().to_path_buf(), false);
3212 let _ = discover_files(&config);
3213
3214 assert_eq!(dotdir_diagnostics(dir.path()).len(), 1);
3215 }
3216
3217 #[test]
3218 fn dotdir_source_below_scan_depth_limit_is_not_reported() {
3219 let dir = tempfile::tempdir().expect("create temp dir");
3220 write_at(
3221 dir.path(),
3222 ".claude/a/b/c/deeper.ts",
3223 "export const a = 1;\n",
3224 );
3225
3226 let config = make_config(dir.path().to_path_buf(), false);
3227 let _ = discover_files(&config);
3228
3229 assert!(
3230 dotdir_diagnostics(dir.path()).is_empty(),
3231 "the depth cap is real, so widening it stays a deliberate act"
3232 );
3233 }
3234
3235 fn mark_as_git_repo(root: &Path) {
3239 std::fs::create_dir_all(root.join(".git")).expect("create .git marker");
3240 }
3241
3242 #[test]
3243 fn gitignored_dotdir_contents_are_not_reported() {
3244 for pattern in [".tooling/**", ".tooling/*", "**/.tooling/**", "*.ts"] {
3248 let dir = tempfile::tempdir().expect("create temp dir");
3249 mark_as_git_repo(dir.path());
3250 write_at(dir.path(), ".gitignore", &format!("{pattern}\n"));
3251 write_at(dir.path(), ".tooling/mod.ts", "export const a = 1;\n");
3252
3253 let config = make_config(dir.path().to_path_buf(), false);
3254 let _ = discover_files(&config);
3255
3256 assert!(
3257 dotdir_diagnostics(dir.path()).is_empty(),
3258 "gitignore pattern '{pattern}' excludes the contents, so neither \
3259 advertised remedy would find anything there"
3260 );
3261 }
3262 }
3263
3264 #[test]
3265 fn self_ignoring_dotdir_is_not_reported() {
3266 let dir = tempfile::tempdir().expect("create temp dir");
3267 mark_as_git_repo(dir.path());
3268 write_at(dir.path(), ".toolcache/.gitignore", "*\n");
3269 write_at(dir.path(), ".toolcache/mod.ts", "export const a = 1;\n");
3270
3271 let config = make_config(dir.path().to_path_buf(), false);
3272 let _ = discover_files(&config);
3273
3274 assert!(
3275 dotdir_diagnostics(dir.path()).is_empty(),
3276 "a cache directory that ignores itself has excluded its own contents"
3277 );
3278 }
3279
3280 #[test]
3281 fn ungitignored_dotdir_in_a_git_repo_is_still_reported() {
3282 let dir = tempfile::tempdir().expect("create temp dir");
3283 mark_as_git_repo(dir.path());
3284 write_at(dir.path(), ".gitignore", "dist/\n");
3285 write_at(dir.path(), ".tooling/mod.ts", "export const a = 1;\n");
3286
3287 let config = make_config(dir.path().to_path_buf(), false);
3288 let _ = discover_files(&config);
3289
3290 assert_eq!(
3291 dotdir_diagnostics(dir.path()).len(),
3292 1,
3293 "the gitignore check must not swallow the case the diagnostic exists for"
3294 );
3295 }
3296
3297 #[test]
3298 fn production_run_does_not_report_a_test_only_dotdir() {
3299 let dir = tempfile::tempdir().expect("create temp dir");
3300 write_at(dir.path(), ".qa/thing.test.ts", "export const a = 1;\n");
3301 write_at(dir.path(), ".qa/thing.stories.tsx", "export const b = 2;\n");
3302
3303 let config = make_config(dir.path().to_path_buf(), true);
3304 let _ = discover_files(&config);
3305
3306 assert!(
3307 dotdir_diagnostics(dir.path()).is_empty(),
3308 "a --production run would analyze none of those files, so the \
3309 --root remedy would return nothing"
3310 );
3311 }
3312
3313 #[test]
3314 fn production_run_still_reports_a_dotdir_with_production_source() {
3315 let dir = tempfile::tempdir().expect("create temp dir");
3316 write_at(dir.path(), ".qa/thing.test.ts", "export const a = 1;\n");
3317 write_at(dir.path(), ".qa/helper.ts", "export const b = 2;\n");
3318
3319 let config = make_config(dir.path().to_path_buf(), true);
3320 let _ = discover_files(&config);
3321
3322 assert_eq!(dotdir_diagnostics(dir.path()).len(), 1);
3323 }
3324
3325 #[test]
3326 fn dotdir_with_only_generated_markup_is_not_reported() {
3327 let dir = tempfile::tempdir().expect("create temp dir");
3328 write_at(dir.path(), ".lighthouseci/lhr-1.html", "<html></html>\n");
3329 write_at(dir.path(), ".styles/theme.css", ":root { color: red; }\n");
3330 write_at(dir.path(), ".gql/schema.graphql", "type Query { a: Int }\n");
3331
3332 let config = make_config(dir.path().to_path_buf(), false);
3333 let _ = discover_files(&config);
3334
3335 assert!(
3336 dotdir_diagnostics(dir.path()).is_empty(),
3337 "the message claims imports and exports are lost, and these have none"
3338 );
3339 }
3340
3341 #[test]
3342 fn generated_tool_and_foreign_vcs_dotdirs_are_not_reported() {
3343 let dir = tempfile::tempdir().expect("create temp dir");
3344 write_at(dir.path(), ".astro/types.d.ts", "export {};\n");
3345 write_at(dir.path(), ".wxt/types/imports.d.ts", "export {};\n");
3346 write_at(dir.path(), ".yalc/pkg/index.js", "export const a = 1;\n");
3347 write_at(dir.path(), ".jj/repo/config.js", "export const b = 2;\n");
3348 write_at(dir.path(), ".svn/pristine/y.js", "export const c = 3;\n");
3349
3350 let config = make_config(dir.path().to_path_buf(), false);
3351 let _ = discover_files(&config);
3352
3353 assert!(
3354 dotdir_diagnostics(dir.path()).is_empty(),
3355 "generated output and foreign VCS metadata are not first-party source"
3356 );
3357 }
3358
3359 #[test]
3360 fn denylisted_dotdirs_do_not_consume_the_candidate_ceiling() {
3361 let dir = tempfile::tempdir().expect("create temp dir");
3362 for index in 0..(DOTDIR_SCAN_MAX_CANDIDATES + 8) {
3365 write_at(
3366 dir.path(),
3367 &format!("packages/pkg{index:03}/.turbo/blob.js"),
3368 "export const a = 1;\n",
3369 );
3370 }
3371 write_at(dir.path(), "zz/.tooling/mod.ts", "export const b = 2;\n");
3372
3373 let config = make_config(dir.path().to_path_buf(), false);
3374 let _ = discover_files(&config);
3375
3376 let reported = dotdir_diagnostics(dir.path());
3377 assert_eq!(reported.len(), 1, "{reported:?}");
3378 assert!(reported[0].path.ends_with(".tooling"));
3379 }
3380
3381 #[test]
3382 fn one_pathological_dotdir_cannot_starve_the_rest() {
3383 let dir = tempfile::tempdir().expect("create temp dir");
3384 for index in 0..(DOTDIR_SCAN_MAX_ENTRIES * 2) {
3386 write_at(dir.path(), &format!(".aaa-noise/f{index}.bin"), "x");
3387 }
3388 write_at(dir.path(), ".zzz-real/mod.ts", "export const a = 1;\n");
3389
3390 let config = make_config(dir.path().to_path_buf(), false);
3391 let _ = discover_files(&config);
3392
3393 let reported = dotdir_diagnostics(dir.path());
3394 assert_eq!(reported.len(), 1, "{reported:?}");
3395 assert!(reported[0].path.ends_with(".zzz-real"));
3396 }
3397
3398 #[test]
3399 fn repeat_walks_do_not_stack_skipped_source_dotdirs() {
3400 let dir = tempfile::tempdir().expect("create temp dir");
3401 write_at(
3402 dir.path(),
3403 ".claude/hooks/probe.mjs",
3404 "export const a = 1;\n",
3405 );
3406
3407 let config = make_config(dir.path().to_path_buf(), false);
3408 let _ = discover_files(&config);
3409 let _ = discover_files(&config);
3410
3411 assert_eq!(
3412 dotdir_diagnostics(dir.path()).len(),
3413 1,
3414 "each walk replaces its own root's source-discovery set"
3415 );
3416 }
3417
3418 #[test]
3419 fn skips_large_one_line_js_as_minified_generated_output() {
3420 let dir = tempfile::tempdir().expect("create temp dir");
3421 let src = dir.path().join("src");
3422 std::fs::create_dir_all(&src).unwrap();
3423 let asset = src.join("index-abc123.js");
3424 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
3425
3426 let config = make_config(dir.path().to_path_buf(), false);
3427 let files = discover_files(&config);
3428 let names = file_names(&files, dir.path());
3429
3430 assert!(
3431 !names.contains(&"src/index-abc123.js".to_string()),
3432 "large one-line JS assets should be skipped before parsing"
3433 );
3434
3435 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
3436 assert!(
3437 diagnostics.iter().any(|diag| {
3438 diag.path.ends_with("src/index-abc123.js")
3439 && matches!(
3440 diag.kind,
3441 fallow_config::WorkspaceDiagnosticKind::SkippedMinifiedFile { .. }
3442 )
3443 }),
3444 "the skipped minified asset is recorded for JSON output: {diagnostics:?}"
3445 );
3446 }
3447
3448 #[test]
3449 fn unlimited_size_keeps_large_one_line_js() {
3450 let dir = tempfile::tempdir().expect("create temp dir");
3451 let src = dir.path().join("src");
3452 std::fs::create_dir_all(&src).unwrap();
3453 let asset = src.join("index-abc123.js");
3454 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
3455
3456 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
3457 let files = discover_files(&config);
3458 let names = file_names(&files, dir.path());
3459
3460 assert!(
3461 names.contains(&"src/index-abc123.js".to_string()),
3462 "--max-file-size 0 should opt out of generated JS skipping"
3463 );
3464 }
3465
3466 #[test]
3467 fn keeps_large_multiline_js() {
3468 let dir = tempfile::tempdir().expect("create temp dir");
3469 let src = dir.path().join("src");
3470 std::fs::create_dir_all(&src).unwrap();
3471 let asset = src.join("handwritten.js");
3472 let mut content = String::new();
3473 while content.len() <= MINIFIED_FILE_SKIP_BYTES as usize + 1 {
3474 content.push_str("export const value = 1;\n");
3475 }
3476 std::fs::write(&asset, content).unwrap();
3477
3478 let config = make_config(dir.path().to_path_buf(), false);
3479 let files = discover_files(&config);
3480 let names = file_names(&files, dir.path());
3481
3482 assert!(
3483 names.contains(&"src/handwritten.js".to_string()),
3484 "large multiline JS should not be treated as a generated minified asset"
3485 );
3486 }
3487 }
3488}