Skip to main content

fallow_config/workspace/
mod.rs

1mod deno_json;
2mod diagnostics;
3mod npm_overrides;
4mod package_json;
5mod parsers;
6mod pnpm_catalog;
7mod pnpm_overrides;
8
9use std::path::{Path, PathBuf};
10
11use rustc_hash::FxHashMap;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15pub use deno_json::{
16    DirManifestProbe, dir_has_deno_json, dir_has_package_manifest, is_deno_without_node_modules,
17    load_deno_import_map, load_dir_package_json, load_member_package_manifest,
18    load_root_deno_workspace_patterns, probe_dir_manifest,
19};
20#[cfg(test)]
21pub use diagnostics::capture_workspace_warnings;
22pub use diagnostics::{
23    WorkspaceDiagnostic, WorkspaceDiagnosticKind, WorkspaceLoadError, append_workspace_diagnostics,
24    clear_analysis_stage_diagnostics, record_source_read_failures, record_workspace_diagnostics,
25    registry_diagnostics_to_fold, replace_source_discovery_diagnostics,
26    stash_workspace_diagnostics, workspace_diagnostics_for,
27};
28use diagnostics::{emit_diagnostics, is_skip_listed_dir};
29pub use npm_overrides::{parse_bun_package_json_resolutions, parse_npm_package_json_overrides};
30pub use package_json::{NapiConfig, PackageJson};
31pub use parsers::parse_tsconfig_root_dir;
32use parsers::{
33    expand_workspace_glob_with_diagnostics, parse_pnpm_workspace_yaml,
34    parse_tsconfig_references_with_diagnostics,
35};
36pub use pnpm_catalog::{
37    PnpmCatalog, PnpmCatalogData, PnpmCatalogEntry, PnpmCatalogGroup,
38    parse_package_json_catalog_data, parse_pnpm_catalog_data,
39};
40pub use pnpm_overrides::{
41    MisconfigReason, OverrideSource, ParsedOverrideKey, PnpmOverrideData, PnpmOverrideEntry,
42    is_valid_override_value, override_misconfig_reason, override_source_label, parse_override_key,
43    parse_pnpm_package_json_overrides, parse_pnpm_workspace_overrides,
44};
45
46/// Workspace configuration for monorepo support.
47#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
48pub struct WorkspaceConfig {
49    /// Additional workspace patterns (beyond what's in root package.json).
50    ///
51    /// `packages` is accepted as a back-compat alias: an older `fallow init --toml`
52    /// wrote `[workspaces]` with a `packages` key, and reading it as `patterns`
53    /// keeps those existing configs scoping correctly. schemars omits serde
54    /// aliases, so `schema.json` documents only `patterns`.
55    #[serde(default, alias = "packages")]
56    pub patterns: Vec<String>,
57}
58
59/// Discovered workspace info from package.json, deno.json, pnpm-workspace.yaml,
60/// or tsconfig.json references.
61#[derive(Debug, Clone)]
62pub struct WorkspaceInfo {
63    /// Workspace root path.
64    pub root: PathBuf,
65    /// Package name from package.json or deno.json.
66    pub name: String,
67    /// Whether this workspace is depended on by other workspaces.
68    pub is_internal_dependency: bool,
69}
70
71/// Return whether a workspace package name is selected by `publicPackages`.
72///
73/// Exact names and glob patterns share one config-owned matcher so analysis
74/// surfaces cannot disagree about which workspace APIs are public. Invalid
75/// patterns do not match, preserving the existing fail-closed behavior.
76#[must_use]
77pub fn workspace_is_public(name: &str, public_packages: &[String]) -> bool {
78    public_packages.iter().any(|pattern| {
79        name == pattern
80            || globset::Glob::new(pattern)
81                .ok()
82                .is_some_and(|glob| glob.compile_matcher().is_match(name))
83    })
84}
85
86/// Discover all workspace packages in a monorepo.
87///
88/// Sources (additive, deduplicated by canonical path):
89/// 1. `package.json` `workspaces` field
90/// 2. `pnpm-workspace.yaml` `packages` field
91/// 3. `deno.json` / `deno.jsonc` `workspace` field
92/// 4. `tsconfig.json` `references` field (TypeScript project references)
93///
94/// Back-compat wrapper: drops any diagnostics and silently treats a malformed
95/// root `package.json` as "no workspaces". New callers should use
96/// [`discover_workspaces_with_diagnostics`] to receive typed
97/// [`WorkspaceDiagnostic`] values and to surface root-malformed errors as
98/// hard exits.
99///
100/// This wrapper goes through the silent collector path that does NOT call
101/// `emit_diagnostics` (private helper in `crates/config/src/workspace/diagnostics.rs`
102/// that does the `tracing::warn!` emission). Without that split, sibling
103/// callers in `core/src/lib.rs` (analyze) and `core/src/discover/mod.rs`
104/// (file discovery) would re-emit `tracing::warn!` on paths the user already
105/// excluded via `ignorePatterns`, because the back-compat wrapper has no
106/// access to the user's globset.
107#[must_use]
108pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
109    collect_workspaces_and_diagnostics(root, &globset::GlobSet::empty())
110        .map(|(workspaces, _)| workspaces)
111        .unwrap_or_default()
112}
113
114/// Discover workspace packages and return any diagnostics produced along the
115/// way.
116///
117/// Replaces the four silent-drop sites in [`discover_workspaces`] with typed
118/// [`WorkspaceDiagnostic`] values:
119/// - malformed declared-workspace `package.json` (warn-and-continue),
120/// - glob match resolving to a directory without `package.json` (warn,
121///   filtered through `ignore_patterns` and an extended skip list),
122/// - malformed `tsconfig.json` (warn-and-continue),
123/// - tsconfig `references[].path` pointing to a missing directory (warn).
124///
125/// `ignore_patterns` mirrors the precedent in
126/// [`find_undeclared_workspaces_with_ignores`]: directories the user already
127/// excluded do not trigger a redundant diagnostic.
128///
129/// Returns [`WorkspaceLoadError::MalformedRootPackageJson`] when the root
130/// `package.json` exists but fails to parse: without a parseable root, no
131/// workspace patterns can be collected and the analysis output would be
132/// fiction. The CLI surfaces this as exit 2.
133///
134/// Shallow-scan fallback candidates (`collect_shallow_workspace_candidate`)
135/// stay silent: the user did not declare them, so a stray malformed
136/// `package.json` two levels deep in a `tools/scratch/` directory should not
137/// produce noise.
138///
139/// # Errors
140///
141/// Returns [`WorkspaceLoadError`] when the project root's `package.json`
142/// exists but is not valid JSON. Callers map this to a hard exit.
143pub fn discover_workspaces_with_diagnostics(
144    root: &Path,
145    ignore_patterns: &globset::GlobSet,
146) -> Result<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>), WorkspaceLoadError> {
147    let (workspaces, diagnostics) = collect_workspaces_and_diagnostics(root, ignore_patterns)?;
148
149    emit_diagnostics(root, &diagnostics);
150
151    Ok((workspaces, diagnostics))
152}
153
154/// Collect workspaces and diagnostics without emitting `tracing::warn!`.
155///
156/// Both [`discover_workspaces_with_diagnostics`] (which adds the emit step)
157/// and [`discover_workspaces`] (which drops both diagnostics and emission)
158/// route through this function. Keeping emission in the public top-level
159/// only means downstream callers that have no access to the user's
160/// `ignorePatterns` cannot accidentally re-emit warnings on paths the user
161/// already excluded.
162///
163/// The returned diagnostics are deduplicated on the whole `(kind, path)`, the
164/// way every downstream fold is. The sources are additive, so a repository that
165/// declares one glob in both `package.json` `workspaces` and
166/// `pnpm-workspace.yaml` `packages` walks it twice and reports every
167/// package-less directory under it twice. Folding here rather than only in the
168/// process registry is what keeps the returned list, the `workspaces` and
169/// `list --workspaces` envelopes, the MCP `project_info` tool and the
170/// aggregated stderr warning (whose per-pattern grouping counts the entries it
171/// is handed) agreeing on one entry per distinct matching pattern (issue
172/// #2366). Two overlapping globs (`["packages/*", "packages/*/*"]`) still
173/// report the same directory once per `pattern`: the payload is part of the
174/// key.
175fn collect_workspaces_and_diagnostics(
176    root: &Path,
177    ignore_patterns: &globset::GlobSet,
178) -> Result<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>), WorkspaceLoadError> {
179    let mut diagnostics = Vec::new();
180    let patterns = collect_workspace_patterns(root)?;
181    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
182    let mut manifest_cache = ManifestCache::default();
183
184    let mut workspaces = expand_patterns_to_workspaces(
185        root,
186        &patterns,
187        &canonical_root,
188        ignore_patterns,
189        &mut diagnostics,
190        &mut manifest_cache,
191    );
192    workspaces.extend(collect_tsconfig_workspaces(
193        root,
194        &canonical_root,
195        ignore_patterns,
196        &mut diagnostics,
197        &mut manifest_cache,
198    ));
199    if patterns.is_empty() {
200        workspaces.extend(collect_shallow_package_workspaces(
201            root,
202            &canonical_root,
203            &mut manifest_cache,
204        ));
205    }
206
207    if !workspaces.is_empty() {
208        mark_internal_dependencies(&mut workspaces);
209    }
210    let workspaces = workspaces.into_iter().map(|(ws, _)| ws).collect();
211    Ok((
212        workspaces,
213        fallow_types::workspace::dedupe_workspace_diagnostics(diagnostics),
214    ))
215}
216
217/// Find directories containing `package.json` that are not declared as workspaces.
218///
219/// Only meaningful in monorepos that declare workspaces (via `package.json` `workspaces`
220/// field or `pnpm-workspace.yaml`). Scans up to two directory levels deep, skipping
221/// hidden directories, `node_modules`, and `build`.
222#[must_use]
223pub fn find_undeclared_workspaces(
224    root: &Path,
225    declared: &[WorkspaceInfo],
226) -> Vec<WorkspaceDiagnostic> {
227    find_undeclared_workspaces_with_ignores(root, declared, &globset::GlobSet::empty())
228}
229
230/// Find directories containing `package.json` that are not declared as workspaces,
231/// excluding candidates covered by the supplied ignore globset.
232///
233/// This is the ignore-aware variant used by the full analyzer after config
234/// resolution. See [`find_undeclared_workspaces`] for the compatibility wrapper.
235///
236/// Directories whose project-root-relative path matches `ignore_patterns` are skipped
237/// so users who already excluded a path via `ignorePatterns` don't see a redundant
238/// "not declared as workspace" warning. See issue #193.
239#[must_use]
240pub fn find_undeclared_workspaces_with_ignores(
241    root: &Path,
242    declared: &[WorkspaceInfo],
243    ignore_patterns: &globset::GlobSet,
244) -> Vec<WorkspaceDiagnostic> {
245    let patterns = collect_workspace_patterns(root).unwrap_or_default();
246    if patterns.is_empty() {
247        return Vec::new();
248    }
249
250    let declared_roots: rustc_hash::FxHashSet<PathBuf> = declared
251        .iter()
252        .map(|w| dunce::canonicalize(&w.root).unwrap_or_else(|_| w.root.clone()))
253        .collect();
254
255    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
256
257    let mut undeclared = Vec::new();
258
259    let Ok(top_entries) = std::fs::read_dir(root) else {
260        return Vec::new();
261    };
262
263    for entry in top_entries.filter_map(Result::ok) {
264        let path = entry.path();
265        if !path.is_dir() || is_undeclared_scan_skip_dir(&entry.file_name().to_string_lossy()) {
266            continue;
267        }
268
269        let mut input = UndeclaredScanInput {
270            root,
271            canonical_root: &canonical_root,
272            declared_roots: &declared_roots,
273            ignore_patterns,
274            undeclared: &mut undeclared,
275        };
276        check_undeclared(
277            &path,
278            input.root,
279            input.canonical_root,
280            input.declared_roots,
281            input.ignore_patterns,
282            input.undeclared,
283        );
284        scan_child_dirs_for_undeclared(&path, &mut input);
285    }
286
287    undeclared
288}
289
290/// Whether an undeclared-workspace scan should skip a directory by leaf name.
291fn is_undeclared_scan_skip_dir(name: &str) -> bool {
292    name.starts_with('.') || name == "node_modules" || name == "build"
293}
294
295/// Borrowed inputs threaded through the second-level undeclared-workspace scan.
296struct UndeclaredScanInput<'a> {
297    root: &'a Path,
298    canonical_root: &'a Path,
299    declared_roots: &'a rustc_hash::FxHashSet<PathBuf>,
300    ignore_patterns: &'a globset::GlobSet,
301    undeclared: &'a mut Vec<WorkspaceDiagnostic>,
302}
303
304/// Check each immediate child of `parent` for an undeclared workspace.
305fn scan_child_dirs_for_undeclared(parent: &Path, input: &mut UndeclaredScanInput<'_>) {
306    let Ok(child_entries) = std::fs::read_dir(parent) else {
307        return;
308    };
309    for child in child_entries.filter_map(Result::ok) {
310        let child_path = child.path();
311        if !child_path.is_dir() || is_undeclared_scan_skip_dir(&child.file_name().to_string_lossy())
312        {
313            continue;
314        }
315        check_undeclared(
316            &child_path,
317            input.root,
318            input.canonical_root,
319            input.declared_roots,
320            input.ignore_patterns,
321            input.undeclared,
322        );
323    }
324}
325
326/// Check a single directory for an undeclared workspace.
327fn check_undeclared(
328    dir: &Path,
329    root: &Path,
330    canonical_root: &Path,
331    declared_roots: &rustc_hash::FxHashSet<PathBuf>,
332    ignore_patterns: &globset::GlobSet,
333    undeclared: &mut Vec<WorkspaceDiagnostic>,
334) {
335    if !dir_has_package_manifest(dir) {
336        return;
337    }
338    let canonical = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
339    if canonical == *canonical_root {
340        return;
341    }
342    if declared_roots.contains(&canonical) {
343        return;
344    }
345    let relative = dir.strip_prefix(root).unwrap_or(dir);
346    let relative_str = relative.to_string_lossy().replace('\\', "/");
347    if ignore_patterns.is_match(relative_str.as_str())
348        || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
349        || ignore_patterns.is_match(format!("{relative_str}/deno.json").as_str())
350        || ignore_patterns.is_match(format!("{relative_str}/deno.jsonc").as_str())
351    {
352        return;
353    }
354    undeclared.push(WorkspaceDiagnostic::new(
355        root,
356        dir.to_path_buf(),
357        WorkspaceDiagnosticKind::UndeclaredWorkspace,
358    ));
359}
360
361/// Collect glob patterns from `package.json` `workspaces`, `pnpm-workspace.yaml`,
362/// and Deno `deno.json` / `deno.jsonc` `workspace`.
363fn collect_workspace_patterns(root: &Path) -> Result<Vec<String>, WorkspaceLoadError> {
364    let mut patterns = Vec::new();
365
366    let pkg_path = root.join("package.json");
367    if pkg_path.exists() {
368        match PackageJson::load(&pkg_path) {
369            Ok(pkg) => patterns.extend(pkg.workspace_patterns()),
370            Err(error) => {
371                return Err(WorkspaceLoadError::MalformedRootPackageJson {
372                    path: pkg_path,
373                    error,
374                });
375            }
376        }
377    }
378
379    let pnpm_workspace = root.join("pnpm-workspace.yaml");
380    if pnpm_workspace.exists()
381        && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
382    {
383        patterns.extend(parse_pnpm_workspace_yaml(&content));
384    }
385
386    if let Some((_path, deno_patterns)) = load_root_deno_workspace_patterns(root)
387        .map_err(|(path, error)| WorkspaceLoadError::MalformedRootDenoConfig { path, error })?
388    {
389        patterns.extend(deno_patterns);
390    }
391
392    Ok(patterns)
393}
394
395/// Memoized [`load_member_package_manifest`] outcomes for one discovery run.
396///
397/// The same directory is commonly matched by more than one workspace source
398/// (identical globs in `package.json` and `pnpm-workspace.yaml`, tsconfig
399/// references overlapping npm workspaces). Each manifest load costs a
400/// `package.json` read plus Deno `deno.json` / `deno.jsonc` probes, so repeat
401/// visits replay the memoized outcome instead of touching the filesystem again.
402type ManifestCache = FxHashMap<PathBuf, Result<Option<(String, PackageJson, Vec<String>)>, String>>;
403
404/// Load a member manifest through the per-discovery memo, preserving the
405/// exact per-call outcome (including errors) on repeat visits.
406fn load_member_package_manifest_cached(
407    dir: &Path,
408    cache: &mut ManifestCache,
409) -> Result<Option<(String, PackageJson, Vec<String>)>, String> {
410    if let Some(cached) = cache.get(dir) {
411        return cached.clone();
412    }
413    let outcome = load_member_package_manifest(dir);
414    cache.insert(dir.to_path_buf(), outcome.clone());
415    outcome
416}
417
418/// Expand workspace glob patterns to discover workspace directories.
419///
420/// Handles positive/negated pattern splitting, glob matching, and package.json
421/// loading for each matched directory.
422fn expand_patterns_to_workspaces(
423    root: &Path,
424    patterns: &[String],
425    canonical_root: &Path,
426    ignore_patterns: &globset::GlobSet,
427    diagnostics: &mut Vec<WorkspaceDiagnostic>,
428    manifest_cache: &mut ManifestCache,
429) -> Vec<(WorkspaceInfo, Vec<String>)> {
430    if patterns.is_empty() {
431        return Vec::new();
432    }
433
434    let mut workspaces = Vec::new();
435
436    let (positive, negative): (Vec<&String>, Vec<&String>) =
437        patterns.iter().partition(|p| !p.starts_with('!'));
438    let negation_matchers: Vec<globset::GlobMatcher> = negative
439        .iter()
440        .filter_map(|p| {
441            let stripped = p.strip_prefix('!').unwrap_or(p);
442            globset::Glob::new(stripped)
443                .ok()
444                .map(|g| g.compile_matcher())
445        })
446        .collect();
447
448    for pattern in &positive {
449        let glob_pattern = if pattern.ends_with('/') {
450            format!("{pattern}*")
451        } else {
452            (*pattern).clone()
453        };
454
455        let matched_dirs = expand_workspace_glob_with_diagnostics(
456            root,
457            pattern,
458            &glob_pattern,
459            canonical_root,
460            ignore_patterns,
461            diagnostics,
462        );
463        for (dir, canonical_dir) in matched_dirs {
464            if canonical_dir == *canonical_root {
465                continue;
466            }
467            if matches_negation(root, &dir, &negation_matchers) {
468                continue;
469            }
470            register_matched_workspace(root, dir, &mut workspaces, diagnostics, manifest_cache);
471        }
472    }
473
474    workspaces
475}
476
477/// Whether a matched directory is excluded by a negated workspace pattern.
478fn matches_negation(root: &Path, dir: &Path, negation_matchers: &[globset::GlobMatcher]) -> bool {
479    let relative = dir.strip_prefix(root).unwrap_or(dir);
480    let relative_str = relative.to_string_lossy();
481    negation_matchers
482        .iter()
483        .any(|m| m.is_match(relative_str.as_ref()))
484}
485
486/// Load a matched directory's package manifest (`package.json` or `deno.json`)
487/// and push a workspace, or a malformed-package diagnostic on parse failure.
488/// A manifest removed after glob expansion is treated as a stale match.
489fn register_matched_workspace(
490    root: &Path,
491    dir: PathBuf,
492    workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
493    diagnostics: &mut Vec<WorkspaceDiagnostic>,
494    manifest_cache: &mut ManifestCache,
495) {
496    match load_member_package_manifest_cached(&dir, manifest_cache) {
497        Ok(Some((name, _pkg, dep_names))) => {
498            workspaces.push((
499                WorkspaceInfo {
500                    root: dir,
501                    name,
502                    is_internal_dependency: false,
503                },
504                dep_names,
505            ));
506        }
507        Ok(None) => {}
508        Err(error) => {
509            let diag = WorkspaceDiagnostic::new(
510                root,
511                dir,
512                WorkspaceDiagnosticKind::MalformedPackageJson { error },
513            );
514            diagnostics.push(diag);
515        }
516    }
517}
518
519/// Discover workspaces from TypeScript project references in `tsconfig.json`.
520///
521/// Referenced directories are added as workspaces, supplementing npm/pnpm workspaces.
522/// This enables cross-workspace resolution for TypeScript composite projects.
523fn collect_tsconfig_workspaces(
524    root: &Path,
525    canonical_root: &Path,
526    ignore_patterns: &globset::GlobSet,
527    diagnostics: &mut Vec<WorkspaceDiagnostic>,
528    manifest_cache: &mut ManifestCache,
529) -> Vec<(WorkspaceInfo, Vec<String>)> {
530    let mut workspaces = Vec::new();
531
532    for dir in parse_tsconfig_references_with_diagnostics(root, ignore_patterns, diagnostics) {
533        if let Some(workspace) =
534            tsconfig_workspace_from_dir(root, dir, canonical_root, diagnostics, manifest_cache)
535        {
536            workspaces.push(workspace);
537        }
538    }
539
540    workspaces
541}
542
543fn tsconfig_workspace_from_dir(
544    root: &Path,
545    dir: PathBuf,
546    canonical_root: &Path,
547    diagnostics: &mut Vec<WorkspaceDiagnostic>,
548    manifest_cache: &mut ManifestCache,
549) -> Option<(WorkspaceInfo, Vec<String>)> {
550    let canonical_dir = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
551    if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
552        return None;
553    }
554
555    let (name, dep_names) =
556        load_tsconfig_workspace_package(root, &dir, diagnostics, manifest_cache);
557    Some((
558        WorkspaceInfo {
559            root: dir,
560            name,
561            is_internal_dependency: false,
562        },
563        dep_names,
564    ))
565}
566
567fn load_tsconfig_workspace_package(
568    root: &Path,
569    dir: &Path,
570    diagnostics: &mut Vec<WorkspaceDiagnostic>,
571    manifest_cache: &mut ManifestCache,
572) -> (String, Vec<String>) {
573    match load_member_package_manifest_cached(dir, manifest_cache) {
574        Ok(Some((name, _pkg, deps))) => (name, deps),
575        Ok(None) => (dir_name(dir), Vec::new()),
576        Err(error) => {
577            let diag = WorkspaceDiagnostic::new(
578                root,
579                dir.to_path_buf(),
580                WorkspaceDiagnosticKind::MalformedPackageJson { error },
581            );
582            diagnostics.push(diag);
583            (dir_name(dir), Vec::new())
584        }
585    }
586}
587
588/// Discover shallow package workspaces when no explicit workspace config exists.
589///
590/// Scans direct children of the project root and their immediate children for
591/// `package.json` files. This catches repos that contain multiple standalone
592/// packages (for example `benchmarks/` or `editors/vscode/`) without declaring
593/// npm/pnpm workspaces at the root.
594fn collect_shallow_package_workspaces(
595    root: &Path,
596    canonical_root: &Path,
597    manifest_cache: &mut ManifestCache,
598) -> Vec<(WorkspaceInfo, Vec<String>)> {
599    let mut workspaces = Vec::new();
600    let Ok(top_entries) = std::fs::read_dir(root) else {
601        return workspaces;
602    };
603
604    for entry in top_entries.filter_map(Result::ok) {
605        let path = entry.path();
606        if !path.is_dir() || should_skip_workspace_scan_dir(&entry.file_name().to_string_lossy()) {
607            continue;
608        }
609
610        collect_shallow_workspace_candidate(&path, canonical_root, &mut workspaces, manifest_cache);
611        collect_shallow_child_workspaces(&path, canonical_root, &mut workspaces, manifest_cache);
612    }
613
614    workspaces
615}
616
617fn collect_shallow_child_workspaces(
618    parent: &Path,
619    canonical_root: &Path,
620    workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
621    manifest_cache: &mut ManifestCache,
622) {
623    let Ok(child_entries) = std::fs::read_dir(parent) else {
624        return;
625    };
626    for child in child_entries.filter_map(Result::ok) {
627        let child_path = child.path();
628        if !child_path.is_dir()
629            || should_skip_workspace_scan_dir(&child.file_name().to_string_lossy())
630        {
631            continue;
632        }
633
634        collect_shallow_workspace_candidate(
635            &child_path,
636            canonical_root,
637            workspaces,
638            manifest_cache,
639        );
640    }
641}
642
643fn collect_shallow_workspace_candidate(
644    dir: &Path,
645    canonical_root: &Path,
646    workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
647    manifest_cache: &mut ManifestCache,
648) {
649    let canonical_dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
650    if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
651        return;
652    }
653
654    let Ok(Some((name, _pkg, dep_names))) =
655        load_member_package_manifest_cached(dir, manifest_cache)
656    else {
657        return;
658    };
659
660    workspaces.push((
661        WorkspaceInfo {
662            root: dir.to_path_buf(),
663            name,
664            is_internal_dependency: false,
665        },
666        dep_names,
667    ));
668}
669
670fn should_skip_workspace_scan_dir(name: &str) -> bool {
671    is_skip_listed_dir(name)
672}
673
674/// Deduplicate workspaces by canonical path and mark internal dependencies.
675///
676/// Overlapping sources (npm workspaces + tsconfig references pointing to the same
677/// directory) are collapsed. npm-discovered entries take precedence (they appear first).
678/// Workspaces depended on by other workspaces are marked as `is_internal_dependency`.
679fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
680    {
681        let mut seen = rustc_hash::FxHashSet::default();
682        workspaces.retain(|(ws, _)| {
683            let canonical = dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone());
684            seen.insert(canonical)
685        });
686    }
687
688    let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
689        .iter()
690        .flat_map(|(_, deps)| deps.iter().cloned())
691        .collect();
692
693    for (ws, _) in &mut *workspaces {
694        ws.is_internal_dependency = all_dep_names.contains(&ws.name);
695    }
696}
697
698/// Extract the directory name as a string, for workspace name fallback.
699fn dir_name(dir: &Path) -> String {
700    dir.file_name()
701        .map(|n| n.to_string_lossy().to_string())
702        .unwrap_or_default()
703}
704
705/// Build the issue-2366 repository shape: one glob declared in both
706/// `package.json` (spelled `./pkgs/*`) and `pnpm-workspace.yaml` (spelled
707/// `pkgs/*`), over two directories that carry no `package.json`.
708///
709/// Shared with the emission tests in `diagnostics.rs`, which assert that the
710/// aggregated stderr warning counts each directory once.
711#[cfg(test)]
712pub fn write_two_manifest_glob_project(root: &Path) {
713    std::fs::create_dir_all(root.join("pkgs/aaa")).unwrap();
714    std::fs::create_dir_all(root.join("pkgs/bbb")).unwrap();
715    std::fs::write(
716        root.join("package.json"),
717        r#"{"name":"two-manifest-root","private":true,"workspaces":["./pkgs/*"]}"#,
718    )
719    .unwrap();
720    std::fs::write(
721        root.join("pnpm-workspace.yaml"),
722        "packages:\n  - \"pkgs/*\"\n",
723    )
724    .unwrap();
725    std::fs::write(root.join("pkgs/aaa/readme.txt"), "no package.json here\n").unwrap();
726    std::fs::write(root.join("pkgs/bbb/readme.txt"), "no package.json here\n").unwrap();
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    #[test]
734    fn public_workspace_selection_supports_exact_names_and_globs() {
735        assert!(!workspace_is_public("@scope/core", &[]));
736        assert!(workspace_is_public(
737            "@scope/core",
738            &["@scope/core".to_string()]
739        ));
740        assert!(workspace_is_public(
741            "@scope/core",
742            &["@scope/*".to_string()]
743        ));
744        assert!(!workspace_is_public(
745            "@scope/core",
746            &["[invalid".to_string(), "@other/*".to_string()]
747        ));
748    }
749
750    #[test]
751    fn discover_workspaces_from_tsconfig_references() {
752        let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
753        let _ = std::fs::remove_dir_all(&temp_dir);
754        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
755        std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
756
757        std::fs::write(
758            temp_dir.join("tsconfig.json"),
759            r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
760        )
761        .unwrap();
762
763        std::fs::write(
764            temp_dir.join("packages/core/package.json"),
765            r#"{"name": "@project/core"}"#,
766        )
767        .unwrap();
768
769        let workspaces = discover_workspaces(&temp_dir);
770        assert_eq!(workspaces.len(), 2);
771        assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
772        assert!(workspaces.iter().any(|ws| ws.name == "ui"));
773
774        let _ = std::fs::remove_dir_all(&temp_dir);
775    }
776
777    #[test]
778    fn tsconfig_references_outside_root_rejected() {
779        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
780        let _ = std::fs::remove_dir_all(&temp_dir);
781        std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
782        std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
783
784        std::fs::write(
785            temp_dir.join("project/tsconfig.json"),
786            r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
787        )
788        .unwrap();
789
790        let workspaces = discover_workspaces(&temp_dir.join("project"));
791        assert_eq!(
792            workspaces.len(),
793            1,
794            "reference outside project root should be rejected: {workspaces:?}"
795        );
796        assert!(
797            workspaces[0]
798                .root
799                .to_string_lossy()
800                .contains("packages/core")
801        );
802
803        let _ = std::fs::remove_dir_all(&temp_dir);
804    }
805
806    #[test]
807    fn dir_name_extracts_last_component() {
808        assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
809        assert_eq!(dir_name(Path::new("/my-app")), "my-app");
810    }
811
812    #[test]
813    fn dir_name_empty_for_root_path() {
814        assert_eq!(dir_name(Path::new("/")), "");
815    }
816
817    #[test]
818    fn workspace_config_deserialize_json() {
819        let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
820        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
821        assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
822    }
823
824    #[test]
825    fn workspace_config_deserialize_empty_patterns() {
826        let json = r#"{"patterns": []}"#;
827        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
828        assert!(config.patterns.is_empty());
829    }
830
831    #[test]
832    fn workspace_config_default_patterns() {
833        let json = "{}";
834        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
835        assert!(config.patterns.is_empty());
836    }
837
838    #[test]
839    fn workspace_info_default_not_internal() {
840        let ws = WorkspaceInfo {
841            root: PathBuf::from("/project/packages/a"),
842            name: "a".to_string(),
843            is_internal_dependency: false,
844        };
845        assert!(!ws.is_internal_dependency);
846    }
847
848    #[test]
849    fn mark_internal_deps_detects_cross_references() {
850        let temp_dir = tempfile::tempdir().expect("create temp dir");
851        let pkg_a = temp_dir.path().join("a");
852        let pkg_b = temp_dir.path().join("b");
853        std::fs::create_dir_all(&pkg_a).unwrap();
854        std::fs::create_dir_all(&pkg_b).unwrap();
855
856        let mut workspaces = vec![
857            (
858                WorkspaceInfo {
859                    root: pkg_a,
860                    name: "@scope/a".to_string(),
861                    is_internal_dependency: false,
862                },
863                vec!["@scope/b".to_string()], // "a" depends on "b"
864            ),
865            (
866                WorkspaceInfo {
867                    root: pkg_b,
868                    name: "@scope/b".to_string(),
869                    is_internal_dependency: false,
870                },
871                vec!["lodash".to_string()], // "b" depends on external only
872            ),
873        ];
874
875        mark_internal_dependencies(&mut workspaces);
876
877        let ws_a = workspaces
878            .iter()
879            .find(|(ws, _)| ws.name == "@scope/a")
880            .unwrap();
881        assert!(
882            !ws_a.0.is_internal_dependency,
883            "a is not depended on by others"
884        );
885
886        let ws_b = workspaces
887            .iter()
888            .find(|(ws, _)| ws.name == "@scope/b")
889            .unwrap();
890        assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
891    }
892
893    #[test]
894    fn mark_internal_deps_no_cross_references() {
895        let temp_dir = tempfile::tempdir().expect("create temp dir");
896        let pkg_a = temp_dir.path().join("a");
897        let pkg_b = temp_dir.path().join("b");
898        std::fs::create_dir_all(&pkg_a).unwrap();
899        std::fs::create_dir_all(&pkg_b).unwrap();
900
901        let mut workspaces = vec![
902            (
903                WorkspaceInfo {
904                    root: pkg_a,
905                    name: "a".to_string(),
906                    is_internal_dependency: false,
907                },
908                vec!["react".to_string()],
909            ),
910            (
911                WorkspaceInfo {
912                    root: pkg_b,
913                    name: "b".to_string(),
914                    is_internal_dependency: false,
915                },
916                vec!["lodash".to_string()],
917            ),
918        ];
919
920        mark_internal_dependencies(&mut workspaces);
921
922        assert!(!workspaces[0].0.is_internal_dependency);
923        assert!(!workspaces[1].0.is_internal_dependency);
924    }
925
926    #[test]
927    fn mark_internal_deps_deduplicates_by_path() {
928        let temp_dir = tempfile::tempdir().expect("create temp dir");
929        let pkg_a = temp_dir.path().join("a");
930        std::fs::create_dir_all(&pkg_a).unwrap();
931
932        let mut workspaces = vec![
933            (
934                WorkspaceInfo {
935                    root: pkg_a.clone(),
936                    name: "a".to_string(),
937                    is_internal_dependency: false,
938                },
939                vec![],
940            ),
941            (
942                WorkspaceInfo {
943                    root: pkg_a,
944                    name: "a".to_string(),
945                    is_internal_dependency: false,
946                },
947                vec![],
948            ),
949        ];
950
951        mark_internal_dependencies(&mut workspaces);
952        assert_eq!(
953            workspaces.len(),
954            1,
955            "duplicate paths should be deduplicated"
956        );
957    }
958
959    #[test]
960    fn collect_patterns_from_package_json() {
961        let dir = tempfile::tempdir().expect("create temp dir");
962        std::fs::write(
963            dir.path().join("package.json"),
964            r#"{"workspaces": ["packages/*", "apps/*"]}"#,
965        )
966        .unwrap();
967
968        let patterns = collect_workspace_patterns(dir.path()).expect("valid root package.json");
969        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
970    }
971
972    #[test]
973    fn collect_patterns_from_pnpm_workspace() {
974        let dir = tempfile::tempdir().expect("create temp dir");
975        std::fs::write(
976            dir.path().join("pnpm-workspace.yaml"),
977            "packages:\n  - 'packages/*'\n  - 'libs/*'\n",
978        )
979        .unwrap();
980
981        let patterns = collect_workspace_patterns(dir.path()).expect("no root package.json");
982        assert_eq!(patterns, vec!["packages/*", "libs/*"]);
983    }
984
985    #[test]
986    fn collect_patterns_combines_sources() {
987        let dir = tempfile::tempdir().expect("create temp dir");
988        std::fs::write(
989            dir.path().join("package.json"),
990            r#"{"workspaces": ["packages/*"]}"#,
991        )
992        .unwrap();
993        std::fs::write(
994            dir.path().join("pnpm-workspace.yaml"),
995            "packages:\n  - 'apps/*'\n",
996        )
997        .unwrap();
998
999        let patterns = collect_workspace_patterns(dir.path()).expect("valid root package.json");
1000        assert!(patterns.contains(&"packages/*".to_string()));
1001        assert!(patterns.contains(&"apps/*".to_string()));
1002    }
1003
1004    #[test]
1005    fn collect_patterns_empty_when_no_configs() {
1006        let dir = tempfile::tempdir().expect("create temp dir");
1007        let patterns = collect_workspace_patterns(dir.path()).expect("no root package.json");
1008        assert!(patterns.is_empty());
1009    }
1010
1011    #[test]
1012    fn discover_workspaces_from_package_json() {
1013        let dir = tempfile::tempdir().expect("create temp dir");
1014        let pkg_a = dir.path().join("packages").join("a");
1015        let pkg_b = dir.path().join("packages").join("b");
1016        std::fs::create_dir_all(&pkg_a).unwrap();
1017        std::fs::create_dir_all(&pkg_b).unwrap();
1018
1019        std::fs::write(
1020            dir.path().join("package.json"),
1021            r#"{"workspaces": ["packages/*"]}"#,
1022        )
1023        .unwrap();
1024        std::fs::write(
1025            pkg_a.join("package.json"),
1026            r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
1027        )
1028        .unwrap();
1029        std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
1030
1031        let workspaces = discover_workspaces(dir.path());
1032        assert_eq!(workspaces.len(), 2);
1033
1034        let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
1035        assert!(!ws_a.is_internal_dependency);
1036
1037        let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
1038        assert!(ws_b.is_internal_dependency, "b is depended on by a");
1039    }
1040
1041    #[test]
1042    fn discover_workspaces_empty_project() {
1043        let dir = tempfile::tempdir().expect("create temp dir");
1044        let workspaces = discover_workspaces(dir.path());
1045        assert!(workspaces.is_empty());
1046    }
1047
1048    #[test]
1049    fn discover_workspaces_falls_back_to_shallow_packages_without_workspace_config() {
1050        let dir = tempfile::tempdir().expect("create temp dir");
1051        let benchmarks = dir.path().join("benchmarks");
1052        let vscode = dir.path().join("editors").join("vscode");
1053        let deep = dir.path().join("tests").join("fixtures").join("demo");
1054        std::fs::create_dir_all(&benchmarks).unwrap();
1055        std::fs::create_dir_all(&vscode).unwrap();
1056        std::fs::create_dir_all(&deep).unwrap();
1057
1058        std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1059        std::fs::write(vscode.join("package.json"), r#"{"name": "fallow-vscode"}"#).unwrap();
1060        std::fs::write(deep.join("package.json"), r#"{"name": "deep-fixture"}"#).unwrap();
1061
1062        let workspaces = discover_workspaces(dir.path());
1063        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1064
1065        assert!(
1066            names.contains(&"benchmarks"),
1067            "top-level nested package should be discovered: {workspaces:?}"
1068        );
1069        assert!(
1070            names.contains(&"fallow-vscode"),
1071            "second-level nested package should be discovered: {workspaces:?}"
1072        );
1073        assert!(
1074            !names.contains(&"deep-fixture"),
1075            "fallback should stay shallow and skip deep fixtures: {workspaces:?}"
1076        );
1077    }
1078
1079    #[test]
1080    fn discover_workspaces_with_negated_patterns() {
1081        let dir = tempfile::tempdir().expect("create temp dir");
1082        let pkg_a = dir.path().join("packages").join("a");
1083        let pkg_test = dir.path().join("packages").join("test-utils");
1084        std::fs::create_dir_all(&pkg_a).unwrap();
1085        std::fs::create_dir_all(&pkg_test).unwrap();
1086
1087        std::fs::write(
1088            dir.path().join("package.json"),
1089            r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
1090        )
1091        .unwrap();
1092        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1093        std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
1094
1095        let workspaces = discover_workspaces(dir.path());
1096        assert_eq!(workspaces.len(), 1);
1097        assert_eq!(workspaces[0].name, "a");
1098    }
1099
1100    #[test]
1101    fn discover_workspaces_skips_root_as_workspace() {
1102        let dir = tempfile::tempdir().expect("create temp dir");
1103        std::fs::write(
1104            dir.path().join("pnpm-workspace.yaml"),
1105            "packages:\n  - '.'\n",
1106        )
1107        .unwrap();
1108        std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
1109
1110        let workspaces = discover_workspaces(dir.path());
1111        assert!(
1112            workspaces.is_empty(),
1113            "root directory should not be added as workspace"
1114        );
1115    }
1116
1117    #[test]
1118    fn discover_workspaces_name_fallback_to_dir_name() {
1119        let dir = tempfile::tempdir().expect("create temp dir");
1120        let pkg_a = dir.path().join("packages").join("my-app");
1121        std::fs::create_dir_all(&pkg_a).unwrap();
1122
1123        std::fs::write(
1124            dir.path().join("package.json"),
1125            r#"{"workspaces": ["packages/*"]}"#,
1126        )
1127        .unwrap();
1128        std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
1129
1130        let workspaces = discover_workspaces(dir.path());
1131        assert_eq!(workspaces.len(), 1);
1132        assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
1133    }
1134
1135    #[test]
1136    fn discover_workspaces_explicit_patterns_disable_shallow_fallback() {
1137        let dir = tempfile::tempdir().expect("create temp dir");
1138        let pkg_a = dir.path().join("packages").join("a");
1139        let benchmarks = dir.path().join("benchmarks");
1140        std::fs::create_dir_all(&pkg_a).unwrap();
1141        std::fs::create_dir_all(&benchmarks).unwrap();
1142
1143        std::fs::write(
1144            dir.path().join("package.json"),
1145            r#"{"workspaces": ["packages/*"]}"#,
1146        )
1147        .unwrap();
1148        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1149        std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1150
1151        let workspaces = discover_workspaces(dir.path());
1152        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1153
1154        assert_eq!(workspaces.len(), 1);
1155        assert!(names.contains(&"a"));
1156        assert!(
1157            !names.contains(&"benchmarks"),
1158            "explicit workspace config should keep undeclared packages out: {workspaces:?}"
1159        );
1160    }
1161
1162    #[test]
1163    fn discover_workspaces_recovers_package_under_bare_glob_intermediate() {
1164        // Issue #842 (reporter metrists/metrists): root declares
1165        // `["./packages/*", "./themes/*"]`, but the real package lives two levels
1166        // deep at `packages/themes/metrists-theme-next` while `packages/themes`
1167        // itself has no package.json. The single-level glob only matches the bare
1168        // `packages/themes`; without recovery the deep package is never discovered,
1169        // its files fall back to the root manifest, and its declared deps (react)
1170        // are reported as unlisted. Discovery must recover the named deep package.
1171        let dir = tempfile::tempdir().expect("create temp dir");
1172        let theme = dir
1173            .path()
1174            .join("packages")
1175            .join("themes")
1176            .join("metrists-theme-next");
1177        std::fs::create_dir_all(&theme).unwrap();
1178        std::fs::write(
1179            dir.path().join("package.json"),
1180            r#"{"name": "metrists-monorepo", "workspaces": ["./packages/*", "./themes/*"]}"#,
1181        )
1182        .unwrap();
1183        // packages/themes intentionally has NO package.json (bare grouping dir).
1184        std::fs::write(
1185            theme.join("package.json"),
1186            r#"{"name": "metrists-theme-next", "dependencies": {"react": "^18"}}"#,
1187        )
1188        .unwrap();
1189
1190        let workspaces = discover_workspaces(dir.path());
1191        assert!(
1192            workspaces.iter().any(|ws| ws.name == "metrists-theme-next"),
1193            "deep package under a bare glob-matched intermediate must be discovered: {workspaces:?}"
1194        );
1195    }
1196
1197    #[test]
1198    fn undeclared_workspace_detected() {
1199        let dir = tempfile::tempdir().expect("create temp dir");
1200        let pkg_a = dir.path().join("packages").join("a");
1201        let pkg_b = dir.path().join("packages").join("b");
1202        std::fs::create_dir_all(&pkg_a).unwrap();
1203        std::fs::create_dir_all(&pkg_b).unwrap();
1204
1205        std::fs::write(
1206            dir.path().join("package.json"),
1207            r#"{"workspaces": ["packages/a"]}"#,
1208        )
1209        .unwrap();
1210        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1211        std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1212
1213        let declared = discover_workspaces(dir.path());
1214        assert_eq!(declared.len(), 1);
1215
1216        let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1217        assert_eq!(undeclared.len(), 1);
1218        assert!(
1219            undeclared[0]
1220                .path
1221                .to_string_lossy()
1222                .replace('\\', "/")
1223                .contains("packages/b"),
1224            "should detect packages/b as undeclared: {:?}",
1225            undeclared[0].path
1226        );
1227    }
1228
1229    #[test]
1230    fn no_undeclared_when_all_covered() {
1231        let dir = tempfile::tempdir().expect("create temp dir");
1232        let pkg_a = dir.path().join("packages").join("a");
1233        std::fs::create_dir_all(&pkg_a).unwrap();
1234
1235        std::fs::write(
1236            dir.path().join("package.json"),
1237            r#"{"workspaces": ["packages/*"]}"#,
1238        )
1239        .unwrap();
1240        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1241
1242        let declared = discover_workspaces(dir.path());
1243        let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1244        assert!(undeclared.is_empty());
1245    }
1246
1247    #[test]
1248    fn no_undeclared_when_no_workspace_patterns() {
1249        let dir = tempfile::tempdir().expect("create temp dir");
1250        let sub = dir.path().join("lib");
1251        std::fs::create_dir_all(&sub).unwrap();
1252
1253        std::fs::write(dir.path().join("package.json"), r#"{"name": "app"}"#).unwrap();
1254        std::fs::write(sub.join("package.json"), r#"{"name": "lib"}"#).unwrap();
1255
1256        let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1257        assert!(
1258            undeclared.is_empty(),
1259            "should skip check when no workspace patterns exist"
1260        );
1261    }
1262
1263    #[test]
1264    fn undeclared_skips_node_modules_and_hidden_dirs() {
1265        let dir = tempfile::tempdir().expect("create temp dir");
1266        let nm = dir.path().join("node_modules").join("some-pkg");
1267        let hidden = dir.path().join(".hidden");
1268        std::fs::create_dir_all(&nm).unwrap();
1269        std::fs::create_dir_all(&hidden).unwrap();
1270
1271        std::fs::write(
1272            dir.path().join("package.json"),
1273            r#"{"workspaces": ["packages/*"]}"#,
1274        )
1275        .unwrap();
1276        std::fs::write(nm.join("package.json"), r#"{"name": "nm-pkg"}"#).unwrap();
1277        std::fs::write(hidden.join("package.json"), r#"{"name": "hidden"}"#).unwrap();
1278
1279        let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1280        assert!(
1281            undeclared.is_empty(),
1282            "should not flag node_modules or hidden directories"
1283        );
1284    }
1285
1286    fn build_globset(patterns: &[&str]) -> globset::GlobSet {
1287        let mut builder = globset::GlobSetBuilder::new();
1288        for pattern in patterns {
1289            builder.add(globset::Glob::new(pattern).expect("valid glob"));
1290        }
1291        builder.build().expect("build globset")
1292    }
1293
1294    #[test]
1295    fn undeclared_skips_dirs_matching_ignore_patterns() {
1296        let dir = tempfile::tempdir().expect("create temp dir");
1297        let pkg_a = dir.path().join("packages").join("a");
1298        let vitest_ref = dir.path().join("references").join("vitest");
1299        let tanstack_ref = dir.path().join("references").join("tanstack-router");
1300        std::fs::create_dir_all(&pkg_a).unwrap();
1301        std::fs::create_dir_all(&vitest_ref).unwrap();
1302        std::fs::create_dir_all(&tanstack_ref).unwrap();
1303
1304        std::fs::write(
1305            dir.path().join("package.json"),
1306            r#"{"workspaces": ["packages/*"]}"#,
1307        )
1308        .unwrap();
1309        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1310        std::fs::write(
1311            vitest_ref.join("package.json"),
1312            r#"{"name": "vitest-reference"}"#,
1313        )
1314        .unwrap();
1315        std::fs::write(
1316            tanstack_ref.join("package.json"),
1317            r#"{"name": "tanstack-reference"}"#,
1318        )
1319        .unwrap();
1320
1321        let declared = discover_workspaces(dir.path());
1322        let ignore = build_globset(&["references/*"]);
1323        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1324        assert!(
1325            undeclared.is_empty(),
1326            "references/* should be ignored: {undeclared:?}"
1327        );
1328    }
1329
1330    #[test]
1331    fn undeclared_still_reported_when_ignore_does_not_match() {
1332        let dir = tempfile::tempdir().expect("create temp dir");
1333        let pkg_b = dir.path().join("packages").join("b");
1334        std::fs::create_dir_all(&pkg_b).unwrap();
1335
1336        std::fs::write(
1337            dir.path().join("package.json"),
1338            r#"{"workspaces": ["packages/a"]}"#,
1339        )
1340        .unwrap();
1341        std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1342
1343        let declared = discover_workspaces(dir.path());
1344        let ignore = build_globset(&["references/*"]);
1345        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1346        assert_eq!(
1347            undeclared.len(),
1348            1,
1349            "non-matching ignore patterns should not silence other undeclared dirs"
1350        );
1351    }
1352
1353    #[test]
1354    fn undeclared_skips_dirs_matching_package_json_glob() {
1355        let dir = tempfile::tempdir().expect("create temp dir");
1356        let pkg_a = dir.path().join("packages").join("a");
1357        let vitest_ref = dir.path().join("references").join("vitest");
1358        std::fs::create_dir_all(&pkg_a).unwrap();
1359        std::fs::create_dir_all(&vitest_ref).unwrap();
1360
1361        std::fs::write(
1362            dir.path().join("package.json"),
1363            r#"{"workspaces": ["packages/*"]}"#,
1364        )
1365        .unwrap();
1366        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1367        std::fs::write(
1368            vitest_ref.join("package.json"),
1369            r#"{"name": "vitest-reference"}"#,
1370        )
1371        .unwrap();
1372
1373        let declared = discover_workspaces(dir.path());
1374        let ignore = build_globset(&["references/*/package.json"]);
1375        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1376        assert!(
1377            undeclared.is_empty(),
1378            "package.json-suffixed glob should silence the warning: {undeclared:?}"
1379        );
1380    }
1381
1382    #[test]
1383    fn undeclared_skips_dirs_matching_doublestar_ignore() {
1384        let dir = tempfile::tempdir().expect("create temp dir");
1385        let pkg_a = dir.path().join("packages").join("a");
1386        let nested_ref = dir.path().join("references").join("vitest");
1387        std::fs::create_dir_all(&pkg_a).unwrap();
1388        std::fs::create_dir_all(&nested_ref).unwrap();
1389
1390        std::fs::write(
1391            dir.path().join("package.json"),
1392            r#"{"workspaces": ["packages/*"]}"#,
1393        )
1394        .unwrap();
1395        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1396        std::fs::write(
1397            nested_ref.join("package.json"),
1398            r#"{"name": "vitest-reference"}"#,
1399        )
1400        .unwrap();
1401
1402        let declared = discover_workspaces(dir.path());
1403        let ignore = build_globset(&["**/references/**"]);
1404        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1405        assert!(
1406            undeclared.is_empty(),
1407            "**/references/** should ignore nested package.json dirs: {undeclared:?}"
1408        );
1409    }
1410
1411    #[test]
1412    fn malformed_workspace_package_json_emits_diagnostic() {
1413        let dir = tempfile::tempdir().expect("create temp dir");
1414        let pkg_a = dir.path().join("packages").join("a");
1415        let pkg_bad = dir.path().join("packages").join("bad");
1416        std::fs::create_dir_all(&pkg_a).unwrap();
1417        std::fs::create_dir_all(&pkg_bad).unwrap();
1418        std::fs::write(
1419            dir.path().join("package.json"),
1420            r#"{"workspaces": ["packages/*"]}"#,
1421        )
1422        .unwrap();
1423        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1424        std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad",}"#).unwrap();
1425
1426        let (result, captured) = capture_workspace_warnings(|| {
1427            discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1428        });
1429        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1430
1431        assert_eq!(workspaces.len(), 1, "the valid workspace still discovers");
1432        assert_eq!(workspaces[0].name, "a");
1433        assert_eq!(diagnostics.len(), 1);
1434        assert!(matches!(
1435            diagnostics[0].kind,
1436            WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1437        ));
1438        assert!(
1439            captured
1440                .iter()
1441                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1442        );
1443    }
1444
1445    #[test]
1446    fn malformed_bridge_deno_config_emits_recoverable_member_diagnostic() {
1447        let dir = tempfile::tempdir().expect("create temp dir");
1448        let pkg_good = dir.path().join("packages/good");
1449        let pkg_bad = dir.path().join("packages/bad");
1450        std::fs::create_dir_all(&pkg_good).unwrap();
1451        std::fs::create_dir_all(&pkg_bad).unwrap();
1452        std::fs::write(
1453            dir.path().join("package.json"),
1454            r#"{"workspaces": ["packages/*"]}"#,
1455        )
1456        .unwrap();
1457        std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1458        std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad"}"#).unwrap();
1459        std::fs::write(pkg_bad.join("deno.jsonc"), "{ imports: [ }").unwrap();
1460
1461        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1462        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1463
1464        assert_eq!(workspaces.len(), 1, "valid sibling should still discover");
1465        assert_eq!(workspaces[0].name, "good");
1466        assert_eq!(diagnostics.len(), 1);
1467        assert!(matches!(
1468            diagnostics[0].kind,
1469            WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1470        ));
1471        assert!(diagnostics[0].message.contains("deno.jsonc"));
1472    }
1473
1474    /// A member reached through two workspace sources (npm glob + tsconfig
1475    /// reference) is diagnosed, and diagnosed ONCE: the two sources produce
1476    /// byte-identical diagnostics, so the discovery fold reports the member
1477    /// once where every envelope used to report it twice (issue #2366).
1478    ///
1479    /// That the tsconfig leg replayed the memoized `Err` rather than silently
1480    /// resolving is NOT observable here (both `Ok(None)` and `Err` fall back
1481    /// to the directory name, and the fold hides a second diagnostic);
1482    /// [`manifest_cache_replays_the_same_err_on_every_visit`] pins it.
1483    #[test]
1484    fn malformed_member_reached_via_two_sources_is_diagnosed_once() {
1485        let dir = tempfile::tempdir().expect("create temp dir");
1486        let pkg_good = dir.path().join("packages").join("good");
1487        let pkg_bad = dir.path().join("packages").join("bad");
1488        std::fs::create_dir_all(&pkg_good).unwrap();
1489        std::fs::create_dir_all(&pkg_bad).unwrap();
1490        std::fs::write(
1491            dir.path().join("package.json"),
1492            r#"{"workspaces": ["packages/*"]}"#,
1493        )
1494        .unwrap();
1495        std::fs::write(
1496            dir.path().join("tsconfig.json"),
1497            r#"{"references": [{"path": "./packages/bad"}]}"#,
1498        )
1499        .unwrap();
1500        std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1501        std::fs::write(pkg_bad.join("package.json"), r"{,}").unwrap();
1502
1503        let (result, _) = capture_workspace_warnings(|| {
1504            discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1505        });
1506        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1507
1508        assert!(
1509            workspaces.iter().any(|w| w.name == "good"),
1510            "valid sibling should still discover: {workspaces:?}"
1511        );
1512        assert!(
1513            workspaces.iter().any(|w| w.name == "bad"),
1514            "tsconfig reference falls back to the directory name on manifest error"
1515        );
1516        let malformed: Vec<_> = diagnostics
1517            .iter()
1518            .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1519            .collect();
1520        assert_eq!(
1521            malformed.len(),
1522            1,
1523            "the replayed Err is one diagnostic, not one per source: {diagnostics:?}"
1524        );
1525        assert!(
1526            malformed[0].path.ends_with("bad"),
1527            "the diagnostic points at the malformed member"
1528        );
1529    }
1530
1531    /// The per-discovery [`ManifestCache`] memoizes `Err` outcomes and every
1532    /// repeat visit must get the same `Err` back. Both callers that can reach
1533    /// one member ([`register_matched_workspace`] for an npm glob and
1534    /// [`load_tsconfig_workspace_package`] for a tsconfig reference) diagnose
1535    /// from the outcome the cache hands them, so a replay that degraded to
1536    /// `Ok(None)` would silently drop the diagnostic on whichever source is
1537    /// second. No end-to-end assertion can witness that: `Ok(None)` and `Err`
1538    /// both fall back to the directory name for the workspace, and the
1539    /// discovery fold collapses the two byte-identical diagnostics into one
1540    /// either way.
1541    #[test]
1542    fn manifest_cache_replays_the_same_err_on_every_visit() {
1543        let dir = tempfile::tempdir().expect("create temp dir");
1544        let member = dir.path().join("packages").join("bad");
1545        std::fs::create_dir_all(&member).unwrap();
1546        std::fs::write(member.join("package.json"), r"{,}").unwrap();
1547
1548        let mut cache = ManifestCache::default();
1549        let first = load_member_package_manifest_cached(&member, &mut cache)
1550            .expect_err("a malformed member manifest is an Err");
1551        assert_eq!(cache.len(), 1, "the outcome is memoized: {cache:?}");
1552
1553        std::fs::remove_file(member.join("package.json")).unwrap();
1554        let replayed = load_member_package_manifest_cached(&member, &mut cache)
1555            .expect_err("the memoized Err replays instead of re-reading the directory");
1556        assert_eq!(
1557            replayed, first,
1558            "the replayed outcome is byte-identical to the first, so both \
1559             workspace sources diagnose the same member the same way"
1560        );
1561    }
1562
1563    #[test]
1564    fn multiple_malformed_workspace_package_jsons_all_diagnosed() {
1565        let dir = tempfile::tempdir().expect("create temp dir");
1566        for name in ["a", "b", "c"] {
1567            let pkg = dir.path().join("packages").join(name);
1568            std::fs::create_dir_all(&pkg).unwrap();
1569            std::fs::write(pkg.join("package.json"), r"{,}").unwrap();
1570        }
1571        std::fs::write(
1572            dir.path().join("package.json"),
1573            r#"{"workspaces": ["packages/*"]}"#,
1574        )
1575        .unwrap();
1576
1577        let (result, _) = capture_workspace_warnings(|| {
1578            discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1579        });
1580        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1581
1582        assert!(workspaces.is_empty(), "all three malformed; nothing valid");
1583        assert_eq!(diagnostics.len(), 3, "each malformed workspace surfaces");
1584        assert!(
1585            diagnostics
1586                .iter()
1587                .all(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
1588            "every diagnostic should be malformed-package-json"
1589        );
1590    }
1591
1592    #[test]
1593    fn malformed_root_package_json_returns_load_error() {
1594        let dir = tempfile::tempdir().expect("create temp dir");
1595        std::fs::write(dir.path().join("package.json"), "this is not json").unwrap();
1596
1597        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1598
1599        match result {
1600            Err(WorkspaceLoadError::MalformedRootPackageJson { path, error }) => {
1601                assert!(path.ends_with("package.json"));
1602                assert!(!error.is_empty(), "underlying parse error is preserved");
1603            }
1604            other => panic!("expected MalformedRootPackageJson, got {other:?}"),
1605        }
1606    }
1607
1608    #[test]
1609    fn glob_match_without_package_json_emits_diagnostic_unless_skip_listed() {
1610        let dir = tempfile::tempdir().expect("create temp dir");
1611        let pkg_a = dir.path().join("packages").join("a");
1612        let cache_dir = dir.path().join("packages").join(".cache");
1613        let scratch_dir = dir.path().join("packages").join("scratch");
1614        std::fs::create_dir_all(&pkg_a).unwrap();
1615        std::fs::create_dir_all(&cache_dir).unwrap();
1616        std::fs::create_dir_all(&scratch_dir).unwrap();
1617        std::fs::write(
1618            dir.path().join("package.json"),
1619            r#"{"workspaces": ["packages/*"]}"#,
1620        )
1621        .unwrap();
1622        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1623
1624        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1625        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1626
1627        assert_eq!(workspaces.len(), 1);
1628        let kinds: Vec<&str> = diagnostics.iter().map(|d| d.kind.id()).collect();
1629        assert!(
1630            kinds.contains(&"glob-matched-no-package-json"),
1631            "scratch should diagnose: {kinds:?}"
1632        );
1633        assert!(
1634            !diagnostics.iter().any(|d| d.path.ends_with(".cache")),
1635            ".cache must be skip-listed: {diagnostics:?}"
1636        );
1637    }
1638
1639    #[test]
1640    fn glob_match_without_package_json_honors_ignore_patterns() {
1641        let dir = tempfile::tempdir().expect("create temp dir");
1642        let pkg_a = dir.path().join("packages").join("a");
1643        let legacy_dir = dir.path().join("packages").join("legacy");
1644        std::fs::create_dir_all(&pkg_a).unwrap();
1645        std::fs::create_dir_all(&legacy_dir).unwrap();
1646        std::fs::write(
1647            dir.path().join("package.json"),
1648            r#"{"workspaces": ["packages/*"]}"#,
1649        )
1650        .unwrap();
1651        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1652
1653        let mut builder = globset::GlobSetBuilder::new();
1654        builder.add(globset::Glob::new("packages/legacy").unwrap());
1655        let ignore = builder.build().unwrap();
1656
1657        let result = discover_workspaces_with_diagnostics(dir.path(), &ignore);
1658        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1659
1660        assert_eq!(workspaces.len(), 1);
1661        assert!(
1662            diagnostics.is_empty(),
1663            "user-excluded path must not produce a diagnostic: {diagnostics:?}"
1664        );
1665    }
1666
1667    #[test]
1668    fn malformed_tsconfig_emits_diagnostic() {
1669        let dir = tempfile::tempdir().expect("create temp dir");
1670        std::fs::write(
1671            dir.path().join("package.json"),
1672            r#"{"workspaces": ["packages/*"]}"#,
1673        )
1674        .unwrap();
1675        std::fs::write(dir.path().join("tsconfig.json"), r#"{"references": [,,,]}"#).unwrap();
1676
1677        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1678        let (_, diagnostics) = result.expect("root package.json is valid");
1679
1680        assert!(
1681            diagnostics
1682                .iter()
1683                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
1684            "expected MalformedTsconfig diagnostic; got: {diagnostics:?}"
1685        );
1686    }
1687
1688    #[test]
1689    fn tsconfig_missing_reference_dir_emits_diagnostic() {
1690        let dir = tempfile::tempdir().expect("create temp dir");
1691        std::fs::write(
1692            dir.path().join("tsconfig.json"),
1693            r#"{"references": [{"path": "./packages/missing"}]}"#,
1694        )
1695        .unwrap();
1696
1697        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1698        let (_, diagnostics) = result.expect("no package.json at root is OK");
1699
1700        assert!(
1701            diagnostics
1702                .iter()
1703                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::TsconfigReferenceDirMissing)),
1704            "expected TsconfigReferenceDirMissing; got: {diagnostics:?}"
1705        );
1706    }
1707
1708    #[test]
1709    fn missing_tsconfig_is_silent() {
1710        let dir = tempfile::tempdir().expect("create temp dir");
1711
1712        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1713        let (_, diagnostics) = result.expect("no root package.json is OK");
1714
1715        assert!(
1716            !diagnostics
1717                .iter()
1718                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
1719            "missing tsconfig must not produce MalformedTsconfig: {diagnostics:?}"
1720        );
1721    }
1722
1723    #[test]
1724    fn shallow_scan_malformed_package_json_stays_silent() {
1725        let dir = tempfile::tempdir().expect("create temp dir");
1726        let scratch = dir.path().join("scratch");
1727        std::fs::create_dir_all(&scratch).unwrap();
1728        std::fs::write(scratch.join("package.json"), r"{not valid json}").unwrap();
1729
1730        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1731        let (_, diagnostics) = result.expect("no root package.json is OK");
1732
1733        assert!(
1734            !diagnostics
1735                .iter()
1736                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
1737            "shallow-scan malformed must stay silent: {diagnostics:?}"
1738        );
1739    }
1740
1741    #[test]
1742    fn mixed_valid_and_malformed_workspaces_partial_recovery() {
1743        let dir = tempfile::tempdir().expect("create temp dir");
1744        let pkg_good = dir.path().join("packages").join("good");
1745        let pkg_bad = dir.path().join("packages").join("bad");
1746        std::fs::create_dir_all(&pkg_good).unwrap();
1747        std::fs::create_dir_all(&pkg_bad).unwrap();
1748        std::fs::write(
1749            dir.path().join("package.json"),
1750            r#"{"workspaces": ["packages/*"]}"#,
1751        )
1752        .unwrap();
1753        std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1754        std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
1755
1756        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1757        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1758
1759        assert_eq!(workspaces.len(), 1);
1760        assert_eq!(workspaces[0].name, "good");
1761        assert_eq!(diagnostics.len(), 1);
1762        assert_eq!(diagnostics[0].kind.id(), "malformed-package-json");
1763    }
1764
1765    #[test]
1766    fn discover_workspaces_back_compat_drops_diagnostics_and_errors() {
1767        let dir = tempfile::tempdir().expect("create temp dir");
1768        std::fs::write(dir.path().join("package.json"), r"{bad json").unwrap();
1769
1770        let workspaces = discover_workspaces(dir.path());
1771        assert!(
1772            workspaces.is_empty(),
1773            "back-compat wrapper returns empty on root-malformed: {workspaces:?}"
1774        );
1775    }
1776
1777    #[test]
1778    fn discovers_deno_workspace_members_without_package_json() {
1779        let dir = tempfile::tempdir().expect("create temp dir");
1780        let core = dir.path().join("packages").join("core");
1781        let app = dir.path().join("apps").join("desktop");
1782        std::fs::create_dir_all(&core).unwrap();
1783        std::fs::create_dir_all(&app).unwrap();
1784
1785        std::fs::write(
1786            dir.path().join("deno.json"),
1787            r#"{
1788              "workspace": ["./apps/*", "./packages/*"],
1789              "imports": { "@std/assert": "jsr:@std/assert@1" }
1790            }"#,
1791        )
1792        .unwrap();
1793        std::fs::write(
1794            core.join("deno.json"),
1795            r#"{
1796              "name": "@fallow/core",
1797              "exports": { ".": "./mod.ts", "./result": "./result.ts" }
1798            }"#,
1799        )
1800        .unwrap();
1801        std::fs::write(
1802            app.join("deno.json"),
1803            r#"{ "name": "@fallow/desktop", "exports": { ".": "./main.ts" } }"#,
1804        )
1805        .unwrap();
1806
1807        let workspaces = discover_workspaces(dir.path());
1808        let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
1809        names.sort_unstable();
1810        assert_eq!(names, vec!["@fallow/core", "@fallow/desktop"]);
1811        assert!(
1812            workspaces
1813                .iter()
1814                .all(|w| !w.root.join("package.json").exists())
1815        );
1816        assert!(
1817            workspaces.iter().all(|w| !w.is_internal_dependency),
1818            "Deno packages need dependency evidence before inventory marks them internal"
1819        );
1820    }
1821
1822    #[test]
1823    fn malformed_root_deno_config_returns_load_error() {
1824        let dir = tempfile::tempdir().expect("create temp dir");
1825        std::fs::write(dir.path().join("deno.jsonc"), "{ workspace: [ }").unwrap();
1826
1827        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1828
1829        match result {
1830            Err(WorkspaceLoadError::MalformedRootDenoConfig { path, error }) => {
1831                assert!(path.ends_with("deno.jsonc"));
1832                assert!(!error.is_empty(), "underlying parse error is preserved");
1833            }
1834            other => panic!("expected MalformedRootDenoConfig, got {other:?}"),
1835        }
1836    }
1837
1838    #[test]
1839    fn deno_and_npm_workspace_patterns_are_additive() {
1840        let dir = tempfile::tempdir().expect("create temp dir");
1841        let a = dir.path().join("packages").join("a");
1842        let b = dir.path().join("packages").join("b");
1843        std::fs::create_dir_all(&a).unwrap();
1844        std::fs::create_dir_all(&b).unwrap();
1845
1846        std::fs::write(
1847            dir.path().join("package.json"),
1848            r#"{"workspaces": ["packages/a"]}"#,
1849        )
1850        .unwrap();
1851        std::fs::write(a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1852        std::fs::write(
1853            dir.path().join("deno.json"),
1854            r#"{"workspace": ["./packages/b"]}"#,
1855        )
1856        .unwrap();
1857        std::fs::write(b.join("deno.json"), r#"{"name": "b"}"#).unwrap();
1858
1859        let workspaces = discover_workspaces(dir.path());
1860        let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
1861        names.sort_unstable();
1862        assert_eq!(names, vec!["a", "b"]);
1863    }
1864
1865    /// Issue #2366: `package.json` `workspaces` and `pnpm-workspace.yaml`
1866    /// `packages` are additive sources, so one glob declared in both is walked
1867    /// twice. Deduplicating at the discovery choke point is what lets the JSON
1868    /// envelopes report one entry per distinct matching pattern and lets the
1869    /// aggregated stderr warning count the directories it actually found.
1870    #[test]
1871    fn one_glob_declared_in_two_manifests_reports_each_directory_once() {
1872        let dir = tempfile::tempdir().expect("create temp dir");
1873        write_two_manifest_glob_project(dir.path());
1874
1875        let (_, diagnostics) =
1876            discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1877                .expect("root package.json is valid");
1878
1879        let mut reported: Vec<(String, String)> = diagnostics
1880            .iter()
1881            .filter_map(|diagnostic| match &diagnostic.kind {
1882                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => Some((
1883                    pattern.clone(),
1884                    diagnostic
1885                        .path
1886                        .strip_prefix(dir.path())
1887                        .unwrap_or(&diagnostic.path)
1888                        .display()
1889                        .to_string()
1890                        .replace('\\', "/"),
1891                )),
1892                _ => None,
1893            })
1894            .collect();
1895        reported.sort();
1896
1897        assert_eq!(
1898            reported,
1899            vec![
1900                ("pkgs/*".to_owned(), "pkgs/aaa".to_owned()),
1901                ("pkgs/*".to_owned(), "pkgs/bbb".to_owned()),
1902            ],
1903            "one glob spelled two ways is one diagnostic per directory: {diagnostics:?}"
1904        );
1905    }
1906
1907    /// The control for the fold above: the key is the WHOLE kind, payload
1908    /// included. Two overlapping globs each report the same package-less
1909    /// directory with their own `pattern`, and both survive.
1910    #[test]
1911    fn overlapping_globs_still_report_one_directory_once_per_pattern() {
1912        let dir = tempfile::tempdir().expect("create temp dir");
1913        std::fs::create_dir_all(dir.path().join("pkgs/aaa")).unwrap();
1914        std::fs::write(
1915            dir.path().join("package.json"),
1916            r#"{"name":"overlap-root","private":true,"workspaces":["pkgs/*","pkgs/a*"]}"#,
1917        )
1918        .unwrap();
1919        std::fs::write(dir.path().join("pkgs/aaa/readme.txt"), "no package.json\n").unwrap();
1920
1921        let (_, diagnostics) =
1922            discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1923                .expect("root package.json is valid");
1924
1925        let patterns: Vec<&str> = diagnostics
1926            .iter()
1927            .filter_map(|diagnostic| match &diagnostic.kind {
1928                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
1929                    Some(pattern.as_str())
1930                }
1931                _ => None,
1932            })
1933            .collect();
1934
1935        assert_eq!(
1936            patterns,
1937            ["pkgs/*", "pkgs/a*"],
1938            "distinct patterns are distinct diagnostics: {diagnostics:?}"
1939        );
1940    }
1941}