1use std::ffi::OsStr;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex, OnceLock};
4
5use fallow_config::{ResolvedConfig, WorkspaceDiagnostic, WorkspaceDiagnosticKind};
6use fallow_types::discover::{DiscoveredFile, FileId};
7use ignore::WalkBuilder;
8use rustc_hash::FxHashSet;
9
10use super::{ALLOWED_HIDDEN_DIRS, SCRIPT_SCOPE_DENYLIST};
11
12fn should_emit_note_once(key: String) -> bool {
18 static EMITTED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
19 EMITTED
20 .get_or_init(|| Mutex::new(FxHashSet::default()))
21 .lock()
22 .map_or(true, |mut set| set.insert(key))
23}
24
25type SizedFile = (PathBuf, u64);
28
29type SkippedDotdirSink = Arc<Mutex<Vec<PathBuf>>>;
37
38const NOTE_EXAMPLE_CAP: usize = 5;
42
43const DOTDIR_SCAN_MAX_DEPTH: usize = 2;
48
49const DOTDIR_SCAN_MAX_ENTRIES: usize = 256;
55
56const DOTDIR_SCAN_TOTAL_ENTRIES: usize = 1024;
67
68const DOTDIR_SCAN_MAX_CANDIDATES: usize = 64;
75
76const DOTDIR_MODULE_EXTENSIONS: &[&str] = &[
83 "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
84 "mdx",
85];
86
87const LARGE_SET_THRESHOLD: usize = 20_000;
91
92const LARGE_FILE_NOTE_BYTES: u64 = 4 * 1024 * 1024;
97
98const NOTE_FILE_FLOOR_BYTES: u64 = 256 * 1024;
102
103const MINIFIED_FILE_SKIP_BYTES: u64 = 1024 * 1024;
107
108const MINIFIED_SAMPLE_BYTES: usize = 256 * 1024;
110
111const MINIFIED_LONG_LINE_BYTES: usize = 128 * 1024;
114
115fn is_declaration_file(path: &Path) -> bool {
120 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
121 name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
122}
123
124fn is_plain_js_file(path: &Path) -> bool {
125 matches!(
126 path.extension().and_then(|ext| ext.to_str()),
127 Some("js" | "mjs" | "cjs")
128 )
129}
130
131fn has_minified_line_shape(path: &Path) -> bool {
132 use std::io::Read;
133
134 let Ok(mut file) = std::fs::File::open(path) else {
135 return false;
136 };
137 let mut sample = vec![0; MINIFIED_SAMPLE_BYTES];
138 let Ok(len) = file.read(&mut sample) else {
139 return false;
140 };
141 sample.truncate(len);
142 if sample.is_empty() {
143 return false;
144 }
145
146 let mut current_line = 0usize;
147 for byte in sample {
148 if byte == b'\n' || byte == b'\r' {
149 current_line = 0;
150 continue;
151 }
152 current_line += 1;
153 if current_line >= MINIFIED_LONG_LINE_BYTES {
154 return true;
155 }
156 }
157 false
158}
159
160fn is_probably_minified_generated_js(path: &Path, size_bytes: u64) -> bool {
161 size_bytes >= MINIFIED_FILE_SKIP_BYTES
162 && is_plain_js_file(path)
163 && !is_declaration_file(path)
164 && has_minified_line_shape(path)
165}
166
167fn format_size_mb(bytes: u64) -> String {
169 #[expect(
170 clippy::cast_precision_loss,
171 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
172 )]
173 let mb = bytes as f64 / (1024.0 * 1024.0);
174 format!("{mb:.1} MB")
175}
176
177fn summarize_examples(root: &Path, examples: &[SizedFile]) -> String {
180 let shown: Vec<String> = examples
181 .iter()
182 .take(NOTE_EXAMPLE_CAP)
183 .map(|(path, size)| {
184 let display = display_relative_path(root, path);
185 format!("{display} ({})", format_size_mb(*size))
186 })
187 .collect();
188 let remaining = examples.len().saturating_sub(NOTE_EXAMPLE_CAP);
189 if remaining > 0 {
190 format!("{}, and {remaining} more", shown.join(", "))
191 } else {
192 shown.join(", ")
193 }
194}
195
196fn partition_by_size(
199 raw: Vec<SizedFile>,
200 max_file_size_bytes: Option<u64>,
201) -> (Vec<SizedFile>, Vec<SizedFile>) {
202 let Some(limit) = max_file_size_bytes else {
203 return (raw, Vec::new());
204 };
205 raw.into_iter()
206 .partition(|(path, size)| *size <= limit || is_declaration_file(path))
207}
208
209fn partition_minified_generated_js(
212 raw: Vec<SizedFile>,
213 max_file_size_bytes: Option<u64>,
214) -> (Vec<SizedFile>, Vec<SizedFile>) {
215 if max_file_size_bytes.is_none() {
216 return (raw, Vec::new());
217 }
218 raw.into_iter()
219 .partition(|(path, size)| !is_probably_minified_generated_js(path, *size))
220}
221
222fn report_skipped_large_files(
227 config: &ResolvedConfig,
228 skipped: &[SizedFile],
229) -> Vec<WorkspaceDiagnostic> {
230 if skipped.is_empty() {
231 return Vec::new();
232 }
233 let diagnostics: Vec<WorkspaceDiagnostic> = skipped
234 .iter()
235 .map(|(path, size_bytes)| {
236 WorkspaceDiagnostic::new(
237 &config.root,
238 path.clone(),
239 WorkspaceDiagnosticKind::SkippedLargeFile {
240 size_bytes: *size_bytes,
241 },
242 )
243 })
244 .collect();
245
246 let mut sorted: Vec<SizedFile> = skipped.to_vec();
247 sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
248 let count = skipped.len();
249 if !config.quiet
250 && should_emit_note_once(format!(
251 "skip::{}::{count}::{}",
252 config.root.display(),
253 sorted.first().map_or(0, |f| f.1)
254 ))
255 {
256 let examples = summarize_examples(&config.root, &sorted);
257 let noun = if count == 1 { "file" } else { "files" };
258 tracing::warn!(
259 "fallow: skipped {count} {noun} over the max file size limit ({examples}). \
260 Raise the limit with --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add them to ignorePatterns."
261 );
262 }
263 diagnostics
264}
265
266fn report_skipped_minified_files(
269 config: &ResolvedConfig,
270 skipped: &[SizedFile],
271) -> Vec<WorkspaceDiagnostic> {
272 if skipped.is_empty() {
273 return Vec::new();
274 }
275 let diagnostics: Vec<WorkspaceDiagnostic> = skipped
276 .iter()
277 .map(|(path, size_bytes)| {
278 WorkspaceDiagnostic::new(
279 &config.root,
280 path.clone(),
281 WorkspaceDiagnosticKind::SkippedMinifiedFile {
282 size_bytes: *size_bytes,
283 },
284 )
285 })
286 .collect();
287
288 let mut sorted: Vec<SizedFile> = skipped.to_vec();
289 sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
290 let count = skipped.len();
291 if !config.quiet
292 && should_emit_note_once(format!(
293 "minified::{}::{count}::{}",
294 config.root.display(),
295 sorted.first().map_or(0, |f| f.1)
296 ))
297 {
298 let examples = summarize_examples(&config.root, &sorted);
299 let noun = if count == 1 { "file" } else { "files" };
300 let pronoun = if count == 1 { "it" } else { "them" };
301 tracing::warn!(
302 "fallow: skipped {count} minified generated JS {noun} ({examples}). \
303 Add {pronoun} to ignorePatterns, rename {pronoun} with a .min.js suffix, or use --max-file-size 0 to analyze {pronoun}."
304 );
305 }
306 diagnostics
307}
308
309fn summarize_paths(root: &Path, examples: &[&PathBuf]) -> String {
313 let shown: Vec<String> = examples
314 .iter()
315 .take(NOTE_EXAMPLE_CAP)
316 .map(|path| display_relative_path(root, path))
317 .collect();
318 let remaining = examples.len().saturating_sub(NOTE_EXAMPLE_CAP);
319 if remaining > 0 {
320 format!("{}, and {remaining} more", shown.join(", "))
321 } else {
322 shown.join(", ")
323 }
324}
325
326fn summarize_paths_open_ended(root: &Path, examples: &[&PathBuf]) -> String {
329 let shown: Vec<String> = examples
330 .iter()
331 .take(NOTE_EXAMPLE_CAP)
332 .map(|path| display_relative_path(root, path))
333 .collect();
334 if examples.len() > NOTE_EXAMPLE_CAP {
335 format!("{}, and more", shown.join(", "))
336 } else {
337 shown.join(", ")
338 }
339}
340
341fn display_relative_path(root: &Path, path: &Path) -> String {
344 path.strip_prefix(root)
345 .unwrap_or(path)
346 .display()
347 .to_string()
348 .replace('\\', "/")
349}
350
351fn is_excluded_from_analysis(
369 config: &ResolvedConfig,
370 production_excludes: Option<&globset::GlobSet>,
371 path: &Path,
372) -> bool {
373 let relative = path.strip_prefix(&config.root).unwrap_or(path);
374 config.ignore_patterns.is_match(relative)
375 || production_excludes.is_some_and(|excludes| excludes.is_match(relative))
376}
377
378fn has_module_extension(path: &Path) -> bool {
382 path.extension()
383 .and_then(OsStr::to_str)
384 .is_some_and(|ext| DOTDIR_MODULE_EXTENSIONS.contains(&ext))
385}
386
387fn scan_for_reportable_source(
410 config: &ResolvedConfig,
411 production_excludes: Option<&globset::GlobSet>,
412 dir: &Path,
413 budget: &mut usize,
414) -> bool {
415 let mut builder = WalkBuilder::new(dir);
416 builder
417 .hidden(false)
418 .git_ignore(true)
419 .git_global(true)
420 .git_exclude(true)
421 .follow_links(false)
422 .max_depth(Some(DOTDIR_SCAN_MAX_DEPTH + 1))
423 .threads(1);
424 builder.filter_entry(|entry| {
425 if entry.depth() == 0 || !entry.file_type().is_some_and(|ft| ft.is_dir()) {
426 return true;
427 }
428 entry
429 .file_name()
430 .to_str()
431 .is_none_or(|name| !SCRIPT_SCOPE_DENYLIST.contains(&name) && name != "node_modules")
432 });
433
434 let mut per_dotdir = DOTDIR_SCAN_MAX_ENTRIES;
435 for entry in builder.build() {
436 if per_dotdir == 0 || *budget == 0 {
437 return false;
438 }
439 per_dotdir -= 1;
440 *budget -= 1;
441 let Ok(entry) = entry else {
442 continue;
443 };
444 #[expect(
447 clippy::filetype_is_file,
448 reason = "regular files only is the point: !is_dir() would readmit fifos and sockets"
449 )]
450 let is_regular_file = entry
451 .file_type()
452 .is_some_and(|file_type| file_type.is_file());
453 if !is_regular_file {
454 continue;
455 }
456 if has_module_extension(entry.path())
457 && !is_excluded_from_analysis(config, production_excludes, entry.path())
458 {
459 return true;
460 }
461 }
462 false
463}
464
465const DOTDIR_NOISE_PATH_COMPONENTS: &[&str] = &[
469 "__fixtures__",
470 "__mocks__",
471 "__tests__",
472 "e2e",
473 "fixture",
474 "fixtures",
475 "playground",
476 "playgrounds",
477 "spec",
478 "test",
479 "tests",
480];
481
482fn dotdir_is_scan_candidate(config: &ResolvedConfig, dir: &Path) -> bool {
489 let Some(name) = dir.file_name().and_then(OsStr::to_str) else {
490 return false;
491 };
492 if SCRIPT_SCOPE_DENYLIST.contains(&name) {
493 return false;
494 }
495 let relative = dir.strip_prefix(&config.root).unwrap_or(dir);
496 if relative
500 .components()
501 .any(|component| component.as_os_str() == OsStr::new("node_modules"))
502 {
503 return false;
504 }
505 !relative.components().any(|component| {
514 DOTDIR_NOISE_PATH_COMPONENTS.contains(&component.as_os_str().to_string_lossy().as_ref())
515 })
516}
517
518fn report_skipped_source_dotdirs(
524 config: &ResolvedConfig,
525 production_excludes: Option<&globset::GlobSet>,
526 candidates: &[PathBuf],
527) -> Vec<WorkspaceDiagnostic> {
528 if candidates.is_empty() {
529 return Vec::new();
530 }
531 let mut budget = DOTDIR_SCAN_TOTAL_ENTRIES;
534 let scannable: Vec<&PathBuf> = candidates
535 .iter()
536 .filter(|dir| dotdir_is_scan_candidate(config, dir))
537 .collect();
538 let reportable: Vec<&PathBuf> = scannable
539 .iter()
540 .copied()
541 .take(DOTDIR_SCAN_MAX_CANDIDATES)
542 .filter(|dir| scan_for_reportable_source(config, production_excludes, dir, &mut budget))
543 .collect();
544 let truncated = scannable.len() > DOTDIR_SCAN_MAX_CANDIDATES || budget == 0;
547 if reportable.is_empty() {
548 return Vec::new();
549 }
550
551 let diagnostics: Vec<WorkspaceDiagnostic> = reportable
552 .iter()
553 .map(|dir| {
554 WorkspaceDiagnostic::new(
555 &config.root,
556 (*dir).clone(),
557 WorkspaceDiagnosticKind::SkippedSourceDotdir,
558 )
559 })
560 .collect();
561
562 let count = reportable.len();
563 if !config.quiet
564 && should_emit_note_once(format!(
565 "dotdir::{}::{count}::{}",
566 config.root.display(),
567 reportable
568 .first()
569 .map_or_else(String::new, |dir| display_relative_path(&config.root, dir))
570 ))
571 {
572 tracing::warn!(
573 "{}",
574 build_skipped_dotdirs_note(&config.root, &reportable, truncated)
575 );
576 }
577 diagnostics
578}
579
580fn build_skipped_dotdirs_note(root: &Path, reportable: &[&PathBuf], truncated: bool) -> String {
590 let count = reportable.len();
591 let examples = if truncated {
595 summarize_paths_open_ended(root, reportable)
596 } else {
597 summarize_paths(root, reportable)
598 };
599 let noun = if count == 1 {
600 "directory"
601 } else {
602 "directories"
603 };
604 let verb = if count == 1 { "contains" } else { "contain" };
605 let at_least = if truncated { "at least " } else { "" };
606 let (target, pronoun) = match reportable {
607 [only] => (display_relative_path(root, only), "it"),
608 _ => ("<dir>".to_owned(), "one"),
609 };
610 format!(
611 "fallow: skipped {at_least}{count} hidden {noun} that {verb} source files ({examples}). \
612 Hidden directories are not traversed and no config field adds one: analyze {pronoun} \
613 with fallow --root {target} if it holds first-party source, or add '{target}/**' to \
614 ignorePatterns to silence this."
615 )
616}
617
618fn build_largest_files_note(root: &Path, files: &[DiscoveredFile]) -> Option<String> {
623 if files.is_empty() {
624 return None;
625 }
626 let largest = files.iter().map(|f| f.size_bytes).max().unwrap_or(0);
627 if files.len() <= LARGE_SET_THRESHOLD && largest < LARGE_FILE_NOTE_BYTES {
628 return None;
629 }
630 let count = files.len();
631 let noun = if count == 1 { "file" } else { "files" };
632 let mut by_size: Vec<SizedFile> = files
633 .iter()
634 .filter(|f| f.size_bytes >= NOTE_FILE_FLOOR_BYTES)
635 .map(|f| (f.path.clone(), f.size_bytes))
636 .collect();
637 by_size.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
638 if by_size.is_empty() {
639 return Some(format!(
642 "fallow: discovered {count} {noun}. If analysis stalls or runs out of memory, \
643 exclude large generated files via ignorePatterns or --max-file-size."
644 ));
645 }
646 let examples = summarize_examples(root, &by_size);
647 Some(format!(
648 "fallow: discovered {count} {noun}; largest: {examples}. If analysis stalls or runs out of memory, \
649 exclude large generated files via ignorePatterns or --max-file-size."
650 ))
651}
652
653fn note_largest_files(config: &ResolvedConfig, files: &[DiscoveredFile]) {
658 if config.quiet {
659 return;
660 }
661 if let Some(message) = build_largest_files_note(&config.root, files)
662 && should_emit_note_once(format!("note::{}::{}", config.root.display(), files.len()))
663 {
664 tracing::warn!("{message}");
665 }
666}
667
668#[derive(Debug, Clone, Copy, PartialEq, Eq)]
670pub enum HiddenDirMatch {
671 AnyDepth,
677 ExactPath,
684}
685
686#[derive(Debug, Clone, PartialEq, Eq)]
688pub struct HiddenDirScope {
689 root: PathBuf,
690 dirs: Vec<String>,
691 match_mode: HiddenDirMatch,
692}
693
694impl HiddenDirScope {
695 #[must_use]
702 pub fn new(root: PathBuf, dirs: Vec<String>) -> Self {
703 Self {
704 root,
705 dirs,
706 match_mode: HiddenDirMatch::AnyDepth,
707 }
708 }
709
710 #[must_use]
713 pub fn new_exact_paths(root: PathBuf, dirs: Vec<String>) -> Self {
714 Self {
715 root,
716 dirs,
717 match_mode: HiddenDirMatch::ExactPath,
718 }
719 }
720
721 #[must_use]
726 pub fn with_match_mode(root: PathBuf, dirs: Vec<String>, match_mode: HiddenDirMatch) -> Self {
727 Self {
728 root,
729 dirs,
730 match_mode,
731 }
732 }
733
734 #[must_use]
735 pub fn root(&self) -> &Path {
736 &self.root
737 }
738
739 #[must_use]
740 pub fn dirs(&self) -> &[String] {
741 &self.dirs
742 }
743
744 #[must_use]
745 pub fn match_mode(&self) -> HiddenDirMatch {
746 self.match_mode
747 }
748
749 fn allows(&self, path: &Path, name: &OsStr) -> bool {
750 match self.match_mode {
751 HiddenDirMatch::AnyDepth => {
752 path.starts_with(&self.root) && self.dirs.iter().any(|dir| OsStr::new(dir) == name)
753 }
754 HiddenDirMatch::ExactPath => {
755 let Ok(relative) = path.strip_prefix(&self.root) else {
758 return false;
759 };
760 self.dirs.iter().any(|dir| Path::new(dir) == relative)
761 }
762 }
763 }
764}
765
766struct FileVisitor<'a> {
773 root: &'a Path,
774 canonical_root: Option<&'a Path>,
775 ignore_patterns: &'a globset::GlobSet,
776 production_excludes: &'a Option<globset::GlobSet>,
777 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
778 config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
779 local: Vec<(std::path::PathBuf, u64)>,
780 config_local: Vec<std::path::PathBuf>,
781}
782
783impl ignore::ParallelVisitor for FileVisitor<'_> {
784 fn visit(&mut self, result: Result<ignore::DirEntry, ignore::Error>) -> ignore::WalkState {
785 let Ok(entry) = result else {
786 return ignore::WalkState::Continue;
787 };
788 if entry.file_type().is_some_and(|ft| ft.is_dir()) {
789 return ignore::WalkState::Continue;
790 }
791 let relative = entry
792 .path()
793 .strip_prefix(self.root)
794 .unwrap_or_else(|_| entry.path());
795 if self.ignore_patterns.is_match(relative) {
796 return ignore::WalkState::Continue;
797 }
798 if self
799 .production_excludes
800 .as_ref()
801 .is_some_and(|excludes| excludes.is_match(relative))
802 {
803 return ignore::WalkState::Continue;
804 }
805 let symlink_size = if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
806 let Some(size) = contained_symlink_file_size(entry.path(), self.canonical_root) else {
807 tracing::debug!(
808 path = %entry.path().display(),
809 "skipping source symlink with a broken, non-file, or outside-root target"
810 );
811 return ignore::WalkState::Continue;
812 };
813 Some(size)
814 } else {
815 None
816 };
817 if has_source_extension(entry.path()) {
818 let size_bytes =
819 symlink_size.unwrap_or_else(|| entry.metadata().map_or(0, |m| m.len()));
820 self.local.push((entry.into_path(), size_bytes));
821 } else if self.config_shared.is_some() {
822 self.config_local.push(entry.into_path());
825 }
826 ignore::WalkState::Continue
827 }
828}
829
830fn contained_symlink_file_size(path: &Path, canonical_root: Option<&Path>) -> Option<u64> {
831 let root = canonical_root?;
832 let target = path.canonicalize().ok()?;
833 if !target.starts_with(root) {
834 return None;
835 }
836 let metadata = target.metadata().ok()?;
837 metadata.is_file().then_some(metadata.len())
838}
839
840impl Drop for FileVisitor<'_> {
841 #[expect(
842 clippy::expect_used,
843 reason = "poisoned walk collector lock means worker state is unrecoverable"
844 )]
845 fn drop(&mut self) {
846 if !self.local.is_empty() {
847 self.shared
848 .lock()
849 .expect("walk collector lock poisoned")
850 .append(&mut self.local);
851 }
852 if let Some(config_shared) = self.config_shared
853 && !self.config_local.is_empty()
854 {
855 config_shared
856 .lock()
857 .expect("walk config collector lock poisoned")
858 .append(&mut self.config_local);
859 }
860 }
861}
862
863struct FileVisitorBuilder<'a> {
865 root: &'a Path,
866 canonical_root: Option<&'a Path>,
867 ignore_patterns: &'a globset::GlobSet,
868 production_excludes: &'a Option<globset::GlobSet>,
869 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
870 config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
871}
872
873impl<'s> ignore::ParallelVisitorBuilder<'s> for FileVisitorBuilder<'s> {
874 fn build(&mut self) -> Box<dyn ignore::ParallelVisitor + 's> {
875 Box::new(FileVisitor {
876 root: self.root,
877 canonical_root: self.canonical_root,
878 ignore_patterns: self.ignore_patterns,
879 production_excludes: self.production_excludes,
880 shared: self.shared,
881 config_shared: self.config_shared,
882 local: Vec::new(),
883 config_local: Vec::new(),
884 })
885 }
886}
887
888pub const SOURCE_EXTENSIONS: &[&str] = &[
890 "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
891 "mdx", "css", "scss", "sass", "less", "html", "graphql", "gql",
892];
893
894pub const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
896 "**/*.test.*",
897 "**/*.spec.*",
898 "**/*.e2e.*",
899 "**/*.e2e-spec.*",
900 "**/*.bench.*",
901 "**/*.fixture.*",
902 "**/*.stories.*",
903 "**/*.story.*",
904 "**/__tests__/**",
905 "**/__mocks__/**",
906 "**/__snapshots__/**",
907 "**/__fixtures__/**",
908 "**/test/**",
909 "**/tests/**",
910 "*.config.*",
911 "**/.*.js",
912 "**/.*.ts",
913 "**/.*.mjs",
914 "**/.*.cjs",
915];
916
917pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
919 ALLOWED_HIDDEN_DIRS.iter().any(|&d| OsStr::new(d) == name)
920}
921
922fn is_allowed_scoped_hidden_dir(
923 name: &OsStr,
924 path: &Path,
925 additional_hidden_dir_scopes: &[HiddenDirScope],
926) -> bool {
927 additional_hidden_dir_scopes
928 .iter()
929 .any(|scope| scope.allows(path, name))
930}
931
932const YARN_PNP_GENERATED_FILES: &[&str] = &[".pnp.cjs", ".pnp.loader.mjs"];
937
938fn is_yarn_pnp_generated_file(name: &OsStr) -> bool {
939 YARN_PNP_GENERATED_FILES
940 .iter()
941 .any(|&f| OsStr::new(f) == name)
942}
943
944fn is_allowed_hidden_with_scopes(
950 entry: &ignore::DirEntry,
951 additional_hidden_dir_scopes: &[HiddenDirScope],
952) -> bool {
953 let name = entry.file_name();
954 let name_str = name.to_string_lossy();
955
956 if !name_str.starts_with('.') {
957 return true;
958 }
959
960 if entry.file_type().is_some_and(|ft| !ft.is_dir()) {
961 return !is_yarn_pnp_generated_file(name);
962 }
963
964 is_allowed_hidden_dir(name)
965 || is_allowed_scoped_hidden_dir(name, entry.path(), additional_hidden_dir_scopes)
966}
967
968pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
974 discover_files_with_additional_hidden_dirs(config, &[])
975}
976
977fn config_candidate_basename_globs() -> &'static [String] {
989 static GLOBS: OnceLock<Vec<String>> = OnceLock::new();
990 GLOBS.get_or_init(|| {
991 let mut set: FxHashSet<String> = FxHashSet::default();
992 for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
993 for pattern in plugin.config_patterns() {
994 let basename = pattern.rsplit('/').next().unwrap_or(pattern);
995 set.insert(basename.to_string());
996 }
997 }
998 let mut globs: Vec<String> = set.into_iter().collect();
999 globs.sort_unstable();
1000 globs
1001 })
1002}
1003
1004fn has_source_extension(path: &Path) -> bool {
1007 path.extension()
1008 .and_then(OsStr::to_str)
1009 .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
1010}
1011
1012#[expect(
1016 clippy::expect_used,
1017 reason = "source file globs are hard-coded compile-time constants"
1018)]
1019fn build_walk_types(capture_config: bool) -> ignore::types::Types {
1020 static SOURCE_TYPES: OnceLock<ignore::types::Types> = OnceLock::new();
1021 static SOURCE_AND_CONFIG_TYPES: OnceLock<ignore::types::Types> = OnceLock::new();
1022
1023 let cache = if capture_config {
1024 &SOURCE_AND_CONFIG_TYPES
1025 } else {
1026 &SOURCE_TYPES
1027 };
1028 cache
1029 .get_or_init(|| {
1030 let mut types_builder = ignore::types::TypesBuilder::new();
1031 let source_glob = format!("*.{{{}}}", SOURCE_EXTENSIONS.join(","));
1032 types_builder
1033 .add("source", &source_glob)
1034 .expect("valid glob");
1035 types_builder.select("source");
1036 if capture_config {
1037 for glob in config_candidate_basename_globs() {
1038 let _ = types_builder.add("config", glob);
1042 }
1043 types_builder.select("config");
1044 }
1045 types_builder.build().expect("valid types")
1046 })
1047 .clone()
1048}
1049
1050fn build_source_walk_builder(
1054 config: &ResolvedConfig,
1055 additional_hidden_dir_scopes: &[HiddenDirScope],
1056 capture_config: bool,
1057 skipped_dotdirs: &SkippedDotdirSink,
1058) -> WalkBuilder {
1059 let mut walk_builder = WalkBuilder::new(&config.root);
1060 walk_builder
1061 .hidden(false)
1062 .git_ignore(true)
1063 .git_global(true)
1064 .git_exclude(true)
1065 .types(build_walk_types(capture_config))
1066 .threads(config.threads);
1067 let scopes = additional_hidden_dir_scopes.to_vec();
1072 let sink = Arc::clone(skipped_dotdirs);
1073 walk_builder.filter_entry(move |entry| {
1074 if is_allowed_hidden_with_scopes(entry, &scopes) {
1075 return true;
1076 }
1077 if entry.file_type().is_some_and(|ft| ft.is_dir())
1078 && let Ok(mut collected) = sink.lock()
1079 {
1080 collected.push(entry.path().to_path_buf());
1081 }
1082 false
1083 });
1084 walk_builder
1085}
1086
1087fn build_production_excludes(config: &ResolvedConfig) -> Option<globset::GlobSet> {
1089 if !config.production {
1090 return None;
1091 }
1092 let mut builder = globset::GlobSetBuilder::new();
1093 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
1094 if let Ok(glob) = globset::GlobBuilder::new(pattern)
1095 .literal_separator(true)
1096 .build()
1097 {
1098 builder.add(glob);
1099 }
1100 }
1101 builder.build().ok()
1102}
1103
1104pub fn discover_files_with_additional_hidden_dirs(
1110 config: &ResolvedConfig,
1111 additional_hidden_dir_scopes: &[HiddenDirScope],
1112) -> Vec<DiscoveredFile> {
1113 discover_files_and_config_candidates(config, additional_hidden_dir_scopes).0
1114}
1115
1116pub fn discover_files_and_config_candidates(
1132 config: &ResolvedConfig,
1133 additional_hidden_dir_scopes: &[HiddenDirScope],
1134) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
1135 let discovered =
1136 discover_files_config_candidates_and_diagnostics(config, additional_hidden_dir_scopes);
1137 (discovered.files, discovered.config_candidates)
1138}
1139
1140pub struct DiscoveredSources {
1149 pub files: Vec<DiscoveredFile>,
1151 pub config_candidates: Vec<PathBuf>,
1153 pub diagnostics: Vec<WorkspaceDiagnostic>,
1156}
1157
1158#[expect(
1166 clippy::cast_possible_truncation,
1167 reason = "file count is bounded by project size, well under u32::MAX"
1168)]
1169#[expect(clippy::expect_used, reason = "the collector lock must remain usable")]
1170pub fn discover_files_config_candidates_and_diagnostics(
1171 config: &ResolvedConfig,
1172 additional_hidden_dir_scopes: &[HiddenDirScope],
1173) -> DiscoveredSources {
1174 let _span = tracing::info_span!("discover_files").entered();
1175
1176 let capture_config = !config.production;
1177 let skipped_dotdirs: SkippedDotdirSink = Arc::new(Mutex::new(Vec::new()));
1178 let walk_builder = build_source_walk_builder(
1179 config,
1180 additional_hidden_dir_scopes,
1181 capture_config,
1182 &skipped_dotdirs,
1183 );
1184 let production_excludes = build_production_excludes(config);
1185 let canonical_root = config.root.canonicalize().ok();
1186
1187 let collected: Mutex<Vec<(std::path::PathBuf, u64)>> = Mutex::new(Vec::new());
1188 let config_collected: Mutex<Vec<std::path::PathBuf>> = Mutex::new(Vec::new());
1189 let mut visitor_builder = FileVisitorBuilder {
1190 root: &config.root,
1191 canonical_root: canonical_root.as_deref(),
1192 ignore_patterns: &config.ignore_patterns,
1193 production_excludes: &production_excludes,
1194 shared: &collected,
1195 config_shared: capture_config.then_some(&config_collected),
1196 };
1197 walk_builder.build_parallel().visit(&mut visitor_builder);
1198
1199 let mut raw = collected
1200 .into_inner()
1201 .expect("walk collector lock poisoned");
1202 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
1210
1211 let mut config_candidates = config_collected
1212 .into_inner()
1213 .expect("walk config collector lock poisoned");
1214 config_candidates.sort_unstable();
1215
1216 let mut dotdir_candidates = skipped_dotdirs
1220 .lock()
1221 .map_or_else(|_| Vec::new(), |mut guard| std::mem::take(&mut *guard));
1222 dotdir_candidates.sort_unstable();
1223 dotdir_candidates.dedup();
1224
1225 let (kept, skipped) = partition_by_size(raw, config.max_file_size_bytes);
1226 let (kept, skipped_minified) =
1227 partition_minified_generated_js(kept, config.max_file_size_bytes);
1228 let diagnostics = fallow_config::replace_source_discovery_diagnostics(
1233 &config.root,
1234 report_skipped_large_files(config, &skipped)
1235 .into_iter()
1236 .chain(report_skipped_minified_files(config, &skipped_minified))
1237 .chain(report_skipped_source_dotdirs(
1238 config,
1239 production_excludes.as_ref(),
1240 &dotdir_candidates,
1241 ))
1242 .collect(),
1243 );
1244
1245 let files: Vec<DiscoveredFile> = kept
1246 .into_iter()
1247 .enumerate()
1248 .map(|(idx, (path, size_bytes))| DiscoveredFile {
1249 id: FileId(idx as u32),
1250 path,
1251 size_bytes,
1252 })
1253 .collect();
1254
1255 note_largest_files(config, &files);
1256
1257 DiscoveredSources {
1258 files,
1259 config_candidates,
1260 diagnostics,
1261 }
1262}
1263
1264#[cfg(test)]
1265mod tests {
1266 use std::ffi::OsStr;
1267 use std::path::MAIN_SEPARATOR;
1268
1269 use super::*;
1270
1271 #[test]
1272 fn skipped_dotdirs_note_names_the_directory_when_there_is_one() {
1273 let root = Path::new("/repo");
1274 let only = PathBuf::from("/repo/.tooling");
1275 let note = build_skipped_dotdirs_note(root, &[&only], false);
1276 assert!(note.contains("skipped 1 hidden directory that contains source files"));
1277 assert!(note.contains("analyze it with fallow --root .tooling"));
1278 assert!(note.contains("add '.tooling/**' to"));
1279 assert!(
1280 !note.contains("<dir>"),
1281 "the single-directory remedy must be copy-pasteable: {note}"
1282 );
1283 }
1284
1285 #[test]
1286 fn skipped_dotdirs_note_pluralizes_and_keeps_the_placeholder() {
1287 let root = Path::new("/repo");
1288 let a = PathBuf::from("/repo/.a");
1289 let b = PathBuf::from("/repo/.b");
1290 let note = build_skipped_dotdirs_note(root, &[&a, &b], false);
1291 assert!(note.contains("skipped 2 hidden directories that contain source files"));
1292 assert!(note.contains("analyze one with fallow --root <dir>"));
1293 }
1294
1295 #[test]
1296 fn skipped_dotdirs_note_drops_the_tail_count_when_truncated() {
1297 let root = Path::new("/repo");
1298 let owned: Vec<PathBuf> = (0..8)
1299 .map(|i| PathBuf::from(format!("/repo/.d{i}")))
1300 .collect();
1301 let reportable: Vec<&PathBuf> = owned.iter().collect();
1302
1303 let bounded = build_skipped_dotdirs_note(root, &reportable, true);
1304 assert!(bounded.contains("skipped at least 8 hidden directories"));
1305 assert!(
1306 bounded.contains("and more") && !bounded.contains("and 3 more"),
1307 "an inexact total must not carry an exact remainder: {bounded}"
1308 );
1309
1310 let complete = build_skipped_dotdirs_note(root, &reportable, false);
1311 assert!(!complete.contains("at least"));
1312 assert!(complete.contains("and 3 more"));
1313 }
1314
1315 #[test]
1316 fn dotdir_noise_path_components_stay_sorted_and_lowercase() {
1317 let mut sorted = DOTDIR_NOISE_PATH_COMPONENTS.to_vec();
1318 sorted.sort_unstable();
1319 assert_eq!(sorted, DOTDIR_NOISE_PATH_COMPONENTS);
1320 for component in DOTDIR_NOISE_PATH_COMPONENTS {
1321 assert!(!component.starts_with('.'), "'{component}' is not hidden");
1322 assert_eq!(
1323 *component,
1324 component.to_lowercase(),
1325 "'{component}' is matched verbatim against a path component"
1326 );
1327 }
1328 }
1329
1330 #[test]
1331 fn script_scope_denylist_stays_disjoint_and_sorted() {
1332 let mut sorted = SCRIPT_SCOPE_DENYLIST.to_vec();
1333 sorted.sort_unstable();
1334 assert_eq!(
1335 sorted, SCRIPT_SCOPE_DENYLIST,
1336 "keep the list sorted so additions stay reviewable"
1337 );
1338 for dir in SCRIPT_SCOPE_DENYLIST {
1339 assert!(dir.starts_with('.'), "'{dir}' is not a hidden directory");
1340 assert!(
1341 !ALLOWED_HIDDEN_DIRS.contains(dir),
1342 "'{dir}' is traversed, so it can never be a skipped candidate"
1343 );
1344 }
1345 }
1346
1347 #[test]
1348 fn dotdir_module_extensions_are_a_subset_of_source_extensions() {
1349 for ext in DOTDIR_MODULE_EXTENSIONS {
1350 assert!(
1351 SOURCE_EXTENSIONS.contains(ext),
1352 "'{ext}' is not discovered as source, so it cannot be a trigger"
1353 );
1354 }
1355 for ext in ["css", "scss", "sass", "less", "html", "graphql", "gql"] {
1356 assert!(
1357 !DOTDIR_MODULE_EXTENSIONS.contains(&ext),
1358 "'{ext}' carries no imports or exports for the message to be about"
1359 );
1360 }
1361 }
1362
1363 fn assign_file_ids(mut raw: Vec<(std::path::PathBuf, u64)>) -> Vec<DiscoveredFile> {
1366 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
1367 raw.into_iter()
1368 .enumerate()
1369 .map(|(idx, (path, size_bytes))| DiscoveredFile {
1370 id: FileId(idx as u32),
1371 path,
1372 size_bytes,
1373 })
1374 .collect()
1375 }
1376
1377 #[test]
1383 fn file_id_assignment_is_deterministic_for_identical_file_set() {
1384 let paths = [
1385 "/project/src/z.ts",
1386 "/project/src/a.ts",
1387 "/project/src/components/Button.tsx",
1388 "/project/src/components/Button.module.css",
1389 "/project/index.ts",
1390 ];
1391
1392 let walk_one: Vec<(std::path::PathBuf, u64)> = paths
1394 .iter()
1395 .map(|p| (std::path::PathBuf::from(p), 10))
1396 .collect();
1397 let mut walk_two = walk_one.clone();
1398 walk_two.reverse();
1399
1400 let files_one = assign_file_ids(walk_one);
1401 let files_two = assign_file_ids(walk_two);
1402
1403 assert_eq!(files_one.len(), files_two.len());
1405 for (a, b) in files_one.iter().zip(files_two.iter()) {
1406 assert_eq!(a.id, b.id);
1407 assert_eq!(a.path, b.path);
1408 }
1409
1410 for (idx, file) in files_one.iter().enumerate() {
1413 assert_eq!(file.id, FileId(idx as u32));
1414 }
1415 assert_eq!(
1416 files_one[0].path,
1417 std::path::PathBuf::from("/project/index.ts")
1418 );
1419 }
1420
1421 #[test]
1422 fn file_id_assignment_recomputes_after_rename_or_delete() {
1423 let before = assign_file_ids(vec![
1424 (std::path::PathBuf::from("/project/src/a.ts"), 10),
1425 (std::path::PathBuf::from("/project/src/b.ts"), 10),
1426 (std::path::PathBuf::from("/project/src/c.ts"), 10),
1427 ]);
1428 let after_delete = assign_file_ids(vec![
1429 (std::path::PathBuf::from("/project/src/a.ts"), 10),
1430 (std::path::PathBuf::from("/project/src/c.ts"), 10),
1431 ]);
1432 let after_rename = assign_file_ids(vec![
1433 (std::path::PathBuf::from("/project/src/a.ts"), 10),
1434 (std::path::PathBuf::from("/project/src/c.ts"), 10),
1435 (std::path::PathBuf::from("/project/src/d.ts"), 10),
1436 ]);
1437
1438 assert_eq!(before[0].id, FileId(0));
1439 assert_eq!(before[1].id, FileId(1));
1440 assert_eq!(before[2].id, FileId(2));
1441 assert_eq!(after_delete[0].id, FileId(0));
1442 assert_eq!(after_delete[1].id, FileId(1));
1443 assert_eq!(
1444 after_delete[1].path,
1445 std::path::PathBuf::from("/project/src/c.ts")
1446 );
1447 assert_eq!(after_rename[0].id, FileId(0));
1448 assert_eq!(after_rename[1].id, FileId(1));
1449 assert_eq!(
1450 after_rename[1].path,
1451 std::path::PathBuf::from("/project/src/c.ts")
1452 );
1453 assert_eq!(after_rename[2].id, FileId(2));
1454 assert_eq!(
1455 after_rename[2].path,
1456 std::path::PathBuf::from("/project/src/d.ts")
1457 );
1458 }
1459
1460 #[test]
1461 fn allowed_hidden_dirs() {
1462 assert!(is_allowed_hidden_dir(OsStr::new(".storybook")));
1463 assert!(is_allowed_hidden_dir(OsStr::new(".vitepress")));
1464 assert!(is_allowed_hidden_dir(OsStr::new(".well-known")));
1465 assert!(is_allowed_hidden_dir(OsStr::new(".changeset")));
1466 assert!(is_allowed_hidden_dir(OsStr::new(".github")));
1467 }
1468
1469 #[test]
1470 fn disallowed_hidden_dirs() {
1471 assert!(!is_allowed_hidden_dir(OsStr::new(".git")));
1472 assert!(!is_allowed_hidden_dir(OsStr::new(".cache")));
1473 assert!(!is_allowed_hidden_dir(OsStr::new(".vscode")));
1474 assert!(!is_allowed_hidden_dir(OsStr::new(".fallow")));
1475 assert!(!is_allowed_hidden_dir(OsStr::new(".next")));
1476 }
1477
1478 #[test]
1479 fn non_hidden_dirs_not_in_allowlist() {
1480 assert!(!is_allowed_hidden_dir(OsStr::new("src")));
1481 assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
1482 }
1483
1484 #[test]
1485 fn walk_types_match_every_supported_source_extension() {
1486 for capture_config in [false, true] {
1487 let types = build_walk_types(capture_config);
1488 for extension in SOURCE_EXTENSIONS {
1489 let path = format!("packages/ui/src/nested/component.{extension}");
1490 assert!(
1491 types.matched(&path, false).is_whitelist(),
1492 "expected source match for {path} with capture_config={capture_config}"
1493 );
1494 }
1495 }
1496 }
1497
1498 #[test]
1499 fn walk_types_match_typescript_declaration_files() {
1500 let types = build_walk_types(true);
1501 for path in [
1502 "src/env.d.ts",
1503 "packages/app/types/generated.d.mts",
1504 "packages/app/types/compat.d.cts",
1505 ] {
1506 assert!(
1507 types.matched(path, false).is_whitelist(),
1508 "expected declaration source match for {path}"
1509 );
1510 }
1511 }
1512
1513 #[test]
1514 fn walk_types_reject_source_extension_near_misses() {
1515 for capture_config in [false, true] {
1516 let types = build_walk_types(capture_config);
1517 for path in [
1518 "src/component.tsx.bak",
1519 "src/component.tsxmap",
1520 "src/component.TS",
1521 "src/component.gqlx",
1522 "src/component.htm",
1523 "src/component",
1524 "assets/component.png",
1525 ] {
1526 assert!(
1527 types.matched(path, false).is_ignore(),
1528 "expected non-source rejection for {path} with capture_config={capture_config}"
1529 );
1530 }
1531 }
1532 }
1533
1534 #[test]
1535 fn walk_types_keep_config_candidate_selection_separate() {
1536 assert!(
1537 build_walk_types(true)
1538 .matched("packages/app/tsconfig.json", false)
1539 .is_whitelist()
1540 );
1541 assert!(
1542 build_walk_types(false)
1543 .matched("packages/app/tsconfig.json", false)
1544 .is_ignore()
1545 );
1546 }
1547
1548 #[test]
1549 fn source_extensions_include_typescript() {
1550 assert!(SOURCE_EXTENSIONS.contains(&"ts"));
1551 assert!(SOURCE_EXTENSIONS.contains(&"tsx"));
1552 assert!(SOURCE_EXTENSIONS.contains(&"mts"));
1553 assert!(SOURCE_EXTENSIONS.contains(&"cts"));
1554 assert!(SOURCE_EXTENSIONS.contains(&"gts"));
1555 }
1556
1557 #[test]
1558 fn source_extensions_include_javascript() {
1559 assert!(SOURCE_EXTENSIONS.contains(&"js"));
1560 assert!(SOURCE_EXTENSIONS.contains(&"jsx"));
1561 assert!(SOURCE_EXTENSIONS.contains(&"mjs"));
1562 assert!(SOURCE_EXTENSIONS.contains(&"cjs"));
1563 assert!(SOURCE_EXTENSIONS.contains(&"gjs"));
1564 }
1565
1566 #[test]
1567 fn source_extensions_include_sfc_formats() {
1568 assert!(SOURCE_EXTENSIONS.contains(&"vue"));
1569 assert!(SOURCE_EXTENSIONS.contains(&"svelte"));
1570 assert!(SOURCE_EXTENSIONS.contains(&"astro"));
1571 }
1572
1573 #[test]
1574 fn source_extensions_include_styles() {
1575 assert!(SOURCE_EXTENSIONS.contains(&"css"));
1576 assert!(SOURCE_EXTENSIONS.contains(&"scss"));
1577 assert!(SOURCE_EXTENSIONS.contains(&"sass"));
1578 assert!(SOURCE_EXTENSIONS.contains(&"less"));
1579 }
1580
1581 #[test]
1582 fn source_extensions_exclude_non_source() {
1583 assert!(!SOURCE_EXTENSIONS.contains(&"json"));
1584 assert!(!SOURCE_EXTENSIONS.contains(&"yaml"));
1585 assert!(!SOURCE_EXTENSIONS.contains(&"md"));
1586 assert!(!SOURCE_EXTENSIONS.contains(&"png"));
1587 assert!(!SOURCE_EXTENSIONS.contains(&"htm"));
1588 }
1589
1590 #[test]
1591 fn source_extensions_include_html() {
1592 assert!(SOURCE_EXTENSIONS.contains(&"html"));
1593 }
1594
1595 #[test]
1596 fn source_extensions_include_graphql_documents() {
1597 assert!(SOURCE_EXTENSIONS.contains(&"graphql"));
1598 assert!(SOURCE_EXTENSIONS.contains(&"gql"));
1599 }
1600
1601 fn build_production_glob_set() -> globset::GlobSet {
1602 let mut builder = globset::GlobSetBuilder::new();
1603 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
1604 builder.add(
1605 globset::GlobBuilder::new(pattern)
1606 .literal_separator(true)
1607 .build()
1608 .expect("valid glob pattern"),
1609 );
1610 }
1611 builder.build().expect("valid glob set")
1612 }
1613
1614 #[test]
1615 fn production_excludes_test_files() {
1616 let set = build_production_glob_set();
1617 assert!(set.is_match("src/Button.test.ts"));
1618 assert!(set.is_match("src/utils.spec.tsx"));
1619 assert!(set.is_match("src/__tests__/helper.ts"));
1620 assert!(!set.is_match("src/Button.ts"));
1621 assert!(!set.is_match("src/utils.tsx"));
1622 }
1623
1624 #[test]
1625 fn production_excludes_story_files() {
1626 let set = build_production_glob_set();
1627 assert!(set.is_match("src/Button.stories.tsx"));
1628 assert!(set.is_match("src/Card.story.ts"));
1629 assert!(!set.is_match("src/Button.tsx"));
1630 }
1631
1632 #[test]
1633 fn production_excludes_config_files_at_root_only() {
1634 let set = build_production_glob_set();
1635 assert!(set.is_match("vitest.config.ts"));
1636 assert!(set.is_match("jest.config.js"));
1637 assert!(!set.is_match("src/app/app.config.ts"));
1638 assert!(!set.is_match("src/app/app.config.server.ts"));
1639 assert!(!set.is_match("packages/foo/vitest.config.ts"));
1640 assert!(!set.is_match("src/config.ts"));
1641 }
1642
1643 #[test]
1644 fn production_patterns_are_valid_globs() {
1645 let _ = build_production_glob_set();
1646 }
1647
1648 #[test]
1649 fn disallowed_hidden_dirs_idea() {
1650 assert!(!is_allowed_hidden_dir(OsStr::new(".idea")));
1651 }
1652
1653 #[test]
1654 fn source_extensions_include_mdx() {
1655 assert!(SOURCE_EXTENSIONS.contains(&"mdx"));
1656 }
1657
1658 #[test]
1659 fn source_extensions_exclude_image_and_data_formats() {
1660 assert!(!SOURCE_EXTENSIONS.contains(&"png"));
1661 assert!(!SOURCE_EXTENSIONS.contains(&"jpg"));
1662 assert!(!SOURCE_EXTENSIONS.contains(&"svg"));
1663 assert!(!SOURCE_EXTENSIONS.contains(&"txt"));
1664 assert!(!SOURCE_EXTENSIONS.contains(&"csv"));
1665 assert!(!SOURCE_EXTENSIONS.contains(&"wasm"));
1666 }
1667
1668 #[test]
1669 fn is_declaration_file_matches_dts_variants() {
1670 assert!(is_declaration_file(Path::new("env.d.ts")));
1671 assert!(is_declaration_file(Path::new("src/auto-imports.d.ts")));
1672 assert!(is_declaration_file(Path::new("mod.d.mts")));
1673 assert!(is_declaration_file(Path::new("compat.d.cts")));
1674 assert!(!is_declaration_file(Path::new("index.ts")));
1675 assert!(!is_declaration_file(Path::new("component.tsx")));
1676 assert!(!is_declaration_file(Path::new("notes.d.txt")));
1677 }
1678
1679 #[test]
1680 fn format_size_mb_renders_one_decimal() {
1681 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
1682 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
1683 assert_eq!(format_size_mb(0), "0.0 MB");
1684 }
1685
1686 #[test]
1687 fn partition_by_size_no_limit_keeps_all() {
1688 let raw = vec![(PathBuf::from("a.ts"), 10), (PathBuf::from("b.ts"), 10_000)];
1689 let (kept, skipped) = partition_by_size(raw, None);
1690 assert_eq!(kept.len(), 2);
1691 assert!(skipped.is_empty());
1692 }
1693
1694 #[test]
1695 fn partition_by_size_skips_strictly_over_limit() {
1696 let raw = vec![
1697 (PathBuf::from("under.ts"), 99),
1698 (PathBuf::from("exact.ts"), 100),
1699 (PathBuf::from("over.ts"), 101),
1700 ];
1701 let (kept, skipped) = partition_by_size(raw, Some(100));
1702 let kept_has = |name: &str| kept.iter().any(|(p, _)| p.as_path() == Path::new(name));
1703 assert!(kept_has("under.ts"));
1704 assert!(
1705 kept_has("exact.ts"),
1706 "a file exactly at the limit is kept (skip is strictly-greater)"
1707 );
1708 assert_eq!(skipped.len(), 1);
1709 assert_eq!(skipped[0].0, PathBuf::from("over.ts"));
1710 }
1711
1712 #[test]
1713 fn partition_by_size_exempts_declaration_files() {
1714 let raw = vec![
1715 (PathBuf::from("huge.ts"), 10_000),
1716 (PathBuf::from("auto-imports.d.ts"), 10_000),
1717 ];
1718 let (kept, skipped) = partition_by_size(raw, Some(100));
1719 assert!(
1720 kept.iter()
1721 .any(|(p, _)| p.as_path() == Path::new("auto-imports.d.ts")),
1722 "declaration files are exempt from the size skip regardless of size"
1723 );
1724 assert_eq!(skipped.len(), 1);
1725 assert_eq!(skipped[0].0, PathBuf::from("huge.ts"));
1726 }
1727
1728 fn disco(path: &str, size_bytes: u64) -> DiscoveredFile {
1729 DiscoveredFile {
1730 id: FileId(0),
1731 path: PathBuf::from(path),
1732 size_bytes,
1733 }
1734 }
1735
1736 #[test]
1737 fn largest_files_note_below_threshold_is_none() {
1738 let files = [disco("a.ts", 100), disco("b.ts", 200)];
1739 assert!(build_largest_files_note(Path::new("/p"), &files).is_none());
1740 }
1741
1742 #[test]
1743 fn largest_files_note_single_file_uses_singular() {
1744 let files = [disco("big.ts", 5 * 1024 * 1024)];
1745 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
1746 assert!(
1747 note.contains("discovered 1 file;"),
1748 "singular noun on the single-big-file path (issue #1086 regression): {note}"
1749 );
1750 assert!(!note.contains("discovered 1 files"));
1751 assert!(note.contains("big.ts (5.0 MB)"));
1752 }
1753
1754 #[test]
1755 fn largest_files_note_filters_sub_floor_files() {
1756 let files = [disco("big.ts", 5 * 1024 * 1024), disco("tiny.ts", 10)];
1757 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
1758 assert!(note.contains("discovered 2 files;"));
1759 assert!(note.contains("big.ts (5.0 MB)"));
1760 assert!(
1761 !note.contains("tiny.ts"),
1762 "sub-floor files are not listed as `0.0 MB` chaff: {note}"
1763 );
1764 }
1765
1766 #[test]
1767 fn largest_files_note_large_set_no_big_file_omits_list() {
1768 let files: Vec<DiscoveredFile> = (0..=LARGE_SET_THRESHOLD)
1769 .map(|i| disco(&format!("f{i}.ts"), 100))
1770 .collect();
1771 let note = build_largest_files_note(Path::new("/p"), &files).expect("large set fires");
1772 assert!(note.contains(&format!("discovered {} files", LARGE_SET_THRESHOLD + 1)));
1773 assert!(
1774 !note.contains("largest:"),
1775 "no sub-floor `largest:` list when no file clears the floor: {note}"
1776 );
1777 }
1778
1779 mod discover_files_integration {
1780 use std::path::PathBuf;
1781
1782 use fallow_config::{
1783 DuplicatesConfig, FallowConfig, FlagsConfig, HealthConfig, OutputFormat, ResolveConfig,
1784 RulesConfig,
1785 };
1786
1787 use super::*;
1788
1789 fn make_config(root: PathBuf, production: bool) -> ResolvedConfig {
1791 FallowConfig {
1792 production: production.into(),
1793 ..Default::default()
1794 }
1795 .resolve(root, OutputFormat::Human, 1, true, true, None)
1796 }
1797
1798 fn file_names(files: &[DiscoveredFile], root: &std::path::Path) -> Vec<String> {
1801 files
1802 .iter()
1803 .map(|f| {
1804 f.path
1805 .strip_prefix(root)
1806 .unwrap_or(&f.path)
1807 .to_string_lossy()
1808 .replace('\\', "/")
1809 })
1810 .collect()
1811 }
1812
1813 #[cfg(unix)]
1814 fn symlink_file(target: &Path, link: &Path) {
1815 std::os::unix::fs::symlink(target, link).expect("create file symlink");
1816 }
1817
1818 #[cfg(windows)]
1819 fn symlink_file(target: &Path, link: &Path) {
1820 std::os::windows::fs::symlink_file(target, link).expect("create file symlink");
1821 }
1822
1823 #[cfg(unix)]
1824 fn symlink_dir(target: &Path, link: &Path) {
1825 std::os::unix::fs::symlink(target, link).expect("create directory symlink");
1826 }
1827
1828 #[cfg(windows)]
1829 fn symlink_dir(target: &Path, link: &Path) {
1830 std::os::windows::fs::symlink_dir(target, link).expect("create directory symlink");
1831 }
1832
1833 #[test]
1834 fn source_symlinks_must_target_regular_files_inside_root() {
1835 let dir = tempfile::tempdir().expect("create project");
1836 let outside = tempfile::tempdir().expect("create outside dir");
1837 let src = dir.path().join("src");
1838 std::fs::create_dir_all(&src).unwrap();
1839 std::fs::write(src.join("regular.ts"), "export const regular = 1;").unwrap();
1840 std::fs::write(src.join("inside-target.ts"), "export const inside = 1;").unwrap();
1841 std::fs::write(
1842 outside.path().join("outside-target.ts"),
1843 "export const outside = 1;",
1844 )
1845 .unwrap();
1846 std::fs::create_dir_all(src.join("directory-target")).unwrap();
1847
1848 symlink_file(&src.join("inside-target.ts"), &src.join("inside-link.ts"));
1849 symlink_file(
1850 &outside.path().join("outside-target.ts"),
1851 &src.join("outside-link.ts"),
1852 );
1853 symlink_file(&src.join("missing-target.ts"), &src.join("broken-link.ts"));
1854 symlink_dir(
1855 &src.join("directory-target"),
1856 &src.join("directory-link.ts"),
1857 );
1858
1859 let config = make_config(dir.path().to_path_buf(), false);
1860 let names = file_names(&discover_files(&config), dir.path());
1861
1862 assert!(names.contains(&"src/regular.ts".to_string()));
1863 assert!(names.contains(&"src/inside-target.ts".to_string()));
1864 assert!(names.contains(&"src/inside-link.ts".to_string()));
1865 assert!(!names.contains(&"src/outside-link.ts".to_string()));
1866 assert!(!names.contains(&"src/broken-link.ts".to_string()));
1867 assert!(!names.contains(&"src/directory-link.ts".to_string()));
1868 }
1869
1870 #[test]
1874 fn skips_yarn_pnp_generated_files() {
1875 let dir = tempfile::tempdir().expect("create temp dir");
1876 std::fs::write(dir.path().join(".pnp.cjs"), "module.exports = {};").unwrap();
1877 std::fs::write(dir.path().join(".pnp.loader.mjs"), "export {};").unwrap();
1878 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1879
1880 let config = make_config(dir.path().to_path_buf(), false);
1881 let names = file_names(&discover_files(&config), dir.path());
1882
1883 assert_eq!(names, vec!["index.ts".to_string()]);
1884 }
1885
1886 #[test]
1887 fn discovers_source_files_with_valid_extensions() {
1888 let dir = tempfile::tempdir().expect("create temp dir");
1889 let src = dir.path().join("src");
1890 std::fs::create_dir_all(&src).unwrap();
1891
1892 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1893 std::fs::write(src.join("component.tsx"), "export default () => {};").unwrap();
1894 std::fs::write(src.join("utils.js"), "module.exports = {};").unwrap();
1895 std::fs::write(src.join("helper.jsx"), "export const h = 1;").unwrap();
1896 std::fs::write(src.join("config.mjs"), "export default {};").unwrap();
1897 std::fs::write(src.join("legacy.cjs"), "module.exports = {};").unwrap();
1898 std::fs::write(src.join("types.mts"), "export type T = string;").unwrap();
1899 std::fs::write(src.join("compat.cts"), "module.exports = {};").unwrap();
1900
1901 let config = make_config(dir.path().to_path_buf(), false);
1902 let files = discover_files(&config);
1903 let names = file_names(&files, dir.path());
1904
1905 assert!(names.contains(&"src/app.ts".to_string()));
1906 assert!(names.contains(&"src/component.tsx".to_string()));
1907 assert!(names.contains(&"src/utils.js".to_string()));
1908 assert!(names.contains(&"src/helper.jsx".to_string()));
1909 assert!(names.contains(&"src/config.mjs".to_string()));
1910 assert!(names.contains(&"src/legacy.cjs".to_string()));
1911 assert!(names.contains(&"src/types.mts".to_string()));
1912 assert!(names.contains(&"src/compat.cts".to_string()));
1913 }
1914
1915 #[test]
1916 fn compact_source_glob_preserves_discovered_file_inventory() {
1917 let dir = tempfile::tempdir().expect("create temp dir");
1918 let nested = dir.path().join("packages/ui/src/nested");
1919 std::fs::create_dir_all(&nested).unwrap();
1920
1921 let mut expected = Vec::new();
1922 for (index, extension) in SOURCE_EXTENSIONS.iter().enumerate() {
1923 let relative = format!("packages/ui/src/nested/source-{index}.{extension}");
1924 std::fs::write(dir.path().join(&relative), "export const value = 1;").unwrap();
1925 expected.push(relative);
1926 }
1927 for relative in [
1928 "packages/ui/src/nested/env.d.ts",
1929 "packages/ui/src/nested/generated.d.mts",
1930 "packages/ui/src/nested/compat.d.cts",
1931 ] {
1932 std::fs::write(dir.path().join(relative), "export type Value = string;").unwrap();
1933 expected.push(relative.to_string());
1934 }
1935 let rejected = [
1936 "packages/ui/src/nested/component.tsx.bak",
1937 "packages/ui/src/nested/component.tsxmap",
1938 "packages/ui/src/nested/component.TS",
1939 "packages/ui/src/nested/component.gqlx",
1940 "packages/ui/src/nested/component.htm",
1941 "packages/ui/src/nested/component",
1942 "packages/ui/src/nested/component.png",
1943 ];
1944 for relative in rejected {
1945 std::fs::write(dir.path().join(relative), "not source").unwrap();
1946 }
1947
1948 let config = make_config(dir.path().to_path_buf(), false);
1949 let names = file_names(&discover_files(&config), dir.path());
1950
1951 for relative in expected {
1952 assert!(
1953 names.contains(&relative),
1954 "missing supported source {relative}"
1955 );
1956 }
1957 for relative in rejected {
1958 assert!(
1959 !names.iter().any(|name| name == relative),
1960 "unexpected near-miss source {relative}"
1961 );
1962 }
1963 }
1964
1965 #[test]
1966 fn excludes_non_source_extensions() {
1967 let dir = tempfile::tempdir().expect("create temp dir");
1968 let src = dir.path().join("src");
1969 std::fs::create_dir_all(&src).unwrap();
1970
1971 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1972
1973 std::fs::write(src.join("data.json"), "{}").unwrap();
1974 std::fs::write(src.join("readme.md"), "# Hello").unwrap();
1975 std::fs::write(src.join("notes.txt"), "notes").unwrap();
1976 std::fs::write(src.join("logo.png"), [0u8; 8]).unwrap();
1977
1978 let config = make_config(dir.path().to_path_buf(), false);
1979 let files = discover_files(&config);
1980 let names = file_names(&files, dir.path());
1981
1982 assert_eq!(names.len(), 1, "only the .ts file should be discovered");
1983 assert!(names.contains(&"src/app.ts".to_string()));
1984 }
1985
1986 #[test]
1987 fn excludes_disallowed_hidden_directories() {
1988 let dir = tempfile::tempdir().expect("create temp dir");
1989
1990 let git_dir = dir.path().join(".git");
1991 std::fs::create_dir_all(&git_dir).unwrap();
1992 std::fs::write(git_dir.join("hooks.ts"), "// git hook").unwrap();
1993
1994 let idea_dir = dir.path().join(".idea");
1995 std::fs::create_dir_all(&idea_dir).unwrap();
1996 std::fs::write(idea_dir.join("workspace.ts"), "// idea").unwrap();
1997
1998 let cache_dir = dir.path().join(".cache");
1999 std::fs::create_dir_all(&cache_dir).unwrap();
2000 std::fs::write(cache_dir.join("cached.js"), "// cached").unwrap();
2001
2002 let src = dir.path().join("src");
2003 std::fs::create_dir_all(&src).unwrap();
2004 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2005
2006 let config = make_config(dir.path().to_path_buf(), false);
2007 let files = discover_files(&config);
2008 let names = file_names(&files, dir.path());
2009
2010 assert_eq!(names.len(), 1, "only src/app.ts should be discovered");
2011 assert!(names.contains(&"src/app.ts".to_string()));
2012 }
2013
2014 #[test]
2015 fn includes_allowed_hidden_directories() {
2016 let dir = tempfile::tempdir().expect("create temp dir");
2017
2018 let storybook = dir.path().join(".storybook");
2019 std::fs::create_dir_all(&storybook).unwrap();
2020 std::fs::write(storybook.join("main.ts"), "export default {};").unwrap();
2021
2022 let github = dir.path().join(".github");
2023 std::fs::create_dir_all(&github).unwrap();
2024 std::fs::write(github.join("actions.js"), "module.exports = {};").unwrap();
2025
2026 let changeset = dir.path().join(".changeset");
2027 std::fs::create_dir_all(&changeset).unwrap();
2028 std::fs::write(changeset.join("config.js"), "module.exports = {};").unwrap();
2029
2030 let config = make_config(dir.path().to_path_buf(), false);
2031 let files = discover_files(&config);
2032 let names = file_names(&files, dir.path());
2033
2034 assert!(
2035 names.contains(&".storybook/main.ts".to_string()),
2036 "files in .storybook should be discovered"
2037 );
2038 assert!(
2039 names.contains(&".github/actions.js".to_string()),
2040 "files in .github should be discovered"
2041 );
2042 assert!(
2043 names.contains(&".changeset/config.js".to_string()),
2044 "files in .changeset should be discovered"
2045 );
2046 }
2047
2048 #[test]
2049 fn default_discovery_excludes_client_and_server_hidden_directories() {
2050 let dir = tempfile::tempdir().expect("create temp dir");
2051 let app = dir.path().join("app");
2052 std::fs::create_dir_all(app.join(".client")).unwrap();
2053 std::fs::create_dir_all(app.join(".server")).unwrap();
2054 std::fs::write(app.join(".client/analytics.ts"), "export const a = 1;").unwrap();
2055 std::fs::write(app.join(".server/db.ts"), "export const db = {};").unwrap();
2056 std::fs::write(app.join("root.tsx"), "export default function Root() {}").unwrap();
2057
2058 let config = make_config(dir.path().to_path_buf(), false);
2059 let files = discover_files(&config);
2060 let names = file_names(&files, dir.path());
2061
2062 assert!(names.contains(&"app/root.tsx".to_string()));
2063 assert!(!names.contains(&"app/.client/analytics.ts".to_string()));
2064 assert!(!names.contains(&"app/.server/db.ts".to_string()));
2065 }
2066
2067 #[test]
2068 fn scoped_hidden_dirs_include_client_and_server_under_package_root() {
2069 let dir = tempfile::tempdir().expect("create temp dir");
2070 let package = dir.path().join("packages/app");
2071 std::fs::create_dir_all(package.join("app/.client")).unwrap();
2072 std::fs::create_dir_all(package.join("app/.server")).unwrap();
2073 std::fs::write(
2074 package.join("app/.client/analytics.ts"),
2075 "export const track = () => {};",
2076 )
2077 .unwrap();
2078 std::fs::write(package.join("app/.server/db.ts"), "export const db = {};").unwrap();
2079
2080 let config = make_config(dir.path().to_path_buf(), false);
2081 let scopes = [HiddenDirScope::new(
2082 package,
2083 vec![".client".to_string(), ".server".to_string()],
2084 )];
2085 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2086 let names = file_names(&files, dir.path());
2087
2088 assert!(names.contains(&"packages/app/app/.client/analytics.ts".to_string()));
2089 assert!(names.contains(&"packages/app/app/.server/db.ts".to_string()));
2090 }
2091
2092 #[test]
2093 fn scoped_hidden_dirs_do_not_include_unscoped_packages() {
2094 let dir = tempfile::tempdir().expect("create temp dir");
2095 let active = dir.path().join("packages/active");
2096 let inactive = dir.path().join("packages/inactive");
2097 std::fs::create_dir_all(active.join("app/.server")).unwrap();
2098 std::fs::create_dir_all(inactive.join("app/.server")).unwrap();
2099 std::fs::write(active.join("app/.server/db.ts"), "export const db = {};").unwrap();
2100 std::fs::write(inactive.join("app/.server/db.ts"), "export const db = {};").unwrap();
2101
2102 let config = make_config(dir.path().to_path_buf(), false);
2103 let scopes = [HiddenDirScope::new(active, vec![".server".to_string()])];
2104 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2105 let names = file_names(&files, dir.path());
2106
2107 assert!(names.contains(&"packages/active/app/.server/db.ts".to_string()));
2108 assert!(!names.contains(&"packages/inactive/app/.server/db.ts".to_string()));
2109 }
2110
2111 #[test]
2112 fn exact_path_scope_does_not_admit_the_same_name_elsewhere() {
2113 let dir = tempfile::tempdir().expect("create temp dir");
2117 std::fs::create_dir_all(dir.path().join(".a/.b")).unwrap();
2118 std::fs::create_dir_all(dir.path().join("elsewhere/.b")).unwrap();
2119 std::fs::create_dir_all(dir.path().join("unrelated/.a")).unwrap();
2120 std::fs::write(dir.path().join(".a/.b/deep.mjs"), "export const a = 1;").unwrap();
2121 std::fs::write(dir.path().join("elsewhere/.b/y.mjs"), "export const b = 1;").unwrap();
2122 std::fs::write(dir.path().join("unrelated/.a/u.mjs"), "export const c = 1;").unwrap();
2123
2124 let config = make_config(dir.path().to_path_buf(), false);
2125 let scopes = [HiddenDirScope::new_exact_paths(
2126 dir.path().to_path_buf(),
2127 vec![".a".to_string(), format!(".a{MAIN_SEPARATOR}.b")],
2128 )];
2129 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2130 let names = file_names(&files, dir.path());
2131
2132 assert!(names.contains(&".a/.b/deep.mjs".to_string()));
2133 assert!(!names.contains(&"elsewhere/.b/y.mjs".to_string()));
2134 assert!(!names.contains(&"unrelated/.a/u.mjs".to_string()));
2135 }
2136
2137 #[test]
2138 fn exact_path_scope_admits_a_hidden_dir_under_a_visible_parent() {
2139 let dir = tempfile::tempdir().expect("create temp dir");
2140 std::fs::create_dir_all(dir.path().join("tools/.config")).unwrap();
2141 std::fs::create_dir_all(dir.path().join("other/.config")).unwrap();
2142 std::fs::write(
2143 dir.path().join("tools/.config/eslint.config.js"),
2144 "export default [];",
2145 )
2146 .unwrap();
2147 std::fs::write(
2148 dir.path().join("other/.config/eslint.config.js"),
2149 "export default [];",
2150 )
2151 .unwrap();
2152
2153 let config = make_config(dir.path().to_path_buf(), false);
2154 let scopes = [HiddenDirScope::new_exact_paths(
2155 dir.path().to_path_buf(),
2156 vec![format!("tools{MAIN_SEPARATOR}.config")],
2157 )];
2158 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2159 let names = file_names(&files, dir.path());
2160
2161 assert!(names.contains(&"tools/.config/eslint.config.js".to_string()));
2162 assert!(!names.contains(&"other/.config/eslint.config.js".to_string()));
2163 }
2164
2165 #[test]
2166 fn any_depth_scope_keeps_matching_by_name_for_plugins() {
2167 let dir = tempfile::tempdir().expect("create temp dir");
2171 std::fs::create_dir_all(dir.path().join("app/routes/deep/.server")).unwrap();
2172 std::fs::write(
2173 dir.path().join("app/routes/deep/.server/db.ts"),
2174 "export const db = {};",
2175 )
2176 .unwrap();
2177
2178 let config = make_config(dir.path().to_path_buf(), false);
2179 let scopes = [HiddenDirScope::new(
2180 dir.path().to_path_buf(),
2181 vec![".server".to_string()],
2182 )];
2183 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2184 let names = file_names(&files, dir.path());
2185
2186 assert!(names.contains(&"app/routes/deep/.server/db.ts".to_string()));
2187 }
2188
2189 #[test]
2190 fn excludes_root_build_directory() {
2191 let dir = tempfile::tempdir().expect("create temp dir");
2192
2193 std::fs::write(dir.path().join(".ignore"), "/build/\n").unwrap();
2194
2195 let build_dir = dir.path().join("build");
2196 std::fs::create_dir_all(&build_dir).unwrap();
2197 std::fs::write(build_dir.join("output.js"), "// build output").unwrap();
2198
2199 let src = dir.path().join("src");
2200 std::fs::create_dir_all(&src).unwrap();
2201 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2202
2203 let config = make_config(dir.path().to_path_buf(), false);
2204 let files = discover_files(&config);
2205 let names = file_names(&files, dir.path());
2206
2207 assert_eq!(names.len(), 1, "root build/ should be excluded via .ignore");
2208 assert!(names.contains(&"src/app.ts".to_string()));
2209 }
2210
2211 #[test]
2212 fn includes_nested_build_directory() {
2213 let dir = tempfile::tempdir().expect("create temp dir");
2214
2215 let nested_build = dir.path().join("src").join("build");
2216 std::fs::create_dir_all(&nested_build).unwrap();
2217 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
2218
2219 let config = make_config(dir.path().to_path_buf(), false);
2220 let files = discover_files(&config);
2221 let names = file_names(&files, dir.path());
2222
2223 assert!(
2224 names.contains(&"src/build/helper.ts".to_string()),
2225 "nested build/ directories should be included"
2226 );
2227 }
2228
2229 #[test]
2230 #[expect(
2231 clippy::cast_possible_truncation,
2232 reason = "test file counts are trivially small"
2233 )]
2234 fn file_ids_are_sequential_after_sorting() {
2235 let dir = tempfile::tempdir().expect("create temp dir");
2236 let src = dir.path().join("src");
2237 std::fs::create_dir_all(&src).unwrap();
2238
2239 std::fs::write(src.join("z_last.ts"), "export const z = 1;").unwrap();
2240 std::fs::write(src.join("a_first.ts"), "export const a = 1;").unwrap();
2241 std::fs::write(src.join("m_middle.ts"), "export const m = 1;").unwrap();
2242
2243 let config = make_config(dir.path().to_path_buf(), false);
2244 let files = discover_files(&config);
2245
2246 for (idx, file) in files.iter().enumerate() {
2247 assert_eq!(file.id, FileId(idx as u32), "FileId should be sequential");
2248 }
2249
2250 for pair in files.windows(2) {
2251 assert!(
2252 pair[0].path < pair[1].path,
2253 "files should be sorted by path"
2254 );
2255 }
2256 }
2257
2258 #[test]
2259 fn production_mode_excludes_test_files() {
2260 let dir = tempfile::tempdir().expect("create temp dir");
2261 let src = dir.path().join("src");
2262 std::fs::create_dir_all(&src).unwrap();
2263
2264 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2265 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
2266 std::fs::write(src.join("app.spec.ts"), "describe('a', () => {});").unwrap();
2267 std::fs::write(src.join("app.stories.tsx"), "export default {};").unwrap();
2268
2269 let config = make_config(dir.path().to_path_buf(), true);
2270 let files = discover_files(&config);
2271 let names = file_names(&files, dir.path());
2272
2273 assert!(
2274 names.contains(&"src/app.ts".to_string()),
2275 "source files should be included in production mode"
2276 );
2277 assert!(
2278 !names.contains(&"src/app.test.ts".to_string()),
2279 "test files should be excluded in production mode"
2280 );
2281 assert!(
2282 !names.contains(&"src/app.spec.ts".to_string()),
2283 "spec files should be excluded in production mode"
2284 );
2285 assert!(
2286 !names.contains(&"src/app.stories.tsx".to_string()),
2287 "story files should be excluded in production mode"
2288 );
2289 }
2290
2291 #[test]
2292 fn non_production_mode_includes_test_files() {
2293 let dir = tempfile::tempdir().expect("create temp dir");
2294 let src = dir.path().join("src");
2295 std::fs::create_dir_all(&src).unwrap();
2296
2297 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2298 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
2299
2300 let config = make_config(dir.path().to_path_buf(), false);
2301 let files = discover_files(&config);
2302 let names = file_names(&files, dir.path());
2303
2304 assert!(names.contains(&"src/app.ts".to_string()));
2305 assert!(
2306 names.contains(&"src/app.test.ts".to_string()),
2307 "test files should be included in non-production mode"
2308 );
2309 }
2310
2311 #[test]
2312 fn empty_directory_returns_no_files() {
2313 let dir = tempfile::tempdir().expect("create temp dir");
2314 let config = make_config(dir.path().to_path_buf(), false);
2315 let files = discover_files(&config);
2316 assert!(files.is_empty(), "empty project should discover no files");
2317 }
2318
2319 #[test]
2320 fn hidden_files_not_discovered_as_source() {
2321 let dir = tempfile::tempdir().expect("create temp dir");
2322
2323 std::fs::write(dir.path().join(".env"), "SECRET=abc").unwrap();
2324 std::fs::write(dir.path().join(".gitignore"), "node_modules").unwrap();
2325 std::fs::write(dir.path().join(".eslintrc.js"), "module.exports = {};").unwrap();
2326
2327 let src = dir.path().join("src");
2328 std::fs::create_dir_all(&src).unwrap();
2329 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2330
2331 let config = make_config(dir.path().to_path_buf(), false);
2332 let files = discover_files(&config);
2333 let names = file_names(&files, dir.path());
2334
2335 assert!(
2336 !names.contains(&".env".to_string()),
2337 ".env should not be discovered"
2338 );
2339 assert!(
2340 !names.contains(&".gitignore".to_string()),
2341 ".gitignore should not be discovered"
2342 );
2343 }
2344
2345 fn make_config_with_ignores(root: PathBuf, ignores: Vec<String>) -> ResolvedConfig {
2347 FallowConfig {
2348 type_aware: fallow_config::TypeAwareConfig::default(),
2349 schema: None,
2350 extends: vec![],
2351 entry: vec![],
2352 ignore_patterns: ignores,
2353 ignore_findings: vec![],
2354 framework: vec![],
2355 workspaces: None,
2356 ignore_dependencies: vec![],
2357 ignore_unresolved_imports: vec![],
2358 ignore_exports: vec![],
2359 ignore_catalog_references: vec![],
2360 ignore_dependency_overrides: vec![],
2361 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(
2362 ),
2363 used_class_members: vec![],
2364 ignore_decorators: vec![],
2365 unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
2366 duplicates: DuplicatesConfig::default(),
2367 similar_code: fallow_config::SimilarCodeConfig::default(),
2368 health: HealthConfig::default(),
2369 rules: RulesConfig::default(),
2370 boundaries: fallow_config::BoundaryConfig::default(),
2371 production: false.into(),
2372 plugins: vec![],
2373 rule_packs: vec![],
2374 dynamically_loaded: vec![],
2375 overrides: vec![],
2376 regression: None,
2377 audit: fallow_config::AuditConfig::default(),
2378 codeowners: None,
2379 public_packages: vec![],
2380 flags: FlagsConfig::default(),
2381 security: fallow_config::SecurityConfig::default(),
2382 fix: fallow_config::FixConfig::default(),
2383 resolve: ResolveConfig::default(),
2384 sealed: false,
2385 include_entry_exports: false,
2386 auto_imports: false,
2387 cache: fallow_config::CacheConfig::default(),
2388 }
2389 .resolve(root, OutputFormat::Human, 1, true, true, None)
2390 }
2391
2392 #[test]
2393 fn custom_ignore_patterns_exclude_matching_files() {
2394 let dir = tempfile::tempdir().expect("create temp dir");
2395
2396 let generated = dir.path().join("src").join("api").join("generated");
2397 std::fs::create_dir_all(&generated).unwrap();
2398 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
2399
2400 let client = dir.path().join("src").join("api").join("client");
2401 std::fs::create_dir_all(&client).unwrap();
2402 std::fs::write(client.join("fetch.ts"), "export const fetch = {};").unwrap();
2403
2404 let src = dir.path().join("src");
2405 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2406
2407 let config = make_config_with_ignores(
2408 dir.path().to_path_buf(),
2409 vec![
2410 "src/api/generated/**".to_string(),
2411 "src/api/client/**".to_string(),
2412 ],
2413 );
2414 let files = discover_files(&config);
2415 let names = file_names(&files, dir.path());
2416
2417 assert_eq!(names.len(), 1, "only non-ignored files: {names:?}");
2418 assert!(names.contains(&"src/index.ts".to_string()));
2419 }
2420
2421 #[test]
2422 fn leading_dot_ignore_patterns_exclude_matching_files() {
2423 let dir = tempfile::tempdir().expect("create temp dir");
2424
2425 let generated = dir.path().join("src").join("generated");
2426 std::fs::create_dir_all(&generated).unwrap();
2427 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
2428
2429 let src = dir.path().join("src");
2430 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2431
2432 let config = make_config_with_ignores(
2433 dir.path().to_path_buf(),
2434 vec!["./src/generated/**".to_string()],
2435 );
2436 let files = discover_files(&config);
2437 let names = file_names(&files, dir.path());
2438
2439 assert_eq!(names, vec!["src/index.ts"]);
2440 }
2441
2442 #[test]
2443 fn default_ignore_patterns_exclude_node_modules_and_dist() {
2444 let dir = tempfile::tempdir().expect("create temp dir");
2445
2446 let nm = dir.path().join("node_modules").join("lodash");
2447 std::fs::create_dir_all(&nm).unwrap();
2448 std::fs::write(nm.join("lodash.js"), "module.exports = {};").unwrap();
2449
2450 let dist = dir.path().join("dist");
2451 std::fs::create_dir_all(&dist).unwrap();
2452 std::fs::write(dist.join("bundle.js"), "// bundled").unwrap();
2453
2454 let src = dir.path().join("src");
2455 std::fs::create_dir_all(&src).unwrap();
2456 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2457
2458 let config = make_config(dir.path().to_path_buf(), false);
2459 let files = discover_files(&config);
2460 let names = file_names(&files, dir.path());
2461
2462 assert_eq!(names.len(), 1);
2463 assert!(names.contains(&"src/index.ts".to_string()));
2464 }
2465
2466 #[test]
2467 fn default_ignore_patterns_exclude_root_build() {
2468 let dir = tempfile::tempdir().expect("create temp dir");
2469
2470 let build = dir.path().join("build");
2471 std::fs::create_dir_all(&build).unwrap();
2472 std::fs::write(build.join("output.js"), "// built").unwrap();
2473
2474 let nested_build = dir.path().join("src").join("build");
2475 std::fs::create_dir_all(&nested_build).unwrap();
2476 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
2477
2478 let src = dir.path().join("src");
2479 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2480
2481 let config = make_config(dir.path().to_path_buf(), false);
2482 let files = discover_files(&config);
2483 let names = file_names(&files, dir.path());
2484
2485 assert_eq!(
2486 names.len(),
2487 2,
2488 "root build/ excluded, nested kept: {names:?}"
2489 );
2490 assert!(names.contains(&"src/index.ts".to_string()));
2491 assert!(names.contains(&"src/build/helper.ts".to_string()));
2492 }
2493
2494 fn make_config_with_max_file_size(
2496 root: PathBuf,
2497 max_file_size_bytes: Option<u64>,
2498 ) -> ResolvedConfig {
2499 let mut config = make_config(root, false);
2500 config.max_file_size_bytes = max_file_size_bytes;
2501 config
2502 }
2503
2504 #[test]
2505 fn skips_files_over_max_file_size() {
2506 let dir = tempfile::tempdir().expect("create temp dir");
2507 let src = dir.path().join("src");
2508 std::fs::create_dir_all(&src).unwrap();
2509 std::fs::write(src.join("small.ts"), "export const a = 1;").unwrap();
2510 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2511
2512 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
2513 let files = discover_files(&config);
2514 let names = file_names(&files, dir.path());
2515
2516 assert!(names.contains(&"src/small.ts".to_string()));
2517 assert!(
2518 !names.contains(&"src/huge.ts".to_string()),
2519 "a file over the size limit must not be discovered"
2520 );
2521 }
2522
2523 #[test]
2524 fn declaration_files_exempt_from_size_skip() {
2525 let dir = tempfile::tempdir().expect("create temp dir");
2526 let src = dir.path().join("src");
2527 std::fs::create_dir_all(&src).unwrap();
2528 std::fs::write(src.join("auto-imports.d.ts"), "x".repeat(5_000)).unwrap();
2529 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2530
2531 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
2532 let files = discover_files(&config);
2533 let names = file_names(&files, dir.path());
2534
2535 assert!(
2536 names.contains(&"src/auto-imports.d.ts".to_string()),
2537 "a large .d.ts is exempt from the skip (reachability root for global types)"
2538 );
2539 assert!(!names.contains(&"src/huge.ts".to_string()));
2540 }
2541
2542 #[test]
2543 fn unlimited_size_keeps_large_files() {
2544 let dir = tempfile::tempdir().expect("create temp dir");
2545 let src = dir.path().join("src");
2546 std::fs::create_dir_all(&src).unwrap();
2547 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2548
2549 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
2550 let files = discover_files(&config);
2551 let names = file_names(&files, dir.path());
2552
2553 assert!(
2554 names.contains(&"src/huge.ts".to_string()),
2555 "no limit keeps every file"
2556 );
2557 }
2558
2559 #[test]
2560 fn skipped_file_recorded_in_workspace_diagnostics() {
2561 let dir = tempfile::tempdir().expect("create temp dir");
2562 let src = dir.path().join("src");
2563 std::fs::create_dir_all(&src).unwrap();
2564 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2565
2566 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
2567 let _ = discover_files(&config);
2568
2569 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
2570 let skipped: Vec<_> = diagnostics
2571 .iter()
2572 .filter(|d| {
2573 matches!(
2574 d.kind,
2575 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { .. }
2576 )
2577 })
2578 .collect();
2579 assert_eq!(
2580 skipped.len(),
2581 1,
2582 "the skipped file is recorded in workspace diagnostics for JSON output"
2583 );
2584 assert!(skipped[0].path.ends_with("src/huge.ts"));
2585 assert!(
2586 matches!(
2587 skipped[0].kind,
2588 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes }
2589 if size_bytes == 5_000
2590 ),
2591 "the recorded diagnostic carries the on-disk byte size"
2592 );
2593 }
2594
2595 fn dotdir_diagnostics(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
2597 fallow_config::workspace_diagnostics_for(root)
2598 .into_iter()
2599 .filter(|d| {
2600 matches!(
2601 d.kind,
2602 fallow_config::WorkspaceDiagnosticKind::SkippedSourceDotdir
2603 )
2604 })
2605 .collect()
2606 }
2607
2608 fn write_at(root: &Path, relative: &str, contents: &str) {
2609 let path = root.join(relative);
2610 std::fs::create_dir_all(path.parent().expect("has a parent")).unwrap();
2611 std::fs::write(path, contents).unwrap();
2612 }
2613
2614 #[test]
2615 fn skipped_source_dotdir_recorded_in_workspace_diagnostics() {
2616 let dir = tempfile::tempdir().expect("create temp dir");
2617 write_at(
2618 dir.path(),
2619 ".claude/hooks/probe.mjs",
2620 "export const a = 1;\n",
2621 );
2622 write_at(dir.path(), "src/app.ts", "export const b = 2;\n");
2623
2624 let config = make_config(dir.path().to_path_buf(), false);
2625 let files = discover_files(&config);
2626 let names = file_names(&files, dir.path());
2627
2628 let reported = dotdir_diagnostics(dir.path());
2629 assert_eq!(reported.len(), 1, "one skipped dotdir holds source files");
2630 assert!(reported[0].path.ends_with(".claude"));
2631 assert_eq!(reported[0].kind.id(), "skipped-source-dotdir");
2632 assert!(
2633 reported[0].message.contains("--root"),
2634 "message names the real remedy: {}",
2635 reported[0].message
2636 );
2637 assert!(
2638 names.contains(&"src/app.ts".to_string()),
2639 "traversal is unchanged for ordinary directories"
2640 );
2641 assert!(
2642 !names.contains(&".claude/hooks/probe.mjs".to_string()),
2643 "the diagnostic reports the skip, it does not change traversal"
2644 );
2645 }
2646
2647 #[test]
2648 fn allowlisted_dotdir_is_not_reported() {
2649 let dir = tempfile::tempdir().expect("create temp dir");
2650 write_at(dir.path(), ".storybook/main.ts", "export const a = 1;\n");
2651
2652 let config = make_config(dir.path().to_path_buf(), false);
2653 let files = discover_files(&config);
2654 let names = file_names(&files, dir.path());
2655
2656 assert!(dotdir_diagnostics(dir.path()).is_empty());
2657 assert!(
2658 names.contains(&".storybook/main.ts".to_string()),
2659 "an allowlisted dotdir is still traversed"
2660 );
2661 }
2662
2663 #[test]
2664 fn denylisted_dotdir_is_not_reported() {
2665 let dir = tempfile::tempdir().expect("create temp dir");
2666 write_at(dir.path(), ".idea/workspace.ts", "export const a = 1;\n");
2667 write_at(dir.path(), ".husky/hook.js", "export const b = 2;\n");
2668 write_at(dir.path(), ".next/page.js", "export const c = 3;\n");
2669 write_at(dir.path(), ".pnpm/x.js", "export const d = 4;\n");
2670
2671 let config = make_config(dir.path().to_path_buf(), false);
2672 let _ = discover_files(&config);
2673
2674 assert!(
2675 dotdir_diagnostics(dir.path()).is_empty(),
2676 "build caches, VCS and package-manager state never advise"
2677 );
2678 }
2679
2680 #[test]
2681 fn scoped_dotdir_is_traversed_and_not_reported() {
2682 let dir = tempfile::tempdir().expect("create temp dir");
2683 write_at(
2684 dir.path(),
2685 ".claude/hooks/probe.mjs",
2686 "export const a = 1;\n",
2687 );
2688
2689 let config = make_config(dir.path().to_path_buf(), false);
2690 let scopes = [HiddenDirScope::new(
2691 dir.path().to_path_buf(),
2692 vec![".claude".to_owned()],
2693 )];
2694 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2695 let names = file_names(&files, dir.path());
2696
2697 assert!(
2698 dotdir_diagnostics(dir.path()).is_empty(),
2699 "a plugin- or script-contributed scope is admitted, so nothing was skipped"
2700 );
2701 assert!(names.contains(&".claude/hooks/probe.mjs".to_string()));
2702 }
2703
2704 #[test]
2705 fn ignore_patterns_silence_the_skipped_source_dotdir() {
2706 let dir = tempfile::tempdir().expect("create temp dir");
2707 write_at(
2708 dir.path(),
2709 ".claude/hooks/probe.mjs",
2710 "export const a = 1;\n",
2711 );
2712
2713 let config =
2714 make_config_with_ignores(dir.path().to_path_buf(), vec![".claude/**".to_owned()]);
2715 let _ = discover_files(&config);
2716
2717 assert!(
2718 dotdir_diagnostics(dir.path()).is_empty(),
2719 "the documented silencing route works"
2720 );
2721 }
2722
2723 #[test]
2724 fn dotdir_without_source_files_is_not_reported() {
2725 let dir = tempfile::tempdir().expect("create temp dir");
2726 write_at(dir.path(), ".claude/settings.json", "{}\n");
2727 write_at(dir.path(), ".claude/README.md", "# notes\n");
2728
2729 let config = make_config(dir.path().to_path_buf(), false);
2730 let _ = discover_files(&config);
2731
2732 assert!(dotdir_diagnostics(dir.path()).is_empty());
2733 }
2734
2735 #[test]
2736 fn dotdir_source_at_scan_depth_limit_is_reported() {
2737 let dir = tempfile::tempdir().expect("create temp dir");
2738 write_at(dir.path(), ".claude/a/b/deep.ts", "export const a = 1;\n");
2739
2740 let config = make_config(dir.path().to_path_buf(), false);
2741 let _ = discover_files(&config);
2742
2743 assert_eq!(dotdir_diagnostics(dir.path()).len(), 1);
2744 }
2745
2746 #[test]
2747 fn dotdir_source_below_scan_depth_limit_is_not_reported() {
2748 let dir = tempfile::tempdir().expect("create temp dir");
2749 write_at(
2750 dir.path(),
2751 ".claude/a/b/c/deeper.ts",
2752 "export const a = 1;\n",
2753 );
2754
2755 let config = make_config(dir.path().to_path_buf(), false);
2756 let _ = discover_files(&config);
2757
2758 assert!(
2759 dotdir_diagnostics(dir.path()).is_empty(),
2760 "the depth cap is real, so widening it stays a deliberate act"
2761 );
2762 }
2763
2764 fn mark_as_git_repo(root: &Path) {
2768 std::fs::create_dir_all(root.join(".git")).expect("create .git marker");
2769 }
2770
2771 #[test]
2772 fn gitignored_dotdir_contents_are_not_reported() {
2773 for pattern in [".tooling/**", ".tooling/*", "**/.tooling/**", "*.ts"] {
2777 let dir = tempfile::tempdir().expect("create temp dir");
2778 mark_as_git_repo(dir.path());
2779 write_at(dir.path(), ".gitignore", &format!("{pattern}\n"));
2780 write_at(dir.path(), ".tooling/mod.ts", "export const a = 1;\n");
2781
2782 let config = make_config(dir.path().to_path_buf(), false);
2783 let _ = discover_files(&config);
2784
2785 assert!(
2786 dotdir_diagnostics(dir.path()).is_empty(),
2787 "gitignore pattern '{pattern}' excludes the contents, so neither \
2788 advertised remedy would find anything there"
2789 );
2790 }
2791 }
2792
2793 #[test]
2794 fn self_ignoring_dotdir_is_not_reported() {
2795 let dir = tempfile::tempdir().expect("create temp dir");
2796 mark_as_git_repo(dir.path());
2797 write_at(dir.path(), ".toolcache/.gitignore", "*\n");
2798 write_at(dir.path(), ".toolcache/mod.ts", "export const a = 1;\n");
2799
2800 let config = make_config(dir.path().to_path_buf(), false);
2801 let _ = discover_files(&config);
2802
2803 assert!(
2804 dotdir_diagnostics(dir.path()).is_empty(),
2805 "a cache directory that ignores itself has excluded its own contents"
2806 );
2807 }
2808
2809 #[test]
2810 fn ungitignored_dotdir_in_a_git_repo_is_still_reported() {
2811 let dir = tempfile::tempdir().expect("create temp dir");
2812 mark_as_git_repo(dir.path());
2813 write_at(dir.path(), ".gitignore", "dist/\n");
2814 write_at(dir.path(), ".tooling/mod.ts", "export const a = 1;\n");
2815
2816 let config = make_config(dir.path().to_path_buf(), false);
2817 let _ = discover_files(&config);
2818
2819 assert_eq!(
2820 dotdir_diagnostics(dir.path()).len(),
2821 1,
2822 "the gitignore check must not swallow the case the diagnostic exists for"
2823 );
2824 }
2825
2826 #[test]
2827 fn production_run_does_not_report_a_test_only_dotdir() {
2828 let dir = tempfile::tempdir().expect("create temp dir");
2829 write_at(dir.path(), ".qa/thing.test.ts", "export const a = 1;\n");
2830 write_at(dir.path(), ".qa/thing.stories.tsx", "export const b = 2;\n");
2831
2832 let config = make_config(dir.path().to_path_buf(), true);
2833 let _ = discover_files(&config);
2834
2835 assert!(
2836 dotdir_diagnostics(dir.path()).is_empty(),
2837 "a --production run would analyze none of those files, so the \
2838 --root remedy would return nothing"
2839 );
2840 }
2841
2842 #[test]
2843 fn production_run_still_reports_a_dotdir_with_production_source() {
2844 let dir = tempfile::tempdir().expect("create temp dir");
2845 write_at(dir.path(), ".qa/thing.test.ts", "export const a = 1;\n");
2846 write_at(dir.path(), ".qa/helper.ts", "export const b = 2;\n");
2847
2848 let config = make_config(dir.path().to_path_buf(), true);
2849 let _ = discover_files(&config);
2850
2851 assert_eq!(dotdir_diagnostics(dir.path()).len(), 1);
2852 }
2853
2854 #[test]
2855 fn dotdir_with_only_generated_markup_is_not_reported() {
2856 let dir = tempfile::tempdir().expect("create temp dir");
2857 write_at(dir.path(), ".lighthouseci/lhr-1.html", "<html></html>\n");
2858 write_at(dir.path(), ".styles/theme.css", ":root { color: red; }\n");
2859 write_at(dir.path(), ".gql/schema.graphql", "type Query { a: Int }\n");
2860
2861 let config = make_config(dir.path().to_path_buf(), false);
2862 let _ = discover_files(&config);
2863
2864 assert!(
2865 dotdir_diagnostics(dir.path()).is_empty(),
2866 "the message claims imports and exports are lost, and these have none"
2867 );
2868 }
2869
2870 #[test]
2871 fn generated_tool_and_foreign_vcs_dotdirs_are_not_reported() {
2872 let dir = tempfile::tempdir().expect("create temp dir");
2873 write_at(dir.path(), ".astro/types.d.ts", "export {};\n");
2874 write_at(dir.path(), ".wxt/types/imports.d.ts", "export {};\n");
2875 write_at(dir.path(), ".yalc/pkg/index.js", "export const a = 1;\n");
2876 write_at(dir.path(), ".jj/repo/config.js", "export const b = 2;\n");
2877 write_at(dir.path(), ".svn/pristine/y.js", "export const c = 3;\n");
2878
2879 let config = make_config(dir.path().to_path_buf(), false);
2880 let _ = discover_files(&config);
2881
2882 assert!(
2883 dotdir_diagnostics(dir.path()).is_empty(),
2884 "generated output and foreign VCS metadata are not first-party source"
2885 );
2886 }
2887
2888 #[test]
2889 fn denylisted_dotdirs_do_not_consume_the_candidate_ceiling() {
2890 let dir = tempfile::tempdir().expect("create temp dir");
2891 for index in 0..(DOTDIR_SCAN_MAX_CANDIDATES + 8) {
2894 write_at(
2895 dir.path(),
2896 &format!("packages/pkg{index:03}/.turbo/blob.js"),
2897 "export const a = 1;\n",
2898 );
2899 }
2900 write_at(dir.path(), "zz/.tooling/mod.ts", "export const b = 2;\n");
2901
2902 let config = make_config(dir.path().to_path_buf(), false);
2903 let _ = discover_files(&config);
2904
2905 let reported = dotdir_diagnostics(dir.path());
2906 assert_eq!(reported.len(), 1, "{reported:?}");
2907 assert!(reported[0].path.ends_with(".tooling"));
2908 }
2909
2910 #[test]
2911 fn one_pathological_dotdir_cannot_starve_the_rest() {
2912 let dir = tempfile::tempdir().expect("create temp dir");
2913 for index in 0..(DOTDIR_SCAN_MAX_ENTRIES * 2) {
2915 write_at(dir.path(), &format!(".aaa-noise/f{index}.bin"), "x");
2916 }
2917 write_at(dir.path(), ".zzz-real/mod.ts", "export const a = 1;\n");
2918
2919 let config = make_config(dir.path().to_path_buf(), false);
2920 let _ = discover_files(&config);
2921
2922 let reported = dotdir_diagnostics(dir.path());
2923 assert_eq!(reported.len(), 1, "{reported:?}");
2924 assert!(reported[0].path.ends_with(".zzz-real"));
2925 }
2926
2927 #[test]
2928 fn repeat_walks_do_not_stack_skipped_source_dotdirs() {
2929 let dir = tempfile::tempdir().expect("create temp dir");
2930 write_at(
2931 dir.path(),
2932 ".claude/hooks/probe.mjs",
2933 "export const a = 1;\n",
2934 );
2935
2936 let config = make_config(dir.path().to_path_buf(), false);
2937 let _ = discover_files(&config);
2938 let _ = discover_files(&config);
2939
2940 assert_eq!(
2941 dotdir_diagnostics(dir.path()).len(),
2942 1,
2943 "each walk replaces its own root's source-discovery set"
2944 );
2945 }
2946
2947 #[test]
2948 fn skips_large_one_line_js_as_minified_generated_output() {
2949 let dir = tempfile::tempdir().expect("create temp dir");
2950 let src = dir.path().join("src");
2951 std::fs::create_dir_all(&src).unwrap();
2952 let asset = src.join("index-abc123.js");
2953 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
2954
2955 let config = make_config(dir.path().to_path_buf(), false);
2956 let files = discover_files(&config);
2957 let names = file_names(&files, dir.path());
2958
2959 assert!(
2960 !names.contains(&"src/index-abc123.js".to_string()),
2961 "large one-line JS assets should be skipped before parsing"
2962 );
2963
2964 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
2965 assert!(
2966 diagnostics.iter().any(|diag| {
2967 diag.path.ends_with("src/index-abc123.js")
2968 && matches!(
2969 diag.kind,
2970 fallow_config::WorkspaceDiagnosticKind::SkippedMinifiedFile { .. }
2971 )
2972 }),
2973 "the skipped minified asset is recorded for JSON output: {diagnostics:?}"
2974 );
2975 }
2976
2977 #[test]
2978 fn unlimited_size_keeps_large_one_line_js() {
2979 let dir = tempfile::tempdir().expect("create temp dir");
2980 let src = dir.path().join("src");
2981 std::fs::create_dir_all(&src).unwrap();
2982 let asset = src.join("index-abc123.js");
2983 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
2984
2985 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
2986 let files = discover_files(&config);
2987 let names = file_names(&files, dir.path());
2988
2989 assert!(
2990 names.contains(&"src/index-abc123.js".to_string()),
2991 "--max-file-size 0 should opt out of generated JS skipping"
2992 );
2993 }
2994
2995 #[test]
2996 fn keeps_large_multiline_js() {
2997 let dir = tempfile::tempdir().expect("create temp dir");
2998 let src = dir.path().join("src");
2999 std::fs::create_dir_all(&src).unwrap();
3000 let asset = src.join("handwritten.js");
3001 let mut content = String::new();
3002 while content.len() <= MINIFIED_FILE_SKIP_BYTES as usize + 1 {
3003 content.push_str("export const value = 1;\n");
3004 }
3005 std::fs::write(&asset, content).unwrap();
3006
3007 let config = make_config(dir.path().to_path_buf(), false);
3008 let files = discover_files(&config);
3009 let names = file_names(&files, dir.path());
3010
3011 assert!(
3012 names.contains(&"src/handwritten.js".to_string()),
3013 "large multiline JS should not be treated as a generated minified asset"
3014 );
3015 }
3016 }
3017}