1mod package_json;
2mod parsers;
3
4use std::path::{Path, PathBuf};
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9pub use package_json::PackageJson;
10pub use parsers::parse_tsconfig_root_dir;
11use parsers::{expand_workspace_glob, parse_pnpm_workspace_yaml, parse_tsconfig_references};
12
13#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
15pub struct WorkspaceConfig {
16 #[serde(default)]
18 pub patterns: Vec<String>,
19}
20
21#[derive(Debug, Clone)]
23pub struct WorkspaceInfo {
24 pub root: PathBuf,
26 pub name: String,
28 pub is_internal_dependency: bool,
30}
31
32#[derive(Debug, Clone)]
34pub struct WorkspaceDiagnostic {
35 pub path: PathBuf,
37 pub message: String,
39}
40
41#[must_use]
48pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
49 let patterns = collect_workspace_patterns(root);
50 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
51
52 let mut workspaces = expand_patterns_to_workspaces(root, &patterns, &canonical_root);
53 workspaces.extend(collect_tsconfig_workspaces(root, &canonical_root));
54
55 if workspaces.is_empty() {
56 return Vec::new();
57 }
58
59 mark_internal_dependencies(&mut workspaces);
60 workspaces.into_iter().map(|(ws, _)| ws).collect()
61}
62
63#[must_use]
69pub fn find_undeclared_workspaces(
70 root: &Path,
71 declared: &[WorkspaceInfo],
72) -> Vec<WorkspaceDiagnostic> {
73 let patterns = collect_workspace_patterns(root);
75 if patterns.is_empty() {
76 return Vec::new();
77 }
78
79 let declared_roots: rustc_hash::FxHashSet<PathBuf> = declared
80 .iter()
81 .map(|w| dunce::canonicalize(&w.root).unwrap_or_else(|_| w.root.clone()))
82 .collect();
83
84 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
85
86 let mut undeclared = Vec::new();
87
88 let Ok(top_entries) = std::fs::read_dir(root) else {
90 return Vec::new();
91 };
92
93 for entry in top_entries.filter_map(Result::ok) {
94 let path = entry.path();
95 if !path.is_dir() {
96 continue;
97 }
98
99 let name = entry.file_name();
100 let name_str = name.to_string_lossy();
101 if name_str.starts_with('.') || name_str == "node_modules" || name_str == "build" {
102 continue;
103 }
104
105 check_undeclared(
107 &path,
108 root,
109 &canonical_root,
110 &declared_roots,
111 &mut undeclared,
112 );
113
114 let Ok(child_entries) = std::fs::read_dir(&path) else {
116 continue;
117 };
118 for child in child_entries.filter_map(Result::ok) {
119 let child_path = child.path();
120 if !child_path.is_dir() {
121 continue;
122 }
123 let child_name = child.file_name();
124 let child_name_str = child_name.to_string_lossy();
125 if child_name_str.starts_with('.')
126 || child_name_str == "node_modules"
127 || child_name_str == "build"
128 {
129 continue;
130 }
131 check_undeclared(
132 &child_path,
133 root,
134 &canonical_root,
135 &declared_roots,
136 &mut undeclared,
137 );
138 }
139 }
140
141 undeclared
142}
143
144fn check_undeclared(
146 dir: &Path,
147 root: &Path,
148 canonical_root: &Path,
149 declared_roots: &rustc_hash::FxHashSet<PathBuf>,
150 undeclared: &mut Vec<WorkspaceDiagnostic>,
151) {
152 if !dir.join("package.json").exists() {
153 return;
154 }
155 let canonical = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
156 if canonical == *canonical_root {
158 return;
159 }
160 if declared_roots.contains(&canonical) {
161 return;
162 }
163 let relative = dir.strip_prefix(root).unwrap_or(dir);
164 undeclared.push(WorkspaceDiagnostic {
165 path: dir.to_path_buf(),
166 message: format!(
167 "Directory '{}' contains package.json but is not declared as a workspace",
168 relative.display()
169 ),
170 });
171}
172
173fn collect_workspace_patterns(root: &Path) -> Vec<String> {
175 let mut patterns = Vec::new();
176
177 let pkg_path = root.join("package.json");
179 if let Ok(pkg) = PackageJson::load(&pkg_path) {
180 patterns.extend(pkg.workspace_patterns());
181 }
182
183 let pnpm_workspace = root.join("pnpm-workspace.yaml");
185 if pnpm_workspace.exists()
186 && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
187 {
188 patterns.extend(parse_pnpm_workspace_yaml(&content));
189 }
190
191 patterns
192}
193
194fn expand_patterns_to_workspaces(
199 root: &Path,
200 patterns: &[String],
201 canonical_root: &Path,
202) -> Vec<(WorkspaceInfo, Vec<String>)> {
203 if patterns.is_empty() {
204 return Vec::new();
205 }
206
207 let mut workspaces = Vec::new();
208
209 let (positive, negative): (Vec<&String>, Vec<&String>) =
213 patterns.iter().partition(|p| !p.starts_with('!'));
214 let negation_matchers: Vec<globset::GlobMatcher> = negative
215 .iter()
216 .filter_map(|p| {
217 let stripped = p.strip_prefix('!').unwrap_or(p);
218 globset::Glob::new(stripped)
219 .ok()
220 .map(|g| g.compile_matcher())
221 })
222 .collect();
223
224 for pattern in &positive {
225 let glob_pattern = if pattern.ends_with('/') {
230 format!("{pattern}*")
231 } else if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('{') {
232 (*pattern).clone()
234 } else {
235 (*pattern).clone()
236 };
237
238 let matched_dirs = expand_workspace_glob(root, &glob_pattern, canonical_root);
242 for (dir, canonical_dir) in matched_dirs {
243 if canonical_dir == *canonical_root {
246 continue;
247 }
248
249 let relative = dir.strip_prefix(root).unwrap_or(&dir);
251 let relative_str = relative.to_string_lossy();
252 if negation_matchers
253 .iter()
254 .any(|m| m.is_match(relative_str.as_ref()))
255 {
256 continue;
257 }
258
259 let ws_pkg_path = dir.join("package.json");
261 if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
262 let dep_names = pkg.all_dependency_names();
265 let name = pkg.name.unwrap_or_else(|| {
266 dir.file_name()
267 .map(|n| n.to_string_lossy().to_string())
268 .unwrap_or_default()
269 });
270 workspaces.push((
271 WorkspaceInfo {
272 root: dir,
273 name,
274 is_internal_dependency: false,
275 },
276 dep_names,
277 ));
278 }
279 }
280 }
281
282 workspaces
283}
284
285fn collect_tsconfig_workspaces(
290 root: &Path,
291 canonical_root: &Path,
292) -> Vec<(WorkspaceInfo, Vec<String>)> {
293 let mut workspaces = Vec::new();
294
295 for dir in parse_tsconfig_references(root) {
296 let canonical_dir = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
297 if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
299 continue;
300 }
301
302 let ws_pkg_path = dir.join("package.json");
304 let (name, dep_names) = if ws_pkg_path.exists() {
305 if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
306 let deps = pkg.all_dependency_names();
307 let n = pkg.name.unwrap_or_else(|| dir_name(&dir));
308 (n, deps)
309 } else {
310 (dir_name(&dir), Vec::new())
311 }
312 } else {
313 (dir_name(&dir), Vec::new())
316 };
317
318 workspaces.push((
319 WorkspaceInfo {
320 root: dir,
321 name,
322 is_internal_dependency: false,
323 },
324 dep_names,
325 ));
326 }
327
328 workspaces
329}
330
331fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
337 {
339 let mut seen = rustc_hash::FxHashSet::default();
340 workspaces.retain(|(ws, _)| {
341 let canonical = dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone());
342 seen.insert(canonical)
343 });
344 }
345
346 let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
350 .iter()
351 .flat_map(|(_, deps)| deps.iter().cloned())
352 .collect();
353 for (ws, _) in &mut *workspaces {
354 ws.is_internal_dependency = all_dep_names.contains(&ws.name);
355 }
356}
357
358fn dir_name(dir: &Path) -> String {
360 dir.file_name()
361 .map(|n| n.to_string_lossy().to_string())
362 .unwrap_or_default()
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 #[test]
370 fn discover_workspaces_from_tsconfig_references() {
371 let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
372 let _ = std::fs::remove_dir_all(&temp_dir);
373 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
374 std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
375
376 std::fs::write(
378 temp_dir.join("tsconfig.json"),
379 r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
380 )
381 .unwrap();
382
383 std::fs::write(
385 temp_dir.join("packages/core/package.json"),
386 r#"{"name": "@project/core"}"#,
387 )
388 .unwrap();
389
390 let workspaces = discover_workspaces(&temp_dir);
392 assert_eq!(workspaces.len(), 2);
393 assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
394 assert!(workspaces.iter().any(|ws| ws.name == "ui"));
395
396 let _ = std::fs::remove_dir_all(&temp_dir);
397 }
398
399 #[test]
400 fn tsconfig_references_outside_root_rejected() {
401 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
402 let _ = std::fs::remove_dir_all(&temp_dir);
403 std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
404 std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
406
407 std::fs::write(
408 temp_dir.join("project/tsconfig.json"),
409 r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
410 )
411 .unwrap();
412
413 let workspaces = discover_workspaces(&temp_dir.join("project"));
415 assert_eq!(
416 workspaces.len(),
417 1,
418 "reference outside project root should be rejected: {workspaces:?}"
419 );
420 assert!(
421 workspaces[0]
422 .root
423 .to_string_lossy()
424 .contains("packages/core")
425 );
426
427 let _ = std::fs::remove_dir_all(&temp_dir);
428 }
429
430 #[test]
433 fn dir_name_extracts_last_component() {
434 assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
435 assert_eq!(dir_name(Path::new("/my-app")), "my-app");
436 }
437
438 #[test]
439 fn dir_name_empty_for_root_path() {
440 assert_eq!(dir_name(Path::new("/")), "");
442 }
443
444 #[test]
447 fn workspace_config_deserialize_json() {
448 let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
449 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
450 assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
451 }
452
453 #[test]
454 fn workspace_config_deserialize_empty_patterns() {
455 let json = r#"{"patterns": []}"#;
456 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
457 assert!(config.patterns.is_empty());
458 }
459
460 #[test]
461 fn workspace_config_default_patterns() {
462 let json = "{}";
463 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
464 assert!(config.patterns.is_empty());
465 }
466
467 #[test]
470 fn workspace_info_default_not_internal() {
471 let ws = WorkspaceInfo {
472 root: PathBuf::from("/project/packages/a"),
473 name: "a".to_string(),
474 is_internal_dependency: false,
475 };
476 assert!(!ws.is_internal_dependency);
477 }
478
479 #[test]
482 fn mark_internal_deps_detects_cross_references() {
483 let temp_dir = tempfile::tempdir().expect("create temp dir");
484 let pkg_a = temp_dir.path().join("a");
485 let pkg_b = temp_dir.path().join("b");
486 std::fs::create_dir_all(&pkg_a).unwrap();
487 std::fs::create_dir_all(&pkg_b).unwrap();
488
489 let mut workspaces = vec![
490 (
491 WorkspaceInfo {
492 root: pkg_a,
493 name: "@scope/a".to_string(),
494 is_internal_dependency: false,
495 },
496 vec!["@scope/b".to_string()], ),
498 (
499 WorkspaceInfo {
500 root: pkg_b,
501 name: "@scope/b".to_string(),
502 is_internal_dependency: false,
503 },
504 vec!["lodash".to_string()], ),
506 ];
507
508 mark_internal_dependencies(&mut workspaces);
509
510 let ws_a = workspaces
512 .iter()
513 .find(|(ws, _)| ws.name == "@scope/a")
514 .unwrap();
515 assert!(
516 !ws_a.0.is_internal_dependency,
517 "a is not depended on by others"
518 );
519
520 let ws_b = workspaces
521 .iter()
522 .find(|(ws, _)| ws.name == "@scope/b")
523 .unwrap();
524 assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
525 }
526
527 #[test]
528 fn mark_internal_deps_no_cross_references() {
529 let temp_dir = tempfile::tempdir().expect("create temp dir");
530 let pkg_a = temp_dir.path().join("a");
531 let pkg_b = temp_dir.path().join("b");
532 std::fs::create_dir_all(&pkg_a).unwrap();
533 std::fs::create_dir_all(&pkg_b).unwrap();
534
535 let mut workspaces = vec![
536 (
537 WorkspaceInfo {
538 root: pkg_a,
539 name: "a".to_string(),
540 is_internal_dependency: false,
541 },
542 vec!["react".to_string()],
543 ),
544 (
545 WorkspaceInfo {
546 root: pkg_b,
547 name: "b".to_string(),
548 is_internal_dependency: false,
549 },
550 vec!["lodash".to_string()],
551 ),
552 ];
553
554 mark_internal_dependencies(&mut workspaces);
555
556 assert!(!workspaces[0].0.is_internal_dependency);
557 assert!(!workspaces[1].0.is_internal_dependency);
558 }
559
560 #[test]
561 fn mark_internal_deps_deduplicates_by_path() {
562 let temp_dir = tempfile::tempdir().expect("create temp dir");
563 let pkg_a = temp_dir.path().join("a");
564 std::fs::create_dir_all(&pkg_a).unwrap();
565
566 let mut workspaces = vec![
567 (
568 WorkspaceInfo {
569 root: pkg_a.clone(),
570 name: "a".to_string(),
571 is_internal_dependency: false,
572 },
573 vec![],
574 ),
575 (
576 WorkspaceInfo {
577 root: pkg_a,
578 name: "a".to_string(),
579 is_internal_dependency: false,
580 },
581 vec![],
582 ),
583 ];
584
585 mark_internal_dependencies(&mut workspaces);
586 assert_eq!(
587 workspaces.len(),
588 1,
589 "duplicate paths should be deduplicated"
590 );
591 }
592
593 #[test]
596 fn collect_patterns_from_package_json() {
597 let dir = tempfile::tempdir().expect("create temp dir");
598 std::fs::write(
599 dir.path().join("package.json"),
600 r#"{"workspaces": ["packages/*", "apps/*"]}"#,
601 )
602 .unwrap();
603
604 let patterns = collect_workspace_patterns(dir.path());
605 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
606 }
607
608 #[test]
609 fn collect_patterns_from_pnpm_workspace() {
610 let dir = tempfile::tempdir().expect("create temp dir");
611 std::fs::write(
612 dir.path().join("pnpm-workspace.yaml"),
613 "packages:\n - 'packages/*'\n - 'libs/*'\n",
614 )
615 .unwrap();
616
617 let patterns = collect_workspace_patterns(dir.path());
618 assert_eq!(patterns, vec!["packages/*", "libs/*"]);
619 }
620
621 #[test]
622 fn collect_patterns_combines_sources() {
623 let dir = tempfile::tempdir().expect("create temp dir");
624 std::fs::write(
625 dir.path().join("package.json"),
626 r#"{"workspaces": ["packages/*"]}"#,
627 )
628 .unwrap();
629 std::fs::write(
630 dir.path().join("pnpm-workspace.yaml"),
631 "packages:\n - 'apps/*'\n",
632 )
633 .unwrap();
634
635 let patterns = collect_workspace_patterns(dir.path());
636 assert!(patterns.contains(&"packages/*".to_string()));
637 assert!(patterns.contains(&"apps/*".to_string()));
638 }
639
640 #[test]
641 fn collect_patterns_empty_when_no_configs() {
642 let dir = tempfile::tempdir().expect("create temp dir");
643 let patterns = collect_workspace_patterns(dir.path());
644 assert!(patterns.is_empty());
645 }
646
647 #[test]
650 fn discover_workspaces_from_package_json() {
651 let dir = tempfile::tempdir().expect("create temp dir");
652 let pkg_a = dir.path().join("packages").join("a");
653 let pkg_b = dir.path().join("packages").join("b");
654 std::fs::create_dir_all(&pkg_a).unwrap();
655 std::fs::create_dir_all(&pkg_b).unwrap();
656
657 std::fs::write(
658 dir.path().join("package.json"),
659 r#"{"workspaces": ["packages/*"]}"#,
660 )
661 .unwrap();
662 std::fs::write(
663 pkg_a.join("package.json"),
664 r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
665 )
666 .unwrap();
667 std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
668
669 let workspaces = discover_workspaces(dir.path());
670 assert_eq!(workspaces.len(), 2);
671
672 let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
673 assert!(!ws_a.is_internal_dependency);
674
675 let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
676 assert!(ws_b.is_internal_dependency, "b is depended on by a");
677 }
678
679 #[test]
680 fn discover_workspaces_empty_project() {
681 let dir = tempfile::tempdir().expect("create temp dir");
682 let workspaces = discover_workspaces(dir.path());
683 assert!(workspaces.is_empty());
684 }
685
686 #[test]
687 fn discover_workspaces_with_negated_patterns() {
688 let dir = tempfile::tempdir().expect("create temp dir");
689 let pkg_a = dir.path().join("packages").join("a");
690 let pkg_test = dir.path().join("packages").join("test-utils");
691 std::fs::create_dir_all(&pkg_a).unwrap();
692 std::fs::create_dir_all(&pkg_test).unwrap();
693
694 std::fs::write(
695 dir.path().join("package.json"),
696 r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
697 )
698 .unwrap();
699 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
700 std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
701
702 let workspaces = discover_workspaces(dir.path());
703 assert_eq!(workspaces.len(), 1);
704 assert_eq!(workspaces[0].name, "a");
705 }
706
707 #[test]
708 fn discover_workspaces_skips_root_as_workspace() {
709 let dir = tempfile::tempdir().expect("create temp dir");
710 std::fs::write(
712 dir.path().join("pnpm-workspace.yaml"),
713 "packages:\n - '.'\n",
714 )
715 .unwrap();
716 std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
717
718 let workspaces = discover_workspaces(dir.path());
719 assert!(
720 workspaces.is_empty(),
721 "root directory should not be added as workspace"
722 );
723 }
724
725 #[test]
726 fn discover_workspaces_name_fallback_to_dir_name() {
727 let dir = tempfile::tempdir().expect("create temp dir");
728 let pkg_a = dir.path().join("packages").join("my-app");
729 std::fs::create_dir_all(&pkg_a).unwrap();
730
731 std::fs::write(
732 dir.path().join("package.json"),
733 r#"{"workspaces": ["packages/*"]}"#,
734 )
735 .unwrap();
736 std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
738
739 let workspaces = discover_workspaces(dir.path());
740 assert_eq!(workspaces.len(), 1);
741 assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
742 }
743
744 #[test]
747 fn undeclared_workspace_detected() {
748 let dir = tempfile::tempdir().expect("create temp dir");
749 let pkg_a = dir.path().join("packages").join("a");
750 let pkg_b = dir.path().join("packages").join("b");
751 std::fs::create_dir_all(&pkg_a).unwrap();
752 std::fs::create_dir_all(&pkg_b).unwrap();
753
754 std::fs::write(
756 dir.path().join("package.json"),
757 r#"{"workspaces": ["packages/a"]}"#,
758 )
759 .unwrap();
760 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
761 std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
762
763 let declared = discover_workspaces(dir.path());
764 assert_eq!(declared.len(), 1);
765
766 let undeclared = find_undeclared_workspaces(dir.path(), &declared);
767 assert_eq!(undeclared.len(), 1);
768 assert!(
769 undeclared[0]
770 .path
771 .to_string_lossy()
772 .replace('\\', "/")
773 .contains("packages/b"),
774 "should detect packages/b as undeclared: {:?}",
775 undeclared[0].path
776 );
777 }
778
779 #[test]
780 fn no_undeclared_when_all_covered() {
781 let dir = tempfile::tempdir().expect("create temp dir");
782 let pkg_a = dir.path().join("packages").join("a");
783 std::fs::create_dir_all(&pkg_a).unwrap();
784
785 std::fs::write(
786 dir.path().join("package.json"),
787 r#"{"workspaces": ["packages/*"]}"#,
788 )
789 .unwrap();
790 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
791
792 let declared = discover_workspaces(dir.path());
793 let undeclared = find_undeclared_workspaces(dir.path(), &declared);
794 assert!(undeclared.is_empty());
795 }
796
797 #[test]
798 fn no_undeclared_when_no_workspace_patterns() {
799 let dir = tempfile::tempdir().expect("create temp dir");
800 let sub = dir.path().join("lib");
801 std::fs::create_dir_all(&sub).unwrap();
802
803 std::fs::write(dir.path().join("package.json"), r#"{"name": "app"}"#).unwrap();
805 std::fs::write(sub.join("package.json"), r#"{"name": "lib"}"#).unwrap();
806
807 let undeclared = find_undeclared_workspaces(dir.path(), &[]);
808 assert!(
809 undeclared.is_empty(),
810 "should skip check when no workspace patterns exist"
811 );
812 }
813
814 #[test]
815 fn undeclared_skips_node_modules_and_hidden_dirs() {
816 let dir = tempfile::tempdir().expect("create temp dir");
817 let nm = dir.path().join("node_modules").join("some-pkg");
818 let hidden = dir.path().join(".hidden");
819 std::fs::create_dir_all(&nm).unwrap();
820 std::fs::create_dir_all(&hidden).unwrap();
821
822 std::fs::write(
823 dir.path().join("package.json"),
824 r#"{"workspaces": ["packages/*"]}"#,
825 )
826 .unwrap();
827 std::fs::write(nm.join("package.json"), r#"{"name": "nm-pkg"}"#).unwrap();
829 std::fs::write(hidden.join("package.json"), r#"{"name": "hidden"}"#).unwrap();
830
831 let undeclared = find_undeclared_workspaces(dir.path(), &[]);
832 assert!(
833 undeclared.is_empty(),
834 "should not flag node_modules or hidden directories"
835 );
836 }
837}