Skip to main content

fallow_core/discover/
walk.rs

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
12/// Process-wide dedupe of the size-skip / largest-files stderr notes, keyed by a
13/// content-derived string, so combined-mode (`fallow` runs check + dupes +
14/// health, each of which can trigger a source walk) emits each note at most once
15/// per distinct content. Mirrors the workspace-diagnostics `should_emit`
16/// pattern (issue #1086).
17fn 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
25/// A discovered file path paired with its on-disk size in bytes, as collected
26/// by the parallel walker before [`DiscoveredFile`] ids are assigned.
27type SizedFile = (PathBuf, u64);
28
29/// Number of example file paths named in the aggregated skipped-large-file and
30/// largest-files stderr notes before the tail collapses to "and N more". Keeps
31/// the notes to one bounded line on a monorepo that skips many files.
32const NOTE_EXAMPLE_CAP: usize = 5;
33
34/// Discovered-file-count threshold above which the pre-parse largest-files note
35/// fires, so an out-of-memory hang at the parse stage has a visible suspect
36/// list (issue #1086).
37const LARGE_SET_THRESHOLD: usize = 20_000;
38
39/// Single-file byte threshold above which the pre-parse largest-files note
40/// fires even on a small project. Set just under the default 5 MB skip so the
41/// note fires for kept files that are approaching the skip limit (the genuine
42/// out-of-memory suspects), not for ordinary large-but-benign files.
43const LARGE_FILE_NOTE_BYTES: u64 = 4 * 1024 * 1024;
44
45/// Minimum size for a file to appear in the largest-files note. Filters out the
46/// `0.0 MB` entries that would otherwise pad the list once it fires, keeping the
47/// named files to plausible memory contributors.
48const NOTE_FILE_FLOOR_BYTES: u64 = 256 * 1024;
49
50/// Minimum size for content-shape based minified-bundle skipping. Smaller
51/// one-line files can be hand-written utilities, while multi-MB one-line JS is
52/// generated output in practice.
53const MINIFIED_FILE_SKIP_BYTES: u64 = 1024 * 1024;
54
55/// Number of bytes inspected when deciding whether a large JS file is minified.
56const MINIFIED_SAMPLE_BYTES: usize = 256 * 1024;
57
58/// A single line this long in a multi-MB JS file is treated as generated
59/// minified output. This avoids parsing assets that can expand to huge ASTs.
60const MINIFIED_LONG_LINE_BYTES: usize = 128 * 1024;
61
62/// Whether a path is a TypeScript declaration file (`.d.ts`/`.d.mts`/`.d.cts`).
63/// Declaration files are exempt from the per-file size skip because they are
64/// reachability roots for global types: skipping a large `auto-imports.d.ts`
65/// would false-flag the files whose types it provides.
66fn 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
114/// Render a byte count as a megabyte figure with one decimal place.
115fn 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
124/// Join up to [`NOTE_EXAMPLE_CAP`] `path (size)` examples (already ordered) into
125/// one comma-separated string, collapsing the tail to "and N more".
126fn 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
148/// Split discovered `(path, size)` pairs into the kept set and the set skipped
149/// for exceeding `max_file_size_bytes`. Declaration files are never skipped.
150fn 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
161/// Split discovered `(path, size)` pairs into files kept for parsing and files
162/// skipped because they look like generated minified JavaScript.
163fn 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
174/// Record the skipped files in the workspace-diagnostics registry (so they
175/// surface in `workspace_diagnostics[]` JSON) and emit one aggregated
176/// `tracing::warn!` so a human running `fallow` sees what was dropped. Mirrors
177/// the JSON-plus-gated-warn pattern used for undeclared workspaces.
178fn 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
215/// Record generated minified JS files skipped before parsing.
216fn 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
254/// Build the pre-parse largest-files note, or `None` when the discovered set is
255/// neither unusually large nor contains an unusually large file. Pure so the
256/// pluralization, floor filtering, and count-only fallback are unit-testable
257/// without a tracing subscriber. See issue #1086.
258fn 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        // Large file SET with no individually large file: report the count only,
276        // omitting a "largest:" list that would otherwise be all sub-floor noise.
277        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
289/// Emit a pre-parse note listing the largest kept files when the discovered set
290/// is unusually large or contains an unusually large file, so an out-of-memory
291/// hang at the parse stage is diagnosable (issue #1086). Visible before the
292/// expensive parse begins, so it survives a subsequent crash.
293fn 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/// Package-scoped hidden directories that source discovery should traverse.
305#[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    #[must_use]
317    pub fn root(&self) -> &Path {
318        &self.root
319    }
320
321    #[must_use]
322    pub fn dirs(&self) -> &[String] {
323        &self.dirs
324    }
325
326    fn allows(&self, path: &Path, name: &OsStr) -> bool {
327        path.starts_with(&self.root) && self.dirs.iter().any(|dir| OsStr::new(dir) == name)
328    }
329}
330
331/// Per-thread file collector for the parallel walker.
332///
333/// Source files (by extension) flow to `shared`; when `config_shared` is set,
334/// non-source files admitted by the config-candidate type group flow to it
335/// instead. The two channels are disjoint and the source channel is byte-for-byte
336/// identical to the config-capture-disabled walk.
337struct FileVisitor<'a> {
338    root: &'a Path,
339    canonical_root: Option<&'a Path>,
340    ignore_patterns: &'a globset::GlobSet,
341    production_excludes: &'a Option<globset::GlobSet>,
342    shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
343    config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
344    local: Vec<(std::path::PathBuf, u64)>,
345    config_local: Vec<std::path::PathBuf>,
346}
347
348impl ignore::ParallelVisitor for FileVisitor<'_> {
349    fn visit(&mut self, result: Result<ignore::DirEntry, ignore::Error>) -> ignore::WalkState {
350        let Ok(entry) = result else {
351            return ignore::WalkState::Continue;
352        };
353        if entry.file_type().is_some_and(|ft| ft.is_dir()) {
354            return ignore::WalkState::Continue;
355        }
356        let relative = entry
357            .path()
358            .strip_prefix(self.root)
359            .unwrap_or_else(|_| entry.path());
360        if self.ignore_patterns.is_match(relative) {
361            return ignore::WalkState::Continue;
362        }
363        if self
364            .production_excludes
365            .as_ref()
366            .is_some_and(|excludes| excludes.is_match(relative))
367        {
368            return ignore::WalkState::Continue;
369        }
370        let symlink_size = if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
371            let Some(size) = contained_symlink_file_size(entry.path(), self.canonical_root) else {
372                tracing::debug!(
373                    path = %entry.path().display(),
374                    "skipping source symlink with a broken, non-file, or outside-root target"
375                );
376                return ignore::WalkState::Continue;
377            };
378            Some(size)
379        } else {
380            None
381        };
382        if has_source_extension(entry.path()) {
383            let size_bytes =
384                symlink_size.unwrap_or_else(|| entry.metadata().map_or(0, |m| m.len()));
385            self.local.push((entry.into_path(), size_bytes));
386        } else if self.config_shared.is_some() {
387            // A non-source file admitted by the config-candidate type group. No
388            // size metadata is needed; these are pattern-matched, never parsed.
389            self.config_local.push(entry.into_path());
390        }
391        ignore::WalkState::Continue
392    }
393}
394
395fn contained_symlink_file_size(path: &Path, canonical_root: Option<&Path>) -> Option<u64> {
396    let root = canonical_root?;
397    let target = path.canonicalize().ok()?;
398    if !target.starts_with(root) {
399        return None;
400    }
401    let metadata = target.metadata().ok()?;
402    metadata.is_file().then_some(metadata.len())
403}
404
405impl Drop for FileVisitor<'_> {
406    #[expect(
407        clippy::expect_used,
408        reason = "poisoned walk collector lock means worker state is unrecoverable"
409    )]
410    fn drop(&mut self) {
411        if !self.local.is_empty() {
412            self.shared
413                .lock()
414                .expect("walk collector lock poisoned")
415                .append(&mut self.local);
416        }
417        if let Some(config_shared) = self.config_shared
418            && !self.config_local.is_empty()
419        {
420            config_shared
421                .lock()
422                .expect("walk config collector lock poisoned")
423                .append(&mut self.config_local);
424        }
425    }
426}
427
428/// Builder that creates per-thread `FileVisitor` instances for the parallel walker.
429struct FileVisitorBuilder<'a> {
430    root: &'a Path,
431    canonical_root: Option<&'a Path>,
432    ignore_patterns: &'a globset::GlobSet,
433    production_excludes: &'a Option<globset::GlobSet>,
434    shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
435    config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
436}
437
438impl<'s> ignore::ParallelVisitorBuilder<'s> for FileVisitorBuilder<'s> {
439    fn build(&mut self) -> Box<dyn ignore::ParallelVisitor + 's> {
440        Box::new(FileVisitor {
441            root: self.root,
442            canonical_root: self.canonical_root,
443            ignore_patterns: self.ignore_patterns,
444            production_excludes: self.production_excludes,
445            shared: self.shared,
446            config_shared: self.config_shared,
447            local: Vec::new(),
448            config_local: Vec::new(),
449        })
450    }
451}
452
453pub const SOURCE_EXTENSIONS: &[&str] = &[
454    "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
455    "mdx", "css", "scss", "sass", "less", "html", "graphql", "gql",
456];
457
458/// Glob patterns for test/dev/story files excluded in production mode.
459pub const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
460    "**/*.test.*",
461    "**/*.spec.*",
462    "**/*.e2e.*",
463    "**/*.e2e-spec.*",
464    "**/*.bench.*",
465    "**/*.fixture.*",
466    "**/*.stories.*",
467    "**/*.story.*",
468    "**/__tests__/**",
469    "**/__mocks__/**",
470    "**/__snapshots__/**",
471    "**/__fixtures__/**",
472    "**/test/**",
473    "**/tests/**",
474    "*.config.*",
475    "**/.*.js",
476    "**/.*.ts",
477    "**/.*.mjs",
478    "**/.*.cjs",
479];
480
481/// Check if a hidden directory name is on the allowlist.
482pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
483    ALLOWED_HIDDEN_DIRS.iter().any(|&d| OsStr::new(d) == name)
484}
485
486fn is_allowed_scoped_hidden_dir(
487    name: &OsStr,
488    path: &Path,
489    additional_hidden_dir_scopes: &[HiddenDirScope],
490) -> bool {
491    additional_hidden_dir_scopes
492        .iter()
493        .any(|scope| scope.allows(path, name))
494}
495
496/// Check if a hidden directory entry should be allowed through the filter.
497///
498/// Returns `true` if the entry is not hidden or is on the allowlist.
499/// Hidden files (not directories) are always allowed through since the type
500/// filter handles them.
501fn is_allowed_hidden(entry: &ignore::DirEntry) -> bool {
502    is_allowed_hidden_with_scopes(entry, &[])
503}
504
505fn is_allowed_hidden_with_scopes(
506    entry: &ignore::DirEntry,
507    additional_hidden_dir_scopes: &[HiddenDirScope],
508) -> bool {
509    let name = entry.file_name();
510    let name_str = name.to_string_lossy();
511
512    if !name_str.starts_with('.') {
513        return true;
514    }
515
516    if entry.file_type().is_some_and(|ft| !ft.is_dir()) {
517        return true;
518    }
519
520    is_allowed_hidden_dir(name)
521        || is_allowed_scoped_hidden_dir(name, entry.path(), additional_hidden_dir_scopes)
522}
523
524/// Discover all source files in the project.
525///
526/// # Panics
527///
528/// Panics if the file type glob or progress template is invalid (compile-time constants).
529pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
530    discover_files_with_additional_hidden_dirs(config, &[])
531}
532
533/// The set of config-file basenames (last path component of every built-in
534/// plugin `config_patterns()` entry, brace forms preserved) that the walk should
535/// additionally admit so non-source configs (`tsconfig.json`, `bunfig.toml`,
536/// `.eslintrc.json`, ...) can be captured in one traversal instead of being
537/// re-discovered by a filesystem re-walk in `discover_config_files`.
538///
539/// Derived live from the built-in plugin list, so it can never drift behind a
540/// new plugin's config patterns. Source-extension config basenames
541/// (`vite.config.{ts,js}`) are admitted too, but the walk visitor routes them
542/// back to the source channel by extension, so the config channel only ever
543/// collects genuinely non-source files.
544fn config_candidate_basename_globs() -> &'static [String] {
545    static GLOBS: OnceLock<Vec<String>> = OnceLock::new();
546    GLOBS.get_or_init(|| {
547        let mut set: FxHashSet<String> = FxHashSet::default();
548        for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
549            for pattern in plugin.config_patterns() {
550                let basename = pattern.rsplit('/').next().unwrap_or(pattern);
551                set.insert(basename.to_string());
552            }
553        }
554        let mut globs: Vec<String> = set.into_iter().collect();
555        globs.sort_unstable();
556        globs
557    })
558}
559
560/// True when `path`'s extension is one of the known source extensions, i.e. the
561/// file belongs in the source channel rather than the config-candidate channel.
562fn has_source_extension(path: &Path) -> bool {
563    path.extension()
564        .and_then(OsStr::to_str)
565        .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
566}
567
568/// Build the file-type filter. Always selects known source extensions; when
569/// `capture_config` is set, also selects config-candidate basenames so the
570/// walker yields them for the second collection channel.
571#[expect(
572    clippy::expect_used,
573    reason = "source file globs are hard-coded compile-time constants"
574)]
575fn build_walk_types(capture_config: bool) -> ignore::types::Types {
576    let mut types_builder = ignore::types::TypesBuilder::new();
577    for ext in SOURCE_EXTENSIONS {
578        types_builder
579            .add("source", &format!("*.{ext}"))
580            .expect("valid glob");
581    }
582    types_builder.select("source");
583    if capture_config {
584        for glob in config_candidate_basename_globs() {
585            // Ignore individually-invalid plugin patterns rather than panicking;
586            // a malformed pattern simply fails to admit its config file (the
587            // pre-existing filesystem fallback still covers production mode).
588            let _ = types_builder.add("config", glob);
589        }
590        types_builder.select("config");
591    }
592    types_builder.build().expect("valid types")
593}
594
595/// Construct the parallel walker, applying the appropriate hidden-dir filter.
596/// When `capture_config` is set the walk also yields config-candidate files for
597/// the secondary collection channel.
598fn build_source_walk_builder(
599    config: &ResolvedConfig,
600    additional_hidden_dir_scopes: &[HiddenDirScope],
601    capture_config: bool,
602) -> WalkBuilder {
603    let mut walk_builder = WalkBuilder::new(&config.root);
604    walk_builder
605        .hidden(false)
606        .git_ignore(true)
607        .git_global(true)
608        .git_exclude(true)
609        .types(build_walk_types(capture_config))
610        .threads(config.threads);
611    if additional_hidden_dir_scopes.is_empty() {
612        walk_builder.filter_entry(is_allowed_hidden);
613    } else {
614        let scopes = additional_hidden_dir_scopes.to_vec();
615        walk_builder.filter_entry(move |entry| is_allowed_hidden_with_scopes(entry, &scopes));
616    }
617    walk_builder
618}
619
620/// Compile the production-mode exclude glob set, or `None` outside production mode.
621fn build_production_excludes(config: &ResolvedConfig) -> Option<globset::GlobSet> {
622    if !config.production {
623        return None;
624    }
625    let mut builder = globset::GlobSetBuilder::new();
626    for pattern in PRODUCTION_EXCLUDE_PATTERNS {
627        if let Ok(glob) = globset::GlobBuilder::new(pattern)
628            .literal_separator(true)
629            .build()
630        {
631            builder.add(glob);
632        }
633    }
634    builder.build().ok()
635}
636
637/// Discover all source files in the project, with package-scoped hidden dirs.
638///
639/// # Panics
640///
641/// Panics if the file type glob or progress template is invalid (compile-time constants).
642pub fn discover_files_with_additional_hidden_dirs(
643    config: &ResolvedConfig,
644    additional_hidden_dir_scopes: &[HiddenDirScope],
645) -> Vec<DiscoveredFile> {
646    discover_files_and_config_candidates(config, additional_hidden_dir_scopes).0
647}
648
649/// Discover source files AND, in one traversal, the non-source config-candidate
650/// files (`tsconfig.json`, `bunfig.toml`, `.eslintrc.json`, ...) used by
651/// `discover_config_files` to resolve plugin config patterns in-memory instead of
652/// re-walking the filesystem.
653///
654/// The returned `Vec<DiscoveredFile>` is byte-for-byte identical to the
655/// config-capture-disabled walk: config candidates are routed to the second
656/// return value by extension and never enter the source channel. Config capture
657/// is skipped in production mode (where the walk applies `PRODUCTION_EXCLUDE_PATTERNS`
658/// and `discover_config_files` keeps its filesystem path), so the second vector is
659/// empty there.
660///
661/// # Panics
662///
663/// Panics if the file type glob or progress template is invalid (compile-time constants).
664#[expect(
665    clippy::cast_possible_truncation,
666    reason = "file count is bounded by project size, well under u32::MAX"
667)]
668#[expect(clippy::expect_used, reason = "the collector lock must remain usable")]
669pub fn discover_files_and_config_candidates(
670    config: &ResolvedConfig,
671    additional_hidden_dir_scopes: &[HiddenDirScope],
672) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
673    let _span = tracing::info_span!("discover_files").entered();
674
675    let capture_config = !config.production;
676    let walk_builder =
677        build_source_walk_builder(config, additional_hidden_dir_scopes, capture_config);
678    let production_excludes = build_production_excludes(config);
679    let canonical_root = config.root.canonicalize().ok();
680
681    let collected: Mutex<Vec<(std::path::PathBuf, u64)>> = Mutex::new(Vec::new());
682    let config_collected: Mutex<Vec<std::path::PathBuf>> = Mutex::new(Vec::new());
683    let mut visitor_builder = FileVisitorBuilder {
684        root: &config.root,
685        canonical_root: canonical_root.as_deref(),
686        ignore_patterns: &config.ignore_patterns,
687        production_excludes: &production_excludes,
688        shared: &collected,
689        config_shared: capture_config.then_some(&config_collected),
690    };
691    walk_builder.build_parallel().visit(&mut visitor_builder);
692
693    let mut raw = collected
694        .into_inner()
695        .expect("walk collector lock poisoned");
696    // ADR-004 (path-sorted FileIds): the parallel walk visits files in
697    // nondeterministic order, so we sort by absolute path BEFORE the
698    // `.enumerate()` FileId assignment below. This is the stable-cross-run
699    // identity invariant the persisted graph cache depends on: an identical
700    // file set yields identical FileIds, so a cache hit (same paths +
701    // fingerprints) can trust graph data persisted by FileId. Do not replace
702    // this with insertion-order assignment.
703    raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
704
705    let mut config_candidates = config_collected
706        .into_inner()
707        .expect("walk config collector lock poisoned");
708    config_candidates.sort_unstable();
709
710    // Drop any source-discovery diagnostics from a previous pass (watch-mode
711    // rerun, combined-mode re-walk) BEFORE re-recording this walk's skips, so a
712    // file that is no longer skipped does not leave a stale entry (issue #1086).
713    fallow_config::clear_source_discovery_diagnostics(&config.root);
714    let (kept, skipped) = partition_by_size(raw, config.max_file_size_bytes);
715    report_skipped_large_files(config, &skipped);
716    let (kept, skipped_minified) =
717        partition_minified_generated_js(kept, config.max_file_size_bytes);
718    report_skipped_minified_files(config, &skipped_minified);
719
720    let files: Vec<DiscoveredFile> = kept
721        .into_iter()
722        .enumerate()
723        .map(|(idx, (path, size_bytes))| DiscoveredFile {
724            id: FileId(idx as u32),
725            path,
726            size_bytes,
727        })
728        .collect();
729
730    note_largest_files(config, &files);
731
732    (files, config_candidates)
733}
734
735#[cfg(test)]
736mod tests {
737    use std::ffi::OsStr;
738
739    use super::*;
740
741    /// Reproduce the FileId-assignment rule used by `walk_source_files`: sort by
742    /// absolute path, then assign `FileId(idx)` in that order.
743    fn assign_file_ids(mut raw: Vec<(std::path::PathBuf, u64)>) -> Vec<DiscoveredFile> {
744        raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
745        raw.into_iter()
746            .enumerate()
747            .map(|(idx, (path, size_bytes))| DiscoveredFile {
748                id: FileId(idx as u32),
749                path,
750                size_bytes,
751            })
752            .collect()
753    }
754
755    /// ADR-004: an identical file set must yield identical FileIds regardless of
756    /// the (nondeterministic, parallel) discovery order. The persisted graph
757    /// cache keys persisted graph data by FileId, so a cache HIT (same paths +
758    /// fingerprints) must reproduce the exact same FileId-to-path mapping the
759    /// graph was built against. This guards the cache's soundness prerequisite.
760    #[test]
761    fn file_id_assignment_is_deterministic_for_identical_file_set() {
762        let paths = [
763            "/project/src/z.ts",
764            "/project/src/a.ts",
765            "/project/src/components/Button.tsx",
766            "/project/src/components/Button.module.css",
767            "/project/index.ts",
768        ];
769
770        // Two independent walks that observe the same paths in DIFFERENT orders.
771        let walk_one: Vec<(std::path::PathBuf, u64)> = paths
772            .iter()
773            .map(|p| (std::path::PathBuf::from(p), 10))
774            .collect();
775        let mut walk_two = walk_one.clone();
776        walk_two.reverse();
777
778        let files_one = assign_file_ids(walk_one);
779        let files_two = assign_file_ids(walk_two);
780
781        // Identical (FileId -> path) mapping despite the different walk orders.
782        assert_eq!(files_one.len(), files_two.len());
783        for (a, b) in files_one.iter().zip(files_two.iter()) {
784            assert_eq!(a.id, b.id);
785            assert_eq!(a.path, b.path);
786        }
787
788        // The mapping is the path-sorted order, and each FileId equals its index
789        // (the density invariant `project.rs` asserts and the graph relies on).
790        for (idx, file) in files_one.iter().enumerate() {
791            assert_eq!(file.id, FileId(idx as u32));
792        }
793        assert_eq!(
794            files_one[0].path,
795            std::path::PathBuf::from("/project/index.ts")
796        );
797    }
798
799    #[test]
800    fn file_id_assignment_recomputes_after_rename_or_delete() {
801        let before = assign_file_ids(vec![
802            (std::path::PathBuf::from("/project/src/a.ts"), 10),
803            (std::path::PathBuf::from("/project/src/b.ts"), 10),
804            (std::path::PathBuf::from("/project/src/c.ts"), 10),
805        ]);
806        let after_delete = assign_file_ids(vec![
807            (std::path::PathBuf::from("/project/src/a.ts"), 10),
808            (std::path::PathBuf::from("/project/src/c.ts"), 10),
809        ]);
810        let after_rename = assign_file_ids(vec![
811            (std::path::PathBuf::from("/project/src/a.ts"), 10),
812            (std::path::PathBuf::from("/project/src/c.ts"), 10),
813            (std::path::PathBuf::from("/project/src/d.ts"), 10),
814        ]);
815
816        assert_eq!(before[0].id, FileId(0));
817        assert_eq!(before[1].id, FileId(1));
818        assert_eq!(before[2].id, FileId(2));
819        assert_eq!(after_delete[0].id, FileId(0));
820        assert_eq!(after_delete[1].id, FileId(1));
821        assert_eq!(
822            after_delete[1].path,
823            std::path::PathBuf::from("/project/src/c.ts")
824        );
825        assert_eq!(after_rename[0].id, FileId(0));
826        assert_eq!(after_rename[1].id, FileId(1));
827        assert_eq!(
828            after_rename[1].path,
829            std::path::PathBuf::from("/project/src/c.ts")
830        );
831        assert_eq!(after_rename[2].id, FileId(2));
832        assert_eq!(
833            after_rename[2].path,
834            std::path::PathBuf::from("/project/src/d.ts")
835        );
836    }
837
838    #[test]
839    fn allowed_hidden_dirs() {
840        assert!(is_allowed_hidden_dir(OsStr::new(".storybook")));
841        assert!(is_allowed_hidden_dir(OsStr::new(".vitepress")));
842        assert!(is_allowed_hidden_dir(OsStr::new(".well-known")));
843        assert!(is_allowed_hidden_dir(OsStr::new(".changeset")));
844        assert!(is_allowed_hidden_dir(OsStr::new(".github")));
845    }
846
847    #[test]
848    fn disallowed_hidden_dirs() {
849        assert!(!is_allowed_hidden_dir(OsStr::new(".git")));
850        assert!(!is_allowed_hidden_dir(OsStr::new(".cache")));
851        assert!(!is_allowed_hidden_dir(OsStr::new(".vscode")));
852        assert!(!is_allowed_hidden_dir(OsStr::new(".fallow")));
853        assert!(!is_allowed_hidden_dir(OsStr::new(".next")));
854    }
855
856    #[test]
857    fn non_hidden_dirs_not_in_allowlist() {
858        assert!(!is_allowed_hidden_dir(OsStr::new("src")));
859        assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
860    }
861
862    #[test]
863    fn source_extensions_include_typescript() {
864        assert!(SOURCE_EXTENSIONS.contains(&"ts"));
865        assert!(SOURCE_EXTENSIONS.contains(&"tsx"));
866        assert!(SOURCE_EXTENSIONS.contains(&"mts"));
867        assert!(SOURCE_EXTENSIONS.contains(&"cts"));
868        assert!(SOURCE_EXTENSIONS.contains(&"gts"));
869    }
870
871    #[test]
872    fn source_extensions_include_javascript() {
873        assert!(SOURCE_EXTENSIONS.contains(&"js"));
874        assert!(SOURCE_EXTENSIONS.contains(&"jsx"));
875        assert!(SOURCE_EXTENSIONS.contains(&"mjs"));
876        assert!(SOURCE_EXTENSIONS.contains(&"cjs"));
877        assert!(SOURCE_EXTENSIONS.contains(&"gjs"));
878    }
879
880    #[test]
881    fn source_extensions_include_sfc_formats() {
882        assert!(SOURCE_EXTENSIONS.contains(&"vue"));
883        assert!(SOURCE_EXTENSIONS.contains(&"svelte"));
884        assert!(SOURCE_EXTENSIONS.contains(&"astro"));
885    }
886
887    #[test]
888    fn source_extensions_include_styles() {
889        assert!(SOURCE_EXTENSIONS.contains(&"css"));
890        assert!(SOURCE_EXTENSIONS.contains(&"scss"));
891        assert!(SOURCE_EXTENSIONS.contains(&"sass"));
892        assert!(SOURCE_EXTENSIONS.contains(&"less"));
893    }
894
895    #[test]
896    fn source_extensions_exclude_non_source() {
897        assert!(!SOURCE_EXTENSIONS.contains(&"json"));
898        assert!(!SOURCE_EXTENSIONS.contains(&"yaml"));
899        assert!(!SOURCE_EXTENSIONS.contains(&"md"));
900        assert!(!SOURCE_EXTENSIONS.contains(&"png"));
901        assert!(!SOURCE_EXTENSIONS.contains(&"htm"));
902    }
903
904    #[test]
905    fn source_extensions_include_html() {
906        assert!(SOURCE_EXTENSIONS.contains(&"html"));
907    }
908
909    #[test]
910    fn source_extensions_include_graphql_documents() {
911        assert!(SOURCE_EXTENSIONS.contains(&"graphql"));
912        assert!(SOURCE_EXTENSIONS.contains(&"gql"));
913    }
914
915    fn build_production_glob_set() -> globset::GlobSet {
916        let mut builder = globset::GlobSetBuilder::new();
917        for pattern in PRODUCTION_EXCLUDE_PATTERNS {
918            builder.add(
919                globset::GlobBuilder::new(pattern)
920                    .literal_separator(true)
921                    .build()
922                    .expect("valid glob pattern"),
923            );
924        }
925        builder.build().expect("valid glob set")
926    }
927
928    #[test]
929    fn production_excludes_test_files() {
930        let set = build_production_glob_set();
931        assert!(set.is_match("src/Button.test.ts"));
932        assert!(set.is_match("src/utils.spec.tsx"));
933        assert!(set.is_match("src/__tests__/helper.ts"));
934        assert!(!set.is_match("src/Button.ts"));
935        assert!(!set.is_match("src/utils.tsx"));
936    }
937
938    #[test]
939    fn production_excludes_story_files() {
940        let set = build_production_glob_set();
941        assert!(set.is_match("src/Button.stories.tsx"));
942        assert!(set.is_match("src/Card.story.ts"));
943        assert!(!set.is_match("src/Button.tsx"));
944    }
945
946    #[test]
947    fn production_excludes_config_files_at_root_only() {
948        let set = build_production_glob_set();
949        assert!(set.is_match("vitest.config.ts"));
950        assert!(set.is_match("jest.config.js"));
951        assert!(!set.is_match("src/app/app.config.ts"));
952        assert!(!set.is_match("src/app/app.config.server.ts"));
953        assert!(!set.is_match("packages/foo/vitest.config.ts"));
954        assert!(!set.is_match("src/config.ts"));
955    }
956
957    #[test]
958    fn production_patterns_are_valid_globs() {
959        let _ = build_production_glob_set();
960    }
961
962    #[test]
963    fn disallowed_hidden_dirs_idea() {
964        assert!(!is_allowed_hidden_dir(OsStr::new(".idea")));
965    }
966
967    #[test]
968    fn source_extensions_include_mdx() {
969        assert!(SOURCE_EXTENSIONS.contains(&"mdx"));
970    }
971
972    #[test]
973    fn source_extensions_exclude_image_and_data_formats() {
974        assert!(!SOURCE_EXTENSIONS.contains(&"png"));
975        assert!(!SOURCE_EXTENSIONS.contains(&"jpg"));
976        assert!(!SOURCE_EXTENSIONS.contains(&"svg"));
977        assert!(!SOURCE_EXTENSIONS.contains(&"txt"));
978        assert!(!SOURCE_EXTENSIONS.contains(&"csv"));
979        assert!(!SOURCE_EXTENSIONS.contains(&"wasm"));
980    }
981
982    #[test]
983    fn is_declaration_file_matches_dts_variants() {
984        assert!(is_declaration_file(Path::new("env.d.ts")));
985        assert!(is_declaration_file(Path::new("src/auto-imports.d.ts")));
986        assert!(is_declaration_file(Path::new("mod.d.mts")));
987        assert!(is_declaration_file(Path::new("compat.d.cts")));
988        assert!(!is_declaration_file(Path::new("index.ts")));
989        assert!(!is_declaration_file(Path::new("component.tsx")));
990        assert!(!is_declaration_file(Path::new("notes.d.txt")));
991    }
992
993    #[test]
994    fn format_size_mb_renders_one_decimal() {
995        assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
996        assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
997        assert_eq!(format_size_mb(0), "0.0 MB");
998    }
999
1000    #[test]
1001    fn partition_by_size_no_limit_keeps_all() {
1002        let raw = vec![(PathBuf::from("a.ts"), 10), (PathBuf::from("b.ts"), 10_000)];
1003        let (kept, skipped) = partition_by_size(raw, None);
1004        assert_eq!(kept.len(), 2);
1005        assert!(skipped.is_empty());
1006    }
1007
1008    #[test]
1009    fn partition_by_size_skips_strictly_over_limit() {
1010        let raw = vec![
1011            (PathBuf::from("under.ts"), 99),
1012            (PathBuf::from("exact.ts"), 100),
1013            (PathBuf::from("over.ts"), 101),
1014        ];
1015        let (kept, skipped) = partition_by_size(raw, Some(100));
1016        let kept_has = |name: &str| kept.iter().any(|(p, _)| p.as_path() == Path::new(name));
1017        assert!(kept_has("under.ts"));
1018        assert!(
1019            kept_has("exact.ts"),
1020            "a file exactly at the limit is kept (skip is strictly-greater)"
1021        );
1022        assert_eq!(skipped.len(), 1);
1023        assert_eq!(skipped[0].0, PathBuf::from("over.ts"));
1024    }
1025
1026    #[test]
1027    fn partition_by_size_exempts_declaration_files() {
1028        let raw = vec![
1029            (PathBuf::from("huge.ts"), 10_000),
1030            (PathBuf::from("auto-imports.d.ts"), 10_000),
1031        ];
1032        let (kept, skipped) = partition_by_size(raw, Some(100));
1033        assert!(
1034            kept.iter()
1035                .any(|(p, _)| p.as_path() == Path::new("auto-imports.d.ts")),
1036            "declaration files are exempt from the size skip regardless of size"
1037        );
1038        assert_eq!(skipped.len(), 1);
1039        assert_eq!(skipped[0].0, PathBuf::from("huge.ts"));
1040    }
1041
1042    fn disco(path: &str, size_bytes: u64) -> DiscoveredFile {
1043        DiscoveredFile {
1044            id: FileId(0),
1045            path: PathBuf::from(path),
1046            size_bytes,
1047        }
1048    }
1049
1050    #[test]
1051    fn largest_files_note_below_threshold_is_none() {
1052        let files = [disco("a.ts", 100), disco("b.ts", 200)];
1053        assert!(build_largest_files_note(Path::new("/p"), &files).is_none());
1054    }
1055
1056    #[test]
1057    fn largest_files_note_single_file_uses_singular() {
1058        let files = [disco("big.ts", 5 * 1024 * 1024)];
1059        let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
1060        assert!(
1061            note.contains("discovered 1 file;"),
1062            "singular noun on the single-big-file path (issue #1086 regression): {note}"
1063        );
1064        assert!(!note.contains("discovered 1 files"));
1065        assert!(note.contains("big.ts (5.0 MB)"));
1066    }
1067
1068    #[test]
1069    fn largest_files_note_filters_sub_floor_files() {
1070        let files = [disco("big.ts", 5 * 1024 * 1024), disco("tiny.ts", 10)];
1071        let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
1072        assert!(note.contains("discovered 2 files;"));
1073        assert!(note.contains("big.ts (5.0 MB)"));
1074        assert!(
1075            !note.contains("tiny.ts"),
1076            "sub-floor files are not listed as `0.0 MB` chaff: {note}"
1077        );
1078    }
1079
1080    #[test]
1081    fn largest_files_note_large_set_no_big_file_omits_list() {
1082        let files: Vec<DiscoveredFile> = (0..=LARGE_SET_THRESHOLD)
1083            .map(|i| disco(&format!("f{i}.ts"), 100))
1084            .collect();
1085        let note = build_largest_files_note(Path::new("/p"), &files).expect("large set fires");
1086        assert!(note.contains(&format!("discovered {} files", LARGE_SET_THRESHOLD + 1)));
1087        assert!(
1088            !note.contains("largest:"),
1089            "no sub-floor `largest:` list when no file clears the floor: {note}"
1090        );
1091    }
1092
1093    mod discover_files_integration {
1094        use std::path::PathBuf;
1095
1096        use fallow_config::{
1097            DuplicatesConfig, FallowConfig, FlagsConfig, HealthConfig, OutputFormat, ResolveConfig,
1098            RulesConfig,
1099        };
1100
1101        use super::*;
1102
1103        /// Create a minimal ResolvedConfig pointing at the given root directory.
1104        fn make_config(root: PathBuf, production: bool) -> ResolvedConfig {
1105            FallowConfig {
1106                production: production.into(),
1107                ..Default::default()
1108            }
1109            .resolve(root, OutputFormat::Human, 1, true, true, None)
1110        }
1111
1112        /// Helper to collect discovered file names (relative to root) for assertions.
1113        /// Normalizes path separators to `/` for cross-platform test consistency.
1114        fn file_names(files: &[DiscoveredFile], root: &std::path::Path) -> Vec<String> {
1115            files
1116                .iter()
1117                .map(|f| {
1118                    f.path
1119                        .strip_prefix(root)
1120                        .unwrap_or(&f.path)
1121                        .to_string_lossy()
1122                        .replace('\\', "/")
1123                })
1124                .collect()
1125        }
1126
1127        #[cfg(unix)]
1128        fn symlink_file(target: &Path, link: &Path) {
1129            std::os::unix::fs::symlink(target, link).expect("create file symlink");
1130        }
1131
1132        #[cfg(windows)]
1133        fn symlink_file(target: &Path, link: &Path) {
1134            std::os::windows::fs::symlink_file(target, link).expect("create file symlink");
1135        }
1136
1137        #[cfg(unix)]
1138        fn symlink_dir(target: &Path, link: &Path) {
1139            std::os::unix::fs::symlink(target, link).expect("create directory symlink");
1140        }
1141
1142        #[cfg(windows)]
1143        fn symlink_dir(target: &Path, link: &Path) {
1144            std::os::windows::fs::symlink_dir(target, link).expect("create directory symlink");
1145        }
1146
1147        #[test]
1148        fn source_symlinks_must_target_regular_files_inside_root() {
1149            let dir = tempfile::tempdir().expect("create project");
1150            let outside = tempfile::tempdir().expect("create outside dir");
1151            let src = dir.path().join("src");
1152            std::fs::create_dir_all(&src).unwrap();
1153            std::fs::write(src.join("regular.ts"), "export const regular = 1;").unwrap();
1154            std::fs::write(src.join("inside-target.ts"), "export const inside = 1;").unwrap();
1155            std::fs::write(
1156                outside.path().join("outside-target.ts"),
1157                "export const outside = 1;",
1158            )
1159            .unwrap();
1160            std::fs::create_dir_all(src.join("directory-target")).unwrap();
1161
1162            symlink_file(&src.join("inside-target.ts"), &src.join("inside-link.ts"));
1163            symlink_file(
1164                &outside.path().join("outside-target.ts"),
1165                &src.join("outside-link.ts"),
1166            );
1167            symlink_file(&src.join("missing-target.ts"), &src.join("broken-link.ts"));
1168            symlink_dir(
1169                &src.join("directory-target"),
1170                &src.join("directory-link.ts"),
1171            );
1172
1173            let config = make_config(dir.path().to_path_buf(), false);
1174            let names = file_names(&discover_files(&config), dir.path());
1175
1176            assert!(names.contains(&"src/regular.ts".to_string()));
1177            assert!(names.contains(&"src/inside-target.ts".to_string()));
1178            assert!(names.contains(&"src/inside-link.ts".to_string()));
1179            assert!(!names.contains(&"src/outside-link.ts".to_string()));
1180            assert!(!names.contains(&"src/broken-link.ts".to_string()));
1181            assert!(!names.contains(&"src/directory-link.ts".to_string()));
1182        }
1183
1184        #[test]
1185        fn discovers_source_files_with_valid_extensions() {
1186            let dir = tempfile::tempdir().expect("create temp dir");
1187            let src = dir.path().join("src");
1188            std::fs::create_dir_all(&src).unwrap();
1189
1190            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1191            std::fs::write(src.join("component.tsx"), "export default () => {};").unwrap();
1192            std::fs::write(src.join("utils.js"), "module.exports = {};").unwrap();
1193            std::fs::write(src.join("helper.jsx"), "export const h = 1;").unwrap();
1194            std::fs::write(src.join("config.mjs"), "export default {};").unwrap();
1195            std::fs::write(src.join("legacy.cjs"), "module.exports = {};").unwrap();
1196            std::fs::write(src.join("types.mts"), "export type T = string;").unwrap();
1197            std::fs::write(src.join("compat.cts"), "module.exports = {};").unwrap();
1198
1199            let config = make_config(dir.path().to_path_buf(), false);
1200            let files = discover_files(&config);
1201            let names = file_names(&files, dir.path());
1202
1203            assert!(names.contains(&"src/app.ts".to_string()));
1204            assert!(names.contains(&"src/component.tsx".to_string()));
1205            assert!(names.contains(&"src/utils.js".to_string()));
1206            assert!(names.contains(&"src/helper.jsx".to_string()));
1207            assert!(names.contains(&"src/config.mjs".to_string()));
1208            assert!(names.contains(&"src/legacy.cjs".to_string()));
1209            assert!(names.contains(&"src/types.mts".to_string()));
1210            assert!(names.contains(&"src/compat.cts".to_string()));
1211        }
1212
1213        #[test]
1214        fn excludes_non_source_extensions() {
1215            let dir = tempfile::tempdir().expect("create temp dir");
1216            let src = dir.path().join("src");
1217            std::fs::create_dir_all(&src).unwrap();
1218
1219            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1220
1221            std::fs::write(src.join("data.json"), "{}").unwrap();
1222            std::fs::write(src.join("readme.md"), "# Hello").unwrap();
1223            std::fs::write(src.join("notes.txt"), "notes").unwrap();
1224            std::fs::write(src.join("logo.png"), [0u8; 8]).unwrap();
1225
1226            let config = make_config(dir.path().to_path_buf(), false);
1227            let files = discover_files(&config);
1228            let names = file_names(&files, dir.path());
1229
1230            assert_eq!(names.len(), 1, "only the .ts file should be discovered");
1231            assert!(names.contains(&"src/app.ts".to_string()));
1232        }
1233
1234        #[test]
1235        fn excludes_disallowed_hidden_directories() {
1236            let dir = tempfile::tempdir().expect("create temp dir");
1237
1238            let git_dir = dir.path().join(".git");
1239            std::fs::create_dir_all(&git_dir).unwrap();
1240            std::fs::write(git_dir.join("hooks.ts"), "// git hook").unwrap();
1241
1242            let idea_dir = dir.path().join(".idea");
1243            std::fs::create_dir_all(&idea_dir).unwrap();
1244            std::fs::write(idea_dir.join("workspace.ts"), "// idea").unwrap();
1245
1246            let cache_dir = dir.path().join(".cache");
1247            std::fs::create_dir_all(&cache_dir).unwrap();
1248            std::fs::write(cache_dir.join("cached.js"), "// cached").unwrap();
1249
1250            let src = dir.path().join("src");
1251            std::fs::create_dir_all(&src).unwrap();
1252            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1253
1254            let config = make_config(dir.path().to_path_buf(), false);
1255            let files = discover_files(&config);
1256            let names = file_names(&files, dir.path());
1257
1258            assert_eq!(names.len(), 1, "only src/app.ts should be discovered");
1259            assert!(names.contains(&"src/app.ts".to_string()));
1260        }
1261
1262        #[test]
1263        fn includes_allowed_hidden_directories() {
1264            let dir = tempfile::tempdir().expect("create temp dir");
1265
1266            let storybook = dir.path().join(".storybook");
1267            std::fs::create_dir_all(&storybook).unwrap();
1268            std::fs::write(storybook.join("main.ts"), "export default {};").unwrap();
1269
1270            let github = dir.path().join(".github");
1271            std::fs::create_dir_all(&github).unwrap();
1272            std::fs::write(github.join("actions.js"), "module.exports = {};").unwrap();
1273
1274            let changeset = dir.path().join(".changeset");
1275            std::fs::create_dir_all(&changeset).unwrap();
1276            std::fs::write(changeset.join("config.js"), "module.exports = {};").unwrap();
1277
1278            let config = make_config(dir.path().to_path_buf(), false);
1279            let files = discover_files(&config);
1280            let names = file_names(&files, dir.path());
1281
1282            assert!(
1283                names.contains(&".storybook/main.ts".to_string()),
1284                "files in .storybook should be discovered"
1285            );
1286            assert!(
1287                names.contains(&".github/actions.js".to_string()),
1288                "files in .github should be discovered"
1289            );
1290            assert!(
1291                names.contains(&".changeset/config.js".to_string()),
1292                "files in .changeset should be discovered"
1293            );
1294        }
1295
1296        #[test]
1297        fn default_discovery_excludes_client_and_server_hidden_directories() {
1298            let dir = tempfile::tempdir().expect("create temp dir");
1299            let app = dir.path().join("app");
1300            std::fs::create_dir_all(app.join(".client")).unwrap();
1301            std::fs::create_dir_all(app.join(".server")).unwrap();
1302            std::fs::write(app.join(".client/analytics.ts"), "export const a = 1;").unwrap();
1303            std::fs::write(app.join(".server/db.ts"), "export const db = {};").unwrap();
1304            std::fs::write(app.join("root.tsx"), "export default function Root() {}").unwrap();
1305
1306            let config = make_config(dir.path().to_path_buf(), false);
1307            let files = discover_files(&config);
1308            let names = file_names(&files, dir.path());
1309
1310            assert!(names.contains(&"app/root.tsx".to_string()));
1311            assert!(!names.contains(&"app/.client/analytics.ts".to_string()));
1312            assert!(!names.contains(&"app/.server/db.ts".to_string()));
1313        }
1314
1315        #[test]
1316        fn scoped_hidden_dirs_include_client_and_server_under_package_root() {
1317            let dir = tempfile::tempdir().expect("create temp dir");
1318            let package = dir.path().join("packages/app");
1319            std::fs::create_dir_all(package.join("app/.client")).unwrap();
1320            std::fs::create_dir_all(package.join("app/.server")).unwrap();
1321            std::fs::write(
1322                package.join("app/.client/analytics.ts"),
1323                "export const track = () => {};",
1324            )
1325            .unwrap();
1326            std::fs::write(package.join("app/.server/db.ts"), "export const db = {};").unwrap();
1327
1328            let config = make_config(dir.path().to_path_buf(), false);
1329            let scopes = [HiddenDirScope::new(
1330                package,
1331                vec![".client".to_string(), ".server".to_string()],
1332            )];
1333            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
1334            let names = file_names(&files, dir.path());
1335
1336            assert!(names.contains(&"packages/app/app/.client/analytics.ts".to_string()));
1337            assert!(names.contains(&"packages/app/app/.server/db.ts".to_string()));
1338        }
1339
1340        #[test]
1341        fn scoped_hidden_dirs_do_not_include_unscoped_packages() {
1342            let dir = tempfile::tempdir().expect("create temp dir");
1343            let active = dir.path().join("packages/active");
1344            let inactive = dir.path().join("packages/inactive");
1345            std::fs::create_dir_all(active.join("app/.server")).unwrap();
1346            std::fs::create_dir_all(inactive.join("app/.server")).unwrap();
1347            std::fs::write(active.join("app/.server/db.ts"), "export const db = {};").unwrap();
1348            std::fs::write(inactive.join("app/.server/db.ts"), "export const db = {};").unwrap();
1349
1350            let config = make_config(dir.path().to_path_buf(), false);
1351            let scopes = [HiddenDirScope::new(active, vec![".server".to_string()])];
1352            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
1353            let names = file_names(&files, dir.path());
1354
1355            assert!(names.contains(&"packages/active/app/.server/db.ts".to_string()));
1356            assert!(!names.contains(&"packages/inactive/app/.server/db.ts".to_string()));
1357        }
1358
1359        #[test]
1360        fn excludes_root_build_directory() {
1361            let dir = tempfile::tempdir().expect("create temp dir");
1362
1363            std::fs::write(dir.path().join(".ignore"), "/build/\n").unwrap();
1364
1365            let build_dir = dir.path().join("build");
1366            std::fs::create_dir_all(&build_dir).unwrap();
1367            std::fs::write(build_dir.join("output.js"), "// build output").unwrap();
1368
1369            let src = dir.path().join("src");
1370            std::fs::create_dir_all(&src).unwrap();
1371            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1372
1373            let config = make_config(dir.path().to_path_buf(), false);
1374            let files = discover_files(&config);
1375            let names = file_names(&files, dir.path());
1376
1377            assert_eq!(names.len(), 1, "root build/ should be excluded via .ignore");
1378            assert!(names.contains(&"src/app.ts".to_string()));
1379        }
1380
1381        #[test]
1382        fn includes_nested_build_directory() {
1383            let dir = tempfile::tempdir().expect("create temp dir");
1384
1385            let nested_build = dir.path().join("src").join("build");
1386            std::fs::create_dir_all(&nested_build).unwrap();
1387            std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
1388
1389            let config = make_config(dir.path().to_path_buf(), false);
1390            let files = discover_files(&config);
1391            let names = file_names(&files, dir.path());
1392
1393            assert!(
1394                names.contains(&"src/build/helper.ts".to_string()),
1395                "nested build/ directories should be included"
1396            );
1397        }
1398
1399        #[test]
1400        #[expect(
1401            clippy::cast_possible_truncation,
1402            reason = "test file counts are trivially small"
1403        )]
1404        fn file_ids_are_sequential_after_sorting() {
1405            let dir = tempfile::tempdir().expect("create temp dir");
1406            let src = dir.path().join("src");
1407            std::fs::create_dir_all(&src).unwrap();
1408
1409            std::fs::write(src.join("z_last.ts"), "export const z = 1;").unwrap();
1410            std::fs::write(src.join("a_first.ts"), "export const a = 1;").unwrap();
1411            std::fs::write(src.join("m_middle.ts"), "export const m = 1;").unwrap();
1412
1413            let config = make_config(dir.path().to_path_buf(), false);
1414            let files = discover_files(&config);
1415
1416            for (idx, file) in files.iter().enumerate() {
1417                assert_eq!(file.id, FileId(idx as u32), "FileId should be sequential");
1418            }
1419
1420            for pair in files.windows(2) {
1421                assert!(
1422                    pair[0].path < pair[1].path,
1423                    "files should be sorted by path"
1424                );
1425            }
1426        }
1427
1428        #[test]
1429        fn production_mode_excludes_test_files() {
1430            let dir = tempfile::tempdir().expect("create temp dir");
1431            let src = dir.path().join("src");
1432            std::fs::create_dir_all(&src).unwrap();
1433
1434            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1435            std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
1436            std::fs::write(src.join("app.spec.ts"), "describe('a', () => {});").unwrap();
1437            std::fs::write(src.join("app.stories.tsx"), "export default {};").unwrap();
1438
1439            let config = make_config(dir.path().to_path_buf(), true);
1440            let files = discover_files(&config);
1441            let names = file_names(&files, dir.path());
1442
1443            assert!(
1444                names.contains(&"src/app.ts".to_string()),
1445                "source files should be included in production mode"
1446            );
1447            assert!(
1448                !names.contains(&"src/app.test.ts".to_string()),
1449                "test files should be excluded in production mode"
1450            );
1451            assert!(
1452                !names.contains(&"src/app.spec.ts".to_string()),
1453                "spec files should be excluded in production mode"
1454            );
1455            assert!(
1456                !names.contains(&"src/app.stories.tsx".to_string()),
1457                "story files should be excluded in production mode"
1458            );
1459        }
1460
1461        #[test]
1462        fn non_production_mode_includes_test_files() {
1463            let dir = tempfile::tempdir().expect("create temp dir");
1464            let src = dir.path().join("src");
1465            std::fs::create_dir_all(&src).unwrap();
1466
1467            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1468            std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
1469
1470            let config = make_config(dir.path().to_path_buf(), false);
1471            let files = discover_files(&config);
1472            let names = file_names(&files, dir.path());
1473
1474            assert!(names.contains(&"src/app.ts".to_string()));
1475            assert!(
1476                names.contains(&"src/app.test.ts".to_string()),
1477                "test files should be included in non-production mode"
1478            );
1479        }
1480
1481        #[test]
1482        fn empty_directory_returns_no_files() {
1483            let dir = tempfile::tempdir().expect("create temp dir");
1484            let config = make_config(dir.path().to_path_buf(), false);
1485            let files = discover_files(&config);
1486            assert!(files.is_empty(), "empty project should discover no files");
1487        }
1488
1489        #[test]
1490        fn hidden_files_not_discovered_as_source() {
1491            let dir = tempfile::tempdir().expect("create temp dir");
1492
1493            std::fs::write(dir.path().join(".env"), "SECRET=abc").unwrap();
1494            std::fs::write(dir.path().join(".gitignore"), "node_modules").unwrap();
1495            std::fs::write(dir.path().join(".eslintrc.js"), "module.exports = {};").unwrap();
1496
1497            let src = dir.path().join("src");
1498            std::fs::create_dir_all(&src).unwrap();
1499            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1500
1501            let config = make_config(dir.path().to_path_buf(), false);
1502            let files = discover_files(&config);
1503            let names = file_names(&files, dir.path());
1504
1505            assert!(
1506                !names.contains(&".env".to_string()),
1507                ".env should not be discovered"
1508            );
1509            assert!(
1510                !names.contains(&".gitignore".to_string()),
1511                ".gitignore should not be discovered"
1512            );
1513        }
1514
1515        /// Create a config with custom ignore patterns.
1516        fn make_config_with_ignores(root: PathBuf, ignores: Vec<String>) -> ResolvedConfig {
1517            FallowConfig {
1518                schema: None,
1519                extends: vec![],
1520                entry: vec![],
1521                ignore_patterns: ignores,
1522                framework: vec![],
1523                workspaces: None,
1524                ignore_dependencies: vec![],
1525                ignore_unresolved_imports: vec![],
1526                ignore_exports: vec![],
1527                ignore_catalog_references: vec![],
1528                ignore_dependency_overrides: vec![],
1529                ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(
1530                ),
1531                used_class_members: vec![],
1532                ignore_decorators: vec![],
1533                unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1534                duplicates: DuplicatesConfig::default(),
1535                health: HealthConfig::default(),
1536                rules: RulesConfig::default(),
1537                boundaries: fallow_config::BoundaryConfig::default(),
1538                production: false.into(),
1539                plugins: vec![],
1540                rule_packs: vec![],
1541                dynamically_loaded: vec![],
1542                overrides: vec![],
1543                regression: None,
1544                audit: fallow_config::AuditConfig::default(),
1545                codeowners: None,
1546                public_packages: vec![],
1547                flags: FlagsConfig::default(),
1548                security: fallow_config::SecurityConfig::default(),
1549                fix: fallow_config::FixConfig::default(),
1550                resolve: ResolveConfig::default(),
1551                sealed: false,
1552                include_entry_exports: false,
1553                auto_imports: false,
1554                cache: fallow_config::CacheConfig::default(),
1555            }
1556            .resolve(root, OutputFormat::Human, 1, true, true, None)
1557        }
1558
1559        #[test]
1560        fn custom_ignore_patterns_exclude_matching_files() {
1561            let dir = tempfile::tempdir().expect("create temp dir");
1562
1563            let generated = dir.path().join("src").join("api").join("generated");
1564            std::fs::create_dir_all(&generated).unwrap();
1565            std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
1566
1567            let client = dir.path().join("src").join("api").join("client");
1568            std::fs::create_dir_all(&client).unwrap();
1569            std::fs::write(client.join("fetch.ts"), "export const fetch = {};").unwrap();
1570
1571            let src = dir.path().join("src");
1572            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1573
1574            let config = make_config_with_ignores(
1575                dir.path().to_path_buf(),
1576                vec![
1577                    "src/api/generated/**".to_string(),
1578                    "src/api/client/**".to_string(),
1579                ],
1580            );
1581            let files = discover_files(&config);
1582            let names = file_names(&files, dir.path());
1583
1584            assert_eq!(names.len(), 1, "only non-ignored files: {names:?}");
1585            assert!(names.contains(&"src/index.ts".to_string()));
1586        }
1587
1588        #[test]
1589        fn leading_dot_ignore_patterns_exclude_matching_files() {
1590            let dir = tempfile::tempdir().expect("create temp dir");
1591
1592            let generated = dir.path().join("src").join("generated");
1593            std::fs::create_dir_all(&generated).unwrap();
1594            std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
1595
1596            let src = dir.path().join("src");
1597            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1598
1599            let config = make_config_with_ignores(
1600                dir.path().to_path_buf(),
1601                vec!["./src/generated/**".to_string()],
1602            );
1603            let files = discover_files(&config);
1604            let names = file_names(&files, dir.path());
1605
1606            assert_eq!(names, vec!["src/index.ts"]);
1607        }
1608
1609        #[test]
1610        fn default_ignore_patterns_exclude_node_modules_and_dist() {
1611            let dir = tempfile::tempdir().expect("create temp dir");
1612
1613            let nm = dir.path().join("node_modules").join("lodash");
1614            std::fs::create_dir_all(&nm).unwrap();
1615            std::fs::write(nm.join("lodash.js"), "module.exports = {};").unwrap();
1616
1617            let dist = dir.path().join("dist");
1618            std::fs::create_dir_all(&dist).unwrap();
1619            std::fs::write(dist.join("bundle.js"), "// bundled").unwrap();
1620
1621            let src = dir.path().join("src");
1622            std::fs::create_dir_all(&src).unwrap();
1623            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1624
1625            let config = make_config(dir.path().to_path_buf(), false);
1626            let files = discover_files(&config);
1627            let names = file_names(&files, dir.path());
1628
1629            assert_eq!(names.len(), 1);
1630            assert!(names.contains(&"src/index.ts".to_string()));
1631        }
1632
1633        #[test]
1634        fn default_ignore_patterns_exclude_root_build() {
1635            let dir = tempfile::tempdir().expect("create temp dir");
1636
1637            let build = dir.path().join("build");
1638            std::fs::create_dir_all(&build).unwrap();
1639            std::fs::write(build.join("output.js"), "// built").unwrap();
1640
1641            let nested_build = dir.path().join("src").join("build");
1642            std::fs::create_dir_all(&nested_build).unwrap();
1643            std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
1644
1645            let src = dir.path().join("src");
1646            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1647
1648            let config = make_config(dir.path().to_path_buf(), false);
1649            let files = discover_files(&config);
1650            let names = file_names(&files, dir.path());
1651
1652            assert_eq!(
1653                names.len(),
1654                2,
1655                "root build/ excluded, nested kept: {names:?}"
1656            );
1657            assert!(names.contains(&"src/index.ts".to_string()));
1658            assert!(names.contains(&"src/build/helper.ts".to_string()));
1659        }
1660
1661        /// Resolve a config then override the per-file size limit in bytes.
1662        fn make_config_with_max_file_size(
1663            root: PathBuf,
1664            max_file_size_bytes: Option<u64>,
1665        ) -> ResolvedConfig {
1666            let mut config = make_config(root, false);
1667            config.max_file_size_bytes = max_file_size_bytes;
1668            config
1669        }
1670
1671        #[test]
1672        fn skips_files_over_max_file_size() {
1673            let dir = tempfile::tempdir().expect("create temp dir");
1674            let src = dir.path().join("src");
1675            std::fs::create_dir_all(&src).unwrap();
1676            std::fs::write(src.join("small.ts"), "export const a = 1;").unwrap();
1677            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1678
1679            let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1680            let files = discover_files(&config);
1681            let names = file_names(&files, dir.path());
1682
1683            assert!(names.contains(&"src/small.ts".to_string()));
1684            assert!(
1685                !names.contains(&"src/huge.ts".to_string()),
1686                "a file over the size limit must not be discovered"
1687            );
1688        }
1689
1690        #[test]
1691        fn declaration_files_exempt_from_size_skip() {
1692            let dir = tempfile::tempdir().expect("create temp dir");
1693            let src = dir.path().join("src");
1694            std::fs::create_dir_all(&src).unwrap();
1695            std::fs::write(src.join("auto-imports.d.ts"), "x".repeat(5_000)).unwrap();
1696            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1697
1698            let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1699            let files = discover_files(&config);
1700            let names = file_names(&files, dir.path());
1701
1702            assert!(
1703                names.contains(&"src/auto-imports.d.ts".to_string()),
1704                "a large .d.ts is exempt from the skip (reachability root for global types)"
1705            );
1706            assert!(!names.contains(&"src/huge.ts".to_string()));
1707        }
1708
1709        #[test]
1710        fn unlimited_size_keeps_large_files() {
1711            let dir = tempfile::tempdir().expect("create temp dir");
1712            let src = dir.path().join("src");
1713            std::fs::create_dir_all(&src).unwrap();
1714            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1715
1716            let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
1717            let files = discover_files(&config);
1718            let names = file_names(&files, dir.path());
1719
1720            assert!(
1721                names.contains(&"src/huge.ts".to_string()),
1722                "no limit keeps every file"
1723            );
1724        }
1725
1726        #[test]
1727        fn skipped_file_recorded_in_workspace_diagnostics() {
1728            let dir = tempfile::tempdir().expect("create temp dir");
1729            let src = dir.path().join("src");
1730            std::fs::create_dir_all(&src).unwrap();
1731            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1732
1733            let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1734            let _ = discover_files(&config);
1735
1736            let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1737            let skipped: Vec<_> = diagnostics
1738                .iter()
1739                .filter(|d| {
1740                    matches!(
1741                        d.kind,
1742                        fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { .. }
1743                    )
1744                })
1745                .collect();
1746            assert_eq!(
1747                skipped.len(),
1748                1,
1749                "the skipped file is recorded in workspace diagnostics for JSON output"
1750            );
1751            assert!(skipped[0].path.ends_with("src/huge.ts"));
1752            assert!(
1753                matches!(
1754                    skipped[0].kind,
1755                    fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes }
1756                        if size_bytes == 5_000
1757                ),
1758                "the recorded diagnostic carries the on-disk byte size"
1759            );
1760        }
1761
1762        #[test]
1763        fn skips_large_one_line_js_as_minified_generated_output() {
1764            let dir = tempfile::tempdir().expect("create temp dir");
1765            let src = dir.path().join("src");
1766            std::fs::create_dir_all(&src).unwrap();
1767            let asset = src.join("index-abc123.js");
1768            std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
1769
1770            let config = make_config(dir.path().to_path_buf(), false);
1771            let files = discover_files(&config);
1772            let names = file_names(&files, dir.path());
1773
1774            assert!(
1775                !names.contains(&"src/index-abc123.js".to_string()),
1776                "large one-line JS assets should be skipped before parsing"
1777            );
1778
1779            let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1780            assert!(
1781                diagnostics.iter().any(|diag| {
1782                    diag.path.ends_with("src/index-abc123.js")
1783                        && matches!(
1784                            diag.kind,
1785                            fallow_config::WorkspaceDiagnosticKind::SkippedMinifiedFile { .. }
1786                        )
1787                }),
1788                "the skipped minified asset is recorded for JSON output: {diagnostics:?}"
1789            );
1790        }
1791
1792        #[test]
1793        fn unlimited_size_keeps_large_one_line_js() {
1794            let dir = tempfile::tempdir().expect("create temp dir");
1795            let src = dir.path().join("src");
1796            std::fs::create_dir_all(&src).unwrap();
1797            let asset = src.join("index-abc123.js");
1798            std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
1799
1800            let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
1801            let files = discover_files(&config);
1802            let names = file_names(&files, dir.path());
1803
1804            assert!(
1805                names.contains(&"src/index-abc123.js".to_string()),
1806                "--max-file-size 0 should opt out of generated JS skipping"
1807            );
1808        }
1809
1810        #[test]
1811        fn keeps_large_multiline_js() {
1812            let dir = tempfile::tempdir().expect("create temp dir");
1813            let src = dir.path().join("src");
1814            std::fs::create_dir_all(&src).unwrap();
1815            let asset = src.join("handwritten.js");
1816            let mut content = String::new();
1817            while content.len() <= MINIFIED_FILE_SKIP_BYTES as usize + 1 {
1818                content.push_str("export const value = 1;\n");
1819            }
1820            std::fs::write(&asset, content).unwrap();
1821
1822            let config = make_config(dir.path().to_path_buf(), false);
1823            let files = discover_files(&config);
1824            let names = file_names(&files, dir.path());
1825
1826            assert!(
1827                names.contains(&"src/handwritten.js".to_string()),
1828                "large multiline JS should not be treated as a generated minified asset"
1829            );
1830        }
1831    }
1832}