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