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() || is_skip_listed_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() || is_skip_listed_dir(&child.file_name().to_string_lossy()) {
629            continue;
630        }
631
632        collect_shallow_workspace_candidate(
633            &child_path,
634            canonical_root,
635            workspaces,
636            manifest_cache,
637        );
638    }
639}
640
641fn collect_shallow_workspace_candidate(
642    dir: &Path,
643    canonical_root: &Path,
644    workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
645    manifest_cache: &mut ManifestCache,
646) {
647    let canonical_dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
648    if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
649        return;
650    }
651
652    let Ok(Some((name, _pkg, dep_names))) =
653        load_member_package_manifest_cached(dir, manifest_cache)
654    else {
655        return;
656    };
657
658    workspaces.push((
659        WorkspaceInfo {
660            root: dir.to_path_buf(),
661            name,
662            is_internal_dependency: false,
663        },
664        dep_names,
665    ));
666}
667
668/// Deduplicate workspaces by canonical path and mark internal dependencies.
669///
670/// Overlapping sources (npm workspaces + tsconfig references pointing to the same
671/// directory) are collapsed. npm-discovered entries take precedence (they appear first).
672/// Workspaces depended on by other workspaces are marked as `is_internal_dependency`.
673fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
674    {
675        let mut seen = rustc_hash::FxHashSet::default();
676        workspaces.retain(|(ws, _)| {
677            let canonical = dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone());
678            seen.insert(canonical)
679        });
680    }
681
682    let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
683        .iter()
684        .flat_map(|(_, deps)| deps.iter().cloned())
685        .collect();
686
687    for (ws, _) in &mut *workspaces {
688        ws.is_internal_dependency = all_dep_names.contains(&ws.name);
689    }
690}
691
692/// Extract the directory name as a string, for workspace name fallback.
693fn dir_name(dir: &Path) -> String {
694    dir.file_name()
695        .map(|n| n.to_string_lossy().to_string())
696        .unwrap_or_default()
697}
698
699/// Build the issue-2366 repository shape: one glob declared in both
700/// `package.json` (spelled `./pkgs/*`) and `pnpm-workspace.yaml` (spelled
701/// `pkgs/*`), over two directories that carry no `package.json`.
702///
703/// Shared with the emission tests in `diagnostics.rs`, which assert that the
704/// aggregated stderr warning counts each directory once.
705#[cfg(test)]
706pub fn write_two_manifest_glob_project(root: &Path) {
707    std::fs::create_dir_all(root.join("pkgs/aaa")).unwrap();
708    std::fs::create_dir_all(root.join("pkgs/bbb")).unwrap();
709    std::fs::write(
710        root.join("package.json"),
711        r#"{"name":"two-manifest-root","private":true,"workspaces":["./pkgs/*"]}"#,
712    )
713    .unwrap();
714    std::fs::write(
715        root.join("pnpm-workspace.yaml"),
716        "packages:\n  - \"pkgs/*\"\n",
717    )
718    .unwrap();
719    std::fs::write(root.join("pkgs/aaa/readme.txt"), "no package.json here\n").unwrap();
720    std::fs::write(root.join("pkgs/bbb/readme.txt"), "no package.json here\n").unwrap();
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726
727    #[test]
728    fn public_workspace_selection_supports_exact_names_and_globs() {
729        assert!(!workspace_is_public("@scope/core", &[]));
730        assert!(workspace_is_public(
731            "@scope/core",
732            &["@scope/core".to_string()]
733        ));
734        assert!(workspace_is_public(
735            "@scope/core",
736            &["@scope/*".to_string()]
737        ));
738        assert!(!workspace_is_public(
739            "@scope/core",
740            &["[invalid".to_string(), "@other/*".to_string()]
741        ));
742    }
743
744    #[test]
745    fn discover_workspaces_from_tsconfig_references() {
746        let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
747        let _ = std::fs::remove_dir_all(&temp_dir);
748        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
749        std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
750
751        std::fs::write(
752            temp_dir.join("tsconfig.json"),
753            r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
754        )
755        .unwrap();
756
757        std::fs::write(
758            temp_dir.join("packages/core/package.json"),
759            r#"{"name": "@project/core"}"#,
760        )
761        .unwrap();
762
763        let workspaces = discover_workspaces(&temp_dir);
764        assert_eq!(workspaces.len(), 2);
765        assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
766        assert!(workspaces.iter().any(|ws| ws.name == "ui"));
767
768        let _ = std::fs::remove_dir_all(&temp_dir);
769    }
770
771    #[test]
772    fn tsconfig_references_outside_root_rejected() {
773        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
774        let _ = std::fs::remove_dir_all(&temp_dir);
775        std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
776        std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
777
778        std::fs::write(
779            temp_dir.join("project/tsconfig.json"),
780            r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
781        )
782        .unwrap();
783
784        let workspaces = discover_workspaces(&temp_dir.join("project"));
785        assert_eq!(
786            workspaces.len(),
787            1,
788            "reference outside project root should be rejected: {workspaces:?}"
789        );
790        assert!(
791            workspaces[0]
792                .root
793                .to_string_lossy()
794                .contains("packages/core")
795        );
796
797        let _ = std::fs::remove_dir_all(&temp_dir);
798    }
799
800    #[test]
801    fn dir_name_extracts_last_component() {
802        assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
803        assert_eq!(dir_name(Path::new("/my-app")), "my-app");
804    }
805
806    #[test]
807    fn dir_name_empty_for_root_path() {
808        assert_eq!(dir_name(Path::new("/")), "");
809    }
810
811    #[test]
812    fn workspace_config_deserialize_json() {
813        let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
814        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
815        assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
816    }
817
818    #[test]
819    fn workspace_config_deserialize_empty_patterns() {
820        let json = r#"{"patterns": []}"#;
821        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
822        assert!(config.patterns.is_empty());
823    }
824
825    #[test]
826    fn workspace_config_default_patterns() {
827        let json = "{}";
828        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
829        assert!(config.patterns.is_empty());
830    }
831
832    #[test]
833    fn workspace_info_default_not_internal() {
834        let ws = WorkspaceInfo {
835            root: PathBuf::from("/project/packages/a"),
836            name: "a".to_string(),
837            is_internal_dependency: false,
838        };
839        assert!(!ws.is_internal_dependency);
840    }
841
842    #[test]
843    fn mark_internal_deps_detects_cross_references() {
844        let temp_dir = tempfile::tempdir().expect("create temp dir");
845        let pkg_a = temp_dir.path().join("a");
846        let pkg_b = temp_dir.path().join("b");
847        std::fs::create_dir_all(&pkg_a).unwrap();
848        std::fs::create_dir_all(&pkg_b).unwrap();
849
850        let mut workspaces = vec![
851            (
852                WorkspaceInfo {
853                    root: pkg_a,
854                    name: "@scope/a".to_string(),
855                    is_internal_dependency: false,
856                },
857                vec!["@scope/b".to_string()], // "a" depends on "b"
858            ),
859            (
860                WorkspaceInfo {
861                    root: pkg_b,
862                    name: "@scope/b".to_string(),
863                    is_internal_dependency: false,
864                },
865                vec!["lodash".to_string()], // "b" depends on external only
866            ),
867        ];
868
869        mark_internal_dependencies(&mut workspaces);
870
871        let ws_a = workspaces
872            .iter()
873            .find(|(ws, _)| ws.name == "@scope/a")
874            .unwrap();
875        assert!(
876            !ws_a.0.is_internal_dependency,
877            "a is not depended on by others"
878        );
879
880        let ws_b = workspaces
881            .iter()
882            .find(|(ws, _)| ws.name == "@scope/b")
883            .unwrap();
884        assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
885    }
886
887    #[test]
888    fn mark_internal_deps_no_cross_references() {
889        let temp_dir = tempfile::tempdir().expect("create temp dir");
890        let pkg_a = temp_dir.path().join("a");
891        let pkg_b = temp_dir.path().join("b");
892        std::fs::create_dir_all(&pkg_a).unwrap();
893        std::fs::create_dir_all(&pkg_b).unwrap();
894
895        let mut workspaces = vec![
896            (
897                WorkspaceInfo {
898                    root: pkg_a,
899                    name: "a".to_string(),
900                    is_internal_dependency: false,
901                },
902                vec!["react".to_string()],
903            ),
904            (
905                WorkspaceInfo {
906                    root: pkg_b,
907                    name: "b".to_string(),
908                    is_internal_dependency: false,
909                },
910                vec!["lodash".to_string()],
911            ),
912        ];
913
914        mark_internal_dependencies(&mut workspaces);
915
916        assert!(!workspaces[0].0.is_internal_dependency);
917        assert!(!workspaces[1].0.is_internal_dependency);
918    }
919
920    #[test]
921    fn mark_internal_deps_deduplicates_by_path() {
922        let temp_dir = tempfile::tempdir().expect("create temp dir");
923        let pkg_a = temp_dir.path().join("a");
924        std::fs::create_dir_all(&pkg_a).unwrap();
925
926        let mut workspaces = vec![
927            (
928                WorkspaceInfo {
929                    root: pkg_a.clone(),
930                    name: "a".to_string(),
931                    is_internal_dependency: false,
932                },
933                vec![],
934            ),
935            (
936                WorkspaceInfo {
937                    root: pkg_a,
938                    name: "a".to_string(),
939                    is_internal_dependency: false,
940                },
941                vec![],
942            ),
943        ];
944
945        mark_internal_dependencies(&mut workspaces);
946        assert_eq!(
947            workspaces.len(),
948            1,
949            "duplicate paths should be deduplicated"
950        );
951    }
952
953    #[test]
954    fn collect_patterns_from_package_json() {
955        let dir = tempfile::tempdir().expect("create temp dir");
956        std::fs::write(
957            dir.path().join("package.json"),
958            r#"{"workspaces": ["packages/*", "apps/*"]}"#,
959        )
960        .unwrap();
961
962        let patterns = collect_workspace_patterns(dir.path()).expect("valid root package.json");
963        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
964    }
965
966    #[test]
967    fn collect_patterns_from_pnpm_workspace() {
968        let dir = tempfile::tempdir().expect("create temp dir");
969        std::fs::write(
970            dir.path().join("pnpm-workspace.yaml"),
971            "packages:\n  - 'packages/*'\n  - 'libs/*'\n",
972        )
973        .unwrap();
974
975        let patterns = collect_workspace_patterns(dir.path()).expect("no root package.json");
976        assert_eq!(patterns, vec!["packages/*", "libs/*"]);
977    }
978
979    #[test]
980    fn collect_patterns_combines_sources() {
981        let dir = tempfile::tempdir().expect("create temp dir");
982        std::fs::write(
983            dir.path().join("package.json"),
984            r#"{"workspaces": ["packages/*"]}"#,
985        )
986        .unwrap();
987        std::fs::write(
988            dir.path().join("pnpm-workspace.yaml"),
989            "packages:\n  - 'apps/*'\n",
990        )
991        .unwrap();
992
993        let patterns = collect_workspace_patterns(dir.path()).expect("valid root package.json");
994        assert!(patterns.contains(&"packages/*".to_string()));
995        assert!(patterns.contains(&"apps/*".to_string()));
996    }
997
998    #[test]
999    fn collect_patterns_empty_when_no_configs() {
1000        let dir = tempfile::tempdir().expect("create temp dir");
1001        let patterns = collect_workspace_patterns(dir.path()).expect("no root package.json");
1002        assert!(patterns.is_empty());
1003    }
1004
1005    #[test]
1006    fn discover_workspaces_from_package_json() {
1007        let dir = tempfile::tempdir().expect("create temp dir");
1008        let pkg_a = dir.path().join("packages").join("a");
1009        let pkg_b = dir.path().join("packages").join("b");
1010        std::fs::create_dir_all(&pkg_a).unwrap();
1011        std::fs::create_dir_all(&pkg_b).unwrap();
1012
1013        std::fs::write(
1014            dir.path().join("package.json"),
1015            r#"{"workspaces": ["packages/*"]}"#,
1016        )
1017        .unwrap();
1018        std::fs::write(
1019            pkg_a.join("package.json"),
1020            r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
1021        )
1022        .unwrap();
1023        std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
1024
1025        let workspaces = discover_workspaces(dir.path());
1026        assert_eq!(workspaces.len(), 2);
1027
1028        let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
1029        assert!(!ws_a.is_internal_dependency);
1030
1031        let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
1032        assert!(ws_b.is_internal_dependency, "b is depended on by a");
1033    }
1034
1035    #[test]
1036    fn discover_workspaces_empty_project() {
1037        let dir = tempfile::tempdir().expect("create temp dir");
1038        let workspaces = discover_workspaces(dir.path());
1039        assert!(workspaces.is_empty());
1040    }
1041
1042    #[test]
1043    fn discover_workspaces_falls_back_to_shallow_packages_without_workspace_config() {
1044        let dir = tempfile::tempdir().expect("create temp dir");
1045        let benchmarks = dir.path().join("benchmarks");
1046        let vscode = dir.path().join("editors").join("vscode");
1047        let deep = dir.path().join("tests").join("fixtures").join("demo");
1048        std::fs::create_dir_all(&benchmarks).unwrap();
1049        std::fs::create_dir_all(&vscode).unwrap();
1050        std::fs::create_dir_all(&deep).unwrap();
1051
1052        std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1053        std::fs::write(vscode.join("package.json"), r#"{"name": "fallow-vscode"}"#).unwrap();
1054        std::fs::write(deep.join("package.json"), r#"{"name": "deep-fixture"}"#).unwrap();
1055
1056        let workspaces = discover_workspaces(dir.path());
1057        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1058
1059        assert!(
1060            names.contains(&"benchmarks"),
1061            "top-level nested package should be discovered: {workspaces:?}"
1062        );
1063        assert!(
1064            names.contains(&"fallow-vscode"),
1065            "second-level nested package should be discovered: {workspaces:?}"
1066        );
1067        assert!(
1068            !names.contains(&"deep-fixture"),
1069            "fallback should stay shallow and skip deep fixtures: {workspaces:?}"
1070        );
1071    }
1072
1073    #[test]
1074    fn discover_workspaces_with_negated_patterns() {
1075        let dir = tempfile::tempdir().expect("create temp dir");
1076        let pkg_a = dir.path().join("packages").join("a");
1077        let pkg_test = dir.path().join("packages").join("test-utils");
1078        std::fs::create_dir_all(&pkg_a).unwrap();
1079        std::fs::create_dir_all(&pkg_test).unwrap();
1080
1081        std::fs::write(
1082            dir.path().join("package.json"),
1083            r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
1084        )
1085        .unwrap();
1086        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1087        std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
1088
1089        let workspaces = discover_workspaces(dir.path());
1090        assert_eq!(workspaces.len(), 1);
1091        assert_eq!(workspaces[0].name, "a");
1092    }
1093
1094    #[test]
1095    fn discover_workspaces_skips_root_as_workspace() {
1096        let dir = tempfile::tempdir().expect("create temp dir");
1097        std::fs::write(
1098            dir.path().join("pnpm-workspace.yaml"),
1099            "packages:\n  - '.'\n",
1100        )
1101        .unwrap();
1102        std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
1103
1104        let workspaces = discover_workspaces(dir.path());
1105        assert!(
1106            workspaces.is_empty(),
1107            "root directory should not be added as workspace"
1108        );
1109    }
1110
1111    #[test]
1112    fn discover_workspaces_name_fallback_to_dir_name() {
1113        let dir = tempfile::tempdir().expect("create temp dir");
1114        let pkg_a = dir.path().join("packages").join("my-app");
1115        std::fs::create_dir_all(&pkg_a).unwrap();
1116
1117        std::fs::write(
1118            dir.path().join("package.json"),
1119            r#"{"workspaces": ["packages/*"]}"#,
1120        )
1121        .unwrap();
1122        std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
1123
1124        let workspaces = discover_workspaces(dir.path());
1125        assert_eq!(workspaces.len(), 1);
1126        assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
1127    }
1128
1129    #[test]
1130    fn discover_workspaces_explicit_patterns_disable_shallow_fallback() {
1131        let dir = tempfile::tempdir().expect("create temp dir");
1132        let pkg_a = dir.path().join("packages").join("a");
1133        let benchmarks = dir.path().join("benchmarks");
1134        std::fs::create_dir_all(&pkg_a).unwrap();
1135        std::fs::create_dir_all(&benchmarks).unwrap();
1136
1137        std::fs::write(
1138            dir.path().join("package.json"),
1139            r#"{"workspaces": ["packages/*"]}"#,
1140        )
1141        .unwrap();
1142        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1143        std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1144
1145        let workspaces = discover_workspaces(dir.path());
1146        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1147
1148        assert_eq!(workspaces.len(), 1);
1149        assert!(names.contains(&"a"));
1150        assert!(
1151            !names.contains(&"benchmarks"),
1152            "explicit workspace config should keep undeclared packages out: {workspaces:?}"
1153        );
1154    }
1155
1156    #[test]
1157    fn discover_workspaces_recovers_package_under_bare_glob_intermediate() {
1158        // Issue #842 (reporter metrists/metrists): root declares
1159        // `["./packages/*", "./themes/*"]`, but the real package lives two levels
1160        // deep at `packages/themes/metrists-theme-next` while `packages/themes`
1161        // itself has no package.json. The single-level glob only matches the bare
1162        // `packages/themes`; without recovery the deep package is never discovered,
1163        // its files fall back to the root manifest, and its declared deps (react)
1164        // are reported as unlisted. Discovery must recover the named deep package.
1165        let dir = tempfile::tempdir().expect("create temp dir");
1166        let theme = dir
1167            .path()
1168            .join("packages")
1169            .join("themes")
1170            .join("metrists-theme-next");
1171        std::fs::create_dir_all(&theme).unwrap();
1172        std::fs::write(
1173            dir.path().join("package.json"),
1174            r#"{"name": "metrists-monorepo", "workspaces": ["./packages/*", "./themes/*"]}"#,
1175        )
1176        .unwrap();
1177        // packages/themes intentionally has NO package.json (bare grouping dir).
1178        std::fs::write(
1179            theme.join("package.json"),
1180            r#"{"name": "metrists-theme-next", "dependencies": {"react": "^18"}}"#,
1181        )
1182        .unwrap();
1183
1184        let workspaces = discover_workspaces(dir.path());
1185        assert!(
1186            workspaces.iter().any(|ws| ws.name == "metrists-theme-next"),
1187            "deep package under a bare glob-matched intermediate must be discovered: {workspaces:?}"
1188        );
1189    }
1190
1191    #[test]
1192    fn undeclared_workspace_detected() {
1193        let dir = tempfile::tempdir().expect("create temp dir");
1194        let pkg_a = dir.path().join("packages").join("a");
1195        let pkg_b = dir.path().join("packages").join("b");
1196        std::fs::create_dir_all(&pkg_a).unwrap();
1197        std::fs::create_dir_all(&pkg_b).unwrap();
1198
1199        std::fs::write(
1200            dir.path().join("package.json"),
1201            r#"{"workspaces": ["packages/a"]}"#,
1202        )
1203        .unwrap();
1204        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1205        std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1206
1207        let declared = discover_workspaces(dir.path());
1208        assert_eq!(declared.len(), 1);
1209
1210        let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1211        assert_eq!(undeclared.len(), 1);
1212        assert!(
1213            undeclared[0]
1214                .path
1215                .to_string_lossy()
1216                .replace('\\', "/")
1217                .contains("packages/b"),
1218            "should detect packages/b as undeclared: {:?}",
1219            undeclared[0].path
1220        );
1221    }
1222
1223    #[test]
1224    fn no_undeclared_when_all_covered() {
1225        let dir = tempfile::tempdir().expect("create temp dir");
1226        let pkg_a = dir.path().join("packages").join("a");
1227        std::fs::create_dir_all(&pkg_a).unwrap();
1228
1229        std::fs::write(
1230            dir.path().join("package.json"),
1231            r#"{"workspaces": ["packages/*"]}"#,
1232        )
1233        .unwrap();
1234        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1235
1236        let declared = discover_workspaces(dir.path());
1237        let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1238        assert!(undeclared.is_empty());
1239    }
1240
1241    #[test]
1242    fn no_undeclared_when_no_workspace_patterns() {
1243        let dir = tempfile::tempdir().expect("create temp dir");
1244        let sub = dir.path().join("lib");
1245        std::fs::create_dir_all(&sub).unwrap();
1246
1247        std::fs::write(dir.path().join("package.json"), r#"{"name": "app"}"#).unwrap();
1248        std::fs::write(sub.join("package.json"), r#"{"name": "lib"}"#).unwrap();
1249
1250        let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1251        assert!(
1252            undeclared.is_empty(),
1253            "should skip check when no workspace patterns exist"
1254        );
1255    }
1256
1257    #[test]
1258    fn undeclared_skips_node_modules_and_hidden_dirs() {
1259        let dir = tempfile::tempdir().expect("create temp dir");
1260        let nm = dir.path().join("node_modules").join("some-pkg");
1261        let hidden = dir.path().join(".hidden");
1262        std::fs::create_dir_all(&nm).unwrap();
1263        std::fs::create_dir_all(&hidden).unwrap();
1264
1265        std::fs::write(
1266            dir.path().join("package.json"),
1267            r#"{"workspaces": ["packages/*"]}"#,
1268        )
1269        .unwrap();
1270        std::fs::write(nm.join("package.json"), r#"{"name": "nm-pkg"}"#).unwrap();
1271        std::fs::write(hidden.join("package.json"), r#"{"name": "hidden"}"#).unwrap();
1272
1273        let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1274        assert!(
1275            undeclared.is_empty(),
1276            "should not flag node_modules or hidden directories"
1277        );
1278    }
1279
1280    fn build_globset(patterns: &[&str]) -> globset::GlobSet {
1281        let mut builder = globset::GlobSetBuilder::new();
1282        for pattern in patterns {
1283            builder.add(globset::Glob::new(pattern).expect("valid glob"));
1284        }
1285        builder.build().expect("build globset")
1286    }
1287
1288    #[test]
1289    fn undeclared_skips_dirs_matching_ignore_patterns() {
1290        let dir = tempfile::tempdir().expect("create temp dir");
1291        let pkg_a = dir.path().join("packages").join("a");
1292        let vitest_ref = dir.path().join("references").join("vitest");
1293        let tanstack_ref = dir.path().join("references").join("tanstack-router");
1294        std::fs::create_dir_all(&pkg_a).unwrap();
1295        std::fs::create_dir_all(&vitest_ref).unwrap();
1296        std::fs::create_dir_all(&tanstack_ref).unwrap();
1297
1298        std::fs::write(
1299            dir.path().join("package.json"),
1300            r#"{"workspaces": ["packages/*"]}"#,
1301        )
1302        .unwrap();
1303        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1304        std::fs::write(
1305            vitest_ref.join("package.json"),
1306            r#"{"name": "vitest-reference"}"#,
1307        )
1308        .unwrap();
1309        std::fs::write(
1310            tanstack_ref.join("package.json"),
1311            r#"{"name": "tanstack-reference"}"#,
1312        )
1313        .unwrap();
1314
1315        let declared = discover_workspaces(dir.path());
1316        let ignore = build_globset(&["references/*"]);
1317        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1318        assert!(
1319            undeclared.is_empty(),
1320            "references/* should be ignored: {undeclared:?}"
1321        );
1322    }
1323
1324    #[test]
1325    fn undeclared_still_reported_when_ignore_does_not_match() {
1326        let dir = tempfile::tempdir().expect("create temp dir");
1327        let pkg_b = dir.path().join("packages").join("b");
1328        std::fs::create_dir_all(&pkg_b).unwrap();
1329
1330        std::fs::write(
1331            dir.path().join("package.json"),
1332            r#"{"workspaces": ["packages/a"]}"#,
1333        )
1334        .unwrap();
1335        std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1336
1337        let declared = discover_workspaces(dir.path());
1338        let ignore = build_globset(&["references/*"]);
1339        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1340        assert_eq!(
1341            undeclared.len(),
1342            1,
1343            "non-matching ignore patterns should not silence other undeclared dirs"
1344        );
1345    }
1346
1347    #[test]
1348    fn undeclared_skips_dirs_matching_package_json_glob() {
1349        let dir = tempfile::tempdir().expect("create temp dir");
1350        let pkg_a = dir.path().join("packages").join("a");
1351        let vitest_ref = dir.path().join("references").join("vitest");
1352        std::fs::create_dir_all(&pkg_a).unwrap();
1353        std::fs::create_dir_all(&vitest_ref).unwrap();
1354
1355        std::fs::write(
1356            dir.path().join("package.json"),
1357            r#"{"workspaces": ["packages/*"]}"#,
1358        )
1359        .unwrap();
1360        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1361        std::fs::write(
1362            vitest_ref.join("package.json"),
1363            r#"{"name": "vitest-reference"}"#,
1364        )
1365        .unwrap();
1366
1367        let declared = discover_workspaces(dir.path());
1368        let ignore = build_globset(&["references/*/package.json"]);
1369        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1370        assert!(
1371            undeclared.is_empty(),
1372            "package.json-suffixed glob should silence the warning: {undeclared:?}"
1373        );
1374    }
1375
1376    #[test]
1377    fn undeclared_skips_dirs_matching_doublestar_ignore() {
1378        let dir = tempfile::tempdir().expect("create temp dir");
1379        let pkg_a = dir.path().join("packages").join("a");
1380        let nested_ref = dir.path().join("references").join("vitest");
1381        std::fs::create_dir_all(&pkg_a).unwrap();
1382        std::fs::create_dir_all(&nested_ref).unwrap();
1383
1384        std::fs::write(
1385            dir.path().join("package.json"),
1386            r#"{"workspaces": ["packages/*"]}"#,
1387        )
1388        .unwrap();
1389        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1390        std::fs::write(
1391            nested_ref.join("package.json"),
1392            r#"{"name": "vitest-reference"}"#,
1393        )
1394        .unwrap();
1395
1396        let declared = discover_workspaces(dir.path());
1397        let ignore = build_globset(&["**/references/**"]);
1398        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1399        assert!(
1400            undeclared.is_empty(),
1401            "**/references/** should ignore nested package.json dirs: {undeclared:?}"
1402        );
1403    }
1404
1405    #[test]
1406    fn malformed_workspace_package_json_emits_diagnostic() {
1407        let dir = tempfile::tempdir().expect("create temp dir");
1408        let pkg_a = dir.path().join("packages").join("a");
1409        let pkg_bad = dir.path().join("packages").join("bad");
1410        std::fs::create_dir_all(&pkg_a).unwrap();
1411        std::fs::create_dir_all(&pkg_bad).unwrap();
1412        std::fs::write(
1413            dir.path().join("package.json"),
1414            r#"{"workspaces": ["packages/*"]}"#,
1415        )
1416        .unwrap();
1417        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1418        std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad",}"#).unwrap();
1419
1420        let (result, captured) = capture_workspace_warnings(|| {
1421            discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1422        });
1423        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1424
1425        assert_eq!(workspaces.len(), 1, "the valid workspace still discovers");
1426        assert_eq!(workspaces[0].name, "a");
1427        assert_eq!(diagnostics.len(), 1);
1428        assert!(matches!(
1429            diagnostics[0].kind,
1430            WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1431        ));
1432        assert!(
1433            captured
1434                .iter()
1435                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1436        );
1437    }
1438
1439    #[test]
1440    fn malformed_bridge_deno_config_emits_recoverable_member_diagnostic() {
1441        let dir = tempfile::tempdir().expect("create temp dir");
1442        let pkg_good = dir.path().join("packages/good");
1443        let pkg_bad = dir.path().join("packages/bad");
1444        std::fs::create_dir_all(&pkg_good).unwrap();
1445        std::fs::create_dir_all(&pkg_bad).unwrap();
1446        std::fs::write(
1447            dir.path().join("package.json"),
1448            r#"{"workspaces": ["packages/*"]}"#,
1449        )
1450        .unwrap();
1451        std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1452        std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad"}"#).unwrap();
1453        std::fs::write(pkg_bad.join("deno.jsonc"), "{ imports: [ }").unwrap();
1454
1455        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1456        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1457
1458        assert_eq!(workspaces.len(), 1, "valid sibling should still discover");
1459        assert_eq!(workspaces[0].name, "good");
1460        assert_eq!(diagnostics.len(), 1);
1461        assert!(matches!(
1462            diagnostics[0].kind,
1463            WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1464        ));
1465        assert!(diagnostics[0].message.contains("deno.jsonc"));
1466    }
1467
1468    /// A member reached through two workspace sources (npm glob + tsconfig
1469    /// reference) is diagnosed, and diagnosed ONCE: the two sources produce
1470    /// byte-identical diagnostics, so the discovery fold reports the member
1471    /// once where every envelope used to report it twice (issue #2366).
1472    ///
1473    /// That the tsconfig leg replayed the memoized `Err` rather than silently
1474    /// resolving is NOT observable here (both `Ok(None)` and `Err` fall back
1475    /// to the directory name, and the fold hides a second diagnostic);
1476    /// [`manifest_cache_replays_the_same_err_on_every_visit`] pins it.
1477    #[test]
1478    fn malformed_member_reached_via_two_sources_is_diagnosed_once() {
1479        let dir = tempfile::tempdir().expect("create temp dir");
1480        let pkg_good = dir.path().join("packages").join("good");
1481        let pkg_bad = dir.path().join("packages").join("bad");
1482        std::fs::create_dir_all(&pkg_good).unwrap();
1483        std::fs::create_dir_all(&pkg_bad).unwrap();
1484        std::fs::write(
1485            dir.path().join("package.json"),
1486            r#"{"workspaces": ["packages/*"]}"#,
1487        )
1488        .unwrap();
1489        std::fs::write(
1490            dir.path().join("tsconfig.json"),
1491            r#"{"references": [{"path": "./packages/bad"}]}"#,
1492        )
1493        .unwrap();
1494        std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1495        std::fs::write(pkg_bad.join("package.json"), r"{,}").unwrap();
1496
1497        let (result, _) = capture_workspace_warnings(|| {
1498            discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1499        });
1500        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1501
1502        assert!(
1503            workspaces.iter().any(|w| w.name == "good"),
1504            "valid sibling should still discover: {workspaces:?}"
1505        );
1506        assert!(
1507            workspaces.iter().any(|w| w.name == "bad"),
1508            "tsconfig reference falls back to the directory name on manifest error"
1509        );
1510        let malformed: Vec<_> = diagnostics
1511            .iter()
1512            .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1513            .collect();
1514        assert_eq!(
1515            malformed.len(),
1516            1,
1517            "the replayed Err is one diagnostic, not one per source: {diagnostics:?}"
1518        );
1519        assert!(
1520            malformed[0].path.ends_with("bad"),
1521            "the diagnostic points at the malformed member"
1522        );
1523    }
1524
1525    /// The per-discovery [`ManifestCache`] memoizes `Err` outcomes and every
1526    /// repeat visit must get the same `Err` back. Both callers that can reach
1527    /// one member ([`register_matched_workspace`] for an npm glob and
1528    /// [`load_tsconfig_workspace_package`] for a tsconfig reference) diagnose
1529    /// from the outcome the cache hands them, so a replay that degraded to
1530    /// `Ok(None)` would silently drop the diagnostic on whichever source is
1531    /// second. No end-to-end assertion can witness that: `Ok(None)` and `Err`
1532    /// both fall back to the directory name for the workspace, and the
1533    /// discovery fold collapses the two byte-identical diagnostics into one
1534    /// either way.
1535    #[test]
1536    fn manifest_cache_replays_the_same_err_on_every_visit() {
1537        let dir = tempfile::tempdir().expect("create temp dir");
1538        let member = dir.path().join("packages").join("bad");
1539        std::fs::create_dir_all(&member).unwrap();
1540        std::fs::write(member.join("package.json"), r"{,}").unwrap();
1541
1542        let mut cache = ManifestCache::default();
1543        let first = load_member_package_manifest_cached(&member, &mut cache)
1544            .expect_err("a malformed member manifest is an Err");
1545        assert_eq!(cache.len(), 1, "the outcome is memoized: {cache:?}");
1546
1547        std::fs::remove_file(member.join("package.json")).unwrap();
1548        let replayed = load_member_package_manifest_cached(&member, &mut cache)
1549            .expect_err("the memoized Err replays instead of re-reading the directory");
1550        assert_eq!(
1551            replayed, first,
1552            "the replayed outcome is byte-identical to the first, so both \
1553             workspace sources diagnose the same member the same way"
1554        );
1555    }
1556
1557    #[test]
1558    fn multiple_malformed_workspace_package_jsons_all_diagnosed() {
1559        let dir = tempfile::tempdir().expect("create temp dir");
1560        for name in ["a", "b", "c"] {
1561            let pkg = dir.path().join("packages").join(name);
1562            std::fs::create_dir_all(&pkg).unwrap();
1563            std::fs::write(pkg.join("package.json"), r"{,}").unwrap();
1564        }
1565        std::fs::write(
1566            dir.path().join("package.json"),
1567            r#"{"workspaces": ["packages/*"]}"#,
1568        )
1569        .unwrap();
1570
1571        let (result, _) = capture_workspace_warnings(|| {
1572            discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1573        });
1574        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1575
1576        assert!(workspaces.is_empty(), "all three malformed; nothing valid");
1577        assert_eq!(diagnostics.len(), 3, "each malformed workspace surfaces");
1578        assert!(
1579            diagnostics
1580                .iter()
1581                .all(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
1582            "every diagnostic should be malformed-package-json"
1583        );
1584    }
1585
1586    #[test]
1587    fn malformed_root_package_json_returns_load_error() {
1588        let dir = tempfile::tempdir().expect("create temp dir");
1589        std::fs::write(dir.path().join("package.json"), "this is not json").unwrap();
1590
1591        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1592
1593        match result {
1594            Err(WorkspaceLoadError::MalformedRootPackageJson { path, error }) => {
1595                assert!(path.ends_with("package.json"));
1596                assert!(!error.is_empty(), "underlying parse error is preserved");
1597            }
1598            other => panic!("expected MalformedRootPackageJson, got {other:?}"),
1599        }
1600    }
1601
1602    #[test]
1603    fn glob_match_without_package_json_emits_diagnostic_unless_skip_listed() {
1604        let dir = tempfile::tempdir().expect("create temp dir");
1605        let pkg_a = dir.path().join("packages").join("a");
1606        let cache_dir = dir.path().join("packages").join(".cache");
1607        let scratch_dir = dir.path().join("packages").join("scratch");
1608        std::fs::create_dir_all(&pkg_a).unwrap();
1609        std::fs::create_dir_all(&cache_dir).unwrap();
1610        std::fs::create_dir_all(&scratch_dir).unwrap();
1611        std::fs::write(
1612            dir.path().join("package.json"),
1613            r#"{"workspaces": ["packages/*"]}"#,
1614        )
1615        .unwrap();
1616        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1617
1618        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1619        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1620
1621        assert_eq!(workspaces.len(), 1);
1622        let kinds: Vec<&str> = diagnostics.iter().map(|d| d.kind.id()).collect();
1623        assert!(
1624            kinds.contains(&"glob-matched-no-package-json"),
1625            "scratch should diagnose: {kinds:?}"
1626        );
1627        assert!(
1628            !diagnostics.iter().any(|d| d.path.ends_with(".cache")),
1629            ".cache must be skip-listed: {diagnostics:?}"
1630        );
1631    }
1632
1633    #[test]
1634    fn glob_match_without_package_json_honors_ignore_patterns() {
1635        let dir = tempfile::tempdir().expect("create temp dir");
1636        let pkg_a = dir.path().join("packages").join("a");
1637        let legacy_dir = dir.path().join("packages").join("legacy");
1638        std::fs::create_dir_all(&pkg_a).unwrap();
1639        std::fs::create_dir_all(&legacy_dir).unwrap();
1640        std::fs::write(
1641            dir.path().join("package.json"),
1642            r#"{"workspaces": ["packages/*"]}"#,
1643        )
1644        .unwrap();
1645        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1646
1647        let mut builder = globset::GlobSetBuilder::new();
1648        builder.add(globset::Glob::new("packages/legacy").unwrap());
1649        let ignore = builder.build().unwrap();
1650
1651        let result = discover_workspaces_with_diagnostics(dir.path(), &ignore);
1652        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1653
1654        assert_eq!(workspaces.len(), 1);
1655        assert!(
1656            diagnostics.is_empty(),
1657            "user-excluded path must not produce a diagnostic: {diagnostics:?}"
1658        );
1659    }
1660
1661    #[test]
1662    fn malformed_tsconfig_emits_diagnostic() {
1663        let dir = tempfile::tempdir().expect("create temp dir");
1664        std::fs::write(
1665            dir.path().join("package.json"),
1666            r#"{"workspaces": ["packages/*"]}"#,
1667        )
1668        .unwrap();
1669        std::fs::write(dir.path().join("tsconfig.json"), r#"{"references": [,,,]}"#).unwrap();
1670
1671        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1672        let (_, diagnostics) = result.expect("root package.json is valid");
1673
1674        assert!(
1675            diagnostics
1676                .iter()
1677                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
1678            "expected MalformedTsconfig diagnostic; got: {diagnostics:?}"
1679        );
1680    }
1681
1682    #[test]
1683    fn tsconfig_missing_reference_dir_emits_diagnostic() {
1684        let dir = tempfile::tempdir().expect("create temp dir");
1685        std::fs::write(
1686            dir.path().join("tsconfig.json"),
1687            r#"{"references": [{"path": "./packages/missing"}]}"#,
1688        )
1689        .unwrap();
1690
1691        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1692        let (_, diagnostics) = result.expect("no package.json at root is OK");
1693
1694        assert!(
1695            diagnostics
1696                .iter()
1697                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::TsconfigReferenceDirMissing)),
1698            "expected TsconfigReferenceDirMissing; got: {diagnostics:?}"
1699        );
1700    }
1701
1702    #[test]
1703    fn missing_tsconfig_is_silent() {
1704        let dir = tempfile::tempdir().expect("create temp dir");
1705
1706        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1707        let (_, diagnostics) = result.expect("no root package.json is OK");
1708
1709        assert!(
1710            !diagnostics
1711                .iter()
1712                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
1713            "missing tsconfig must not produce MalformedTsconfig: {diagnostics:?}"
1714        );
1715    }
1716
1717    #[test]
1718    fn shallow_scan_malformed_package_json_stays_silent() {
1719        let dir = tempfile::tempdir().expect("create temp dir");
1720        let scratch = dir.path().join("scratch");
1721        std::fs::create_dir_all(&scratch).unwrap();
1722        std::fs::write(scratch.join("package.json"), r"{not valid json}").unwrap();
1723
1724        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1725        let (_, diagnostics) = result.expect("no root package.json is OK");
1726
1727        assert!(
1728            !diagnostics
1729                .iter()
1730                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
1731            "shallow-scan malformed must stay silent: {diagnostics:?}"
1732        );
1733    }
1734
1735    #[test]
1736    fn mixed_valid_and_malformed_workspaces_partial_recovery() {
1737        let dir = tempfile::tempdir().expect("create temp dir");
1738        let pkg_good = dir.path().join("packages").join("good");
1739        let pkg_bad = dir.path().join("packages").join("bad");
1740        std::fs::create_dir_all(&pkg_good).unwrap();
1741        std::fs::create_dir_all(&pkg_bad).unwrap();
1742        std::fs::write(
1743            dir.path().join("package.json"),
1744            r#"{"workspaces": ["packages/*"]}"#,
1745        )
1746        .unwrap();
1747        std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1748        std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
1749
1750        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1751        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1752
1753        assert_eq!(workspaces.len(), 1);
1754        assert_eq!(workspaces[0].name, "good");
1755        assert_eq!(diagnostics.len(), 1);
1756        assert_eq!(diagnostics[0].kind.id(), "malformed-package-json");
1757    }
1758
1759    #[test]
1760    fn discover_workspaces_back_compat_drops_diagnostics_and_errors() {
1761        let dir = tempfile::tempdir().expect("create temp dir");
1762        std::fs::write(dir.path().join("package.json"), r"{bad json").unwrap();
1763
1764        let workspaces = discover_workspaces(dir.path());
1765        assert!(
1766            workspaces.is_empty(),
1767            "back-compat wrapper returns empty on root-malformed: {workspaces:?}"
1768        );
1769    }
1770
1771    #[test]
1772    fn discovers_deno_workspace_members_without_package_json() {
1773        let dir = tempfile::tempdir().expect("create temp dir");
1774        let core = dir.path().join("packages").join("core");
1775        let app = dir.path().join("apps").join("desktop");
1776        std::fs::create_dir_all(&core).unwrap();
1777        std::fs::create_dir_all(&app).unwrap();
1778
1779        std::fs::write(
1780            dir.path().join("deno.json"),
1781            r#"{
1782              "workspace": ["./apps/*", "./packages/*"],
1783              "imports": { "@std/assert": "jsr:@std/assert@1" }
1784            }"#,
1785        )
1786        .unwrap();
1787        std::fs::write(
1788            core.join("deno.json"),
1789            r#"{
1790              "name": "@fallow/core",
1791              "exports": { ".": "./mod.ts", "./result": "./result.ts" }
1792            }"#,
1793        )
1794        .unwrap();
1795        std::fs::write(
1796            app.join("deno.json"),
1797            r#"{ "name": "@fallow/desktop", "exports": { ".": "./main.ts" } }"#,
1798        )
1799        .unwrap();
1800
1801        let workspaces = discover_workspaces(dir.path());
1802        let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
1803        names.sort_unstable();
1804        assert_eq!(names, vec!["@fallow/core", "@fallow/desktop"]);
1805        assert!(
1806            workspaces
1807                .iter()
1808                .all(|w| !w.root.join("package.json").exists())
1809        );
1810        assert!(
1811            workspaces.iter().all(|w| !w.is_internal_dependency),
1812            "Deno packages need dependency evidence before inventory marks them internal"
1813        );
1814    }
1815
1816    #[test]
1817    fn malformed_root_deno_config_returns_load_error() {
1818        let dir = tempfile::tempdir().expect("create temp dir");
1819        std::fs::write(dir.path().join("deno.jsonc"), "{ workspace: [ }").unwrap();
1820
1821        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1822
1823        match result {
1824            Err(WorkspaceLoadError::MalformedRootDenoConfig { path, error }) => {
1825                assert!(path.ends_with("deno.jsonc"));
1826                assert!(!error.is_empty(), "underlying parse error is preserved");
1827            }
1828            other => panic!("expected MalformedRootDenoConfig, got {other:?}"),
1829        }
1830    }
1831
1832    #[test]
1833    fn deno_and_npm_workspace_patterns_are_additive() {
1834        let dir = tempfile::tempdir().expect("create temp dir");
1835        let a = dir.path().join("packages").join("a");
1836        let b = dir.path().join("packages").join("b");
1837        std::fs::create_dir_all(&a).unwrap();
1838        std::fs::create_dir_all(&b).unwrap();
1839
1840        std::fs::write(
1841            dir.path().join("package.json"),
1842            r#"{"workspaces": ["packages/a"]}"#,
1843        )
1844        .unwrap();
1845        std::fs::write(a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1846        std::fs::write(
1847            dir.path().join("deno.json"),
1848            r#"{"workspace": ["./packages/b"]}"#,
1849        )
1850        .unwrap();
1851        std::fs::write(b.join("deno.json"), r#"{"name": "b"}"#).unwrap();
1852
1853        let workspaces = discover_workspaces(dir.path());
1854        let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
1855        names.sort_unstable();
1856        assert_eq!(names, vec!["a", "b"]);
1857    }
1858
1859    /// Issue #2366: `package.json` `workspaces` and `pnpm-workspace.yaml`
1860    /// `packages` are additive sources, so one glob declared in both is walked
1861    /// twice. Deduplicating at the discovery choke point is what lets the JSON
1862    /// envelopes report one entry per distinct matching pattern and lets the
1863    /// aggregated stderr warning count the directories it actually found.
1864    #[test]
1865    fn one_glob_declared_in_two_manifests_reports_each_directory_once() {
1866        let dir = tempfile::tempdir().expect("create temp dir");
1867        write_two_manifest_glob_project(dir.path());
1868
1869        let (_, diagnostics) =
1870            discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1871                .expect("root package.json is valid");
1872
1873        let mut reported: Vec<(String, String)> = diagnostics
1874            .iter()
1875            .filter_map(|diagnostic| match &diagnostic.kind {
1876                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => Some((
1877                    pattern.clone(),
1878                    diagnostic
1879                        .path
1880                        .strip_prefix(dir.path())
1881                        .unwrap_or(&diagnostic.path)
1882                        .display()
1883                        .to_string()
1884                        .replace('\\', "/"),
1885                )),
1886                _ => None,
1887            })
1888            .collect();
1889        reported.sort();
1890
1891        assert_eq!(
1892            reported,
1893            vec![
1894                ("pkgs/*".to_owned(), "pkgs/aaa".to_owned()),
1895                ("pkgs/*".to_owned(), "pkgs/bbb".to_owned()),
1896            ],
1897            "one glob spelled two ways is one diagnostic per directory: {diagnostics:?}"
1898        );
1899    }
1900
1901    /// The control for the fold above: the key is the WHOLE kind, payload
1902    /// included. Two overlapping globs each report the same package-less
1903    /// directory with their own `pattern`, and both survive.
1904    #[test]
1905    fn overlapping_globs_still_report_one_directory_once_per_pattern() {
1906        let dir = tempfile::tempdir().expect("create temp dir");
1907        std::fs::create_dir_all(dir.path().join("pkgs/aaa")).unwrap();
1908        std::fs::write(
1909            dir.path().join("package.json"),
1910            r#"{"name":"overlap-root","private":true,"workspaces":["pkgs/*","pkgs/a*"]}"#,
1911        )
1912        .unwrap();
1913        std::fs::write(dir.path().join("pkgs/aaa/readme.txt"), "no package.json\n").unwrap();
1914
1915        let (_, diagnostics) =
1916            discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1917                .expect("root package.json is valid");
1918
1919        let patterns: Vec<&str> = diagnostics
1920            .iter()
1921            .filter_map(|diagnostic| match &diagnostic.kind {
1922                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
1923                    Some(pattern.as_str())
1924                }
1925                _ => None,
1926            })
1927            .collect();
1928
1929        assert_eq!(
1930            patterns,
1931            ["pkgs/*", "pkgs/a*"],
1932            "distinct patterns are distinct diagnostics: {diagnostics:?}"
1933        );
1934    }
1935}