1mod entry_points;
2mod infrastructure;
3mod parse_scripts;
4mod walk;
5
6use std::path::{Component, Path};
7
8use fallow_config::{PackageJson, ResolvedConfig};
9use rustc_hash::FxHashSet;
10
11pub use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
13
14pub(crate) use entry_points::resolve_entry_path;
16pub use entry_points::{
17 CategorizedEntryPoints, compile_glob_set, discover_dynamically_loaded_entry_points,
18 discover_entry_points, discover_plugin_entry_point_sets, discover_plugin_entry_points,
19 discover_workspace_entry_points,
20};
21pub(crate) use entry_points::{
22 EntryPointDiscovery, discover_entry_points_with_warnings_from_pkg,
23 discover_workspace_entry_points_with_warnings_from_pkg, warn_skipped_entry_summary,
24};
25pub use infrastructure::discover_infrastructure_entry_points;
26pub use walk::{
27 HiddenDirScope, PRODUCTION_EXCLUDE_PATTERNS, SOURCE_EXTENSIONS, discover_files,
28 discover_files_with_additional_hidden_dirs,
29};
30
31#[must_use]
40pub fn collect_plugin_hidden_dir_scopes(
41 config: &ResolvedConfig,
42 root_pkg: Option<&PackageJson>,
43 workspaces: &[fallow_config::WorkspaceInfo],
44) -> Vec<HiddenDirScope> {
45 let registry = crate::plugins::PluginRegistry::new(config.external_plugins.clone());
46 let mut scopes = Vec::new();
47
48 if let Some(pkg) = root_pkg {
49 push_plugin_hidden_dir_scope(&mut scopes, ®istry, pkg, &config.root);
50 }
51
52 for ws in workspaces {
53 if let Ok(pkg) = PackageJson::load(&ws.root.join("package.json")) {
54 push_plugin_hidden_dir_scope(&mut scopes, ®istry, &pkg, &ws.root);
55 }
56 }
57
58 scopes
59}
60
61#[must_use]
71pub fn collect_hidden_dir_scopes(
72 config: &ResolvedConfig,
73 root_pkg: Option<&PackageJson>,
74 workspaces: &[fallow_config::WorkspaceInfo],
75) -> Vec<HiddenDirScope> {
76 let _span = tracing::info_span!("collect_hidden_dir_scopes").entered();
77 let registry = crate::plugins::PluginRegistry::new(config.external_plugins.clone());
78 let mut scopes = Vec::new();
79
80 if let Some(pkg) = root_pkg {
81 push_plugin_hidden_dir_scope(&mut scopes, ®istry, pkg, &config.root);
82 if let Some(scope) = build_script_scope(pkg, &config.root) {
83 scopes.push(scope);
84 }
85 }
86
87 for ws in workspaces {
88 if let Ok(pkg) = PackageJson::load(&ws.root.join("package.json")) {
89 push_plugin_hidden_dir_scope(&mut scopes, ®istry, &pkg, &ws.root);
90 if let Some(scope) = build_script_scope(&pkg, &ws.root) {
91 scopes.push(scope);
92 }
93 }
94 }
95
96 scopes
97}
98
99fn push_plugin_hidden_dir_scope(
100 scopes: &mut Vec<HiddenDirScope>,
101 registry: &crate::plugins::PluginRegistry,
102 pkg: &PackageJson,
103 root: &Path,
104) {
105 let dirs = registry.discovery_hidden_dirs(pkg, root);
106 if !dirs.is_empty() {
107 scopes.push(HiddenDirScope::new(root.to_path_buf(), dirs));
108 }
109}
110
111#[must_use]
121pub fn discover_files_with_plugin_scopes(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
122 let root_pkg = PackageJson::load(&config.root.join("package.json")).ok();
123 let workspaces = fallow_config::discover_workspaces(&config.root);
124 let scopes = collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces);
125 discover_files_with_additional_hidden_dirs(config, &scopes)
126}
127
128const ALLOWED_HIDDEN_DIRS: &[&str] = &[
138 ".storybook",
139 ".vitepress",
140 ".well-known",
141 ".changeset",
142 ".github",
143];
144
145const SCRIPT_SCOPE_DENYLIST: &[&str] = &[
151 ".git",
152 ".next",
153 ".nuxt",
154 ".output",
155 ".svelte-kit",
156 ".turbo",
157 ".nx",
158 ".cache",
159 ".parcel-cache",
160 ".vercel",
161 ".netlify",
162 ".yarn",
163 ".pnpm-store",
164 ".docusaurus",
165 ".vscode",
166 ".idea",
167 ".fallow",
168 ".husky",
169];
170
171#[must_use]
191pub fn collect_script_hidden_dir_scopes(
192 config: &ResolvedConfig,
193 root_pkg: Option<&PackageJson>,
194 workspaces: &[fallow_config::WorkspaceInfo],
195) -> Vec<HiddenDirScope> {
196 let _span = tracing::info_span!("collect_script_hidden_dir_scopes").entered();
197 let mut scopes = Vec::new();
198
199 if let Some(pkg) = root_pkg
200 && let Some(scope) = build_script_scope(pkg, &config.root)
201 {
202 scopes.push(scope);
203 }
204 for ws in workspaces {
205 if let Ok(pkg) = PackageJson::load(&ws.root.join("package.json"))
206 && let Some(scope) = build_script_scope(&pkg, &ws.root)
207 {
208 scopes.push(scope);
209 }
210 }
211 scopes
212}
213
214fn build_script_scope(pkg: &PackageJson, root: &Path) -> Option<HiddenDirScope> {
215 let scripts = pkg.scripts.as_ref()?;
216 let mut seen = FxHashSet::default();
217 let mut dirs: Vec<String> = Vec::new();
218
219 for (script_name, script_value) in scripts {
220 for cmd in crate::scripts::parse_script(script_value) {
221 for path in cmd.config_args.iter().chain(cmd.file_args.iter()) {
222 for hidden in extract_hidden_segments(path) {
223 if SCRIPT_SCOPE_DENYLIST.contains(&hidden.as_str()) {
224 continue;
225 }
226 if seen.insert(hidden.clone()) {
227 tracing::debug!(
228 dir = %hidden,
229 script = %script_name,
230 package_root = %root.display(),
231 "inferred hidden_dir_scope from package.json#scripts"
232 );
233 dirs.push(hidden);
234 }
235 }
236 }
237 }
238 }
239
240 if dirs.is_empty() {
241 None
242 } else {
243 Some(HiddenDirScope::new(root.to_path_buf(), dirs))
244 }
245}
246
247fn extract_hidden_segments(path: &str) -> Vec<String> {
259 let p = Path::new(path);
260 if p.is_absolute() {
261 return Vec::new();
262 }
263 let components: Vec<Component> = p.components().collect();
264 if components.iter().any(|c| matches!(c, Component::ParentDir)) {
265 return Vec::new();
266 }
267 let mut out = Vec::new();
268 let upto = components.len().saturating_sub(1);
272 for component in &components[..upto] {
273 if let Component::Normal(name) = component {
274 let s = name.to_string_lossy();
275 if s.starts_with('.') && s.len() > 1 {
276 out.push(s.into_owned());
277 }
278 }
279 }
280 out
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
290 fn allowed_hidden_dirs_count() {
291 assert_eq!(
293 ALLOWED_HIDDEN_DIRS.len(),
294 5,
295 "update tests when adding new allowed hidden dirs"
296 );
297 }
298
299 #[test]
300 fn allowed_hidden_dirs_all_start_with_dot() {
301 for dir in ALLOWED_HIDDEN_DIRS {
302 assert!(
303 dir.starts_with('.'),
304 "allowed hidden dir '{dir}' must start with '.'"
305 );
306 }
307 }
308
309 #[test]
310 fn allowed_hidden_dirs_no_duplicates() {
311 let mut seen = rustc_hash::FxHashSet::default();
312 for dir in ALLOWED_HIDDEN_DIRS {
313 assert!(seen.insert(*dir), "duplicate allowed hidden dir: {dir}");
314 }
315 }
316
317 #[test]
318 fn allowed_hidden_dirs_no_trailing_slash() {
319 for dir in ALLOWED_HIDDEN_DIRS {
320 assert!(
321 !dir.ends_with('/'),
322 "allowed hidden dir '{dir}' should not have trailing slash"
323 );
324 }
325 }
326
327 #[test]
330 fn file_id_re_exported() {
331 let id = FileId(42);
333 assert_eq!(id.0, 42);
334 }
335
336 #[test]
337 fn source_extensions_re_exported() {
338 assert!(SOURCE_EXTENSIONS.contains(&"ts"));
339 assert!(SOURCE_EXTENSIONS.contains(&"tsx"));
340 }
341
342 #[test]
343 fn compile_glob_set_re_exported() {
344 let result = compile_glob_set(&["**/*.ts".to_string()]);
345 assert!(result.is_some());
346 }
347
348 #[test]
351 fn script_scope_denylist_all_start_with_dot() {
352 for dir in SCRIPT_SCOPE_DENYLIST {
353 assert!(
354 dir.starts_with('.'),
355 "denylisted dir '{dir}' must start with '.'"
356 );
357 }
358 }
359
360 #[test]
361 fn script_scope_denylist_no_duplicates() {
362 let mut seen = rustc_hash::FxHashSet::default();
363 for dir in SCRIPT_SCOPE_DENYLIST {
364 assert!(seen.insert(*dir), "duplicate denylisted dir: {dir}");
365 }
366 }
367
368 #[test]
369 fn script_scope_denylist_does_not_overlap_allowlist() {
370 for dir in SCRIPT_SCOPE_DENYLIST {
371 assert!(
372 !ALLOWED_HIDDEN_DIRS.contains(dir),
373 "denylisted dir '{dir}' must not also appear in ALLOWED_HIDDEN_DIRS"
374 );
375 }
376 }
377
378 #[test]
381 fn extract_hidden_segments_single_segment() {
382 assert_eq!(
383 extract_hidden_segments(".config/eslint.config.js"),
384 vec![".config".to_string()]
385 );
386 }
387
388 #[test]
389 fn extract_hidden_segments_with_leading_dot_slash() {
390 assert_eq!(
391 extract_hidden_segments("./.config/eslint.config.js"),
392 vec![".config".to_string()]
393 );
394 }
395
396 #[test]
397 fn extract_hidden_segments_nested_hidden() {
398 assert_eq!(
399 extract_hidden_segments(".foo/.bar/x.js"),
400 vec![".foo".to_string(), ".bar".to_string()]
401 );
402 }
403
404 #[test]
405 fn extract_hidden_segments_hidden_inside_normal_parent() {
406 assert_eq!(
407 extract_hidden_segments("sub/.config/eslint.config.js"),
408 vec![".config".to_string()]
409 );
410 }
411
412 #[test]
413 fn extract_hidden_segments_no_hidden_returns_empty() {
414 assert!(extract_hidden_segments("src/index.ts").is_empty());
415 }
416
417 #[test]
418 fn extract_hidden_segments_skips_trailing_filename() {
419 assert!(extract_hidden_segments(".env").is_empty());
422 assert!(extract_hidden_segments("src/.eslintrc.js").is_empty());
423 }
424
425 #[test]
426 fn extract_hidden_segments_skips_paths_with_parent_dir() {
427 assert!(extract_hidden_segments("../.config/eslint.config.js").is_empty());
429 assert!(extract_hidden_segments(".config/../other/x.js").is_empty());
430 assert!(extract_hidden_segments("../../.config/eslint.config.js").is_empty());
431 }
432
433 #[test]
434 fn extract_hidden_segments_skips_absolute_paths() {
435 #[cfg(unix)]
437 {
438 assert!(extract_hidden_segments("/etc/.config/eslint.config.js").is_empty());
439 }
440 #[cfg(windows)]
441 {
442 assert!(extract_hidden_segments(r"C:\etc\.config\eslint.config.js").is_empty());
443 }
444 }
445
446 #[test]
447 fn extract_hidden_segments_ignores_bare_dot() {
448 assert!(extract_hidden_segments(".").is_empty());
450 assert!(extract_hidden_segments("./src/index.ts").is_empty());
451 }
452
453 #[expect(
456 clippy::disallowed_types,
457 reason = "PackageJson::scripts uses std HashMap for serde compatibility"
458 )]
459 fn make_pkg_with_scripts(entries: &[(&str, &str)]) -> PackageJson {
460 let mut pkg = PackageJson::default();
461 let mut scripts: std::collections::HashMap<String, String> =
462 std::collections::HashMap::new();
463 for (name, value) in entries {
464 scripts.insert((*name).to_string(), (*value).to_string());
465 }
466 pkg.scripts = Some(scripts);
467 pkg
468 }
469
470 fn make_config(root: std::path::PathBuf) -> ResolvedConfig {
471 fallow_config::FallowConfig::default().resolve(
472 root,
473 fallow_config::OutputFormat::Human,
474 1,
475 true,
476 true,
477 None,
478 )
479 }
480
481 #[test]
482 fn script_scope_extracts_dash_c_config_arg() {
483 let dir = tempfile::tempdir().expect("tempdir");
484 let config = make_config(dir.path().to_path_buf());
485 let pkg = make_pkg_with_scripts(&[("lint", "eslint -c .config/eslint.config.js")]);
486 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
487
488 assert_eq!(scopes.len(), 1, "one scope for the root package");
489 let target_dir = dir.path().join(".config");
492 std::fs::create_dir_all(&target_dir).unwrap();
493 std::fs::write(target_dir.join("eslint.config.js"), "export default {};").unwrap();
494 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
495 let names: Vec<String> = files
496 .iter()
497 .map(|f| {
498 f.path
499 .strip_prefix(dir.path())
500 .unwrap_or(&f.path)
501 .to_string_lossy()
502 .replace('\\', "/")
503 })
504 .collect();
505 assert!(
506 names.contains(&".config/eslint.config.js".to_string()),
507 "expected .config/eslint.config.js to be discovered; got {names:?}"
508 );
509 }
510
511 #[test]
512 fn script_scope_extracts_long_config_arg_with_equals() {
513 let dir = tempfile::tempdir().expect("tempdir");
514 let config = make_config(dir.path().to_path_buf());
515 let pkg = make_pkg_with_scripts(&[("test", "vitest --config=.config/vitest.config.ts")]);
516 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
517 assert_eq!(scopes.len(), 1);
518 }
519
520 #[test]
521 fn script_scope_extracts_positional_file_arg() {
522 let dir = tempfile::tempdir().expect("tempdir");
523 let config = make_config(dir.path().to_path_buf());
524 let pkg = make_pkg_with_scripts(&[("build", "tsx ./.scripts/build.ts")]);
525 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
526 assert_eq!(scopes.len(), 1);
527 }
528
529 #[test]
530 fn script_scope_denies_known_bad_dirs() {
531 let dir = tempfile::tempdir().expect("tempdir");
532 let config = make_config(dir.path().to_path_buf());
533 let pkg = make_pkg_with_scripts(&[
535 ("cache", "tsx .nx/scripts/cache.ts"),
536 ("vscode", "node .vscode/build.js"),
537 ("yarn-state", "node .yarn/releases/yarn-4.0.0.cjs"),
538 ]);
539 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
540 assert!(
541 scopes.is_empty(),
542 "denylisted dirs must not produce scopes; got {scopes:?}"
543 );
544 }
545
546 #[test]
547 fn script_scope_mixes_denied_and_allowed_dirs() {
548 let dir = tempfile::tempdir().expect("tempdir");
549 let config = make_config(dir.path().to_path_buf());
550 let pkg = make_pkg_with_scripts(&[(
552 "lint",
553 "nx run-many --target=lint && eslint -c .config/eslint.config.js",
554 )]);
555 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
556 assert_eq!(scopes.len(), 1, "one scope for the .config reference");
557
558 std::fs::create_dir_all(dir.path().join(".config")).unwrap();
560 std::fs::write(
561 dir.path().join(".config/eslint.config.js"),
562 "export default {};",
563 )
564 .unwrap();
565 std::fs::create_dir_all(dir.path().join(".nx/cache")).unwrap();
566 std::fs::write(dir.path().join(".nx/cache/build.js"), "// cache").unwrap();
567
568 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
569 let names: Vec<String> = files
570 .iter()
571 .map(|f| {
572 f.path
573 .strip_prefix(dir.path())
574 .unwrap_or(&f.path)
575 .to_string_lossy()
576 .replace('\\', "/")
577 })
578 .collect();
579 assert!(names.contains(&".config/eslint.config.js".to_string()));
580 assert!(
581 !names.contains(&".nx/cache/build.js".to_string()),
582 "denylisted .nx must stay hidden"
583 );
584 }
585
586 #[test]
587 fn script_scope_skips_parent_dir_paths() {
588 let dir = tempfile::tempdir().expect("tempdir");
589 let config = make_config(dir.path().to_path_buf());
590 let pkg = make_pkg_with_scripts(&[("lint", "eslint -c ../../.config/eslint.config.js")]);
591 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
592 assert!(
593 scopes.is_empty(),
594 "paths with .. must not generate scopes; got {scopes:?}"
595 );
596 }
597
598 #[test]
599 fn script_scope_no_scripts_returns_empty() {
600 let dir = tempfile::tempdir().expect("tempdir");
601 let config = make_config(dir.path().to_path_buf());
602 let pkg = PackageJson::default();
603 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
604 assert!(scopes.is_empty());
605 }
606
607 #[test]
608 fn script_scope_no_hidden_paths_returns_empty() {
609 let dir = tempfile::tempdir().expect("tempdir");
610 let config = make_config(dir.path().to_path_buf());
611 let pkg = make_pkg_with_scripts(&[
612 ("build", "tsc -p tsconfig.json"),
613 ("lint", "eslint -c eslint.config.js"),
614 ]);
615 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
616 assert!(scopes.is_empty());
617 }
618
619 #[test]
620 fn script_scope_dedupes_within_package() {
621 let dir = tempfile::tempdir().expect("tempdir");
622 let config = make_config(dir.path().to_path_buf());
623 let pkg = make_pkg_with_scripts(&[
625 ("lint", "eslint -c .config/eslint.config.js"),
626 ("test", "vitest --config .config/vitest.config.ts"),
627 ]);
628 let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
629 assert_eq!(scopes.len(), 1);
630 }
631
632 #[test]
633 fn script_scope_workspace_packages_have_own_scope_root() {
634 let dir = tempfile::tempdir().expect("tempdir");
635 let config = make_config(dir.path().to_path_buf());
636 let ws_root = dir.path().join("packages/app");
639 std::fs::create_dir_all(&ws_root).unwrap();
640 let ws_pkg_path = ws_root.join("package.json");
641 std::fs::write(
642 &ws_pkg_path,
643 r#"{"name":"app","scripts":{"lint":"eslint -c .config/eslint.config.js"}}"#,
644 )
645 .unwrap();
646 let ws = fallow_config::WorkspaceInfo {
647 root: ws_root.clone(),
648 name: "app".to_string(),
649 is_internal_dependency: false,
650 };
651 let scopes = collect_script_hidden_dir_scopes(&config, None, &[ws]);
652 assert_eq!(scopes.len(), 1);
653
654 std::fs::create_dir_all(ws_root.join(".config")).unwrap();
656 std::fs::write(
657 ws_root.join(".config/eslint.config.js"),
658 "export default {};",
659 )
660 .unwrap();
661 let other_root = dir.path().join("packages/other");
663 std::fs::create_dir_all(other_root.join(".config")).unwrap();
664 std::fs::write(
665 other_root.join(".config/eslint.config.js"),
666 "export default {};",
667 )
668 .unwrap();
669
670 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
671 let names: Vec<String> = files
672 .iter()
673 .map(|f| {
674 f.path
675 .strip_prefix(dir.path())
676 .unwrap_or(&f.path)
677 .to_string_lossy()
678 .replace('\\', "/")
679 })
680 .collect();
681 assert!(names.contains(&"packages/app/.config/eslint.config.js".to_string()));
682 assert!(
683 !names.contains(&"packages/other/.config/eslint.config.js".to_string()),
684 "unscoped workspace must not get .config traversed"
685 );
686 }
687}