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