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/// Report that the walk finished with nothing to analyze (issue #2686).
689///
690/// The condition is the file list being empty, not any particular exclusion,
691/// so it also covers a docs-only repository, a workspace member with no
692/// TypeScript and a path filter that matched nothing. The built-in-ignore
693/// tally rides along as `excluded_file_count` so the common cause stays
694/// attributable without making it the trigger.
695///
696/// Recorded by discovery rather than by the CLI's human note, so every envelope
697/// built from a diagnostics snapshot carries it: the MCP tools and the
698/// programmatic routes share this list, and a kind that existed on the CLI path
699/// alone would break that.
700fn report_no_source_files_analyzed(
701    config: &ResolvedConfig,
702    analyzed_file_count: usize,
703    tally: &ExclusionTally,
704) -> Vec<WorkspaceDiagnostic> {
705    if analyzed_file_count > 0 {
706        return Vec::new();
707    }
708    let excluded_file_count = tally.values().map(|excluded| excluded.file_count).sum();
709    vec![
710        WorkspaceDiagnostic::new(
711            &config.root,
712            config.root.clone(),
713            WorkspaceDiagnosticKind::NoSourceFilesAnalyzed {
714                excluded_file_count,
715            },
716        )
717        .into_root_relative(&config.root),
718    ]
719}
720
721/// Build the typed diagnostics for the dot-prefixed directories this walk
722/// dropped that hold source files the project has not excluded, and emit one
723/// aggregated `tracing::warn!` so the otherwise silent skip is visible on
724/// stderr too (issue #461). The caller writes the returned list to the
725/// registry.
726fn report_skipped_source_dotdirs(
727    config: &ResolvedConfig,
728    production_excludes: Option<&globset::GlobSet>,
729    candidates: &[PathBuf],
730) -> Vec<WorkspaceDiagnostic> {
731    if candidates.is_empty() {
732        return Vec::new();
733    }
734    // The caller sorted and deduped, so both caps truncate deterministically:
735    // the same tree reports the same prefix on every run.
736    let mut budget = DOTDIR_SCAN_TOTAL_ENTRIES;
737    let scannable: Vec<&PathBuf> = candidates
738        .iter()
739        .filter(|dir| dotdir_is_scan_candidate(config, dir))
740        .collect();
741    let reportable: Vec<&PathBuf> = scannable
742        .iter()
743        .copied()
744        .take(DOTDIR_SCAN_MAX_CANDIDATES)
745        .filter(|dir| scan_for_reportable_source(config, production_excludes, dir, &mut budget))
746        .collect();
747    // Either ceiling can stop the scan with candidates left unexamined, so the
748    // count is a floor rather than a total whenever one of them binds.
749    let truncated = scannable.len() > DOTDIR_SCAN_MAX_CANDIDATES || budget == 0;
750    if reportable.is_empty() {
751        return Vec::new();
752    }
753
754    let diagnostics: Vec<WorkspaceDiagnostic> = reportable
755        .iter()
756        .map(|dir| {
757            WorkspaceDiagnostic::new(
758                &config.root,
759                (*dir).clone(),
760                WorkspaceDiagnosticKind::SkippedSourceDotdir,
761            )
762        })
763        .collect();
764
765    let count = reportable.len();
766    if !config.quiet
767        && should_emit_note_once(format!(
768            "dotdir::{}::{count}::{}",
769            config.root.display(),
770            reportable
771                .first()
772                .map_or_else(String::new, |dir| display_relative_path(&config.root, dir))
773        ))
774    {
775        tracing::warn!(
776            "{}",
777            build_skipped_dotdirs_note(&config.root, &reportable, truncated)
778        );
779    }
780    diagnostics
781}
782
783/// Build the skipped-source-dotdir note. Pure so the singular and plural forms,
784/// the truncated prefix, and the single-directory remedy substitution are
785/// unit-testable without a tracing subscriber, mirroring
786/// [`build_largest_files_note`].
787///
788/// With exactly one directory the remedy names it instead of printing a `<dir>`
789/// placeholder: the path is already known and was printed a few words earlier,
790/// so a placeholder would make the one case a user can act on directly the one
791/// case they have to retype.
792fn build_skipped_dotdirs_note(root: &Path, reportable: &[&PathBuf], truncated: bool) -> String {
793    let count = reportable.len();
794    // An exact remainder inside an explicitly inexact total reads as a
795    // contradiction ("at least 64 ... and 59 more"), so a truncated run drops
796    // the tail count.
797    let examples = if truncated {
798        summarize_paths_open_ended(root, reportable)
799    } else {
800        summarize_paths(root, reportable)
801    };
802    let noun = if count == 1 {
803        "directory"
804    } else {
805        "directories"
806    };
807    let verb = if count == 1 { "contains" } else { "contain" };
808    let at_least = if truncated { "at least " } else { "" };
809    let (target, pronoun) = match reportable {
810        [only] => (display_relative_path(root, only), "it"),
811        _ => ("<dir>".to_owned(), "one"),
812    };
813    format!(
814        "fallow: skipped {at_least}{count} hidden {noun} that {verb} source files ({examples}). \
815         Hidden directories are not traversed and no config field adds one: analyze {pronoun} \
816         with fallow --root {target} if it holds first-party source, or add '{target}/**' to \
817         ignorePatterns to silence this."
818    )
819}
820
821/// Build the pre-parse largest-files note, or `None` when the discovered set is
822/// neither unusually large nor contains an unusually large file. Pure so the
823/// pluralization, floor filtering, and count-only fallback are unit-testable
824/// without a tracing subscriber. See issue #1086.
825fn build_largest_files_note(root: &Path, files: &[DiscoveredFile]) -> Option<String> {
826    if files.is_empty() {
827        return None;
828    }
829    let largest = files.iter().map(|f| f.size_bytes).max().unwrap_or(0);
830    if files.len() <= LARGE_SET_THRESHOLD && largest < LARGE_FILE_NOTE_BYTES {
831        return None;
832    }
833    let count = files.len();
834    let noun = if count == 1 { "file" } else { "files" };
835    let mut by_size: Vec<SizedFile> = files
836        .iter()
837        .filter(|f| f.size_bytes >= NOTE_FILE_FLOOR_BYTES)
838        .map(|f| (f.path.clone(), f.size_bytes))
839        .collect();
840    by_size.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
841    if by_size.is_empty() {
842        // Large file SET with no individually large file: report the count only,
843        // omitting a "largest:" list that would otherwise be all sub-floor noise.
844        return Some(format!(
845            "fallow: discovered {count} {noun}. If analysis stalls or runs out of memory, \
846             exclude large generated files via ignorePatterns or --max-file-size."
847        ));
848    }
849    let examples = summarize_examples(root, &by_size);
850    Some(format!(
851        "fallow: discovered {count} {noun}; largest: {examples}. If analysis stalls or runs out of memory, \
852         exclude large generated files via ignorePatterns or --max-file-size."
853    ))
854}
855
856/// Emit a pre-parse note listing the largest kept files when the discovered set
857/// is unusually large or contains an unusually large file, so an out-of-memory
858/// hang at the parse stage is diagnosable (issue #1086). Visible before the
859/// expensive parse begins, so it survives a subsequent crash.
860fn note_largest_files(config: &ResolvedConfig, files: &[DiscoveredFile]) {
861    if config.quiet {
862        return;
863    }
864    if let Some(message) = build_largest_files_note(&config.root, files)
865        && should_emit_note_once(format!("note::{}::{}", config.root.display(), files.len()))
866    {
867        tracing::warn!("{message}");
868    }
869}
870
871/// How a [`HiddenDirScope`] matches a hidden directory during the walk.
872#[derive(Debug, Clone, Copy, PartialEq, Eq)]
873pub enum HiddenDirMatch {
874    /// Match by directory NAME at any depth beneath the scope root.
875    ///
876    /// Framework plugins declare bundle-boundary conventions like `.client`
877    /// and `.server` that a project may place under any route directory, so
878    /// the name is the whole rule and the depth is not knowable in advance.
879    AnyDepth,
880    /// Match the exact root-relative directory PATH.
881    ///
882    /// A `package.json` script naming `.a/.b/build.mjs` states where the file
883    /// it needs actually lives, so the scope admits `<root>/.a` and
884    /// `<root>/.a/.b` and nothing else. An unrelated `packages/x/.b` stays
885    /// untraversed (issue #461).
886    ExactPath,
887}
888
889/// Package-scoped hidden directories that source discovery should traverse.
890#[derive(Debug, Clone, PartialEq, Eq)]
891pub struct HiddenDirScope {
892    root: PathBuf,
893    dirs: Vec<String>,
894    match_mode: HiddenDirMatch,
895}
896
897impl HiddenDirScope {
898    /// Build a scope rooted at a package directory that admits the given
899    /// hidden directory names at any depth beneath it.
900    ///
901    /// This is the plugin-contributed shape. For a scope inferred from a
902    /// concrete path, use [`HiddenDirScope::new_exact_paths`], which does not
903    /// admit the same name elsewhere in the tree.
904    #[must_use]
905    pub fn new(root: PathBuf, dirs: Vec<String>) -> Self {
906        Self {
907            root,
908            dirs,
909            match_mode: HiddenDirMatch::AnyDepth,
910        }
911    }
912
913    /// Build a scope rooted at a package directory that admits exactly the
914    /// given root-relative directory paths.
915    #[must_use]
916    pub fn new_exact_paths(root: PathBuf, dirs: Vec<String>) -> Self {
917        Self {
918            root,
919            dirs,
920            match_mode: HiddenDirMatch::ExactPath,
921        }
922    }
923
924    /// Rebuild a scope with an explicit match mode.
925    ///
926    /// Used when a scope crosses a crate boundary and must arrive with the
927    /// same semantics it left with.
928    #[must_use]
929    pub fn with_match_mode(root: PathBuf, dirs: Vec<String>, match_mode: HiddenDirMatch) -> Self {
930        Self {
931            root,
932            dirs,
933            match_mode,
934        }
935    }
936
937    #[must_use]
938    pub fn root(&self) -> &Path {
939        &self.root
940    }
941
942    #[must_use]
943    pub fn dirs(&self) -> &[String] {
944        &self.dirs
945    }
946
947    #[must_use]
948    pub fn match_mode(&self) -> HiddenDirMatch {
949        self.match_mode
950    }
951
952    fn allows(&self, path: &Path, name: &OsStr) -> bool {
953        match self.match_mode {
954            HiddenDirMatch::AnyDepth => {
955                path.starts_with(&self.root) && self.dirs.iter().any(|dir| OsStr::new(dir) == name)
956            }
957            HiddenDirMatch::ExactPath => {
958                // `Path` compares component-wise, so a `/`-separated entry
959                // from a script string matches on every platform.
960                let Ok(relative) = path.strip_prefix(&self.root) else {
961                    return false;
962                };
963                self.dirs.iter().any(|dir| Path::new(dir) == relative)
964            }
965        }
966    }
967}
968
969/// Per-thread file collector for the parallel walker.
970///
971/// Source files (by extension) flow to `shared`; when `config_shared` is set,
972/// non-source files admitted by the config-candidate type group flow to it
973/// instead. The two channels are disjoint and the source channel is byte-for-byte
974/// identical to the config-capture-disabled walk.
975struct FileVisitor<'a> {
976    root: &'a Path,
977    canonical_root: Option<&'a Path>,
978    ignore_patterns: &'a globset::GlobSet,
979    /// Globs at the front of `ignore_patterns` that came from the project's
980    /// own `ignorePatterns`; the built-in defaults follow them.
981    user_ignore_pattern_count: usize,
982    production_excludes: &'a Option<globset::GlobSet>,
983    shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
984    config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
985    excluded_shared: &'a Mutex<ExclusionTally>,
986    local: Vec<(std::path::PathBuf, u64)>,
987    config_local: Vec<std::path::PathBuf>,
988    excluded_local: ExclusionTally,
989    /// Reused across every excluded candidate so attribution allocates once
990    /// per walker thread rather than once per file.
991    match_buf: Vec<usize>,
992}
993
994impl FileVisitor<'_> {
995    /// Attribute one excluded candidate source file to the built-in pattern
996    /// that removed it, or to nothing when the project asked for the exclusion
997    /// itself (issue #2638).
998    ///
999    /// Runs only on files `is_match` already rejected, so the kept-file path
1000    /// still pays a single boolean match.
1001    fn record_default_ignore_exclusion(&mut self, relative: &Path) {
1002        // Cheap pre-filter, not a second rule: `**/node_modules/**` is in
1003        // UNREPORTED_DEFAULT_IGNORES, and it is also the one built-in that
1004        // fires on an entire dependency tree. Testing a path component beats
1005        // running the whole glob union over tens of thousands of files whose
1006        // attribution the report would then discard.
1007        if relative
1008            .components()
1009            .any(|component| component.as_os_str() == OsStr::new("node_modules"))
1010        {
1011            return;
1012        }
1013        self.match_buf.clear();
1014        self.ignore_patterns
1015            .matches_into(relative, &mut self.match_buf);
1016        // globset returns ascending indices, so the first match is both the
1017        // cheapest user-pattern test and the lowest-index built-in.
1018        let Some(&first) = self.match_buf.first() else {
1019            return;
1020        };
1021        if first < self.user_ignore_pattern_count {
1022            // An `ignorePatterns` entry also matched. The project chose this
1023            // exclusion, so reporting it as a surprise would be wrong.
1024            return;
1025        }
1026        let Some(pattern) = DEFAULT_IGNORE_PATTERNS.get(first - self.user_ignore_pattern_count)
1027        else {
1028            return;
1029        };
1030        if UNREPORTED_DEFAULT_IGNORES.contains(pattern) {
1031            return;
1032        }
1033        self.excluded_local
1034            .entry(first - self.user_ignore_pattern_count)
1035            .or_default()
1036            .record(exclusion_scope(relative, pattern));
1037    }
1038}
1039
1040impl ignore::ParallelVisitor for FileVisitor<'_> {
1041    fn visit(&mut self, result: Result<ignore::DirEntry, ignore::Error>) -> ignore::WalkState {
1042        let Ok(entry) = result else {
1043            return ignore::WalkState::Continue;
1044        };
1045        if entry.file_type().is_some_and(|ft| ft.is_dir()) {
1046            return ignore::WalkState::Continue;
1047        }
1048        let relative = entry
1049            .path()
1050            .strip_prefix(self.root)
1051            .unwrap_or_else(|_| entry.path());
1052        if self.ignore_patterns.is_match(relative) {
1053            if has_source_extension(entry.path()) {
1054                self.record_default_ignore_exclusion(relative);
1055            }
1056            return ignore::WalkState::Continue;
1057        }
1058        if self
1059            .production_excludes
1060            .as_ref()
1061            .is_some_and(|excludes| excludes.is_match(relative))
1062        {
1063            return ignore::WalkState::Continue;
1064        }
1065        let symlink_size = if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
1066            let Some(size) = contained_symlink_file_size(entry.path(), self.canonical_root) else {
1067                tracing::debug!(
1068                    path = %entry.path().display(),
1069                    "skipping source symlink with a broken, non-file, or outside-root target"
1070                );
1071                return ignore::WalkState::Continue;
1072            };
1073            Some(size)
1074        } else {
1075            None
1076        };
1077        if has_source_extension(entry.path()) {
1078            let size_bytes =
1079                symlink_size.unwrap_or_else(|| entry.metadata().map_or(0, |m| m.len()));
1080            self.local.push((entry.into_path(), size_bytes));
1081        } else if self.config_shared.is_some() {
1082            // A non-source file admitted by the config-candidate type group. No
1083            // size metadata is needed; these are pattern-matched, never parsed.
1084            self.config_local.push(entry.into_path());
1085        }
1086        ignore::WalkState::Continue
1087    }
1088}
1089
1090fn contained_symlink_file_size(path: &Path, canonical_root: Option<&Path>) -> Option<u64> {
1091    let root = canonical_root?;
1092    let target = path.canonicalize().ok()?;
1093    if !target.starts_with(root) {
1094        return None;
1095    }
1096    let metadata = target.metadata().ok()?;
1097    metadata.is_file().then_some(metadata.len())
1098}
1099
1100impl Drop for FileVisitor<'_> {
1101    #[expect(
1102        clippy::expect_used,
1103        reason = "poisoned walk collector lock means worker state is unrecoverable"
1104    )]
1105    fn drop(&mut self) {
1106        if !self.local.is_empty() {
1107            self.shared
1108                .lock()
1109                .expect("walk collector lock poisoned")
1110                .append(&mut self.local);
1111        }
1112        if let Some(config_shared) = self.config_shared
1113            && !self.config_local.is_empty()
1114        {
1115            config_shared
1116                .lock()
1117                .expect("walk config collector lock poisoned")
1118                .append(&mut self.config_local);
1119        }
1120        if !self.excluded_local.is_empty() {
1121            let mut shared = self
1122                .excluded_shared
1123                .lock()
1124                .expect("walk exclusion collector lock poisoned");
1125            for (index, tally) in std::mem::take(&mut self.excluded_local) {
1126                shared.entry(index).or_default().merge(tally);
1127            }
1128        }
1129    }
1130}
1131
1132/// Builder that creates per-thread `FileVisitor` instances for the parallel walker.
1133struct FileVisitorBuilder<'a> {
1134    root: &'a Path,
1135    canonical_root: Option<&'a Path>,
1136    ignore_patterns: &'a globset::GlobSet,
1137    user_ignore_pattern_count: usize,
1138    production_excludes: &'a Option<globset::GlobSet>,
1139    shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
1140    config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
1141    excluded_shared: &'a Mutex<ExclusionTally>,
1142}
1143
1144impl<'s> ignore::ParallelVisitorBuilder<'s> for FileVisitorBuilder<'s> {
1145    fn build(&mut self) -> Box<dyn ignore::ParallelVisitor + 's> {
1146        Box::new(FileVisitor {
1147            root: self.root,
1148            canonical_root: self.canonical_root,
1149            ignore_patterns: self.ignore_patterns,
1150            user_ignore_pattern_count: self.user_ignore_pattern_count,
1151            production_excludes: self.production_excludes,
1152            shared: self.shared,
1153            config_shared: self.config_shared,
1154            excluded_shared: self.excluded_shared,
1155            local: Vec::new(),
1156            config_local: Vec::new(),
1157            excluded_local: ExclusionTally::default(),
1158            match_buf: Vec::new(),
1159        })
1160    }
1161}
1162
1163/// File extensions discovery treats as analyzable source files.
1164pub const SOURCE_EXTENSIONS: &[&str] = &[
1165    "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
1166    "mdx", "css", "scss", "sass", "less", "html", "graphql", "gql",
1167];
1168
1169/// Glob patterns for test/dev/story files excluded in production mode.
1170pub const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
1171    "**/*.test.*",
1172    "**/*.spec.*",
1173    "**/*.e2e.*",
1174    "**/*.e2e-spec.*",
1175    "**/*.bench.*",
1176    "**/*.fixture.*",
1177    "**/*.stories.*",
1178    "**/*.story.*",
1179    "**/__tests__/**",
1180    "**/__mocks__/**",
1181    "**/__snapshots__/**",
1182    "**/__fixtures__/**",
1183    "**/test/**",
1184    "**/tests/**",
1185    "*.config.*",
1186    "**/.*.js",
1187    "**/.*.ts",
1188    "**/.*.mjs",
1189    "**/.*.cjs",
1190];
1191
1192/// Check if a hidden directory name is on the allowlist.
1193pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
1194    ALLOWED_HIDDEN_DIRS.iter().any(|&d| OsStr::new(d) == name)
1195}
1196
1197fn is_allowed_scoped_hidden_dir(
1198    name: &OsStr,
1199    path: &Path,
1200    additional_hidden_dir_scopes: &[HiddenDirScope],
1201) -> bool {
1202    additional_hidden_dir_scopes
1203        .iter()
1204        .any(|scope| scope.allows(path, name))
1205}
1206
1207/// Files Yarn Plug'n'Play writes at the workspace root. They carry source
1208/// extensions (`.pnp.cjs` is the multi-megabyte generated loader with the
1209/// install state inlined, `.pnp.loader.mjs` its ESM shim) but are install
1210/// artifacts, not project source, so the walker drops them by name.
1211const YARN_PNP_GENERATED_FILES: &[&str] = &[".pnp.cjs", ".pnp.loader.mjs"];
1212
1213fn is_yarn_pnp_generated_file(name: &OsStr) -> bool {
1214    YARN_PNP_GENERATED_FILES
1215        .iter()
1216        .any(|&f| OsStr::new(f) == name)
1217}
1218
1219/// Check if a hidden directory entry should be allowed through the filter.
1220///
1221/// Returns `true` if the entry is not hidden or is on the allowlist.
1222/// Hidden files (not directories) are allowed through since the type filter
1223/// handles them, except for the generated Yarn PnP files.
1224fn is_allowed_hidden_with_scopes(
1225    entry: &ignore::DirEntry,
1226    additional_hidden_dir_scopes: &[HiddenDirScope],
1227) -> bool {
1228    let name = entry.file_name();
1229    let name_str = name.to_string_lossy();
1230
1231    if !name_str.starts_with('.') {
1232        return true;
1233    }
1234
1235    if entry.file_type().is_some_and(|ft| !ft.is_dir()) {
1236        return !is_yarn_pnp_generated_file(name);
1237    }
1238
1239    is_allowed_hidden_dir(name)
1240        || is_allowed_scoped_hidden_dir(name, entry.path(), additional_hidden_dir_scopes)
1241}
1242
1243/// Discover all source files in the project.
1244///
1245/// # Panics
1246///
1247/// Panics if the file type glob or progress template is invalid (compile-time constants).
1248pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
1249    discover_files_with_additional_hidden_dirs(config, &[])
1250}
1251
1252/// The set of config-file basenames (last path component of every built-in
1253/// plugin `config_patterns()` entry, brace forms preserved) that the walk should
1254/// additionally admit so non-source configs (`tsconfig.json`, `bunfig.toml`,
1255/// `.eslintrc.json`, ...) can be captured in one traversal instead of being
1256/// re-discovered by a filesystem re-walk in `discover_config_files`.
1257///
1258/// Derived live from the built-in plugin list, so it can never drift behind a
1259/// new plugin's config patterns. Source-extension config basenames
1260/// (`vite.config.{ts,js}`) are admitted too, but the walk visitor routes them
1261/// back to the source channel by extension, so the config channel only ever
1262/// collects genuinely non-source files.
1263fn config_candidate_basename_globs() -> &'static [String] {
1264    static GLOBS: OnceLock<Vec<String>> = OnceLock::new();
1265    GLOBS.get_or_init(|| {
1266        let mut set: FxHashSet<String> = FxHashSet::default();
1267        for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
1268            for pattern in plugin.config_patterns() {
1269                let basename = pattern.rsplit('/').next().unwrap_or(pattern);
1270                set.insert(basename.to_string());
1271            }
1272        }
1273        let mut globs: Vec<String> = set.into_iter().collect();
1274        globs.sort_unstable();
1275        globs
1276    })
1277}
1278
1279/// True when `path`'s extension is one of the known source extensions, i.e. the
1280/// file belongs in the source channel rather than the config-candidate channel.
1281fn has_source_extension(path: &Path) -> bool {
1282    path.extension()
1283        .and_then(OsStr::to_str)
1284        .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
1285}
1286
1287/// Build the file-type filter. Always selects known source extensions; when
1288/// `capture_config` is set, also selects config-candidate basenames so the
1289/// walker yields them for the second collection channel.
1290#[expect(
1291    clippy::expect_used,
1292    reason = "source file globs are hard-coded compile-time constants"
1293)]
1294fn build_walk_types(capture_config: bool) -> ignore::types::Types {
1295    static SOURCE_TYPES: OnceLock<ignore::types::Types> = OnceLock::new();
1296    static SOURCE_AND_CONFIG_TYPES: OnceLock<ignore::types::Types> = OnceLock::new();
1297
1298    let cache = if capture_config {
1299        &SOURCE_AND_CONFIG_TYPES
1300    } else {
1301        &SOURCE_TYPES
1302    };
1303    cache
1304        .get_or_init(|| {
1305            let mut types_builder = ignore::types::TypesBuilder::new();
1306            let source_glob = format!("*.{{{}}}", SOURCE_EXTENSIONS.join(","));
1307            types_builder
1308                .add("source", &source_glob)
1309                .expect("valid glob");
1310            types_builder.select("source");
1311            if capture_config {
1312                for glob in config_candidate_basename_globs() {
1313                    // Ignore individually-invalid plugin patterns rather than panicking;
1314                    // a malformed pattern simply fails to admit its config file (the
1315                    // pre-existing filesystem fallback still covers production mode).
1316                    let _ = types_builder.add("config", glob);
1317                }
1318                types_builder.select("config");
1319            }
1320            types_builder.build().expect("valid types")
1321        })
1322        .clone()
1323}
1324
1325/// Construct the parallel walker, applying the appropriate hidden-dir filter.
1326/// When `capture_config` is set the walk also yields config-candidate files for
1327/// the secondary collection channel.
1328fn build_source_walk_builder(
1329    config: &ResolvedConfig,
1330    additional_hidden_dir_scopes: &[HiddenDirScope],
1331    capture_config: bool,
1332    skipped_dotdirs: &SkippedDotdirSink,
1333) -> WalkBuilder {
1334    let mut walk_builder = WalkBuilder::new(&config.root);
1335    walk_builder
1336        .hidden(false)
1337        .git_ignore(true)
1338        .git_global(true)
1339        .git_exclude(true)
1340        .types(build_walk_types(capture_config))
1341        .threads(config.threads);
1342    // One filter, not two: `filter_entry` replaces rather than chains, and the
1343    // dropped-dotdir record has to happen on the same false path that decides
1344    // the skip so the allowlist and every plugin- or script-contributed scope
1345    // are excluded by construction (issue #461).
1346    let scopes = additional_hidden_dir_scopes.to_vec();
1347    let sink = Arc::clone(skipped_dotdirs);
1348    walk_builder.filter_entry(move |entry| {
1349        if is_allowed_hidden_with_scopes(entry, &scopes) {
1350            return true;
1351        }
1352        if entry.file_type().is_some_and(|ft| ft.is_dir())
1353            && let Ok(mut collected) = sink.lock()
1354        {
1355            collected.push(entry.path().to_path_buf());
1356        }
1357        false
1358    });
1359    walk_builder
1360}
1361
1362/// Compile the production-mode exclude glob set, or `None` outside production mode.
1363fn build_production_excludes(config: &ResolvedConfig) -> Option<globset::GlobSet> {
1364    if !config.production {
1365        return None;
1366    }
1367    let mut builder = globset::GlobSetBuilder::new();
1368    for pattern in PRODUCTION_EXCLUDE_PATTERNS {
1369        if let Ok(glob) = globset::GlobBuilder::new(pattern)
1370            .literal_separator(true)
1371            .build()
1372        {
1373            builder.add(glob);
1374        }
1375    }
1376    builder.build().ok()
1377}
1378
1379/// Discover all source files in the project, with package-scoped hidden dirs.
1380///
1381/// # Panics
1382///
1383/// Panics if the file type glob or progress template is invalid (compile-time constants).
1384pub fn discover_files_with_additional_hidden_dirs(
1385    config: &ResolvedConfig,
1386    additional_hidden_dir_scopes: &[HiddenDirScope],
1387) -> Vec<DiscoveredFile> {
1388    discover_files_and_config_candidates(config, additional_hidden_dir_scopes).0
1389}
1390
1391/// Discover source files AND, in one traversal, the non-source config-candidate
1392/// files (`tsconfig.json`, `bunfig.toml`, `.eslintrc.json`, ...) used by
1393/// `discover_config_files` to resolve plugin config patterns in-memory instead of
1394/// re-walking the filesystem.
1395///
1396/// The returned `Vec<DiscoveredFile>` is byte-for-byte identical to the
1397/// config-capture-disabled walk: config candidates are routed to the second
1398/// return value by extension and never enter the source channel. Config capture
1399/// is skipped in production mode (where the walk applies `PRODUCTION_EXCLUDE_PATTERNS`
1400/// and `discover_config_files` keeps its filesystem path), so the second vector is
1401/// empty there.
1402///
1403/// # Panics
1404///
1405/// Panics if the file type glob or progress template is invalid (compile-time constants).
1406pub fn discover_files_and_config_candidates(
1407    config: &ResolvedConfig,
1408    additional_hidden_dir_scopes: &[HiddenDirScope],
1409) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
1410    let discovered =
1411        discover_files_config_candidates_and_diagnostics(config, additional_hidden_dir_scopes);
1412    (discovered.files, discovered.config_candidates)
1413}
1414
1415/// Source files, config candidates, and the source-discovery diagnostics one
1416/// walk produced.
1417///
1418/// `diagnostics` is the walk's OWN skip list, not a read of the process-wide
1419/// registry: combined mode can run two walks on the same root concurrently, and
1420/// each walk replaces the registry's source-discovery entries, so only the
1421/// by-value list is a stable answer to "what did THIS analysis skip" (issue
1422/// #2366).
1423pub struct DiscoveredSources {
1424    /// Source files with stable path-sorted [`FileId`]s.
1425    pub files: Vec<DiscoveredFile>,
1426    /// Non-source config-candidate paths captured in the same traversal.
1427    pub config_candidates: Vec<PathBuf>,
1428    /// Skipped-large-file, skipped-minified-file, and skipped-source-dotdir
1429    /// diagnostics from this walk.
1430    pub diagnostics: Vec<WorkspaceDiagnostic>,
1431}
1432
1433/// [`discover_files_and_config_candidates`] plus the source-discovery
1434/// diagnostics this walk recorded, for callers that must carry a per-analysis
1435/// snapshot instead of reading the shared registry back (issue #2366).
1436///
1437/// # Panics
1438///
1439/// Panics if the file type glob or progress template is invalid (compile-time constants).
1440#[expect(
1441    clippy::cast_possible_truncation,
1442    reason = "file count is bounded by project size, well under u32::MAX"
1443)]
1444#[expect(clippy::expect_used, reason = "the collector lock must remain usable")]
1445pub fn discover_files_config_candidates_and_diagnostics(
1446    config: &ResolvedConfig,
1447    additional_hidden_dir_scopes: &[HiddenDirScope],
1448) -> DiscoveredSources {
1449    let _span = tracing::info_span!("discover_files").entered();
1450
1451    let capture_config = !config.production;
1452    let skipped_dotdirs: SkippedDotdirSink = Arc::new(Mutex::new(Vec::new()));
1453    let walk_builder = build_source_walk_builder(
1454        config,
1455        additional_hidden_dir_scopes,
1456        capture_config,
1457        &skipped_dotdirs,
1458    );
1459    let production_excludes = build_production_excludes(config);
1460    let canonical_root = config.root.canonicalize().ok();
1461
1462    let collected: Mutex<Vec<(std::path::PathBuf, u64)>> = Mutex::new(Vec::new());
1463    let config_collected: Mutex<Vec<std::path::PathBuf>> = Mutex::new(Vec::new());
1464    let excluded_collected: Mutex<ExclusionTally> = Mutex::new(ExclusionTally::default());
1465    let mut visitor_builder = FileVisitorBuilder {
1466        root: &config.root,
1467        canonical_root: canonical_root.as_deref(),
1468        ignore_patterns: &config.ignore_patterns,
1469        user_ignore_pattern_count: config.user_ignore_pattern_count,
1470        production_excludes: &production_excludes,
1471        shared: &collected,
1472        config_shared: capture_config.then_some(&config_collected),
1473        excluded_shared: &excluded_collected,
1474    };
1475    walk_builder.build_parallel().visit(&mut visitor_builder);
1476
1477    let mut raw = collected
1478        .into_inner()
1479        .expect("walk collector lock poisoned");
1480    // ADR-004 (path-sorted FileIds): the parallel walk visits files in
1481    // nondeterministic order, so we sort by absolute path BEFORE the
1482    // `.enumerate()` FileId assignment below. This is the stable-cross-run
1483    // identity invariant the persisted graph cache depends on: an identical
1484    // file set yields identical FileIds, so a cache hit (same paths +
1485    // fingerprints) can trust graph data persisted by FileId. Do not replace
1486    // this with insertion-order assignment.
1487    raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
1488
1489    let mut config_candidates = config_collected
1490        .into_inner()
1491        .expect("walk config collector lock poisoned");
1492    config_candidates.sort_unstable();
1493
1494    let excluded_by_default_ignore = excluded_collected
1495        .into_inner()
1496        .expect("walk exclusion collector lock poisoned");
1497
1498    // The parallel walk records dotdirs in nondeterministic thread order, and
1499    // the diagnostic array order is part of the JSON contract, so sort and
1500    // dedupe before the predicate runs (issue #2366).
1501    let mut dotdir_candidates = skipped_dotdirs
1502        .lock()
1503        .map_or_else(|_| Vec::new(), |mut guard| std::mem::take(&mut *guard));
1504    dotdir_candidates.sort_unstable();
1505    dotdir_candidates.dedup();
1506
1507    let (kept, skipped) = partition_by_size(raw, config.max_file_size_bytes);
1508    let (kept, skipped_minified) =
1509        partition_minified_generated_js(kept, config.max_file_size_bytes);
1510    // One registry write replaces this root's whole source-discovery set, so a
1511    // stale entry from a previous pass drops out (issue #1086) without a window
1512    // in which a concurrent walk on the same root can observe or clobber a
1513    // half-written set (issue #2366).
1514    let diagnostics = fallow_config::replace_source_discovery_diagnostics(
1515        &config.root,
1516        report_skipped_large_files(config, &skipped)
1517            .into_iter()
1518            .chain(report_skipped_minified_files(config, &skipped_minified))
1519            .chain(report_skipped_source_dotdirs(
1520                config,
1521                production_excludes.as_ref(),
1522                &dotdir_candidates,
1523            ))
1524            .chain(report_default_ignore_exclusions(
1525                config,
1526                &excluded_by_default_ignore,
1527            ))
1528            .chain(report_missing_node_modules(config))
1529            .chain(report_no_source_files_analyzed(
1530                config,
1531                kept.len(),
1532                &excluded_by_default_ignore,
1533            ))
1534            .collect(),
1535    );
1536
1537    let files: Vec<DiscoveredFile> = kept
1538        .into_iter()
1539        .enumerate()
1540        .map(|(idx, (path, size_bytes))| DiscoveredFile {
1541            id: FileId(idx as u32),
1542            path,
1543            size_bytes,
1544        })
1545        .collect();
1546
1547    note_largest_files(config, &files);
1548
1549    DiscoveredSources {
1550        files,
1551        config_candidates,
1552        diagnostics,
1553    }
1554}
1555
1556#[cfg(test)]
1557mod tests {
1558    use std::ffi::OsStr;
1559    use std::path::MAIN_SEPARATOR;
1560
1561    use super::*;
1562
1563    /// Issue #2638: the anchor a directory-shaped built-in reports is the
1564    /// directory a reader can act on and the one `fallow --root` takes, not
1565    /// the deepest directory that happens to hold the file.
1566    #[test]
1567    fn exclusion_scope_stops_at_the_patterns_literal_directory_segment() {
1568        assert_eq!(
1569            exclusion_scope(
1570                Path::new("projects/app/build/static/js/main.js"),
1571                "**/build/**"
1572            ),
1573            PathBuf::from("projects/app/build")
1574        );
1575        assert_eq!(
1576            exclusion_scope(Path::new("dist/a.ts"), "**/dist/**"),
1577            PathBuf::from("dist")
1578        );
1579        assert_eq!(
1580            exclusion_scope(
1581                Path::new("node_modules/react/index.js"),
1582                "**/node_modules/**"
1583            ),
1584            PathBuf::from("node_modules"),
1585            "the helper is shape-only: `**/node_modules/**` is never tallied, \
1586             but a directory-shaped pattern still collapses to its literal segment"
1587        );
1588    }
1589
1590    /// The DEEPEST matching segment wins, because the anchor is the directory
1591    /// the `--root` remedy names. Anchoring at the outermost `build` would
1592    /// leave the inner one in the path relative to the new root, so the
1593    /// built-in would match again and the advertised remedy would recover
1594    /// nothing.
1595    #[test]
1596    fn exclusion_scope_takes_the_deepest_matching_segment() {
1597        assert_eq!(
1598            exclusion_scope(Path::new("build/tools/build/a.ts"), "**/build/**"),
1599            PathBuf::from("build/tools/build")
1600        );
1601        assert_eq!(
1602            exclusion_scope(Path::new("dist/pkg/dist/inner/a.ts"), "**/dist/**"),
1603            PathBuf::from("dist/pkg/dist")
1604        );
1605    }
1606
1607    /// A file-shaped pattern has no literal segment to stop at, so the file's
1608    /// own directory is the most specific honest answer.
1609    #[test]
1610    fn exclusion_scope_falls_back_to_the_parent_for_a_file_shaped_pattern() {
1611        assert_eq!(
1612            exclusion_scope(Path::new("vendor/a.min.js"), "**/*.min.js"),
1613            PathBuf::from("vendor")
1614        );
1615        assert_eq!(
1616            exclusion_scope(Path::new("a.min.js"), "**/*.min.js"),
1617            PathBuf::new(),
1618            "a root-level match anchors at the root itself"
1619        );
1620    }
1621
1622    /// A pattern whose literal segment is absent from the path (only reachable
1623    /// through a future pattern shape) degrades to the parent rather than
1624    /// returning the whole path.
1625    #[test]
1626    fn exclusion_scope_falls_back_when_the_literal_segment_is_absent() {
1627        assert_eq!(
1628            exclusion_scope(Path::new("src/nested/a.ts"), "**/build/**"),
1629            PathBuf::from("src/nested")
1630        );
1631    }
1632
1633    /// Issue #2638: `directory_count` distinguishes one contained tree from an
1634    /// exclusion scattered over sibling packages, which is what keeps the
1635    /// rendered message from claiming a majority it does not have.
1636    #[test]
1637    fn the_directory_count_is_the_number_of_distinct_scopes() {
1638        let mut one_tree = ExcludedByPattern::default();
1639        one_tree.record(PathBuf::from("packages/web/build"));
1640        one_tree.record(PathBuf::from("packages/web/build"));
1641        assert_eq!(one_tree.file_count, 2);
1642        assert_eq!(one_tree.directory_count(), 1);
1643
1644        let mut scattered = ExcludedByPattern::default();
1645        scattered.record(PathBuf::from("packages/a/dist"));
1646        scattered.record(PathBuf::from("packages/b/dist"));
1647        assert_eq!(scattered.directory_count(), 2);
1648    }
1649
1650    /// Issue #2638 (AC2): the anchor is the directory with the most excluded
1651    /// files, and a tie resolves to the lexicographically first path so two
1652    /// runs on one tree report the same location.
1653    #[test]
1654    fn the_anchor_is_the_largest_group_with_ties_broken_by_path() {
1655        let mut tally = ExcludedByPattern::default();
1656        for _ in 0..3 {
1657            tally.record(PathBuf::from("packages/web/build"));
1658        }
1659        tally.record(PathBuf::from("packages/api/build"));
1660        assert_eq!(tally.file_count, 4);
1661        assert_eq!(tally.anchor(), PathBuf::from("packages/web/build"));
1662
1663        let mut tied = ExcludedByPattern::default();
1664        tied.record(PathBuf::from("z/build"));
1665        tied.record(PathBuf::from("a/build"));
1666        assert_eq!(tied.anchor(), PathBuf::from("a/build"));
1667    }
1668
1669    /// Per-thread tallies merge into one exact total, which is what makes
1670    /// `file_count` trustworthy on a parallel walk.
1671    #[test]
1672    fn merging_two_thread_tallies_keeps_the_count_exact() {
1673        let mut left = ExcludedByPattern::default();
1674        left.record(PathBuf::from("dist"));
1675        left.record(PathBuf::from("dist"));
1676        let mut right = ExcludedByPattern::default();
1677        right.record(PathBuf::from("dist"));
1678        right.record(PathBuf::from("packages/ui/dist"));
1679
1680        left.merge(right);
1681        assert_eq!(left.file_count, 4);
1682        assert_eq!(left.scopes.get(Path::new("dist")), Some(&3));
1683        assert_eq!(left.anchor(), PathBuf::from("dist"));
1684    }
1685
1686    #[test]
1687    fn skipped_dotdirs_note_names_the_directory_when_there_is_one() {
1688        let root = Path::new("/repo");
1689        let only = PathBuf::from("/repo/.tooling");
1690        let note = build_skipped_dotdirs_note(root, &[&only], false);
1691        assert!(note.contains("skipped 1 hidden directory that contains source files"));
1692        assert!(note.contains("analyze it with fallow --root .tooling"));
1693        assert!(note.contains("add '.tooling/**' to"));
1694        assert!(
1695            !note.contains("<dir>"),
1696            "the single-directory remedy must be copy-pasteable: {note}"
1697        );
1698    }
1699
1700    #[test]
1701    fn skipped_dotdirs_note_pluralizes_and_keeps_the_placeholder() {
1702        let root = Path::new("/repo");
1703        let a = PathBuf::from("/repo/.a");
1704        let b = PathBuf::from("/repo/.b");
1705        let note = build_skipped_dotdirs_note(root, &[&a, &b], false);
1706        assert!(note.contains("skipped 2 hidden directories that contain source files"));
1707        assert!(note.contains("analyze one with fallow --root <dir>"));
1708    }
1709
1710    #[test]
1711    fn skipped_dotdirs_note_drops_the_tail_count_when_truncated() {
1712        let root = Path::new("/repo");
1713        let owned: Vec<PathBuf> = (0..8)
1714            .map(|i| PathBuf::from(format!("/repo/.d{i}")))
1715            .collect();
1716        let reportable: Vec<&PathBuf> = owned.iter().collect();
1717
1718        let bounded = build_skipped_dotdirs_note(root, &reportable, true);
1719        assert!(bounded.contains("skipped at least 8 hidden directories"));
1720        assert!(
1721            bounded.contains("and more") && !bounded.contains("and 3 more"),
1722            "an inexact total must not carry an exact remainder: {bounded}"
1723        );
1724
1725        let complete = build_skipped_dotdirs_note(root, &reportable, false);
1726        assert!(!complete.contains("at least"));
1727        assert!(complete.contains("and 3 more"));
1728    }
1729
1730    #[test]
1731    fn dotdir_noise_path_components_stay_sorted_and_lowercase() {
1732        let mut sorted = DOTDIR_NOISE_PATH_COMPONENTS.to_vec();
1733        sorted.sort_unstable();
1734        assert_eq!(sorted, DOTDIR_NOISE_PATH_COMPONENTS);
1735        for component in DOTDIR_NOISE_PATH_COMPONENTS {
1736            assert!(!component.starts_with('.'), "'{component}' is not hidden");
1737            assert_eq!(
1738                *component,
1739                component.to_lowercase(),
1740                "'{component}' is matched verbatim against a path component"
1741            );
1742        }
1743    }
1744
1745    #[test]
1746    fn script_scope_denylist_stays_disjoint_and_sorted() {
1747        let mut sorted = SCRIPT_SCOPE_DENYLIST.to_vec();
1748        sorted.sort_unstable();
1749        assert_eq!(
1750            sorted, SCRIPT_SCOPE_DENYLIST,
1751            "keep the list sorted so additions stay reviewable"
1752        );
1753        for dir in SCRIPT_SCOPE_DENYLIST {
1754            assert!(dir.starts_with('.'), "'{dir}' is not a hidden directory");
1755            assert!(
1756                !ALLOWED_HIDDEN_DIRS.contains(dir),
1757                "'{dir}' is traversed, so it can never be a skipped candidate"
1758            );
1759        }
1760    }
1761
1762    #[test]
1763    fn dotdir_module_extensions_are_a_subset_of_source_extensions() {
1764        for ext in DOTDIR_MODULE_EXTENSIONS {
1765            assert!(
1766                SOURCE_EXTENSIONS.contains(ext),
1767                "'{ext}' is not discovered as source, so it cannot be a trigger"
1768            );
1769        }
1770        for ext in ["css", "scss", "sass", "less", "html", "graphql", "gql"] {
1771            assert!(
1772                !DOTDIR_MODULE_EXTENSIONS.contains(&ext),
1773                "'{ext}' carries no imports or exports for the message to be about"
1774            );
1775        }
1776    }
1777
1778    /// Reproduce the FileId-assignment rule used by `walk_source_files`: sort by
1779    /// absolute path, then assign `FileId(idx)` in that order.
1780    fn assign_file_ids(mut raw: Vec<(std::path::PathBuf, u64)>) -> Vec<DiscoveredFile> {
1781        raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
1782        raw.into_iter()
1783            .enumerate()
1784            .map(|(idx, (path, size_bytes))| DiscoveredFile {
1785                id: FileId(idx as u32),
1786                path,
1787                size_bytes,
1788            })
1789            .collect()
1790    }
1791
1792    /// ADR-004: an identical file set must yield identical FileIds regardless of
1793    /// the (nondeterministic, parallel) discovery order. The persisted graph
1794    /// cache keys persisted graph data by FileId, so a cache HIT (same paths +
1795    /// fingerprints) must reproduce the exact same FileId-to-path mapping the
1796    /// graph was built against. This guards the cache's soundness prerequisite.
1797    #[test]
1798    fn file_id_assignment_is_deterministic_for_identical_file_set() {
1799        let paths = [
1800            "/project/src/z.ts",
1801            "/project/src/a.ts",
1802            "/project/src/components/Button.tsx",
1803            "/project/src/components/Button.module.css",
1804            "/project/index.ts",
1805        ];
1806
1807        // Two independent walks that observe the same paths in DIFFERENT orders.
1808        let walk_one: Vec<(std::path::PathBuf, u64)> = paths
1809            .iter()
1810            .map(|p| (std::path::PathBuf::from(p), 10))
1811            .collect();
1812        let mut walk_two = walk_one.clone();
1813        walk_two.reverse();
1814
1815        let files_one = assign_file_ids(walk_one);
1816        let files_two = assign_file_ids(walk_two);
1817
1818        // Identical (FileId -> path) mapping despite the different walk orders.
1819        assert_eq!(files_one.len(), files_two.len());
1820        for (a, b) in files_one.iter().zip(files_two.iter()) {
1821            assert_eq!(a.id, b.id);
1822            assert_eq!(a.path, b.path);
1823        }
1824
1825        // The mapping is the path-sorted order, and each FileId equals its index
1826        // (the density invariant `project.rs` asserts and the graph relies on).
1827        for (idx, file) in files_one.iter().enumerate() {
1828            assert_eq!(file.id, FileId(idx as u32));
1829        }
1830        assert_eq!(
1831            files_one[0].path,
1832            std::path::PathBuf::from("/project/index.ts")
1833        );
1834    }
1835
1836    #[test]
1837    fn file_id_assignment_recomputes_after_rename_or_delete() {
1838        let before = assign_file_ids(vec![
1839            (std::path::PathBuf::from("/project/src/a.ts"), 10),
1840            (std::path::PathBuf::from("/project/src/b.ts"), 10),
1841            (std::path::PathBuf::from("/project/src/c.ts"), 10),
1842        ]);
1843        let after_delete = assign_file_ids(vec![
1844            (std::path::PathBuf::from("/project/src/a.ts"), 10),
1845            (std::path::PathBuf::from("/project/src/c.ts"), 10),
1846        ]);
1847        let after_rename = assign_file_ids(vec![
1848            (std::path::PathBuf::from("/project/src/a.ts"), 10),
1849            (std::path::PathBuf::from("/project/src/c.ts"), 10),
1850            (std::path::PathBuf::from("/project/src/d.ts"), 10),
1851        ]);
1852
1853        assert_eq!(before[0].id, FileId(0));
1854        assert_eq!(before[1].id, FileId(1));
1855        assert_eq!(before[2].id, FileId(2));
1856        assert_eq!(after_delete[0].id, FileId(0));
1857        assert_eq!(after_delete[1].id, FileId(1));
1858        assert_eq!(
1859            after_delete[1].path,
1860            std::path::PathBuf::from("/project/src/c.ts")
1861        );
1862        assert_eq!(after_rename[0].id, FileId(0));
1863        assert_eq!(after_rename[1].id, FileId(1));
1864        assert_eq!(
1865            after_rename[1].path,
1866            std::path::PathBuf::from("/project/src/c.ts")
1867        );
1868        assert_eq!(after_rename[2].id, FileId(2));
1869        assert_eq!(
1870            after_rename[2].path,
1871            std::path::PathBuf::from("/project/src/d.ts")
1872        );
1873    }
1874
1875    #[test]
1876    fn allowed_hidden_dirs() {
1877        assert!(is_allowed_hidden_dir(OsStr::new(".storybook")));
1878        assert!(is_allowed_hidden_dir(OsStr::new(".vitepress")));
1879        assert!(is_allowed_hidden_dir(OsStr::new(".well-known")));
1880        assert!(is_allowed_hidden_dir(OsStr::new(".changeset")));
1881        assert!(is_allowed_hidden_dir(OsStr::new(".github")));
1882    }
1883
1884    #[test]
1885    fn disallowed_hidden_dirs() {
1886        assert!(!is_allowed_hidden_dir(OsStr::new(".git")));
1887        assert!(!is_allowed_hidden_dir(OsStr::new(".cache")));
1888        assert!(!is_allowed_hidden_dir(OsStr::new(".vscode")));
1889        assert!(!is_allowed_hidden_dir(OsStr::new(".fallow")));
1890        assert!(!is_allowed_hidden_dir(OsStr::new(".next")));
1891    }
1892
1893    #[test]
1894    fn non_hidden_dirs_not_in_allowlist() {
1895        assert!(!is_allowed_hidden_dir(OsStr::new("src")));
1896        assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
1897    }
1898
1899    #[test]
1900    fn walk_types_match_every_supported_source_extension() {
1901        for capture_config in [false, true] {
1902            let types = build_walk_types(capture_config);
1903            for extension in SOURCE_EXTENSIONS {
1904                let path = format!("packages/ui/src/nested/component.{extension}");
1905                assert!(
1906                    types.matched(&path, false).is_whitelist(),
1907                    "expected source match for {path} with capture_config={capture_config}"
1908                );
1909            }
1910        }
1911    }
1912
1913    #[test]
1914    fn walk_types_match_typescript_declaration_files() {
1915        let types = build_walk_types(true);
1916        for path in [
1917            "src/env.d.ts",
1918            "packages/app/types/generated.d.mts",
1919            "packages/app/types/compat.d.cts",
1920        ] {
1921            assert!(
1922                types.matched(path, false).is_whitelist(),
1923                "expected declaration source match for {path}"
1924            );
1925        }
1926    }
1927
1928    #[test]
1929    fn walk_types_reject_source_extension_near_misses() {
1930        for capture_config in [false, true] {
1931            let types = build_walk_types(capture_config);
1932            for path in [
1933                "src/component.tsx.bak",
1934                "src/component.tsxmap",
1935                "src/component.TS",
1936                "src/component.gqlx",
1937                "src/component.htm",
1938                "src/component",
1939                "assets/component.png",
1940            ] {
1941                assert!(
1942                    types.matched(path, false).is_ignore(),
1943                    "expected non-source rejection for {path} with capture_config={capture_config}"
1944                );
1945            }
1946        }
1947    }
1948
1949    #[test]
1950    fn walk_types_keep_config_candidate_selection_separate() {
1951        assert!(
1952            build_walk_types(true)
1953                .matched("packages/app/tsconfig.json", false)
1954                .is_whitelist()
1955        );
1956        assert!(
1957            build_walk_types(false)
1958                .matched("packages/app/tsconfig.json", false)
1959                .is_ignore()
1960        );
1961    }
1962
1963    #[test]
1964    fn source_extensions_include_typescript() {
1965        assert!(SOURCE_EXTENSIONS.contains(&"ts"));
1966        assert!(SOURCE_EXTENSIONS.contains(&"tsx"));
1967        assert!(SOURCE_EXTENSIONS.contains(&"mts"));
1968        assert!(SOURCE_EXTENSIONS.contains(&"cts"));
1969        assert!(SOURCE_EXTENSIONS.contains(&"gts"));
1970    }
1971
1972    #[test]
1973    fn source_extensions_include_javascript() {
1974        assert!(SOURCE_EXTENSIONS.contains(&"js"));
1975        assert!(SOURCE_EXTENSIONS.contains(&"jsx"));
1976        assert!(SOURCE_EXTENSIONS.contains(&"mjs"));
1977        assert!(SOURCE_EXTENSIONS.contains(&"cjs"));
1978        assert!(SOURCE_EXTENSIONS.contains(&"gjs"));
1979    }
1980
1981    #[test]
1982    fn source_extensions_include_sfc_formats() {
1983        assert!(SOURCE_EXTENSIONS.contains(&"vue"));
1984        assert!(SOURCE_EXTENSIONS.contains(&"svelte"));
1985        assert!(SOURCE_EXTENSIONS.contains(&"astro"));
1986    }
1987
1988    #[test]
1989    fn source_extensions_include_styles() {
1990        assert!(SOURCE_EXTENSIONS.contains(&"css"));
1991        assert!(SOURCE_EXTENSIONS.contains(&"scss"));
1992        assert!(SOURCE_EXTENSIONS.contains(&"sass"));
1993        assert!(SOURCE_EXTENSIONS.contains(&"less"));
1994    }
1995
1996    #[test]
1997    fn source_extensions_exclude_non_source() {
1998        assert!(!SOURCE_EXTENSIONS.contains(&"json"));
1999        assert!(!SOURCE_EXTENSIONS.contains(&"yaml"));
2000        assert!(!SOURCE_EXTENSIONS.contains(&"md"));
2001        assert!(!SOURCE_EXTENSIONS.contains(&"png"));
2002        assert!(!SOURCE_EXTENSIONS.contains(&"htm"));
2003    }
2004
2005    #[test]
2006    fn source_extensions_include_html() {
2007        assert!(SOURCE_EXTENSIONS.contains(&"html"));
2008    }
2009
2010    #[test]
2011    fn source_extensions_include_graphql_documents() {
2012        assert!(SOURCE_EXTENSIONS.contains(&"graphql"));
2013        assert!(SOURCE_EXTENSIONS.contains(&"gql"));
2014    }
2015
2016    fn build_production_glob_set() -> globset::GlobSet {
2017        let mut builder = globset::GlobSetBuilder::new();
2018        for pattern in PRODUCTION_EXCLUDE_PATTERNS {
2019            builder.add(
2020                globset::GlobBuilder::new(pattern)
2021                    .literal_separator(true)
2022                    .build()
2023                    .expect("valid glob pattern"),
2024            );
2025        }
2026        builder.build().expect("valid glob set")
2027    }
2028
2029    #[test]
2030    fn production_excludes_test_files() {
2031        let set = build_production_glob_set();
2032        assert!(set.is_match("src/Button.test.ts"));
2033        assert!(set.is_match("src/utils.spec.tsx"));
2034        assert!(set.is_match("src/__tests__/helper.ts"));
2035        assert!(!set.is_match("src/Button.ts"));
2036        assert!(!set.is_match("src/utils.tsx"));
2037    }
2038
2039    #[test]
2040    fn production_excludes_story_files() {
2041        let set = build_production_glob_set();
2042        assert!(set.is_match("src/Button.stories.tsx"));
2043        assert!(set.is_match("src/Card.story.ts"));
2044        assert!(!set.is_match("src/Button.tsx"));
2045    }
2046
2047    #[test]
2048    fn production_excludes_config_files_at_root_only() {
2049        let set = build_production_glob_set();
2050        assert!(set.is_match("vitest.config.ts"));
2051        assert!(set.is_match("jest.config.js"));
2052        assert!(!set.is_match("src/app/app.config.ts"));
2053        assert!(!set.is_match("src/app/app.config.server.ts"));
2054        assert!(!set.is_match("packages/foo/vitest.config.ts"));
2055        assert!(!set.is_match("src/config.ts"));
2056    }
2057
2058    #[test]
2059    fn production_patterns_are_valid_globs() {
2060        let _ = build_production_glob_set();
2061    }
2062
2063    #[test]
2064    fn disallowed_hidden_dirs_idea() {
2065        assert!(!is_allowed_hidden_dir(OsStr::new(".idea")));
2066    }
2067
2068    #[test]
2069    fn source_extensions_include_mdx() {
2070        assert!(SOURCE_EXTENSIONS.contains(&"mdx"));
2071    }
2072
2073    #[test]
2074    fn source_extensions_exclude_image_and_data_formats() {
2075        assert!(!SOURCE_EXTENSIONS.contains(&"png"));
2076        assert!(!SOURCE_EXTENSIONS.contains(&"jpg"));
2077        assert!(!SOURCE_EXTENSIONS.contains(&"svg"));
2078        assert!(!SOURCE_EXTENSIONS.contains(&"txt"));
2079        assert!(!SOURCE_EXTENSIONS.contains(&"csv"));
2080        assert!(!SOURCE_EXTENSIONS.contains(&"wasm"));
2081    }
2082
2083    #[test]
2084    fn is_declaration_file_matches_dts_variants() {
2085        assert!(is_declaration_file(Path::new("env.d.ts")));
2086        assert!(is_declaration_file(Path::new("src/auto-imports.d.ts")));
2087        assert!(is_declaration_file(Path::new("mod.d.mts")));
2088        assert!(is_declaration_file(Path::new("compat.d.cts")));
2089        assert!(!is_declaration_file(Path::new("index.ts")));
2090        assert!(!is_declaration_file(Path::new("component.tsx")));
2091        assert!(!is_declaration_file(Path::new("notes.d.txt")));
2092    }
2093
2094    #[test]
2095    fn format_size_mb_renders_one_decimal() {
2096        assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
2097        assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
2098        assert_eq!(format_size_mb(0), "0.0 MB");
2099    }
2100
2101    #[test]
2102    fn partition_by_size_no_limit_keeps_all() {
2103        let raw = vec![(PathBuf::from("a.ts"), 10), (PathBuf::from("b.ts"), 10_000)];
2104        let (kept, skipped) = partition_by_size(raw, None);
2105        assert_eq!(kept.len(), 2);
2106        assert!(skipped.is_empty());
2107    }
2108
2109    #[test]
2110    fn partition_by_size_skips_strictly_over_limit() {
2111        let raw = vec![
2112            (PathBuf::from("under.ts"), 99),
2113            (PathBuf::from("exact.ts"), 100),
2114            (PathBuf::from("over.ts"), 101),
2115        ];
2116        let (kept, skipped) = partition_by_size(raw, Some(100));
2117        let kept_has = |name: &str| kept.iter().any(|(p, _)| p.as_path() == Path::new(name));
2118        assert!(kept_has("under.ts"));
2119        assert!(
2120            kept_has("exact.ts"),
2121            "a file exactly at the limit is kept (skip is strictly-greater)"
2122        );
2123        assert_eq!(skipped.len(), 1);
2124        assert_eq!(skipped[0].0, PathBuf::from("over.ts"));
2125    }
2126
2127    #[test]
2128    fn partition_by_size_exempts_declaration_files() {
2129        let raw = vec![
2130            (PathBuf::from("huge.ts"), 10_000),
2131            (PathBuf::from("auto-imports.d.ts"), 10_000),
2132        ];
2133        let (kept, skipped) = partition_by_size(raw, Some(100));
2134        assert!(
2135            kept.iter()
2136                .any(|(p, _)| p.as_path() == Path::new("auto-imports.d.ts")),
2137            "declaration files are exempt from the size skip regardless of size"
2138        );
2139        assert_eq!(skipped.len(), 1);
2140        assert_eq!(skipped[0].0, PathBuf::from("huge.ts"));
2141    }
2142
2143    fn disco(path: &str, size_bytes: u64) -> DiscoveredFile {
2144        DiscoveredFile {
2145            id: FileId(0),
2146            path: PathBuf::from(path),
2147            size_bytes,
2148        }
2149    }
2150
2151    #[test]
2152    fn largest_files_note_below_threshold_is_none() {
2153        let files = [disco("a.ts", 100), disco("b.ts", 200)];
2154        assert!(build_largest_files_note(Path::new("/p"), &files).is_none());
2155    }
2156
2157    #[test]
2158    fn largest_files_note_single_file_uses_singular() {
2159        let files = [disco("big.ts", 5 * 1024 * 1024)];
2160        let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
2161        assert!(
2162            note.contains("discovered 1 file;"),
2163            "singular noun on the single-big-file path (issue #1086 regression): {note}"
2164        );
2165        assert!(!note.contains("discovered 1 files"));
2166        assert!(note.contains("big.ts (5.0 MB)"));
2167    }
2168
2169    #[test]
2170    fn largest_files_note_filters_sub_floor_files() {
2171        let files = [disco("big.ts", 5 * 1024 * 1024), disco("tiny.ts", 10)];
2172        let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
2173        assert!(note.contains("discovered 2 files;"));
2174        assert!(note.contains("big.ts (5.0 MB)"));
2175        assert!(
2176            !note.contains("tiny.ts"),
2177            "sub-floor files are not listed as `0.0 MB` chaff: {note}"
2178        );
2179    }
2180
2181    #[test]
2182    fn largest_files_note_large_set_no_big_file_omits_list() {
2183        let files: Vec<DiscoveredFile> = (0..=LARGE_SET_THRESHOLD)
2184            .map(|i| disco(&format!("f{i}.ts"), 100))
2185            .collect();
2186        let note = build_largest_files_note(Path::new("/p"), &files).expect("large set fires");
2187        assert!(note.contains(&format!("discovered {} files", LARGE_SET_THRESHOLD + 1)));
2188        assert!(
2189            !note.contains("largest:"),
2190            "no sub-floor `largest:` list when no file clears the floor: {note}"
2191        );
2192    }
2193
2194    mod discover_files_integration {
2195        use std::path::PathBuf;
2196
2197        use fallow_config::{
2198            DuplicatesConfig, FallowConfig, FlagsConfig, HealthConfig, OutputFormat, ResolveConfig,
2199            RulesConfig,
2200        };
2201
2202        use super::*;
2203
2204        /// Create a minimal ResolvedConfig pointing at the given root directory.
2205        fn make_config(root: PathBuf, production: bool) -> ResolvedConfig {
2206            FallowConfig {
2207                production: production.into(),
2208                ..Default::default()
2209            }
2210            .resolve(root, OutputFormat::Human, 1, true, true, None)
2211        }
2212
2213        /// Helper to collect discovered file names (relative to root) for assertions.
2214        /// Normalizes path separators to `/` for cross-platform test consistency.
2215        fn file_names(files: &[DiscoveredFile], root: &std::path::Path) -> Vec<String> {
2216            files
2217                .iter()
2218                .map(|f| {
2219                    f.path
2220                        .strip_prefix(root)
2221                        .unwrap_or(&f.path)
2222                        .to_string_lossy()
2223                        .replace('\\', "/")
2224                })
2225                .collect()
2226        }
2227
2228        #[cfg(unix)]
2229        fn symlink_file(target: &Path, link: &Path) {
2230            std::os::unix::fs::symlink(target, link).expect("create file symlink");
2231        }
2232
2233        #[cfg(windows)]
2234        fn symlink_file(target: &Path, link: &Path) {
2235            std::os::windows::fs::symlink_file(target, link).expect("create file symlink");
2236        }
2237
2238        #[cfg(unix)]
2239        fn symlink_dir(target: &Path, link: &Path) {
2240            std::os::unix::fs::symlink(target, link).expect("create directory symlink");
2241        }
2242
2243        #[cfg(windows)]
2244        fn symlink_dir(target: &Path, link: &Path) {
2245            std::os::windows::fs::symlink_dir(target, link).expect("create directory symlink");
2246        }
2247
2248        #[test]
2249        fn source_symlinks_must_target_regular_files_inside_root() {
2250            let dir = tempfile::tempdir().expect("create project");
2251            let outside = tempfile::tempdir().expect("create outside dir");
2252            let src = dir.path().join("src");
2253            std::fs::create_dir_all(&src).unwrap();
2254            std::fs::write(src.join("regular.ts"), "export const regular = 1;").unwrap();
2255            std::fs::write(src.join("inside-target.ts"), "export const inside = 1;").unwrap();
2256            std::fs::write(
2257                outside.path().join("outside-target.ts"),
2258                "export const outside = 1;",
2259            )
2260            .unwrap();
2261            std::fs::create_dir_all(src.join("directory-target")).unwrap();
2262
2263            symlink_file(&src.join("inside-target.ts"), &src.join("inside-link.ts"));
2264            symlink_file(
2265                &outside.path().join("outside-target.ts"),
2266                &src.join("outside-link.ts"),
2267            );
2268            symlink_file(&src.join("missing-target.ts"), &src.join("broken-link.ts"));
2269            symlink_dir(
2270                &src.join("directory-target"),
2271                &src.join("directory-link.ts"),
2272            );
2273
2274            let config = make_config(dir.path().to_path_buf(), false);
2275            let names = file_names(&discover_files(&config), dir.path());
2276
2277            assert!(names.contains(&"src/regular.ts".to_string()));
2278            assert!(names.contains(&"src/inside-target.ts".to_string()));
2279            assert!(names.contains(&"src/inside-link.ts".to_string()));
2280            assert!(!names.contains(&"src/outside-link.ts".to_string()));
2281            assert!(!names.contains(&"src/broken-link.ts".to_string()));
2282            assert!(!names.contains(&"src/directory-link.ts".to_string()));
2283        }
2284
2285        /// Yarn PnP writes `.pnp.cjs` and `.pnp.loader.mjs` at the workspace
2286        /// root. They match the source extension filter but are generated
2287        /// install state, not code to analyze.
2288        #[test]
2289        fn skips_yarn_pnp_generated_files() {
2290            let dir = tempfile::tempdir().expect("create temp dir");
2291            std::fs::write(dir.path().join(".pnp.cjs"), "module.exports = {};").unwrap();
2292            std::fs::write(dir.path().join(".pnp.loader.mjs"), "export {};").unwrap();
2293            std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
2294
2295            let config = make_config(dir.path().to_path_buf(), false);
2296            let names = file_names(&discover_files(&config), dir.path());
2297
2298            assert_eq!(names, vec!["index.ts".to_string()]);
2299        }
2300
2301        #[test]
2302        fn discovers_source_files_with_valid_extensions() {
2303            let dir = tempfile::tempdir().expect("create temp dir");
2304            let src = dir.path().join("src");
2305            std::fs::create_dir_all(&src).unwrap();
2306
2307            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2308            std::fs::write(src.join("component.tsx"), "export default () => {};").unwrap();
2309            std::fs::write(src.join("utils.js"), "module.exports = {};").unwrap();
2310            std::fs::write(src.join("helper.jsx"), "export const h = 1;").unwrap();
2311            std::fs::write(src.join("config.mjs"), "export default {};").unwrap();
2312            std::fs::write(src.join("legacy.cjs"), "module.exports = {};").unwrap();
2313            std::fs::write(src.join("types.mts"), "export type T = string;").unwrap();
2314            std::fs::write(src.join("compat.cts"), "module.exports = {};").unwrap();
2315
2316            let config = make_config(dir.path().to_path_buf(), false);
2317            let files = discover_files(&config);
2318            let names = file_names(&files, dir.path());
2319
2320            assert!(names.contains(&"src/app.ts".to_string()));
2321            assert!(names.contains(&"src/component.tsx".to_string()));
2322            assert!(names.contains(&"src/utils.js".to_string()));
2323            assert!(names.contains(&"src/helper.jsx".to_string()));
2324            assert!(names.contains(&"src/config.mjs".to_string()));
2325            assert!(names.contains(&"src/legacy.cjs".to_string()));
2326            assert!(names.contains(&"src/types.mts".to_string()));
2327            assert!(names.contains(&"src/compat.cts".to_string()));
2328        }
2329
2330        #[test]
2331        fn compact_source_glob_preserves_discovered_file_inventory() {
2332            let dir = tempfile::tempdir().expect("create temp dir");
2333            let nested = dir.path().join("packages/ui/src/nested");
2334            std::fs::create_dir_all(&nested).unwrap();
2335
2336            let mut expected = Vec::new();
2337            for (index, extension) in SOURCE_EXTENSIONS.iter().enumerate() {
2338                let relative = format!("packages/ui/src/nested/source-{index}.{extension}");
2339                std::fs::write(dir.path().join(&relative), "export const value = 1;").unwrap();
2340                expected.push(relative);
2341            }
2342            for relative in [
2343                "packages/ui/src/nested/env.d.ts",
2344                "packages/ui/src/nested/generated.d.mts",
2345                "packages/ui/src/nested/compat.d.cts",
2346            ] {
2347                std::fs::write(dir.path().join(relative), "export type Value = string;").unwrap();
2348                expected.push(relative.to_string());
2349            }
2350            let rejected = [
2351                "packages/ui/src/nested/component.tsx.bak",
2352                "packages/ui/src/nested/component.tsxmap",
2353                "packages/ui/src/nested/component.TS",
2354                "packages/ui/src/nested/component.gqlx",
2355                "packages/ui/src/nested/component.htm",
2356                "packages/ui/src/nested/component",
2357                "packages/ui/src/nested/component.png",
2358            ];
2359            for relative in rejected {
2360                std::fs::write(dir.path().join(relative), "not source").unwrap();
2361            }
2362
2363            let config = make_config(dir.path().to_path_buf(), false);
2364            let names = file_names(&discover_files(&config), dir.path());
2365
2366            for relative in expected {
2367                assert!(
2368                    names.contains(&relative),
2369                    "missing supported source {relative}"
2370                );
2371            }
2372            for relative in rejected {
2373                assert!(
2374                    !names.iter().any(|name| name == relative),
2375                    "unexpected near-miss source {relative}"
2376                );
2377            }
2378        }
2379
2380        #[test]
2381        fn excludes_non_source_extensions() {
2382            let dir = tempfile::tempdir().expect("create temp dir");
2383            let src = dir.path().join("src");
2384            std::fs::create_dir_all(&src).unwrap();
2385
2386            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2387
2388            std::fs::write(src.join("data.json"), "{}").unwrap();
2389            std::fs::write(src.join("readme.md"), "# Hello").unwrap();
2390            std::fs::write(src.join("notes.txt"), "notes").unwrap();
2391            std::fs::write(src.join("logo.png"), [0u8; 8]).unwrap();
2392
2393            let config = make_config(dir.path().to_path_buf(), false);
2394            let files = discover_files(&config);
2395            let names = file_names(&files, dir.path());
2396
2397            assert_eq!(names.len(), 1, "only the .ts file should be discovered");
2398            assert!(names.contains(&"src/app.ts".to_string()));
2399        }
2400
2401        #[test]
2402        fn excludes_disallowed_hidden_directories() {
2403            let dir = tempfile::tempdir().expect("create temp dir");
2404
2405            let git_dir = dir.path().join(".git");
2406            std::fs::create_dir_all(&git_dir).unwrap();
2407            std::fs::write(git_dir.join("hooks.ts"), "// git hook").unwrap();
2408
2409            let idea_dir = dir.path().join(".idea");
2410            std::fs::create_dir_all(&idea_dir).unwrap();
2411            std::fs::write(idea_dir.join("workspace.ts"), "// idea").unwrap();
2412
2413            let cache_dir = dir.path().join(".cache");
2414            std::fs::create_dir_all(&cache_dir).unwrap();
2415            std::fs::write(cache_dir.join("cached.js"), "// cached").unwrap();
2416
2417            let src = dir.path().join("src");
2418            std::fs::create_dir_all(&src).unwrap();
2419            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2420
2421            let config = make_config(dir.path().to_path_buf(), false);
2422            let files = discover_files(&config);
2423            let names = file_names(&files, dir.path());
2424
2425            assert_eq!(names.len(), 1, "only src/app.ts should be discovered");
2426            assert!(names.contains(&"src/app.ts".to_string()));
2427        }
2428
2429        #[test]
2430        fn includes_allowed_hidden_directories() {
2431            let dir = tempfile::tempdir().expect("create temp dir");
2432
2433            let storybook = dir.path().join(".storybook");
2434            std::fs::create_dir_all(&storybook).unwrap();
2435            std::fs::write(storybook.join("main.ts"), "export default {};").unwrap();
2436
2437            let github = dir.path().join(".github");
2438            std::fs::create_dir_all(&github).unwrap();
2439            std::fs::write(github.join("actions.js"), "module.exports = {};").unwrap();
2440
2441            let changeset = dir.path().join(".changeset");
2442            std::fs::create_dir_all(&changeset).unwrap();
2443            std::fs::write(changeset.join("config.js"), "module.exports = {};").unwrap();
2444
2445            let config = make_config(dir.path().to_path_buf(), false);
2446            let files = discover_files(&config);
2447            let names = file_names(&files, dir.path());
2448
2449            assert!(
2450                names.contains(&".storybook/main.ts".to_string()),
2451                "files in .storybook should be discovered"
2452            );
2453            assert!(
2454                names.contains(&".github/actions.js".to_string()),
2455                "files in .github should be discovered"
2456            );
2457            assert!(
2458                names.contains(&".changeset/config.js".to_string()),
2459                "files in .changeset should be discovered"
2460            );
2461        }
2462
2463        #[test]
2464        fn default_discovery_excludes_client_and_server_hidden_directories() {
2465            let dir = tempfile::tempdir().expect("create temp dir");
2466            let app = dir.path().join("app");
2467            std::fs::create_dir_all(app.join(".client")).unwrap();
2468            std::fs::create_dir_all(app.join(".server")).unwrap();
2469            std::fs::write(app.join(".client/analytics.ts"), "export const a = 1;").unwrap();
2470            std::fs::write(app.join(".server/db.ts"), "export const db = {};").unwrap();
2471            std::fs::write(app.join("root.tsx"), "export default function Root() {}").unwrap();
2472
2473            let config = make_config(dir.path().to_path_buf(), false);
2474            let files = discover_files(&config);
2475            let names = file_names(&files, dir.path());
2476
2477            assert!(names.contains(&"app/root.tsx".to_string()));
2478            assert!(!names.contains(&"app/.client/analytics.ts".to_string()));
2479            assert!(!names.contains(&"app/.server/db.ts".to_string()));
2480        }
2481
2482        #[test]
2483        fn scoped_hidden_dirs_include_client_and_server_under_package_root() {
2484            let dir = tempfile::tempdir().expect("create temp dir");
2485            let package = dir.path().join("packages/app");
2486            std::fs::create_dir_all(package.join("app/.client")).unwrap();
2487            std::fs::create_dir_all(package.join("app/.server")).unwrap();
2488            std::fs::write(
2489                package.join("app/.client/analytics.ts"),
2490                "export const track = () => {};",
2491            )
2492            .unwrap();
2493            std::fs::write(package.join("app/.server/db.ts"), "export const db = {};").unwrap();
2494
2495            let config = make_config(dir.path().to_path_buf(), false);
2496            let scopes = [HiddenDirScope::new(
2497                package,
2498                vec![".client".to_string(), ".server".to_string()],
2499            )];
2500            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2501            let names = file_names(&files, dir.path());
2502
2503            assert!(names.contains(&"packages/app/app/.client/analytics.ts".to_string()));
2504            assert!(names.contains(&"packages/app/app/.server/db.ts".to_string()));
2505        }
2506
2507        #[test]
2508        fn scoped_hidden_dirs_do_not_include_unscoped_packages() {
2509            let dir = tempfile::tempdir().expect("create temp dir");
2510            let active = dir.path().join("packages/active");
2511            let inactive = dir.path().join("packages/inactive");
2512            std::fs::create_dir_all(active.join("app/.server")).unwrap();
2513            std::fs::create_dir_all(inactive.join("app/.server")).unwrap();
2514            std::fs::write(active.join("app/.server/db.ts"), "export const db = {};").unwrap();
2515            std::fs::write(inactive.join("app/.server/db.ts"), "export const db = {};").unwrap();
2516
2517            let config = make_config(dir.path().to_path_buf(), false);
2518            let scopes = [HiddenDirScope::new(active, vec![".server".to_string()])];
2519            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2520            let names = file_names(&files, dir.path());
2521
2522            assert!(names.contains(&"packages/active/app/.server/db.ts".to_string()));
2523            assert!(!names.contains(&"packages/inactive/app/.server/db.ts".to_string()));
2524        }
2525
2526        #[test]
2527        fn exact_path_scope_does_not_admit_the_same_name_elsewhere() {
2528            // A script naming `.a/.b/deep.mjs` says where the file it needs
2529            // lives. Before issue #461 the scope stored the bare names, so an
2530            // unrelated `elsewhere/.b` and `unrelated/.a` were pulled in too.
2531            let dir = tempfile::tempdir().expect("create temp dir");
2532            std::fs::create_dir_all(dir.path().join(".a/.b")).unwrap();
2533            std::fs::create_dir_all(dir.path().join("elsewhere/.b")).unwrap();
2534            std::fs::create_dir_all(dir.path().join("unrelated/.a")).unwrap();
2535            std::fs::write(dir.path().join(".a/.b/deep.mjs"), "export const a = 1;").unwrap();
2536            std::fs::write(dir.path().join("elsewhere/.b/y.mjs"), "export const b = 1;").unwrap();
2537            std::fs::write(dir.path().join("unrelated/.a/u.mjs"), "export const c = 1;").unwrap();
2538
2539            let config = make_config(dir.path().to_path_buf(), false);
2540            let scopes = [HiddenDirScope::new_exact_paths(
2541                dir.path().to_path_buf(),
2542                vec![".a".to_string(), format!(".a{MAIN_SEPARATOR}.b")],
2543            )];
2544            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2545            let names = file_names(&files, dir.path());
2546
2547            assert!(names.contains(&".a/.b/deep.mjs".to_string()));
2548            assert!(!names.contains(&"elsewhere/.b/y.mjs".to_string()));
2549            assert!(!names.contains(&"unrelated/.a/u.mjs".to_string()));
2550        }
2551
2552        #[test]
2553        fn exact_path_scope_admits_a_hidden_dir_under_a_visible_parent() {
2554            let dir = tempfile::tempdir().expect("create temp dir");
2555            std::fs::create_dir_all(dir.path().join("tools/.config")).unwrap();
2556            std::fs::create_dir_all(dir.path().join("other/.config")).unwrap();
2557            std::fs::write(
2558                dir.path().join("tools/.config/eslint.config.js"),
2559                "export default [];",
2560            )
2561            .unwrap();
2562            std::fs::write(
2563                dir.path().join("other/.config/eslint.config.js"),
2564                "export default [];",
2565            )
2566            .unwrap();
2567
2568            let config = make_config(dir.path().to_path_buf(), false);
2569            let scopes = [HiddenDirScope::new_exact_paths(
2570                dir.path().to_path_buf(),
2571                vec![format!("tools{MAIN_SEPARATOR}.config")],
2572            )];
2573            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2574            let names = file_names(&files, dir.path());
2575
2576            assert!(names.contains(&"tools/.config/eslint.config.js".to_string()));
2577            assert!(!names.contains(&"other/.config/eslint.config.js".to_string()));
2578        }
2579
2580        #[test]
2581        fn any_depth_scope_keeps_matching_by_name_for_plugins() {
2582            // Framework plugins declare `.client` / `.server` conventions that
2583            // may sit under any route directory, so the plugin shape must keep
2584            // matching at any depth.
2585            let dir = tempfile::tempdir().expect("create temp dir");
2586            std::fs::create_dir_all(dir.path().join("app/routes/deep/.server")).unwrap();
2587            std::fs::write(
2588                dir.path().join("app/routes/deep/.server/db.ts"),
2589                "export const db = {};",
2590            )
2591            .unwrap();
2592
2593            let config = make_config(dir.path().to_path_buf(), false);
2594            let scopes = [HiddenDirScope::new(
2595                dir.path().to_path_buf(),
2596                vec![".server".to_string()],
2597            )];
2598            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
2599            let names = file_names(&files, dir.path());
2600
2601            assert!(names.contains(&"app/routes/deep/.server/db.ts".to_string()));
2602        }
2603
2604        #[test]
2605        fn excludes_root_build_directory() {
2606            let dir = tempfile::tempdir().expect("create temp dir");
2607
2608            std::fs::write(dir.path().join(".ignore"), "/build/\n").unwrap();
2609
2610            let build_dir = dir.path().join("build");
2611            std::fs::create_dir_all(&build_dir).unwrap();
2612            std::fs::write(build_dir.join("output.js"), "// build output").unwrap();
2613
2614            let src = dir.path().join("src");
2615            std::fs::create_dir_all(&src).unwrap();
2616            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2617
2618            let config = make_config(dir.path().to_path_buf(), false);
2619            let files = discover_files(&config);
2620            let names = file_names(&files, dir.path());
2621
2622            assert_eq!(names.len(), 1, "root build/ should be excluded via .ignore");
2623            assert!(names.contains(&"src/app.ts".to_string()));
2624        }
2625
2626        #[test]
2627        fn excludes_nested_build_directory() {
2628            let dir = tempfile::tempdir().expect("create temp dir");
2629
2630            let nested_build = dir.path().join("src").join("build");
2631            std::fs::create_dir_all(&nested_build).unwrap();
2632            std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
2633
2634            let config = make_config(dir.path().to_path_buf(), false);
2635            let files = discover_files(&config);
2636            let names = file_names(&files, dir.path());
2637
2638            assert!(
2639                !names.contains(&"src/build/helper.ts".to_string()),
2640                "build/ is treated as generated output at any depth: {names:?}"
2641            );
2642        }
2643
2644        #[test]
2645        #[expect(
2646            clippy::cast_possible_truncation,
2647            reason = "test file counts are trivially small"
2648        )]
2649        fn file_ids_are_sequential_after_sorting() {
2650            let dir = tempfile::tempdir().expect("create temp dir");
2651            let src = dir.path().join("src");
2652            std::fs::create_dir_all(&src).unwrap();
2653
2654            std::fs::write(src.join("z_last.ts"), "export const z = 1;").unwrap();
2655            std::fs::write(src.join("a_first.ts"), "export const a = 1;").unwrap();
2656            std::fs::write(src.join("m_middle.ts"), "export const m = 1;").unwrap();
2657
2658            let config = make_config(dir.path().to_path_buf(), false);
2659            let files = discover_files(&config);
2660
2661            for (idx, file) in files.iter().enumerate() {
2662                assert_eq!(file.id, FileId(idx as u32), "FileId should be sequential");
2663            }
2664
2665            for pair in files.windows(2) {
2666                assert!(
2667                    pair[0].path < pair[1].path,
2668                    "files should be sorted by path"
2669                );
2670            }
2671        }
2672
2673        #[test]
2674        fn production_mode_excludes_test_files() {
2675            let dir = tempfile::tempdir().expect("create temp dir");
2676            let src = dir.path().join("src");
2677            std::fs::create_dir_all(&src).unwrap();
2678
2679            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2680            std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
2681            std::fs::write(src.join("app.spec.ts"), "describe('a', () => {});").unwrap();
2682            std::fs::write(src.join("app.stories.tsx"), "export default {};").unwrap();
2683
2684            let config = make_config(dir.path().to_path_buf(), true);
2685            let files = discover_files(&config);
2686            let names = file_names(&files, dir.path());
2687
2688            assert!(
2689                names.contains(&"src/app.ts".to_string()),
2690                "source files should be included in production mode"
2691            );
2692            assert!(
2693                !names.contains(&"src/app.test.ts".to_string()),
2694                "test files should be excluded in production mode"
2695            );
2696            assert!(
2697                !names.contains(&"src/app.spec.ts".to_string()),
2698                "spec files should be excluded in production mode"
2699            );
2700            assert!(
2701                !names.contains(&"src/app.stories.tsx".to_string()),
2702                "story files should be excluded in production mode"
2703            );
2704        }
2705
2706        #[test]
2707        fn non_production_mode_includes_test_files() {
2708            let dir = tempfile::tempdir().expect("create temp dir");
2709            let src = dir.path().join("src");
2710            std::fs::create_dir_all(&src).unwrap();
2711
2712            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2713            std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
2714
2715            let config = make_config(dir.path().to_path_buf(), false);
2716            let files = discover_files(&config);
2717            let names = file_names(&files, dir.path());
2718
2719            assert!(names.contains(&"src/app.ts".to_string()));
2720            assert!(
2721                names.contains(&"src/app.test.ts".to_string()),
2722                "test files should be included in non-production mode"
2723            );
2724        }
2725
2726        #[test]
2727        fn empty_directory_returns_no_files() {
2728            let dir = tempfile::tempdir().expect("create temp dir");
2729            let config = make_config(dir.path().to_path_buf(), false);
2730            let files = discover_files(&config);
2731            assert!(files.is_empty(), "empty project should discover no files");
2732        }
2733
2734        #[test]
2735        fn hidden_files_not_discovered_as_source() {
2736            let dir = tempfile::tempdir().expect("create temp dir");
2737
2738            std::fs::write(dir.path().join(".env"), "SECRET=abc").unwrap();
2739            std::fs::write(dir.path().join(".gitignore"), "node_modules").unwrap();
2740            std::fs::write(dir.path().join(".eslintrc.js"), "module.exports = {};").unwrap();
2741
2742            let src = dir.path().join("src");
2743            std::fs::create_dir_all(&src).unwrap();
2744            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
2745
2746            let config = make_config(dir.path().to_path_buf(), false);
2747            let files = discover_files(&config);
2748            let names = file_names(&files, dir.path());
2749
2750            assert!(
2751                !names.contains(&".env".to_string()),
2752                ".env should not be discovered"
2753            );
2754            assert!(
2755                !names.contains(&".gitignore".to_string()),
2756                ".gitignore should not be discovered"
2757            );
2758        }
2759
2760        /// Create a config with custom ignore patterns.
2761        fn make_config_with_ignores(root: PathBuf, ignores: Vec<String>) -> ResolvedConfig {
2762            FallowConfig {
2763                type_aware: fallow_config::TypeAwareConfig::default(),
2764                schema: None,
2765                minimum_version: None,
2766                extends: vec![],
2767                entry: vec![],
2768                ignore_patterns: ignores,
2769                ignore_findings: vec![],
2770                framework: vec![],
2771                workspaces: None,
2772                ignore_dependencies: vec![],
2773                ignore_unresolved_imports: vec![],
2774                ignore_exports: vec![],
2775                ignore_catalog_references: vec![],
2776                ignore_dependency_overrides: vec![],
2777                ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(
2778                ),
2779                used_class_members: vec![],
2780                ignore_decorators: vec![],
2781                unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
2782                duplicates: DuplicatesConfig::default(),
2783                similar_code: fallow_config::SimilarCodeConfig::default(),
2784                health: HealthConfig::default(),
2785                rules: RulesConfig::default(),
2786                boundaries: fallow_config::BoundaryConfig::default(),
2787                production: false.into(),
2788                plugins: vec![],
2789                rule_packs: vec![],
2790                dynamically_loaded: vec![],
2791                overrides: vec![],
2792                regression: None,
2793                audit: fallow_config::AuditConfig::default(),
2794                codeowners: None,
2795                public_packages: vec![],
2796                flags: FlagsConfig::default(),
2797                security: fallow_config::SecurityConfig::default(),
2798                fix: fallow_config::FixConfig::default(),
2799                resolve: ResolveConfig::default(),
2800                sealed: false,
2801                include_entry_exports: false,
2802                auto_imports: false,
2803                cache: fallow_config::CacheConfig::default(),
2804            }
2805            .resolve(root, OutputFormat::Human, 1, true, true, None)
2806        }
2807
2808        #[test]
2809        fn custom_ignore_patterns_exclude_matching_files() {
2810            let dir = tempfile::tempdir().expect("create temp dir");
2811
2812            let generated = dir.path().join("src").join("api").join("generated");
2813            std::fs::create_dir_all(&generated).unwrap();
2814            std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
2815
2816            let client = dir.path().join("src").join("api").join("client");
2817            std::fs::create_dir_all(&client).unwrap();
2818            std::fs::write(client.join("fetch.ts"), "export const fetch = {};").unwrap();
2819
2820            let src = dir.path().join("src");
2821            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2822
2823            let config = make_config_with_ignores(
2824                dir.path().to_path_buf(),
2825                vec![
2826                    "src/api/generated/**".to_string(),
2827                    "src/api/client/**".to_string(),
2828                ],
2829            );
2830            let files = discover_files(&config);
2831            let names = file_names(&files, dir.path());
2832
2833            assert_eq!(names.len(), 1, "only non-ignored files: {names:?}");
2834            assert!(names.contains(&"src/index.ts".to_string()));
2835        }
2836
2837        #[test]
2838        fn leading_dot_ignore_patterns_exclude_matching_files() {
2839            let dir = tempfile::tempdir().expect("create temp dir");
2840
2841            let generated = dir.path().join("src").join("generated");
2842            std::fs::create_dir_all(&generated).unwrap();
2843            std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
2844
2845            let src = dir.path().join("src");
2846            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2847
2848            let config = make_config_with_ignores(
2849                dir.path().to_path_buf(),
2850                vec!["./src/generated/**".to_string()],
2851            );
2852            let files = discover_files(&config);
2853            let names = file_names(&files, dir.path());
2854
2855            assert_eq!(names, vec!["src/index.ts"]);
2856        }
2857
2858        #[test]
2859        fn default_ignore_patterns_exclude_node_modules_and_dist() {
2860            let dir = tempfile::tempdir().expect("create temp dir");
2861
2862            let nm = dir.path().join("node_modules").join("lodash");
2863            std::fs::create_dir_all(&nm).unwrap();
2864            std::fs::write(nm.join("lodash.js"), "module.exports = {};").unwrap();
2865
2866            let dist = dir.path().join("dist");
2867            std::fs::create_dir_all(&dist).unwrap();
2868            std::fs::write(dist.join("bundle.js"), "// bundled").unwrap();
2869
2870            let src = dir.path().join("src");
2871            std::fs::create_dir_all(&src).unwrap();
2872            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2873
2874            let config = make_config(dir.path().to_path_buf(), false);
2875            let files = discover_files(&config);
2876            let names = file_names(&files, dir.path());
2877
2878            assert_eq!(names.len(), 1);
2879            assert!(names.contains(&"src/index.ts".to_string()));
2880        }
2881
2882        #[test]
2883        fn default_ignore_patterns_exclude_build_at_any_depth() {
2884            let dir = tempfile::tempdir().expect("create temp dir");
2885
2886            let build = dir.path().join("build");
2887            std::fs::create_dir_all(&build).unwrap();
2888            std::fs::write(build.join("output.js"), "// built").unwrap();
2889
2890            let nested_build = dir.path().join("src").join("build");
2891            std::fs::create_dir_all(&nested_build).unwrap();
2892            std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
2893
2894            let src = dir.path().join("src");
2895            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2896
2897            let config = make_config(dir.path().to_path_buf(), false);
2898            let files = discover_files(&config);
2899            let names = file_names(&files, dir.path());
2900
2901            assert_eq!(names, vec!["src/index.ts".to_string()]);
2902        }
2903
2904        /// A monorepo keeps its generated output inside each package, so the
2905        /// built-in exclusion has to survive the workspace prefix.
2906        #[test]
2907        fn default_ignore_patterns_exclude_nested_build() {
2908            let dir = tempfile::tempdir().expect("create temp dir");
2909
2910            let build = dir.path().join("build");
2911            std::fs::create_dir_all(&build).unwrap();
2912            std::fs::write(build.join("output.js"), "// built").unwrap();
2913
2914            let package_build = dir.path().join("projects").join("app").join("build");
2915            std::fs::create_dir_all(&package_build).unwrap();
2916            std::fs::write(package_build.join("index.js"), "// built").unwrap();
2917
2918            let src = dir.path().join("src");
2919            std::fs::create_dir_all(&src).unwrap();
2920            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
2921
2922            let config = make_config(dir.path().to_path_buf(), false);
2923            let files = discover_files(&config);
2924            let names = file_names(&files, dir.path());
2925
2926            assert_eq!(names, vec!["src/index.ts".to_string()]);
2927        }
2928
2929        /// `build` only counts as output when it is a whole path segment.
2930        #[test]
2931        fn default_ignore_patterns_keep_paths_that_merely_contain_build() {
2932            let dir = tempfile::tempdir().expect("create temp dir");
2933
2934            let src = dir.path().join("src");
2935            std::fs::create_dir_all(src.join("rebuild")).unwrap();
2936            std::fs::create_dir_all(src.join("buildings")).unwrap();
2937            std::fs::write(src.join("build.ts"), "export const a = 1;").unwrap();
2938            std::fs::write(src.join("rebuild").join("helper.ts"), "export const b = 1;").unwrap();
2939            std::fs::write(src.join("buildings").join("a.ts"), "export const c = 1;").unwrap();
2940
2941            let config = make_config(dir.path().to_path_buf(), false);
2942            let files = discover_files(&config);
2943            let mut names = file_names(&files, dir.path());
2944            names.sort();
2945
2946            assert_eq!(
2947                names,
2948                vec![
2949                    "src/build.ts".to_string(),
2950                    "src/buildings/a.ts".to_string(),
2951                    "src/rebuild/helper.ts".to_string(),
2952                ]
2953            );
2954        }
2955
2956        /// Resolve a config then override the per-file size limit in bytes.
2957        fn make_config_with_max_file_size(
2958            root: PathBuf,
2959            max_file_size_bytes: Option<u64>,
2960        ) -> ResolvedConfig {
2961            let mut config = make_config(root, false);
2962            config.max_file_size_bytes = max_file_size_bytes;
2963            config
2964        }
2965
2966        #[test]
2967        fn skips_files_over_max_file_size() {
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("small.ts"), "export const a = 1;").unwrap();
2972            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2973
2974            let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
2975            let files = discover_files(&config);
2976            let names = file_names(&files, dir.path());
2977
2978            assert!(names.contains(&"src/small.ts".to_string()));
2979            assert!(
2980                !names.contains(&"src/huge.ts".to_string()),
2981                "a file over the size limit must not be discovered"
2982            );
2983        }
2984
2985        #[test]
2986        fn declaration_files_exempt_from_size_skip() {
2987            let dir = tempfile::tempdir().expect("create temp dir");
2988            let src = dir.path().join("src");
2989            std::fs::create_dir_all(&src).unwrap();
2990            std::fs::write(src.join("auto-imports.d.ts"), "x".repeat(5_000)).unwrap();
2991            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
2992
2993            let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
2994            let files = discover_files(&config);
2995            let names = file_names(&files, dir.path());
2996
2997            assert!(
2998                names.contains(&"src/auto-imports.d.ts".to_string()),
2999                "a large .d.ts is exempt from the skip (reachability root for global types)"
3000            );
3001            assert!(!names.contains(&"src/huge.ts".to_string()));
3002        }
3003
3004        #[test]
3005        fn unlimited_size_keeps_large_files() {
3006            let dir = tempfile::tempdir().expect("create temp dir");
3007            let src = dir.path().join("src");
3008            std::fs::create_dir_all(&src).unwrap();
3009            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
3010
3011            let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
3012            let files = discover_files(&config);
3013            let names = file_names(&files, dir.path());
3014
3015            assert!(
3016                names.contains(&"src/huge.ts".to_string()),
3017                "no limit keeps every file"
3018            );
3019        }
3020
3021        #[test]
3022        fn skipped_file_recorded_in_workspace_diagnostics() {
3023            let dir = tempfile::tempdir().expect("create temp dir");
3024            let src = dir.path().join("src");
3025            std::fs::create_dir_all(&src).unwrap();
3026            std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
3027
3028            let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
3029            let _ = discover_files(&config);
3030
3031            let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
3032            let skipped: Vec<_> = diagnostics
3033                .iter()
3034                .filter(|d| {
3035                    matches!(
3036                        d.kind,
3037                        fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { .. }
3038                    )
3039                })
3040                .collect();
3041            assert_eq!(
3042                skipped.len(),
3043                1,
3044                "the skipped file is recorded in workspace diagnostics for JSON output"
3045            );
3046            assert!(skipped[0].path.ends_with("src/huge.ts"));
3047            assert!(
3048                matches!(
3049                    skipped[0].kind,
3050                    fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes }
3051                        if size_bytes == 5_000
3052                ),
3053                "the recorded diagnostic carries the on-disk byte size"
3054            );
3055        }
3056
3057        /// The skipped-source-dotdir entries the last walk on `root` recorded.
3058        fn dotdir_diagnostics(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
3059            fallow_config::workspace_diagnostics_for(root)
3060                .into_iter()
3061                .filter(|d| {
3062                    matches!(
3063                        d.kind,
3064                        fallow_config::WorkspaceDiagnosticKind::SkippedSourceDotdir
3065                    )
3066                })
3067                .collect()
3068        }
3069
3070        fn write_at(root: &Path, relative: &str, contents: &str) {
3071            let path = root.join(relative);
3072            std::fs::create_dir_all(path.parent().expect("has a parent")).unwrap();
3073            std::fs::write(path, contents).unwrap();
3074        }
3075
3076        #[test]
3077        fn skipped_source_dotdir_recorded_in_workspace_diagnostics() {
3078            let dir = tempfile::tempdir().expect("create temp dir");
3079            write_at(
3080                dir.path(),
3081                ".claude/hooks/probe.mjs",
3082                "export const a = 1;\n",
3083            );
3084            write_at(dir.path(), "src/app.ts", "export const b = 2;\n");
3085
3086            let config = make_config(dir.path().to_path_buf(), false);
3087            let files = discover_files(&config);
3088            let names = file_names(&files, dir.path());
3089
3090            let reported = dotdir_diagnostics(dir.path());
3091            assert_eq!(reported.len(), 1, "one skipped dotdir holds source files");
3092            assert!(reported[0].path.ends_with(".claude"));
3093            assert_eq!(reported[0].kind.id(), "skipped-source-dotdir");
3094            assert!(
3095                reported[0].message.contains("--root"),
3096                "message names the real remedy: {}",
3097                reported[0].message
3098            );
3099            assert!(
3100                names.contains(&"src/app.ts".to_string()),
3101                "traversal is unchanged for ordinary directories"
3102            );
3103            assert!(
3104                !names.contains(&".claude/hooks/probe.mjs".to_string()),
3105                "the diagnostic reports the skip, it does not change traversal"
3106            );
3107        }
3108
3109        #[test]
3110        fn allowlisted_dotdir_is_not_reported() {
3111            let dir = tempfile::tempdir().expect("create temp dir");
3112            write_at(dir.path(), ".storybook/main.ts", "export const a = 1;\n");
3113
3114            let config = make_config(dir.path().to_path_buf(), false);
3115            let files = discover_files(&config);
3116            let names = file_names(&files, dir.path());
3117
3118            assert!(dotdir_diagnostics(dir.path()).is_empty());
3119            assert!(
3120                names.contains(&".storybook/main.ts".to_string()),
3121                "an allowlisted dotdir is still traversed"
3122            );
3123        }
3124
3125        #[test]
3126        fn denylisted_dotdir_is_not_reported() {
3127            let dir = tempfile::tempdir().expect("create temp dir");
3128            write_at(dir.path(), ".idea/workspace.ts", "export const a = 1;\n");
3129            write_at(dir.path(), ".husky/hook.js", "export const b = 2;\n");
3130            write_at(dir.path(), ".next/page.js", "export const c = 3;\n");
3131            write_at(dir.path(), ".pnpm/x.js", "export const d = 4;\n");
3132
3133            let config = make_config(dir.path().to_path_buf(), false);
3134            let _ = discover_files(&config);
3135
3136            assert!(
3137                dotdir_diagnostics(dir.path()).is_empty(),
3138                "build caches, VCS and package-manager state never advise"
3139            );
3140        }
3141
3142        #[test]
3143        fn scoped_dotdir_is_traversed_and_not_reported() {
3144            let dir = tempfile::tempdir().expect("create temp dir");
3145            write_at(
3146                dir.path(),
3147                ".claude/hooks/probe.mjs",
3148                "export const a = 1;\n",
3149            );
3150
3151            let config = make_config(dir.path().to_path_buf(), false);
3152            let scopes = [HiddenDirScope::new(
3153                dir.path().to_path_buf(),
3154                vec![".claude".to_owned()],
3155            )];
3156            let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
3157            let names = file_names(&files, dir.path());
3158
3159            assert!(
3160                dotdir_diagnostics(dir.path()).is_empty(),
3161                "a plugin- or script-contributed scope is admitted, so nothing was skipped"
3162            );
3163            assert!(names.contains(&".claude/hooks/probe.mjs".to_string()));
3164        }
3165
3166        #[test]
3167        fn ignore_patterns_silence_the_skipped_source_dotdir() {
3168            let dir = tempfile::tempdir().expect("create temp dir");
3169            write_at(
3170                dir.path(),
3171                ".claude/hooks/probe.mjs",
3172                "export const a = 1;\n",
3173            );
3174
3175            let config =
3176                make_config_with_ignores(dir.path().to_path_buf(), vec![".claude/**".to_owned()]);
3177            let _ = discover_files(&config);
3178
3179            assert!(
3180                dotdir_diagnostics(dir.path()).is_empty(),
3181                "the documented silencing route works"
3182            );
3183        }
3184
3185        #[test]
3186        fn dotdir_without_source_files_is_not_reported() {
3187            let dir = tempfile::tempdir().expect("create temp dir");
3188            write_at(dir.path(), ".claude/settings.json", "{}\n");
3189            write_at(dir.path(), ".claude/README.md", "# notes\n");
3190
3191            let config = make_config(dir.path().to_path_buf(), false);
3192            let _ = discover_files(&config);
3193
3194            assert!(dotdir_diagnostics(dir.path()).is_empty());
3195        }
3196
3197        #[test]
3198        fn dotdir_source_at_scan_depth_limit_is_reported() {
3199            let dir = tempfile::tempdir().expect("create temp dir");
3200            write_at(dir.path(), ".claude/a/b/deep.ts", "export const a = 1;\n");
3201
3202            let config = make_config(dir.path().to_path_buf(), false);
3203            let _ = discover_files(&config);
3204
3205            assert_eq!(dotdir_diagnostics(dir.path()).len(), 1);
3206        }
3207
3208        #[test]
3209        fn dotdir_source_below_scan_depth_limit_is_not_reported() {
3210            let dir = tempfile::tempdir().expect("create temp dir");
3211            write_at(
3212                dir.path(),
3213                ".claude/a/b/c/deeper.ts",
3214                "export const a = 1;\n",
3215            );
3216
3217            let config = make_config(dir.path().to_path_buf(), false);
3218            let _ = discover_files(&config);
3219
3220            assert!(
3221                dotdir_diagnostics(dir.path()).is_empty(),
3222                "the depth cap is real, so widening it stays a deliberate act"
3223            );
3224        }
3225
3226        /// Mark `root` as a git worktree so the `ignore` crate applies the
3227        /// gitignore files below it. `require_git` is on by default, and it
3228        /// tests for the presence of `.git`, not for a valid object store.
3229        fn mark_as_git_repo(root: &Path) {
3230            std::fs::create_dir_all(root.join(".git")).expect("create .git marker");
3231        }
3232
3233        #[test]
3234        fn gitignored_dotdir_contents_are_not_reported() {
3235            // The directory FORM (`.tooling/`) prunes the dotdir upstream of the
3236            // predicate, so these are the forms that reach it with every file
3237            // inside already ignored.
3238            for pattern in [".tooling/**", ".tooling/*", "**/.tooling/**", "*.ts"] {
3239                let dir = tempfile::tempdir().expect("create temp dir");
3240                mark_as_git_repo(dir.path());
3241                write_at(dir.path(), ".gitignore", &format!("{pattern}\n"));
3242                write_at(dir.path(), ".tooling/mod.ts", "export const a = 1;\n");
3243
3244                let config = make_config(dir.path().to_path_buf(), false);
3245                let _ = discover_files(&config);
3246
3247                assert!(
3248                    dotdir_diagnostics(dir.path()).is_empty(),
3249                    "gitignore pattern '{pattern}' excludes the contents, so neither \
3250                     advertised remedy would find anything there"
3251                );
3252            }
3253        }
3254
3255        #[test]
3256        fn self_ignoring_dotdir_is_not_reported() {
3257            let dir = tempfile::tempdir().expect("create temp dir");
3258            mark_as_git_repo(dir.path());
3259            write_at(dir.path(), ".toolcache/.gitignore", "*\n");
3260            write_at(dir.path(), ".toolcache/mod.ts", "export const a = 1;\n");
3261
3262            let config = make_config(dir.path().to_path_buf(), false);
3263            let _ = discover_files(&config);
3264
3265            assert!(
3266                dotdir_diagnostics(dir.path()).is_empty(),
3267                "a cache directory that ignores itself has excluded its own contents"
3268            );
3269        }
3270
3271        #[test]
3272        fn ungitignored_dotdir_in_a_git_repo_is_still_reported() {
3273            let dir = tempfile::tempdir().expect("create temp dir");
3274            mark_as_git_repo(dir.path());
3275            write_at(dir.path(), ".gitignore", "dist/\n");
3276            write_at(dir.path(), ".tooling/mod.ts", "export const a = 1;\n");
3277
3278            let config = make_config(dir.path().to_path_buf(), false);
3279            let _ = discover_files(&config);
3280
3281            assert_eq!(
3282                dotdir_diagnostics(dir.path()).len(),
3283                1,
3284                "the gitignore check must not swallow the case the diagnostic exists for"
3285            );
3286        }
3287
3288        #[test]
3289        fn production_run_does_not_report_a_test_only_dotdir() {
3290            let dir = tempfile::tempdir().expect("create temp dir");
3291            write_at(dir.path(), ".qa/thing.test.ts", "export const a = 1;\n");
3292            write_at(dir.path(), ".qa/thing.stories.tsx", "export const b = 2;\n");
3293
3294            let config = make_config(dir.path().to_path_buf(), true);
3295            let _ = discover_files(&config);
3296
3297            assert!(
3298                dotdir_diagnostics(dir.path()).is_empty(),
3299                "a --production run would analyze none of those files, so the \
3300                 --root remedy would return nothing"
3301            );
3302        }
3303
3304        #[test]
3305        fn production_run_still_reports_a_dotdir_with_production_source() {
3306            let dir = tempfile::tempdir().expect("create temp dir");
3307            write_at(dir.path(), ".qa/thing.test.ts", "export const a = 1;\n");
3308            write_at(dir.path(), ".qa/helper.ts", "export const b = 2;\n");
3309
3310            let config = make_config(dir.path().to_path_buf(), true);
3311            let _ = discover_files(&config);
3312
3313            assert_eq!(dotdir_diagnostics(dir.path()).len(), 1);
3314        }
3315
3316        #[test]
3317        fn dotdir_with_only_generated_markup_is_not_reported() {
3318            let dir = tempfile::tempdir().expect("create temp dir");
3319            write_at(dir.path(), ".lighthouseci/lhr-1.html", "<html></html>\n");
3320            write_at(dir.path(), ".styles/theme.css", ":root { color: red; }\n");
3321            write_at(dir.path(), ".gql/schema.graphql", "type Query { a: Int }\n");
3322
3323            let config = make_config(dir.path().to_path_buf(), false);
3324            let _ = discover_files(&config);
3325
3326            assert!(
3327                dotdir_diagnostics(dir.path()).is_empty(),
3328                "the message claims imports and exports are lost, and these have none"
3329            );
3330        }
3331
3332        #[test]
3333        fn generated_tool_and_foreign_vcs_dotdirs_are_not_reported() {
3334            let dir = tempfile::tempdir().expect("create temp dir");
3335            write_at(dir.path(), ".astro/types.d.ts", "export {};\n");
3336            write_at(dir.path(), ".wxt/types/imports.d.ts", "export {};\n");
3337            write_at(dir.path(), ".yalc/pkg/index.js", "export const a = 1;\n");
3338            write_at(dir.path(), ".jj/repo/config.js", "export const b = 2;\n");
3339            write_at(dir.path(), ".svn/pristine/y.js", "export const c = 3;\n");
3340
3341            let config = make_config(dir.path().to_path_buf(), false);
3342            let _ = discover_files(&config);
3343
3344            assert!(
3345                dotdir_diagnostics(dir.path()).is_empty(),
3346                "generated output and foreign VCS metadata are not first-party source"
3347            );
3348        }
3349
3350        #[test]
3351        fn denylisted_dotdirs_do_not_consume_the_candidate_ceiling() {
3352            let dir = tempfile::tempdir().expect("create temp dir");
3353            // Sorted before the real candidate, and more of them than the
3354            // ceiling, so a cap applied before the name checks would hide it.
3355            for index in 0..(DOTDIR_SCAN_MAX_CANDIDATES + 8) {
3356                write_at(
3357                    dir.path(),
3358                    &format!("packages/pkg{index:03}/.turbo/blob.js"),
3359                    "export const a = 1;\n",
3360                );
3361            }
3362            write_at(dir.path(), "zz/.tooling/mod.ts", "export const b = 2;\n");
3363
3364            let config = make_config(dir.path().to_path_buf(), false);
3365            let _ = discover_files(&config);
3366
3367            let reported = dotdir_diagnostics(dir.path());
3368            assert_eq!(reported.len(), 1, "{reported:?}");
3369            assert!(reported[0].path.ends_with(".tooling"));
3370        }
3371
3372        #[test]
3373        fn one_pathological_dotdir_cannot_starve_the_rest() {
3374            let dir = tempfile::tempdir().expect("create temp dir");
3375            // Wide and shallow, no source: exhausts this candidate's own budget.
3376            for index in 0..(DOTDIR_SCAN_MAX_ENTRIES * 2) {
3377                write_at(dir.path(), &format!(".aaa-noise/f{index}.bin"), "x");
3378            }
3379            write_at(dir.path(), ".zzz-real/mod.ts", "export const a = 1;\n");
3380
3381            let config = make_config(dir.path().to_path_buf(), false);
3382            let _ = discover_files(&config);
3383
3384            let reported = dotdir_diagnostics(dir.path());
3385            assert_eq!(reported.len(), 1, "{reported:?}");
3386            assert!(reported[0].path.ends_with(".zzz-real"));
3387        }
3388
3389        #[test]
3390        fn repeat_walks_do_not_stack_skipped_source_dotdirs() {
3391            let dir = tempfile::tempdir().expect("create temp dir");
3392            write_at(
3393                dir.path(),
3394                ".claude/hooks/probe.mjs",
3395                "export const a = 1;\n",
3396            );
3397
3398            let config = make_config(dir.path().to_path_buf(), false);
3399            let _ = discover_files(&config);
3400            let _ = discover_files(&config);
3401
3402            assert_eq!(
3403                dotdir_diagnostics(dir.path()).len(),
3404                1,
3405                "each walk replaces its own root's source-discovery set"
3406            );
3407        }
3408
3409        #[test]
3410        fn skips_large_one_line_js_as_minified_generated_output() {
3411            let dir = tempfile::tempdir().expect("create temp dir");
3412            let src = dir.path().join("src");
3413            std::fs::create_dir_all(&src).unwrap();
3414            let asset = src.join("index-abc123.js");
3415            std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
3416
3417            let config = make_config(dir.path().to_path_buf(), false);
3418            let files = discover_files(&config);
3419            let names = file_names(&files, dir.path());
3420
3421            assert!(
3422                !names.contains(&"src/index-abc123.js".to_string()),
3423                "large one-line JS assets should be skipped before parsing"
3424            );
3425
3426            let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
3427            assert!(
3428                diagnostics.iter().any(|diag| {
3429                    diag.path.ends_with("src/index-abc123.js")
3430                        && matches!(
3431                            diag.kind,
3432                            fallow_config::WorkspaceDiagnosticKind::SkippedMinifiedFile { .. }
3433                        )
3434                }),
3435                "the skipped minified asset is recorded for JSON output: {diagnostics:?}"
3436            );
3437        }
3438
3439        #[test]
3440        fn unlimited_size_keeps_large_one_line_js() {
3441            let dir = tempfile::tempdir().expect("create temp dir");
3442            let src = dir.path().join("src");
3443            std::fs::create_dir_all(&src).unwrap();
3444            let asset = src.join("index-abc123.js");
3445            std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
3446
3447            let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
3448            let files = discover_files(&config);
3449            let names = file_names(&files, dir.path());
3450
3451            assert!(
3452                names.contains(&"src/index-abc123.js".to_string()),
3453                "--max-file-size 0 should opt out of generated JS skipping"
3454            );
3455        }
3456
3457        #[test]
3458        fn keeps_large_multiline_js() {
3459            let dir = tempfile::tempdir().expect("create temp dir");
3460            let src = dir.path().join("src");
3461            std::fs::create_dir_all(&src).unwrap();
3462            let asset = src.join("handwritten.js");
3463            let mut content = String::new();
3464            while content.len() <= MINIFIED_FILE_SKIP_BYTES as usize + 1 {
3465                content.push_str("export const value = 1;\n");
3466            }
3467            std::fs::write(&asset, content).unwrap();
3468
3469            let config = make_config(dir.path().to_path_buf(), false);
3470            let files = discover_files(&config);
3471            let names = file_names(&files, dir.path());
3472
3473            assert!(
3474                names.contains(&"src/handwritten.js".to_string()),
3475                "large multiline JS should not be treated as a generated minified asset"
3476            );
3477        }
3478    }
3479}