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