1use std::ffi::OsStr;
4use std::path::{Path, PathBuf};
5use std::time::Instant;
6
7use fallow_config::{
8 PackageJson, ResolvedConfig, WorkspaceDiagnostic, WorkspaceInfo, discover_workspaces,
9 find_undeclared_workspaces_with_ignores,
10};
11pub use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
12
13pub use crate::core_backend::{
16 ALLOWED_HIDDEN_DIRS, PRODUCTION_EXCLUDE_PATTERNS, SOURCE_EXTENSIONS,
17};
18use rustc_hash::FxHashSet;
19
20use crate::{EngineError, EngineResult, plugins::PluginRegistry};
21
22const UNDECLARED_WORKSPACE_WARNING_PREVIEW: usize = 5;
23
24const SCRIPT_SCOPE_DENYLIST: &[&str] = &[
25 ".angular",
26 ".astro",
27 ".cache",
28 ".contentlayer",
29 ".docusaurus",
30 ".expo",
31 ".fallow",
32 ".git",
33 ".hg",
34 ".husky",
35 ".idea",
36 ".jj",
37 ".netlify",
38 ".next",
39 ".nuxt",
40 ".nx",
41 ".output",
42 ".parcel-cache",
43 ".pnpm",
44 ".pnpm-store",
45 ".react-router",
46 ".rollup.cache",
47 ".sst",
48 ".svelte-kit",
49 ".svn",
50 ".swc",
51 ".tanstack",
52 ".turbo",
53 ".velite",
54 ".vercel",
55 ".vinxi",
56 ".vscode",
57 ".wrangler",
58 ".wxt",
59 ".yalc",
60 ".yarn",
61];
62
63const ENV_WRAPPERS: &[&str] = &["cross-env", "dotenv", "env"];
64const NODE_RUNNERS: &[&str] = &["node", "ts-node", "tsx", "babel-node", "bun"];
65const SCRIPT_MULTIPLEXERS: &[&str] = &[
66 "concurrently",
67 "npm-run-all",
68 "npm-run-all2",
69 "run-s",
70 "run-p",
71 "run-s2",
72 "run-p2",
73];
74const BUN_RUNTIME_FLAGS: &[&str] = &["--bun", "--watch", "--hot", "--smol", "--no-clear-screen"];
75
76#[must_use]
91pub fn discover_workspace_packages(root: &Path) -> Vec<WorkspaceInfo> {
92 discover_workspaces(root)
93}
94
95#[must_use]
98pub fn discover_workspace_packages_with_patterns(
99 root: &Path,
100 workspace_patterns: &[String],
101) -> Vec<WorkspaceInfo> {
102 fallow_config::discover_workspaces_with_patterns(root, workspace_patterns)
103}
104
105pub fn discover_workspace_packages_for_config(
116 root: &Path,
117 ignore_patterns: &fallow_config::IgnorePatternSet,
118 workspace_patterns: &[String],
119) -> EngineResult<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>)> {
120 fallow_config::discover_workspaces_with_diagnostics_and_patterns(
121 root,
122 ignore_patterns,
123 workspace_patterns,
124 )
125 .map_err(|err| EngineError::new(err.to_string()))
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum HiddenDirMatch {
134 AnyDepth,
136 ExactPath,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct HiddenDirScope {
143 root: PathBuf,
144 dirs: Vec<String>,
145 match_mode: HiddenDirMatch,
146}
147
148impl HiddenDirScope {
149 #[must_use]
150 const fn new(root: PathBuf, dirs: Vec<String>) -> Self {
151 Self {
152 root,
153 dirs,
154 match_mode: HiddenDirMatch::AnyDepth,
155 }
156 }
157
158 #[must_use]
159 const fn new_exact_paths(root: PathBuf, dirs: Vec<String>) -> Self {
160 Self {
161 root,
162 dirs,
163 match_mode: HiddenDirMatch::ExactPath,
164 }
165 }
166
167 #[must_use]
168 pub(crate) fn root(&self) -> &Path {
169 &self.root
170 }
171
172 #[must_use]
173 pub(crate) fn dirs(&self) -> &[String] {
174 &self.dirs
175 }
176
177 #[must_use]
178 pub(crate) const fn match_mode(&self) -> HiddenDirMatch {
179 self.match_mode
180 }
181}
182
183#[derive(Debug, Clone)]
185pub struct AnalysisDiscovery {
186 files: Vec<DiscoveredFile>,
187 workspaces: Vec<WorkspaceInfo>,
188 root_pkg: Option<PackageJson>,
189 config_candidates: Vec<PathBuf>,
190 source_diagnostics: Vec<WorkspaceDiagnostic>,
191 discover_ms: f64,
192 workspaces_ms: f64,
193}
194
195impl AnalysisDiscovery {
196 fn from_parts(
197 sources: crate::core_backend::DiscoveredSources,
198 workspaces: Vec<WorkspaceInfo>,
199 root_pkg: Option<PackageJson>,
200 discover_ms: f64,
201 workspaces_ms: f64,
202 ) -> Self {
203 Self {
204 files: sources.files,
205 workspaces,
206 root_pkg,
207 config_candidates: sources.config_candidates,
208 source_diagnostics: sources.diagnostics,
209 discover_ms,
210 workspaces_ms,
211 }
212 }
213
214 #[must_use]
216 pub(crate) fn files(&self) -> &[DiscoveredFile] {
217 &self.files
218 }
219
220 #[must_use]
222 pub(crate) fn workspaces(&self) -> &[WorkspaceInfo] {
223 &self.workspaces
224 }
225
226 pub(crate) fn root_pkg(&self) -> Option<&PackageJson> {
227 self.root_pkg.as_ref()
228 }
229
230 pub(crate) fn config_candidates(&self) -> &[PathBuf] {
231 &self.config_candidates
232 }
233
234 pub(crate) fn source_diagnostics(&self) -> &[WorkspaceDiagnostic] {
238 &self.source_diagnostics
239 }
240
241 pub(crate) fn discover_ms(&self) -> f64 {
242 self.discover_ms
243 }
244
245 pub(crate) fn workspaces_ms(&self) -> f64 {
246 self.workspaces_ms
247 }
248
249 #[must_use]
251 pub fn into_files(self) -> Vec<DiscoveredFile> {
252 self.files
253 }
254}
255
256#[must_use]
258pub(crate) fn prepare_analysis_discovery(config: &ResolvedConfig) -> AnalysisDiscovery {
259 let workspaces_start = Instant::now();
260 let workspaces =
261 fallow_config::discover_workspaces_with_patterns(&config.root, &config.workspace_patterns);
262 let workspaces_ms = workspaces_start.elapsed().as_secs_f64() * 1000.0;
263 if !workspaces.is_empty() {
264 tracing::info!(count = workspaces.len(), "workspaces discovered");
265 }
266 warn_undeclared_workspaces(
267 &config.root,
268 &workspaces,
269 &config.ignore_patterns,
270 config.quiet,
271 );
272
273 let root_pkg = fallow_config::load_dir_package_json(&config.root);
274 let hidden_dir_scopes = collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces);
275
276 let discover_start = Instant::now();
277 let sources = discover_files_config_candidates_and_diagnostics(config, &hidden_dir_scopes);
278 let discover_ms = discover_start.elapsed().as_secs_f64() * 1000.0;
279
280 AnalysisDiscovery::from_parts(sources, workspaces, root_pkg, discover_ms, workspaces_ms)
281}
282
283#[must_use]
290pub(crate) fn prepare_analysis_discovery_with_workspaces(
291 config: &ResolvedConfig,
292 workspaces: &[WorkspaceInfo],
293 workspaces_ms: f64,
294) -> AnalysisDiscovery {
295 if !workspaces.is_empty() {
296 tracing::info!(count = workspaces.len(), "workspaces discovered");
297 }
298
299 let root_pkg = fallow_config::load_dir_package_json(&config.root);
300 let hidden_dir_scopes = collect_hidden_dir_scopes(config, root_pkg.as_ref(), workspaces);
301
302 let discover_start = Instant::now();
303 let sources = discover_files_config_candidates_and_diagnostics(config, &hidden_dir_scopes);
304 let discover_ms = discover_start.elapsed().as_secs_f64() * 1000.0;
305
306 AnalysisDiscovery::from_parts(
307 sources,
308 workspaces.to_vec(),
309 root_pkg,
310 discover_ms,
311 workspaces_ms,
312 )
313}
314
315fn format_undeclared_workspace_warning(
316 root: &Path,
317 undeclared: &[WorkspaceDiagnostic],
318) -> Option<String> {
319 if undeclared.is_empty() {
320 return None;
321 }
322
323 let preview = undeclared
324 .iter()
325 .take(UNDECLARED_WORKSPACE_WARNING_PREVIEW)
326 .map(|diagnostic| {
327 diagnostic
328 .path
329 .strip_prefix(root)
330 .unwrap_or(&diagnostic.path)
331 .display()
332 .to_string()
333 .replace('\\', "/")
334 })
335 .collect::<Vec<_>>();
336 let remaining = undeclared
337 .len()
338 .saturating_sub(UNDECLARED_WORKSPACE_WARNING_PREVIEW);
339 let tail = if remaining > 0 {
340 format!(" (and {remaining} more)")
341 } else {
342 String::new()
343 };
344 let noun = if undeclared.len() == 1 {
345 "directory with package.json is"
346 } else {
347 "directories with package.json are"
348 };
349 let guidance = if undeclared.len() == 1 {
350 "Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
351 } else {
352 "Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
353 };
354
355 Some(format!(
356 "{} {} not declared as {}: {}{}. {}",
357 undeclared.len(),
358 noun,
359 if undeclared.len() == 1 {
360 "a workspace"
361 } else {
362 "workspaces"
363 },
364 preview.join(", "),
365 tail,
366 guidance
367 ))
368}
369
370fn warn_undeclared_workspaces(
371 root: &Path,
372 workspaces: &[WorkspaceInfo],
373 ignore_patterns: &fallow_config::IgnorePatternSet,
374 quiet: bool,
375) {
376 let undeclared = find_undeclared_workspaces_with_ignores(root, workspaces, ignore_patterns);
377 if undeclared.is_empty() {
378 return;
379 }
380
381 let existing = fallow_config::workspace_diagnostics_for(root);
382 let already_flagged: FxHashSet<PathBuf> = existing
383 .iter()
384 .map(|diagnostic| {
385 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
386 })
387 .collect();
388 let undeclared: Vec<_> = undeclared
389 .into_iter()
390 .filter(|diagnostic| {
391 let canonical =
392 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
393 !already_flagged.contains(&canonical)
394 })
395 .collect();
396 if undeclared.is_empty() {
397 return;
398 }
399
400 fallow_config::append_workspace_diagnostics(root, undeclared.clone());
401
402 if !quiet && let Some(message) = format_undeclared_workspace_warning(root, &undeclared) {
403 tracing::warn!("{message}");
404 }
405}
406
407#[must_use]
409pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
410 ALLOWED_HIDDEN_DIRS
411 .iter()
412 .any(|&dir| OsStr::new(dir) == name)
413}
414
415#[must_use]
417pub fn collect_plugin_hidden_dir_scopes(
418 config: &ResolvedConfig,
419 root_pkg: Option<&PackageJson>,
420 workspaces: &[WorkspaceInfo],
421) -> Vec<HiddenDirScope> {
422 let registry = PluginRegistry::new(config.external_plugins.clone());
423 let mut scopes = Vec::new();
424
425 if let Some(pkg) = root_pkg {
426 push_plugin_hidden_dir_scope(&mut scopes, ®istry, pkg, &config.root);
427 }
428
429 for ws in workspaces {
430 if let Some(pkg) = fallow_config::load_dir_package_json(&ws.root) {
431 push_plugin_hidden_dir_scope(&mut scopes, ®istry, &pkg, &ws.root);
432 }
433 }
434
435 scopes
436}
437
438fn push_plugin_hidden_dir_scope(
439 scopes: &mut Vec<HiddenDirScope>,
440 registry: &PluginRegistry,
441 pkg: &PackageJson,
442 root: &Path,
443) {
444 let dirs = registry.discovery_hidden_dirs(pkg, root);
445 if !dirs.is_empty() {
446 scopes.push(HiddenDirScope::new(root.to_path_buf(), dirs));
447 }
448}
449
450#[must_use]
452pub(crate) fn collect_hidden_dir_scopes(
453 config: &ResolvedConfig,
454 root_pkg: Option<&PackageJson>,
455 workspaces: &[WorkspaceInfo],
456) -> Vec<HiddenDirScope> {
457 let _span = tracing::info_span!("collect_hidden_dir_scopes").entered();
458 let registry = PluginRegistry::new(config.external_plugins.clone());
459 let mut scopes = Vec::new();
460
461 if let Some(pkg) = root_pkg {
462 push_plugin_hidden_dir_scope(&mut scopes, ®istry, pkg, &config.root);
463 push_script_hidden_dir_scope(&mut scopes, pkg, &config.root);
464 }
465
466 for ws in workspaces {
467 if let Some(pkg) = fallow_config::load_dir_package_json(&ws.root) {
468 push_plugin_hidden_dir_scope(&mut scopes, ®istry, &pkg, &ws.root);
469 push_script_hidden_dir_scope(&mut scopes, &pkg, &ws.root);
470 }
471 }
472
473 scopes
474}
475
476fn push_script_hidden_dir_scope(scopes: &mut Vec<HiddenDirScope>, pkg: &PackageJson, root: &Path) {
477 if let Some(scope) = build_script_scope(pkg, root) {
478 scopes.push(scope);
479 }
480}
481
482fn build_script_scope(pkg: &PackageJson, root: &Path) -> Option<HiddenDirScope> {
483 let scripts = pkg.scripts.as_ref()?;
484 let mut seen = FxHashSet::default();
485 let mut dirs: Vec<String> = Vec::new();
486
487 for (script_name, script_value) in scripts {
488 for cmd in parse_script_value(script_value) {
489 for path in cmd.config_args.iter().chain(cmd.file_args.iter()) {
490 for hidden in extract_hidden_dir_paths(path) {
491 if hidden_dir_path_is_denied(&hidden) {
492 continue;
493 }
494 if seen.insert(hidden.clone()) {
495 tracing::debug!(
496 dir = %hidden,
497 script = %script_name,
498 package_root = %root.display(),
499 "inferred hidden_dir_scope from package.json#scripts"
500 );
501 dirs.push(hidden);
502 }
503 }
504 }
505 }
506 }
507
508 if dirs.is_empty() {
509 None
510 } else {
511 Some(HiddenDirScope::new_exact_paths(root.to_path_buf(), dirs))
512 }
513}
514
515fn hidden_dir_path_is_denied(path: &str) -> bool {
521 Path::new(path)
522 .file_name()
523 .and_then(|name| name.to_str())
524 .is_some_and(|name| SCRIPT_SCOPE_DENYLIST.contains(&name))
525}
526
527#[derive(Debug, PartialEq, Eq)]
528struct ScriptCommand {
529 config_args: Vec<String>,
530 file_args: Vec<String>,
531}
532
533fn parse_script_value(script: &str) -> Vec<ScriptCommand> {
534 let mut commands = Vec::new();
535
536 for segment in split_shell_operators(script) {
537 let segment = segment.trim();
538 if segment.is_empty() {
539 continue;
540 }
541 if let Some(cmd) = parse_command_segment(segment) {
542 commands.push(cmd);
543 }
544 }
545
546 commands
547}
548
549fn parse_command_segment(segment: &str) -> Option<ScriptCommand> {
550 let tokens: Vec<&str> = segment
551 .split_whitespace()
552 .map(strip_surrounding_quotes)
553 .collect();
554 if tokens.is_empty() {
555 return None;
556 }
557
558 let idx = skip_initial_wrappers(&tokens, 0)?;
559 let idx = advance_past_package_manager(&tokens, idx)?;
560 let binary = tokens[idx];
561
562 if SCRIPT_MULTIPLEXERS.contains(&binary) {
563 return Some(ScriptCommand {
564 config_args: Vec::new(),
565 file_args: Vec::new(),
566 });
567 }
568
569 let is_node_runner = NODE_RUNNERS.contains(&binary);
570 let (file_args, config_args) = extract_args_for_binary(&tokens, idx + 1, is_node_runner);
571
572 Some(ScriptCommand {
573 config_args,
574 file_args,
575 })
576}
577
578fn split_shell_operators(script: &str) -> Vec<&str> {
579 let mut segments = Vec::new();
580 let mut start = 0;
581 let bytes = script.as_bytes();
582 let len = bytes.len();
583 let mut index = 0;
584 let mut in_single_quote = false;
585 let mut in_double_quote = false;
586
587 while index < len {
588 let byte = bytes[index];
589
590 if byte == b'\'' && !in_double_quote {
591 in_single_quote = !in_single_quote;
592 index += 1;
593 continue;
594 }
595 if byte == b'"' && !in_single_quote {
596 in_double_quote = !in_double_quote;
597 index += 1;
598 continue;
599 }
600
601 if in_single_quote || in_double_quote {
602 index += 1;
603 continue;
604 }
605
606 if let Some(op_len) = shell_operator_len(bytes, index) {
607 segments.push(&script[start..index]);
608 index += op_len;
609 start = index;
610 continue;
611 }
612
613 index += 1;
614 }
615
616 if start < len {
617 segments.push(&script[start..]);
618 }
619
620 segments
621}
622
623fn shell_operator_len(bytes: &[u8], index: usize) -> Option<usize> {
624 let byte = bytes[index];
625 let next = bytes.get(index + 1).copied();
626
627 if matches!((byte, next), (b'&', Some(b'&')) | (b'|', Some(b'|'))) {
628 return Some(2);
629 }
630
631 if byte == b';' {
632 return Some(1);
633 }
634 if byte == b'|' && next != Some(b'|') {
635 return Some(1);
636 }
637 if byte == b'&' && next != Some(b'&') {
638 return Some(1);
639 }
640
641 None
642}
643
644fn strip_surrounding_quotes(token: &str) -> &str {
645 if token.len() >= 2 {
646 let first = token.as_bytes()[0];
647 let last = token.as_bytes()[token.len() - 1];
648 if (first == b'\'' || first == b'"') && first == last {
649 return &token[1..token.len() - 1];
650 }
651 }
652 token
653}
654
655fn skip_initial_wrappers(tokens: &[&str], mut index: usize) -> Option<usize> {
656 while index < tokens.len() && is_env_assignment(tokens[index]) {
657 index += 1;
658 }
659 if index >= tokens.len() {
660 return None;
661 }
662
663 while index < tokens.len() && ENV_WRAPPERS.contains(&tokens[index]) {
664 index += 1;
665 while index < tokens.len() && is_env_assignment(tokens[index]) {
666 index += 1;
667 }
668 if index < tokens.len() && tokens[index] == "--" {
669 index += 1;
670 }
671 }
672 if index >= tokens.len() {
673 return None;
674 }
675
676 Some(index)
677}
678
679fn advance_past_package_manager(tokens: &[&str], mut index: usize) -> Option<usize> {
680 let token = tokens[index];
681 if matches!(token, "npx" | "pnpx" | "bunx") {
682 index += 1;
683 while index < tokens.len() && tokens[index].starts_with('-') {
684 let flag = tokens[index];
685 index += 1;
686 if matches!(flag, "--package" | "-p") && index < tokens.len() {
687 index += 1;
688 }
689 }
690 } else if token == "bun" {
691 index += 1;
692 let mut saw_runtime_flag = false;
693 while index < tokens.len() && BUN_RUNTIME_FLAGS.contains(&tokens[index]) {
694 index += 1;
695 saw_runtime_flag = true;
696 }
697 if index >= tokens.len() {
698 return None;
699 }
700 let subcmd = tokens[index];
701 if subcmd == "exec" || subcmd == "x" {
702 index += 1;
703 } else if matches!(subcmd, "run" | "run-script") || !saw_runtime_flag {
704 return None;
705 }
706 } else if matches!(token, "yarn" | "pnpm" | "npm") {
707 if index + 1 < tokens.len() {
708 let subcmd = tokens[index + 1];
709 if subcmd == "exec" || subcmd == "dlx" {
710 index += 2;
711 } else {
712 return None;
713 }
714 } else {
715 return None;
716 }
717 }
718 if index >= tokens.len() {
719 return None;
720 }
721
722 Some(index)
723}
724
725fn extract_args_for_binary(
726 tokens: &[&str],
727 mut index: usize,
728 is_node_runner: bool,
729) -> (Vec<String>, Vec<String>) {
730 let mut file_args = Vec::new();
731 let mut config_args = Vec::new();
732
733 while index < tokens.len() {
734 let token = tokens[index];
735
736 if is_node_runner
737 && matches!(
738 token,
739 "-e" | "--eval" | "-p" | "--print" | "-r" | "--require"
740 )
741 {
742 index += 2;
743 continue;
744 }
745
746 if let Some(config) = extract_config_arg(token, tokens.get(index + 1).copied()) {
747 config_args.push(config);
748 if token.contains('=') || token.starts_with("--config=") || token.starts_with("-c=") {
749 index += 1;
750 } else {
751 index += 2;
752 }
753 continue;
754 }
755
756 if token.starts_with('-') {
757 index += 1;
758 continue;
759 }
760
761 if looks_like_file_path(token) {
762 file_args.push(token.to_string());
763 }
764 index += 1;
765 }
766
767 (file_args, config_args)
768}
769
770fn extract_config_arg(token: &str, next: Option<&str>) -> Option<String> {
771 if let Some(value) = token.strip_prefix("--config=")
772 && !value.is_empty()
773 {
774 return Some(value.to_string());
775 }
776 if let Some(value) = token.strip_prefix("-c=")
777 && !value.is_empty()
778 {
779 return Some(value.to_string());
780 }
781 if matches!(token, "--config" | "-c")
782 && let Some(next_token) = next
783 && !next_token.starts_with('-')
784 {
785 return Some(next_token.to_string());
786 }
787 None
788}
789
790fn is_env_assignment(token: &str) -> bool {
791 token.find('=').is_some_and(|eq_pos| {
792 let name = &token[..eq_pos];
793 !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
794 })
795}
796
797fn looks_like_file_path(token: &str) -> bool {
798 if !could_be_file_path(token) {
799 return false;
800 }
801
802 const EXTENSIONS: &[&str] = &[
803 ".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx", ".json", ".yaml", ".yml",
804 ".toml",
805 ];
806 if EXTENSIONS.iter().any(|ext| token.ends_with(ext)) {
807 return true;
808 }
809 token.starts_with("./")
810 || token.starts_with("../")
811 || (token.contains('/') && !token.starts_with('@') && !token.contains("://"))
812}
813
814fn could_be_file_path(token: &str) -> bool {
815 if token.contains("${{") || (token.contains("}}") && !token.contains("{{")) {
816 return false;
817 }
818
819 if token.contains('\\') {
820 return false;
821 }
822
823 if let Some(open) = token.find('[') {
824 let after_open = &token[open + 1..];
825 let close_offset = after_open.find(']');
826 if !matches!(close_offset, Some(offset) if offset > 0) {
827 return false;
828 }
829 }
830
831 true
832}
833
834fn extract_hidden_dir_paths(path: &str) -> Vec<String> {
846 let path = Path::new(path);
847 if path.is_absolute() {
848 return Vec::new();
849 }
850
851 let mut hidden = Vec::new();
852 let components = path.components().collect::<Vec<_>>();
853 if components.iter().any(|component| {
854 matches!(
855 component,
856 std::path::Component::ParentDir | std::path::Component::RootDir
857 )
858 }) {
859 return Vec::new();
860 }
861
862 let mut prefix = PathBuf::new();
863 for (index, component) in components.iter().enumerate() {
864 let std::path::Component::Normal(value) = component else {
865 continue;
866 };
867 if index == components.len().saturating_sub(1) {
868 continue;
869 }
870 prefix.push(value);
871 let value = value.to_string_lossy();
872 if !value.starts_with('.') || value == "." || value == ".." {
873 continue;
874 }
875 hidden.push(prefix.to_string_lossy().into_owned());
876 }
877
878 hidden
879}
880
881#[must_use]
883pub fn discover_files_and_config_candidates(
884 config: &ResolvedConfig,
885 additional_hidden_dir_scopes: &[HiddenDirScope],
886) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
887 crate::core_backend::discover_files_and_config_candidates(config, additional_hidden_dir_scopes)
888}
889
890#[must_use]
892pub(crate) fn discover_files_config_candidates_and_diagnostics(
893 config: &ResolvedConfig,
894 additional_hidden_dir_scopes: &[HiddenDirScope],
895) -> crate::core_backend::DiscoveredSources {
896 crate::core_backend::discover_files_config_candidates_and_diagnostics(
897 config,
898 additional_hidden_dir_scopes,
899 )
900}
901
902#[must_use]
905pub(crate) fn discover_entry_points(
906 config: &ResolvedConfig,
907 files: &[DiscoveredFile],
908 workspaces: &[WorkspaceInfo],
909) -> Vec<EntryPoint> {
910 crate::core_backend::discover_entry_points(config, files, workspaces)
911}
912
913#[must_use]
915pub(crate) fn discover_plugin_entry_points(
916 plugin_result: &crate::plugins::AggregatedPluginResult,
917 config: &ResolvedConfig,
918 files: &[DiscoveredFile],
919) -> Vec<EntryPoint> {
920 crate::core_backend::discover_plugin_entry_points(plugin_result.backend(), config, files)
921}
922
923#[cfg(test)]
924mod tests {
925 use std::path::PathBuf;
926
927 use fallow_config::PackageJson;
928
929 use super::{
930 ALLOWED_HIDDEN_DIRS, HiddenDirScope, collect_hidden_dir_scopes,
931 collect_plugin_hidden_dir_scopes, extract_hidden_dir_paths, is_allowed_hidden_dir,
932 };
933
934 #[test]
935 fn hidden_dir_scope_exposes_root_and_dirs() {
936 let scope = HiddenDirScope::new(PathBuf::from("/repo/packages/app"), vec![".next".into()]);
937
938 assert_eq!(scope.root(), PathBuf::from("/repo/packages/app"));
939 assert_eq!(scope.dirs(), [".next"]);
940 }
941
942 #[test]
943 fn hidden_dir_allowlist_is_engine_owned() {
944 for dir in ALLOWED_HIDDEN_DIRS {
945 assert!(is_allowed_hidden_dir(std::ffi::OsStr::new(dir)));
946 }
947 assert!(!is_allowed_hidden_dir(std::ffi::OsStr::new(".git")));
948 }
949
950 #[test]
951 fn plugin_hidden_dir_scopes_are_engine_owned() {
952 let dir = tempfile::tempdir().expect("tempdir");
953 let config = fallow_config::FallowConfig::default().resolve(
954 dir.path().to_path_buf(),
955 fallow_config::OutputFormat::Human,
956 1,
957 true,
958 true,
959 None,
960 );
961 let pkg: PackageJson = serde_json::from_value(serde_json::json!({
962 "devDependencies": {
963 "@react-router/dev": "^7.0.0"
964 }
965 }))
966 .expect("valid package fixture");
967
968 let scopes = collect_plugin_hidden_dir_scopes(&config, Some(&pkg), &[]);
969
970 assert_eq!(scopes.len(), 1);
971 assert_eq!(scopes[0].root(), dir.path());
972 assert_eq!(scopes[0].dirs(), [".client", ".server"]);
973 }
974
975 #[test]
976 fn script_hidden_dir_scopes_are_engine_owned() {
977 let dir = tempfile::tempdir().expect("tempdir");
978 let config = fallow_config::FallowConfig::default().resolve(
979 dir.path().to_path_buf(),
980 fallow_config::OutputFormat::Human,
981 1,
982 true,
983 true,
984 None,
985 );
986 let pkg: PackageJson = serde_json::from_value(serde_json::json!({
987 "scripts": {
988 "lint": "eslint -c .config/eslint.config.js",
989 "build": "tsx ./.scripts/build.ts",
990 "cache": "tsx .nx/cache/build.ts",
991 "pnpm": "node node_modules/.pnpm/tool/bin.js"
992 }
993 }))
994 .expect("valid package fixture");
995
996 let scopes = collect_hidden_dir_scopes(&config, Some(&pkg), &[]);
997
998 assert_eq!(scopes.len(), 1);
999 assert_eq!(scopes[0].root(), dir.path());
1000 let mut dirs = scopes[0].dirs().to_vec();
1001 dirs.sort();
1002 assert_eq!(dirs, [".config", ".scripts"]);
1003 }
1004
1005 #[test]
1006 fn hidden_dir_path_extraction_rejects_escape_paths() {
1007 assert_eq!(
1008 extract_hidden_dir_paths(".foo/.bar/x.js"),
1009 vec![
1010 ".foo".to_string(),
1011 format!(".foo{}.bar", std::path::MAIN_SEPARATOR)
1012 ]
1013 );
1014 assert!(extract_hidden_dir_paths("../../.config/eslint.config.js").is_empty());
1015 assert!(extract_hidden_dir_paths(".env").is_empty());
1016 }
1017}