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