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