Skip to main content

fallow_core/discover/
walk.rs

1use std::ffi::OsStr;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex, OnceLock};
4
5use fallow_config::{
6    DEFAULT_IGNORE_PATTERNS, IgnorePatternSet, ResolvedConfig, WorkspaceDiagnostic,
7    WorkspaceDiagnosticKind,
8};
9use fallow_types::discover::{DiscoveredFile, FileId};
10use fallow_types::path_util::display_relative;
11use fallow_types::workspace::glob_first_literal_segment;
12use ignore::WalkBuilder;
13use rustc_hash::{FxHashMap, FxHashSet};
14
15use super::{ALLOWED_HIDDEN_DIRS, SCRIPT_SCOPE_DENYLIST};
16
17/// Process-wide dedupe of the size-skip / largest-files stderr notes, keyed by a
18/// content-derived string, so combined-mode (`fallow` runs check + dupes +
19/// health, each of which can trigger a source walk) emits each note at most once
20/// per distinct content. Mirrors the workspace-diagnostics `should_emit`
21/// pattern (issue #1086).
22fn should_emit_note_once(key: String) -> bool {
23    static EMITTED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
24    EMITTED
25        .get_or_init(|| Mutex::new(FxHashSet::default()))
26        .lock()
27        .map_or(true, |mut set| set.insert(key))
28}
29
30/// A discovered file path paired with its on-disk size in bytes, as collected
31/// by the parallel walker before [`DiscoveredFile`] ids are assigned.
32type SizedFile = (PathBuf, u64);
33
34/// Dot-prefixed directories the walk dropped, collected inside the parallel
35/// walker's `filter_entry` predicate.
36///
37/// `Arc<Mutex<..>>` and not a borrow: `WalkBuilder::filter_entry` requires
38/// `Fn(&DirEntry) -> bool + Send + Sync + 'static`, so the closure can neither
39/// borrow a local nor mutate captured state directly, and the predicate runs
40/// on every walker thread.
41type SkippedDotdirSink = Arc<Mutex<Vec<PathBuf>>>;
42
43/// Candidate source files ONE built-in ignore pattern removed from a walk,
44/// with the directories they sat in (issue #2638).
45///
46/// The scope map is deliberately uncapped, and its size is not the risk it
47/// looks like. For a directory-shaped pattern every file under one matched
48/// directory collapses to a single key, the one built-in that could span a
49/// whole dependency tree is never tallied at all (see
50/// [`UNREPORTED_DEFAULT_IGNORES`]), and only the file-shaped patterns
51/// (`**/*.min.js` and friends) key on a file's parent, on files that are rare
52/// by construction. The map is therefore strictly smaller than the file list
53/// the same walk already holds. A ceiling would buy nothing and would cost
54/// both determinism and honesty: the parallel walk fills per-thread maps in
55/// nondeterministic order, so "the first N directories" is not a stable set,
56/// the anchor picked from a truncated set would differ between runs of the
57/// same command, and `directory_count` would become a floor rather than a
58/// count.
59#[derive(Default)]
60struct ExcludedByPattern {
61    /// Candidate source files this pattern excluded. Exact.
62    file_count: u32,
63    /// Excluded files per scope directory, project-root-relative.
64    scopes: FxHashMap<PathBuf, u32>,
65}
66
67impl ExcludedByPattern {
68    fn record(&mut self, scope: PathBuf) {
69        self.file_count = self.file_count.saturating_add(1);
70        *self.scopes.entry(scope).or_insert(0) += 1;
71    }
72
73    fn merge(&mut self, other: Self) {
74        self.file_count = self.file_count.saturating_add(other.file_count);
75        for (scope, count) in other.scopes {
76            *self.scopes.entry(scope).or_insert(0) += count;
77        }
78    }
79
80    /// Distinct directories this pattern MATCHED at, which for a
81    /// directory-shaped pattern is not the number of directories that held
82    /// the files: everything under one matched `dist/` collapses to that one
83    /// scope. Exact, and the number the message needs to say whether
84    /// [`Self::anchor`] names the whole exclusion or one matched location of
85    /// several.
86    fn directory_count(&self) -> u32 {
87        u32::try_from(self.scopes.len()).unwrap_or(u32::MAX)
88    }
89
90    /// The matched directory this pattern excluded the most files from, ties
91    /// broken by the lexicographically first path so two runs on one tree
92    /// report the same anchor.
93    fn anchor(&self) -> PathBuf {
94        self.scopes
95            .iter()
96            .max_by(|(left_path, left_count), (right_path, right_count)| {
97                left_count
98                    .cmp(right_count)
99                    .then_with(|| right_path.cmp(left_path))
100            })
101            .map(|(path, _)| path.clone())
102            .unwrap_or_default()
103    }
104}
105
106/// Per-built-in-pattern exclusion tallies, keyed by index into
107/// [`DEFAULT_IGNORE_PATTERNS`].
108type ExclusionTally = FxHashMap<usize, ExcludedByPattern>;
109
110/// Built-in ignore patterns whose exclusions are never reported (issue #2638).
111///
112/// The diagnostic exists to say "first-party source of yours was dropped".
113/// `**/node_modules/**` drops installed dependencies, which are nobody's
114/// first-party source: on a project whose `node_modules` is not gitignored it
115/// would report a five-figure count whose only honest remedy is "that is your
116/// dependency tree", and it would be the one built-in able to dominate the
117/// walk's memory with per-package scope directories. `**/.git/**` cannot fire
118/// at all, because hidden directories are not traversed; it is listed so the
119/// two exclusions read as one policy rather than as an accident.
120const UNREPORTED_DEFAULT_IGNORES: &[&str] = &["**/node_modules/**", "**/.git/**"];
121
122/// The directory a built-in pattern excluded a file "at": the LONGEST prefix
123/// of the file's project-relative parent directory ending in the pattern's
124/// literal segment, or the parent itself when the pattern has no literal
125/// segment.
126///
127/// `**/build/**` with `projects/app/build/static/js/main.js` gives
128/// `projects/app/build`, which is the directory a reader can act on and the
129/// one `fallow --root` takes. The deepest match is what makes that remedy
130/// true: the glob is tested against the path relative to the run root, so on
131/// `build/tools/build/a.ts` an anchor at the outer `build` leaves the inner
132/// one in the relative path and the built-in matches again. `**/*.min.js` has
133/// no literal segment, so `vendor/a.min.js` gives `vendor`. A root-level match
134/// returns the empty path, which the diagnostic renders as the root itself.
135fn exclusion_scope(relative: &Path, pattern: &str) -> PathBuf {
136    let parent = relative.parent().unwrap_or_else(|| Path::new(""));
137    let Some(literal) = glob_first_literal_segment(pattern) else {
138        return parent.to_path_buf();
139    };
140    let mut prefix = PathBuf::new();
141    let mut deepest = None;
142    for component in parent.components() {
143        prefix.push(component);
144        if component.as_os_str() == OsStr::new(literal) {
145            deepest = Some(prefix.clone());
146        }
147    }
148    deepest.unwrap_or_else(|| parent.to_path_buf())
149}
150
151/// Number of example file paths named in the aggregated skipped-large-file and
152/// largest-files stderr notes before the tail collapses to "and N more". Keeps
153/// the notes to one bounded line on a monorepo that skips many files.
154const NOTE_EXAMPLE_CAP: usize = 5;
155
156/// Directory levels below a skipped dotdir the bounded scan descends. The
157/// dotdir itself is level 0. Two levels reach the conventional
158/// `<dotdir>/<group>/<file>` layout (`.claude/hooks/probe.mjs`) with one level
159/// of headroom, and stop well above a vendored toolchain tree.
160const DOTDIR_SCAN_MAX_DEPTH: usize = 2;
161
162/// Directory entries the bounded scan reads across all levels of ONE skipped
163/// dotdir. The ceiling this buys is a SYSCALL count, not a wall-clock figure:
164/// a directory-heavy dotdir spends budget on subdirectories that each cost an
165/// opendir of their own, so 256 entries can still mean 257 directory reads and
166/// several milliseconds. State the bound in syscalls, never in milliseconds.
167const DOTDIR_SCAN_MAX_ENTRIES: usize = 256;
168
169/// Directory entries the bounded scan reads across ALL skipped dotdirs in one
170/// walk. [`DOTDIR_SCAN_MAX_ENTRIES`] bounds a single directory and nothing
171/// bounded the sum, so the added cost was linear in the candidate count: a
172/// synthetic tree of 1000 directory-heavy dotdirs turned a 191 ms run into
173/// 6.6 s. Candidates are scanned in sorted order and share this budget, so
174/// exhausting it drops the advisory for the remaining candidates
175/// deterministically instead of paying an unbounded cost. With this ceiling
176/// and [`DOTDIR_SCAN_MAX_CANDIDATES`] the same synthetic trees measure about
177/// 30 ms of added work whether they hold 300 or 1000 candidates, and a real
178/// repository stays inside run-to-run noise.
179const DOTDIR_SCAN_TOTAL_ENTRIES: usize = 1024;
180
181/// Skipped dotdirs the bounded scan OPENS in one walk. The entry budget does
182/// not bound the per-candidate setup cost, since each scan builds its own
183/// gitignore matcher chain (about 0.2 ms) before it reads a single entry, so
184/// the candidate count needs a ceiling of its own. Counted after the name
185/// checks, so a monorepo full of `.turbo` and `.next` directories cannot spend
186/// the ceiling on directories that were never going to be scanned.
187const DOTDIR_SCAN_MAX_CANDIDATES: usize = 64;
188
189/// File extensions that put a file in the module graph the advisory talks
190/// about. Narrower than [`SOURCE_EXTENSIONS`] on purpose: the message states
191/// that the directory's imports and exports are not analyzed, and that is only
192/// true of code. A dotdir holding nothing but a generated Lighthouse
193/// `report.html`, a Sanity runtime page, a `schema.graphql`, or a stylesheet
194/// has no imports or exports to lose, so it does not earn the advisory.
195const DOTDIR_MODULE_EXTENSIONS: &[&str] = &[
196    "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
197    "mdx",
198];
199
200/// Discovered-file-count threshold above which the pre-parse largest-files note
201/// fires, so an out-of-memory hang at the parse stage has a visible suspect
202/// list (issue #1086).
203const LARGE_SET_THRESHOLD: usize = 20_000;
204
205/// Single-file byte threshold above which the pre-parse largest-files note
206/// fires even on a small project. Set just under the default 5 MB skip so the
207/// note fires for kept files that are approaching the skip limit (the genuine
208/// out-of-memory suspects), not for ordinary large-but-benign files.
209const LARGE_FILE_NOTE_BYTES: u64 = 4 * 1024 * 1024;
210
211/// Minimum size for a file to appear in the largest-files note. Filters out the
212/// `0.0 MB` entries that would otherwise pad the list once it fires, keeping the
213/// named files to plausible memory contributors.
214const NOTE_FILE_FLOOR_BYTES: u64 = 256 * 1024;
215
216/// Minimum size for content-shape based minified-bundle skipping. Smaller
217/// one-line files can be hand-written utilities, while multi-MB one-line JS is
218/// generated output in practice.
219const MINIFIED_FILE_SKIP_BYTES: u64 = 1024 * 1024;
220
221/// Number of bytes inspected when deciding whether a large JS file is minified.
222const MINIFIED_SAMPLE_BYTES: usize = 256 * 1024;
223
224/// A single line this long in a multi-MB JS file is treated as generated
225/// minified output. This avoids parsing assets that can expand to huge ASTs.
226const MINIFIED_LONG_LINE_BYTES: usize = 128 * 1024;
227
228/// Whether a path is a TypeScript declaration file (`.d.ts`/`.d.mts`/`.d.cts`).
229/// Declaration files are exempt from the per-file size skip because they are
230/// reachability roots for global types: skipping a large `auto-imports.d.ts`
231/// would false-flag the files whose types it provides.
232fn is_declaration_file(path: &Path) -> bool {
233    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
234    name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
235}
236
237fn is_plain_js_file(path: &Path) -> bool {
238    matches!(
239        path.extension().and_then(|ext| ext.to_str()),
240        Some("js" | "mjs" | "cjs")
241    )
242}
243
244fn has_minified_line_shape(path: &Path) -> bool {
245    use std::io::Read;
246
247    let Ok(mut file) = std::fs::File::open(path) else {
248        return false;
249    };
250    let mut sample = vec![0; MINIFIED_SAMPLE_BYTES];
251    let Ok(len) = file.read(&mut sample) else {
252        return false;
253    };
254    sample.truncate(len);
255    if sample.is_empty() {
256        return false;
257    }
258
259    let mut current_line = 0usize;
260    for byte in sample {
261        if byte == b'\n' || byte == b'\r' {
262            current_line = 0;
263            continue;
264        }
265        current_line += 1;
266        if current_line >= MINIFIED_LONG_LINE_BYTES {
267            return true;
268        }
269    }
270    false
271}
272
273fn is_probably_minified_generated_js(path: &Path, size_bytes: u64) -> bool {
274    size_bytes >= MINIFIED_FILE_SKIP_BYTES
275        && is_plain_js_file(path)
276        && !is_declaration_file(path)
277        && has_minified_line_shape(path)
278}
279
280/// Render a byte count as a megabyte figure with one decimal place.
281fn format_size_mb(bytes: u64) -> String {
282    #[expect(
283        clippy::cast_precision_loss,
284        reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
285    )]
286    let mb = bytes as f64 / (1024.0 * 1024.0);
287    format!("{mb:.1} MB")
288}
289
290/// Join up to [`NOTE_EXAMPLE_CAP`] `path (size)` examples (already ordered) into
291/// one comma-separated string, collapsing the tail to "and N more".
292fn summarize_examples(root: &Path, examples: &[SizedFile]) -> String {
293    let shown: Vec<String> = examples
294        .iter()
295        .take(NOTE_EXAMPLE_CAP)
296        .map(|(path, size)| {
297            let display = display_relative(root, path);
298            format!("{display} ({})", format_size_mb(*size))
299        })
300        .collect();
301    let remaining = examples.len().saturating_sub(NOTE_EXAMPLE_CAP);
302    if remaining > 0 {
303        format!("{}, and {remaining} more", shown.join(", "))
304    } else {
305        shown.join(", ")
306    }
307}
308
309/// Split discovered `(path, size)` pairs into the kept set and the set skipped
310/// for exceeding `max_file_size_bytes`. Declaration files are never skipped.
311fn partition_by_size(
312    raw: Vec<SizedFile>,
313    max_file_size_bytes: Option<u64>,
314) -> (Vec<SizedFile>, Vec<SizedFile>) {
315    let Some(limit) = max_file_size_bytes else {
316        return (raw, Vec::new());
317    };
318    raw.into_iter()
319        .partition(|(path, size)| *size <= limit || is_declaration_file(path))
320}
321
322/// Split discovered `(path, size)` pairs into files kept for parsing and files
323/// skipped because they look like generated minified JavaScript.
324fn partition_minified_generated_js(
325    raw: Vec<SizedFile>,
326    max_file_size_bytes: Option<u64>,
327) -> (Vec<SizedFile>, Vec<SizedFile>) {
328    if max_file_size_bytes.is_none() {
329        return (raw, Vec::new());
330    }
331    raw.into_iter()
332        .partition(|(path, size)| !is_probably_minified_generated_js(path, *size))
333}
334
335/// Build the typed diagnostics for the over-limit files this walk dropped and
336/// emit one aggregated `tracing::warn!` so a human running `fallow` sees what
337/// was dropped. Mirrors the JSON-plus-gated-warn pattern used for undeclared
338/// workspaces. The caller writes the returned list to the registry.
339/// Report an uninstalled dependency tree as part of the walk's own
340/// source-discovery set.
341///
342/// It belongs here rather than beside the two pipelines that used to warn
343/// about it: the walk owns this root's source-discovery diagnostics, and any
344/// second writer would be replaced by whichever walk finished last.
345fn report_missing_node_modules(config: &ResolvedConfig) -> Vec<WorkspaceDiagnostic> {
346    let Some(diagnostic) = fallow_config::missing_node_modules_diagnostic(&config.root) else {
347        return Vec::new();
348    };
349    if !config.quiet
350        && should_emit_note_once(format!("node-modules-missing::{}", config.root.display()))
351    {
352        tracing::warn!("fallow: {}", diagnostic.message);
353    }
354    vec![diagnostic]
355}
356
357fn report_skipped_large_files(
358    config: &ResolvedConfig,
359    skipped: &[SizedFile],
360) -> Vec<WorkspaceDiagnostic> {
361    if skipped.is_empty() {
362        return Vec::new();
363    }
364    let diagnostics: Vec<WorkspaceDiagnostic> = skipped
365        .iter()
366        .map(|(path, size_bytes)| {
367            WorkspaceDiagnostic::new(
368                &config.root,
369                path.clone(),
370                WorkspaceDiagnosticKind::SkippedLargeFile {
371                    size_bytes: *size_bytes,
372                },
373            )
374        })
375        .collect();
376
377    let mut sorted: Vec<SizedFile> = skipped.to_vec();
378    sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
379    let count = skipped.len();
380    if !config.quiet
381        && should_emit_note_once(format!(
382            "skip::{}::{count}::{}",
383            config.root.display(),
384            sorted.first().map_or(0, |f| f.1)
385        ))
386    {
387        let examples = summarize_examples(&config.root, &sorted);
388        let noun = if count == 1 { "file" } else { "files" };
389        tracing::warn!(
390            "fallow: skipped {count} {noun} over the max file size limit ({examples}). \
391             Raise the limit with --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add them to ignorePatterns."
392        );
393    }
394    diagnostics
395}
396
397/// Warn once for each config glob that matches fewer discovered files now
398/// that a single `*` or `?` stays within one path segment.
399///
400/// The old matcher compiles only for patterns with a single-segment
401/// wildcard, and one pass over the discovered files counts every pattern.
402/// The warning goes to stderr only; JSON output does not change.
403fn warn_legacy_glob_narrowings(config: &ResolvedConfig, files: &[DiscoveredFile]) {
404    if config.quiet || config.legacy_glob_groups.is_empty() {
405        return;
406    }
407    let relative: Vec<&Path> = files
408        .iter()
409        .map(|file| file.path.strip_prefix(&config.root).unwrap_or(&file.path))
410        .collect();
411    for narrowing in
412        fallow_config::find_legacy_glob_narrowings(&config.legacy_glob_groups, &relative)
413    {
414        let message = narrowing.message();
415        if should_emit_note_once(format!(
416            "legacy-glob::{}::{}::{}",
417            config.root.display(),
418            narrowing.field,
419            narrowing.pattern
420        )) {
421            tracing::warn!("{message}");
422        }
423    }
424}
425
426/// Build the typed diagnostics for generated minified JS files skipped before
427/// parsing. The caller writes the returned list to the registry.
428fn report_skipped_minified_files(
429    config: &ResolvedConfig,
430    skipped: &[SizedFile],
431) -> Vec<WorkspaceDiagnostic> {
432    if skipped.is_empty() {
433        return Vec::new();
434    }
435    let diagnostics: Vec<WorkspaceDiagnostic> = skipped
436        .iter()
437        .map(|(path, size_bytes)| {
438            WorkspaceDiagnostic::new(
439                &config.root,
440                path.clone(),
441                WorkspaceDiagnosticKind::SkippedMinifiedFile {
442                    size_bytes: *size_bytes,
443                },
444            )
445        })
446        .collect();
447
448    let mut sorted: Vec<SizedFile> = skipped.to_vec();
449    sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
450    let count = skipped.len();
451    if !config.quiet
452        && should_emit_note_once(format!(
453            "minified::{}::{count}::{}",
454            config.root.display(),
455            sorted.first().map_or(0, |f| f.1)
456        ))
457    {
458        let examples = summarize_examples(&config.root, &sorted);
459        let noun = if count == 1 { "file" } else { "files" };
460        let pronoun = if count == 1 { "it" } else { "them" };
461        tracing::warn!(
462            "fallow: skipped {count} minified generated JS {noun} ({examples}). \
463             Add {pronoun} to ignorePatterns, rename {pronoun} with a .min.js suffix, or use --max-file-size 0 to analyze {pronoun}."
464        );
465    }
466    diagnostics
467}
468
469/// Join up to [`NOTE_EXAMPLE_CAP`] root-relative paths (already ordered) into
470/// one comma-separated string, collapsing the tail to "and N more". The
471/// size-bearing sibling is [`summarize_examples`].
472fn summarize_paths(root: &Path, examples: &[&PathBuf]) -> String {
473    let shown: Vec<String> = examples
474        .iter()
475        .take(NOTE_EXAMPLE_CAP)
476        .map(|path| display_relative(root, path))
477        .collect();
478    let remaining = examples.len().saturating_sub(NOTE_EXAMPLE_CAP);
479    if remaining > 0 {
480        format!("{}, and {remaining} more", shown.join(", "))
481    } else {
482        shown.join(", ")
483    }
484}
485
486/// Like [`summarize_paths`], but for a list already known to be incomplete: the
487/// tail reads "and more" rather than naming a count the run cannot vouch for.
488fn summarize_paths_open_ended(root: &Path, examples: &[&PathBuf]) -> String {
489    let shown: Vec<String> = examples
490        .iter()
491        .take(NOTE_EXAMPLE_CAP)
492        .map(|path| display_relative(root, path))
493        .collect();
494    if examples.len() > NOTE_EXAMPLE_CAP {
495        format!("{}, and more", shown.join(", "))
496    } else {
497        shown.join(", ")
498    }
499}
500
501/// Whether a candidate file inside a skipped dotdir is one this run had
502/// already excluded from analysis, matching what [`FileVisitor`] applies to
503/// every discovered file: the compiled `ignorePatterns` set (user entries plus
504/// the built-in defaults) against the ROOT-RELATIVE path, plus the production
505/// excludes when the run is a `--production` run.
506///
507/// The root-relative path is what matters. Matching the directory path instead
508/// would miss the pattern a user actually writes, because `.claude/**` does not
509/// match `.claude`.
510///
511/// Production excludes ARE applied, even though the skip the diagnostic reports
512/// is a traversal decision that `--production` does not change. A `--production`
513/// run that named a dotdir holding only `thing.test.ts` would print a remedy
514/// (`fallow --root .qa --production`) that returns nothing, so the advisory has
515/// to agree with the file set the run would actually analyze. Combined mode's
516/// two walks can therefore disagree, which the documented union semantics of
517/// the combined root already cover.
518fn is_excluded_from_analysis(
519    config: &ResolvedConfig,
520    production_excludes: Option<&globset::GlobSet>,
521    path: &Path,
522) -> bool {
523    let relative = path.strip_prefix(&config.root).unwrap_or(path);
524    config.ignore_patterns.is_match(relative)
525        || production_excludes.is_some_and(|excludes| excludes.is_match(relative))
526}
527
528/// True when `path` carries an extension that puts it in the module graph the
529/// advisory describes. See [`DOTDIR_MODULE_EXTENSIONS`] for why this is
530/// narrower than [`has_source_extension`].
531fn has_module_extension(path: &Path) -> bool {
532    path.extension()
533        .and_then(OsStr::to_str)
534        .is_some_and(|ext| DOTDIR_MODULE_EXTENSIONS.contains(&ext))
535}
536
537/// Depth- and entry-capped search for one reportable source file, stopping at
538/// the first hit. Never a recursive walk of an unbounded tree: the ceiling is
539/// `1 + DOTDIR_SCAN_MAX_ENTRIES` directory reads for one dotdir, and
540/// [`DOTDIR_SCAN_TOTAL_ENTRIES`] across the whole walk.
541///
542/// Runs on `fallow_config::source_walk_builder`, the git settings of the
543/// source walk, rather than a bare `read_dir`, because "the project has not excluded it" has
544/// to mean what git means. Only the DIRECTORY form of a gitignore rule
545/// (`.build-tools/`) prunes a dotdir before the walk's own filter sees it: the
546/// `dir/**`, `dir/*`, `**/dir/**` and file-level (`*.ts`) forms all leave the
547/// directory reaching this scan with every file inside it ignored, and a cache
548/// directory that ignores itself through its own nested `.gitignore` does the
549/// same. Reporting those would advertise two remedies that both do nothing,
550/// since a re-rooted `fallow --root <dir>` still reads the parent repository's
551/// gitignore and would find no files either.
552///
553/// Accepted tradeoff: for a dotdir with more than [`DOTDIR_SCAN_MAX_ENTRIES`]
554/// entries whose only source file sits past the budget, the verdict is
555/// readdir-order dependent, so the advisory can flap between runs. The
556/// alternative is unbounded I/O, and the consequence of a flap is a missing
557/// advisory line, never a changed analysis. No test may depend on that
558/// boundary.
559fn scan_for_reportable_source(
560    config: &ResolvedConfig,
561    production_excludes: Option<&globset::GlobSet>,
562    dir: &Path,
563    budget: &mut usize,
564) -> bool {
565    let mut builder = fallow_config::source_walk_builder(dir);
566    builder
567        .follow_links(false)
568        .max_depth(Some(DOTDIR_SCAN_MAX_DEPTH + 1))
569        .threads(1);
570    builder.filter_entry(|entry| {
571        if entry.depth() == 0 || !entry.file_type().is_some_and(|ft| ft.is_dir()) {
572            return true;
573        }
574        entry
575            .file_name()
576            .to_str()
577            .is_none_or(|name| !SCRIPT_SCOPE_DENYLIST.contains(&name) && name != "node_modules")
578    });
579
580    let mut per_dotdir = DOTDIR_SCAN_MAX_ENTRIES;
581    for entry in builder.build() {
582        if per_dotdir == 0 || *budget == 0 {
583            return false;
584        }
585        per_dotdir -= 1;
586        *budget -= 1;
587        let Ok(entry) = entry else {
588            continue;
589        };
590        // Regular files only. A symlink is never followed out of the scan, and
591        // a fifo or a socket named `pipe.ts` is not source either.
592        #[expect(
593            clippy::filetype_is_file,
594            reason = "regular files only is the point: !is_dir() would readmit fifos and sockets"
595        )]
596        let is_regular_file = entry
597            .file_type()
598            .is_some_and(|file_type| file_type.is_file());
599        if !is_regular_file {
600            continue;
601        }
602        if has_module_extension(entry.path())
603            && !is_excluded_from_analysis(config, production_excludes, entry.path())
604        {
605            return true;
606        }
607    }
608    false
609}
610
611/// Path components whose subtrees never earn the skipped-source-dotdir
612/// advisory. A hidden directory under one of these is test scaffolding rather
613/// than first-party source the project meant to analyze.
614const DOTDIR_NOISE_PATH_COMPONENTS: &[&str] = &[
615    "__fixtures__",
616    "__mocks__",
617    "__tests__",
618    "e2e",
619    "fixture",
620    "fixtures",
621    "playground",
622    "playgrounds",
623    "spec",
624    "test",
625    "tests",
626];
627
628/// Whether a dropped dotdir is worth opening at all. Decided from the PATH
629/// alone, so it costs no I/O and runs before the scan budget is touched:
630/// `.git` in a large repository, a `.jj` object store, and `node_modules/.pnpm`
631/// are never opened. `ALLOWED_HIDDEN_DIRS` and every plugin- or
632/// script-contributed scope are already excluded by construction, since a
633/// directory they admit is never dropped and so never reaches this list.
634fn dotdir_is_scan_candidate(config: &ResolvedConfig, dir: &Path) -> bool {
635    let Some(name) = dir.file_name().and_then(OsStr::to_str) else {
636        return false;
637    };
638    if SCRIPT_SCOPE_DENYLIST.contains(&name) {
639        return false;
640    }
641    let relative = dir.strip_prefix(&config.root).unwrap_or(dir);
642    // Pure cost saving, not a further condition: the built-in `**/node_modules/**`
643    // ignore default makes every file under a `node_modules` component ignored,
644    // so the scan could only ever return false.
645    if relative
646        .components()
647        .any(|component| component.as_os_str() == OsStr::new("node_modules"))
648    {
649        return false;
650    }
651    // Precision, and the one place this check is deliberately less complete
652    // than it could be. A hidden directory under a test, fixture, or playground
653    // tree is usually there BECAUSE it is hidden: some of these exist purely to
654    // exercise hidden-directory handling, so an advisory about them is wrong
655    // about the project every time it fires. Measured on a ten-repository
656    // corpus, this component filter removes every false positive one framework
657    // contributed and half of another's while keeping the true positives, which
658    // sit at a repository root rather than under a test tree.
659    !relative.components().any(|component| {
660        DOTDIR_NOISE_PATH_COMPONENTS.contains(&component.as_os_str().to_string_lossy().as_ref())
661    })
662}
663
664/// Build the typed diagnostics for the built-in ignore patterns that removed
665/// candidate source files from this walk (issue #2638).
666///
667/// One entry per pattern, in [`DEFAULT_IGNORE_PATTERNS`] order, so the array
668/// grows by at most the number of built-in patterns on a project of any size
669/// and its order does not depend on the parallel walk. No stderr output: the
670/// kind answers `warns_on_stderr()` with `false`, and the CLI prints a note
671/// only under `--explain-skipped`.
672fn report_default_ignore_exclusions(
673    config: &ResolvedConfig,
674    tally: &ExclusionTally,
675) -> Vec<WorkspaceDiagnostic> {
676    let mut indices: Vec<usize> = tally.keys().copied().collect();
677    indices.sort_unstable();
678    indices
679        .into_iter()
680        .filter_map(|index| {
681            let excluded = tally.get(&index)?;
682            let pattern = DEFAULT_IGNORE_PATTERNS.get(index)?;
683            Some(
684                WorkspaceDiagnostic::new(
685                    &config.root,
686                    config.root.join(excluded.anchor()),
687                    WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
688                        pattern: (*pattern).to_owned(),
689                        file_count: excluded.file_count,
690                        directory_count: excluded.directory_count(),
691                    },
692                )
693                // A file-shaped built-in that matched a file directly at the
694                // analysis root anchors at the root, and `root.join("")` is the
695                // root itself. The analysis envelopes' post-serialisation strip
696                // only removes a `root + separator` prefix, so without this the
697                // entry would carry the absolute host path into every JSON
698                // surface while its siblings render `.`.
699                .into_root_relative(&config.root),
700            )
701        })
702        .collect()
703}
704
705/// Report that the walk finished with nothing to analyze (issue #2686).
706///
707/// The condition is the file list being empty, not any particular exclusion,
708/// so it also covers a docs-only repository, a workspace member with no
709/// TypeScript and a path filter that matched nothing. The built-in-ignore
710/// tally rides along as `excluded_file_count` so the common cause stays
711/// attributable without making it the trigger.
712///
713/// Recorded by discovery rather than by the CLI's human note, so every envelope
714/// built from a diagnostics snapshot carries it: the MCP tools and the
715/// programmatic routes share this list, and a kind that existed on the CLI path
716/// alone would break that.
717fn report_no_source_files_analyzed(
718    config: &ResolvedConfig,
719    analyzed_file_count: usize,
720    tally: &ExclusionTally,
721) -> Vec<WorkspaceDiagnostic> {
722    if analyzed_file_count > 0 {
723        return Vec::new();
724    }
725    let excluded_file_count = tally.values().map(|excluded| excluded.file_count).sum();
726    vec![
727        WorkspaceDiagnostic::new(
728            &config.root,
729            config.root.clone(),
730            WorkspaceDiagnosticKind::NoSourceFilesAnalyzed {
731                excluded_file_count,
732            },
733        )
734        .into_root_relative(&config.root),
735    ]
736}
737
738/// Build the typed diagnostics for the dot-prefixed directories this walk
739/// dropped that hold source files the project has not excluded, and emit one
740/// aggregated `tracing::warn!` so the otherwise silent skip is visible on
741/// stderr too (issue #461). The caller writes the returned list to the
742/// registry.
743fn report_skipped_source_dotdirs(
744    config: &ResolvedConfig,
745    production_excludes: Option<&globset::GlobSet>,
746    candidates: &[PathBuf],
747) -> Vec<WorkspaceDiagnostic> {
748    if candidates.is_empty() {
749        return Vec::new();
750    }
751    // The caller sorted and deduped, so both caps truncate deterministically:
752    // the same tree reports the same prefix on every run.
753    let mut budget = DOTDIR_SCAN_TOTAL_ENTRIES;
754    let scannable: Vec<&PathBuf> = candidates
755        .iter()
756        .filter(|dir| dotdir_is_scan_candidate(config, dir))
757        .collect();
758    let reportable: Vec<&PathBuf> = scannable
759        .iter()
760        .copied()
761        .take(DOTDIR_SCAN_MAX_CANDIDATES)
762        .filter(|dir| scan_for_reportable_source(config, production_excludes, dir, &mut budget))
763        .collect();
764    // Either ceiling can stop the scan with candidates left unexamined, so the
765    // count is a floor rather than a total whenever one of them binds.
766    let truncated = scannable.len() > DOTDIR_SCAN_MAX_CANDIDATES || budget == 0;
767    if reportable.is_empty() {
768        return Vec::new();
769    }
770
771    let diagnostics: Vec<WorkspaceDiagnostic> = reportable
772        .iter()
773        .map(|dir| {
774            WorkspaceDiagnostic::new(
775                &config.root,
776                (*dir).clone(),
777                WorkspaceDiagnosticKind::SkippedSourceDotdir,
778            )
779        })
780        .collect();
781
782    let count = reportable.len();
783    if !config.quiet
784        && should_emit_note_once(format!(
785            "dotdir::{}::{count}::{}",
786            config.root.display(),
787            reportable
788                .first()
789                .map_or_else(String::new, |dir| display_relative(&config.root, dir))
790        ))
791    {
792        tracing::warn!(
793            "{}",
794            build_skipped_dotdirs_note(&config.root, &reportable, truncated)
795        );
796    }
797    diagnostics
798}
799
800/// Build the skipped-source-dotdir note. Pure so the singular and plural forms,
801/// the truncated prefix, and the single-directory remedy substitution are
802/// unit-testable without a tracing subscriber, mirroring
803/// [`build_largest_files_note`].
804///
805/// With exactly one directory the remedy names it instead of printing a `<dir>`
806/// placeholder: the path is already known and was printed a few words earlier,
807/// so a placeholder would make the one case a user can act on directly the one
808/// case they have to retype.
809fn build_skipped_dotdirs_note(root: &Path, reportable: &[&PathBuf], truncated: bool) -> String {
810    let count = reportable.len();
811    // An exact remainder inside an explicitly inexact total reads as a
812    // contradiction ("at least 64 ... and 59 more"), so a truncated run drops
813    // the tail count.
814    let examples = if truncated {
815        summarize_paths_open_ended(root, reportable)
816    } else {
817        summarize_paths(root, reportable)
818    };
819    let noun = if count == 1 {
820        "directory"
821    } else {
822        "directories"
823    };
824    let verb = if count == 1 { "contains" } else { "contain" };
825    let at_least = if truncated { "at least " } else { "" };
826    let (target, pronoun) = match reportable {
827        [only] => (display_relative(root, only), "it"),
828        _ => ("<dir>".to_owned(), "one"),
829    };
830    format!(
831        "fallow: skipped {at_least}{count} hidden {noun} that {verb} source files ({examples}). \
832         Hidden directories are not traversed, so a file, export or dependency used only \
833         there can be reported as unused: add '!{target}/**' to ignorePatterns to analyze \
834         {pronoun}, or add it to entry, ignoreExports or ignoreDependencies, or add \
835         '{target}/**' to ignorePatterns to silence this. fallow --root {target} analyzes \
836         {pronoun} on its own and does not fix this run."
837    )
838}
839
840/// Build the pre-parse largest-files note, or `None` when the discovered set is
841/// neither unusually large nor contains an unusually large file. Pure so the
842/// pluralization, floor filtering, and count-only fallback are unit-testable
843/// without a tracing subscriber. See issue #1086.
844fn build_largest_files_note(root: &Path, files: &[DiscoveredFile]) -> Option<String> {
845    if files.is_empty() {
846        return None;
847    }
848    let largest = files.iter().map(|f| f.size_bytes).max().unwrap_or(0);
849    if files.len() <= LARGE_SET_THRESHOLD && largest < LARGE_FILE_NOTE_BYTES {
850        return None;
851    }
852    let count = files.len();
853    let noun = if count == 1 { "file" } else { "files" };
854    let mut by_size: Vec<SizedFile> = files
855        .iter()
856        .filter(|f| f.size_bytes >= NOTE_FILE_FLOOR_BYTES)
857        .map(|f| (f.path.clone(), f.size_bytes))
858        .collect();
859    by_size.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
860    if by_size.is_empty() {
861        // Large file SET with no individually large file: report the count only,
862        // omitting a "largest:" list that would otherwise be all sub-floor noise.
863        return Some(format!(
864            "fallow: discovered {count} {noun}. If analysis stalls or runs out of memory, \
865             exclude large generated files via ignorePatterns or --max-file-size."
866        ));
867    }
868    let examples = summarize_examples(root, &by_size);
869    Some(format!(
870        "fallow: discovered {count} {noun}; largest: {examples}. If analysis stalls or runs out of memory, \
871         exclude large generated files via ignorePatterns or --max-file-size."
872    ))
873}
874
875/// Emit a pre-parse note listing the largest kept files when the discovered set
876/// is unusually large or contains an unusually large file, so an out-of-memory
877/// hang at the parse stage is diagnosable (issue #1086). Visible before the
878/// expensive parse begins, so it survives a subsequent crash.
879fn note_largest_files(config: &ResolvedConfig, files: &[DiscoveredFile]) {
880    if config.quiet {
881        return;
882    }
883    if let Some(message) = build_largest_files_note(&config.root, files)
884        && should_emit_note_once(format!("note::{}::{}", config.root.display(), files.len()))
885    {
886        tracing::warn!("{message}");
887    }
888}
889
890/// How a [`HiddenDirScope`] matches a hidden directory during the walk.
891#[derive(Debug, Clone, Copy, PartialEq, Eq)]
892pub enum HiddenDirMatch {
893    /// Match by directory NAME at any depth beneath the scope root.
894    ///
895    /// Framework plugins declare bundle-boundary conventions like `.client`
896    /// and `.server` that a project may place under any route directory, so
897    /// the name is the whole rule and the depth is not knowable in advance.
898    AnyDepth,
899    /// Match the exact root-relative directory PATH.
900    ///
901    /// A `package.json` script naming `.a/.b/build.mjs` states where the file
902    /// it needs actually lives, so the scope admits `<root>/.a` and
903    /// `<root>/.a/.b` and nothing else. An unrelated `packages/x/.b` stays
904    /// untraversed (issue #461).
905    ExactPath,
906}
907
908/// Package-scoped hidden directories that source discovery should traverse.
909#[derive(Debug, Clone, PartialEq, Eq)]
910pub struct HiddenDirScope {
911    root: PathBuf,
912    dirs: Vec<String>,
913    match_mode: HiddenDirMatch,
914}
915
916impl HiddenDirScope {
917    /// Build a scope rooted at a package directory that admits the given
918    /// hidden directory names at any depth beneath it.
919    ///
920    /// This is the plugin-contributed shape. For a scope inferred from a
921    /// concrete path, use [`HiddenDirScope::new_exact_paths`], which does not
922    /// admit the same name elsewhere in the tree.
923    #[must_use]
924    pub fn new(root: PathBuf, dirs: Vec<String>) -> Self {
925        Self {
926            root,
927            dirs,
928            match_mode: HiddenDirMatch::AnyDepth,
929        }
930    }
931
932    /// Build a scope rooted at a package directory that admits exactly the
933    /// given root-relative directory paths.
934    #[must_use]
935    pub fn new_exact_paths(root: PathBuf, dirs: Vec<String>) -> Self {
936        Self {
937            root,
938            dirs,
939            match_mode: HiddenDirMatch::ExactPath,
940        }
941    }
942
943    /// Rebuild a scope with an explicit match mode.
944    ///
945    /// Used when a scope crosses a crate boundary and must arrive with the
946    /// same semantics it left with.
947    #[must_use]
948    pub fn with_match_mode(root: PathBuf, dirs: Vec<String>, match_mode: HiddenDirMatch) -> Self {
949        Self {
950            root,
951            dirs,
952            match_mode,
953        }
954    }
955
956    #[must_use]
957    pub fn root(&self) -> &Path {
958        &self.root
959    }
960
961    #[must_use]
962    pub fn dirs(&self) -> &[String] {
963        &self.dirs
964    }
965
966    #[must_use]
967    pub fn match_mode(&self) -> HiddenDirMatch {
968        self.match_mode
969    }
970
971    fn allows(&self, path: &Path, name: &OsStr) -> bool {
972        match self.match_mode {
973            HiddenDirMatch::AnyDepth => {
974                path.starts_with(&self.root) && self.dirs.iter().any(|dir| OsStr::new(dir) == name)
975            }
976            HiddenDirMatch::ExactPath => {
977                // `Path` compares component-wise, so a `/`-separated entry
978                // from a script string matches on every platform.
979                let Ok(relative) = path.strip_prefix(&self.root) else {
980                    return false;
981                };
982                self.dirs.iter().any(|dir| Path::new(dir) == relative)
983            }
984        }
985    }
986}
987
988/// Per-thread file collector for the parallel walker.
989///
990/// Source files (by extension) flow to `shared`; when `config_shared` is set,
991/// non-source files admitted by the config-candidate type group flow to it
992/// instead. The two channels are disjoint and the source channel is byte-for-byte
993/// identical to the config-capture-disabled walk.
994struct FileVisitor<'a> {
995    root: &'a Path,
996    canonical_root: Option<&'a Path>,
997    ignore_patterns: &'a IgnorePatternSet,
998    /// Globs at the front of `ignore_patterns` that came from the project's
999    /// own `ignorePatterns`; the built-in defaults follow them.
1000    user_ignore_pattern_count: usize,
1001    /// Plugin- and script-contributed hidden directory scopes. Read only when
1002    /// the project wrote a `!` exception, to tell a hidden directory that the
1003    /// exception opened from one the walk always opens.
1004    hidden_dir_scopes: &'a [HiddenDirScope],
1005    production_excludes: &'a Option<globset::GlobSet>,
1006    shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
1007    config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
1008    excluded_shared: &'a Mutex<ExclusionTally>,
1009    local: Vec<(std::path::PathBuf, u64)>,
1010    config_local: Vec<std::path::PathBuf>,
1011    excluded_local: ExclusionTally,
1012    /// Reused across every excluded candidate so attribution allocates once
1013    /// per walker thread rather than once per file.
1014    match_buf: Vec<usize>,
1015}
1016
1017impl FileVisitor<'_> {
1018    /// Attribute one excluded candidate source file to the built-in pattern
1019    /// that removed it, or to nothing when the project asked for the exclusion
1020    /// itself (issue #2638).
1021    ///
1022    /// Runs only on files `is_match` already rejected, so the kept-file path
1023    /// still pays a single boolean match.
1024    fn record_default_ignore_exclusion(&mut self, relative: &Path) {
1025        // Cheap pre-filter, not a second rule: `**/node_modules/**` is in
1026        // UNREPORTED_DEFAULT_IGNORES, and it is also the one built-in that
1027        // fires on an entire dependency tree. Testing a path component beats
1028        // running the whole glob union over tens of thousands of files whose
1029        // attribution the report would then discard.
1030        if relative
1031            .components()
1032            .any(|component| component.as_os_str() == OsStr::new("node_modules"))
1033        {
1034            return;
1035        }
1036        self.match_buf.clear();
1037        self.ignore_patterns
1038            .matches_into(relative, &mut self.match_buf);
1039        // globset returns ascending indices, so the first match is both the
1040        // cheapest user-pattern test and the lowest-index built-in.
1041        let Some(&first) = self.match_buf.first() else {
1042            return;
1043        };
1044        if first < self.user_ignore_pattern_count {
1045            // An `ignorePatterns` entry also matched. The project chose this
1046            // exclusion, so reporting it as a surprise would be wrong.
1047            return;
1048        }
1049        let Some(pattern) = DEFAULT_IGNORE_PATTERNS.get(first - self.user_ignore_pattern_count)
1050        else {
1051            return;
1052        };
1053        if UNREPORTED_DEFAULT_IGNORES.contains(pattern) {
1054            return;
1055        }
1056        self.excluded_local
1057            .entry(first - self.user_ignore_pattern_count)
1058            .or_default()
1059            .record(exclusion_scope(relative, pattern));
1060    }
1061}
1062
1063impl ignore::ParallelVisitor for FileVisitor<'_> {
1064    fn visit(&mut self, result: Result<ignore::DirEntry, ignore::Error>) -> ignore::WalkState {
1065        let Ok(entry) = result else {
1066            return ignore::WalkState::Continue;
1067        };
1068        if entry.file_type().is_some_and(|ft| ft.is_dir()) {
1069            return ignore::WalkState::Continue;
1070        }
1071        let relative = entry
1072            .path()
1073            .strip_prefix(self.root)
1074            .unwrap_or_else(|_| entry.path());
1075        if self.ignore_patterns.is_match(relative) {
1076            if has_source_extension(entry.path()) {
1077                self.record_default_ignore_exclusion(relative);
1078            }
1079            return ignore::WalkState::Continue;
1080        }
1081        if self
1082            .production_excludes
1083            .as_ref()
1084            .is_some_and(|excludes| excludes.is_match(relative))
1085        {
1086            return ignore::WalkState::Continue;
1087        }
1088        if self.ignore_patterns.has_exceptions()
1089            && is_under_exception_only_hidden_dir(self.root, entry.path(), self.hidden_dir_scopes)
1090            && !self.ignore_patterns.is_lifted(relative)
1091        {
1092            return ignore::WalkState::Continue;
1093        }
1094        let symlink_size = if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
1095            let Some(size) = contained_symlink_file_size(entry.path(), self.canonical_root) else {
1096                tracing::debug!(
1097                    path = %entry.path().display(),
1098                    "skipping source symlink with a broken, non-file, or outside-root target"
1099                );
1100                return ignore::WalkState::Continue;
1101            };
1102            Some(size)
1103        } else {
1104            None
1105        };
1106        if has_source_extension(entry.path()) {
1107            let size_bytes =
1108                symlink_size.unwrap_or_else(|| entry.metadata().map_or(0, |m| m.len()));
1109            self.local.push((entry.into_path(), size_bytes));
1110        } else if self.config_shared.is_some() {
1111            // A non-source file admitted by the config-candidate type group. No
1112            // size metadata is needed; these are pattern-matched, never parsed.
1113            self.config_local.push(entry.into_path());
1114        }
1115        ignore::WalkState::Continue
1116    }
1117}
1118
1119fn contained_symlink_file_size(path: &Path, canonical_root: Option<&Path>) -> Option<u64> {
1120    let root = canonical_root?;
1121    let target = path.canonicalize().ok()?;
1122    if !target.starts_with(root) {
1123        return None;
1124    }
1125    let metadata = target.metadata().ok()?;
1126    metadata.is_file().then_some(metadata.len())
1127}
1128
1129impl Drop for FileVisitor<'_> {
1130    #[expect(
1131        clippy::expect_used,
1132        reason = "poisoned walk collector lock means worker state is unrecoverable"
1133    )]
1134    fn drop(&mut self) {
1135        if !self.local.is_empty() {
1136            self.shared
1137                .lock()
1138                .expect("walk collector lock poisoned")
1139                .append(&mut self.local);
1140        }
1141        if let Some(config_shared) = self.config_shared
1142            && !self.config_local.is_empty()
1143        {
1144            config_shared
1145                .lock()
1146                .expect("walk config collector lock poisoned")
1147                .append(&mut self.config_local);
1148        }
1149        if !self.excluded_local.is_empty() {
1150            let mut shared = self
1151                .excluded_shared
1152                .lock()
1153                .expect("walk exclusion collector lock poisoned");
1154            for (index, tally) in std::mem::take(&mut self.excluded_local) {
1155                shared.entry(index).or_default().merge(tally);
1156            }
1157        }
1158    }
1159}
1160
1161/// Builder that creates per-thread `FileVisitor` instances for the parallel walker.
1162struct FileVisitorBuilder<'a> {
1163    root: &'a Path,
1164    canonical_root: Option<&'a Path>,
1165    ignore_patterns: &'a IgnorePatternSet,
1166    user_ignore_pattern_count: usize,
1167    hidden_dir_scopes: &'a [HiddenDirScope],
1168    production_excludes: &'a Option<globset::GlobSet>,
1169    shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
1170    config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
1171    excluded_shared: &'a Mutex<ExclusionTally>,
1172}
1173
1174impl<'s> ignore::ParallelVisitorBuilder<'s> for FileVisitorBuilder<'s> {
1175    fn build(&mut self) -> Box<dyn ignore::ParallelVisitor + 's> {
1176        Box::new(FileVisitor {
1177            root: self.root,
1178            canonical_root: self.canonical_root,
1179            ignore_patterns: self.ignore_patterns,
1180            user_ignore_pattern_count: self.user_ignore_pattern_count,
1181            hidden_dir_scopes: self.hidden_dir_scopes,
1182            production_excludes: self.production_excludes,
1183            shared: self.shared,
1184            config_shared: self.config_shared,
1185            excluded_shared: self.excluded_shared,
1186            local: Vec::new(),
1187            config_local: Vec::new(),
1188            excluded_local: ExclusionTally::default(),
1189            match_buf: Vec::new(),
1190        })
1191    }
1192}
1193
1194/// File extensions discovery treats as analyzable source files.
1195pub const SOURCE_EXTENSIONS: &[&str] = &[
1196    "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
1197    "mdx", "css", "scss", "sass", "less", "html", "graphql", "gql",
1198];
1199
1200/// Glob patterns for test/dev/story files excluded in production mode.
1201pub const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
1202    "**/*.test.*",
1203    "**/*.spec.*",
1204    "**/*.e2e.*",
1205    "**/*.e2e-spec.*",
1206    "**/*.bench.*",
1207    "**/*.fixture.*",
1208    "**/*.stories.*",
1209    "**/*.story.*",
1210    "**/__tests__/**",
1211    "**/__mocks__/**",
1212    "**/__snapshots__/**",
1213    "**/__fixtures__/**",
1214    "**/test-d/**",
1215    "**/test/**",
1216    "**/tests/**",
1217    "*.config.*",
1218    "**/.*.js",
1219    "**/.*.ts",
1220    "**/.*.mjs",
1221    "**/.*.cjs",
1222];
1223
1224/// Check if a hidden directory name is on the allowlist.
1225pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
1226    ALLOWED_HIDDEN_DIRS.iter().any(|&d| OsStr::new(d) == name)
1227}
1228
1229fn is_allowed_scoped_hidden_dir(
1230    name: &OsStr,
1231    path: &Path,
1232    additional_hidden_dir_scopes: &[HiddenDirScope],
1233) -> bool {
1234    additional_hidden_dir_scopes
1235        .iter()
1236        .any(|scope| scope.allows(path, name))
1237}
1238
1239/// Files Yarn Plug'n'Play writes at the workspace root. They carry source
1240/// extensions (`.pnp.cjs` is the multi-megabyte generated loader with the
1241/// install state inlined, `.pnp.loader.mjs` its ESM shim) but are install
1242/// artifacts, not project source, so the walker drops them by name.
1243const YARN_PNP_GENERATED_FILES: &[&str] = &[".pnp.cjs", ".pnp.loader.mjs"];
1244
1245fn is_yarn_pnp_generated_file(name: &OsStr) -> bool {
1246    YARN_PNP_GENERATED_FILES
1247        .iter()
1248        .any(|&f| OsStr::new(f) == name)
1249}
1250
1251/// Check if a hidden directory entry should be allowed through the filter.
1252///
1253/// Returns `true` if the entry is not hidden or is on the allowlist.
1254/// Hidden files (not directories) are allowed through since the type filter
1255/// handles them, except for the generated Yarn PnP files.
1256fn is_allowed_hidden_with_scopes(
1257    entry: &ignore::DirEntry,
1258    additional_hidden_dir_scopes: &[HiddenDirScope],
1259) -> bool {
1260    let name = entry.file_name();
1261    let name_str = name.to_string_lossy();
1262
1263    if !name_str.starts_with('.') {
1264        return true;
1265    }
1266
1267    if entry.file_type().is_some_and(|ft| !ft.is_dir()) {
1268        return !is_yarn_pnp_generated_file(name);
1269    }
1270
1271    is_allowed_hidden_dir(name)
1272        || is_allowed_scoped_hidden_dir(name, entry.path(), additional_hidden_dir_scopes)
1273}
1274
1275/// True when a parent directory of `path` is hidden and only a `!` exception
1276/// in `ignorePatterns` opened it: the allowlist and the plugin and script
1277/// scopes do not admit it. A file there is kept only when an exception
1278/// matches the file itself.
1279fn is_under_exception_only_hidden_dir(
1280    root: &Path,
1281    path: &Path,
1282    hidden_dir_scopes: &[HiddenDirScope],
1283) -> bool {
1284    path.ancestors()
1285        .skip(1)
1286        .take_while(|dir| *dir != root && dir.starts_with(root))
1287        .any(|dir| {
1288            dir.file_name().is_some_and(|name| {
1289                name.to_string_lossy().starts_with('.')
1290                    && !is_allowed_hidden_dir(name)
1291                    && !is_allowed_scoped_hidden_dir(name, dir, hidden_dir_scopes)
1292            })
1293        })
1294}
1295
1296/// Discover all source files in the project.
1297///
1298/// # Panics
1299///
1300/// Panics if the file type glob or progress template is invalid (compile-time constants).
1301pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
1302    discover_files_with_additional_hidden_dirs(config, &[])
1303}
1304
1305/// The set of config-file basenames (last path component of every built-in
1306/// plugin `config_patterns()` entry, brace forms preserved) that the walk should
1307/// additionally admit so non-source configs (`tsconfig.json`, `bunfig.toml`,
1308/// `.eslintrc.json`, ...) can be captured in one traversal instead of being
1309/// re-discovered by a filesystem re-walk in `discover_config_files`.
1310///
1311/// Derived live from the built-in plugin list, so it can never drift behind a
1312/// new plugin's config patterns. Source-extension config basenames
1313/// (`vite.config.{ts,js}`) are admitted too, but the walk visitor routes them
1314/// back to the source channel by extension, so the config channel only ever
1315/// collects genuinely non-source files.
1316fn config_candidate_basename_globs() -> &'static [String] {
1317    static GLOBS: OnceLock<Vec<String>> = OnceLock::new();
1318    GLOBS.get_or_init(|| {
1319        let mut set: FxHashSet<String> = FxHashSet::default();
1320        for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
1321            for pattern in plugin.config_patterns() {
1322                let basename = pattern.rsplit('/').next().unwrap_or(pattern);
1323                set.insert(basename.to_string());
1324            }
1325            // Tooling evidence resolves always-used files, such as `.c8rc.json`,
1326            // in workspace directories against the candidate index. A wildcard
1327            // basename such as `*` would capture every file, so it stays out.
1328            if plugin.tooling_dependencies().is_empty() {
1329                continue;
1330            }
1331            for pattern in plugin.always_used() {
1332                let basename = pattern.rsplit('/').next().unwrap_or(pattern);
1333                if !basename.starts_with('!') && !basename.contains(['*', '?', '[']) {
1334                    set.insert(basename.to_string());
1335                }
1336            }
1337        }
1338        let mut globs: Vec<String> = set.into_iter().collect();
1339        globs.sort_unstable();
1340        globs
1341    })
1342}
1343
1344/// True when `path`'s extension is one of the known source extensions, i.e. the
1345/// file belongs in the source channel rather than the config-candidate channel.
1346fn has_source_extension(path: &Path) -> bool {
1347    path.extension()
1348        .and_then(OsStr::to_str)
1349        .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
1350}
1351
1352/// Build the file-type filter. Always selects known source extensions; when
1353/// `capture_config` is set, also selects config-candidate basenames so the
1354/// walker yields them for the second collection channel.
1355#[expect(
1356    clippy::expect_used,
1357    reason = "source file globs are hard-coded compile-time constants"
1358)]
1359fn build_walk_types(capture_config: bool) -> ignore::types::Types {
1360    static SOURCE_TYPES: OnceLock<ignore::types::Types> = OnceLock::new();
1361    static SOURCE_AND_CONFIG_TYPES: OnceLock<ignore::types::Types> = OnceLock::new();
1362
1363    let cache = if capture_config {
1364        &SOURCE_AND_CONFIG_TYPES
1365    } else {
1366        &SOURCE_TYPES
1367    };
1368    cache
1369        .get_or_init(|| {
1370            let mut types_builder = ignore::types::TypesBuilder::new();
1371            let source_glob = format!("*.{{{}}}", SOURCE_EXTENSIONS.join(","));
1372            types_builder
1373                .add("source", &source_glob)
1374                .expect("valid glob");
1375            types_builder.select("source");
1376            if capture_config {
1377                for glob in config_candidate_basename_globs() {
1378                    // Ignore individually-invalid plugin patterns rather than panicking;
1379                    // a malformed pattern simply fails to admit its config file (the
1380                    // pre-existing filesystem fallback still covers production mode).
1381                    let _ = types_builder.add("config", glob);
1382                }
1383                types_builder.select("config");
1384            }
1385            types_builder.build().expect("valid types")
1386        })
1387        .clone()
1388}
1389
1390/// Construct the parallel walker, applying the appropriate hidden-dir filter.
1391/// When `capture_config` is set the walk also yields config-candidate files for
1392/// the secondary collection channel.
1393fn build_source_walk_builder(
1394    config: &ResolvedConfig,
1395    additional_hidden_dir_scopes: &[HiddenDirScope],
1396    capture_config: bool,
1397    skipped_dotdirs: &SkippedDotdirSink,
1398) -> WalkBuilder {
1399    let mut walk_builder = fallow_config::source_walk_builder(&config.root);
1400    walk_builder
1401        .types(build_walk_types(capture_config))
1402        .threads(config.threads);
1403    // One filter, not two: `filter_entry` replaces rather than chains, and the
1404    // dropped-dotdir record has to happen on the same false path that decides
1405    // the skip so the allowlist and every plugin- or script-contributed scope
1406    // are excluded by construction (issue #461).
1407    let scopes = additional_hidden_dir_scopes.to_vec();
1408    let sink = Arc::clone(skipped_dotdirs);
1409    let root = config.root.clone();
1410    let ignore_patterns = config.ignore_patterns.clone();
1411    walk_builder.filter_entry(move |entry| {
1412        if is_allowed_hidden_with_scopes(entry, &scopes) {
1413            return true;
1414        }
1415        // A `!` entry in `ignorePatterns` that can match a file in this hidden
1416        // directory opens it (issue #2452). The visitor then keeps only the
1417        // files an exception matches.
1418        if ignore_patterns.has_exceptions()
1419            && entry.file_type().is_some_and(|ft| ft.is_dir())
1420            && ignore_patterns.admits_hidden_dir(
1421                entry
1422                    .path()
1423                    .strip_prefix(&root)
1424                    .unwrap_or_else(|_| entry.path()),
1425            )
1426        {
1427            return true;
1428        }
1429        if entry.file_type().is_some_and(|ft| ft.is_dir())
1430            && let Ok(mut collected) = sink.lock()
1431        {
1432            collected.push(entry.path().to_path_buf());
1433        }
1434        false
1435    });
1436    walk_builder
1437}
1438
1439/// Compile the production-mode exclude glob set, or `None` outside production mode.
1440fn build_production_excludes(config: &ResolvedConfig) -> Option<globset::GlobSet> {
1441    if !config.production {
1442        return None;
1443    }
1444    let mut builder = globset::GlobSetBuilder::new();
1445    for pattern in PRODUCTION_EXCLUDE_PATTERNS {
1446        if let Ok(glob) = globset::GlobBuilder::new(pattern)
1447            .literal_separator(true)
1448            .build()
1449        {
1450            builder.add(glob);
1451        }
1452    }
1453    builder.build().ok()
1454}
1455
1456/// Discover all source files in the project, with package-scoped hidden dirs.
1457///
1458/// # Panics
1459///
1460/// Panics if the file type glob or progress template is invalid (compile-time constants).
1461pub fn discover_files_with_additional_hidden_dirs(
1462    config: &ResolvedConfig,
1463    additional_hidden_dir_scopes: &[HiddenDirScope],
1464) -> Vec<DiscoveredFile> {
1465    discover_files_and_config_candidates(config, additional_hidden_dir_scopes).0
1466}
1467
1468/// Discover source files AND, in one traversal, the non-source config-candidate
1469/// files (`tsconfig.json`, `bunfig.toml`, `.eslintrc.json`, ...) used by
1470/// `discover_config_files` to resolve plugin config patterns in-memory instead of
1471/// re-walking the filesystem.
1472///
1473/// The returned `Vec<DiscoveredFile>` is byte-for-byte identical to the
1474/// config-capture-disabled walk: config candidates are routed to the second
1475/// return value by extension and never enter the source channel. Config capture
1476/// is skipped in production mode (where the walk applies `PRODUCTION_EXCLUDE_PATTERNS`
1477/// and `discover_config_files` keeps its filesystem path), so the second vector is
1478/// empty there.
1479///
1480/// # Panics
1481///
1482/// Panics if the file type glob or progress template is invalid (compile-time constants).
1483pub fn discover_files_and_config_candidates(
1484    config: &ResolvedConfig,
1485    additional_hidden_dir_scopes: &[HiddenDirScope],
1486) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
1487    let discovered =
1488        discover_files_config_candidates_and_diagnostics(config, additional_hidden_dir_scopes);
1489    (discovered.files, discovered.config_candidates)
1490}
1491
1492/// Source files, config candidates, and the source-discovery diagnostics one
1493/// walk produced.
1494///
1495/// `diagnostics` is the walk's OWN skip list, not a read of the process-wide
1496/// registry: combined mode can run two walks on the same root concurrently, and
1497/// each walk replaces the registry's source-discovery entries, so only the
1498/// by-value list is a stable answer to "what did THIS analysis skip" (issue
1499/// #2366).
1500pub struct DiscoveredSources {
1501    /// Source files with stable path-sorted [`FileId`]s.
1502    pub files: Vec<DiscoveredFile>,
1503    /// Non-source config-candidate paths captured in the same traversal.
1504    pub config_candidates: Vec<PathBuf>,
1505    /// Skipped-large-file, skipped-minified-file, and skipped-source-dotdir
1506    /// diagnostics from this walk.
1507    pub diagnostics: Vec<WorkspaceDiagnostic>,
1508}
1509
1510/// [`discover_files_and_config_candidates`] plus the source-discovery
1511/// diagnostics this walk recorded, for callers that must carry a per-analysis
1512/// snapshot instead of reading the shared registry back (issue #2366).
1513///
1514/// # Panics
1515///
1516/// Panics if the file type glob or progress template is invalid (compile-time constants).
1517#[expect(
1518    clippy::cast_possible_truncation,
1519    reason = "file count is bounded by project size, well under u32::MAX"
1520)]
1521#[expect(clippy::expect_used, reason = "the collector lock must remain usable")]
1522pub fn discover_files_config_candidates_and_diagnostics(
1523    config: &ResolvedConfig,
1524    additional_hidden_dir_scopes: &[HiddenDirScope],
1525) -> DiscoveredSources {
1526    let _span = tracing::info_span!("discover_files").entered();
1527
1528    let capture_config = !config.production;
1529    let skipped_dotdirs: SkippedDotdirSink = Arc::new(Mutex::new(Vec::new()));
1530    let walk_builder = build_source_walk_builder(
1531        config,
1532        additional_hidden_dir_scopes,
1533        capture_config,
1534        &skipped_dotdirs,
1535    );
1536    let production_excludes = build_production_excludes(config);
1537    let canonical_root = config.root.canonicalize().ok();
1538
1539    let collected: Mutex<Vec<(std::path::PathBuf, u64)>> = Mutex::new(Vec::new());
1540    let config_collected: Mutex<Vec<std::path::PathBuf>> = Mutex::new(Vec::new());
1541    let excluded_collected: Mutex<ExclusionTally> = Mutex::new(ExclusionTally::default());
1542    let mut visitor_builder = FileVisitorBuilder {
1543        root: &config.root,
1544        canonical_root: canonical_root.as_deref(),
1545        ignore_patterns: &config.ignore_patterns,
1546        user_ignore_pattern_count: config.user_ignore_pattern_count,
1547        hidden_dir_scopes: additional_hidden_dir_scopes,
1548        production_excludes: &production_excludes,
1549        shared: &collected,
1550        config_shared: capture_config.then_some(&config_collected),
1551        excluded_shared: &excluded_collected,
1552    };
1553    walk_builder.build_parallel().visit(&mut visitor_builder);
1554
1555    let mut raw = collected
1556        .into_inner()
1557        .expect("walk collector lock poisoned");
1558    // ADR-004 (path-sorted FileIds): the parallel walk visits files in
1559    // nondeterministic order, so we sort by absolute path BEFORE the
1560    // `.enumerate()` FileId assignment below. This is the stable-cross-run
1561    // identity invariant the persisted graph cache depends on: an identical
1562    // file set yields identical FileIds, so a cache hit (same paths +
1563    // fingerprints) can trust graph data persisted by FileId. Do not replace
1564    // this with insertion-order assignment.
1565    raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
1566
1567    let mut config_candidates = config_collected
1568        .into_inner()
1569        .expect("walk config collector lock poisoned");
1570    config_candidates.sort_unstable();
1571
1572    let excluded_by_default_ignore = excluded_collected
1573        .into_inner()
1574        .expect("walk exclusion collector lock poisoned");
1575
1576    // The parallel walk records dotdirs in nondeterministic thread order, and
1577    // the diagnostic array order is part of the JSON contract, so sort and
1578    // dedupe before the predicate runs (issue #2366).
1579    let mut dotdir_candidates = skipped_dotdirs
1580        .lock()
1581        .map_or_else(|_| Vec::new(), |mut guard| std::mem::take(&mut *guard));
1582    dotdir_candidates.sort_unstable();
1583    dotdir_candidates.dedup();
1584
1585    let (kept, skipped) = partition_by_size(raw, config.max_file_size_bytes);
1586    let (kept, skipped_minified) =
1587        partition_minified_generated_js(kept, config.max_file_size_bytes);
1588    // One registry write replaces this root's whole source-discovery set, so a
1589    // stale entry from a previous pass drops out (issue #1086) without a window
1590    // in which a concurrent walk on the same root can observe or clobber a
1591    // half-written set (issue #2366).
1592    let diagnostics = fallow_config::replace_source_discovery_diagnostics(
1593        &config.root,
1594        report_skipped_large_files(config, &skipped)
1595            .into_iter()
1596            .chain(report_skipped_minified_files(config, &skipped_minified))
1597            .chain(report_skipped_source_dotdirs(
1598                config,
1599                production_excludes.as_ref(),
1600                &dotdir_candidates,
1601            ))
1602            .chain(report_default_ignore_exclusions(
1603                config,
1604                &excluded_by_default_ignore,
1605            ))
1606            .chain(report_missing_node_modules(config))
1607            .chain(report_no_source_files_analyzed(
1608                config,
1609                kept.len(),
1610                &excluded_by_default_ignore,
1611            ))
1612            .collect(),
1613    );
1614
1615    let files: Vec<DiscoveredFile> = kept
1616        .into_iter()
1617        .enumerate()
1618        .map(|(idx, (path, size_bytes))| DiscoveredFile {
1619            id: FileId(idx as u32),
1620            path,
1621            size_bytes,
1622        })
1623        .collect();
1624
1625    note_largest_files(config, &files);
1626    warn_legacy_glob_narrowings(config, &files);
1627
1628    DiscoveredSources {
1629        files,
1630        config_candidates,
1631        diagnostics,
1632    }
1633}
1634
1635#[cfg(test)]
1636mod tests {
1637    use std::ffi::OsStr;
1638    use std::path::MAIN_SEPARATOR;
1639
1640    use super::*;
1641
1642    /// Issue #2638: the anchor a directory-shaped built-in reports is the
1643    /// directory a reader can act on and the one `fallow --root` takes, not
1644    /// the deepest directory that happens to hold the file.
1645    #[test]
1646    fn exclusion_scope_stops_at_the_patterns_literal_directory_segment() {
1647        assert_eq!(
1648            exclusion_scope(
1649                Path::new("projects/app/build/static/js/main.js"),
1650                "**/build/**"
1651            ),
1652            PathBuf::from("projects/app/build")
1653        );
1654        assert_eq!(
1655            exclusion_scope(Path::new("dist/a.ts"), "**/dist/**"),
1656            PathBuf::from("dist")
1657        );
1658        assert_eq!(
1659            exclusion_scope(
1660                Path::new("node_modules/react/index.js"),
1661                "**/node_modules/**"
1662            ),
1663            PathBuf::from("node_modules"),
1664            "the helper is shape-only: `**/node_modules/**` is never tallied, \
1665             but a directory-shaped pattern still collapses to its literal segment"
1666        );
1667    }
1668
1669    /// The DEEPEST matching segment wins, because the anchor is the directory
1670    /// the `--root` remedy names. Anchoring at the outermost `build` would
1671    /// leave the inner one in the path relative to the new root, so the
1672    /// built-in would match again and the advertised remedy would recover
1673    /// nothing.
1674    #[test]
1675    fn exclusion_scope_takes_the_deepest_matching_segment() {
1676        assert_eq!(
1677            exclusion_scope(Path::new("build/tools/build/a.ts"), "**/build/**"),
1678            PathBuf::from("build/tools/build")
1679        );
1680        assert_eq!(
1681            exclusion_scope(Path::new("dist/pkg/dist/inner/a.ts"), "**/dist/**"),
1682            PathBuf::from("dist/pkg/dist")
1683        );
1684    }
1685
1686    /// A file-shaped pattern has no literal segment to stop at, so the file's
1687    /// own directory is the most specific honest answer.
1688    #[test]
1689    fn exclusion_scope_falls_back_to_the_parent_for_a_file_shaped_pattern() {
1690        assert_eq!(
1691            exclusion_scope(Path::new("vendor/a.min.js"), "**/*.min.js"),
1692            PathBuf::from("vendor")
1693        );
1694        assert_eq!(
1695            exclusion_scope(Path::new("a.min.js"), "**/*.min.js"),
1696            PathBuf::new(),
1697            "a root-level match anchors at the root itself"
1698        );
1699    }
1700
1701    /// A pattern whose literal segment is absent from the path (only reachable
1702    /// through a future pattern shape) degrades to the parent rather than
1703    /// returning the whole path.
1704    #[test]
1705    fn exclusion_scope_falls_back_when_the_literal_segment_is_absent() {
1706        assert_eq!(
1707            exclusion_scope(Path::new("src/nested/a.ts"), "**/build/**"),
1708            PathBuf::from("src/nested")
1709        );
1710    }
1711
1712    /// Issue #2638: `directory_count` distinguishes one contained tree from an
1713    /// exclusion scattered over sibling packages, which is what keeps the
1714    /// rendered message from claiming a majority it does not have.
1715    #[test]
1716    fn the_directory_count_is_the_number_of_distinct_scopes() {
1717        let mut one_tree = ExcludedByPattern::default();
1718        one_tree.record(PathBuf::from("packages/web/build"));
1719        one_tree.record(PathBuf::from("packages/web/build"));
1720        assert_eq!(one_tree.file_count, 2);
1721        assert_eq!(one_tree.directory_count(), 1);
1722
1723        let mut scattered = ExcludedByPattern::default();
1724        scattered.record(PathBuf::from("packages/a/dist"));
1725        scattered.record(PathBuf::from("packages/b/dist"));
1726        assert_eq!(scattered.directory_count(), 2);
1727    }
1728
1729    /// Issue #2638 (AC2): the anchor is the directory with the most excluded
1730    /// files, and a tie resolves to the lexicographically first path so two
1731    /// runs on one tree report the same location.
1732    #[test]
1733    fn the_anchor_is_the_largest_group_with_ties_broken_by_path() {
1734        let mut tally = ExcludedByPattern::default();
1735        for _ in 0..3 {
1736            tally.record(PathBuf::from("packages/web/build"));
1737        }
1738        tally.record(PathBuf::from("packages/api/build"));
1739        assert_eq!(tally.file_count, 4);
1740        assert_eq!(tally.anchor(), PathBuf::from("packages/web/build"));
1741
1742        let mut tied = ExcludedByPattern::default();
1743        tied.record(PathBuf::from("z/build"));
1744        tied.record(PathBuf::from("a/build"));
1745        assert_eq!(tied.anchor(), PathBuf::from("a/build"));
1746    }
1747
1748    /// Per-thread tallies merge into one exact total, which is what makes
1749    /// `file_count` trustworthy on a parallel walk.
1750    #[test]
1751    fn merging_two_thread_tallies_keeps_the_count_exact() {
1752        let mut left = ExcludedByPattern::default();
1753        left.record(PathBuf::from("dist"));
1754        left.record(PathBuf::from("dist"));
1755        let mut right = ExcludedByPattern::default();
1756        right.record(PathBuf::from("dist"));
1757        right.record(PathBuf::from("packages/ui/dist"));
1758
1759        left.merge(right);
1760        assert_eq!(left.file_count, 4);
1761        assert_eq!(left.scopes.get(Path::new("dist")), Some(&3));
1762        assert_eq!(left.anchor(), PathBuf::from("dist"));
1763    }
1764
1765    #[test]
1766    fn skipped_dotdirs_note_names_the_directory_when_there_is_one() {
1767        let root = Path::new("/repo");
1768        let only = PathBuf::from("/repo/.tooling");
1769        let note = build_skipped_dotdirs_note(root, &[&only], false);
1770        assert!(note.contains("skipped 1 hidden directory that contains source files"));
1771        assert!(note.contains("add it to entry, ignoreExports or ignoreDependencies"));
1772        assert!(note.contains("fallow --root .tooling analyzes it on its own"));
1773        assert!(note.contains("does not fix this run"));
1774        assert!(note.contains("add '.tooling/**' to"));
1775        assert!(note.contains("add '!.tooling/**' to ignorePatterns to analyze it"));
1776        assert!(
1777            !note.contains("<dir>"),
1778            "the single-directory remedy must be copy-pasteable: {note}"
1779        );
1780    }
1781
1782    #[test]
1783    fn skipped_dotdirs_note_pluralizes_and_keeps_the_placeholder() {
1784        let root = Path::new("/repo");
1785        let a = PathBuf::from("/repo/.a");
1786        let b = PathBuf::from("/repo/.b");
1787        let note = build_skipped_dotdirs_note(root, &[&a, &b], false);
1788        assert!(note.contains("skipped 2 hidden directories that contain source files"));
1789        assert!(note.contains("fallow --root <dir> analyzes one on its own"));
1790    }
1791
1792    #[test]
1793    fn skipped_dotdirs_note_drops_the_tail_count_when_truncated() {
1794        let root = Path::new("/repo");
1795        let owned: Vec<PathBuf> = (0..8)
1796            .map(|i| PathBuf::from(format!("/repo/.d{i}")))
1797            .collect();
1798        let reportable: Vec<&PathBuf> = owned.iter().collect();
1799
1800        let bounded = build_skipped_dotdirs_note(root, &reportable, true);
1801        assert!(bounded.contains("skipped at least 8 hidden directories"));
1802        assert!(
1803            bounded.contains("and more") && !bounded.contains("and 3 more"),
1804            "an inexact total must not carry an exact remainder: {bounded}"
1805        );
1806
1807        let complete = build_skipped_dotdirs_note(root, &reportable, false);
1808        assert!(!complete.contains("at least"));
1809        assert!(complete.contains("and 3 more"));
1810    }
1811
1812    #[test]
1813    fn dotdir_noise_path_components_stay_sorted_and_lowercase() {
1814        let mut sorted = DOTDIR_NOISE_PATH_COMPONENTS.to_vec();
1815        sorted.sort_unstable();
1816        assert_eq!(sorted, DOTDIR_NOISE_PATH_COMPONENTS);
1817        for component in DOTDIR_NOISE_PATH_COMPONENTS {
1818            assert!(!component.starts_with('.'), "'{component}' is not hidden");
1819            assert_eq!(
1820                *component,
1821                component.to_lowercase(),
1822                "'{component}' is matched verbatim against a path component"
1823            );
1824        }
1825    }
1826
1827    #[test]
1828    fn script_scope_denylist_stays_disjoint_and_sorted() {
1829        let mut sorted = SCRIPT_SCOPE_DENYLIST.to_vec();
1830        sorted.sort_unstable();
1831        assert_eq!(
1832            sorted, SCRIPT_SCOPE_DENYLIST,
1833            "keep the list sorted so additions stay reviewable"
1834        );
1835        for dir in SCRIPT_SCOPE_DENYLIST {
1836            assert!(dir.starts_with('.'), "'{dir}' is not a hidden directory");
1837            assert!(
1838                !ALLOWED_HIDDEN_DIRS.contains(dir),
1839                "'{dir}' is traversed, so it can never be a skipped candidate"
1840            );
1841        }
1842    }
1843
1844    #[test]
1845    fn dotdir_module_extensions_are_a_subset_of_source_extensions() {
1846        for ext in DOTDIR_MODULE_EXTENSIONS {
1847            assert!(
1848                SOURCE_EXTENSIONS.contains(ext),
1849                "'{ext}' is not discovered as source, so it cannot be a trigger"
1850            );
1851        }
1852        for ext in ["css", "scss", "sass", "less", "html", "graphql", "gql"] {
1853            assert!(
1854                !DOTDIR_MODULE_EXTENSIONS.contains(&ext),
1855                "'{ext}' carries no imports or exports for the message to be about"
1856            );
1857        }
1858    }
1859
1860    /// Reproduce the FileId-assignment rule used by `walk_source_files`: sort by
1861    /// absolute path, then assign `FileId(idx)` in that order.
1862    fn assign_file_ids(mut raw: Vec<(std::path::PathBuf, u64)>) -> Vec<DiscoveredFile> {
1863        raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
1864        raw.into_iter()
1865            .enumerate()
1866            .map(|(idx, (path, size_bytes))| DiscoveredFile {
1867                id: FileId(idx as u32),
1868                path,
1869                size_bytes,
1870            })
1871            .collect()
1872    }
1873
1874    /// ADR-004: an identical file set must yield identical FileIds regardless of
1875    /// the (nondeterministic, parallel) discovery order. The persisted graph
1876    /// cache keys persisted graph data by FileId, so a cache HIT (same paths +
1877    /// fingerprints) must reproduce the exact same FileId-to-path mapping the
1878    /// graph was built against. This guards the cache's soundness prerequisite.
1879    #[test]
1880    fn file_id_assignment_is_deterministic_for_identical_file_set() {
1881        let paths = [
1882            "/project/src/z.ts",
1883            "/project/src/a.ts",
1884            "/project/src/components/Button.tsx",
1885            "/project/src/components/Button.module.css",
1886            "/project/index.ts",
1887        ];
1888
1889        // Two independent walks that observe the same paths in DIFFERENT orders.
1890        let walk_one: Vec<(std::path::PathBuf, u64)> = paths
1891            .iter()
1892            .map(|p| (std::path::PathBuf::from(p), 10))
1893            .collect();
1894        let mut walk_two = walk_one.clone();
1895        walk_two.reverse();
1896
1897        let files_one = assign_file_ids(walk_one);
1898        let files_two = assign_file_ids(walk_two);
1899
1900        // Identical (FileId -> path) mapping despite the different walk orders.
1901        assert_eq!(files_one.len(), files_two.len());
1902        for (a, b) in files_one.iter().zip(files_two.iter()) {
1903            assert_eq!(a.id, b.id);
1904            assert_eq!(a.path, b.path);
1905        }
1906
1907        // The mapping is the path-sorted order, and each FileId equals its index
1908        // (the density invariant `project.rs` asserts and the graph relies on).
1909        for (idx, file) in files_one.iter().enumerate() {
1910            assert_eq!(file.id, FileId(idx as u32));
1911        }
1912        assert_eq!(
1913            files_one[0].path,
1914            std::path::PathBuf::from("/project/index.ts")
1915        );
1916    }
1917
1918    #[test]
1919    fn file_id_assignment_recomputes_after_rename_or_delete() {
1920        let before = assign_file_ids(vec![
1921            (std::path::PathBuf::from("/project/src/a.ts"), 10),
1922            (std::path::PathBuf::from("/project/src/b.ts"), 10),
1923            (std::path::PathBuf::from("/project/src/c.ts"), 10),
1924        ]);
1925        let after_delete = assign_file_ids(vec![
1926            (std::path::PathBuf::from("/project/src/a.ts"), 10),
1927            (std::path::PathBuf::from("/project/src/c.ts"), 10),
1928        ]);
1929        let after_rename = assign_file_ids(vec![
1930            (std::path::PathBuf::from("/project/src/a.ts"), 10),
1931            (std::path::PathBuf::from("/project/src/c.ts"), 10),
1932            (std::path::PathBuf::from("/project/src/d.ts"), 10),
1933        ]);
1934
1935        assert_eq!(before[0].id, FileId(0));
1936        assert_eq!(before[1].id, FileId(1));
1937        assert_eq!(before[2].id, FileId(2));
1938        assert_eq!(after_delete[0].id, FileId(0));
1939        assert_eq!(after_delete[1].id, FileId(1));
1940        assert_eq!(
1941            after_delete[1].path,
1942            std::path::PathBuf::from("/project/src/c.ts")
1943        );
1944        assert_eq!(after_rename[0].id, FileId(0));
1945        assert_eq!(after_rename[1].id, FileId(1));
1946        assert_eq!(
1947            after_rename[1].path,
1948            std::path::PathBuf::from("/project/src/c.ts")
1949        );
1950        assert_eq!(after_rename[2].id, FileId(2));
1951        assert_eq!(
1952            after_rename[2].path,
1953            std::path::PathBuf::from("/project/src/d.ts")
1954        );
1955    }
1956
1957    #[test]
1958    fn allowed_hidden_dirs() {
1959        assert!(is_allowed_hidden_dir(OsStr::new(".storybook")));
1960        assert!(is_allowed_hidden_dir(OsStr::new(".vitepress")));
1961        assert!(is_allowed_hidden_dir(OsStr::new(".well-known")));
1962        assert!(is_allowed_hidden_dir(OsStr::new(".changeset")));
1963        assert!(is_allowed_hidden_dir(OsStr::new(".github")));
1964    }
1965
1966    #[test]
1967    fn disallowed_hidden_dirs() {
1968        assert!(!is_allowed_hidden_dir(OsStr::new(".git")));
1969        assert!(!is_allowed_hidden_dir(OsStr::new(".cache")));
1970        assert!(!is_allowed_hidden_dir(OsStr::new(".vscode")));
1971        assert!(!is_allowed_hidden_dir(OsStr::new(".fallow")));
1972        assert!(!is_allowed_hidden_dir(OsStr::new(".next")));
1973    }
1974
1975    #[test]
1976    fn non_hidden_dirs_not_in_allowlist() {
1977        assert!(!is_allowed_hidden_dir(OsStr::new("src")));
1978        assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
1979    }
1980
1981    #[test]
1982    fn walk_types_match_every_supported_source_extension() {
1983        for capture_config in [false, true] {
1984            let types = build_walk_types(capture_config);
1985            for extension in SOURCE_EXTENSIONS {
1986                let path = format!("packages/ui/src/nested/component.{extension}");
1987                assert!(
1988                    types.matched(&path, false).is_whitelist(),
1989                    "expected source match for {path} with capture_config={capture_config}"
1990                );
1991            }
1992        }
1993    }
1994
1995    #[test]
1996    fn walk_types_match_typescript_declaration_files() {
1997        let types = build_walk_types(true);
1998        for path in [
1999            "src/env.d.ts",
2000            "packages/app/types/generated.d.mts",
2001            "packages/app/types/compat.d.cts",
2002        ] {
2003            assert!(
2004                types.matched(path, false).is_whitelist(),
2005                "expected declaration source match for {path}"
2006            );
2007        }
2008    }
2009
2010    #[test]
2011    fn walk_types_reject_source_extension_near_misses() {
2012        for capture_config in [false, true] {
2013            let types = build_walk_types(capture_config);
2014            for path in [
2015                "src/component.tsx.bak",
2016                "src/component.tsxmap",
2017                "src/component.TS",
2018                "src/component.gqlx",
2019                "src/component.htm",
2020                "src/component",
2021                "assets/component.png",
2022            ] {
2023                assert!(
2024                    types.matched(path, false).is_ignore(),
2025                    "expected non-source rejection for {path} with capture_config={capture_config}"
2026                );
2027            }
2028        }
2029    }
2030
2031    #[test]
2032    fn walk_types_keep_config_candidate_selection_separate() {
2033        assert!(
2034            build_walk_types(true)
2035                .matched("packages/app/tsconfig.json", false)
2036                .is_whitelist()
2037        );
2038        assert!(
2039            build_walk_types(false)
2040                .matched("packages/app/tsconfig.json", false)
2041                .is_ignore()
2042        );
2043    }
2044
2045    #[test]
2046    fn source_extensions_are_exactly_the_supported_set() {
2047        let mut actual = SOURCE_EXTENSIONS.to_vec();
2048        actual.sort_unstable();
2049        let mut expected = vec![
2050            "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte",
2051            "astro", "mdx", "css", "scss", "sass", "less", "html", "graphql", "gql",
2052        ];
2053        expected.sort_unstable();
2054        assert_eq!(actual, expected);
2055    }
2056
2057    /// The production exclude set that discovery uses in production mode.
2058    fn production_excludes() -> globset::GlobSet {
2059        let config = fallow_config::FallowConfig {
2060            production: true.into(),
2061            ..Default::default()
2062        }
2063        .resolve(
2064            std::path::PathBuf::from("/project"),
2065            fallow_config::OutputFormat::Human,
2066            1,
2067            true,
2068            true,
2069            None,
2070        );
2071        build_production_excludes(&config).expect("production mode builds an exclude set")
2072    }
2073
2074    /// `build_production_excludes` drops a pattern that does not compile, so a
2075    /// broken pattern would silently stop excluding its files.
2076    #[test]
2077    fn production_exclude_patterns_all_compile() {
2078        for pattern in PRODUCTION_EXCLUDE_PATTERNS {
2079            assert!(
2080                globset::GlobBuilder::new(pattern)
2081                    .literal_separator(true)
2082                    .build()
2083                    .is_ok(),
2084                "production exclude pattern does not compile: {pattern}"
2085            );
2086        }
2087    }
2088
2089    #[test]
2090    fn production_excludes_test_files() {
2091        let set = production_excludes();
2092        assert!(set.is_match("src/Button.test.ts"));
2093        assert!(set.is_match("src/utils.spec.tsx"));
2094        assert!(set.is_match("src/__tests__/helper.ts"));
2095        assert!(!set.is_match("src/Button.ts"));
2096        assert!(!set.is_match("src/utils.tsx"));
2097    }
2098
2099    #[test]
2100    fn production_excludes_story_files() {
2101        let set = production_excludes();
2102        assert!(set.is_match("src/Button.stories.tsx"));
2103        assert!(set.is_match("src/Card.story.ts"));
2104        assert!(!set.is_match("src/Button.tsx"));
2105    }
2106
2107    #[test]
2108    fn production_excludes_config_files_at_root_only() {
2109        let set = production_excludes();
2110        assert!(set.is_match("vitest.config.ts"));
2111        assert!(set.is_match("jest.config.js"));
2112        assert!(!set.is_match("src/app/app.config.ts"));
2113        assert!(!set.is_match("src/app/app.config.server.ts"));
2114        assert!(!set.is_match("packages/foo/vitest.config.ts"));
2115        assert!(!set.is_match("src/config.ts"));
2116    }
2117
2118    #[test]
2119    fn disallowed_hidden_dirs_idea() {
2120        assert!(!is_allowed_hidden_dir(OsStr::new(".idea")));
2121    }
2122
2123    #[test]
2124    fn is_declaration_file_matches_dts_variants() {
2125        assert!(is_declaration_file(Path::new("env.d.ts")));
2126        assert!(is_declaration_file(Path::new("src/auto-imports.d.ts")));
2127        assert!(is_declaration_file(Path::new("mod.d.mts")));
2128        assert!(is_declaration_file(Path::new("compat.d.cts")));
2129        assert!(!is_declaration_file(Path::new("index.ts")));
2130        assert!(!is_declaration_file(Path::new("component.tsx")));
2131        assert!(!is_declaration_file(Path::new("notes.d.txt")));
2132    }
2133
2134    #[test]
2135    fn format_size_mb_renders_one_decimal() {
2136        assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
2137        assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
2138        assert_eq!(format_size_mb(0), "0.0 MB");
2139    }
2140
2141    #[test]
2142    fn partition_by_size_no_limit_keeps_all() {
2143        let raw = vec![(PathBuf::from("a.ts"), 10), (PathBuf::from("b.ts"), 10_000)];
2144        let (kept, skipped) = partition_by_size(raw, None);
2145        assert_eq!(kept.len(), 2);
2146        assert!(skipped.is_empty());
2147    }
2148
2149    #[test]
2150    fn partition_by_size_skips_strictly_over_limit() {
2151        let raw = vec![
2152            (PathBuf::from("under.ts"), 99),
2153            (PathBuf::from("exact.ts"), 100),
2154            (PathBuf::from("over.ts"), 101),
2155        ];
2156        let (kept, skipped) = partition_by_size(raw, Some(100));
2157        let kept_has = |name: &str| kept.iter().any(|(p, _)| p.as_path() == Path::new(name));
2158        assert!(kept_has("under.ts"));
2159        assert!(
2160            kept_has("exact.ts"),
2161            "a file exactly at the limit is kept (skip is strictly-greater)"
2162        );
2163        assert_eq!(skipped.len(), 1);
2164        assert_eq!(skipped[0].0, PathBuf::from("over.ts"));
2165    }
2166
2167    #[test]
2168    fn partition_by_size_exempts_declaration_files() {
2169        let raw = vec![
2170            (PathBuf::from("huge.ts"), 10_000),
2171            (PathBuf::from("auto-imports.d.ts"), 10_000),
2172        ];
2173        let (kept, skipped) = partition_by_size(raw, Some(100));
2174        assert!(
2175            kept.iter()
2176                .any(|(p, _)| p.as_path() == Path::new("auto-imports.d.ts")),
2177            "declaration files are exempt from the size skip regardless of size"
2178        );
2179        assert_eq!(skipped.len(), 1);
2180        assert_eq!(skipped[0].0, PathBuf::from("huge.ts"));
2181    }
2182
2183    fn disco(path: &str, size_bytes: u64) -> DiscoveredFile {
2184        DiscoveredFile {
2185            id: FileId(0),
2186            path: PathBuf::from(path),
2187            size_bytes,
2188        }
2189    }
2190
2191    #[test]
2192    fn largest_files_note_below_threshold_is_none() {
2193        let files = [disco("a.ts", 100), disco("b.ts", 200)];
2194        assert!(build_largest_files_note(Path::new("/p"), &files).is_none());
2195    }
2196
2197    #[test]
2198    fn largest_files_note_single_file_uses_singular() {
2199        let files = [disco("big.ts", 5 * 1024 * 1024)];
2200        let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
2201        assert!(
2202            note.contains("discovered 1 file;"),
2203            "singular noun on the single-big-file path (issue #1086 regression): {note}"
2204        );
2205        assert!(!note.contains("discovered 1 files"));
2206        assert!(note.contains("big.ts (5.0 MB)"));
2207    }
2208
2209    #[test]
2210    fn largest_files_note_filters_sub_floor_files() {
2211        let files = [disco("big.ts", 5 * 1024 * 1024), disco("tiny.ts", 10)];
2212        let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
2213        assert!(note.contains("discovered 2 files;"));
2214        assert!(note.contains("big.ts (5.0 MB)"));
2215        assert!(
2216            !note.contains("tiny.ts"),
2217            "sub-floor files are not listed as `0.0 MB` chaff: {note}"
2218        );
2219    }
2220
2221    #[test]
2222    fn largest_files_note_large_set_no_big_file_omits_list() {
2223        let files: Vec<DiscoveredFile> = (0..=LARGE_SET_THRESHOLD)
2224            .map(|i| disco(&format!("f{i}.ts"), 100))
2225            .collect();
2226        let note = build_largest_files_note(Path::new("/p"), &files).expect("large set fires");
2227        assert!(note.contains(&format!("discovered {} files", LARGE_SET_THRESHOLD + 1)));
2228        assert!(
2229            !note.contains("largest:"),
2230            "no sub-floor `largest:` list when no file clears the floor: {note}"
2231        );
2232    }
2233
2234    mod discover_files_integration {
2235        use std::path::PathBuf;
2236
2237        use fallow_config::{
2238            DuplicatesConfig, FallowConfig, FlagsConfig, HealthConfig, OutputFormat, ResolveConfig,
2239            RulesConfig,
2240        };
2241
2242        use super::*;
2243
2244        /// Create a minimal ResolvedConfig pointing at the given root directory.
2245        fn make_config(root: PathBuf, production: bool) -> ResolvedConfig {
2246            FallowConfig {
2247                production: production.into(),
2248                ..Default::default()
2249            }
2250            .resolve(root, OutputFormat::Human, 1, true, true, None)
2251        }
2252
2253        /// Helper to collect discovered file names (relative to root) for assertions.
2254        /// Normalizes path separators to `/` for cross-platform test consistency.
2255        fn file_names(files: &[DiscoveredFile], root: &std::path::Path) -> Vec<String> {
2256            files
2257                .iter()
2258                .map(|f| {
2259                    f.path
2260                        .strip_prefix(root)
2261                        .unwrap_or(&f.path)
2262                        .to_string_lossy()
2263                        .replace('\\', "/")
2264                })
2265                .collect()
2266        }
2267
2268        #[cfg(unix)]
2269        fn symlink_file(target: &Path, link: &Path) {
2270            std::os::unix::fs::symlink(target, link).expect("create file symlink");
2271        }
2272
2273        #[cfg(windows)]
2274        fn symlink_file(target: &Path, link: &Path) {
2275            std::os::windows::fs::symlink_file(target, link).expect("create file symlink");
2276        }
2277
2278        #[cfg(unix)]
2279        fn symlink_dir(target: &Path, link: &Path) {
2280            std::os::unix::fs::symlink(target, link).expect("create directory symlink");
2281        }
2282
2283        #[cfg(windows)]
2284        fn symlink_dir(target: &Path, link: &Path) {
2285            std::os::windows::fs::symlink_dir(target, link).expect("create directory symlink");
2286        }
2287
2288        #[test]
2289        fn source_symlinks_must_target_regular_files_inside_root() {
2290            let dir = tempfile::tempdir().expect("create project");
2291            let outside = tempfile::tempdir().expect("create outside dir");
2292            let src = dir.path().join("src");
2293            std::fs::create_dir_all(&src).unwrap();
2294            std::fs::write(src.join("regular.ts"), "export const regular = 1;").unwrap();
2295            std::fs::write(src.join("inside-target.ts"), "export const inside = 1;").unwrap();
2296            std::fs::write(
2297                outside.path().join("outside-target.ts"),
2298                "export const outside = 1;",
2299            )
2300            .unwrap();
2301            std::fs::create_dir_all(src.join("directory-target")).unwrap();
2302
2303            symlink_file(&src.join("inside-target.ts"), &src.join("inside-link.ts"));
2304            symlink_file(
2305                &outside.path().join("outside-target.ts"),
2306                &src.join("outside-link.ts"),
2307            );
2308            symlink_file(&src.join("missing-target.ts"), &src.join("broken-link.ts"));
2309            symlink_dir(
2310                &src.join("directory-target"),
2311                &src.join("directory-link.ts"),
2312            );
2313
2314            let config = make_config(dir.path().to_path_buf(), false);
2315            let names = file_names(&discover_files(&config), dir.path());
2316
2317            assert!(names.contains(&"src/regular.ts".to_string()));
2318            assert!(names.contains(&"src/inside-target.ts".to_string()));
2319            assert!(names.contains(&"src/inside-link.ts".to_string()));
2320            assert!(!names.contains(&"src/outside-link.ts".to_string()));
2321            assert!(!names.contains(&"src/broken-link.ts".to_string()));
2322            assert!(!names.contains(&"src/directory-link.ts".to_string()));
2323        }
2324
2325        /// Yarn PnP writes `.pnp.cjs` and `.pnp.loader.mjs` at the workspace
2326        /// root. They match the source extension filter but are generated
2327        /// install state, not code to analyze.
2328        #[test]
2329        fn skips_yarn_pnp_generated_files() {
2330            let dir = tempfile::tempdir().expect("create temp dir");
2331            std::fs::write(dir.path().join(".pnp.cjs"), "module.exports = {};").unwrap();
2332            std::fs::write(dir.path().join(".pnp.loader.mjs"), "export {};").unwrap();
2333            std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
2334
2335            let config = make_config(dir.path().to_path_buf(), false);
2336            let names = file_names(&discover_files(&config), dir.path());
2337
2338            assert_eq!(names, vec!["index.ts".to_string()]);
2339        }
2340
2341        #[test]
2342        fn discovers_source_files_with_valid_extensions() {
2343            let dir = tempfile::tempdir().expect("create temp dir");
2344            let src = dir.path().join("src");
2345            std::fs::create_dir_all(&src).unwrap();
2346
2347            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2348            std::fs::write(src.join("component.tsx"), "export default () => {};").unwrap();
2349            std::fs::write(src.join("utils.js"), "module.exports = {};").unwrap();
2350            std::fs::write(src.join("helper.jsx"), "export const h = 1;").unwrap();
2351            std::fs::write(src.join("config.mjs"), "export default {};").unwrap();
2352            std::fs::write(src.join("legacy.cjs"), "module.exports = {};").unwrap();
2353            std::fs::write(src.join("types.mts"), "export type T = string;").unwrap();
2354            std::fs::write(src.join("compat.cts"), "module.exports = {};").unwrap();
2355
2356            let config = make_config(dir.path().to_path_buf(), false);
2357            let files = discover_files(&config);
2358            let names = file_names(&files, dir.path());
2359
2360            assert!(names.contains(&"src/app.ts".to_string()));
2361            assert!(names.contains(&"src/component.tsx".to_string()));
2362            assert!(names.contains(&"src/utils.js".to_string()));
2363            assert!(names.contains(&"src/helper.jsx".to_string()));
2364            assert!(names.contains(&"src/config.mjs".to_string()));
2365            assert!(names.contains(&"src/legacy.cjs".to_string()));
2366            assert!(names.contains(&"src/types.mts".to_string()));
2367            assert!(names.contains(&"src/compat.cts".to_string()));
2368        }
2369
2370        #[test]
2371        fn compact_source_glob_preserves_discovered_file_inventory() {
2372            let dir = tempfile::tempdir().expect("create temp dir");
2373            let nested = dir.path().join("packages/ui/src/nested");
2374            std::fs::create_dir_all(&nested).unwrap();
2375
2376            let mut expected = Vec::new();
2377            for (index, extension) in SOURCE_EXTENSIONS.iter().enumerate() {
2378                let relative = format!("packages/ui/src/nested/source-{index}.{extension}");
2379                std::fs::write(dir.path().join(&relative), "export const value = 1;").unwrap();
2380                expected.push(relative);
2381            }
2382            for relative in [
2383                "packages/ui/src/nested/env.d.ts",
2384                "packages/ui/src/nested/generated.d.mts",
2385                "packages/ui/src/nested/compat.d.cts",
2386            ] {
2387                std::fs::write(dir.path().join(relative), "export type Value = string;").unwrap();
2388                expected.push(relative.to_string());
2389            }
2390            let rejected = [
2391                "packages/ui/src/nested/component.tsx.bak",
2392                "packages/ui/src/nested/component.tsxmap",
2393                "packages/ui/src/nested/component.TS",
2394                "packages/ui/src/nested/component.gqlx",
2395                "packages/ui/src/nested/component.htm",
2396                "packages/ui/src/nested/component",
2397                "packages/ui/src/nested/component.png",
2398            ];
2399            for relative in rejected {
2400                std::fs::write(dir.path().join(relative), "not source").unwrap();
2401            }
2402
2403            let config = make_config(dir.path().to_path_buf(), false);
2404            let names = file_names(&discover_files(&config), dir.path());
2405
2406            for relative in expected {
2407                assert!(
2408                    names.contains(&relative),
2409                    "missing supported source {relative}"
2410                );
2411            }
2412            for relative in rejected {
2413                assert!(
2414                    !names.iter().any(|name| name == relative),
2415                    "unexpected near-miss source {relative}"
2416                );
2417            }
2418        }
2419
2420        #[test]
2421        fn excludes_non_source_extensions() {
2422            let dir = tempfile::tempdir().expect("create temp dir");
2423            let src = dir.path().join("src");
2424            std::fs::create_dir_all(&src).unwrap();
2425
2426            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2427
2428            std::fs::write(src.join("data.json"), "{}").unwrap();
2429            std::fs::write(src.join("readme.md"), "# Hello").unwrap();
2430            std::fs::write(src.join("notes.txt"), "notes").unwrap();
2431            std::fs::write(src.join("logo.png"), [0u8; 8]).unwrap();
2432
2433            let config = make_config(dir.path().to_path_buf(), false);
2434            let files = discover_files(&config);
2435            let names = file_names(&files, dir.path());
2436
2437            assert_eq!(names.len(), 1, "only the .ts file should be discovered");
2438            assert!(names.contains(&"src/app.ts".to_string()));
2439        }
2440
2441        #[test]
2442        fn excludes_disallowed_hidden_directories() {
2443            let dir = tempfile::tempdir().expect("create temp dir");
2444
2445            let git_dir = dir.path().join(".git");
2446            std::fs::create_dir_all(&git_dir).unwrap();
2447            std::fs::write(git_dir.join("hooks.ts"), "// git hook").unwrap();
2448
2449            let idea_dir = dir.path().join(".idea");
2450            std::fs::create_dir_all(&idea_dir).unwrap();
2451            std::fs::write(idea_dir.join("workspace.ts"), "// idea").unwrap();
2452
2453            let cache_dir = dir.path().join(".cache");
2454            std::fs::create_dir_all(&cache_dir).unwrap();
2455            std::fs::write(cache_dir.join("cached.js"), "// cached").unwrap();
2456
2457            let src = dir.path().join("src");
2458            std::fs::create_dir_all(&src).unwrap();
2459            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2460
2461            let config = make_config(dir.path().to_path_buf(), false);
2462            let files = discover_files(&config);
2463            let names = file_names(&files, dir.path());
2464
2465            assert_eq!(names.len(), 1, "only src/app.ts should be discovered");
2466            assert!(names.contains(&"src/app.ts".to_string()));
2467        }
2468
2469        #[test]
2470        fn includes_allowed_hidden_directories() {
2471            let dir = tempfile::tempdir().expect("create temp dir");
2472
2473            let storybook = dir.path().join(".storybook");
2474            std::fs::create_dir_all(&storybook).unwrap();
2475            std::fs::write(storybook.join("main.ts"), "export default {};").unwrap();
2476
2477            let github = dir.path().join(".github");
2478            std::fs::create_dir_all(&github).unwrap();
2479            std::fs::write(github.join("actions.js"), "module.exports = {};").unwrap();
2480
2481            let changeset = dir.path().join(".changeset");
2482            std::fs::create_dir_all(&changeset).unwrap();
2483            std::fs::write(changeset.join("config.js"), "module.exports = {};").unwrap();
2484
2485            let config = make_config(dir.path().to_path_buf(), false);
2486            let files = discover_files(&config);
2487            let names = file_names(&files, dir.path());
2488
2489            assert!(
2490                names.contains(&".storybook/main.ts".to_string()),
2491                "files in .storybook should be discovered"
2492            );
2493            assert!(
2494                names.contains(&".github/actions.js".to_string()),
2495                "files in .github should be discovered"
2496            );
2497            assert!(
2498                names.contains(&".changeset/config.js".to_string()),
2499                "files in .changeset should be discovered"
2500            );
2501        }
2502
2503        #[test]
2504        fn default_discovery_excludes_client_and_server_hidden_directories() {
2505            let dir = tempfile::tempdir().expect("create temp dir");
2506            let app = dir.path().join("app");
2507            std::fs::create_dir_all(app.join(".client")).unwrap();
2508            std::fs::create_dir_all(app.join(".server")).unwrap();
2509            std::fs::write(app.join(".client/analytics.ts"), "export const a = 1;").unwrap();
2510            std::fs::write(app.join(".server/db.ts"), "export const db = {};").unwrap();
2511            std::fs::write(app.join("root.tsx"), "export default function Root() {}").unwrap();
2512
2513            let config = make_config(dir.path().to_path_buf(), false);
2514            let files = discover_files(&config);
2515            let names = file_names(&files, dir.path());
2516
2517            assert!(names.contains(&"app/root.tsx".to_string()));
2518            assert!(!names.contains(&"app/.client/analytics.ts".to_string()));
2519            assert!(!names.contains(&"app/.server/db.ts".to_string()));
2520        }
2521
2522        #[test]
2523        fn scoped_hidden_dirs_include_client_and_server_under_package_root() {
2524            let dir = tempfile::tempdir().expect("create temp dir");
2525            let package = dir.path().join("packages/app");
2526            std::fs::create_dir_all(package.join("app/.client")).unwrap();
2527            std::fs::create_dir_all(package.join("app/.server")).unwrap();
2528            std::fs::write(
2529                package.join("app/.client/analytics.ts"),
2530                "export const track = () => {};",
2531            )
2532            .unwrap();
2533            std::fs::write(package.join("app/.server/db.ts"), "export const db = {};").unwrap();
2534
2535            let config = make_config(dir.path().to_path_buf(), false);
2536            let scopes = [HiddenDirScope::new(
2537                package,
2538                vec![".client".to_string(), ".server".to_string()],
2539            )];
2540            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2541            let names = file_names(&files, dir.path());
2542
2543            assert!(names.contains(&"packages/app/app/.client/analytics.ts".to_string()));
2544            assert!(names.contains(&"packages/app/app/.server/db.ts".to_string()));
2545        }
2546
2547        #[test]
2548        fn scoped_hidden_dirs_do_not_include_unscoped_packages() {
2549            let dir = tempfile::tempdir().expect("create temp dir");
2550            let active = dir.path().join("packages/active");
2551            let inactive = dir.path().join("packages/inactive");
2552            std::fs::create_dir_all(active.join("app/.server")).unwrap();
2553            std::fs::create_dir_all(inactive.join("app/.server")).unwrap();
2554            std::fs::write(active.join("app/.server/db.ts"), "export const db = {};").unwrap();
2555            std::fs::write(inactive.join("app/.server/db.ts"), "export const db = {};").unwrap();
2556
2557            let config = make_config(dir.path().to_path_buf(), false);
2558            let scopes = [HiddenDirScope::new(active, vec![".server".to_string()])];
2559            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2560            let names = file_names(&files, dir.path());
2561
2562            assert!(names.contains(&"packages/active/app/.server/db.ts".to_string()));
2563            assert!(!names.contains(&"packages/inactive/app/.server/db.ts".to_string()));
2564        }
2565
2566        #[test]
2567        fn exact_path_scope_does_not_admit_the_same_name_elsewhere() {
2568            // A script naming `.a/.b/deep.mjs` says where the file it needs
2569            // lives. Before issue #461 the scope stored the bare names, so an
2570            // unrelated `elsewhere/.b` and `unrelated/.a` were pulled in too.
2571            let dir = tempfile::tempdir().expect("create temp dir");
2572            std::fs::create_dir_all(dir.path().join(".a/.b")).unwrap();
2573            std::fs::create_dir_all(dir.path().join("elsewhere/.b")).unwrap();
2574            std::fs::create_dir_all(dir.path().join("unrelated/.a")).unwrap();
2575            std::fs::write(dir.path().join(".a/.b/deep.mjs"), "export const a = 1;").unwrap();
2576            std::fs::write(dir.path().join("elsewhere/.b/y.mjs"), "export const b = 1;").unwrap();
2577            std::fs::write(dir.path().join("unrelated/.a/u.mjs"), "export const c = 1;").unwrap();
2578
2579            let config = make_config(dir.path().to_path_buf(), false);
2580            let scopes = [HiddenDirScope::new_exact_paths(
2581                dir.path().to_path_buf(),
2582                vec![".a".to_string(), format!(".a{MAIN_SEPARATOR}.b")],
2583            )];
2584            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2585            let names = file_names(&files, dir.path());
2586
2587            assert!(names.contains(&".a/.b/deep.mjs".to_string()));
2588            assert!(!names.contains(&"elsewhere/.b/y.mjs".to_string()));
2589            assert!(!names.contains(&"unrelated/.a/u.mjs".to_string()));
2590        }
2591
2592        #[test]
2593        fn exact_path_scope_admits_a_hidden_dir_under_a_visible_parent() {
2594            let dir = tempfile::tempdir().expect("create temp dir");
2595            std::fs::create_dir_all(dir.path().join("tools/.config")).unwrap();
2596            std::fs::create_dir_all(dir.path().join("other/.config")).unwrap();
2597            std::fs::write(
2598                dir.path().join("tools/.config/eslint.config.js"),
2599                "export default [];",
2600            )
2601            .unwrap();
2602            std::fs::write(
2603                dir.path().join("other/.config/eslint.config.js"),
2604                "export default [];",
2605            )
2606            .unwrap();
2607
2608            let config = make_config(dir.path().to_path_buf(), false);
2609            let scopes = [HiddenDirScope::new_exact_paths(
2610                dir.path().to_path_buf(),
2611                vec![format!("tools{MAIN_SEPARATOR}.config")],
2612            )];
2613            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2614            let names = file_names(&files, dir.path());
2615
2616            assert!(names.contains(&"tools/.config/eslint.config.js".to_string()));
2617            assert!(!names.contains(&"other/.config/eslint.config.js".to_string()));
2618        }
2619
2620        #[test]
2621        fn any_depth_scope_keeps_matching_by_name_for_plugins() {
2622            // Framework plugins declare `.client` / `.server` conventions that
2623            // may sit under any route directory, so the plugin shape must keep
2624            // matching at any depth.
2625            let dir = tempfile::tempdir().expect("create temp dir");
2626            std::fs::create_dir_all(dir.path().join("app/routes/deep/.server")).unwrap();
2627            std::fs::write(
2628                dir.path().join("app/routes/deep/.server/db.ts"),
2629                "export const db = {};",
2630            )
2631            .unwrap();
2632
2633            let config = make_config(dir.path().to_path_buf(), false);
2634            let scopes = [HiddenDirScope::new(
2635                dir.path().to_path_buf(),
2636                vec![".server".to_string()],
2637            )];
2638            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2639            let names = file_names(&files, dir.path());
2640
2641            assert!(names.contains(&"app/routes/deep/.server/db.ts".to_string()));
2642        }
2643
2644        #[test]
2645        fn excludes_root_build_directory() {
2646            let dir = tempfile::tempdir().expect("create temp dir");
2647
2648            std::fs::write(dir.path().join(".ignore"), "/build/\n").unwrap();
2649
2650            let build_dir = dir.path().join("build");
2651            std::fs::create_dir_all(&build_dir).unwrap();
2652            std::fs::write(build_dir.join("output.js"), "// build output").unwrap();
2653
2654            let src = dir.path().join("src");
2655            std::fs::create_dir_all(&src).unwrap();
2656            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2657
2658            let config = make_config(dir.path().to_path_buf(), false);
2659            let files = discover_files(&config);
2660            let names = file_names(&files, dir.path());
2661
2662            assert_eq!(names.len(), 1, "root build/ should be excluded via .ignore");
2663            assert!(names.contains(&"src/app.ts".to_string()));
2664        }
2665
2666        #[test]
2667        fn excludes_nested_build_directory() {
2668            let dir = tempfile::tempdir().expect("create temp dir");
2669
2670            let nested_build = dir.path().join("src").join("build");
2671            std::fs::create_dir_all(&nested_build).unwrap();
2672            std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
2673
2674            let config = make_config(dir.path().to_path_buf(), false);
2675            let files = discover_files(&config);
2676            let names = file_names(&files, dir.path());
2677
2678            assert!(
2679                !names.contains(&"src/build/helper.ts".to_string()),
2680                "build/ is treated as generated output at any depth: {names:?}"
2681            );
2682        }
2683
2684        #[test]
2685        #[expect(
2686            clippy::cast_possible_truncation,
2687            reason = "test file counts are trivially small"
2688        )]
2689        fn file_ids_are_sequential_after_sorting() {
2690            let dir = tempfile::tempdir().expect("create temp dir");
2691            let src = dir.path().join("src");
2692            std::fs::create_dir_all(&src).unwrap();
2693
2694            std::fs::write(src.join("z_last.ts"), "export const z = 1;").unwrap();
2695            std::fs::write(src.join("a_first.ts"), "export const a = 1;").unwrap();
2696            std::fs::write(src.join("m_middle.ts"), "export const m = 1;").unwrap();
2697
2698            let config = make_config(dir.path().to_path_buf(), false);
2699            let files = discover_files(&config);
2700
2701            for (idx, file) in files.iter().enumerate() {
2702                assert_eq!(file.id, FileId(idx as u32), "FileId should be sequential");
2703            }
2704
2705            for pair in files.windows(2) {
2706                assert!(
2707                    pair[0].path < pair[1].path,
2708                    "files should be sorted by path"
2709                );
2710            }
2711        }
2712
2713        #[test]
2714        fn production_mode_excludes_test_files() {
2715            let dir = tempfile::tempdir().expect("create temp dir");
2716            let src = dir.path().join("src");
2717            std::fs::create_dir_all(&src).unwrap();
2718
2719            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2720            std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
2721            std::fs::write(src.join("app.spec.ts"), "describe('a', () => {});").unwrap();
2722            std::fs::write(src.join("app.stories.tsx"), "export default {};").unwrap();
2723
2724            let config = make_config(dir.path().to_path_buf(), true);
2725            let files = discover_files(&config);
2726            let names = file_names(&files, dir.path());
2727
2728            assert!(
2729                names.contains(&"src/app.ts".to_string()),
2730                "source files should be included in production mode"
2731            );
2732            assert!(
2733                !names.contains(&"src/app.test.ts".to_string()),
2734                "test files should be excluded in production mode"
2735            );
2736            assert!(
2737                !names.contains(&"src/app.spec.ts".to_string()),
2738                "spec files should be excluded in production mode"
2739            );
2740            assert!(
2741                !names.contains(&"src/app.stories.tsx".to_string()),
2742                "story files should be excluded in production mode"
2743            );
2744        }
2745
2746        #[test]
2747        fn non_production_mode_includes_test_files() {
2748            let dir = tempfile::tempdir().expect("create temp dir");
2749            let src = dir.path().join("src");
2750            std::fs::create_dir_all(&src).unwrap();
2751
2752            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2753            std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
2754
2755            let config = make_config(dir.path().to_path_buf(), false);
2756            let files = discover_files(&config);
2757            let names = file_names(&files, dir.path());
2758
2759            assert!(names.contains(&"src/app.ts".to_string()));
2760            assert!(
2761                names.contains(&"src/app.test.ts".to_string()),
2762                "test files should be included in non-production mode"
2763            );
2764        }
2765
2766        #[test]
2767        fn empty_directory_returns_no_files() {
2768            let dir = tempfile::tempdir().expect("create temp dir");
2769            let config = make_config(dir.path().to_path_buf(), false);
2770            let files = discover_files(&config);
2771            assert!(files.is_empty(), "empty project should discover no files");
2772        }
2773
2774        #[test]
2775        fn hidden_files_not_discovered_as_source() {
2776            let dir = tempfile::tempdir().expect("create temp dir");
2777
2778            std::fs::write(dir.path().join(".env"), "SECRET=abc").unwrap();
2779            std::fs::write(dir.path().join(".gitignore"), "node_modules").unwrap();
2780            std::fs::write(dir.path().join(".eslintrc.js"), "module.exports = {};").unwrap();
2781
2782            let src = dir.path().join("src");
2783            std::fs::create_dir_all(&src).unwrap();
2784            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2785
2786            let config = make_config(dir.path().to_path_buf(), false);
2787            let files = discover_files(&config);
2788            let names = file_names(&files, dir.path());
2789
2790            assert!(
2791                !names.contains(&".env".to_string()),
2792                ".env should not be discovered"
2793            );
2794            assert!(
2795                !names.contains(&".gitignore".to_string()),
2796                ".gitignore should not be discovered"
2797            );
2798        }
2799
2800        /// Create a config with custom ignore patterns.
2801        fn make_config_with_ignores(root: PathBuf, ignores: Vec<String>) -> ResolvedConfig {
2802            FallowConfig {
2803                type_aware: fallow_config::TypeAwareConfig::default(),
2804                schema: None,
2805                minimum_version: None,
2806                extends: vec![],
2807                entry: vec![],
2808                ignore_patterns: ignores,
2809                ignore_findings: vec![],
2810                framework: vec![],
2811                workspaces: None,
2812                ignore_dependencies: vec![],
2813                ignore_command_entries: vec![],
2814                ignore_unresolved_imports: vec![],
2815                ignore_exports: vec![],
2816                ignore_catalog_references: vec![],
2817                ignore_dependency_overrides: vec![],
2818                ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(
2819                ),
2820                used_class_members: vec![],
2821                ignore_decorators: vec![],
2822                unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
2823                circular_dependencies: fallow_config::CircularDependenciesConfig::default(),
2824                duplicates: DuplicatesConfig::default(),
2825                similar_code: fallow_config::SimilarCodeConfig::default(),
2826                mutation: fallow_config::MutationConfig::default(),
2827                health: HealthConfig::default(),
2828                rules: RulesConfig::default(),
2829                boundaries: fallow_config::BoundaryConfig::default(),
2830                production: false.into(),
2831                plugins: vec![],
2832                rule_packs: vec![],
2833                dynamically_loaded: vec![],
2834                overrides: vec![],
2835                regression: None,
2836                audit: fallow_config::AuditConfig::default(),
2837                codeowners: None,
2838                public_packages: vec![],
2839                flags: FlagsConfig::default(),
2840                security: fallow_config::SecurityConfig::default(),
2841                fix: fallow_config::FixConfig::default(),
2842                resolve: ResolveConfig::default(),
2843                sealed: false,
2844                include_entry_exports: false,
2845                show_cascade: false,
2846                auto_imports: false,
2847                fail_on_parse_error: false,
2848                cache: fallow_config::CacheConfig::default(),
2849            }
2850            .resolve(root, OutputFormat::Human, 1, true, true, None)
2851        }
2852
2853        #[test]
2854        fn custom_ignore_patterns_exclude_matching_files() {
2855            let dir = tempfile::tempdir().expect("create temp dir");
2856
2857            let generated = dir.path().join("src").join("api").join("generated");
2858            std::fs::create_dir_all(&generated).unwrap();
2859            std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
2860
2861            let client = dir.path().join("src").join("api").join("client");
2862            std::fs::create_dir_all(&client).unwrap();
2863            std::fs::write(client.join("fetch.ts"), "export const fetch = {};").unwrap();
2864
2865            let src = dir.path().join("src");
2866            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2867
2868            let config = make_config_with_ignores(
2869                dir.path().to_path_buf(),
2870                vec![
2871                    "src/api/generated/**".to_string(),
2872                    "src/api/client/**".to_string(),
2873                ],
2874            );
2875            let files = discover_files(&config);
2876            let names = file_names(&files, dir.path());
2877
2878            assert_eq!(names.len(), 1, "only non-ignored files: {names:?}");
2879            assert!(names.contains(&"src/index.ts".to_string()));
2880        }
2881
2882        #[test]
2883        fn leading_dot_ignore_patterns_exclude_matching_files() {
2884            let dir = tempfile::tempdir().expect("create temp dir");
2885
2886            let generated = dir.path().join("src").join("generated");
2887            std::fs::create_dir_all(&generated).unwrap();
2888            std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
2889
2890            let src = dir.path().join("src");
2891            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2892
2893            let config = make_config_with_ignores(
2894                dir.path().to_path_buf(),
2895                vec!["./src/generated/**".to_string()],
2896            );
2897            let files = discover_files(&config);
2898            let names = file_names(&files, dir.path());
2899
2900            assert_eq!(names, vec!["src/index.ts"]);
2901        }
2902
2903        #[test]
2904        fn default_ignore_patterns_exclude_node_modules_and_dist() {
2905            let dir = tempfile::tempdir().expect("create temp dir");
2906
2907            let nm = dir.path().join("node_modules").join("lodash");
2908            std::fs::create_dir_all(&nm).unwrap();
2909            std::fs::write(nm.join("lodash.js"), "module.exports = {};").unwrap();
2910
2911            let dist = dir.path().join("dist");
2912            std::fs::create_dir_all(&dist).unwrap();
2913            std::fs::write(dist.join("bundle.js"), "// bundled").unwrap();
2914
2915            let src = dir.path().join("src");
2916            std::fs::create_dir_all(&src).unwrap();
2917            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2918
2919            let config = make_config(dir.path().to_path_buf(), false);
2920            let files = discover_files(&config);
2921            let names = file_names(&files, dir.path());
2922
2923            assert_eq!(names.len(), 1);
2924            assert!(names.contains(&"src/index.ts".to_string()));
2925        }
2926
2927        #[test]
2928        fn default_ignore_patterns_exclude_build_at_any_depth() {
2929            let dir = tempfile::tempdir().expect("create temp dir");
2930
2931            let build = dir.path().join("build");
2932            std::fs::create_dir_all(&build).unwrap();
2933            std::fs::write(build.join("output.js"), "// built").unwrap();
2934
2935            let nested_build = dir.path().join("src").join("build");
2936            std::fs::create_dir_all(&nested_build).unwrap();
2937            std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
2938
2939            let src = dir.path().join("src");
2940            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2941
2942            let config = make_config(dir.path().to_path_buf(), false);
2943            let files = discover_files(&config);
2944            let names = file_names(&files, dir.path());
2945
2946            assert_eq!(names, vec!["src/index.ts".to_string()]);
2947        }
2948
2949        /// A monorepo keeps its generated output inside each package, so the
2950        /// built-in exclusion has to survive the workspace prefix.
2951        #[test]
2952        fn default_ignore_patterns_exclude_nested_build() {
2953            let dir = tempfile::tempdir().expect("create temp dir");
2954
2955            let build = dir.path().join("build");
2956            std::fs::create_dir_all(&build).unwrap();
2957            std::fs::write(build.join("output.js"), "// built").unwrap();
2958
2959            let package_build = dir.path().join("projects").join("app").join("build");
2960            std::fs::create_dir_all(&package_build).unwrap();
2961            std::fs::write(package_build.join("index.js"), "// built").unwrap();
2962
2963            let src = dir.path().join("src");
2964            std::fs::create_dir_all(&src).unwrap();
2965            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2966
2967            let config = make_config(dir.path().to_path_buf(), false);
2968            let files = discover_files(&config);
2969            let names = file_names(&files, dir.path());
2970
2971            assert_eq!(names, vec!["src/index.ts".to_string()]);
2972        }
2973
2974        /// `build` only counts as output when it is a whole path segment.
2975        #[test]
2976        fn default_ignore_patterns_keep_paths_that_merely_contain_build() {
2977            let dir = tempfile::tempdir().expect("create temp dir");
2978
2979            let src = dir.path().join("src");
2980            std::fs::create_dir_all(src.join("rebuild")).unwrap();
2981            std::fs::create_dir_all(src.join("buildings")).unwrap();
2982            std::fs::write(src.join("build.ts"), "export const a = 1;").unwrap();
2983            std::fs::write(src.join("rebuild").join("helper.ts"), "export const b = 1;").unwrap();
2984            std::fs::write(src.join("buildings").join("a.ts"), "export const c = 1;").unwrap();
2985
2986            let config = make_config(dir.path().to_path_buf(), false);
2987            let files = discover_files(&config);
2988            let mut names = file_names(&files, dir.path());
2989            names.sort();
2990
2991            assert_eq!(
2992                names,
2993                vec![
2994                    "src/build.ts".to_string(),
2995                    "src/buildings/a.ts".to_string(),
2996                    "src/rebuild/helper.ts".to_string(),
2997                ]
2998            );
2999        }
3000
3001        /// Resolve a config then override the per-file size limit in bytes.
3002        fn make_config_with_max_file_size(
3003            root: PathBuf,
3004            max_file_size_bytes: Option<u64>,
3005        ) -> ResolvedConfig {
3006            let mut config = make_config(root, false);
3007            config.max_file_size_bytes = max_file_size_bytes;
3008            config
3009        }
3010
3011        #[test]
3012        fn skips_files_over_max_file_size() {
3013            let dir = tempfile::tempdir().expect("create temp dir");
3014            let src = dir.path().join("src");
3015            std::fs::create_dir_all(&src).unwrap();
3016            std::fs::write(src.join("small.ts"), "export const a = 1;").unwrap();
3017            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
3018
3019            let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
3020            let files = discover_files(&config);
3021            let names = file_names(&files, dir.path());
3022
3023            assert!(names.contains(&"src/small.ts".to_string()));
3024            assert!(
3025                !names.contains(&"src/huge.ts".to_string()),
3026                "a file over the size limit must not be discovered"
3027            );
3028        }
3029
3030        #[test]
3031        fn declaration_files_exempt_from_size_skip() {
3032            let dir = tempfile::tempdir().expect("create temp dir");
3033            let src = dir.path().join("src");
3034            std::fs::create_dir_all(&src).unwrap();
3035            std::fs::write(src.join("auto-imports.d.ts"), "x".repeat(5_000)).unwrap();
3036            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
3037
3038            let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
3039            let files = discover_files(&config);
3040            let names = file_names(&files, dir.path());
3041
3042            assert!(
3043                names.contains(&"src/auto-imports.d.ts".to_string()),
3044                "a large .d.ts is exempt from the skip (reachability root for global types)"
3045            );
3046            assert!(!names.contains(&"src/huge.ts".to_string()));
3047        }
3048
3049        #[test]
3050        fn unlimited_size_keeps_large_files() {
3051            let dir = tempfile::tempdir().expect("create temp dir");
3052            let src = dir.path().join("src");
3053            std::fs::create_dir_all(&src).unwrap();
3054            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
3055
3056            let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
3057            let files = discover_files(&config);
3058            let names = file_names(&files, dir.path());
3059
3060            assert!(
3061                names.contains(&"src/huge.ts".to_string()),
3062                "no limit keeps every file"
3063            );
3064        }
3065
3066        #[test]
3067        fn skipped_file_recorded_in_workspace_diagnostics() {
3068            let dir = tempfile::tempdir().expect("create temp dir");
3069            let src = dir.path().join("src");
3070            std::fs::create_dir_all(&src).unwrap();
3071            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
3072
3073            let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
3074            let _ = discover_files(&config);
3075
3076            let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
3077            let skipped: Vec<_> = diagnostics
3078                .iter()
3079                .filter(|d| {
3080                    matches!(
3081                        d.kind,
3082                        fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { .. }
3083                    )
3084                })
3085                .collect();
3086            assert_eq!(
3087                skipped.len(),
3088                1,
3089                "the skipped file is recorded in workspace diagnostics for JSON output"
3090            );
3091            assert!(skipped[0].path.ends_with("src/huge.ts"));
3092            assert!(
3093                matches!(
3094                    skipped[0].kind,
3095                    fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes }
3096                        if size_bytes == 5_000
3097                ),
3098                "the recorded diagnostic carries the on-disk byte size"
3099            );
3100        }
3101
3102        /// The skipped-source-dotdir entries the last walk on `root` recorded.
3103        fn dotdir_diagnostics(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
3104            fallow_config::workspace_diagnostics_for(root)
3105                .into_iter()
3106                .filter(|d| {
3107                    matches!(
3108                        d.kind,
3109                        fallow_config::WorkspaceDiagnosticKind::SkippedSourceDotdir
3110                    )
3111                })
3112                .collect()
3113        }
3114
3115        fn write_at(root: &Path, relative: &str, contents: &str) {
3116            let path = root.join(relative);
3117            std::fs::create_dir_all(path.parent().expect("has a parent")).unwrap();
3118            std::fs::write(path, contents).unwrap();
3119        }
3120
3121        #[test]
3122        fn skipped_source_dotdir_recorded_in_workspace_diagnostics() {
3123            let dir = tempfile::tempdir().expect("create temp dir");
3124            write_at(
3125                dir.path(),
3126                ".claude/hooks/probe.mjs",
3127                "export const a = 1;\n",
3128            );
3129            write_at(dir.path(), "src/app.ts", "export const b = 2;\n");
3130
3131            let config = make_config(dir.path().to_path_buf(), false);
3132            let files = discover_files(&config);
3133            let names = file_names(&files, dir.path());
3134
3135            let reported = dotdir_diagnostics(dir.path());
3136            assert_eq!(reported.len(), 1, "one skipped dotdir holds source files");
3137            assert!(reported[0].path.ends_with(".claude"));
3138            assert_eq!(reported[0].kind.id(), "skipped-source-dotdir");
3139            assert!(
3140                reported[0].message.contains("--root"),
3141                "message names the real remedy: {}",
3142                reported[0].message
3143            );
3144            assert!(
3145                names.contains(&"src/app.ts".to_string()),
3146                "traversal is unchanged for ordinary directories"
3147            );
3148            assert!(
3149                !names.contains(&".claude/hooks/probe.mjs".to_string()),
3150                "the diagnostic reports the skip, it does not change traversal"
3151            );
3152        }
3153
3154        #[test]
3155        fn allowlisted_dotdir_is_not_reported() {
3156            let dir = tempfile::tempdir().expect("create temp dir");
3157            write_at(dir.path(), ".storybook/main.ts", "export const a = 1;\n");
3158
3159            let config = make_config(dir.path().to_path_buf(), false);
3160            let files = discover_files(&config);
3161            let names = file_names(&files, dir.path());
3162
3163            assert!(dotdir_diagnostics(dir.path()).is_empty());
3164            assert!(
3165                names.contains(&".storybook/main.ts".to_string()),
3166                "an allowlisted dotdir is still traversed"
3167            );
3168        }
3169
3170        #[test]
3171        fn denylisted_dotdir_is_not_reported() {
3172            let dir = tempfile::tempdir().expect("create temp dir");
3173            write_at(dir.path(), ".idea/workspace.ts", "export const a = 1;\n");
3174            write_at(dir.path(), ".husky/hook.js", "export const b = 2;\n");
3175            write_at(dir.path(), ".next/page.js", "export const c = 3;\n");
3176            write_at(dir.path(), ".pnpm/x.js", "export const d = 4;\n");
3177
3178            let config = make_config(dir.path().to_path_buf(), false);
3179            let _ = discover_files(&config);
3180
3181            assert!(
3182                dotdir_diagnostics(dir.path()).is_empty(),
3183                "build caches, VCS and package-manager state never advise"
3184            );
3185        }
3186
3187        #[test]
3188        fn scoped_dotdir_is_traversed_and_not_reported() {
3189            let dir = tempfile::tempdir().expect("create temp dir");
3190            write_at(
3191                dir.path(),
3192                ".claude/hooks/probe.mjs",
3193                "export const a = 1;\n",
3194            );
3195
3196            let config = make_config(dir.path().to_path_buf(), false);
3197            let scopes = [HiddenDirScope::new(
3198                dir.path().to_path_buf(),
3199                vec![".claude".to_owned()],
3200            )];
3201            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
3202            let names = file_names(&files, dir.path());
3203
3204            assert!(
3205                dotdir_diagnostics(dir.path()).is_empty(),
3206                "a plugin- or script-contributed scope is admitted, so nothing was skipped"
3207            );
3208            assert!(names.contains(&".claude/hooks/probe.mjs".to_string()));
3209        }
3210
3211        #[test]
3212        fn ignore_patterns_silence_the_skipped_source_dotdir() {
3213            let dir = tempfile::tempdir().expect("create temp dir");
3214            write_at(
3215                dir.path(),
3216                ".claude/hooks/probe.mjs",
3217                "export const a = 1;\n",
3218            );
3219
3220            let config =
3221                make_config_with_ignores(dir.path().to_path_buf(), vec![".claude/**".to_owned()]);
3222            let _ = discover_files(&config);
3223
3224            assert!(
3225                dotdir_diagnostics(dir.path()).is_empty(),
3226                "the documented silencing route works"
3227            );
3228        }
3229
3230        #[test]
3231        fn dotdir_without_source_files_is_not_reported() {
3232            let dir = tempfile::tempdir().expect("create temp dir");
3233            write_at(dir.path(), ".claude/settings.json", "{}\n");
3234            write_at(dir.path(), ".claude/README.md", "# notes\n");
3235
3236            let config = make_config(dir.path().to_path_buf(), false);
3237            let _ = discover_files(&config);
3238
3239            assert!(dotdir_diagnostics(dir.path()).is_empty());
3240        }
3241
3242        #[test]
3243        fn dotdir_source_at_scan_depth_limit_is_reported() {
3244            let dir = tempfile::tempdir().expect("create temp dir");
3245            write_at(dir.path(), ".claude/a/b/deep.ts", "export const a = 1;\n");
3246
3247            let config = make_config(dir.path().to_path_buf(), false);
3248            let _ = discover_files(&config);
3249
3250            assert_eq!(dotdir_diagnostics(dir.path()).len(), 1);
3251        }
3252
3253        #[test]
3254        fn dotdir_source_below_scan_depth_limit_is_not_reported() {
3255            let dir = tempfile::tempdir().expect("create temp dir");
3256            write_at(
3257                dir.path(),
3258                ".claude/a/b/c/deeper.ts",
3259                "export const a = 1;\n",
3260            );
3261
3262            let config = make_config(dir.path().to_path_buf(), false);
3263            let _ = discover_files(&config);
3264
3265            assert!(
3266                dotdir_diagnostics(dir.path()).is_empty(),
3267                "the depth cap is real, so widening it stays a deliberate act"
3268            );
3269        }
3270
3271        /// Mark `root` as a git worktree so the `ignore` crate applies the
3272        /// gitignore files below it. `require_git` is on by default, and it
3273        /// tests for the presence of `.git`, not for a valid object store.
3274        fn mark_as_git_repo(root: &Path) {
3275            std::fs::create_dir_all(root.join(".git")).expect("create .git marker");
3276        }
3277
3278        #[test]
3279        fn gitignored_dotdir_contents_are_not_reported() {
3280            // The directory FORM (`.tooling/`) prunes the dotdir upstream of the
3281            // predicate, so these are the forms that reach it with every file
3282            // inside already ignored.
3283            for pattern in [".tooling/**", ".tooling/*", "**/.tooling/**", "*.ts"] {
3284                let dir = tempfile::tempdir().expect("create temp dir");
3285                mark_as_git_repo(dir.path());
3286                write_at(dir.path(), ".gitignore", &format!("{pattern}\n"));
3287                write_at(dir.path(), ".tooling/mod.ts", "export const a = 1;\n");
3288
3289                let config = make_config(dir.path().to_path_buf(), false);
3290                let _ = discover_files(&config);
3291
3292                assert!(
3293                    dotdir_diagnostics(dir.path()).is_empty(),
3294                    "gitignore pattern '{pattern}' excludes the contents, so neither \
3295                     advertised remedy would find anything there"
3296                );
3297            }
3298        }
3299
3300        #[test]
3301        fn self_ignoring_dotdir_is_not_reported() {
3302            let dir = tempfile::tempdir().expect("create temp dir");
3303            mark_as_git_repo(dir.path());
3304            write_at(dir.path(), ".toolcache/.gitignore", "*\n");
3305            write_at(dir.path(), ".toolcache/mod.ts", "export const a = 1;\n");
3306
3307            let config = make_config(dir.path().to_path_buf(), false);
3308            let _ = discover_files(&config);
3309
3310            assert!(
3311                dotdir_diagnostics(dir.path()).is_empty(),
3312                "a cache directory that ignores itself has excluded its own contents"
3313            );
3314        }
3315
3316        #[test]
3317        fn ungitignored_dotdir_in_a_git_repo_is_still_reported() {
3318            let dir = tempfile::tempdir().expect("create temp dir");
3319            mark_as_git_repo(dir.path());
3320            write_at(dir.path(), ".gitignore", "dist/\n");
3321            write_at(dir.path(), ".tooling/mod.ts", "export const a = 1;\n");
3322
3323            let config = make_config(dir.path().to_path_buf(), false);
3324            let _ = discover_files(&config);
3325
3326            assert_eq!(
3327                dotdir_diagnostics(dir.path()).len(),
3328                1,
3329                "the gitignore check must not swallow the case the diagnostic exists for"
3330            );
3331        }
3332
3333        #[test]
3334        fn production_run_does_not_report_a_test_only_dotdir() {
3335            let dir = tempfile::tempdir().expect("create temp dir");
3336            write_at(dir.path(), ".qa/thing.test.ts", "export const a = 1;\n");
3337            write_at(dir.path(), ".qa/thing.stories.tsx", "export const b = 2;\n");
3338
3339            let config = make_config(dir.path().to_path_buf(), true);
3340            let _ = discover_files(&config);
3341
3342            assert!(
3343                dotdir_diagnostics(dir.path()).is_empty(),
3344                "a --production run would analyze none of those files, so the \
3345                 --root remedy would return nothing"
3346            );
3347        }
3348
3349        #[test]
3350        fn production_run_still_reports_a_dotdir_with_production_source() {
3351            let dir = tempfile::tempdir().expect("create temp dir");
3352            write_at(dir.path(), ".qa/thing.test.ts", "export const a = 1;\n");
3353            write_at(dir.path(), ".qa/helper.ts", "export const b = 2;\n");
3354
3355            let config = make_config(dir.path().to_path_buf(), true);
3356            let _ = discover_files(&config);
3357
3358            assert_eq!(dotdir_diagnostics(dir.path()).len(), 1);
3359        }
3360
3361        #[test]
3362        fn dotdir_with_only_generated_markup_is_not_reported() {
3363            let dir = tempfile::tempdir().expect("create temp dir");
3364            write_at(dir.path(), ".lighthouseci/lhr-1.html", "<html></html>\n");
3365            write_at(dir.path(), ".styles/theme.css", ":root { color: red; }\n");
3366            write_at(dir.path(), ".gql/schema.graphql", "type Query { a: Int }\n");
3367
3368            let config = make_config(dir.path().to_path_buf(), false);
3369            let _ = discover_files(&config);
3370
3371            assert!(
3372                dotdir_diagnostics(dir.path()).is_empty(),
3373                "the message claims imports and exports are lost, and these have none"
3374            );
3375        }
3376
3377        #[test]
3378        fn generated_tool_and_foreign_vcs_dotdirs_are_not_reported() {
3379            let dir = tempfile::tempdir().expect("create temp dir");
3380            write_at(dir.path(), ".astro/types.d.ts", "export {};\n");
3381            write_at(dir.path(), ".wxt/types/imports.d.ts", "export {};\n");
3382            write_at(dir.path(), ".yalc/pkg/index.js", "export const a = 1;\n");
3383            write_at(dir.path(), ".jj/repo/config.js", "export const b = 2;\n");
3384            write_at(dir.path(), ".svn/pristine/y.js", "export const c = 3;\n");
3385
3386            let config = make_config(dir.path().to_path_buf(), false);
3387            let _ = discover_files(&config);
3388
3389            assert!(
3390                dotdir_diagnostics(dir.path()).is_empty(),
3391                "generated output and foreign VCS metadata are not first-party source"
3392            );
3393        }
3394
3395        #[test]
3396        fn denylisted_dotdirs_do_not_consume_the_candidate_ceiling() {
3397            let dir = tempfile::tempdir().expect("create temp dir");
3398            // Sorted before the real candidate, and more of them than the
3399            // ceiling, so a cap applied before the name checks would hide it.
3400            for index in 0..(DOTDIR_SCAN_MAX_CANDIDATES + 8) {
3401                write_at(
3402                    dir.path(),
3403                    &format!("packages/pkg{index:03}/.turbo/blob.js"),
3404                    "export const a = 1;\n",
3405                );
3406            }
3407            write_at(dir.path(), "zz/.tooling/mod.ts", "export const b = 2;\n");
3408
3409            let config = make_config(dir.path().to_path_buf(), false);
3410            let _ = discover_files(&config);
3411
3412            let reported = dotdir_diagnostics(dir.path());
3413            assert_eq!(reported.len(), 1, "{reported:?}");
3414            assert!(reported[0].path.ends_with(".tooling"));
3415        }
3416
3417        #[test]
3418        fn one_pathological_dotdir_cannot_starve_the_rest() {
3419            let dir = tempfile::tempdir().expect("create temp dir");
3420            // Wide and shallow, no source: exhausts this candidate's own budget.
3421            for index in 0..(DOTDIR_SCAN_MAX_ENTRIES * 2) {
3422                write_at(dir.path(), &format!(".aaa-noise/f{index}.bin"), "x");
3423            }
3424            write_at(dir.path(), ".zzz-real/mod.ts", "export const a = 1;\n");
3425
3426            let config = make_config(dir.path().to_path_buf(), false);
3427            let _ = discover_files(&config);
3428
3429            let reported = dotdir_diagnostics(dir.path());
3430            assert_eq!(reported.len(), 1, "{reported:?}");
3431            assert!(reported[0].path.ends_with(".zzz-real"));
3432        }
3433
3434        #[test]
3435        fn repeat_walks_do_not_stack_skipped_source_dotdirs() {
3436            let dir = tempfile::tempdir().expect("create temp dir");
3437            write_at(
3438                dir.path(),
3439                ".claude/hooks/probe.mjs",
3440                "export const a = 1;\n",
3441            );
3442
3443            let config = make_config(dir.path().to_path_buf(), false);
3444            let _ = discover_files(&config);
3445            let _ = discover_files(&config);
3446
3447            assert_eq!(
3448                dotdir_diagnostics(dir.path()).len(),
3449                1,
3450                "each walk replaces its own root's source-discovery set"
3451            );
3452        }
3453
3454        #[test]
3455        fn skips_large_one_line_js_as_minified_generated_output() {
3456            let dir = tempfile::tempdir().expect("create temp dir");
3457            let src = dir.path().join("src");
3458            std::fs::create_dir_all(&src).unwrap();
3459            let asset = src.join("index-abc123.js");
3460            std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
3461
3462            let config = make_config(dir.path().to_path_buf(), false);
3463            let files = discover_files(&config);
3464            let names = file_names(&files, dir.path());
3465
3466            assert!(
3467                !names.contains(&"src/index-abc123.js".to_string()),
3468                "large one-line JS assets should be skipped before parsing"
3469            );
3470
3471            let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
3472            assert!(
3473                diagnostics.iter().any(|diag| {
3474                    diag.path.ends_with("src/index-abc123.js")
3475                        && matches!(
3476                            diag.kind,
3477                            fallow_config::WorkspaceDiagnosticKind::SkippedMinifiedFile { .. }
3478                        )
3479                }),
3480                "the skipped minified asset is recorded for JSON output: {diagnostics:?}"
3481            );
3482        }
3483
3484        #[test]
3485        fn unlimited_size_keeps_large_one_line_js() {
3486            let dir = tempfile::tempdir().expect("create temp dir");
3487            let src = dir.path().join("src");
3488            std::fs::create_dir_all(&src).unwrap();
3489            let asset = src.join("index-abc123.js");
3490            std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
3491
3492            let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
3493            let files = discover_files(&config);
3494            let names = file_names(&files, dir.path());
3495
3496            assert!(
3497                names.contains(&"src/index-abc123.js".to_string()),
3498                "--max-file-size 0 should opt out of generated JS skipping"
3499            );
3500        }
3501
3502        #[test]
3503        fn keeps_large_multiline_js() {
3504            let dir = tempfile::tempdir().expect("create temp dir");
3505            let src = dir.path().join("src");
3506            std::fs::create_dir_all(&src).unwrap();
3507            let asset = src.join("handwritten.js");
3508            let mut content = String::new();
3509            while content.len() <= MINIFIED_FILE_SKIP_BYTES as usize + 1 {
3510                content.push_str("export const value = 1;\n");
3511            }
3512            std::fs::write(&asset, content).unwrap();
3513
3514            let config = make_config(dir.path().to_path_buf(), false);
3515            let files = discover_files(&config);
3516            let names = file_names(&files, dir.path());
3517
3518            assert!(
3519                names.contains(&"src/handwritten.js".to_string()),
3520                "large multiline JS should not be treated as a generated minified asset"
3521            );
3522        }
3523    }
3524}