Skip to main content

fallow_config/workspace/
mod.rs

1mod deno_json;
2mod diagnostics;
3mod missing_pattern_dirs;
4mod nested_project;
5mod npm_overrides;
6mod package_json;
7mod parsers;
8mod pnpm_catalog;
9mod pnpm_overrides;
10mod tsconfig;
11mod undeclared;
12
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16use rustc_hash::FxHashMap;
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20pub use deno_json::{
21    DirManifestProbe, dir_has_deno_json, dir_has_package_manifest, is_deno_without_node_modules,
22    load_deno_import_map, load_dir_package_json, load_member_package_manifest,
23    load_root_deno_workspace_patterns, probe_dir_manifest,
24};
25#[cfg(test)]
26pub use diagnostics::capture_workspace_warnings;
27pub use diagnostics::{
28    WorkspaceDiagnostic, WorkspaceDiagnosticKind, WorkspaceLoadError, append_workspace_diagnostics,
29    clear_analysis_stage_diagnostics, clear_health_stage_diagnostics,
30    health_stage_workspace_diagnostics, missing_node_modules_diagnostic, node_modules_missing,
31    node_modules_state, record_plugin_config_diagnostics, record_source_parse_degradations,
32    record_source_read_failures, record_workspace_diagnostics, registry_diagnostics_to_fold,
33    replace_source_discovery_diagnostics, stash_workspace_diagnostics, workspace_diagnostics_for,
34};
35use diagnostics::{emit_diagnostics, is_ignored_workspace_dir, is_skip_listed_dir};
36pub use missing_pattern_dirs::has_missing_workspace_pattern_dirs;
37use missing_pattern_dirs::missing_dir_patterns;
38pub use nested_project::is_independent_pnpm_project;
39use nested_project::{NestedProjectScan, collect_nested_project_workspaces};
40pub use npm_overrides::{parse_bun_package_json_resolutions, parse_npm_package_json_overrides};
41pub use package_json::{NapiConfig, PackageJson};
42pub use parsers::parse_tsconfig_root_dir;
43use parsers::{
44    expand_workspace_glob_with_diagnostics, parse_pnpm_workspace_yaml,
45    parse_tsconfig_references_with_diagnostics,
46};
47pub use pnpm_catalog::{
48    PnpmCatalog, PnpmCatalogData, PnpmCatalogEntry, PnpmCatalogGroup,
49    parse_package_json_catalog_data, parse_pnpm_catalog_data,
50};
51pub use pnpm_overrides::{
52    MisconfigReason, OverrideSource, ParsedOverrideKey, PnpmOverrideData, PnpmOverrideEntry,
53    is_valid_override_value, override_misconfig_reason, override_source_label, parse_override_key,
54    parse_pnpm_package_json_overrides, parse_pnpm_workspace_overrides,
55};
56pub use tsconfig::{TsconfigOutputMap, TsconfigOutputResolution, tsconfig_linked_files};
57pub use undeclared::{find_undeclared_workspaces, find_undeclared_workspaces_with_ignores};
58
59/// Workspace configuration for monorepo support.
60#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
61pub struct WorkspaceConfig {
62    /// Additional workspace patterns (beyond what's in root package.json).
63    ///
64    /// `packages` is accepted as a back-compat alias: an older `fallow init --toml`
65    /// wrote `[workspaces]` with a `packages` key, and reading it as `patterns`
66    /// keeps those existing configs scoping correctly. schemars omits serde
67    /// aliases, so `schema.json` documents only `patterns`.
68    #[serde(default, alias = "packages")]
69    pub patterns: Vec<String>,
70    /// Git baseline refs keyed by workspace roots, written relative to the project root as `fallow list --workspaces` prints them. Scopes `check`, `dead-code`, `dupes` and editor diagnostics; `health`, `security` and `audit` ignore it. A global changed-since request takes precedence. Unlisted workspaces and root files remain in full scope. A key that names no workspace, or a ref that Git cannot resolve, leaves every package in full scope with a warning.
71    #[serde(
72        default,
73        rename = "changedSince",
74        skip_serializing_if = "BTreeMap::is_empty"
75    )]
76    pub changed_since: BTreeMap<String, String>,
77}
78
79/// Discovered workspace info from package.json, deno.json, pnpm-workspace.yaml,
80/// or tsconfig.json references.
81#[derive(Debug, Clone)]
82pub struct WorkspaceInfo {
83    /// Workspace root path.
84    pub root: PathBuf,
85    /// Package name from package.json or deno.json.
86    pub name: String,
87    /// Whether this workspace is depended on by other workspaces.
88    pub is_internal_dependency: bool,
89}
90
91/// Return whether a workspace package name is selected by `publicPackages`.
92///
93/// Exact names and glob patterns share one config-owned matcher so analysis
94/// surfaces cannot disagree about which workspace APIs are public. Invalid
95/// patterns do not match, preserving the existing fail-closed behavior.
96#[must_use]
97pub fn workspace_is_public(name: &str, public_packages: &[String]) -> bool {
98    public_packages.iter().any(|pattern| {
99        name == pattern
100            || globset::Glob::new(pattern).is_ok_and(|glob| glob.compile_matcher().is_match(name))
101    })
102}
103
104/// Discover all workspace packages in a monorepo.
105///
106/// Sources (additive, deduplicated by canonical path):
107/// 1. `package.json` `workspaces` field
108/// 2. `pnpm-workspace.yaml` `packages` field
109/// 3. `deno.json` / `deno.jsonc` `workspace` field
110/// 4. `tsconfig.json` `references` field (TypeScript project references)
111/// 5. `link:` and `file:` dependency targets in the root `package.json`
112///    (yarn-era monorepos without a `workspaces` field)
113///
114/// Back-compat wrapper: drops any diagnostics and silently treats a malformed
115/// root `package.json` as "no workspaces". New callers should use
116/// [`discover_workspaces_with_diagnostics`] to receive typed
117/// [`WorkspaceDiagnostic`] values and to surface root-malformed errors as
118/// hard exits.
119///
120/// This wrapper goes through the silent collector path that does NOT call
121/// `emit_diagnostics` (private helper in `crates/config/src/workspace/diagnostics.rs`
122/// that does the `tracing::warn!` emission). Without that split, sibling
123/// callers in `core/src/lib.rs` (analyze) and `core/src/discover/mod.rs`
124/// (file discovery) would re-emit `tracing::warn!` on paths the user already
125/// excluded via `ignorePatterns`, because the back-compat wrapper has no
126/// access to the user's globset.
127#[must_use]
128pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
129    discover_workspaces_with_patterns(root, &[])
130}
131
132/// Like [`discover_workspaces`], with the `workspaces.patterns` of the fallow
133/// config added to the patterns that the manifests declare.
134#[must_use]
135pub fn discover_workspaces_with_patterns(
136    root: &Path,
137    extra_patterns: &[String],
138) -> Vec<WorkspaceInfo> {
139    collect_workspaces_and_diagnostics(root, &crate::IgnorePatternSet::empty(), extra_patterns)
140        .map(|(workspaces, _)| workspaces)
141        .unwrap_or_default()
142}
143
144/// Discover workspace packages and return any diagnostics produced along the
145/// way.
146///
147/// Replaces the four silent-drop sites in [`discover_workspaces`] with typed
148/// [`WorkspaceDiagnostic`] values:
149/// - malformed declared-workspace `package.json` (warn-and-continue),
150/// - glob match resolving to a directory without `package.json` (warn,
151///   filtered through `ignore_patterns` and an extended skip list),
152/// - malformed `tsconfig.json` (warn-and-continue),
153/// - tsconfig `references[].path` pointing to a missing directory (warn).
154///
155/// `ignore_patterns` mirrors the precedent in
156/// [`find_undeclared_workspaces_with_ignores`]: directories the user already
157/// excluded do not trigger a redundant diagnostic.
158///
159/// Returns [`WorkspaceLoadError::MalformedRootPackageJson`] when the root
160/// `package.json` exists but fails to parse: without a parseable root, no
161/// workspace patterns can be collected and the analysis output would be
162/// fiction. The CLI surfaces this as exit 2.
163///
164/// Shallow-scan fallback candidates (`collect_shallow_workspace_candidate`)
165/// stay silent: the user did not declare them, so a stray malformed
166/// `package.json` two levels deep in a `tools/scratch/` directory should not
167/// produce noise.
168///
169/// # Errors
170///
171/// Returns [`WorkspaceLoadError`] when the project root's `package.json`
172/// exists but is not valid JSON. Callers map this to a hard exit.
173pub fn discover_workspaces_with_diagnostics(
174    root: &Path,
175    ignore_patterns: &crate::IgnorePatternSet,
176) -> Result<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>), WorkspaceLoadError> {
177    discover_workspaces_with_diagnostics_and_patterns(root, ignore_patterns, &[])
178}
179
180/// Like [`discover_workspaces_with_diagnostics`], with the `workspaces.patterns`
181/// of the fallow config added to the patterns that the manifests declare.
182///
183/// # Errors
184///
185/// Returns [`WorkspaceLoadError`] when the project root's `package.json`
186/// exists but is not valid JSON.
187pub fn discover_workspaces_with_diagnostics_and_patterns(
188    root: &Path,
189    ignore_patterns: &crate::IgnorePatternSet,
190    extra_patterns: &[String],
191) -> Result<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>), WorkspaceLoadError> {
192    let (workspaces, diagnostics) =
193        collect_workspaces_and_diagnostics(root, ignore_patterns, extra_patterns)?;
194
195    emit_diagnostics(root, &diagnostics);
196
197    Ok((workspaces, diagnostics))
198}
199
200/// Collect workspaces and diagnostics without emitting `tracing::warn!`.
201///
202/// Both [`discover_workspaces_with_diagnostics`] (which adds the emit step)
203/// and [`discover_workspaces`] (which drops both diagnostics and emission)
204/// route through this function. Keeping emission in the public top-level
205/// only means downstream callers that have no access to the user's
206/// `ignorePatterns` cannot accidentally re-emit warnings on paths the user
207/// already excluded.
208///
209/// The returned diagnostics are deduplicated on the whole `(kind, path)`, the
210/// way every downstream fold is. The sources are additive, so a repository that
211/// declares one glob in both `package.json` `workspaces` and
212/// `pnpm-workspace.yaml` `packages` walks it twice and reports every
213/// package-less directory under it twice. Folding here rather than only in the
214/// process registry is what keeps the returned list, the `workspaces` and
215/// `list --workspaces` envelopes, the MCP `project_info` tool and the
216/// aggregated stderr warning (whose per-pattern grouping counts the entries it
217/// is handed) agreeing on one entry per distinct matching pattern (issue
218/// #2366). Two overlapping globs (`["packages/*", "packages/*/*"]`) still
219/// report the same directory once per `pattern`: the payload is part of the
220/// key.
221fn collect_workspaces_and_diagnostics(
222    root: &Path,
223    ignore_patterns: &crate::IgnorePatternSet,
224    extra_patterns: &[String],
225) -> Result<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>), WorkspaceLoadError> {
226    let mut diagnostics = Vec::new();
227    let manifest_declares_workspaces = !collect_declared_patterns(root, &[])?.is_empty();
228    let declared = collect_declared_patterns(root, extra_patterns)?;
229    diagnostics.extend(missing_dir_patterns(root, &declared).map(|declared| {
230        pattern_source_diagnostic(
231            root,
232            declared.source.clone(),
233            WorkspaceDiagnosticKind::WorkspacePatternDirMissing {
234                pattern: declared.pattern.clone(),
235            },
236        )
237    }));
238    let patterns = split_outside_root_patterns(root, declared, &mut diagnostics);
239    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
240    let mut manifest_cache = ManifestCache::default();
241
242    let mut workspaces = collect_declared_workspaces(
243        root,
244        &patterns,
245        &canonical_root,
246        ignore_patterns,
247        &mut diagnostics,
248        &mut manifest_cache,
249    );
250    workspaces.extend(collect_link_dependency_workspaces(
251        root,
252        &canonical_root,
253        ignore_patterns,
254        &mut diagnostics,
255        &mut manifest_cache,
256    ));
257    if manifest_declares_workspaces {
258        collect_nested_project_workspaces(
259            &NestedProjectScan {
260                root,
261                canonical_root: &canonical_root,
262                ignore_patterns,
263            },
264            &mut workspaces,
265            &mut diagnostics,
266            &mut manifest_cache,
267        );
268    } else {
269        workspaces.extend(collect_shallow_package_workspaces(
270            root,
271            &canonical_root,
272            &mut manifest_cache,
273        ));
274    }
275
276    if !workspaces.is_empty() {
277        mark_internal_dependencies(&mut workspaces);
278    }
279    let workspaces = workspaces.into_iter().map(|(ws, _)| ws).collect();
280    Ok((
281        workspaces,
282        fallow_types::workspace::dedupe_workspace_diagnostics(diagnostics),
283    ))
284}
285
286/// Workspaces that a workspace glob (`package.json`, `pnpm-workspace.yaml`,
287/// Deno) or a `tsconfig.json` reference declares.
288fn collect_declared_workspaces(
289    root: &Path,
290    patterns: &[String],
291    canonical_root: &Path,
292    ignore_patterns: &crate::IgnorePatternSet,
293    diagnostics: &mut Vec<WorkspaceDiagnostic>,
294    manifest_cache: &mut ManifestCache,
295) -> Vec<(WorkspaceInfo, Vec<String>)> {
296    let mut workspaces = expand_patterns_to_workspaces(
297        root,
298        patterns,
299        canonical_root,
300        ignore_patterns,
301        diagnostics,
302        manifest_cache,
303    );
304    workspaces.extend(collect_tsconfig_workspaces(
305        root,
306        canonical_root,
307        ignore_patterns,
308        diagnostics,
309        manifest_cache,
310    ));
311    workspaces
312}
313
314/// Names of root `link:` and `file:` dependencies whose spec is the only
315/// declaration of a discovered workspace.
316///
317/// In a yarn-era monorepo without a `workspaces` field, the root entry is how
318/// the repository declares the package. Removing it removes the package from
319/// the analysis, so the unused-dependency pass must not report it. When a
320/// workspace glob, `pnpm-workspace.yaml`, a Deno workspace or a `tsconfig.json`
321/// reference also declares the target, the entry is an ordinary dependency.
322/// A malformed root manifest gives an empty set, because discovery reports it.
323#[must_use]
324pub fn link_only_workspace_dependencies(
325    root: &Path,
326    ignore_patterns: &crate::IgnorePatternSet,
327    workspaces: &[WorkspaceInfo],
328    extra_patterns: &[String],
329) -> rustc_hash::FxHashSet<String> {
330    let Ok(root_pkg) = PackageJson::load(&root.join("package.json")) else {
331        return rustc_hash::FxHashSet::default();
332    };
333    let links = root_pkg.local_link_dependencies();
334    if links.is_empty() || workspaces.is_empty() {
335        return rustc_hash::FxHashSet::default();
336    }
337    let canonical = |path: &Path| dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
338    let workspace_roots: rustc_hash::FxHashSet<PathBuf> =
339        workspaces.iter().map(|ws| canonical(&ws.root)).collect();
340    let linked: Vec<(String, PathBuf)> = links
341        .into_iter()
342        .map(|(name, target)| (name, canonical(&root.join(target))))
343        .filter(|(_, dir)| workspace_roots.contains(dir))
344        .collect();
345    if linked.is_empty() {
346        return rustc_hash::FxHashSet::default();
347    }
348
349    let patterns = collect_workspace_patterns(root, extra_patterns).unwrap_or_default();
350    let declared: rustc_hash::FxHashSet<PathBuf> = collect_declared_workspaces(
351        root,
352        &patterns,
353        &canonical(root),
354        ignore_patterns,
355        &mut Vec::new(),
356        &mut ManifestCache::default(),
357    )
358    .into_iter()
359    .map(|(ws, _)| canonical(&ws.root))
360    .collect();
361    linked
362        .into_iter()
363        .filter(|(_, dir)| !declared.contains(dir))
364        .map(|(name, _)| name)
365        .collect()
366}
367
368/// A diagnostic about a declared workspace pattern, anchored at the file that
369/// declares it. A fallow config pattern is anchored at the root, stored as
370/// `.`: the JSON envelopes strip only a `root + separator` prefix, so a path
371/// equal to the root would stay absolute there.
372fn pattern_source_diagnostic(
373    root: &Path,
374    source: PathBuf,
375    kind: WorkspaceDiagnosticKind,
376) -> WorkspaceDiagnostic {
377    let anchored_at_root = source == root;
378    let diagnostic = WorkspaceDiagnostic::new(root, source, kind);
379    if anchored_at_root {
380        diagnostic.into_root_relative(root)
381    } else {
382        diagnostic
383    }
384}
385
386/// A workspace glob and the file that declares it.
387///
388/// `source` is the declaring manifest, or the project root for an entry of the
389/// fallow config `workspaces.patterns`.
390struct DeclaredPattern {
391    pattern: String,
392    source: PathBuf,
393}
394
395/// Collect glob patterns from `package.json` `workspaces`, `pnpm-workspace.yaml`,
396/// and Deno `deno.json` / `deno.jsonc` `workspace`, then the `workspaces.patterns`
397/// of the fallow config in `extra_patterns`.
398///
399/// An entry that can name a directory outside the project root is left out.
400/// Use [`collect_declared_patterns`] and [`split_outside_root_patterns`] to
401/// report those entries.
402fn collect_workspace_patterns(
403    root: &Path,
404    extra_patterns: &[String],
405) -> Result<Vec<String>, WorkspaceLoadError> {
406    let declared = collect_declared_patterns(root, extra_patterns)?;
407    Ok(split_outside_root_patterns(root, declared, &mut Vec::new()))
408}
409
410/// Collect every declared workspace glob with its source, in declaration
411/// order. A fallow config pattern that a manifest also declares is kept once.
412fn collect_declared_patterns(
413    root: &Path,
414    extra_patterns: &[String],
415) -> Result<Vec<DeclaredPattern>, WorkspaceLoadError> {
416    let mut patterns = Vec::new();
417    let mut push_all = |source: &Path, entries: Vec<String>| {
418        patterns.extend(entries.into_iter().map(|pattern| DeclaredPattern {
419            pattern,
420            source: source.to_path_buf(),
421        }));
422    };
423
424    let pkg_path = root.join("package.json");
425    if pkg_path.exists() {
426        match PackageJson::load(&pkg_path) {
427            Ok(pkg) => push_all(&pkg_path, pkg.workspace_patterns()),
428            Err(error) => {
429                return Err(WorkspaceLoadError::MalformedRootPackageJson {
430                    path: pkg_path,
431                    error,
432                });
433            }
434        }
435    }
436
437    let pnpm_workspace = root.join("pnpm-workspace.yaml");
438    if pnpm_workspace.exists()
439        && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
440    {
441        push_all(&pnpm_workspace, parse_pnpm_workspace_yaml(&content));
442    }
443
444    if let Some((deno_path, deno_patterns)) = load_root_deno_workspace_patterns(root)
445        .map_err(|(path, error)| WorkspaceLoadError::MalformedRootDenoConfig { path, error })?
446    {
447        push_all(&deno_path, deno_patterns);
448    }
449
450    for pattern in extra_patterns {
451        if !patterns.iter().any(|declared| declared.pattern == *pattern) {
452            patterns.push(DeclaredPattern {
453                pattern: pattern.clone(),
454                source: root.to_path_buf(),
455            });
456        }
457    }
458
459    Ok(patterns)
460}
461
462/// Keep the patterns that stay inside the project root, and report each
463/// positive entry that is absolute or whose `..` segments lead above the root.
464///
465/// The skip happens before any glob reads the disk, so discovery never lists a
466/// directory outside the root. npm, pnpm and yarn accept a `../` entry, so the
467/// entry gets a diagnostic, not a load error. A negated entry only filters the
468/// matches of the positive entries and reads no directory, so it is kept.
469fn split_outside_root_patterns(
470    root: &Path,
471    declared: Vec<DeclaredPattern>,
472    diagnostics: &mut Vec<WorkspaceDiagnostic>,
473) -> Vec<String> {
474    let mut patterns = Vec::with_capacity(declared.len());
475    for DeclaredPattern { pattern, source } in declared {
476        if !pattern.starts_with('!') && crate::glob_validation::escapes_project_root(&pattern) {
477            diagnostics.push(pattern_source_diagnostic(
478                root,
479                source,
480                WorkspaceDiagnosticKind::WorkspaceGlobOutsideRoot { pattern },
481            ));
482            continue;
483        }
484        patterns.push(pattern);
485    }
486    patterns
487}
488
489/// Memoized [`load_member_package_manifest`] outcomes for one discovery run.
490///
491/// The same directory is commonly matched by more than one workspace source
492/// (identical globs in `package.json` and `pnpm-workspace.yaml`, tsconfig
493/// references overlapping npm workspaces). Each manifest load costs a
494/// `package.json` read plus Deno `deno.json` / `deno.jsonc` probes, so repeat
495/// visits replay the memoized outcome instead of touching the filesystem again.
496type ManifestCache = FxHashMap<PathBuf, Result<Option<(String, PackageJson, Vec<String>)>, String>>;
497
498/// Load a member manifest through the per-discovery memo, preserving the
499/// exact per-call outcome (including errors) on repeat visits.
500fn load_member_package_manifest_cached(
501    dir: &Path,
502    cache: &mut ManifestCache,
503) -> Result<Option<(String, PackageJson, Vec<String>)>, String> {
504    if let Some(cached) = cache.get(dir) {
505        return cached.clone();
506    }
507    let outcome = load_member_package_manifest(dir);
508    cache.insert(dir.to_path_buf(), outcome.clone());
509    outcome
510}
511
512/// Expand workspace glob patterns to discover workspace directories.
513///
514/// Handles positive/negated pattern splitting, glob matching, and package.json
515/// loading for each matched directory.
516fn expand_patterns_to_workspaces(
517    root: &Path,
518    patterns: &[String],
519    canonical_root: &Path,
520    ignore_patterns: &crate::IgnorePatternSet,
521    diagnostics: &mut Vec<WorkspaceDiagnostic>,
522    manifest_cache: &mut ManifestCache,
523) -> Vec<(WorkspaceInfo, Vec<String>)> {
524    if patterns.is_empty() {
525        return Vec::new();
526    }
527
528    let mut workspaces = Vec::new();
529
530    let (positive, negative): (Vec<&String>, Vec<&String>) =
531        patterns.iter().partition(|p| !p.starts_with('!'));
532    let negation_matchers: Vec<globset::GlobMatcher> = negative
533        .iter()
534        .filter_map(|p| {
535            let stripped = p.strip_prefix('!').unwrap_or(p);
536            globset::Glob::new(stripped)
537                .ok()
538                .map(|g| g.compile_matcher())
539        })
540        .collect();
541
542    for pattern in &positive {
543        let glob_pattern = if pattern.ends_with('/') {
544            format!("{pattern}*")
545        } else {
546            (*pattern).clone()
547        };
548
549        let matched_dirs = expand_workspace_glob_with_diagnostics(
550            root,
551            pattern,
552            &glob_pattern,
553            canonical_root,
554            ignore_patterns,
555            diagnostics,
556        );
557        for (dir, canonical_dir) in matched_dirs {
558            if canonical_dir == *canonical_root {
559                continue;
560            }
561            if matches_negation(root, &dir, &negation_matchers) {
562                continue;
563            }
564            register_matched_workspace(root, dir, &mut workspaces, diagnostics, manifest_cache);
565        }
566    }
567
568    workspaces
569}
570
571/// Whether a matched directory is excluded by a negated workspace pattern.
572fn matches_negation(root: &Path, dir: &Path, negation_matchers: &[globset::GlobMatcher]) -> bool {
573    let relative = dir.strip_prefix(root).unwrap_or(dir);
574    let relative_str = relative.to_string_lossy();
575    negation_matchers
576        .iter()
577        .any(|m| m.is_match(relative_str.as_ref()))
578}
579
580/// Load a matched directory's package manifest (`package.json` or `deno.json`)
581/// and push a workspace, or a malformed-package diagnostic on parse failure.
582/// A manifest removed after glob expansion is treated as a stale match.
583fn register_matched_workspace(
584    root: &Path,
585    dir: PathBuf,
586    workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
587    diagnostics: &mut Vec<WorkspaceDiagnostic>,
588    manifest_cache: &mut ManifestCache,
589) {
590    match load_member_package_manifest_cached(&dir, manifest_cache) {
591        Ok(Some((name, _pkg, dep_names))) => {
592            workspaces.push((
593                WorkspaceInfo {
594                    root: dir,
595                    name,
596                    is_internal_dependency: false,
597                },
598                dep_names,
599            ));
600        }
601        Ok(None) => {}
602        Err(error) => {
603            let diag = WorkspaceDiagnostic::new(
604                root,
605                dir,
606                WorkspaceDiagnosticKind::MalformedPackageJson { error },
607            );
608            diagnostics.push(diag);
609        }
610    }
611}
612
613/// Discover workspaces from TypeScript project references in `tsconfig.json`.
614///
615/// Referenced directories are added as workspaces, supplementing npm/pnpm workspaces.
616/// This enables cross-workspace resolution for TypeScript composite projects.
617fn collect_tsconfig_workspaces(
618    root: &Path,
619    canonical_root: &Path,
620    ignore_patterns: &crate::IgnorePatternSet,
621    diagnostics: &mut Vec<WorkspaceDiagnostic>,
622    manifest_cache: &mut ManifestCache,
623) -> Vec<(WorkspaceInfo, Vec<String>)> {
624    let mut workspaces = Vec::new();
625
626    for dir in parse_tsconfig_references_with_diagnostics(root, ignore_patterns, diagnostics) {
627        if let Some(workspace) =
628            tsconfig_workspace_from_dir(root, dir, canonical_root, diagnostics, manifest_cache)
629        {
630            workspaces.push(workspace);
631        }
632    }
633
634    workspaces
635}
636
637fn tsconfig_workspace_from_dir(
638    root: &Path,
639    dir: PathBuf,
640    canonical_root: &Path,
641    diagnostics: &mut Vec<WorkspaceDiagnostic>,
642    manifest_cache: &mut ManifestCache,
643) -> Option<(WorkspaceInfo, Vec<String>)> {
644    let canonical_dir = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
645    if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
646        return None;
647    }
648
649    let (name, dep_names) =
650        load_tsconfig_workspace_package(root, &dir, diagnostics, manifest_cache);
651    Some((
652        WorkspaceInfo {
653            root: dir,
654            name,
655            is_internal_dependency: false,
656        },
657        dep_names,
658    ))
659}
660
661fn load_tsconfig_workspace_package(
662    root: &Path,
663    dir: &Path,
664    diagnostics: &mut Vec<WorkspaceDiagnostic>,
665    manifest_cache: &mut ManifestCache,
666) -> (String, Vec<String>) {
667    match load_member_package_manifest_cached(dir, manifest_cache) {
668        Ok(Some((name, _pkg, deps))) => (name, deps),
669        Ok(None) => (dir_name(dir), Vec::new()),
670        Err(error) => {
671            let diag = WorkspaceDiagnostic::new(
672                root,
673                dir.to_path_buf(),
674                WorkspaceDiagnosticKind::MalformedPackageJson { error },
675            );
676            diagnostics.push(diag);
677            (dir_name(dir), Vec::new())
678        }
679    }
680}
681
682/// Discover workspaces from `link:` and `file:` dependency specs in the root
683/// `package.json`.
684///
685/// Older yarn monorepos (for example Kibana before its pnpm move) declare no
686/// `workspaces` field. The root lists each local package as
687/// `"@scope/pkg": "link:path/to/pkg"`, often three to five directories deep,
688/// out of reach of the shallow fallback scan. A target is a workspace only when
689/// it is a directory inside the project root that holds a package manifest.
690/// Targets outside the root, missing targets and tarballs are skipped without a
691/// diagnostic, because a dependency spec is not a workspace declaration. A
692/// target that source discovery does not walk is also skipped (see
693/// [`is_skipped_link_target`] and [`crate::source_walk::dirs_reached_by_source_walk`]). A
694/// target with a malformed `package.json` gets the same diagnostic as a
695/// declared workspace.
696fn collect_link_dependency_workspaces(
697    root: &Path,
698    canonical_root: &Path,
699    ignore_patterns: &crate::IgnorePatternSet,
700    diagnostics: &mut Vec<WorkspaceDiagnostic>,
701    manifest_cache: &mut ManifestCache,
702) -> Vec<(WorkspaceInfo, Vec<String>)> {
703    let Ok(root_pkg) = PackageJson::load(&root.join("package.json")) else {
704        return Vec::new();
705    };
706
707    let candidates: Vec<PathBuf> = root_pkg
708        .local_link_dependencies()
709        .into_iter()
710        .filter_map(|(_name, target)| dunce::canonicalize(root.join(target)).ok())
711        .filter(|canonical_dir| {
712            *canonical_dir != *canonical_root
713                && canonical_dir.starts_with(canonical_root)
714                && canonical_dir.is_dir()
715                && !is_skipped_link_target(canonical_root, canonical_dir, ignore_patterns)
716        })
717        .collect();
718    let walked = crate::source_walk::dirs_reached_by_source_walk(canonical_root, &candidates);
719
720    let mut workspaces = Vec::new();
721    for canonical_dir in candidates {
722        if !walked.contains(&canonical_dir) {
723            continue;
724        }
725        let dir = normalize_link_target(root, canonical_root, &canonical_dir);
726        register_matched_workspace(root, dir, &mut workspaces, diagnostics, manifest_cache);
727    }
728    workspaces
729}
730
731/// Whether a link target is under a path that source discovery skips by name.
732///
733/// A yalc copy (`file:.yalc/pkg`), a package under `node_modules`, a build
734/// output or a path that `ignorePatterns` matches has no discovered source
735/// files. A workspace there makes each import of the package an unresolved
736/// import, so such a target stays an external package. The skip list is the
737/// one the workspace glob expansion uses.
738fn is_skipped_link_target(
739    canonical_root: &Path,
740    canonical_dir: &Path,
741    ignore_patterns: &crate::IgnorePatternSet,
742) -> bool {
743    let Ok(relative) = canonical_dir.strip_prefix(canonical_root) else {
744        return true;
745    };
746    relative
747        .components()
748        .any(|component| is_skip_listed_dir(&component.as_os_str().to_string_lossy()))
749        || is_ignored_workspace_dir(relative, ignore_patterns)
750}
751
752/// Express a canonical link target under the caller's `root` spelling.
753///
754/// A spec such as `link:./pkg/../pkg` or a symlinked root would otherwise give
755/// a workspace root that does not prefix-match the discovered source paths.
756fn normalize_link_target(root: &Path, canonical_root: &Path, canonical_dir: &Path) -> PathBuf {
757    canonical_dir
758        .strip_prefix(canonical_root)
759        .map_or_else(|_| canonical_dir.to_path_buf(), |rel| root.join(rel))
760}
761
762/// Discover shallow package workspaces when no explicit workspace config exists.
763///
764/// Scans direct children of the project root and their immediate children for
765/// `package.json` files. This catches repos that contain multiple standalone
766/// packages (for example `benchmarks/` or `editors/vscode/`) without declaring
767/// npm/pnpm workspaces at the root.
768fn collect_shallow_package_workspaces(
769    root: &Path,
770    canonical_root: &Path,
771    manifest_cache: &mut ManifestCache,
772) -> Vec<(WorkspaceInfo, Vec<String>)> {
773    let mut workspaces = Vec::new();
774    let Ok(top_entries) = std::fs::read_dir(root) else {
775        return workspaces;
776    };
777
778    for entry in top_entries.filter_map(Result::ok) {
779        let path = entry.path();
780        if !path.is_dir() || is_skip_listed_dir(&entry.file_name().to_string_lossy()) {
781            continue;
782        }
783
784        collect_shallow_workspace_candidate(&path, canonical_root, &mut workspaces, manifest_cache);
785        collect_shallow_child_workspaces(&path, canonical_root, &mut workspaces, manifest_cache);
786    }
787
788    workspaces
789}
790
791fn collect_shallow_child_workspaces(
792    parent: &Path,
793    canonical_root: &Path,
794    workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
795    manifest_cache: &mut ManifestCache,
796) {
797    let Ok(child_entries) = std::fs::read_dir(parent) else {
798        return;
799    };
800    for child in child_entries.filter_map(Result::ok) {
801        let child_path = child.path();
802        if !child_path.is_dir() || is_skip_listed_dir(&child.file_name().to_string_lossy()) {
803            continue;
804        }
805
806        collect_shallow_workspace_candidate(
807            &child_path,
808            canonical_root,
809            workspaces,
810            manifest_cache,
811        );
812    }
813}
814
815fn collect_shallow_workspace_candidate(
816    dir: &Path,
817    canonical_root: &Path,
818    workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
819    manifest_cache: &mut ManifestCache,
820) {
821    let canonical_dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
822    if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
823        return;
824    }
825
826    let Ok(Some((name, _pkg, dep_names))) =
827        load_member_package_manifest_cached(dir, manifest_cache)
828    else {
829        return;
830    };
831
832    workspaces.push((
833        WorkspaceInfo {
834            root: dir.to_path_buf(),
835            name,
836            is_internal_dependency: false,
837        },
838        dep_names,
839    ));
840}
841
842/// Deduplicate workspaces by canonical path and mark internal dependencies.
843///
844/// Overlapping sources (npm workspaces + tsconfig references pointing to the same
845/// directory) are collapsed. npm-discovered entries take precedence (they appear first).
846/// Workspaces depended on by other workspaces are marked as `is_internal_dependency`.
847fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
848    {
849        let mut seen = rustc_hash::FxHashSet::default();
850        workspaces.retain(|(ws, _)| {
851            let canonical = dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone());
852            seen.insert(canonical)
853        });
854    }
855
856    let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
857        .iter()
858        .flat_map(|(_, deps)| deps.iter().cloned())
859        .collect();
860
861    for (ws, _) in &mut *workspaces {
862        ws.is_internal_dependency = all_dep_names.contains(&ws.name);
863    }
864}
865
866/// Extract the directory name as a string, for workspace name fallback.
867fn dir_name(dir: &Path) -> String {
868    dir.file_name()
869        .map(|n| n.to_string_lossy().to_string())
870        .unwrap_or_default()
871}
872
873/// Build the issue-2366 repository shape: one glob declared in both
874/// `package.json` (spelled `./pkgs/*`) and `pnpm-workspace.yaml` (spelled
875/// `pkgs/*`), over two directories that carry no `package.json`.
876///
877/// Shared with the emission tests in `diagnostics.rs`, which assert that the
878/// aggregated stderr warning counts each directory once.
879///
880/// # Panics
881///
882/// Panics if a fixture directory or file cannot be written.
883#[cfg(test)]
884pub fn write_two_manifest_glob_project(root: &Path) {
885    std::fs::create_dir_all(root.join("pkgs/aaa")).unwrap();
886    std::fs::create_dir_all(root.join("pkgs/bbb")).unwrap();
887    std::fs::write(
888        root.join("package.json"),
889        r#"{"name":"two-manifest-root","private":true,"workspaces":["./pkgs/*"]}"#,
890    )
891    .unwrap();
892    std::fs::write(
893        root.join("pnpm-workspace.yaml"),
894        "packages:\n  - \"pkgs/*\"\n",
895    )
896    .unwrap();
897    std::fs::write(root.join("pkgs/aaa/readme.txt"), "no package.json here\n").unwrap();
898    std::fs::write(root.join("pkgs/bbb/readme.txt"), "no package.json here\n").unwrap();
899}
900
901#[cfg(test)]
902mod tests {
903    use super::*;
904
905    #[test]
906    fn public_workspace_selection_supports_exact_names_and_globs() {
907        assert!(!workspace_is_public("@scope/core", &[]));
908        assert!(workspace_is_public(
909            "@scope/core",
910            &["@scope/core".to_string()]
911        ));
912        assert!(workspace_is_public(
913            "@scope/core",
914            &["@scope/*".to_string()]
915        ));
916        assert!(!workspace_is_public(
917            "@scope/core",
918            &["[invalid".to_string(), "@other/*".to_string()]
919        ));
920    }
921
922    #[test]
923    fn discover_workspaces_from_tsconfig_references() {
924        let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
925        let _ = std::fs::remove_dir_all(&temp_dir);
926        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
927        std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
928
929        std::fs::write(
930            temp_dir.join("tsconfig.json"),
931            r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
932        )
933        .unwrap();
934
935        std::fs::write(
936            temp_dir.join("packages/core/package.json"),
937            r#"{"name": "@project/core"}"#,
938        )
939        .unwrap();
940
941        let workspaces = discover_workspaces(&temp_dir);
942        assert_eq!(workspaces.len(), 2);
943        assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
944        assert!(workspaces.iter().any(|ws| ws.name == "ui"));
945
946        let _ = std::fs::remove_dir_all(&temp_dir);
947    }
948
949    #[test]
950    fn tsconfig_references_outside_root_rejected() {
951        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
952        let _ = std::fs::remove_dir_all(&temp_dir);
953        std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
954        std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
955
956        std::fs::write(
957            temp_dir.join("project/tsconfig.json"),
958            r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
959        )
960        .unwrap();
961
962        let workspaces = discover_workspaces(&temp_dir.join("project"));
963        assert_eq!(
964            workspaces.len(),
965            1,
966            "reference outside project root should be rejected: {workspaces:?}"
967        );
968        assert!(
969            workspaces[0]
970                .root
971                .to_string_lossy()
972                .contains("packages/core")
973        );
974
975        let _ = std::fs::remove_dir_all(&temp_dir);
976    }
977
978    #[test]
979    fn dir_name_extracts_last_component() {
980        assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
981        assert_eq!(dir_name(Path::new("/my-app")), "my-app");
982    }
983
984    #[test]
985    fn dir_name_empty_for_root_path() {
986        assert_eq!(dir_name(Path::new("/")), "");
987    }
988
989    #[test]
990    fn workspace_config_deserialize_json() {
991        let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
992        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
993        assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
994    }
995
996    #[test]
997    fn workspace_config_deserialize_empty_patterns() {
998        let json = r#"{"patterns": []}"#;
999        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
1000        assert!(config.patterns.is_empty());
1001    }
1002
1003    #[test]
1004    fn workspace_config_default_patterns() {
1005        let json = "{}";
1006        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
1007        assert!(config.patterns.is_empty());
1008    }
1009
1010    #[test]
1011    fn workspace_info_default_not_internal() {
1012        let ws = WorkspaceInfo {
1013            root: PathBuf::from("/project/packages/a"),
1014            name: "a".to_string(),
1015            is_internal_dependency: false,
1016        };
1017        assert!(!ws.is_internal_dependency);
1018    }
1019
1020    #[test]
1021    fn mark_internal_deps_detects_cross_references() {
1022        let temp_dir = tempfile::tempdir().expect("create temp dir");
1023        let pkg_a = temp_dir.path().join("a");
1024        let pkg_b = temp_dir.path().join("b");
1025        std::fs::create_dir_all(&pkg_a).unwrap();
1026        std::fs::create_dir_all(&pkg_b).unwrap();
1027
1028        let mut workspaces = vec![
1029            (
1030                WorkspaceInfo {
1031                    root: pkg_a,
1032                    name: "@scope/a".to_string(),
1033                    is_internal_dependency: false,
1034                },
1035                vec!["@scope/b".to_string()], // "a" depends on "b"
1036            ),
1037            (
1038                WorkspaceInfo {
1039                    root: pkg_b,
1040                    name: "@scope/b".to_string(),
1041                    is_internal_dependency: false,
1042                },
1043                vec!["lodash".to_string()], // "b" depends on external only
1044            ),
1045        ];
1046
1047        mark_internal_dependencies(&mut workspaces);
1048
1049        let ws_a = workspaces
1050            .iter()
1051            .find(|(ws, _)| ws.name == "@scope/a")
1052            .unwrap();
1053        assert!(
1054            !ws_a.0.is_internal_dependency,
1055            "a is not depended on by others"
1056        );
1057
1058        let ws_b = workspaces
1059            .iter()
1060            .find(|(ws, _)| ws.name == "@scope/b")
1061            .unwrap();
1062        assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
1063    }
1064
1065    #[test]
1066    fn mark_internal_deps_no_cross_references() {
1067        let temp_dir = tempfile::tempdir().expect("create temp dir");
1068        let pkg_a = temp_dir.path().join("a");
1069        let pkg_b = temp_dir.path().join("b");
1070        std::fs::create_dir_all(&pkg_a).unwrap();
1071        std::fs::create_dir_all(&pkg_b).unwrap();
1072
1073        let mut workspaces = vec![
1074            (
1075                WorkspaceInfo {
1076                    root: pkg_a,
1077                    name: "a".to_string(),
1078                    is_internal_dependency: false,
1079                },
1080                vec!["react".to_string()],
1081            ),
1082            (
1083                WorkspaceInfo {
1084                    root: pkg_b,
1085                    name: "b".to_string(),
1086                    is_internal_dependency: false,
1087                },
1088                vec!["lodash".to_string()],
1089            ),
1090        ];
1091
1092        mark_internal_dependencies(&mut workspaces);
1093
1094        assert!(!workspaces[0].0.is_internal_dependency);
1095        assert!(!workspaces[1].0.is_internal_dependency);
1096    }
1097
1098    #[test]
1099    fn mark_internal_deps_deduplicates_by_path() {
1100        let temp_dir = tempfile::tempdir().expect("create temp dir");
1101        let pkg_a = temp_dir.path().join("a");
1102        std::fs::create_dir_all(&pkg_a).unwrap();
1103
1104        let mut workspaces = vec![
1105            (
1106                WorkspaceInfo {
1107                    root: pkg_a.clone(),
1108                    name: "a".to_string(),
1109                    is_internal_dependency: false,
1110                },
1111                vec![],
1112            ),
1113            (
1114                WorkspaceInfo {
1115                    root: pkg_a,
1116                    name: "a".to_string(),
1117                    is_internal_dependency: false,
1118                },
1119                vec![],
1120            ),
1121        ];
1122
1123        mark_internal_dependencies(&mut workspaces);
1124        assert_eq!(
1125            workspaces.len(),
1126            1,
1127            "duplicate paths should be deduplicated"
1128        );
1129    }
1130
1131    #[test]
1132    fn collect_patterns_from_package_json() {
1133        let dir = tempfile::tempdir().expect("create temp dir");
1134        std::fs::write(
1135            dir.path().join("package.json"),
1136            r#"{"workspaces": ["packages/*", "apps/*"]}"#,
1137        )
1138        .unwrap();
1139
1140        let patterns =
1141            collect_workspace_patterns(dir.path(), &[]).expect("valid root package.json");
1142        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
1143    }
1144
1145    #[test]
1146    fn collect_patterns_from_pnpm_workspace() {
1147        let dir = tempfile::tempdir().expect("create temp dir");
1148        std::fs::write(
1149            dir.path().join("pnpm-workspace.yaml"),
1150            "packages:\n  - 'packages/*'\n  - 'libs/*'\n",
1151        )
1152        .unwrap();
1153
1154        let patterns = collect_workspace_patterns(dir.path(), &[]).expect("no root package.json");
1155        assert_eq!(patterns, vec!["packages/*", "libs/*"]);
1156    }
1157
1158    #[test]
1159    fn collect_patterns_combines_sources() {
1160        let dir = tempfile::tempdir().expect("create temp dir");
1161        std::fs::write(
1162            dir.path().join("package.json"),
1163            r#"{"workspaces": ["packages/*"]}"#,
1164        )
1165        .unwrap();
1166        std::fs::write(
1167            dir.path().join("pnpm-workspace.yaml"),
1168            "packages:\n  - 'apps/*'\n",
1169        )
1170        .unwrap();
1171
1172        let patterns =
1173            collect_workspace_patterns(dir.path(), &[]).expect("valid root package.json");
1174        assert!(patterns.contains(&"packages/*".to_string()));
1175        assert!(patterns.contains(&"apps/*".to_string()));
1176    }
1177
1178    #[test]
1179    fn collect_patterns_empty_when_no_configs() {
1180        let dir = tempfile::tempdir().expect("create temp dir");
1181        let patterns = collect_workspace_patterns(dir.path(), &[]).expect("no root package.json");
1182        assert!(patterns.is_empty());
1183    }
1184
1185    #[test]
1186    fn discover_workspaces_from_package_json() {
1187        let dir = tempfile::tempdir().expect("create temp dir");
1188        let pkg_a = dir.path().join("packages").join("a");
1189        let pkg_b = dir.path().join("packages").join("b");
1190        std::fs::create_dir_all(&pkg_a).unwrap();
1191        std::fs::create_dir_all(&pkg_b).unwrap();
1192
1193        std::fs::write(
1194            dir.path().join("package.json"),
1195            r#"{"workspaces": ["packages/*"]}"#,
1196        )
1197        .unwrap();
1198        std::fs::write(
1199            pkg_a.join("package.json"),
1200            r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
1201        )
1202        .unwrap();
1203        std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
1204
1205        let workspaces = discover_workspaces(dir.path());
1206        assert_eq!(workspaces.len(), 2);
1207
1208        let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
1209        assert!(!ws_a.is_internal_dependency);
1210
1211        let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
1212        assert!(ws_b.is_internal_dependency, "b is depended on by a");
1213    }
1214
1215    #[test]
1216    fn configured_patterns_add_workspaces_beyond_the_manifest() {
1217        let dir = tempfile::tempdir().expect("create temp dir");
1218        let app = dir.path().join("apps").join("deep").join("one");
1219        std::fs::create_dir_all(&app).unwrap();
1220        std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
1221        std::fs::write(app.join("package.json"), r#"{"name": "@test/one"}"#).unwrap();
1222
1223        assert!(
1224            discover_workspaces(dir.path()).is_empty(),
1225            "the manifest declares no workspace"
1226        );
1227
1228        let patterns = vec!["apps/deep/*".to_string()];
1229        let workspaces = discover_workspaces_with_patterns(dir.path(), &patterns);
1230        assert_eq!(workspaces.len(), 1);
1231        assert_eq!(workspaces[0].name, "@test/one");
1232    }
1233
1234    #[test]
1235    fn configured_patterns_and_manifest_patterns_add_up_without_duplicates() {
1236        let dir = tempfile::tempdir().expect("create temp dir");
1237        for name in ["a", "b"] {
1238            let pkg = dir.path().join("packages").join(name);
1239            std::fs::create_dir_all(&pkg).unwrap();
1240            std::fs::write(
1241                pkg.join("package.json"),
1242                format!(r#"{{"name": "@test/{name}"}}"#),
1243            )
1244            .unwrap();
1245        }
1246        std::fs::write(
1247            dir.path().join("package.json"),
1248            r#"{"workspaces": ["packages/a"]}"#,
1249        )
1250        .unwrap();
1251
1252        let patterns = vec!["packages/*".to_string()];
1253        let workspaces = discover_workspaces_with_patterns(dir.path(), &patterns);
1254        let mut names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1255        names.sort_unstable();
1256        assert_eq!(names, vec!["@test/a", "@test/b"]);
1257    }
1258
1259    #[test]
1260    fn configured_patterns_keep_the_shallow_package_fallback() {
1261        let dir = tempfile::tempdir().expect("create temp dir");
1262        let shallow = dir.path().join("packages").join("a");
1263        let deep = dir.path().join("apps").join("deep").join("one");
1264        std::fs::create_dir_all(&shallow).unwrap();
1265        std::fs::create_dir_all(&deep).unwrap();
1266        std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
1267        std::fs::write(shallow.join("package.json"), r#"{"name": "@test/a"}"#).unwrap();
1268        std::fs::write(deep.join("package.json"), r#"{"name": "@test/one"}"#).unwrap();
1269
1270        let patterns = vec!["apps/deep/*".to_string()];
1271        let workspaces = discover_workspaces_with_patterns(dir.path(), &patterns);
1272        let mut names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1273        names.sort_unstable();
1274        assert_eq!(
1275            names,
1276            vec!["@test/a", "@test/one"],
1277            "a package found without any manifest declaration stays found"
1278        );
1279    }
1280
1281    #[test]
1282    fn discover_workspaces_empty_project() {
1283        let dir = tempfile::tempdir().expect("create temp dir");
1284        let workspaces = discover_workspaces(dir.path());
1285        assert!(workspaces.is_empty());
1286    }
1287
1288    #[test]
1289    fn discover_workspaces_falls_back_to_shallow_packages_without_workspace_config() {
1290        let dir = tempfile::tempdir().expect("create temp dir");
1291        let benchmarks = dir.path().join("benchmarks");
1292        let vscode = dir.path().join("editors").join("vscode");
1293        let deep = dir.path().join("tests").join("fixtures").join("demo");
1294        std::fs::create_dir_all(&benchmarks).unwrap();
1295        std::fs::create_dir_all(&vscode).unwrap();
1296        std::fs::create_dir_all(&deep).unwrap();
1297
1298        std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1299        std::fs::write(vscode.join("package.json"), r#"{"name": "fallow-vscode"}"#).unwrap();
1300        std::fs::write(deep.join("package.json"), r#"{"name": "deep-fixture"}"#).unwrap();
1301
1302        let workspaces = discover_workspaces(dir.path());
1303        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1304
1305        assert!(
1306            names.contains(&"benchmarks"),
1307            "top-level nested package should be discovered: {workspaces:?}"
1308        );
1309        assert!(
1310            names.contains(&"fallow-vscode"),
1311            "second-level nested package should be discovered: {workspaces:?}"
1312        );
1313        assert!(
1314            !names.contains(&"deep-fixture"),
1315            "fallback should stay shallow and skip deep fixtures: {workspaces:?}"
1316        );
1317    }
1318
1319    #[test]
1320    fn discover_workspaces_with_negated_patterns() {
1321        let dir = tempfile::tempdir().expect("create temp dir");
1322        let pkg_a = dir.path().join("packages").join("a");
1323        let pkg_test = dir.path().join("packages").join("test-utils");
1324        std::fs::create_dir_all(&pkg_a).unwrap();
1325        std::fs::create_dir_all(&pkg_test).unwrap();
1326
1327        std::fs::write(
1328            dir.path().join("package.json"),
1329            r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
1330        )
1331        .unwrap();
1332        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1333        std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
1334
1335        let workspaces = discover_workspaces(dir.path());
1336        assert_eq!(workspaces.len(), 1);
1337        assert_eq!(workspaces[0].name, "a");
1338    }
1339
1340    #[test]
1341    fn discover_workspaces_skips_root_as_workspace() {
1342        let dir = tempfile::tempdir().expect("create temp dir");
1343        std::fs::write(
1344            dir.path().join("pnpm-workspace.yaml"),
1345            "packages:\n  - '.'\n",
1346        )
1347        .unwrap();
1348        std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
1349
1350        let workspaces = discover_workspaces(dir.path());
1351        assert!(
1352            workspaces.is_empty(),
1353            "root directory should not be added as workspace"
1354        );
1355    }
1356
1357    #[test]
1358    fn discover_workspaces_name_fallback_to_dir_name() {
1359        let dir = tempfile::tempdir().expect("create temp dir");
1360        let pkg_a = dir.path().join("packages").join("my-app");
1361        std::fs::create_dir_all(&pkg_a).unwrap();
1362
1363        std::fs::write(
1364            dir.path().join("package.json"),
1365            r#"{"workspaces": ["packages/*"]}"#,
1366        )
1367        .unwrap();
1368        std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
1369
1370        let workspaces = discover_workspaces(dir.path());
1371        assert_eq!(workspaces.len(), 1);
1372        assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
1373    }
1374
1375    #[test]
1376    fn discover_workspaces_explicit_patterns_disable_shallow_fallback() {
1377        let dir = tempfile::tempdir().expect("create temp dir");
1378        let pkg_a = dir.path().join("packages").join("a");
1379        let benchmarks = dir.path().join("benchmarks");
1380        std::fs::create_dir_all(&pkg_a).unwrap();
1381        std::fs::create_dir_all(&benchmarks).unwrap();
1382
1383        std::fs::write(
1384            dir.path().join("package.json"),
1385            r#"{"workspaces": ["packages/*"]}"#,
1386        )
1387        .unwrap();
1388        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1389        std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1390
1391        let workspaces = discover_workspaces(dir.path());
1392        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1393
1394        assert_eq!(workspaces.len(), 1);
1395        assert!(names.contains(&"a"));
1396        assert!(
1397            !names.contains(&"benchmarks"),
1398            "explicit workspace config should keep undeclared packages out: {workspaces:?}"
1399        );
1400    }
1401
1402    #[test]
1403    fn discover_workspaces_recovers_package_under_bare_glob_intermediate() {
1404        // Issue #842 (reporter metrists/metrists): root declares
1405        // `["./packages/*", "./themes/*"]`, but the real package lives two levels
1406        // deep at `packages/themes/metrists-theme-next` while `packages/themes`
1407        // itself has no package.json. The single-level glob only matches the bare
1408        // `packages/themes`; without recovery the deep package is never discovered,
1409        // its files fall back to the root manifest, and its declared deps (react)
1410        // are reported as unlisted. Discovery must recover the named deep package.
1411        let dir = tempfile::tempdir().expect("create temp dir");
1412        let theme = dir
1413            .path()
1414            .join("packages")
1415            .join("themes")
1416            .join("metrists-theme-next");
1417        std::fs::create_dir_all(&theme).unwrap();
1418        std::fs::write(
1419            dir.path().join("package.json"),
1420            r#"{"name": "metrists-monorepo", "workspaces": ["./packages/*", "./themes/*"]}"#,
1421        )
1422        .unwrap();
1423        // packages/themes intentionally has NO package.json (bare grouping dir).
1424        std::fs::write(
1425            theme.join("package.json"),
1426            r#"{"name": "metrists-theme-next", "dependencies": {"react": "^18"}}"#,
1427        )
1428        .unwrap();
1429
1430        let workspaces = discover_workspaces(dir.path());
1431        assert!(
1432            workspaces.iter().any(|ws| ws.name == "metrists-theme-next"),
1433            "deep package under a bare glob-matched intermediate must be discovered: {workspaces:?}"
1434        );
1435    }
1436
1437    fn write_package(dir: &Path, manifest: &str) {
1438        std::fs::create_dir_all(dir).unwrap();
1439        std::fs::write(dir.join("package.json"), manifest).unwrap();
1440    }
1441
1442    #[test]
1443    fn discover_workspaces_follows_deep_link_dependency_targets() {
1444        // A yarn-era monorepo (for example older Kibana) has no `workspaces`
1445        // field. The root lists each package as a `link:` or `file:`
1446        // dependency, three to five directories deep.
1447        let dir = tempfile::tempdir().expect("create temp dir");
1448        let root = dir.path();
1449        std::fs::write(
1450            root.join("package.json"),
1451            r#"{
1452              "name": "kibana",
1453              "dependencies": {
1454                "@kbn/foo": "link:src/platform/packages/shared/kbn-foo",
1455                "@kbn/app": "link:./x-pack/solutions/search/plugins/app"
1456              },
1457              "devDependencies": {
1458                "@kbn/test": "file:src/platform/packages/private/kbn-test"
1459              }
1460            }"#,
1461        )
1462        .unwrap();
1463        write_package(
1464            &root.join("src/platform/packages/shared/kbn-foo"),
1465            r#"{"name": "@kbn/foo"}"#,
1466        );
1467        write_package(
1468            &root.join("x-pack/solutions/search/plugins/app"),
1469            r#"{"name": "@kbn/app", "dependencies": {"@kbn/foo": "link:../../../../../src/platform/packages/shared/kbn-foo"}}"#,
1470        );
1471        write_package(
1472            &root.join("src/platform/packages/private/kbn-test"),
1473            r#"{"name": "@kbn/test"}"#,
1474        );
1475
1476        let workspaces = discover_workspaces(root);
1477        let mut names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1478        names.sort_unstable();
1479        assert_eq!(names, ["@kbn/app", "@kbn/foo", "@kbn/test"]);
1480        let foo = workspaces.iter().find(|ws| ws.name == "@kbn/foo").unwrap();
1481        assert!(foo.is_internal_dependency, "{workspaces:?}");
1482    }
1483
1484    #[test]
1485    fn discover_workspaces_skips_link_targets_outside_root_or_without_manifest() {
1486        let dir = tempfile::tempdir().expect("create temp dir");
1487        let root = dir.path().join("repo");
1488        std::fs::create_dir_all(&root).unwrap();
1489        write_package(&dir.path().join("sibling"), r#"{"name": "sibling"}"#);
1490        std::fs::create_dir_all(root.join("packages/no-manifest")).unwrap();
1491        std::fs::write(root.join("vendor.tgz"), "").unwrap();
1492        std::fs::write(
1493            root.join("package.json"),
1494            r#"{
1495              "dependencies": {
1496                "sibling": "link:../sibling",
1497                "absolute": "file:/definitely/not/here",
1498                "missing": "link:packages/missing",
1499                "no-manifest": "link:packages/no-manifest",
1500                "tarball": "file:./vendor.tgz",
1501                "self": "link:.",
1502                "react": "^18.0.0"
1503              }
1504            }"#,
1505        )
1506        .unwrap();
1507
1508        let workspaces = discover_workspaces(&root);
1509        assert!(workspaces.is_empty(), "{workspaces:?}");
1510    }
1511
1512    #[test]
1513    fn discover_workspaces_skips_link_targets_that_source_discovery_skips() {
1514        let dir = tempfile::tempdir().expect("create temp dir");
1515        let root = dir.path();
1516        std::fs::write(
1517            root.join("package.json"),
1518            r#"{
1519              "dependencies": {
1520                "ylib": "file:.yalc/ylib",
1521                "nm": "link:node_modules/nm",
1522                "blib": "link:build/blib",
1523                "dlib": "link:dist/dlib",
1524                "clib": "link:packages/coverage/clib",
1525                "vlib": "link:vendor/deep/vlib",
1526                "kept": "link:libs/deep/kept"
1527              }
1528            }"#,
1529        )
1530        .unwrap();
1531        for (path, name) in [
1532            (".yalc/ylib", "ylib"),
1533            ("node_modules/nm", "nm"),
1534            ("build/blib", "blib"),
1535            ("dist/dlib", "dlib"),
1536            ("packages/coverage/clib", "clib"),
1537            ("vendor/deep/vlib", "vlib"),
1538            ("libs/deep/kept", "kept"),
1539        ] {
1540            write_package(&root.join(path), &format!(r#"{{"name": "{name}"}}"#));
1541        }
1542        let mut builder = globset::GlobSetBuilder::new();
1543        builder.add(globset::Glob::new("vendor/**").unwrap());
1544        let ignore = crate::IgnorePatternSet::from(builder.build().unwrap());
1545
1546        let (workspaces, _) = discover_workspaces_with_diagnostics(root, &ignore).unwrap();
1547        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1548        assert_eq!(names, ["kept"]);
1549    }
1550
1551    fn write_gitignored_link_fixture(root: &Path) {
1552        std::fs::write(
1553            root.join("package.json"),
1554            r#"{
1555              "dependencies": {
1556                "vlib": "file:vendor/deep/vlib",
1557                "plib": "link:libs/private/plib",
1558                "kept": "link:libs/deep/kept"
1559              }
1560            }"#,
1561        )
1562        .unwrap();
1563        for (path, name) in [
1564            ("vendor/deep/vlib", "vlib"),
1565            ("libs/private/plib", "plib"),
1566            ("libs/deep/kept", "kept"),
1567        ] {
1568            write_package(&root.join(path), &format!(r#"{{"name": "{name}"}}"#));
1569        }
1570        std::fs::write(root.join(".gitignore"), "vendor/\n").unwrap();
1571        std::fs::write(root.join("libs/.gitignore"), "private/\n").unwrap();
1572    }
1573
1574    fn link_workspace_names(root: &Path) -> Vec<String> {
1575        let ignore = crate::IgnorePatternSet::default();
1576        let (workspaces, _) = discover_workspaces_with_diagnostics(root, &ignore).unwrap();
1577        let mut names: Vec<String> = workspaces.into_iter().map(|ws| ws.name).collect();
1578        names.sort_unstable();
1579        names
1580    }
1581
1582    #[test]
1583    fn discover_workspaces_skips_gitignored_link_targets() {
1584        let dir = tempfile::tempdir().expect("create temp dir");
1585        let root = dir.path();
1586        std::fs::create_dir_all(root.join(".git")).unwrap();
1587        write_gitignored_link_fixture(root);
1588
1589        assert_eq!(link_workspace_names(root), ["kept"]);
1590    }
1591
1592    #[test]
1593    fn discover_workspaces_keeps_link_targets_when_gitignore_does_not_apply() {
1594        // Source discovery honors `.gitignore` only inside a git repository.
1595        let dir = tempfile::tempdir().expect("create temp dir");
1596        let root = dir.path();
1597        write_gitignored_link_fixture(root);
1598
1599        assert_eq!(link_workspace_names(root), ["kept", "plib", "vlib"]);
1600    }
1601
1602    #[test]
1603    fn discover_workspaces_dedupes_link_targets_with_declared_workspaces() {
1604        let dir = tempfile::tempdir().expect("create temp dir");
1605        let root = dir.path();
1606        std::fs::write(
1607            root.join("package.json"),
1608            r#"{
1609              "workspaces": ["packages/*"],
1610              "dependencies": {
1611                "a": "link:packages/a",
1612                "tool": "link:tools/deep/tool"
1613              }
1614            }"#,
1615        )
1616        .unwrap();
1617        write_package(&root.join("packages/a"), r#"{"name": "a"}"#);
1618        write_package(&root.join("tools/deep/tool"), r#"{"name": "tool"}"#);
1619
1620        let workspaces = discover_workspaces(root);
1621        let mut names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1622        names.sort_unstable();
1623        assert_eq!(names, ["a", "tool"]);
1624        assert!(
1625            find_undeclared_workspaces(root, &workspaces).is_empty(),
1626            "a linked package is declared, not undeclared"
1627        );
1628    }
1629
1630    #[test]
1631    fn undeclared_workspace_detected() {
1632        let dir = tempfile::tempdir().expect("create temp dir");
1633        let pkg_a = dir.path().join("packages").join("a");
1634        let pkg_b = dir.path().join("packages").join("b");
1635        std::fs::create_dir_all(&pkg_a).unwrap();
1636        std::fs::create_dir_all(&pkg_b).unwrap();
1637
1638        std::fs::write(
1639            dir.path().join("package.json"),
1640            r#"{"workspaces": ["packages/a"]}"#,
1641        )
1642        .unwrap();
1643        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1644        std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1645
1646        let declared = discover_workspaces(dir.path());
1647        assert_eq!(declared.len(), 1);
1648
1649        let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1650        assert_eq!(undeclared.len(), 1);
1651        assert!(
1652            undeclared[0]
1653                .path
1654                .to_string_lossy()
1655                .replace('\\', "/")
1656                .contains("packages/b"),
1657            "should detect packages/b as undeclared: {:?}",
1658            undeclared[0].path
1659        );
1660    }
1661
1662    #[test]
1663    fn no_undeclared_when_all_covered() {
1664        let dir = tempfile::tempdir().expect("create temp dir");
1665        let pkg_a = dir.path().join("packages").join("a");
1666        std::fs::create_dir_all(&pkg_a).unwrap();
1667
1668        std::fs::write(
1669            dir.path().join("package.json"),
1670            r#"{"workspaces": ["packages/*"]}"#,
1671        )
1672        .unwrap();
1673        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1674
1675        let declared = discover_workspaces(dir.path());
1676        let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1677        assert!(undeclared.is_empty());
1678    }
1679
1680    #[test]
1681    fn no_undeclared_when_no_workspace_patterns() {
1682        let dir = tempfile::tempdir().expect("create temp dir");
1683        let sub = dir.path().join("lib");
1684        std::fs::create_dir_all(&sub).unwrap();
1685
1686        std::fs::write(dir.path().join("package.json"), r#"{"name": "app"}"#).unwrap();
1687        std::fs::write(sub.join("package.json"), r#"{"name": "lib"}"#).unwrap();
1688
1689        let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1690        assert!(
1691            undeclared.is_empty(),
1692            "should skip check when no workspace patterns exist"
1693        );
1694    }
1695
1696    #[test]
1697    fn undeclared_skips_node_modules_and_hidden_dirs() {
1698        let dir = tempfile::tempdir().expect("create temp dir");
1699        let nm = dir.path().join("node_modules").join("some-pkg");
1700        let hidden = dir.path().join(".hidden");
1701        std::fs::create_dir_all(&nm).unwrap();
1702        std::fs::create_dir_all(&hidden).unwrap();
1703
1704        std::fs::write(
1705            dir.path().join("package.json"),
1706            r#"{"workspaces": ["packages/*"]}"#,
1707        )
1708        .unwrap();
1709        std::fs::write(nm.join("package.json"), r#"{"name": "nm-pkg"}"#).unwrap();
1710        std::fs::write(hidden.join("package.json"), r#"{"name": "hidden"}"#).unwrap();
1711
1712        let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1713        assert!(
1714            undeclared.is_empty(),
1715            "should not flag node_modules or hidden directories"
1716        );
1717    }
1718
1719    fn build_globset(patterns: &[&str]) -> crate::IgnorePatternSet {
1720        let mut builder = globset::GlobSetBuilder::new();
1721        for pattern in patterns {
1722            builder.add(globset::Glob::new(pattern).expect("valid glob"));
1723        }
1724        crate::IgnorePatternSet::from(builder.build().expect("build globset"))
1725    }
1726
1727    #[test]
1728    fn undeclared_skips_dirs_matching_ignore_patterns() {
1729        let dir = tempfile::tempdir().expect("create temp dir");
1730        let pkg_a = dir.path().join("packages").join("a");
1731        let vitest_ref = dir.path().join("references").join("vitest");
1732        let tanstack_ref = dir.path().join("references").join("tanstack-router");
1733        std::fs::create_dir_all(&pkg_a).unwrap();
1734        std::fs::create_dir_all(&vitest_ref).unwrap();
1735        std::fs::create_dir_all(&tanstack_ref).unwrap();
1736
1737        std::fs::write(
1738            dir.path().join("package.json"),
1739            r#"{"workspaces": ["packages/*"]}"#,
1740        )
1741        .unwrap();
1742        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1743        std::fs::write(
1744            vitest_ref.join("package.json"),
1745            r#"{"name": "vitest-reference"}"#,
1746        )
1747        .unwrap();
1748        std::fs::write(
1749            tanstack_ref.join("package.json"),
1750            r#"{"name": "tanstack-reference"}"#,
1751        )
1752        .unwrap();
1753
1754        let declared = discover_workspaces(dir.path());
1755        let ignore = build_globset(&["references/*"]);
1756        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1757        assert!(
1758            undeclared.is_empty(),
1759            "references/* should be ignored: {undeclared:?}"
1760        );
1761    }
1762
1763    #[test]
1764    fn undeclared_still_reported_when_ignore_does_not_match() {
1765        let dir = tempfile::tempdir().expect("create temp dir");
1766        let pkg_b = dir.path().join("packages").join("b");
1767        std::fs::create_dir_all(&pkg_b).unwrap();
1768
1769        std::fs::write(
1770            dir.path().join("package.json"),
1771            r#"{"workspaces": ["packages/a"]}"#,
1772        )
1773        .unwrap();
1774        std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1775
1776        let declared = discover_workspaces(dir.path());
1777        let ignore = build_globset(&["references/*"]);
1778        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1779        assert_eq!(
1780            undeclared.len(),
1781            1,
1782            "non-matching ignore patterns should not silence other undeclared dirs"
1783        );
1784    }
1785
1786    #[test]
1787    fn undeclared_skips_dirs_matching_package_json_glob() {
1788        let dir = tempfile::tempdir().expect("create temp dir");
1789        let pkg_a = dir.path().join("packages").join("a");
1790        let vitest_ref = dir.path().join("references").join("vitest");
1791        std::fs::create_dir_all(&pkg_a).unwrap();
1792        std::fs::create_dir_all(&vitest_ref).unwrap();
1793
1794        std::fs::write(
1795            dir.path().join("package.json"),
1796            r#"{"workspaces": ["packages/*"]}"#,
1797        )
1798        .unwrap();
1799        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1800        std::fs::write(
1801            vitest_ref.join("package.json"),
1802            r#"{"name": "vitest-reference"}"#,
1803        )
1804        .unwrap();
1805
1806        let declared = discover_workspaces(dir.path());
1807        let ignore = build_globset(&["references/*/package.json"]);
1808        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1809        assert!(
1810            undeclared.is_empty(),
1811            "package.json-suffixed glob should silence the warning: {undeclared:?}"
1812        );
1813    }
1814
1815    #[test]
1816    fn undeclared_skips_dirs_matching_doublestar_ignore() {
1817        let dir = tempfile::tempdir().expect("create temp dir");
1818        let pkg_a = dir.path().join("packages").join("a");
1819        let nested_ref = dir.path().join("references").join("vitest");
1820        std::fs::create_dir_all(&pkg_a).unwrap();
1821        std::fs::create_dir_all(&nested_ref).unwrap();
1822
1823        std::fs::write(
1824            dir.path().join("package.json"),
1825            r#"{"workspaces": ["packages/*"]}"#,
1826        )
1827        .unwrap();
1828        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1829        std::fs::write(
1830            nested_ref.join("package.json"),
1831            r#"{"name": "vitest-reference"}"#,
1832        )
1833        .unwrap();
1834
1835        let declared = discover_workspaces(dir.path());
1836        let ignore = build_globset(&["**/references/**"]);
1837        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1838        assert!(
1839            undeclared.is_empty(),
1840            "**/references/** should ignore nested package.json dirs: {undeclared:?}"
1841        );
1842    }
1843
1844    #[test]
1845    fn malformed_workspace_package_json_emits_diagnostic() {
1846        let dir = tempfile::tempdir().expect("create temp dir");
1847        let pkg_a = dir.path().join("packages").join("a");
1848        let pkg_bad = dir.path().join("packages").join("bad");
1849        std::fs::create_dir_all(&pkg_a).unwrap();
1850        std::fs::create_dir_all(&pkg_bad).unwrap();
1851        std::fs::write(
1852            dir.path().join("package.json"),
1853            r#"{"workspaces": ["packages/*"]}"#,
1854        )
1855        .unwrap();
1856        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1857        std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad",}"#).unwrap();
1858
1859        let (result, captured) = capture_workspace_warnings(|| {
1860            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
1861        });
1862        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1863
1864        assert_eq!(workspaces.len(), 1, "the valid workspace still discovers");
1865        assert_eq!(workspaces[0].name, "a");
1866        assert_eq!(diagnostics.len(), 1);
1867        assert!(matches!(
1868            diagnostics[0].kind,
1869            WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1870        ));
1871        assert!(
1872            captured
1873                .iter()
1874                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1875        );
1876    }
1877
1878    #[test]
1879    fn malformed_bridge_deno_config_emits_recoverable_member_diagnostic() {
1880        let dir = tempfile::tempdir().expect("create temp dir");
1881        let pkg_good = dir.path().join("packages/good");
1882        let pkg_bad = dir.path().join("packages/bad");
1883        std::fs::create_dir_all(&pkg_good).unwrap();
1884        std::fs::create_dir_all(&pkg_bad).unwrap();
1885        std::fs::write(
1886            dir.path().join("package.json"),
1887            r#"{"workspaces": ["packages/*"]}"#,
1888        )
1889        .unwrap();
1890        std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1891        std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad"}"#).unwrap();
1892        std::fs::write(pkg_bad.join("deno.jsonc"), "{ imports: [ }").unwrap();
1893
1894        let result =
1895            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
1896        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1897
1898        assert_eq!(workspaces.len(), 1, "valid sibling should still discover");
1899        assert_eq!(workspaces[0].name, "good");
1900        assert_eq!(diagnostics.len(), 1);
1901        assert!(matches!(
1902            diagnostics[0].kind,
1903            WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1904        ));
1905        assert!(diagnostics[0].message.contains("deno.jsonc"));
1906    }
1907
1908    /// A member reached through two workspace sources (npm glob + tsconfig
1909    /// reference) is diagnosed, and diagnosed ONCE: the two sources produce
1910    /// byte-identical diagnostics, so the discovery fold reports the member
1911    /// once where every envelope used to report it twice (issue #2366).
1912    ///
1913    /// That the tsconfig leg replayed the memoized `Err` rather than silently
1914    /// resolving is NOT observable here (both `Ok(None)` and `Err` fall back
1915    /// to the directory name, and the fold hides a second diagnostic);
1916    /// [`manifest_cache_replays_the_same_err_on_every_visit`] pins it.
1917    #[test]
1918    fn malformed_member_reached_via_two_sources_is_diagnosed_once() {
1919        let dir = tempfile::tempdir().expect("create temp dir");
1920        let pkg_good = dir.path().join("packages").join("good");
1921        let pkg_bad = dir.path().join("packages").join("bad");
1922        std::fs::create_dir_all(&pkg_good).unwrap();
1923        std::fs::create_dir_all(&pkg_bad).unwrap();
1924        std::fs::write(
1925            dir.path().join("package.json"),
1926            r#"{"workspaces": ["packages/*"]}"#,
1927        )
1928        .unwrap();
1929        std::fs::write(
1930            dir.path().join("tsconfig.json"),
1931            r#"{"references": [{"path": "./packages/bad"}]}"#,
1932        )
1933        .unwrap();
1934        std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1935        std::fs::write(pkg_bad.join("package.json"), r"{,}").unwrap();
1936
1937        let (result, _) = capture_workspace_warnings(|| {
1938            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
1939        });
1940        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1941
1942        assert!(
1943            workspaces.iter().any(|w| w.name == "good"),
1944            "valid sibling should still discover: {workspaces:?}"
1945        );
1946        assert!(
1947            workspaces.iter().any(|w| w.name == "bad"),
1948            "tsconfig reference falls back to the directory name on manifest error"
1949        );
1950        let malformed: Vec<_> = diagnostics
1951            .iter()
1952            .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1953            .collect();
1954        assert_eq!(
1955            malformed.len(),
1956            1,
1957            "the replayed Err is one diagnostic, not one per source: {diagnostics:?}"
1958        );
1959        assert!(
1960            malformed[0].path.ends_with("bad"),
1961            "the diagnostic points at the malformed member"
1962        );
1963    }
1964
1965    /// The per-discovery [`ManifestCache`] memoizes `Err` outcomes and every
1966    /// repeat visit must get the same `Err` back. Both callers that can reach
1967    /// one member ([`register_matched_workspace`] for an npm glob and
1968    /// [`load_tsconfig_workspace_package`] for a tsconfig reference) diagnose
1969    /// from the outcome the cache hands them, so a replay that degraded to
1970    /// `Ok(None)` would silently drop the diagnostic on whichever source is
1971    /// second. No end-to-end assertion can witness that: `Ok(None)` and `Err`
1972    /// both fall back to the directory name for the workspace, and the
1973    /// discovery fold collapses the two byte-identical diagnostics into one
1974    /// either way.
1975    #[test]
1976    fn manifest_cache_replays_the_same_err_on_every_visit() {
1977        let dir = tempfile::tempdir().expect("create temp dir");
1978        let member = dir.path().join("packages").join("bad");
1979        std::fs::create_dir_all(&member).unwrap();
1980        std::fs::write(member.join("package.json"), r"{,}").unwrap();
1981
1982        let mut cache = ManifestCache::default();
1983        let first = load_member_package_manifest_cached(&member, &mut cache)
1984            .expect_err("a malformed member manifest is an Err");
1985        assert_eq!(cache.len(), 1, "the outcome is memoized: {cache:?}");
1986
1987        std::fs::remove_file(member.join("package.json")).unwrap();
1988        let replayed = load_member_package_manifest_cached(&member, &mut cache)
1989            .expect_err("the memoized Err replays instead of re-reading the directory");
1990        assert_eq!(
1991            replayed, first,
1992            "the replayed outcome is byte-identical to the first, so both \
1993             workspace sources diagnose the same member the same way"
1994        );
1995    }
1996
1997    #[test]
1998    fn multiple_malformed_workspace_package_jsons_all_diagnosed() {
1999        let dir = tempfile::tempdir().expect("create temp dir");
2000        for name in ["a", "b", "c"] {
2001            let pkg = dir.path().join("packages").join(name);
2002            std::fs::create_dir_all(&pkg).unwrap();
2003            std::fs::write(pkg.join("package.json"), r"{,}").unwrap();
2004        }
2005        std::fs::write(
2006            dir.path().join("package.json"),
2007            r#"{"workspaces": ["packages/*"]}"#,
2008        )
2009        .unwrap();
2010
2011        let (result, _) = capture_workspace_warnings(|| {
2012            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
2013        });
2014        let (workspaces, diagnostics) = result.expect("root package.json is valid");
2015
2016        assert!(workspaces.is_empty(), "all three malformed; nothing valid");
2017        assert_eq!(diagnostics.len(), 3, "each malformed workspace surfaces");
2018        assert!(
2019            diagnostics
2020                .iter()
2021                .all(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
2022            "every diagnostic should be malformed-package-json"
2023        );
2024    }
2025
2026    #[test]
2027    fn malformed_root_package_json_returns_load_error() {
2028        let dir = tempfile::tempdir().expect("create temp dir");
2029        std::fs::write(dir.path().join("package.json"), "this is not json").unwrap();
2030
2031        let result =
2032            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2033
2034        match result {
2035            Err(WorkspaceLoadError::MalformedRootPackageJson { path, error }) => {
2036                assert!(path.ends_with("package.json"));
2037                assert!(!error.is_empty(), "underlying parse error is preserved");
2038            }
2039            other => panic!("expected MalformedRootPackageJson, got {other:?}"),
2040        }
2041    }
2042
2043    #[test]
2044    fn glob_match_without_package_json_emits_diagnostic_unless_skip_listed() {
2045        let dir = tempfile::tempdir().expect("create temp dir");
2046        let pkg_a = dir.path().join("packages").join("a");
2047        let cache_dir = dir.path().join("packages").join(".cache");
2048        let scratch_dir = dir.path().join("packages").join("scratch");
2049        std::fs::create_dir_all(&pkg_a).unwrap();
2050        std::fs::create_dir_all(&cache_dir).unwrap();
2051        std::fs::create_dir_all(&scratch_dir).unwrap();
2052        std::fs::write(
2053            dir.path().join("package.json"),
2054            r#"{"workspaces": ["packages/*"]}"#,
2055        )
2056        .unwrap();
2057        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
2058
2059        let result =
2060            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2061        let (workspaces, diagnostics) = result.expect("root package.json is valid");
2062
2063        assert_eq!(workspaces.len(), 1);
2064        let kinds: Vec<&str> = diagnostics.iter().map(|d| d.kind.id()).collect();
2065        assert!(
2066            kinds.contains(&"glob-matched-no-package-json"),
2067            "scratch should diagnose: {kinds:?}"
2068        );
2069        assert!(
2070            !diagnostics.iter().any(|d| d.path.ends_with(".cache")),
2071            ".cache must be skip-listed: {diagnostics:?}"
2072        );
2073    }
2074
2075    #[test]
2076    fn glob_match_without_package_json_honors_ignore_patterns() {
2077        let dir = tempfile::tempdir().expect("create temp dir");
2078        let pkg_a = dir.path().join("packages").join("a");
2079        let legacy_dir = dir.path().join("packages").join("legacy");
2080        std::fs::create_dir_all(&pkg_a).unwrap();
2081        std::fs::create_dir_all(&legacy_dir).unwrap();
2082        std::fs::write(
2083            dir.path().join("package.json"),
2084            r#"{"workspaces": ["packages/*"]}"#,
2085        )
2086        .unwrap();
2087        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
2088
2089        let mut builder = globset::GlobSetBuilder::new();
2090        builder.add(globset::Glob::new("packages/legacy").unwrap());
2091        let ignore = crate::IgnorePatternSet::from(builder.build().unwrap());
2092
2093        let result = discover_workspaces_with_diagnostics(dir.path(), &ignore);
2094        let (workspaces, diagnostics) = result.expect("root package.json is valid");
2095
2096        assert_eq!(workspaces.len(), 1);
2097        assert!(
2098            diagnostics.is_empty(),
2099            "user-excluded path must not produce a diagnostic: {diagnostics:?}"
2100        );
2101    }
2102
2103    #[test]
2104    fn malformed_tsconfig_emits_diagnostic() {
2105        let dir = tempfile::tempdir().expect("create temp dir");
2106        std::fs::write(
2107            dir.path().join("package.json"),
2108            r#"{"workspaces": ["packages/*"]}"#,
2109        )
2110        .unwrap();
2111        std::fs::write(dir.path().join("tsconfig.json"), r#"{"references": [,,,]}"#).unwrap();
2112
2113        let result =
2114            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2115        let (_, diagnostics) = result.expect("root package.json is valid");
2116
2117        assert!(
2118            diagnostics
2119                .iter()
2120                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
2121            "expected MalformedTsconfig diagnostic; got: {diagnostics:?}"
2122        );
2123    }
2124
2125    #[test]
2126    fn tsconfig_missing_reference_dir_emits_diagnostic() {
2127        let dir = tempfile::tempdir().expect("create temp dir");
2128        std::fs::write(
2129            dir.path().join("tsconfig.json"),
2130            r#"{"references": [{"path": "./packages/missing"}]}"#,
2131        )
2132        .unwrap();
2133
2134        let result =
2135            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2136        let (_, diagnostics) = result.expect("no package.json at root is OK");
2137
2138        assert!(
2139            diagnostics
2140                .iter()
2141                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::TsconfigReferenceDirMissing)),
2142            "expected TsconfigReferenceDirMissing; got: {diagnostics:?}"
2143        );
2144    }
2145
2146    #[test]
2147    fn missing_tsconfig_is_silent() {
2148        let dir = tempfile::tempdir().expect("create temp dir");
2149
2150        let result =
2151            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2152        let (_, diagnostics) = result.expect("no root package.json is OK");
2153
2154        assert!(
2155            !diagnostics
2156                .iter()
2157                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
2158            "missing tsconfig must not produce MalformedTsconfig: {diagnostics:?}"
2159        );
2160    }
2161
2162    #[test]
2163    fn shallow_scan_malformed_package_json_stays_silent() {
2164        let dir = tempfile::tempdir().expect("create temp dir");
2165        let scratch = dir.path().join("scratch");
2166        std::fs::create_dir_all(&scratch).unwrap();
2167        std::fs::write(scratch.join("package.json"), r"{not valid json}").unwrap();
2168
2169        let result =
2170            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2171        let (_, diagnostics) = result.expect("no root package.json is OK");
2172
2173        assert!(
2174            !diagnostics
2175                .iter()
2176                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
2177            "shallow-scan malformed must stay silent: {diagnostics:?}"
2178        );
2179    }
2180
2181    #[test]
2182    fn mixed_valid_and_malformed_workspaces_partial_recovery() {
2183        let dir = tempfile::tempdir().expect("create temp dir");
2184        let pkg_good = dir.path().join("packages").join("good");
2185        let pkg_bad = dir.path().join("packages").join("bad");
2186        std::fs::create_dir_all(&pkg_good).unwrap();
2187        std::fs::create_dir_all(&pkg_bad).unwrap();
2188        std::fs::write(
2189            dir.path().join("package.json"),
2190            r#"{"workspaces": ["packages/*"]}"#,
2191        )
2192        .unwrap();
2193        std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
2194        std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
2195
2196        let result =
2197            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2198        let (workspaces, diagnostics) = result.expect("root package.json is valid");
2199
2200        assert_eq!(workspaces.len(), 1);
2201        assert_eq!(workspaces[0].name, "good");
2202        assert_eq!(diagnostics.len(), 1);
2203        assert_eq!(diagnostics[0].kind.id(), "malformed-package-json");
2204    }
2205
2206    #[test]
2207    fn discover_workspaces_back_compat_drops_diagnostics_and_errors() {
2208        let dir = tempfile::tempdir().expect("create temp dir");
2209        std::fs::write(dir.path().join("package.json"), r"{bad json").unwrap();
2210
2211        let workspaces = discover_workspaces(dir.path());
2212        assert!(
2213            workspaces.is_empty(),
2214            "back-compat wrapper returns empty on root-malformed: {workspaces:?}"
2215        );
2216    }
2217
2218    #[test]
2219    fn discovers_deno_workspace_members_without_package_json() {
2220        let dir = tempfile::tempdir().expect("create temp dir");
2221        let core = dir.path().join("packages").join("core");
2222        let app = dir.path().join("apps").join("desktop");
2223        std::fs::create_dir_all(&core).unwrap();
2224        std::fs::create_dir_all(&app).unwrap();
2225
2226        std::fs::write(
2227            dir.path().join("deno.json"),
2228            r#"{
2229              "workspace": ["./apps/*", "./packages/*"],
2230              "imports": { "@std/assert": "jsr:@std/assert@1" }
2231            }"#,
2232        )
2233        .unwrap();
2234        std::fs::write(
2235            core.join("deno.json"),
2236            r#"{
2237              "name": "@fallow/core",
2238              "exports": { ".": "./mod.ts", "./result": "./result.ts" }
2239            }"#,
2240        )
2241        .unwrap();
2242        std::fs::write(
2243            app.join("deno.json"),
2244            r#"{ "name": "@fallow/desktop", "exports": { ".": "./main.ts" } }"#,
2245        )
2246        .unwrap();
2247
2248        let workspaces = discover_workspaces(dir.path());
2249        let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
2250        names.sort_unstable();
2251        assert_eq!(names, vec!["@fallow/core", "@fallow/desktop"]);
2252        assert!(
2253            workspaces
2254                .iter()
2255                .all(|w| !w.root.join("package.json").exists())
2256        );
2257        assert!(
2258            workspaces.iter().all(|w| !w.is_internal_dependency),
2259            "Deno packages need dependency evidence before inventory marks them internal"
2260        );
2261    }
2262
2263    #[test]
2264    fn malformed_root_deno_config_returns_load_error() {
2265        let dir = tempfile::tempdir().expect("create temp dir");
2266        std::fs::write(dir.path().join("deno.jsonc"), "{ workspace: [ }").unwrap();
2267
2268        let result =
2269            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2270
2271        match result {
2272            Err(WorkspaceLoadError::MalformedRootDenoConfig { path, error }) => {
2273                assert!(path.ends_with("deno.jsonc"));
2274                assert!(!error.is_empty(), "underlying parse error is preserved");
2275            }
2276            other => panic!("expected MalformedRootDenoConfig, got {other:?}"),
2277        }
2278    }
2279
2280    #[test]
2281    fn deno_and_npm_workspace_patterns_are_additive() {
2282        let dir = tempfile::tempdir().expect("create temp dir");
2283        let a = dir.path().join("packages").join("a");
2284        let b = dir.path().join("packages").join("b");
2285        std::fs::create_dir_all(&a).unwrap();
2286        std::fs::create_dir_all(&b).unwrap();
2287
2288        std::fs::write(
2289            dir.path().join("package.json"),
2290            r#"{"workspaces": ["packages/a"]}"#,
2291        )
2292        .unwrap();
2293        std::fs::write(a.join("package.json"), r#"{"name": "a"}"#).unwrap();
2294        std::fs::write(
2295            dir.path().join("deno.json"),
2296            r#"{"workspace": ["./packages/b"]}"#,
2297        )
2298        .unwrap();
2299        std::fs::write(b.join("deno.json"), r#"{"name": "b"}"#).unwrap();
2300
2301        let workspaces = discover_workspaces(dir.path());
2302        let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
2303        names.sort_unstable();
2304        assert_eq!(names, vec!["a", "b"]);
2305    }
2306
2307    /// Issue #2366: `package.json` `workspaces` and `pnpm-workspace.yaml`
2308    /// `packages` are additive sources, so one glob declared in both is walked
2309    /// twice. Deduplicating at the discovery choke point is what lets the JSON
2310    /// envelopes report one entry per distinct matching pattern and lets the
2311    /// aggregated stderr warning count the directories it actually found.
2312    #[test]
2313    fn one_glob_declared_in_two_manifests_reports_each_directory_once() {
2314        let dir = tempfile::tempdir().expect("create temp dir");
2315        write_two_manifest_glob_project(dir.path());
2316
2317        let (_, diagnostics) =
2318            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
2319                .expect("root package.json is valid");
2320
2321        let mut reported: Vec<(String, String)> = diagnostics
2322            .iter()
2323            .filter_map(|diagnostic| match &diagnostic.kind {
2324                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => Some((
2325                    pattern.clone(),
2326                    diagnostic
2327                        .path
2328                        .strip_prefix(dir.path())
2329                        .unwrap_or(&diagnostic.path)
2330                        .display()
2331                        .to_string()
2332                        .replace('\\', "/"),
2333                )),
2334                _ => None,
2335            })
2336            .collect();
2337        reported.sort();
2338
2339        assert_eq!(
2340            reported,
2341            vec![
2342                ("pkgs/*".to_owned(), "pkgs/aaa".to_owned()),
2343                ("pkgs/*".to_owned(), "pkgs/bbb".to_owned()),
2344            ],
2345            "one glob spelled two ways is one diagnostic per directory: {diagnostics:?}"
2346        );
2347    }
2348
2349    /// The control for the fold above: the key is the WHOLE kind, payload
2350    /// included. Two overlapping globs each report the same package-less
2351    /// directory with their own `pattern`, and both survive.
2352    #[test]
2353    fn overlapping_globs_still_report_one_directory_once_per_pattern() {
2354        let dir = tempfile::tempdir().expect("create temp dir");
2355        std::fs::create_dir_all(dir.path().join("pkgs/aaa")).unwrap();
2356        std::fs::write(
2357            dir.path().join("package.json"),
2358            r#"{"name":"overlap-root","private":true,"workspaces":["pkgs/*","pkgs/a*"]}"#,
2359        )
2360        .unwrap();
2361        std::fs::write(dir.path().join("pkgs/aaa/readme.txt"), "no package.json\n").unwrap();
2362
2363        let (_, diagnostics) =
2364            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
2365                .expect("root package.json is valid");
2366
2367        let patterns: Vec<&str> = diagnostics
2368            .iter()
2369            .filter_map(|diagnostic| match &diagnostic.kind {
2370                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
2371                    Some(pattern.as_str())
2372                }
2373                _ => None,
2374            })
2375            .collect();
2376
2377        assert_eq!(
2378            patterns,
2379            ["pkgs/*", "pkgs/a*"],
2380            "distinct patterns are distinct diagnostics: {diagnostics:?}"
2381        );
2382    }
2383
2384    /// A workspace entry that is absolute or whose `..` segments lead above the
2385    /// root can name
2386    /// directories outside the project root. Discovery skips it before any
2387    /// disk walk and reports the entry and the file that declares it, never a
2388    /// path outside the root. The in-root entry still finds its package.
2389    #[test]
2390    fn workspace_entries_outside_the_root_are_skipped_before_the_walk() {
2391        let sandbox = tempfile::tempdir().expect("create temp dir");
2392        let outside = sandbox.path().join("outside");
2393        std::fs::create_dir_all(outside.join("no-manifest")).unwrap();
2394        std::fs::create_dir_all(outside.join("pkg-out")).unwrap();
2395        std::fs::write(
2396            outside.join("pkg-out/package.json"),
2397            r#"{"name":"pkg-out"}"#,
2398        )
2399        .unwrap();
2400        let root = sandbox.path().join("repo");
2401        std::fs::create_dir_all(root.join("packages/pkg-in")).unwrap();
2402        std::fs::write(
2403            root.join("packages/pkg-in/package.json"),
2404            r#"{"name":"pkg-in"}"#,
2405        )
2406        .unwrap();
2407        let absolute = format!("{}/*", outside.display()).replace('\\', "/");
2408        std::fs::write(
2409            root.join("package.json"),
2410            serde_json::json!({
2411                "name": "root",
2412                "private": true,
2413                "workspaces": ["packages/*", "../outside/*", absolute],
2414            })
2415            .to_string(),
2416        )
2417        .unwrap();
2418        std::fs::write(
2419            root.join("pnpm-workspace.yaml"),
2420            "packages:\n  - packages/*\n  - ../outside/**\n",
2421        )
2422        .unwrap();
2423
2424        let (workspaces, diagnostics) =
2425            discover_workspaces_with_diagnostics(&root, &crate::IgnorePatternSet::empty())
2426                .expect("root package.json is valid");
2427
2428        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
2429        assert_eq!(names, ["pkg-in"], "only the in-root package is a workspace");
2430
2431        let canonical_root = dunce::canonicalize(&root).unwrap();
2432        for diagnostic in &diagnostics {
2433            let path =
2434                dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
2435            assert!(
2436                path.starts_with(&canonical_root),
2437                "no diagnostic names a path outside the root: {diagnostic:?}"
2438            );
2439            assert!(
2440                !diagnostic.message.contains("no-manifest")
2441                    && !diagnostic.message.contains("pkg-out"),
2442                "no message names an outside directory: {diagnostic:?}"
2443            );
2444        }
2445
2446        let mut skipped: Vec<(String, String)> = diagnostics
2447            .iter()
2448            .filter(|diagnostic| diagnostic.kind.id() == "workspace-glob-outside-root")
2449            .map(|diagnostic| {
2450                (
2451                    fallow_types::path_util::display_relative(&root, &diagnostic.path),
2452                    diagnostic.message.clone(),
2453                )
2454            })
2455            .collect();
2456        skipped.sort();
2457        let files: Vec<&str> = skipped.iter().map(|(file, _)| file.as_str()).collect();
2458        assert_eq!(
2459            files,
2460            ["package.json", "package.json", "pnpm-workspace.yaml"],
2461            "one diagnostic per skipped entry, anchored at its file: {diagnostics:?}"
2462        );
2463        assert!(
2464            skipped
2465                .iter()
2466                .any(|(_, message)| message.contains("'../outside/*'")),
2467            "the message names the entry: {skipped:?}"
2468        );
2469    }
2470
2471    /// A Deno workspace entry gets the same skip. A `..` that stays inside the
2472    /// root and a negated outside entry read no outside directory, so they get
2473    /// no diagnostic, and the in-root package is still found.
2474    #[test]
2475    fn deno_entries_and_in_root_dot_dot_segments_follow_the_root_boundary() {
2476        let sandbox = tempfile::tempdir().expect("create temp dir");
2477        std::fs::create_dir_all(sandbox.path().join("outside/no-manifest")).unwrap();
2478        let root = sandbox.path().join("repo");
2479        std::fs::create_dir_all(root.join("packages/pkg-in")).unwrap();
2480        std::fs::write(
2481            root.join("packages/pkg-in/deno.json"),
2482            r#"{"name":"@x/pkg-in"}"#,
2483        )
2484        .unwrap();
2485        std::fs::write(
2486            root.join("deno.json"),
2487            r#"{"workspace":["packages/../packages/*","../outside/*","!../outside/no-manifest"]}"#,
2488        )
2489        .unwrap();
2490
2491        let (workspaces, diagnostics) =
2492            discover_workspaces_with_diagnostics(&root, &crate::IgnorePatternSet::empty())
2493                .expect("root deno.json is valid");
2494
2495        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
2496        assert_eq!(
2497            names,
2498            ["@x/pkg-in"],
2499            "the in-root `..` entry still finds its package"
2500        );
2501        let [diagnostic] = diagnostics.as_slice() else {
2502            panic!("expected one diagnostic: {diagnostics:?}");
2503        };
2504        assert_eq!(diagnostic.path, root.join("deno.json"));
2505        assert_eq!(
2506            diagnostic.kind,
2507            WorkspaceDiagnosticKind::WorkspaceGlobOutsideRoot {
2508                pattern: "../outside/*".to_owned()
2509            }
2510        );
2511    }
2512
2513    /// A fallow config `workspaces.patterns` entry has no manifest, so its
2514    /// diagnostic is anchored at the root and its message names the setting.
2515    #[test]
2516    fn configured_pattern_outside_the_root_names_the_config_setting() {
2517        let dir = tempfile::tempdir().expect("create temp dir");
2518        std::fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#).unwrap();
2519
2520        let (_, diagnostics) = discover_workspaces_with_diagnostics_and_patterns(
2521            dir.path(),
2522            &crate::IgnorePatternSet::empty(),
2523            &["../shared/*".to_owned()],
2524        )
2525        .expect("root package.json is valid");
2526
2527        let [diagnostic] = diagnostics.as_slice() else {
2528            panic!("expected one diagnostic: {diagnostics:?}");
2529        };
2530        assert_eq!(diagnostic.path, Path::new("."));
2531        assert_eq!(
2532            diagnostic.kind,
2533            WorkspaceDiagnosticKind::WorkspaceGlobOutsideRoot {
2534                pattern: "../shared/*".to_owned()
2535            }
2536        );
2537        assert!(
2538            diagnostic
2539                .message
2540                .contains("fallow config `workspaces.patterns`"),
2541            "{diagnostic:?}"
2542        );
2543    }
2544    mod layout_tests;
2545}