Skip to main content

fallow_core/discover/
walk.rs

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