1use std::ffi::OsStr;
2use std::path::{Path, PathBuf};
3use std::sync::{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;
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
29const NOTE_EXAMPLE_CAP: usize = 5;
33
34const LARGE_SET_THRESHOLD: usize = 20_000;
38
39const LARGE_FILE_NOTE_BYTES: u64 = 4 * 1024 * 1024;
44
45const NOTE_FILE_FLOOR_BYTES: u64 = 256 * 1024;
49
50const MINIFIED_FILE_SKIP_BYTES: u64 = 1024 * 1024;
54
55const MINIFIED_SAMPLE_BYTES: usize = 256 * 1024;
57
58const MINIFIED_LONG_LINE_BYTES: usize = 128 * 1024;
61
62fn is_declaration_file(path: &Path) -> bool {
67 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
68 name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
69}
70
71fn is_plain_js_file(path: &Path) -> bool {
72 matches!(
73 path.extension().and_then(|ext| ext.to_str()),
74 Some("js" | "mjs" | "cjs")
75 )
76}
77
78fn has_minified_line_shape(path: &Path) -> bool {
79 use std::io::Read;
80
81 let Ok(mut file) = std::fs::File::open(path) else {
82 return false;
83 };
84 let mut sample = vec![0; MINIFIED_SAMPLE_BYTES];
85 let Ok(len) = file.read(&mut sample) else {
86 return false;
87 };
88 sample.truncate(len);
89 if sample.is_empty() {
90 return false;
91 }
92
93 let mut current_line = 0usize;
94 for byte in sample {
95 if byte == b'\n' || byte == b'\r' {
96 current_line = 0;
97 continue;
98 }
99 current_line += 1;
100 if current_line >= MINIFIED_LONG_LINE_BYTES {
101 return true;
102 }
103 }
104 false
105}
106
107fn is_probably_minified_generated_js(path: &Path, size_bytes: u64) -> bool {
108 size_bytes >= MINIFIED_FILE_SKIP_BYTES
109 && is_plain_js_file(path)
110 && !is_declaration_file(path)
111 && has_minified_line_shape(path)
112}
113
114fn format_size_mb(bytes: u64) -> String {
116 #[expect(
117 clippy::cast_precision_loss,
118 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
119 )]
120 let mb = bytes as f64 / (1024.0 * 1024.0);
121 format!("{mb:.1} MB")
122}
123
124fn summarize_examples(root: &Path, examples: &[SizedFile]) -> String {
127 let shown: Vec<String> = examples
128 .iter()
129 .take(NOTE_EXAMPLE_CAP)
130 .map(|(path, size)| {
131 let display = path
132 .strip_prefix(root)
133 .unwrap_or(path)
134 .display()
135 .to_string()
136 .replace('\\', "/");
137 format!("{display} ({})", format_size_mb(*size))
138 })
139 .collect();
140 let remaining = examples.len().saturating_sub(NOTE_EXAMPLE_CAP);
141 if remaining > 0 {
142 format!("{}, and {remaining} more", shown.join(", "))
143 } else {
144 shown.join(", ")
145 }
146}
147
148fn partition_by_size(
151 raw: Vec<SizedFile>,
152 max_file_size_bytes: Option<u64>,
153) -> (Vec<SizedFile>, Vec<SizedFile>) {
154 let Some(limit) = max_file_size_bytes else {
155 return (raw, Vec::new());
156 };
157 raw.into_iter()
158 .partition(|(path, size)| *size <= limit || is_declaration_file(path))
159}
160
161fn partition_minified_generated_js(
164 raw: Vec<SizedFile>,
165 max_file_size_bytes: Option<u64>,
166) -> (Vec<SizedFile>, Vec<SizedFile>) {
167 if max_file_size_bytes.is_none() {
168 return (raw, Vec::new());
169 }
170 raw.into_iter()
171 .partition(|(path, size)| !is_probably_minified_generated_js(path, *size))
172}
173
174fn report_skipped_large_files(config: &ResolvedConfig, skipped: &[SizedFile]) {
179 if skipped.is_empty() {
180 return;
181 }
182 let diagnostics: Vec<WorkspaceDiagnostic> = skipped
183 .iter()
184 .map(|(path, size_bytes)| {
185 WorkspaceDiagnostic::new(
186 &config.root,
187 path.clone(),
188 WorkspaceDiagnosticKind::SkippedLargeFile {
189 size_bytes: *size_bytes,
190 },
191 )
192 })
193 .collect();
194 fallow_config::append_workspace_diagnostics(&config.root, diagnostics);
195
196 let mut sorted: Vec<SizedFile> = skipped.to_vec();
197 sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
198 let count = skipped.len();
199 if !config.quiet
200 && should_emit_note_once(format!(
201 "skip::{}::{count}::{}",
202 config.root.display(),
203 sorted.first().map_or(0, |f| f.1)
204 ))
205 {
206 let examples = summarize_examples(&config.root, &sorted);
207 let noun = if count == 1 { "file" } else { "files" };
208 tracing::warn!(
209 "fallow: skipped {count} {noun} over the max file size limit ({examples}). \
210 Raise the limit with --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add them to ignorePatterns."
211 );
212 }
213}
214
215fn report_skipped_minified_files(config: &ResolvedConfig, skipped: &[SizedFile]) {
217 if skipped.is_empty() {
218 return;
219 }
220 let diagnostics: Vec<WorkspaceDiagnostic> = skipped
221 .iter()
222 .map(|(path, size_bytes)| {
223 WorkspaceDiagnostic::new(
224 &config.root,
225 path.clone(),
226 WorkspaceDiagnosticKind::SkippedMinifiedFile {
227 size_bytes: *size_bytes,
228 },
229 )
230 })
231 .collect();
232 fallow_config::append_workspace_diagnostics(&config.root, diagnostics);
233
234 let mut sorted: Vec<SizedFile> = skipped.to_vec();
235 sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
236 let count = skipped.len();
237 if !config.quiet
238 && should_emit_note_once(format!(
239 "minified::{}::{count}::{}",
240 config.root.display(),
241 sorted.first().map_or(0, |f| f.1)
242 ))
243 {
244 let examples = summarize_examples(&config.root, &sorted);
245 let noun = if count == 1 { "file" } else { "files" };
246 let pronoun = if count == 1 { "it" } else { "them" };
247 tracing::warn!(
248 "fallow: skipped {count} minified generated JS {noun} ({examples}). \
249 Add {pronoun} to ignorePatterns, rename {pronoun} with a .min.js suffix, or use --max-file-size 0 to analyze {pronoun}."
250 );
251 }
252}
253
254fn build_largest_files_note(root: &Path, files: &[DiscoveredFile]) -> Option<String> {
259 if files.is_empty() {
260 return None;
261 }
262 let largest = files.iter().map(|f| f.size_bytes).max().unwrap_or(0);
263 if files.len() <= LARGE_SET_THRESHOLD && largest < LARGE_FILE_NOTE_BYTES {
264 return None;
265 }
266 let count = files.len();
267 let noun = if count == 1 { "file" } else { "files" };
268 let mut by_size: Vec<SizedFile> = files
269 .iter()
270 .filter(|f| f.size_bytes >= NOTE_FILE_FLOOR_BYTES)
271 .map(|f| (f.path.clone(), f.size_bytes))
272 .collect();
273 by_size.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
274 if by_size.is_empty() {
275 return Some(format!(
278 "fallow: discovered {count} {noun}. If analysis stalls or runs out of memory, \
279 exclude large generated files via ignorePatterns or --max-file-size."
280 ));
281 }
282 let examples = summarize_examples(root, &by_size);
283 Some(format!(
284 "fallow: discovered {count} {noun}; largest: {examples}. If analysis stalls or runs out of memory, \
285 exclude large generated files via ignorePatterns or --max-file-size."
286 ))
287}
288
289fn note_largest_files(config: &ResolvedConfig, files: &[DiscoveredFile]) {
294 if config.quiet {
295 return;
296 }
297 if let Some(message) = build_largest_files_note(&config.root, files)
298 && should_emit_note_once(format!("note::{}::{}", config.root.display(), files.len()))
299 {
300 tracing::warn!("{message}");
301 }
302}
303
304#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct HiddenDirScope {
307 root: PathBuf,
308 dirs: Vec<String>,
309}
310
311impl HiddenDirScope {
312 pub fn new(root: PathBuf, dirs: Vec<String>) -> Self {
313 Self { root, dirs }
314 }
315
316 fn allows(&self, path: &Path, name: &OsStr) -> bool {
317 path.starts_with(&self.root) && self.dirs.iter().any(|dir| OsStr::new(dir) == name)
318 }
319}
320
321struct FileVisitor<'a> {
328 root: &'a Path,
329 ignore_patterns: &'a globset::GlobSet,
330 production_excludes: &'a Option<globset::GlobSet>,
331 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
332 config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
333 local: Vec<(std::path::PathBuf, u64)>,
334 config_local: Vec<std::path::PathBuf>,
335}
336
337impl ignore::ParallelVisitor for FileVisitor<'_> {
338 fn visit(&mut self, result: Result<ignore::DirEntry, ignore::Error>) -> ignore::WalkState {
339 let Ok(entry) = result else {
340 return ignore::WalkState::Continue;
341 };
342 if entry.file_type().is_some_and(|ft| ft.is_dir()) {
343 return ignore::WalkState::Continue;
344 }
345 let relative = entry
346 .path()
347 .strip_prefix(self.root)
348 .unwrap_or_else(|_| entry.path());
349 if self.ignore_patterns.is_match(relative) {
350 return ignore::WalkState::Continue;
351 }
352 if self
353 .production_excludes
354 .as_ref()
355 .is_some_and(|excludes| excludes.is_match(relative))
356 {
357 return ignore::WalkState::Continue;
358 }
359 if has_source_extension(entry.path()) {
360 let size_bytes = entry.metadata().map_or(0, |m| m.len());
361 self.local.push((entry.into_path(), size_bytes));
362 } else if self.config_shared.is_some() {
363 self.config_local.push(entry.into_path());
366 }
367 ignore::WalkState::Continue
368 }
369}
370
371impl Drop for FileVisitor<'_> {
372 #[expect(
373 clippy::expect_used,
374 reason = "poisoned walk collector lock means worker state is unrecoverable"
375 )]
376 fn drop(&mut self) {
377 if !self.local.is_empty() {
378 self.shared
379 .lock()
380 .expect("walk collector lock poisoned")
381 .append(&mut self.local);
382 }
383 if let Some(config_shared) = self.config_shared
384 && !self.config_local.is_empty()
385 {
386 config_shared
387 .lock()
388 .expect("walk config collector lock poisoned")
389 .append(&mut self.config_local);
390 }
391 }
392}
393
394struct FileVisitorBuilder<'a> {
396 root: &'a Path,
397 ignore_patterns: &'a globset::GlobSet,
398 production_excludes: &'a Option<globset::GlobSet>,
399 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
400 config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
401}
402
403impl<'s> ignore::ParallelVisitorBuilder<'s> for FileVisitorBuilder<'s> {
404 fn build(&mut self) -> Box<dyn ignore::ParallelVisitor + 's> {
405 Box::new(FileVisitor {
406 root: self.root,
407 ignore_patterns: self.ignore_patterns,
408 production_excludes: self.production_excludes,
409 shared: self.shared,
410 config_shared: self.config_shared,
411 local: Vec::new(),
412 config_local: Vec::new(),
413 })
414 }
415}
416
417pub const SOURCE_EXTENSIONS: &[&str] = &[
418 "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
419 "mdx", "css", "scss", "sass", "less", "html", "graphql", "gql",
420];
421
422pub const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
424 "**/*.test.*",
425 "**/*.spec.*",
426 "**/*.e2e.*",
427 "**/*.e2e-spec.*",
428 "**/*.bench.*",
429 "**/*.fixture.*",
430 "**/*.stories.*",
431 "**/*.story.*",
432 "**/__tests__/**",
433 "**/__mocks__/**",
434 "**/__snapshots__/**",
435 "**/__fixtures__/**",
436 "**/test/**",
437 "**/tests/**",
438 "*.config.*",
439 "**/.*.js",
440 "**/.*.ts",
441 "**/.*.mjs",
442 "**/.*.cjs",
443];
444
445pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
447 ALLOWED_HIDDEN_DIRS.iter().any(|&d| OsStr::new(d) == name)
448}
449
450fn is_allowed_scoped_hidden_dir(
451 name: &OsStr,
452 path: &Path,
453 additional_hidden_dir_scopes: &[HiddenDirScope],
454) -> bool {
455 additional_hidden_dir_scopes
456 .iter()
457 .any(|scope| scope.allows(path, name))
458}
459
460fn is_allowed_hidden(entry: &ignore::DirEntry) -> bool {
466 is_allowed_hidden_with_scopes(entry, &[])
467}
468
469fn is_allowed_hidden_with_scopes(
470 entry: &ignore::DirEntry,
471 additional_hidden_dir_scopes: &[HiddenDirScope],
472) -> bool {
473 let name = entry.file_name();
474 let name_str = name.to_string_lossy();
475
476 if !name_str.starts_with('.') {
477 return true;
478 }
479
480 if entry.file_type().is_some_and(|ft| !ft.is_dir()) {
481 return true;
482 }
483
484 is_allowed_hidden_dir(name)
485 || is_allowed_scoped_hidden_dir(name, entry.path(), additional_hidden_dir_scopes)
486}
487
488pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
494 discover_files_with_additional_hidden_dirs(config, &[])
495}
496
497fn config_candidate_basename_globs() -> &'static [String] {
509 static GLOBS: OnceLock<Vec<String>> = OnceLock::new();
510 GLOBS.get_or_init(|| {
511 let mut set: FxHashSet<String> = FxHashSet::default();
512 for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
513 for pattern in plugin.config_patterns() {
514 let basename = pattern.rsplit('/').next().unwrap_or(pattern);
515 set.insert(basename.to_string());
516 }
517 }
518 let mut globs: Vec<String> = set.into_iter().collect();
519 globs.sort_unstable();
520 globs
521 })
522}
523
524fn has_source_extension(path: &Path) -> bool {
527 path.extension()
528 .and_then(OsStr::to_str)
529 .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
530}
531
532#[expect(
536 clippy::expect_used,
537 reason = "source file globs are hard-coded compile-time constants"
538)]
539fn build_walk_types(capture_config: bool) -> ignore::types::Types {
540 let mut types_builder = ignore::types::TypesBuilder::new();
541 for ext in SOURCE_EXTENSIONS {
542 types_builder
543 .add("source", &format!("*.{ext}"))
544 .expect("valid glob");
545 }
546 types_builder.select("source");
547 if capture_config {
548 for glob in config_candidate_basename_globs() {
549 let _ = types_builder.add("config", glob);
553 }
554 types_builder.select("config");
555 }
556 types_builder.build().expect("valid types")
557}
558
559fn build_source_walk_builder(
563 config: &ResolvedConfig,
564 additional_hidden_dir_scopes: &[HiddenDirScope],
565 capture_config: bool,
566) -> WalkBuilder {
567 let mut walk_builder = WalkBuilder::new(&config.root);
568 walk_builder
569 .hidden(false)
570 .git_ignore(true)
571 .git_global(true)
572 .git_exclude(true)
573 .types(build_walk_types(capture_config))
574 .threads(config.threads);
575 if additional_hidden_dir_scopes.is_empty() {
576 walk_builder.filter_entry(is_allowed_hidden);
577 } else {
578 let scopes = additional_hidden_dir_scopes.to_vec();
579 walk_builder.filter_entry(move |entry| is_allowed_hidden_with_scopes(entry, &scopes));
580 }
581 walk_builder
582}
583
584fn build_production_excludes(config: &ResolvedConfig) -> Option<globset::GlobSet> {
586 if !config.production {
587 return None;
588 }
589 let mut builder = globset::GlobSetBuilder::new();
590 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
591 if let Ok(glob) = globset::GlobBuilder::new(pattern)
592 .literal_separator(true)
593 .build()
594 {
595 builder.add(glob);
596 }
597 }
598 builder.build().ok()
599}
600
601pub fn discover_files_with_additional_hidden_dirs(
607 config: &ResolvedConfig,
608 additional_hidden_dir_scopes: &[HiddenDirScope],
609) -> Vec<DiscoveredFile> {
610 discover_files_and_config_candidates(config, additional_hidden_dir_scopes).0
611}
612
613#[expect(
629 clippy::cast_possible_truncation,
630 reason = "file count is bounded by project size, well under u32::MAX"
631)]
632#[expect(clippy::expect_used, reason = "the collector lock must remain usable")]
633pub fn discover_files_and_config_candidates(
634 config: &ResolvedConfig,
635 additional_hidden_dir_scopes: &[HiddenDirScope],
636) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
637 let _span = tracing::info_span!("discover_files").entered();
638
639 let capture_config = !config.production;
640 let walk_builder =
641 build_source_walk_builder(config, additional_hidden_dir_scopes, capture_config);
642 let production_excludes = build_production_excludes(config);
643
644 let collected: Mutex<Vec<(std::path::PathBuf, u64)>> = Mutex::new(Vec::new());
645 let config_collected: Mutex<Vec<std::path::PathBuf>> = Mutex::new(Vec::new());
646 let mut visitor_builder = FileVisitorBuilder {
647 root: &config.root,
648 ignore_patterns: &config.ignore_patterns,
649 production_excludes: &production_excludes,
650 shared: &collected,
651 config_shared: capture_config.then_some(&config_collected),
652 };
653 walk_builder.build_parallel().visit(&mut visitor_builder);
654
655 let mut raw = collected
656 .into_inner()
657 .expect("walk collector lock poisoned");
658 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
666
667 let mut config_candidates = config_collected
668 .into_inner()
669 .expect("walk config collector lock poisoned");
670 config_candidates.sort_unstable();
671
672 fallow_config::clear_source_discovery_diagnostics(&config.root);
676 let (kept, skipped) = partition_by_size(raw, config.max_file_size_bytes);
677 report_skipped_large_files(config, &skipped);
678 let (kept, skipped_minified) =
679 partition_minified_generated_js(kept, config.max_file_size_bytes);
680 report_skipped_minified_files(config, &skipped_minified);
681
682 let files: Vec<DiscoveredFile> = kept
683 .into_iter()
684 .enumerate()
685 .map(|(idx, (path, size_bytes))| DiscoveredFile {
686 id: FileId(idx as u32),
687 path,
688 size_bytes,
689 })
690 .collect();
691
692 note_largest_files(config, &files);
693
694 (files, config_candidates)
695}
696
697#[cfg(test)]
698mod tests {
699 use std::ffi::OsStr;
700
701 use super::*;
702
703 fn assign_file_ids(mut raw: Vec<(std::path::PathBuf, u64)>) -> Vec<DiscoveredFile> {
706 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
707 raw.into_iter()
708 .enumerate()
709 .map(|(idx, (path, size_bytes))| DiscoveredFile {
710 id: FileId(idx as u32),
711 path,
712 size_bytes,
713 })
714 .collect()
715 }
716
717 #[test]
723 fn file_id_assignment_is_deterministic_for_identical_file_set() {
724 let paths = [
725 "/project/src/z.ts",
726 "/project/src/a.ts",
727 "/project/src/components/Button.tsx",
728 "/project/src/components/Button.module.css",
729 "/project/index.ts",
730 ];
731
732 let walk_one: Vec<(std::path::PathBuf, u64)> = paths
734 .iter()
735 .map(|p| (std::path::PathBuf::from(p), 10))
736 .collect();
737 let mut walk_two = walk_one.clone();
738 walk_two.reverse();
739
740 let files_one = assign_file_ids(walk_one);
741 let files_two = assign_file_ids(walk_two);
742
743 assert_eq!(files_one.len(), files_two.len());
745 for (a, b) in files_one.iter().zip(files_two.iter()) {
746 assert_eq!(a.id, b.id);
747 assert_eq!(a.path, b.path);
748 }
749
750 for (idx, file) in files_one.iter().enumerate() {
753 assert_eq!(file.id, FileId(idx as u32));
754 }
755 assert_eq!(
756 files_one[0].path,
757 std::path::PathBuf::from("/project/index.ts")
758 );
759 }
760
761 #[test]
762 fn allowed_hidden_dirs() {
763 assert!(is_allowed_hidden_dir(OsStr::new(".storybook")));
764 assert!(is_allowed_hidden_dir(OsStr::new(".vitepress")));
765 assert!(is_allowed_hidden_dir(OsStr::new(".well-known")));
766 assert!(is_allowed_hidden_dir(OsStr::new(".changeset")));
767 assert!(is_allowed_hidden_dir(OsStr::new(".github")));
768 }
769
770 #[test]
771 fn disallowed_hidden_dirs() {
772 assert!(!is_allowed_hidden_dir(OsStr::new(".git")));
773 assert!(!is_allowed_hidden_dir(OsStr::new(".cache")));
774 assert!(!is_allowed_hidden_dir(OsStr::new(".vscode")));
775 assert!(!is_allowed_hidden_dir(OsStr::new(".fallow")));
776 assert!(!is_allowed_hidden_dir(OsStr::new(".next")));
777 }
778
779 #[test]
780 fn non_hidden_dirs_not_in_allowlist() {
781 assert!(!is_allowed_hidden_dir(OsStr::new("src")));
782 assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
783 }
784
785 #[test]
786 fn source_extensions_include_typescript() {
787 assert!(SOURCE_EXTENSIONS.contains(&"ts"));
788 assert!(SOURCE_EXTENSIONS.contains(&"tsx"));
789 assert!(SOURCE_EXTENSIONS.contains(&"mts"));
790 assert!(SOURCE_EXTENSIONS.contains(&"cts"));
791 assert!(SOURCE_EXTENSIONS.contains(&"gts"));
792 }
793
794 #[test]
795 fn source_extensions_include_javascript() {
796 assert!(SOURCE_EXTENSIONS.contains(&"js"));
797 assert!(SOURCE_EXTENSIONS.contains(&"jsx"));
798 assert!(SOURCE_EXTENSIONS.contains(&"mjs"));
799 assert!(SOURCE_EXTENSIONS.contains(&"cjs"));
800 assert!(SOURCE_EXTENSIONS.contains(&"gjs"));
801 }
802
803 #[test]
804 fn source_extensions_include_sfc_formats() {
805 assert!(SOURCE_EXTENSIONS.contains(&"vue"));
806 assert!(SOURCE_EXTENSIONS.contains(&"svelte"));
807 assert!(SOURCE_EXTENSIONS.contains(&"astro"));
808 }
809
810 #[test]
811 fn source_extensions_include_styles() {
812 assert!(SOURCE_EXTENSIONS.contains(&"css"));
813 assert!(SOURCE_EXTENSIONS.contains(&"scss"));
814 assert!(SOURCE_EXTENSIONS.contains(&"sass"));
815 assert!(SOURCE_EXTENSIONS.contains(&"less"));
816 }
817
818 #[test]
819 fn source_extensions_exclude_non_source() {
820 assert!(!SOURCE_EXTENSIONS.contains(&"json"));
821 assert!(!SOURCE_EXTENSIONS.contains(&"yaml"));
822 assert!(!SOURCE_EXTENSIONS.contains(&"md"));
823 assert!(!SOURCE_EXTENSIONS.contains(&"png"));
824 assert!(!SOURCE_EXTENSIONS.contains(&"htm"));
825 }
826
827 #[test]
828 fn source_extensions_include_html() {
829 assert!(SOURCE_EXTENSIONS.contains(&"html"));
830 }
831
832 #[test]
833 fn source_extensions_include_graphql_documents() {
834 assert!(SOURCE_EXTENSIONS.contains(&"graphql"));
835 assert!(SOURCE_EXTENSIONS.contains(&"gql"));
836 }
837
838 fn build_production_glob_set() -> globset::GlobSet {
839 let mut builder = globset::GlobSetBuilder::new();
840 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
841 builder.add(
842 globset::GlobBuilder::new(pattern)
843 .literal_separator(true)
844 .build()
845 .expect("valid glob pattern"),
846 );
847 }
848 builder.build().expect("valid glob set")
849 }
850
851 #[test]
852 fn production_excludes_test_files() {
853 let set = build_production_glob_set();
854 assert!(set.is_match("src/Button.test.ts"));
855 assert!(set.is_match("src/utils.spec.tsx"));
856 assert!(set.is_match("src/__tests__/helper.ts"));
857 assert!(!set.is_match("src/Button.ts"));
858 assert!(!set.is_match("src/utils.tsx"));
859 }
860
861 #[test]
862 fn production_excludes_story_files() {
863 let set = build_production_glob_set();
864 assert!(set.is_match("src/Button.stories.tsx"));
865 assert!(set.is_match("src/Card.story.ts"));
866 assert!(!set.is_match("src/Button.tsx"));
867 }
868
869 #[test]
870 fn production_excludes_config_files_at_root_only() {
871 let set = build_production_glob_set();
872 assert!(set.is_match("vitest.config.ts"));
873 assert!(set.is_match("jest.config.js"));
874 assert!(!set.is_match("src/app/app.config.ts"));
875 assert!(!set.is_match("src/app/app.config.server.ts"));
876 assert!(!set.is_match("packages/foo/vitest.config.ts"));
877 assert!(!set.is_match("src/config.ts"));
878 }
879
880 #[test]
881 fn production_patterns_are_valid_globs() {
882 let _ = build_production_glob_set();
883 }
884
885 #[test]
886 fn disallowed_hidden_dirs_idea() {
887 assert!(!is_allowed_hidden_dir(OsStr::new(".idea")));
888 }
889
890 #[test]
891 fn source_extensions_include_mdx() {
892 assert!(SOURCE_EXTENSIONS.contains(&"mdx"));
893 }
894
895 #[test]
896 fn source_extensions_exclude_image_and_data_formats() {
897 assert!(!SOURCE_EXTENSIONS.contains(&"png"));
898 assert!(!SOURCE_EXTENSIONS.contains(&"jpg"));
899 assert!(!SOURCE_EXTENSIONS.contains(&"svg"));
900 assert!(!SOURCE_EXTENSIONS.contains(&"txt"));
901 assert!(!SOURCE_EXTENSIONS.contains(&"csv"));
902 assert!(!SOURCE_EXTENSIONS.contains(&"wasm"));
903 }
904
905 #[test]
906 fn is_declaration_file_matches_dts_variants() {
907 assert!(is_declaration_file(Path::new("env.d.ts")));
908 assert!(is_declaration_file(Path::new("src/auto-imports.d.ts")));
909 assert!(is_declaration_file(Path::new("mod.d.mts")));
910 assert!(is_declaration_file(Path::new("compat.d.cts")));
911 assert!(!is_declaration_file(Path::new("index.ts")));
912 assert!(!is_declaration_file(Path::new("component.tsx")));
913 assert!(!is_declaration_file(Path::new("notes.d.txt")));
914 }
915
916 #[test]
917 fn format_size_mb_renders_one_decimal() {
918 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
919 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
920 assert_eq!(format_size_mb(0), "0.0 MB");
921 }
922
923 #[test]
924 fn partition_by_size_no_limit_keeps_all() {
925 let raw = vec![(PathBuf::from("a.ts"), 10), (PathBuf::from("b.ts"), 10_000)];
926 let (kept, skipped) = partition_by_size(raw, None);
927 assert_eq!(kept.len(), 2);
928 assert!(skipped.is_empty());
929 }
930
931 #[test]
932 fn partition_by_size_skips_strictly_over_limit() {
933 let raw = vec![
934 (PathBuf::from("under.ts"), 99),
935 (PathBuf::from("exact.ts"), 100),
936 (PathBuf::from("over.ts"), 101),
937 ];
938 let (kept, skipped) = partition_by_size(raw, Some(100));
939 let kept_has = |name: &str| kept.iter().any(|(p, _)| p.as_path() == Path::new(name));
940 assert!(kept_has("under.ts"));
941 assert!(
942 kept_has("exact.ts"),
943 "a file exactly at the limit is kept (skip is strictly-greater)"
944 );
945 assert_eq!(skipped.len(), 1);
946 assert_eq!(skipped[0].0, PathBuf::from("over.ts"));
947 }
948
949 #[test]
950 fn partition_by_size_exempts_declaration_files() {
951 let raw = vec![
952 (PathBuf::from("huge.ts"), 10_000),
953 (PathBuf::from("auto-imports.d.ts"), 10_000),
954 ];
955 let (kept, skipped) = partition_by_size(raw, Some(100));
956 assert!(
957 kept.iter()
958 .any(|(p, _)| p.as_path() == Path::new("auto-imports.d.ts")),
959 "declaration files are exempt from the size skip regardless of size"
960 );
961 assert_eq!(skipped.len(), 1);
962 assert_eq!(skipped[0].0, PathBuf::from("huge.ts"));
963 }
964
965 fn disco(path: &str, size_bytes: u64) -> DiscoveredFile {
966 DiscoveredFile {
967 id: FileId(0),
968 path: PathBuf::from(path),
969 size_bytes,
970 }
971 }
972
973 #[test]
974 fn largest_files_note_below_threshold_is_none() {
975 let files = [disco("a.ts", 100), disco("b.ts", 200)];
976 assert!(build_largest_files_note(Path::new("/p"), &files).is_none());
977 }
978
979 #[test]
980 fn largest_files_note_single_file_uses_singular() {
981 let files = [disco("big.ts", 5 * 1024 * 1024)];
982 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
983 assert!(
984 note.contains("discovered 1 file;"),
985 "singular noun on the single-big-file path (issue #1086 regression): {note}"
986 );
987 assert!(!note.contains("discovered 1 files"));
988 assert!(note.contains("big.ts (5.0 MB)"));
989 }
990
991 #[test]
992 fn largest_files_note_filters_sub_floor_files() {
993 let files = [disco("big.ts", 5 * 1024 * 1024), disco("tiny.ts", 10)];
994 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
995 assert!(note.contains("discovered 2 files;"));
996 assert!(note.contains("big.ts (5.0 MB)"));
997 assert!(
998 !note.contains("tiny.ts"),
999 "sub-floor files are not listed as `0.0 MB` chaff: {note}"
1000 );
1001 }
1002
1003 #[test]
1004 fn largest_files_note_large_set_no_big_file_omits_list() {
1005 let files: Vec<DiscoveredFile> = (0..=LARGE_SET_THRESHOLD)
1006 .map(|i| disco(&format!("f{i}.ts"), 100))
1007 .collect();
1008 let note = build_largest_files_note(Path::new("/p"), &files).expect("large set fires");
1009 assert!(note.contains(&format!("discovered {} files", LARGE_SET_THRESHOLD + 1)));
1010 assert!(
1011 !note.contains("largest:"),
1012 "no sub-floor `largest:` list when no file clears the floor: {note}"
1013 );
1014 }
1015
1016 mod discover_files_integration {
1017 use std::path::PathBuf;
1018
1019 use fallow_config::{
1020 DuplicatesConfig, FallowConfig, FlagsConfig, HealthConfig, OutputFormat, ResolveConfig,
1021 RulesConfig,
1022 };
1023
1024 use super::*;
1025
1026 fn make_config(root: PathBuf, production: bool) -> ResolvedConfig {
1028 FallowConfig {
1029 production: production.into(),
1030 ..Default::default()
1031 }
1032 .resolve(root, OutputFormat::Human, 1, true, true, None)
1033 }
1034
1035 fn file_names(files: &[DiscoveredFile], root: &std::path::Path) -> Vec<String> {
1038 files
1039 .iter()
1040 .map(|f| {
1041 f.path
1042 .strip_prefix(root)
1043 .unwrap_or(&f.path)
1044 .to_string_lossy()
1045 .replace('\\', "/")
1046 })
1047 .collect()
1048 }
1049
1050 #[test]
1051 fn discovers_source_files_with_valid_extensions() {
1052 let dir = tempfile::tempdir().expect("create temp dir");
1053 let src = dir.path().join("src");
1054 std::fs::create_dir_all(&src).unwrap();
1055
1056 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1057 std::fs::write(src.join("component.tsx"), "export default () => {};").unwrap();
1058 std::fs::write(src.join("utils.js"), "module.exports = {};").unwrap();
1059 std::fs::write(src.join("helper.jsx"), "export const h = 1;").unwrap();
1060 std::fs::write(src.join("config.mjs"), "export default {};").unwrap();
1061 std::fs::write(src.join("legacy.cjs"), "module.exports = {};").unwrap();
1062 std::fs::write(src.join("types.mts"), "export type T = string;").unwrap();
1063 std::fs::write(src.join("compat.cts"), "module.exports = {};").unwrap();
1064
1065 let config = make_config(dir.path().to_path_buf(), false);
1066 let files = discover_files(&config);
1067 let names = file_names(&files, dir.path());
1068
1069 assert!(names.contains(&"src/app.ts".to_string()));
1070 assert!(names.contains(&"src/component.tsx".to_string()));
1071 assert!(names.contains(&"src/utils.js".to_string()));
1072 assert!(names.contains(&"src/helper.jsx".to_string()));
1073 assert!(names.contains(&"src/config.mjs".to_string()));
1074 assert!(names.contains(&"src/legacy.cjs".to_string()));
1075 assert!(names.contains(&"src/types.mts".to_string()));
1076 assert!(names.contains(&"src/compat.cts".to_string()));
1077 }
1078
1079 #[test]
1080 fn excludes_non_source_extensions() {
1081 let dir = tempfile::tempdir().expect("create temp dir");
1082 let src = dir.path().join("src");
1083 std::fs::create_dir_all(&src).unwrap();
1084
1085 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1086
1087 std::fs::write(src.join("data.json"), "{}").unwrap();
1088 std::fs::write(src.join("readme.md"), "# Hello").unwrap();
1089 std::fs::write(src.join("notes.txt"), "notes").unwrap();
1090 std::fs::write(src.join("logo.png"), [0u8; 8]).unwrap();
1091
1092 let config = make_config(dir.path().to_path_buf(), false);
1093 let files = discover_files(&config);
1094 let names = file_names(&files, dir.path());
1095
1096 assert_eq!(names.len(), 1, "only the .ts file should be discovered");
1097 assert!(names.contains(&"src/app.ts".to_string()));
1098 }
1099
1100 #[test]
1101 fn excludes_disallowed_hidden_directories() {
1102 let dir = tempfile::tempdir().expect("create temp dir");
1103
1104 let git_dir = dir.path().join(".git");
1105 std::fs::create_dir_all(&git_dir).unwrap();
1106 std::fs::write(git_dir.join("hooks.ts"), "// git hook").unwrap();
1107
1108 let idea_dir = dir.path().join(".idea");
1109 std::fs::create_dir_all(&idea_dir).unwrap();
1110 std::fs::write(idea_dir.join("workspace.ts"), "// idea").unwrap();
1111
1112 let cache_dir = dir.path().join(".cache");
1113 std::fs::create_dir_all(&cache_dir).unwrap();
1114 std::fs::write(cache_dir.join("cached.js"), "// cached").unwrap();
1115
1116 let src = dir.path().join("src");
1117 std::fs::create_dir_all(&src).unwrap();
1118 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1119
1120 let config = make_config(dir.path().to_path_buf(), false);
1121 let files = discover_files(&config);
1122 let names = file_names(&files, dir.path());
1123
1124 assert_eq!(names.len(), 1, "only src/app.ts should be discovered");
1125 assert!(names.contains(&"src/app.ts".to_string()));
1126 }
1127
1128 #[test]
1129 fn includes_allowed_hidden_directories() {
1130 let dir = tempfile::tempdir().expect("create temp dir");
1131
1132 let storybook = dir.path().join(".storybook");
1133 std::fs::create_dir_all(&storybook).unwrap();
1134 std::fs::write(storybook.join("main.ts"), "export default {};").unwrap();
1135
1136 let github = dir.path().join(".github");
1137 std::fs::create_dir_all(&github).unwrap();
1138 std::fs::write(github.join("actions.js"), "module.exports = {};").unwrap();
1139
1140 let changeset = dir.path().join(".changeset");
1141 std::fs::create_dir_all(&changeset).unwrap();
1142 std::fs::write(changeset.join("config.js"), "module.exports = {};").unwrap();
1143
1144 let config = make_config(dir.path().to_path_buf(), false);
1145 let files = discover_files(&config);
1146 let names = file_names(&files, dir.path());
1147
1148 assert!(
1149 names.contains(&".storybook/main.ts".to_string()),
1150 "files in .storybook should be discovered"
1151 );
1152 assert!(
1153 names.contains(&".github/actions.js".to_string()),
1154 "files in .github should be discovered"
1155 );
1156 assert!(
1157 names.contains(&".changeset/config.js".to_string()),
1158 "files in .changeset should be discovered"
1159 );
1160 }
1161
1162 #[test]
1163 fn default_discovery_excludes_client_and_server_hidden_directories() {
1164 let dir = tempfile::tempdir().expect("create temp dir");
1165 let app = dir.path().join("app");
1166 std::fs::create_dir_all(app.join(".client")).unwrap();
1167 std::fs::create_dir_all(app.join(".server")).unwrap();
1168 std::fs::write(app.join(".client/analytics.ts"), "export const a = 1;").unwrap();
1169 std::fs::write(app.join(".server/db.ts"), "export const db = {};").unwrap();
1170 std::fs::write(app.join("root.tsx"), "export default function Root() {}").unwrap();
1171
1172 let config = make_config(dir.path().to_path_buf(), false);
1173 let files = discover_files(&config);
1174 let names = file_names(&files, dir.path());
1175
1176 assert!(names.contains(&"app/root.tsx".to_string()));
1177 assert!(!names.contains(&"app/.client/analytics.ts".to_string()));
1178 assert!(!names.contains(&"app/.server/db.ts".to_string()));
1179 }
1180
1181 #[test]
1182 fn scoped_hidden_dirs_include_client_and_server_under_package_root() {
1183 let dir = tempfile::tempdir().expect("create temp dir");
1184 let package = dir.path().join("packages/app");
1185 std::fs::create_dir_all(package.join("app/.client")).unwrap();
1186 std::fs::create_dir_all(package.join("app/.server")).unwrap();
1187 std::fs::write(
1188 package.join("app/.client/analytics.ts"),
1189 "export const track = () => {};",
1190 )
1191 .unwrap();
1192 std::fs::write(package.join("app/.server/db.ts"), "export const db = {};").unwrap();
1193
1194 let config = make_config(dir.path().to_path_buf(), false);
1195 let scopes = [HiddenDirScope::new(
1196 package,
1197 vec![".client".to_string(), ".server".to_string()],
1198 )];
1199 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
1200 let names = file_names(&files, dir.path());
1201
1202 assert!(names.contains(&"packages/app/app/.client/analytics.ts".to_string()));
1203 assert!(names.contains(&"packages/app/app/.server/db.ts".to_string()));
1204 }
1205
1206 #[test]
1207 fn scoped_hidden_dirs_do_not_include_unscoped_packages() {
1208 let dir = tempfile::tempdir().expect("create temp dir");
1209 let active = dir.path().join("packages/active");
1210 let inactive = dir.path().join("packages/inactive");
1211 std::fs::create_dir_all(active.join("app/.server")).unwrap();
1212 std::fs::create_dir_all(inactive.join("app/.server")).unwrap();
1213 std::fs::write(active.join("app/.server/db.ts"), "export const db = {};").unwrap();
1214 std::fs::write(inactive.join("app/.server/db.ts"), "export const db = {};").unwrap();
1215
1216 let config = make_config(dir.path().to_path_buf(), false);
1217 let scopes = [HiddenDirScope::new(active, vec![".server".to_string()])];
1218 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
1219 let names = file_names(&files, dir.path());
1220
1221 assert!(names.contains(&"packages/active/app/.server/db.ts".to_string()));
1222 assert!(!names.contains(&"packages/inactive/app/.server/db.ts".to_string()));
1223 }
1224
1225 #[test]
1226 fn excludes_root_build_directory() {
1227 let dir = tempfile::tempdir().expect("create temp dir");
1228
1229 std::fs::write(dir.path().join(".ignore"), "/build/\n").unwrap();
1230
1231 let build_dir = dir.path().join("build");
1232 std::fs::create_dir_all(&build_dir).unwrap();
1233 std::fs::write(build_dir.join("output.js"), "// build output").unwrap();
1234
1235 let src = dir.path().join("src");
1236 std::fs::create_dir_all(&src).unwrap();
1237 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1238
1239 let config = make_config(dir.path().to_path_buf(), false);
1240 let files = discover_files(&config);
1241 let names = file_names(&files, dir.path());
1242
1243 assert_eq!(names.len(), 1, "root build/ should be excluded via .ignore");
1244 assert!(names.contains(&"src/app.ts".to_string()));
1245 }
1246
1247 #[test]
1248 fn includes_nested_build_directory() {
1249 let dir = tempfile::tempdir().expect("create temp dir");
1250
1251 let nested_build = dir.path().join("src").join("build");
1252 std::fs::create_dir_all(&nested_build).unwrap();
1253 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
1254
1255 let config = make_config(dir.path().to_path_buf(), false);
1256 let files = discover_files(&config);
1257 let names = file_names(&files, dir.path());
1258
1259 assert!(
1260 names.contains(&"src/build/helper.ts".to_string()),
1261 "nested build/ directories should be included"
1262 );
1263 }
1264
1265 #[test]
1266 #[expect(
1267 clippy::cast_possible_truncation,
1268 reason = "test file counts are trivially small"
1269 )]
1270 fn file_ids_are_sequential_after_sorting() {
1271 let dir = tempfile::tempdir().expect("create temp dir");
1272 let src = dir.path().join("src");
1273 std::fs::create_dir_all(&src).unwrap();
1274
1275 std::fs::write(src.join("z_last.ts"), "export const z = 1;").unwrap();
1276 std::fs::write(src.join("a_first.ts"), "export const a = 1;").unwrap();
1277 std::fs::write(src.join("m_middle.ts"), "export const m = 1;").unwrap();
1278
1279 let config = make_config(dir.path().to_path_buf(), false);
1280 let files = discover_files(&config);
1281
1282 for (idx, file) in files.iter().enumerate() {
1283 assert_eq!(file.id, FileId(idx as u32), "FileId should be sequential");
1284 }
1285
1286 for pair in files.windows(2) {
1287 assert!(
1288 pair[0].path < pair[1].path,
1289 "files should be sorted by path"
1290 );
1291 }
1292 }
1293
1294 #[test]
1295 fn production_mode_excludes_test_files() {
1296 let dir = tempfile::tempdir().expect("create temp dir");
1297 let src = dir.path().join("src");
1298 std::fs::create_dir_all(&src).unwrap();
1299
1300 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1301 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
1302 std::fs::write(src.join("app.spec.ts"), "describe('a', () => {});").unwrap();
1303 std::fs::write(src.join("app.stories.tsx"), "export default {};").unwrap();
1304
1305 let config = make_config(dir.path().to_path_buf(), true);
1306 let files = discover_files(&config);
1307 let names = file_names(&files, dir.path());
1308
1309 assert!(
1310 names.contains(&"src/app.ts".to_string()),
1311 "source files should be included in production mode"
1312 );
1313 assert!(
1314 !names.contains(&"src/app.test.ts".to_string()),
1315 "test files should be excluded in production mode"
1316 );
1317 assert!(
1318 !names.contains(&"src/app.spec.ts".to_string()),
1319 "spec files should be excluded in production mode"
1320 );
1321 assert!(
1322 !names.contains(&"src/app.stories.tsx".to_string()),
1323 "story files should be excluded in production mode"
1324 );
1325 }
1326
1327 #[test]
1328 fn non_production_mode_includes_test_files() {
1329 let dir = tempfile::tempdir().expect("create temp dir");
1330 let src = dir.path().join("src");
1331 std::fs::create_dir_all(&src).unwrap();
1332
1333 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1334 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
1335
1336 let config = make_config(dir.path().to_path_buf(), false);
1337 let files = discover_files(&config);
1338 let names = file_names(&files, dir.path());
1339
1340 assert!(names.contains(&"src/app.ts".to_string()));
1341 assert!(
1342 names.contains(&"src/app.test.ts".to_string()),
1343 "test files should be included in non-production mode"
1344 );
1345 }
1346
1347 #[test]
1348 fn empty_directory_returns_no_files() {
1349 let dir = tempfile::tempdir().expect("create temp dir");
1350 let config = make_config(dir.path().to_path_buf(), false);
1351 let files = discover_files(&config);
1352 assert!(files.is_empty(), "empty project should discover no files");
1353 }
1354
1355 #[test]
1356 fn hidden_files_not_discovered_as_source() {
1357 let dir = tempfile::tempdir().expect("create temp dir");
1358
1359 std::fs::write(dir.path().join(".env"), "SECRET=abc").unwrap();
1360 std::fs::write(dir.path().join(".gitignore"), "node_modules").unwrap();
1361 std::fs::write(dir.path().join(".eslintrc.js"), "module.exports = {};").unwrap();
1362
1363 let src = dir.path().join("src");
1364 std::fs::create_dir_all(&src).unwrap();
1365 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1366
1367 let config = make_config(dir.path().to_path_buf(), false);
1368 let files = discover_files(&config);
1369 let names = file_names(&files, dir.path());
1370
1371 assert!(
1372 !names.contains(&".env".to_string()),
1373 ".env should not be discovered"
1374 );
1375 assert!(
1376 !names.contains(&".gitignore".to_string()),
1377 ".gitignore should not be discovered"
1378 );
1379 }
1380
1381 fn make_config_with_ignores(root: PathBuf, ignores: Vec<String>) -> ResolvedConfig {
1383 FallowConfig {
1384 schema: None,
1385 extends: vec![],
1386 entry: vec![],
1387 ignore_patterns: ignores,
1388 framework: vec![],
1389 workspaces: None,
1390 ignore_dependencies: vec![],
1391 ignore_unresolved_imports: vec![],
1392 ignore_exports: vec![],
1393 ignore_catalog_references: vec![],
1394 ignore_dependency_overrides: vec![],
1395 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(
1396 ),
1397 used_class_members: vec![],
1398 ignore_decorators: vec![],
1399 duplicates: DuplicatesConfig::default(),
1400 health: HealthConfig::default(),
1401 rules: RulesConfig::default(),
1402 boundaries: fallow_config::BoundaryConfig::default(),
1403 production: false.into(),
1404 plugins: vec![],
1405 rule_packs: vec![],
1406 dynamically_loaded: vec![],
1407 overrides: vec![],
1408 regression: None,
1409 audit: fallow_config::AuditConfig::default(),
1410 codeowners: None,
1411 public_packages: vec![],
1412 flags: FlagsConfig::default(),
1413 security: fallow_config::SecurityConfig::default(),
1414 fix: fallow_config::FixConfig::default(),
1415 resolve: ResolveConfig::default(),
1416 sealed: false,
1417 include_entry_exports: false,
1418 auto_imports: false,
1419 cache: fallow_config::CacheConfig::default(),
1420 }
1421 .resolve(root, OutputFormat::Human, 1, true, true, None)
1422 }
1423
1424 #[test]
1425 fn custom_ignore_patterns_exclude_matching_files() {
1426 let dir = tempfile::tempdir().expect("create temp dir");
1427
1428 let generated = dir.path().join("src").join("api").join("generated");
1429 std::fs::create_dir_all(&generated).unwrap();
1430 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
1431
1432 let client = dir.path().join("src").join("api").join("client");
1433 std::fs::create_dir_all(&client).unwrap();
1434 std::fs::write(client.join("fetch.ts"), "export const fetch = {};").unwrap();
1435
1436 let src = dir.path().join("src");
1437 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1438
1439 let config = make_config_with_ignores(
1440 dir.path().to_path_buf(),
1441 vec![
1442 "src/api/generated/**".to_string(),
1443 "src/api/client/**".to_string(),
1444 ],
1445 );
1446 let files = discover_files(&config);
1447 let names = file_names(&files, dir.path());
1448
1449 assert_eq!(names.len(), 1, "only non-ignored files: {names:?}");
1450 assert!(names.contains(&"src/index.ts".to_string()));
1451 }
1452
1453 #[test]
1454 fn leading_dot_ignore_patterns_exclude_matching_files() {
1455 let dir = tempfile::tempdir().expect("create temp dir");
1456
1457 let generated = dir.path().join("src").join("generated");
1458 std::fs::create_dir_all(&generated).unwrap();
1459 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
1460
1461 let src = dir.path().join("src");
1462 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1463
1464 let config = make_config_with_ignores(
1465 dir.path().to_path_buf(),
1466 vec!["./src/generated/**".to_string()],
1467 );
1468 let files = discover_files(&config);
1469 let names = file_names(&files, dir.path());
1470
1471 assert_eq!(names, vec!["src/index.ts"]);
1472 }
1473
1474 #[test]
1475 fn default_ignore_patterns_exclude_node_modules_and_dist() {
1476 let dir = tempfile::tempdir().expect("create temp dir");
1477
1478 let nm = dir.path().join("node_modules").join("lodash");
1479 std::fs::create_dir_all(&nm).unwrap();
1480 std::fs::write(nm.join("lodash.js"), "module.exports = {};").unwrap();
1481
1482 let dist = dir.path().join("dist");
1483 std::fs::create_dir_all(&dist).unwrap();
1484 std::fs::write(dist.join("bundle.js"), "// bundled").unwrap();
1485
1486 let src = dir.path().join("src");
1487 std::fs::create_dir_all(&src).unwrap();
1488 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1489
1490 let config = make_config(dir.path().to_path_buf(), false);
1491 let files = discover_files(&config);
1492 let names = file_names(&files, dir.path());
1493
1494 assert_eq!(names.len(), 1);
1495 assert!(names.contains(&"src/index.ts".to_string()));
1496 }
1497
1498 #[test]
1499 fn default_ignore_patterns_exclude_root_build() {
1500 let dir = tempfile::tempdir().expect("create temp dir");
1501
1502 let build = dir.path().join("build");
1503 std::fs::create_dir_all(&build).unwrap();
1504 std::fs::write(build.join("output.js"), "// built").unwrap();
1505
1506 let nested_build = dir.path().join("src").join("build");
1507 std::fs::create_dir_all(&nested_build).unwrap();
1508 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
1509
1510 let src = dir.path().join("src");
1511 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1512
1513 let config = make_config(dir.path().to_path_buf(), false);
1514 let files = discover_files(&config);
1515 let names = file_names(&files, dir.path());
1516
1517 assert_eq!(
1518 names.len(),
1519 2,
1520 "root build/ excluded, nested kept: {names:?}"
1521 );
1522 assert!(names.contains(&"src/index.ts".to_string()));
1523 assert!(names.contains(&"src/build/helper.ts".to_string()));
1524 }
1525
1526 fn make_config_with_max_file_size(
1528 root: PathBuf,
1529 max_file_size_bytes: Option<u64>,
1530 ) -> ResolvedConfig {
1531 let mut config = make_config(root, false);
1532 config.max_file_size_bytes = max_file_size_bytes;
1533 config
1534 }
1535
1536 #[test]
1537 fn skips_files_over_max_file_size() {
1538 let dir = tempfile::tempdir().expect("create temp dir");
1539 let src = dir.path().join("src");
1540 std::fs::create_dir_all(&src).unwrap();
1541 std::fs::write(src.join("small.ts"), "export const a = 1;").unwrap();
1542 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1543
1544 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1545 let files = discover_files(&config);
1546 let names = file_names(&files, dir.path());
1547
1548 assert!(names.contains(&"src/small.ts".to_string()));
1549 assert!(
1550 !names.contains(&"src/huge.ts".to_string()),
1551 "a file over the size limit must not be discovered"
1552 );
1553 }
1554
1555 #[test]
1556 fn declaration_files_exempt_from_size_skip() {
1557 let dir = tempfile::tempdir().expect("create temp dir");
1558 let src = dir.path().join("src");
1559 std::fs::create_dir_all(&src).unwrap();
1560 std::fs::write(src.join("auto-imports.d.ts"), "x".repeat(5_000)).unwrap();
1561 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1562
1563 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1564 let files = discover_files(&config);
1565 let names = file_names(&files, dir.path());
1566
1567 assert!(
1568 names.contains(&"src/auto-imports.d.ts".to_string()),
1569 "a large .d.ts is exempt from the skip (reachability root for global types)"
1570 );
1571 assert!(!names.contains(&"src/huge.ts".to_string()));
1572 }
1573
1574 #[test]
1575 fn unlimited_size_keeps_large_files() {
1576 let dir = tempfile::tempdir().expect("create temp dir");
1577 let src = dir.path().join("src");
1578 std::fs::create_dir_all(&src).unwrap();
1579 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1580
1581 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
1582 let files = discover_files(&config);
1583 let names = file_names(&files, dir.path());
1584
1585 assert!(
1586 names.contains(&"src/huge.ts".to_string()),
1587 "no limit keeps every file"
1588 );
1589 }
1590
1591 #[test]
1592 fn skipped_file_recorded_in_workspace_diagnostics() {
1593 let dir = tempfile::tempdir().expect("create temp dir");
1594 let src = dir.path().join("src");
1595 std::fs::create_dir_all(&src).unwrap();
1596 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1597
1598 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1599 let _ = discover_files(&config);
1600
1601 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1602 let skipped: Vec<_> = diagnostics
1603 .iter()
1604 .filter(|d| {
1605 matches!(
1606 d.kind,
1607 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { .. }
1608 )
1609 })
1610 .collect();
1611 assert_eq!(
1612 skipped.len(),
1613 1,
1614 "the skipped file is recorded in workspace diagnostics for JSON output"
1615 );
1616 assert!(skipped[0].path.ends_with("src/huge.ts"));
1617 assert!(
1618 matches!(
1619 skipped[0].kind,
1620 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes }
1621 if size_bytes == 5_000
1622 ),
1623 "the recorded diagnostic carries the on-disk byte size"
1624 );
1625 }
1626
1627 #[test]
1628 fn skips_large_one_line_js_as_minified_generated_output() {
1629 let dir = tempfile::tempdir().expect("create temp dir");
1630 let src = dir.path().join("src");
1631 std::fs::create_dir_all(&src).unwrap();
1632 let asset = src.join("index-abc123.js");
1633 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
1634
1635 let config = make_config(dir.path().to_path_buf(), false);
1636 let files = discover_files(&config);
1637 let names = file_names(&files, dir.path());
1638
1639 assert!(
1640 !names.contains(&"src/index-abc123.js".to_string()),
1641 "large one-line JS assets should be skipped before parsing"
1642 );
1643
1644 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1645 assert!(
1646 diagnostics.iter().any(|diag| {
1647 diag.path.ends_with("src/index-abc123.js")
1648 && matches!(
1649 diag.kind,
1650 fallow_config::WorkspaceDiagnosticKind::SkippedMinifiedFile { .. }
1651 )
1652 }),
1653 "the skipped minified asset is recorded for JSON output: {diagnostics:?}"
1654 );
1655 }
1656
1657 #[test]
1658 fn unlimited_size_keeps_large_one_line_js() {
1659 let dir = tempfile::tempdir().expect("create temp dir");
1660 let src = dir.path().join("src");
1661 std::fs::create_dir_all(&src).unwrap();
1662 let asset = src.join("index-abc123.js");
1663 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
1664
1665 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
1666 let files = discover_files(&config);
1667 let names = file_names(&files, dir.path());
1668
1669 assert!(
1670 names.contains(&"src/index-abc123.js".to_string()),
1671 "--max-file-size 0 should opt out of generated JS skipping"
1672 );
1673 }
1674
1675 #[test]
1676 fn keeps_large_multiline_js() {
1677 let dir = tempfile::tempdir().expect("create temp dir");
1678 let src = dir.path().join("src");
1679 std::fs::create_dir_all(&src).unwrap();
1680 let asset = src.join("handwritten.js");
1681 let mut content = String::new();
1682 while content.len() <= MINIFIED_FILE_SKIP_BYTES as usize + 1 {
1683 content.push_str("export const value = 1;\n");
1684 }
1685 std::fs::write(&asset, content).unwrap();
1686
1687 let config = make_config(dir.path().to_path_buf(), false);
1688 let files = discover_files(&config);
1689 let names = file_names(&files, dir.path());
1690
1691 assert!(
1692 names.contains(&"src/handwritten.js".to_string()),
1693 "large multiline JS should not be treated as a generated minified asset"
1694 );
1695 }
1696 }
1697}