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