Skip to main content

fallow_config/workspace/
mod.rs

1mod diagnostics;
2mod package_json;
3mod parsers;
4mod pnpm_catalog;
5mod pnpm_overrides;
6
7use std::path::{Path, PathBuf};
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12#[cfg(test)]
13pub use diagnostics::capture_workspace_warnings;
14pub use diagnostics::{
15    WorkspaceDiagnostic, WorkspaceDiagnosticKind, WorkspaceLoadError, append_workspace_diagnostics,
16    stash_workspace_diagnostics, workspace_diagnostics_for,
17};
18use diagnostics::{emit_diagnostics, is_skip_listed_dir};
19pub use package_json::PackageJson;
20pub use parsers::parse_tsconfig_root_dir;
21use parsers::{
22    expand_workspace_glob_with_diagnostics, parse_pnpm_workspace_yaml,
23    parse_tsconfig_references_with_diagnostics,
24};
25pub use pnpm_catalog::{
26    PnpmCatalog, PnpmCatalogData, PnpmCatalogEntry, PnpmCatalogGroup, parse_pnpm_catalog_data,
27};
28pub use pnpm_overrides::{
29    MisconfigReason, OverrideSource, ParsedOverrideKey, PnpmOverrideData, PnpmOverrideEntry,
30    is_valid_override_value, override_misconfig_reason, override_source_label, parse_override_key,
31    parse_pnpm_package_json_overrides, parse_pnpm_workspace_overrides,
32};
33
34/// Workspace configuration for monorepo support.
35#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
36pub struct WorkspaceConfig {
37    /// Additional workspace patterns (beyond what's in root package.json).
38    #[serde(default)]
39    pub patterns: Vec<String>,
40}
41
42/// Discovered workspace info from package.json, pnpm-workspace.yaml, or tsconfig.json references.
43#[derive(Debug, Clone)]
44pub struct WorkspaceInfo {
45    /// Workspace root path.
46    pub root: PathBuf,
47    /// Package name from package.json.
48    pub name: String,
49    /// Whether this workspace is depended on by other workspaces.
50    pub is_internal_dependency: bool,
51}
52
53/// Discover all workspace packages in a monorepo.
54///
55/// Sources (additive, deduplicated by canonical path):
56/// 1. `package.json` `workspaces` field
57/// 2. `pnpm-workspace.yaml` `packages` field
58/// 3. `tsconfig.json` `references` field (TypeScript project references)
59///
60/// Back-compat wrapper: drops any diagnostics and silently treats a malformed
61/// root `package.json` as "no workspaces". New callers should use
62/// [`discover_workspaces_with_diagnostics`] to receive typed
63/// [`WorkspaceDiagnostic`] values and to surface root-malformed errors as
64/// hard exits.
65///
66/// This wrapper goes through the silent collector path that does NOT call
67/// `emit_diagnostics` (private helper in `crates/config/src/workspace/diagnostics.rs`
68/// that does the `tracing::warn!` emission). Without that split, sibling
69/// callers in `core/src/lib.rs` (analyze) and `core/src/discover/mod.rs`
70/// (file discovery) would re-emit `tracing::warn!` on paths the user already
71/// excluded via `ignorePatterns`, because the back-compat wrapper has no
72/// access to the user's globset.
73#[must_use]
74pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
75    collect_workspaces_and_diagnostics(root, &globset::GlobSet::empty())
76        .map(|(workspaces, _)| workspaces)
77        .unwrap_or_default()
78}
79
80/// Discover workspace packages and return any diagnostics produced along the
81/// way.
82///
83/// Replaces the four silent-drop sites in [`discover_workspaces`] with typed
84/// [`WorkspaceDiagnostic`] values:
85/// - malformed declared-workspace `package.json` (warn-and-continue),
86/// - glob match resolving to a directory without `package.json` (warn,
87///   filtered through `ignore_patterns` and an extended skip list),
88/// - malformed `tsconfig.json` (warn-and-continue),
89/// - tsconfig `references[].path` pointing to a missing directory (warn).
90///
91/// `ignore_patterns` mirrors the precedent in
92/// [`find_undeclared_workspaces_with_ignores`]: directories the user already
93/// excluded do not trigger a redundant diagnostic.
94///
95/// Returns [`WorkspaceLoadError::MalformedRootPackageJson`] when the root
96/// `package.json` exists but fails to parse: without a parseable root, no
97/// workspace patterns can be collected and the analysis output would be
98/// fiction. The CLI surfaces this as exit 2.
99///
100/// Shallow-scan fallback candidates (`collect_shallow_workspace_candidate`)
101/// stay silent: the user did not declare them, so a stray malformed
102/// `package.json` two levels deep in a `tools/scratch/` directory should not
103/// produce noise.
104///
105/// # Errors
106///
107/// Returns [`WorkspaceLoadError`] when the project root's `package.json`
108/// exists but is not valid JSON. Callers map this to a hard exit.
109pub fn discover_workspaces_with_diagnostics(
110    root: &Path,
111    ignore_patterns: &globset::GlobSet,
112) -> Result<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>), WorkspaceLoadError> {
113    let (workspaces, diagnostics) = collect_workspaces_and_diagnostics(root, ignore_patterns)?;
114
115    emit_diagnostics(root, &diagnostics);
116
117    Ok((workspaces, diagnostics))
118}
119
120/// Collect workspaces and diagnostics without emitting `tracing::warn!`.
121///
122/// Both [`discover_workspaces_with_diagnostics`] (which adds the emit step)
123/// and [`discover_workspaces`] (which drops both diagnostics and emission)
124/// route through this function. Keeping emission in the public top-level
125/// only means downstream callers that have no access to the user's
126/// `ignorePatterns` cannot accidentally re-emit warnings on paths the user
127/// already excluded.
128fn collect_workspaces_and_diagnostics(
129    root: &Path,
130    ignore_patterns: &globset::GlobSet,
131) -> Result<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>), WorkspaceLoadError> {
132    let mut diagnostics = Vec::new();
133    let patterns = collect_workspace_patterns(root)?;
134    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
135
136    let mut workspaces = expand_patterns_to_workspaces(
137        root,
138        &patterns,
139        &canonical_root,
140        ignore_patterns,
141        &mut diagnostics,
142    );
143    workspaces.extend(collect_tsconfig_workspaces(
144        root,
145        &canonical_root,
146        ignore_patterns,
147        &mut diagnostics,
148    ));
149    if patterns.is_empty() {
150        workspaces.extend(collect_shallow_package_workspaces(root, &canonical_root));
151    }
152
153    if !workspaces.is_empty() {
154        mark_internal_dependencies(&mut workspaces);
155    }
156    let workspaces = workspaces.into_iter().map(|(ws, _)| ws).collect();
157    Ok((workspaces, diagnostics))
158}
159
160/// Find directories containing `package.json` that are not declared as workspaces.
161///
162/// Only meaningful in monorepos that declare workspaces (via `package.json` `workspaces`
163/// field or `pnpm-workspace.yaml`). Scans up to two directory levels deep, skipping
164/// hidden directories, `node_modules`, and `build`.
165#[must_use]
166pub fn find_undeclared_workspaces(
167    root: &Path,
168    declared: &[WorkspaceInfo],
169) -> Vec<WorkspaceDiagnostic> {
170    find_undeclared_workspaces_with_ignores(root, declared, &globset::GlobSet::empty())
171}
172
173/// Find directories containing `package.json` that are not declared as workspaces,
174/// excluding candidates covered by the supplied ignore globset.
175///
176/// This is the ignore-aware variant used by the full analyzer after config
177/// resolution. See [`find_undeclared_workspaces`] for the compatibility wrapper.
178///
179/// Directories whose project-root-relative path matches `ignore_patterns` are skipped
180/// so users who already excluded a path via `ignorePatterns` don't see a redundant
181/// "not declared as workspace" warning. See issue #193.
182#[must_use]
183pub fn find_undeclared_workspaces_with_ignores(
184    root: &Path,
185    declared: &[WorkspaceInfo],
186    ignore_patterns: &globset::GlobSet,
187) -> Vec<WorkspaceDiagnostic> {
188    let patterns = collect_workspace_patterns(root).unwrap_or_default();
189    if patterns.is_empty() {
190        return Vec::new();
191    }
192
193    let declared_roots: rustc_hash::FxHashSet<PathBuf> = declared
194        .iter()
195        .map(|w| dunce::canonicalize(&w.root).unwrap_or_else(|_| w.root.clone()))
196        .collect();
197
198    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
199
200    let mut undeclared = Vec::new();
201
202    let Ok(top_entries) = std::fs::read_dir(root) else {
203        return Vec::new();
204    };
205
206    for entry in top_entries.filter_map(Result::ok) {
207        let path = entry.path();
208        if !path.is_dir() {
209            continue;
210        }
211
212        let name = entry.file_name();
213        let name_str = name.to_string_lossy();
214        if name_str.starts_with('.') || name_str == "node_modules" || name_str == "build" {
215            continue;
216        }
217
218        check_undeclared(
219            &path,
220            root,
221            &canonical_root,
222            &declared_roots,
223            ignore_patterns,
224            &mut undeclared,
225        );
226
227        let Ok(child_entries) = std::fs::read_dir(&path) else {
228            continue;
229        };
230        for child in child_entries.filter_map(Result::ok) {
231            let child_path = child.path();
232            if !child_path.is_dir() {
233                continue;
234            }
235            let child_name = child.file_name();
236            let child_name_str = child_name.to_string_lossy();
237            if child_name_str.starts_with('.')
238                || child_name_str == "node_modules"
239                || child_name_str == "build"
240            {
241                continue;
242            }
243            check_undeclared(
244                &child_path,
245                root,
246                &canonical_root,
247                &declared_roots,
248                ignore_patterns,
249                &mut undeclared,
250            );
251        }
252    }
253
254    undeclared
255}
256
257/// Check a single directory for an undeclared workspace.
258fn check_undeclared(
259    dir: &Path,
260    root: &Path,
261    canonical_root: &Path,
262    declared_roots: &rustc_hash::FxHashSet<PathBuf>,
263    ignore_patterns: &globset::GlobSet,
264    undeclared: &mut Vec<WorkspaceDiagnostic>,
265) {
266    if !dir.join("package.json").exists() {
267        return;
268    }
269    let canonical = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
270    if canonical == *canonical_root {
271        return;
272    }
273    if declared_roots.contains(&canonical) {
274        return;
275    }
276    let relative = dir.strip_prefix(root).unwrap_or(dir);
277    let relative_str = relative.to_string_lossy().replace('\\', "/");
278    if ignore_patterns.is_match(relative_str.as_str())
279        || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
280    {
281        return;
282    }
283    undeclared.push(WorkspaceDiagnostic::new(
284        root,
285        dir.to_path_buf(),
286        WorkspaceDiagnosticKind::UndeclaredWorkspace,
287    ));
288}
289
290/// Collect glob patterns from `package.json` `workspaces` field and `pnpm-workspace.yaml`.
291fn collect_workspace_patterns(root: &Path) -> Result<Vec<String>, WorkspaceLoadError> {
292    let mut patterns = Vec::new();
293
294    let pkg_path = root.join("package.json");
295    if pkg_path.exists() {
296        match PackageJson::load(&pkg_path) {
297            Ok(pkg) => patterns.extend(pkg.workspace_patterns()),
298            Err(error) => {
299                return Err(WorkspaceLoadError::MalformedRootPackageJson {
300                    path: pkg_path,
301                    error,
302                });
303            }
304        }
305    }
306
307    let pnpm_workspace = root.join("pnpm-workspace.yaml");
308    if pnpm_workspace.exists()
309        && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
310    {
311        patterns.extend(parse_pnpm_workspace_yaml(&content));
312    }
313
314    Ok(patterns)
315}
316
317/// Expand workspace glob patterns to discover workspace directories.
318///
319/// Handles positive/negated pattern splitting, glob matching, and package.json
320/// loading for each matched directory.
321fn expand_patterns_to_workspaces(
322    root: &Path,
323    patterns: &[String],
324    canonical_root: &Path,
325    ignore_patterns: &globset::GlobSet,
326    diagnostics: &mut Vec<WorkspaceDiagnostic>,
327) -> Vec<(WorkspaceInfo, Vec<String>)> {
328    if patterns.is_empty() {
329        return Vec::new();
330    }
331
332    let mut workspaces = Vec::new();
333
334    let (positive, negative): (Vec<&String>, Vec<&String>) =
335        patterns.iter().partition(|p| !p.starts_with('!'));
336    let negation_matchers: Vec<globset::GlobMatcher> = negative
337        .iter()
338        .filter_map(|p| {
339            let stripped = p.strip_prefix('!').unwrap_or(p);
340            globset::Glob::new(stripped)
341                .ok()
342                .map(|g| g.compile_matcher())
343        })
344        .collect();
345
346    for pattern in &positive {
347        let glob_pattern = if pattern.ends_with('/') {
348            format!("{pattern}*")
349        } else {
350            (*pattern).clone()
351        };
352
353        let matched_dirs = expand_workspace_glob_with_diagnostics(
354            root,
355            pattern,
356            &glob_pattern,
357            canonical_root,
358            ignore_patterns,
359            diagnostics,
360        );
361        for (dir, canonical_dir) in matched_dirs {
362            if canonical_dir == *canonical_root {
363                continue;
364            }
365
366            let relative = dir.strip_prefix(root).unwrap_or(&dir);
367            let relative_str = relative.to_string_lossy();
368            if negation_matchers
369                .iter()
370                .any(|m| m.is_match(relative_str.as_ref()))
371            {
372                continue;
373            }
374
375            let ws_pkg_path = dir.join("package.json");
376            match PackageJson::load(&ws_pkg_path) {
377                Ok(pkg) => {
378                    let dep_names = pkg.all_dependency_names();
379                    let name = pkg.name.unwrap_or_else(|| {
380                        dir.file_name()
381                            .map(|n| n.to_string_lossy().to_string())
382                            .unwrap_or_default()
383                    });
384                    workspaces.push((
385                        WorkspaceInfo {
386                            root: dir,
387                            name,
388                            is_internal_dependency: false,
389                        },
390                        dep_names,
391                    ));
392                }
393                Err(error) => {
394                    let diag = WorkspaceDiagnostic::new(
395                        root,
396                        dir.clone(),
397                        WorkspaceDiagnosticKind::MalformedPackageJson { error },
398                    );
399                    diagnostics.push(diag);
400                }
401            }
402        }
403    }
404
405    workspaces
406}
407
408/// Discover workspaces from TypeScript project references in `tsconfig.json`.
409///
410/// Referenced directories are added as workspaces, supplementing npm/pnpm workspaces.
411/// This enables cross-workspace resolution for TypeScript composite projects.
412fn collect_tsconfig_workspaces(
413    root: &Path,
414    canonical_root: &Path,
415    ignore_patterns: &globset::GlobSet,
416    diagnostics: &mut Vec<WorkspaceDiagnostic>,
417) -> Vec<(WorkspaceInfo, Vec<String>)> {
418    let mut workspaces = Vec::new();
419
420    for dir in parse_tsconfig_references_with_diagnostics(root, ignore_patterns, diagnostics) {
421        let canonical_dir = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
422        if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
423            continue;
424        }
425
426        let ws_pkg_path = dir.join("package.json");
427        let (name, dep_names) = if ws_pkg_path.exists() {
428            match PackageJson::load(&ws_pkg_path) {
429                Ok(pkg) => {
430                    let deps = pkg.all_dependency_names();
431                    let n = pkg.name.unwrap_or_else(|| dir_name(&dir));
432                    (n, deps)
433                }
434                Err(error) => {
435                    let diag = WorkspaceDiagnostic::new(
436                        root,
437                        dir.clone(),
438                        WorkspaceDiagnosticKind::MalformedPackageJson { error },
439                    );
440                    diagnostics.push(diag);
441                    (dir_name(&dir), Vec::new())
442                }
443            }
444        } else {
445            (dir_name(&dir), Vec::new())
446        };
447
448        workspaces.push((
449            WorkspaceInfo {
450                root: dir,
451                name,
452                is_internal_dependency: false,
453            },
454            dep_names,
455        ));
456    }
457
458    workspaces
459}
460
461/// Discover shallow package workspaces when no explicit workspace config exists.
462///
463/// Scans direct children of the project root and their immediate children for
464/// `package.json` files. This catches repos that contain multiple standalone
465/// packages (for example `benchmarks/` or `editors/vscode/`) without declaring
466/// npm/pnpm workspaces at the root.
467fn collect_shallow_package_workspaces(
468    root: &Path,
469    canonical_root: &Path,
470) -> Vec<(WorkspaceInfo, Vec<String>)> {
471    let mut workspaces = Vec::new();
472    let Ok(top_entries) = std::fs::read_dir(root) else {
473        return workspaces;
474    };
475
476    for entry in top_entries.filter_map(Result::ok) {
477        let path = entry.path();
478        if !path.is_dir() || should_skip_workspace_scan_dir(&entry.file_name().to_string_lossy()) {
479            continue;
480        }
481
482        collect_shallow_workspace_candidate(&path, canonical_root, &mut workspaces);
483
484        let Ok(child_entries) = std::fs::read_dir(&path) else {
485            continue;
486        };
487        for child in child_entries.filter_map(Result::ok) {
488            let child_path = child.path();
489            if !child_path.is_dir()
490                || should_skip_workspace_scan_dir(&child.file_name().to_string_lossy())
491            {
492                continue;
493            }
494
495            collect_shallow_workspace_candidate(&child_path, canonical_root, &mut workspaces);
496        }
497    }
498
499    workspaces
500}
501
502fn collect_shallow_workspace_candidate(
503    dir: &Path,
504    canonical_root: &Path,
505    workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
506) {
507    let pkg_path = dir.join("package.json");
508    if !pkg_path.exists() {
509        return;
510    }
511
512    let canonical_dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
513    if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
514        return;
515    }
516
517    let Ok(pkg) = PackageJson::load(&pkg_path) else {
518        return;
519    };
520    let dep_names = pkg.all_dependency_names();
521    let name = pkg.name.unwrap_or_else(|| dir_name(dir));
522
523    workspaces.push((
524        WorkspaceInfo {
525            root: dir.to_path_buf(),
526            name,
527            is_internal_dependency: false,
528        },
529        dep_names,
530    ));
531}
532
533fn should_skip_workspace_scan_dir(name: &str) -> bool {
534    is_skip_listed_dir(name)
535}
536
537/// Deduplicate workspaces by canonical path and mark internal dependencies.
538///
539/// Overlapping sources (npm workspaces + tsconfig references pointing to the same
540/// directory) are collapsed. npm-discovered entries take precedence (they appear first).
541/// Workspaces depended on by other workspaces are marked as `is_internal_dependency`.
542fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
543    {
544        let mut seen = rustc_hash::FxHashSet::default();
545        workspaces.retain(|(ws, _)| {
546            let canonical = dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone());
547            seen.insert(canonical)
548        });
549    }
550
551    let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
552        .iter()
553        .flat_map(|(_, deps)| deps.iter().cloned())
554        .collect();
555    for (ws, _) in &mut *workspaces {
556        ws.is_internal_dependency = all_dep_names.contains(&ws.name);
557    }
558}
559
560/// Extract the directory name as a string, for workspace name fallback.
561fn dir_name(dir: &Path) -> String {
562    dir.file_name()
563        .map(|n| n.to_string_lossy().to_string())
564        .unwrap_or_default()
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    #[test]
572    fn discover_workspaces_from_tsconfig_references() {
573        let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
574        let _ = std::fs::remove_dir_all(&temp_dir);
575        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
576        std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
577
578        std::fs::write(
579            temp_dir.join("tsconfig.json"),
580            r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
581        )
582        .unwrap();
583
584        std::fs::write(
585            temp_dir.join("packages/core/package.json"),
586            r#"{"name": "@project/core"}"#,
587        )
588        .unwrap();
589
590        let workspaces = discover_workspaces(&temp_dir);
591        assert_eq!(workspaces.len(), 2);
592        assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
593        assert!(workspaces.iter().any(|ws| ws.name == "ui"));
594
595        let _ = std::fs::remove_dir_all(&temp_dir);
596    }
597
598    #[test]
599    fn tsconfig_references_outside_root_rejected() {
600        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
601        let _ = std::fs::remove_dir_all(&temp_dir);
602        std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
603        std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
604
605        std::fs::write(
606            temp_dir.join("project/tsconfig.json"),
607            r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
608        )
609        .unwrap();
610
611        let workspaces = discover_workspaces(&temp_dir.join("project"));
612        assert_eq!(
613            workspaces.len(),
614            1,
615            "reference outside project root should be rejected: {workspaces:?}"
616        );
617        assert!(
618            workspaces[0]
619                .root
620                .to_string_lossy()
621                .contains("packages/core")
622        );
623
624        let _ = std::fs::remove_dir_all(&temp_dir);
625    }
626
627    #[test]
628    fn dir_name_extracts_last_component() {
629        assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
630        assert_eq!(dir_name(Path::new("/my-app")), "my-app");
631    }
632
633    #[test]
634    fn dir_name_empty_for_root_path() {
635        assert_eq!(dir_name(Path::new("/")), "");
636    }
637
638    #[test]
639    fn workspace_config_deserialize_json() {
640        let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
641        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
642        assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
643    }
644
645    #[test]
646    fn workspace_config_deserialize_empty_patterns() {
647        let json = r#"{"patterns": []}"#;
648        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
649        assert!(config.patterns.is_empty());
650    }
651
652    #[test]
653    fn workspace_config_default_patterns() {
654        let json = "{}";
655        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
656        assert!(config.patterns.is_empty());
657    }
658
659    #[test]
660    fn workspace_info_default_not_internal() {
661        let ws = WorkspaceInfo {
662            root: PathBuf::from("/project/packages/a"),
663            name: "a".to_string(),
664            is_internal_dependency: false,
665        };
666        assert!(!ws.is_internal_dependency);
667    }
668
669    #[test]
670    fn mark_internal_deps_detects_cross_references() {
671        let temp_dir = tempfile::tempdir().expect("create temp dir");
672        let pkg_a = temp_dir.path().join("a");
673        let pkg_b = temp_dir.path().join("b");
674        std::fs::create_dir_all(&pkg_a).unwrap();
675        std::fs::create_dir_all(&pkg_b).unwrap();
676
677        let mut workspaces = vec![
678            (
679                WorkspaceInfo {
680                    root: pkg_a,
681                    name: "@scope/a".to_string(),
682                    is_internal_dependency: false,
683                },
684                vec!["@scope/b".to_string()], // "a" depends on "b"
685            ),
686            (
687                WorkspaceInfo {
688                    root: pkg_b,
689                    name: "@scope/b".to_string(),
690                    is_internal_dependency: false,
691                },
692                vec!["lodash".to_string()], // "b" depends on external only
693            ),
694        ];
695
696        mark_internal_dependencies(&mut workspaces);
697
698        let ws_a = workspaces
699            .iter()
700            .find(|(ws, _)| ws.name == "@scope/a")
701            .unwrap();
702        assert!(
703            !ws_a.0.is_internal_dependency,
704            "a is not depended on by others"
705        );
706
707        let ws_b = workspaces
708            .iter()
709            .find(|(ws, _)| ws.name == "@scope/b")
710            .unwrap();
711        assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
712    }
713
714    #[test]
715    fn mark_internal_deps_no_cross_references() {
716        let temp_dir = tempfile::tempdir().expect("create temp dir");
717        let pkg_a = temp_dir.path().join("a");
718        let pkg_b = temp_dir.path().join("b");
719        std::fs::create_dir_all(&pkg_a).unwrap();
720        std::fs::create_dir_all(&pkg_b).unwrap();
721
722        let mut workspaces = vec![
723            (
724                WorkspaceInfo {
725                    root: pkg_a,
726                    name: "a".to_string(),
727                    is_internal_dependency: false,
728                },
729                vec!["react".to_string()],
730            ),
731            (
732                WorkspaceInfo {
733                    root: pkg_b,
734                    name: "b".to_string(),
735                    is_internal_dependency: false,
736                },
737                vec!["lodash".to_string()],
738            ),
739        ];
740
741        mark_internal_dependencies(&mut workspaces);
742
743        assert!(!workspaces[0].0.is_internal_dependency);
744        assert!(!workspaces[1].0.is_internal_dependency);
745    }
746
747    #[test]
748    fn mark_internal_deps_deduplicates_by_path() {
749        let temp_dir = tempfile::tempdir().expect("create temp dir");
750        let pkg_a = temp_dir.path().join("a");
751        std::fs::create_dir_all(&pkg_a).unwrap();
752
753        let mut workspaces = vec![
754            (
755                WorkspaceInfo {
756                    root: pkg_a.clone(),
757                    name: "a".to_string(),
758                    is_internal_dependency: false,
759                },
760                vec![],
761            ),
762            (
763                WorkspaceInfo {
764                    root: pkg_a,
765                    name: "a".to_string(),
766                    is_internal_dependency: false,
767                },
768                vec![],
769            ),
770        ];
771
772        mark_internal_dependencies(&mut workspaces);
773        assert_eq!(
774            workspaces.len(),
775            1,
776            "duplicate paths should be deduplicated"
777        );
778    }
779
780    #[test]
781    fn collect_patterns_from_package_json() {
782        let dir = tempfile::tempdir().expect("create temp dir");
783        std::fs::write(
784            dir.path().join("package.json"),
785            r#"{"workspaces": ["packages/*", "apps/*"]}"#,
786        )
787        .unwrap();
788
789        let patterns = collect_workspace_patterns(dir.path()).expect("valid root package.json");
790        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
791    }
792
793    #[test]
794    fn collect_patterns_from_pnpm_workspace() {
795        let dir = tempfile::tempdir().expect("create temp dir");
796        std::fs::write(
797            dir.path().join("pnpm-workspace.yaml"),
798            "packages:\n  - 'packages/*'\n  - 'libs/*'\n",
799        )
800        .unwrap();
801
802        let patterns = collect_workspace_patterns(dir.path()).expect("no root package.json");
803        assert_eq!(patterns, vec!["packages/*", "libs/*"]);
804    }
805
806    #[test]
807    fn collect_patterns_combines_sources() {
808        let dir = tempfile::tempdir().expect("create temp dir");
809        std::fs::write(
810            dir.path().join("package.json"),
811            r#"{"workspaces": ["packages/*"]}"#,
812        )
813        .unwrap();
814        std::fs::write(
815            dir.path().join("pnpm-workspace.yaml"),
816            "packages:\n  - 'apps/*'\n",
817        )
818        .unwrap();
819
820        let patterns = collect_workspace_patterns(dir.path()).expect("valid root package.json");
821        assert!(patterns.contains(&"packages/*".to_string()));
822        assert!(patterns.contains(&"apps/*".to_string()));
823    }
824
825    #[test]
826    fn collect_patterns_empty_when_no_configs() {
827        let dir = tempfile::tempdir().expect("create temp dir");
828        let patterns = collect_workspace_patterns(dir.path()).expect("no root package.json");
829        assert!(patterns.is_empty());
830    }
831
832    #[test]
833    fn discover_workspaces_from_package_json() {
834        let dir = tempfile::tempdir().expect("create temp dir");
835        let pkg_a = dir.path().join("packages").join("a");
836        let pkg_b = dir.path().join("packages").join("b");
837        std::fs::create_dir_all(&pkg_a).unwrap();
838        std::fs::create_dir_all(&pkg_b).unwrap();
839
840        std::fs::write(
841            dir.path().join("package.json"),
842            r#"{"workspaces": ["packages/*"]}"#,
843        )
844        .unwrap();
845        std::fs::write(
846            pkg_a.join("package.json"),
847            r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
848        )
849        .unwrap();
850        std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
851
852        let workspaces = discover_workspaces(dir.path());
853        assert_eq!(workspaces.len(), 2);
854
855        let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
856        assert!(!ws_a.is_internal_dependency);
857
858        let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
859        assert!(ws_b.is_internal_dependency, "b is depended on by a");
860    }
861
862    #[test]
863    fn discover_workspaces_empty_project() {
864        let dir = tempfile::tempdir().expect("create temp dir");
865        let workspaces = discover_workspaces(dir.path());
866        assert!(workspaces.is_empty());
867    }
868
869    #[test]
870    fn discover_workspaces_falls_back_to_shallow_packages_without_workspace_config() {
871        let dir = tempfile::tempdir().expect("create temp dir");
872        let benchmarks = dir.path().join("benchmarks");
873        let vscode = dir.path().join("editors").join("vscode");
874        let deep = dir.path().join("tests").join("fixtures").join("demo");
875        std::fs::create_dir_all(&benchmarks).unwrap();
876        std::fs::create_dir_all(&vscode).unwrap();
877        std::fs::create_dir_all(&deep).unwrap();
878
879        std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
880        std::fs::write(vscode.join("package.json"), r#"{"name": "fallow-vscode"}"#).unwrap();
881        std::fs::write(deep.join("package.json"), r#"{"name": "deep-fixture"}"#).unwrap();
882
883        let workspaces = discover_workspaces(dir.path());
884        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
885
886        assert!(
887            names.contains(&"benchmarks"),
888            "top-level nested package should be discovered: {workspaces:?}"
889        );
890        assert!(
891            names.contains(&"fallow-vscode"),
892            "second-level nested package should be discovered: {workspaces:?}"
893        );
894        assert!(
895            !names.contains(&"deep-fixture"),
896            "fallback should stay shallow and skip deep fixtures: {workspaces:?}"
897        );
898    }
899
900    #[test]
901    fn discover_workspaces_with_negated_patterns() {
902        let dir = tempfile::tempdir().expect("create temp dir");
903        let pkg_a = dir.path().join("packages").join("a");
904        let pkg_test = dir.path().join("packages").join("test-utils");
905        std::fs::create_dir_all(&pkg_a).unwrap();
906        std::fs::create_dir_all(&pkg_test).unwrap();
907
908        std::fs::write(
909            dir.path().join("package.json"),
910            r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
911        )
912        .unwrap();
913        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
914        std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
915
916        let workspaces = discover_workspaces(dir.path());
917        assert_eq!(workspaces.len(), 1);
918        assert_eq!(workspaces[0].name, "a");
919    }
920
921    #[test]
922    fn discover_workspaces_skips_root_as_workspace() {
923        let dir = tempfile::tempdir().expect("create temp dir");
924        std::fs::write(
925            dir.path().join("pnpm-workspace.yaml"),
926            "packages:\n  - '.'\n",
927        )
928        .unwrap();
929        std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
930
931        let workspaces = discover_workspaces(dir.path());
932        assert!(
933            workspaces.is_empty(),
934            "root directory should not be added as workspace"
935        );
936    }
937
938    #[test]
939    fn discover_workspaces_name_fallback_to_dir_name() {
940        let dir = tempfile::tempdir().expect("create temp dir");
941        let pkg_a = dir.path().join("packages").join("my-app");
942        std::fs::create_dir_all(&pkg_a).unwrap();
943
944        std::fs::write(
945            dir.path().join("package.json"),
946            r#"{"workspaces": ["packages/*"]}"#,
947        )
948        .unwrap();
949        std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
950
951        let workspaces = discover_workspaces(dir.path());
952        assert_eq!(workspaces.len(), 1);
953        assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
954    }
955
956    #[test]
957    fn discover_workspaces_explicit_patterns_disable_shallow_fallback() {
958        let dir = tempfile::tempdir().expect("create temp dir");
959        let pkg_a = dir.path().join("packages").join("a");
960        let benchmarks = dir.path().join("benchmarks");
961        std::fs::create_dir_all(&pkg_a).unwrap();
962        std::fs::create_dir_all(&benchmarks).unwrap();
963
964        std::fs::write(
965            dir.path().join("package.json"),
966            r#"{"workspaces": ["packages/*"]}"#,
967        )
968        .unwrap();
969        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
970        std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
971
972        let workspaces = discover_workspaces(dir.path());
973        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
974
975        assert_eq!(workspaces.len(), 1);
976        assert!(names.contains(&"a"));
977        assert!(
978            !names.contains(&"benchmarks"),
979            "explicit workspace config should keep undeclared packages out: {workspaces:?}"
980        );
981    }
982
983    #[test]
984    fn discover_workspaces_recovers_package_under_bare_glob_intermediate() {
985        // Issue #842 (reporter metrists/metrists): root declares
986        // `["./packages/*", "./themes/*"]`, but the real package lives two levels
987        // deep at `packages/themes/metrists-theme-next` while `packages/themes`
988        // itself has no package.json. The single-level glob only matches the bare
989        // `packages/themes`; without recovery the deep package is never discovered,
990        // its files fall back to the root manifest, and its declared deps (react)
991        // are reported as unlisted. Discovery must recover the named deep package.
992        let dir = tempfile::tempdir().expect("create temp dir");
993        let theme = dir
994            .path()
995            .join("packages")
996            .join("themes")
997            .join("metrists-theme-next");
998        std::fs::create_dir_all(&theme).unwrap();
999        std::fs::write(
1000            dir.path().join("package.json"),
1001            r#"{"name": "metrists-monorepo", "workspaces": ["./packages/*", "./themes/*"]}"#,
1002        )
1003        .unwrap();
1004        // packages/themes intentionally has NO package.json (bare grouping dir).
1005        std::fs::write(
1006            theme.join("package.json"),
1007            r#"{"name": "metrists-theme-next", "dependencies": {"react": "^18"}}"#,
1008        )
1009        .unwrap();
1010
1011        let workspaces = discover_workspaces(dir.path());
1012        assert!(
1013            workspaces.iter().any(|ws| ws.name == "metrists-theme-next"),
1014            "deep package under a bare glob-matched intermediate must be discovered: {workspaces:?}"
1015        );
1016    }
1017
1018    #[test]
1019    fn undeclared_workspace_detected() {
1020        let dir = tempfile::tempdir().expect("create temp dir");
1021        let pkg_a = dir.path().join("packages").join("a");
1022        let pkg_b = dir.path().join("packages").join("b");
1023        std::fs::create_dir_all(&pkg_a).unwrap();
1024        std::fs::create_dir_all(&pkg_b).unwrap();
1025
1026        std::fs::write(
1027            dir.path().join("package.json"),
1028            r#"{"workspaces": ["packages/a"]}"#,
1029        )
1030        .unwrap();
1031        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1032        std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1033
1034        let declared = discover_workspaces(dir.path());
1035        assert_eq!(declared.len(), 1);
1036
1037        let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1038        assert_eq!(undeclared.len(), 1);
1039        assert!(
1040            undeclared[0]
1041                .path
1042                .to_string_lossy()
1043                .replace('\\', "/")
1044                .contains("packages/b"),
1045            "should detect packages/b as undeclared: {:?}",
1046            undeclared[0].path
1047        );
1048    }
1049
1050    #[test]
1051    fn no_undeclared_when_all_covered() {
1052        let dir = tempfile::tempdir().expect("create temp dir");
1053        let pkg_a = dir.path().join("packages").join("a");
1054        std::fs::create_dir_all(&pkg_a).unwrap();
1055
1056        std::fs::write(
1057            dir.path().join("package.json"),
1058            r#"{"workspaces": ["packages/*"]}"#,
1059        )
1060        .unwrap();
1061        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1062
1063        let declared = discover_workspaces(dir.path());
1064        let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1065        assert!(undeclared.is_empty());
1066    }
1067
1068    #[test]
1069    fn no_undeclared_when_no_workspace_patterns() {
1070        let dir = tempfile::tempdir().expect("create temp dir");
1071        let sub = dir.path().join("lib");
1072        std::fs::create_dir_all(&sub).unwrap();
1073
1074        std::fs::write(dir.path().join("package.json"), r#"{"name": "app"}"#).unwrap();
1075        std::fs::write(sub.join("package.json"), r#"{"name": "lib"}"#).unwrap();
1076
1077        let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1078        assert!(
1079            undeclared.is_empty(),
1080            "should skip check when no workspace patterns exist"
1081        );
1082    }
1083
1084    #[test]
1085    fn undeclared_skips_node_modules_and_hidden_dirs() {
1086        let dir = tempfile::tempdir().expect("create temp dir");
1087        let nm = dir.path().join("node_modules").join("some-pkg");
1088        let hidden = dir.path().join(".hidden");
1089        std::fs::create_dir_all(&nm).unwrap();
1090        std::fs::create_dir_all(&hidden).unwrap();
1091
1092        std::fs::write(
1093            dir.path().join("package.json"),
1094            r#"{"workspaces": ["packages/*"]}"#,
1095        )
1096        .unwrap();
1097        std::fs::write(nm.join("package.json"), r#"{"name": "nm-pkg"}"#).unwrap();
1098        std::fs::write(hidden.join("package.json"), r#"{"name": "hidden"}"#).unwrap();
1099
1100        let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1101        assert!(
1102            undeclared.is_empty(),
1103            "should not flag node_modules or hidden directories"
1104        );
1105    }
1106
1107    fn build_globset(patterns: &[&str]) -> globset::GlobSet {
1108        let mut builder = globset::GlobSetBuilder::new();
1109        for pattern in patterns {
1110            builder.add(globset::Glob::new(pattern).expect("valid glob"));
1111        }
1112        builder.build().expect("build globset")
1113    }
1114
1115    #[test]
1116    fn undeclared_skips_dirs_matching_ignore_patterns() {
1117        let dir = tempfile::tempdir().expect("create temp dir");
1118        let pkg_a = dir.path().join("packages").join("a");
1119        let vitest_ref = dir.path().join("references").join("vitest");
1120        let tanstack_ref = dir.path().join("references").join("tanstack-router");
1121        std::fs::create_dir_all(&pkg_a).unwrap();
1122        std::fs::create_dir_all(&vitest_ref).unwrap();
1123        std::fs::create_dir_all(&tanstack_ref).unwrap();
1124
1125        std::fs::write(
1126            dir.path().join("package.json"),
1127            r#"{"workspaces": ["packages/*"]}"#,
1128        )
1129        .unwrap();
1130        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1131        std::fs::write(
1132            vitest_ref.join("package.json"),
1133            r#"{"name": "vitest-reference"}"#,
1134        )
1135        .unwrap();
1136        std::fs::write(
1137            tanstack_ref.join("package.json"),
1138            r#"{"name": "tanstack-reference"}"#,
1139        )
1140        .unwrap();
1141
1142        let declared = discover_workspaces(dir.path());
1143        let ignore = build_globset(&["references/*"]);
1144        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1145        assert!(
1146            undeclared.is_empty(),
1147            "references/* should be ignored: {undeclared:?}"
1148        );
1149    }
1150
1151    #[test]
1152    fn undeclared_still_reported_when_ignore_does_not_match() {
1153        let dir = tempfile::tempdir().expect("create temp dir");
1154        let pkg_b = dir.path().join("packages").join("b");
1155        std::fs::create_dir_all(&pkg_b).unwrap();
1156
1157        std::fs::write(
1158            dir.path().join("package.json"),
1159            r#"{"workspaces": ["packages/a"]}"#,
1160        )
1161        .unwrap();
1162        std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1163
1164        let declared = discover_workspaces(dir.path());
1165        let ignore = build_globset(&["references/*"]);
1166        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1167        assert_eq!(
1168            undeclared.len(),
1169            1,
1170            "non-matching ignore patterns should not silence other undeclared dirs"
1171        );
1172    }
1173
1174    #[test]
1175    fn undeclared_skips_dirs_matching_package_json_glob() {
1176        let dir = tempfile::tempdir().expect("create temp dir");
1177        let pkg_a = dir.path().join("packages").join("a");
1178        let vitest_ref = dir.path().join("references").join("vitest");
1179        std::fs::create_dir_all(&pkg_a).unwrap();
1180        std::fs::create_dir_all(&vitest_ref).unwrap();
1181
1182        std::fs::write(
1183            dir.path().join("package.json"),
1184            r#"{"workspaces": ["packages/*"]}"#,
1185        )
1186        .unwrap();
1187        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1188        std::fs::write(
1189            vitest_ref.join("package.json"),
1190            r#"{"name": "vitest-reference"}"#,
1191        )
1192        .unwrap();
1193
1194        let declared = discover_workspaces(dir.path());
1195        let ignore = build_globset(&["references/*/package.json"]);
1196        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1197        assert!(
1198            undeclared.is_empty(),
1199            "package.json-suffixed glob should silence the warning: {undeclared:?}"
1200        );
1201    }
1202
1203    #[test]
1204    fn undeclared_skips_dirs_matching_doublestar_ignore() {
1205        let dir = tempfile::tempdir().expect("create temp dir");
1206        let pkg_a = dir.path().join("packages").join("a");
1207        let nested_ref = dir.path().join("references").join("vitest");
1208        std::fs::create_dir_all(&pkg_a).unwrap();
1209        std::fs::create_dir_all(&nested_ref).unwrap();
1210
1211        std::fs::write(
1212            dir.path().join("package.json"),
1213            r#"{"workspaces": ["packages/*"]}"#,
1214        )
1215        .unwrap();
1216        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1217        std::fs::write(
1218            nested_ref.join("package.json"),
1219            r#"{"name": "vitest-reference"}"#,
1220        )
1221        .unwrap();
1222
1223        let declared = discover_workspaces(dir.path());
1224        let ignore = build_globset(&["**/references/**"]);
1225        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1226        assert!(
1227            undeclared.is_empty(),
1228            "**/references/** should ignore nested package.json dirs: {undeclared:?}"
1229        );
1230    }
1231
1232    #[test]
1233    fn malformed_workspace_package_json_emits_diagnostic() {
1234        let dir = tempfile::tempdir().expect("create temp dir");
1235        let pkg_a = dir.path().join("packages").join("a");
1236        let pkg_bad = dir.path().join("packages").join("bad");
1237        std::fs::create_dir_all(&pkg_a).unwrap();
1238        std::fs::create_dir_all(&pkg_bad).unwrap();
1239        std::fs::write(
1240            dir.path().join("package.json"),
1241            r#"{"workspaces": ["packages/*"]}"#,
1242        )
1243        .unwrap();
1244        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1245        std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad",}"#).unwrap();
1246
1247        let (result, captured) = capture_workspace_warnings(|| {
1248            discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1249        });
1250        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1251
1252        assert_eq!(workspaces.len(), 1, "the valid workspace still discovers");
1253        assert_eq!(workspaces[0].name, "a");
1254        assert_eq!(diagnostics.len(), 1);
1255        assert!(matches!(
1256            diagnostics[0].kind,
1257            WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1258        ));
1259        assert!(
1260            captured
1261                .iter()
1262                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1263        );
1264    }
1265
1266    #[test]
1267    fn multiple_malformed_workspace_package_jsons_all_diagnosed() {
1268        let dir = tempfile::tempdir().expect("create temp dir");
1269        for name in ["a", "b", "c"] {
1270            let pkg = dir.path().join("packages").join(name);
1271            std::fs::create_dir_all(&pkg).unwrap();
1272            std::fs::write(pkg.join("package.json"), r"{,}").unwrap();
1273        }
1274        std::fs::write(
1275            dir.path().join("package.json"),
1276            r#"{"workspaces": ["packages/*"]}"#,
1277        )
1278        .unwrap();
1279
1280        let (result, _) = capture_workspace_warnings(|| {
1281            discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1282        });
1283        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1284
1285        assert!(workspaces.is_empty(), "all three malformed; nothing valid");
1286        assert_eq!(diagnostics.len(), 3, "each malformed workspace surfaces");
1287        assert!(
1288            diagnostics
1289                .iter()
1290                .all(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
1291            "every diagnostic should be malformed-package-json"
1292        );
1293    }
1294
1295    #[test]
1296    fn malformed_root_package_json_returns_load_error() {
1297        let dir = tempfile::tempdir().expect("create temp dir");
1298        std::fs::write(dir.path().join("package.json"), "this is not json").unwrap();
1299
1300        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1301
1302        match result {
1303            Err(WorkspaceLoadError::MalformedRootPackageJson { path, error }) => {
1304                assert!(path.ends_with("package.json"));
1305                assert!(!error.is_empty(), "underlying parse error is preserved");
1306            }
1307            Ok(_) => panic!("expected MalformedRootPackageJson"),
1308        }
1309    }
1310
1311    #[test]
1312    fn glob_match_without_package_json_emits_diagnostic_unless_skip_listed() {
1313        let dir = tempfile::tempdir().expect("create temp dir");
1314        let pkg_a = dir.path().join("packages").join("a");
1315        let cache_dir = dir.path().join("packages").join(".cache");
1316        let scratch_dir = dir.path().join("packages").join("scratch");
1317        std::fs::create_dir_all(&pkg_a).unwrap();
1318        std::fs::create_dir_all(&cache_dir).unwrap();
1319        std::fs::create_dir_all(&scratch_dir).unwrap();
1320        std::fs::write(
1321            dir.path().join("package.json"),
1322            r#"{"workspaces": ["packages/*"]}"#,
1323        )
1324        .unwrap();
1325        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1326
1327        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1328        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1329
1330        assert_eq!(workspaces.len(), 1);
1331        let kinds: Vec<&str> = diagnostics.iter().map(|d| d.kind.id()).collect();
1332        assert!(
1333            kinds.contains(&"glob-matched-no-package-json"),
1334            "scratch should diagnose: {kinds:?}"
1335        );
1336        assert!(
1337            !diagnostics.iter().any(|d| d.path.ends_with(".cache")),
1338            ".cache must be skip-listed: {diagnostics:?}"
1339        );
1340    }
1341
1342    #[test]
1343    fn glob_match_without_package_json_honors_ignore_patterns() {
1344        let dir = tempfile::tempdir().expect("create temp dir");
1345        let pkg_a = dir.path().join("packages").join("a");
1346        let legacy_dir = dir.path().join("packages").join("legacy");
1347        std::fs::create_dir_all(&pkg_a).unwrap();
1348        std::fs::create_dir_all(&legacy_dir).unwrap();
1349        std::fs::write(
1350            dir.path().join("package.json"),
1351            r#"{"workspaces": ["packages/*"]}"#,
1352        )
1353        .unwrap();
1354        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1355
1356        let mut builder = globset::GlobSetBuilder::new();
1357        builder.add(globset::Glob::new("packages/legacy").unwrap());
1358        let ignore = builder.build().unwrap();
1359
1360        let result = discover_workspaces_with_diagnostics(dir.path(), &ignore);
1361        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1362
1363        assert_eq!(workspaces.len(), 1);
1364        assert!(
1365            diagnostics.is_empty(),
1366            "user-excluded path must not produce a diagnostic: {diagnostics:?}"
1367        );
1368    }
1369
1370    #[test]
1371    fn malformed_tsconfig_emits_diagnostic() {
1372        let dir = tempfile::tempdir().expect("create temp dir");
1373        std::fs::write(
1374            dir.path().join("package.json"),
1375            r#"{"workspaces": ["packages/*"]}"#,
1376        )
1377        .unwrap();
1378        std::fs::write(dir.path().join("tsconfig.json"), r#"{"references": [,,,]}"#).unwrap();
1379
1380        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1381        let (_, diagnostics) = result.expect("root package.json is valid");
1382
1383        assert!(
1384            diagnostics
1385                .iter()
1386                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
1387            "expected MalformedTsconfig diagnostic; got: {diagnostics:?}"
1388        );
1389    }
1390
1391    #[test]
1392    fn tsconfig_missing_reference_dir_emits_diagnostic() {
1393        let dir = tempfile::tempdir().expect("create temp dir");
1394        std::fs::write(
1395            dir.path().join("tsconfig.json"),
1396            r#"{"references": [{"path": "./packages/missing"}]}"#,
1397        )
1398        .unwrap();
1399
1400        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1401        let (_, diagnostics) = result.expect("no package.json at root is OK");
1402
1403        assert!(
1404            diagnostics
1405                .iter()
1406                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::TsconfigReferenceDirMissing)),
1407            "expected TsconfigReferenceDirMissing; got: {diagnostics:?}"
1408        );
1409    }
1410
1411    #[test]
1412    fn missing_tsconfig_is_silent() {
1413        let dir = tempfile::tempdir().expect("create temp dir");
1414
1415        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1416        let (_, diagnostics) = result.expect("no root package.json is OK");
1417
1418        assert!(
1419            !diagnostics
1420                .iter()
1421                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
1422            "missing tsconfig must not produce MalformedTsconfig: {diagnostics:?}"
1423        );
1424    }
1425
1426    #[test]
1427    fn shallow_scan_malformed_package_json_stays_silent() {
1428        let dir = tempfile::tempdir().expect("create temp dir");
1429        let scratch = dir.path().join("scratch");
1430        std::fs::create_dir_all(&scratch).unwrap();
1431        std::fs::write(scratch.join("package.json"), r"{not valid json}").unwrap();
1432
1433        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1434        let (_, diagnostics) = result.expect("no root package.json is OK");
1435
1436        assert!(
1437            !diagnostics
1438                .iter()
1439                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
1440            "shallow-scan malformed must stay silent: {diagnostics:?}"
1441        );
1442    }
1443
1444    #[test]
1445    fn mixed_valid_and_malformed_workspaces_partial_recovery() {
1446        let dir = tempfile::tempdir().expect("create temp dir");
1447        let pkg_good = dir.path().join("packages").join("good");
1448        let pkg_bad = dir.path().join("packages").join("bad");
1449        std::fs::create_dir_all(&pkg_good).unwrap();
1450        std::fs::create_dir_all(&pkg_bad).unwrap();
1451        std::fs::write(
1452            dir.path().join("package.json"),
1453            r#"{"workspaces": ["packages/*"]}"#,
1454        )
1455        .unwrap();
1456        std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1457        std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
1458
1459        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1460        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1461
1462        assert_eq!(workspaces.len(), 1);
1463        assert_eq!(workspaces[0].name, "good");
1464        assert_eq!(diagnostics.len(), 1);
1465        assert_eq!(diagnostics[0].kind.id(), "malformed-package-json");
1466    }
1467
1468    #[test]
1469    fn discover_workspaces_back_compat_drops_diagnostics_and_errors() {
1470        let dir = tempfile::tempdir().expect("create temp dir");
1471        std::fs::write(dir.path().join("package.json"), r"{bad json").unwrap();
1472
1473        let workspaces = discover_workspaces(dir.path());
1474        assert!(
1475            workspaces.is_empty(),
1476            "back-compat wrapper returns empty on root-malformed: {workspaces:?}"
1477        );
1478    }
1479}