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