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