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 [`link_targets_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 = link_targets_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/// The link targets that the source discovery walk reaches.
753///
754/// A target under a gitignored directory has no discovered source files, for
755/// the same reason as a skip-listed target. The walk uses the ignore-file
756/// settings of source discovery ([`crate::source_walk_builder`]), so nested
757/// `.gitignore` files, `.git/info/exclude` and the global gitignore apply with
758/// the same rules. The walk enters only the ancestor directories of the
759/// targets, so it reads a small number of directories. A gitignore rule that
760/// ignores files but not their directory (for example `*.ts`) does not stop
761/// the walk, so such a target stays a workspace.
762fn link_targets_reached_by_source_walk(
763    canonical_root: &Path,
764    targets: &[PathBuf],
765) -> rustc_hash::FxHashSet<PathBuf> {
766    let mut reached = rustc_hash::FxHashSet::default();
767    if targets.is_empty() {
768        return reached;
769    }
770    let mut on_path: rustc_hash::FxHashSet<PathBuf> = rustc_hash::FxHashSet::default();
771    for target in targets {
772        for ancestor in target.ancestors() {
773            if ancestor == canonical_root || !on_path.insert(ancestor.to_path_buf()) {
774                break;
775            }
776        }
777    }
778    let max_depth = targets
779        .iter()
780        .filter_map(|target| target.strip_prefix(canonical_root).ok())
781        .map(|relative| relative.components().count())
782        .max();
783    let mut builder = crate::source_walk_builder(canonical_root);
784    builder
785        .max_depth(max_depth)
786        .filter_entry(move |entry| entry.depth() == 0 || on_path.contains(entry.path()));
787    let targets: rustc_hash::FxHashSet<&Path> = targets.iter().map(PathBuf::as_path).collect();
788    for entry in builder.build().flatten() {
789        if targets.contains(entry.path()) {
790            reached.insert(entry.into_path());
791        }
792    }
793    reached
794}
795
796/// Express a canonical link target under the caller's `root` spelling.
797///
798/// A spec such as `link:./pkg/../pkg` or a symlinked root would otherwise give
799/// a workspace root that does not prefix-match the discovered source paths.
800fn normalize_link_target(root: &Path, canonical_root: &Path, canonical_dir: &Path) -> PathBuf {
801    canonical_dir
802        .strip_prefix(canonical_root)
803        .map_or_else(|_| canonical_dir.to_path_buf(), |rel| root.join(rel))
804}
805
806/// Discover shallow package workspaces when no explicit workspace config exists.
807///
808/// Scans direct children of the project root and their immediate children for
809/// `package.json` files. This catches repos that contain multiple standalone
810/// packages (for example `benchmarks/` or `editors/vscode/`) without declaring
811/// npm/pnpm workspaces at the root.
812fn collect_shallow_package_workspaces(
813    root: &Path,
814    canonical_root: &Path,
815    manifest_cache: &mut ManifestCache,
816) -> Vec<(WorkspaceInfo, Vec<String>)> {
817    let mut workspaces = Vec::new();
818    let Ok(top_entries) = std::fs::read_dir(root) else {
819        return workspaces;
820    };
821
822    for entry in top_entries.filter_map(Result::ok) {
823        let path = entry.path();
824        if !path.is_dir() || is_skip_listed_dir(&entry.file_name().to_string_lossy()) {
825            continue;
826        }
827
828        collect_shallow_workspace_candidate(&path, canonical_root, &mut workspaces, manifest_cache);
829        collect_shallow_child_workspaces(&path, canonical_root, &mut workspaces, manifest_cache);
830    }
831
832    workspaces
833}
834
835fn collect_shallow_child_workspaces(
836    parent: &Path,
837    canonical_root: &Path,
838    workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
839    manifest_cache: &mut ManifestCache,
840) {
841    let Ok(child_entries) = std::fs::read_dir(parent) else {
842        return;
843    };
844    for child in child_entries.filter_map(Result::ok) {
845        let child_path = child.path();
846        if !child_path.is_dir() || is_skip_listed_dir(&child.file_name().to_string_lossy()) {
847            continue;
848        }
849
850        collect_shallow_workspace_candidate(
851            &child_path,
852            canonical_root,
853            workspaces,
854            manifest_cache,
855        );
856    }
857}
858
859fn collect_shallow_workspace_candidate(
860    dir: &Path,
861    canonical_root: &Path,
862    workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
863    manifest_cache: &mut ManifestCache,
864) {
865    let canonical_dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
866    if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
867        return;
868    }
869
870    let Ok(Some((name, _pkg, dep_names))) =
871        load_member_package_manifest_cached(dir, manifest_cache)
872    else {
873        return;
874    };
875
876    workspaces.push((
877        WorkspaceInfo {
878            root: dir.to_path_buf(),
879            name,
880            is_internal_dependency: false,
881        },
882        dep_names,
883    ));
884}
885
886/// Deduplicate workspaces by canonical path and mark internal dependencies.
887///
888/// Overlapping sources (npm workspaces + tsconfig references pointing to the same
889/// directory) are collapsed. npm-discovered entries take precedence (they appear first).
890/// Workspaces depended on by other workspaces are marked as `is_internal_dependency`.
891fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
892    {
893        let mut seen = rustc_hash::FxHashSet::default();
894        workspaces.retain(|(ws, _)| {
895            let canonical = dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone());
896            seen.insert(canonical)
897        });
898    }
899
900    let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
901        .iter()
902        .flat_map(|(_, deps)| deps.iter().cloned())
903        .collect();
904
905    for (ws, _) in &mut *workspaces {
906        ws.is_internal_dependency = all_dep_names.contains(&ws.name);
907    }
908}
909
910/// Extract the directory name as a string, for workspace name fallback.
911fn dir_name(dir: &Path) -> String {
912    dir.file_name()
913        .map(|n| n.to_string_lossy().to_string())
914        .unwrap_or_default()
915}
916
917/// Build the issue-2366 repository shape: one glob declared in both
918/// `package.json` (spelled `./pkgs/*`) and `pnpm-workspace.yaml` (spelled
919/// `pkgs/*`), over two directories that carry no `package.json`.
920///
921/// Shared with the emission tests in `diagnostics.rs`, which assert that the
922/// aggregated stderr warning counts each directory once.
923///
924/// # Panics
925///
926/// Panics if a fixture directory or file cannot be written.
927#[cfg(test)]
928pub fn write_two_manifest_glob_project(root: &Path) {
929    std::fs::create_dir_all(root.join("pkgs/aaa")).unwrap();
930    std::fs::create_dir_all(root.join("pkgs/bbb")).unwrap();
931    std::fs::write(
932        root.join("package.json"),
933        r#"{"name":"two-manifest-root","private":true,"workspaces":["./pkgs/*"]}"#,
934    )
935    .unwrap();
936    std::fs::write(
937        root.join("pnpm-workspace.yaml"),
938        "packages:\n  - \"pkgs/*\"\n",
939    )
940    .unwrap();
941    std::fs::write(root.join("pkgs/aaa/readme.txt"), "no package.json here\n").unwrap();
942    std::fs::write(root.join("pkgs/bbb/readme.txt"), "no package.json here\n").unwrap();
943}
944
945#[cfg(test)]
946mod tests {
947    use super::*;
948
949    #[test]
950    fn public_workspace_selection_supports_exact_names_and_globs() {
951        assert!(!workspace_is_public("@scope/core", &[]));
952        assert!(workspace_is_public(
953            "@scope/core",
954            &["@scope/core".to_string()]
955        ));
956        assert!(workspace_is_public(
957            "@scope/core",
958            &["@scope/*".to_string()]
959        ));
960        assert!(!workspace_is_public(
961            "@scope/core",
962            &["[invalid".to_string(), "@other/*".to_string()]
963        ));
964    }
965
966    #[test]
967    fn discover_workspaces_from_tsconfig_references() {
968        let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
969        let _ = std::fs::remove_dir_all(&temp_dir);
970        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
971        std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
972
973        std::fs::write(
974            temp_dir.join("tsconfig.json"),
975            r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
976        )
977        .unwrap();
978
979        std::fs::write(
980            temp_dir.join("packages/core/package.json"),
981            r#"{"name": "@project/core"}"#,
982        )
983        .unwrap();
984
985        let workspaces = discover_workspaces(&temp_dir);
986        assert_eq!(workspaces.len(), 2);
987        assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
988        assert!(workspaces.iter().any(|ws| ws.name == "ui"));
989
990        let _ = std::fs::remove_dir_all(&temp_dir);
991    }
992
993    #[test]
994    fn tsconfig_references_outside_root_rejected() {
995        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
996        let _ = std::fs::remove_dir_all(&temp_dir);
997        std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
998        std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
999
1000        std::fs::write(
1001            temp_dir.join("project/tsconfig.json"),
1002            r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
1003        )
1004        .unwrap();
1005
1006        let workspaces = discover_workspaces(&temp_dir.join("project"));
1007        assert_eq!(
1008            workspaces.len(),
1009            1,
1010            "reference outside project root should be rejected: {workspaces:?}"
1011        );
1012        assert!(
1013            workspaces[0]
1014                .root
1015                .to_string_lossy()
1016                .contains("packages/core")
1017        );
1018
1019        let _ = std::fs::remove_dir_all(&temp_dir);
1020    }
1021
1022    #[test]
1023    fn dir_name_extracts_last_component() {
1024        assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
1025        assert_eq!(dir_name(Path::new("/my-app")), "my-app");
1026    }
1027
1028    #[test]
1029    fn dir_name_empty_for_root_path() {
1030        assert_eq!(dir_name(Path::new("/")), "");
1031    }
1032
1033    #[test]
1034    fn workspace_config_deserialize_json() {
1035        let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
1036        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
1037        assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
1038    }
1039
1040    #[test]
1041    fn workspace_config_deserialize_empty_patterns() {
1042        let json = r#"{"patterns": []}"#;
1043        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
1044        assert!(config.patterns.is_empty());
1045    }
1046
1047    #[test]
1048    fn workspace_config_default_patterns() {
1049        let json = "{}";
1050        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
1051        assert!(config.patterns.is_empty());
1052    }
1053
1054    #[test]
1055    fn workspace_info_default_not_internal() {
1056        let ws = WorkspaceInfo {
1057            root: PathBuf::from("/project/packages/a"),
1058            name: "a".to_string(),
1059            is_internal_dependency: false,
1060        };
1061        assert!(!ws.is_internal_dependency);
1062    }
1063
1064    #[test]
1065    fn mark_internal_deps_detects_cross_references() {
1066        let temp_dir = tempfile::tempdir().expect("create temp dir");
1067        let pkg_a = temp_dir.path().join("a");
1068        let pkg_b = temp_dir.path().join("b");
1069        std::fs::create_dir_all(&pkg_a).unwrap();
1070        std::fs::create_dir_all(&pkg_b).unwrap();
1071
1072        let mut workspaces = vec![
1073            (
1074                WorkspaceInfo {
1075                    root: pkg_a,
1076                    name: "@scope/a".to_string(),
1077                    is_internal_dependency: false,
1078                },
1079                vec!["@scope/b".to_string()], // "a" depends on "b"
1080            ),
1081            (
1082                WorkspaceInfo {
1083                    root: pkg_b,
1084                    name: "@scope/b".to_string(),
1085                    is_internal_dependency: false,
1086                },
1087                vec!["lodash".to_string()], // "b" depends on external only
1088            ),
1089        ];
1090
1091        mark_internal_dependencies(&mut workspaces);
1092
1093        let ws_a = workspaces
1094            .iter()
1095            .find(|(ws, _)| ws.name == "@scope/a")
1096            .unwrap();
1097        assert!(
1098            !ws_a.0.is_internal_dependency,
1099            "a is not depended on by others"
1100        );
1101
1102        let ws_b = workspaces
1103            .iter()
1104            .find(|(ws, _)| ws.name == "@scope/b")
1105            .unwrap();
1106        assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
1107    }
1108
1109    #[test]
1110    fn mark_internal_deps_no_cross_references() {
1111        let temp_dir = tempfile::tempdir().expect("create temp dir");
1112        let pkg_a = temp_dir.path().join("a");
1113        let pkg_b = temp_dir.path().join("b");
1114        std::fs::create_dir_all(&pkg_a).unwrap();
1115        std::fs::create_dir_all(&pkg_b).unwrap();
1116
1117        let mut workspaces = vec![
1118            (
1119                WorkspaceInfo {
1120                    root: pkg_a,
1121                    name: "a".to_string(),
1122                    is_internal_dependency: false,
1123                },
1124                vec!["react".to_string()],
1125            ),
1126            (
1127                WorkspaceInfo {
1128                    root: pkg_b,
1129                    name: "b".to_string(),
1130                    is_internal_dependency: false,
1131                },
1132                vec!["lodash".to_string()],
1133            ),
1134        ];
1135
1136        mark_internal_dependencies(&mut workspaces);
1137
1138        assert!(!workspaces[0].0.is_internal_dependency);
1139        assert!(!workspaces[1].0.is_internal_dependency);
1140    }
1141
1142    #[test]
1143    fn mark_internal_deps_deduplicates_by_path() {
1144        let temp_dir = tempfile::tempdir().expect("create temp dir");
1145        let pkg_a = temp_dir.path().join("a");
1146        std::fs::create_dir_all(&pkg_a).unwrap();
1147
1148        let mut workspaces = vec![
1149            (
1150                WorkspaceInfo {
1151                    root: pkg_a.clone(),
1152                    name: "a".to_string(),
1153                    is_internal_dependency: false,
1154                },
1155                vec![],
1156            ),
1157            (
1158                WorkspaceInfo {
1159                    root: pkg_a,
1160                    name: "a".to_string(),
1161                    is_internal_dependency: false,
1162                },
1163                vec![],
1164            ),
1165        ];
1166
1167        mark_internal_dependencies(&mut workspaces);
1168        assert_eq!(
1169            workspaces.len(),
1170            1,
1171            "duplicate paths should be deduplicated"
1172        );
1173    }
1174
1175    #[test]
1176    fn collect_patterns_from_package_json() {
1177        let dir = tempfile::tempdir().expect("create temp dir");
1178        std::fs::write(
1179            dir.path().join("package.json"),
1180            r#"{"workspaces": ["packages/*", "apps/*"]}"#,
1181        )
1182        .unwrap();
1183
1184        let patterns =
1185            collect_workspace_patterns(dir.path(), &[]).expect("valid root package.json");
1186        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
1187    }
1188
1189    #[test]
1190    fn collect_patterns_from_pnpm_workspace() {
1191        let dir = tempfile::tempdir().expect("create temp dir");
1192        std::fs::write(
1193            dir.path().join("pnpm-workspace.yaml"),
1194            "packages:\n  - 'packages/*'\n  - 'libs/*'\n",
1195        )
1196        .unwrap();
1197
1198        let patterns = collect_workspace_patterns(dir.path(), &[]).expect("no root package.json");
1199        assert_eq!(patterns, vec!["packages/*", "libs/*"]);
1200    }
1201
1202    #[test]
1203    fn collect_patterns_combines_sources() {
1204        let dir = tempfile::tempdir().expect("create temp dir");
1205        std::fs::write(
1206            dir.path().join("package.json"),
1207            r#"{"workspaces": ["packages/*"]}"#,
1208        )
1209        .unwrap();
1210        std::fs::write(
1211            dir.path().join("pnpm-workspace.yaml"),
1212            "packages:\n  - 'apps/*'\n",
1213        )
1214        .unwrap();
1215
1216        let patterns =
1217            collect_workspace_patterns(dir.path(), &[]).expect("valid root package.json");
1218        assert!(patterns.contains(&"packages/*".to_string()));
1219        assert!(patterns.contains(&"apps/*".to_string()));
1220    }
1221
1222    #[test]
1223    fn collect_patterns_empty_when_no_configs() {
1224        let dir = tempfile::tempdir().expect("create temp dir");
1225        let patterns = collect_workspace_patterns(dir.path(), &[]).expect("no root package.json");
1226        assert!(patterns.is_empty());
1227    }
1228
1229    #[test]
1230    fn discover_workspaces_from_package_json() {
1231        let dir = tempfile::tempdir().expect("create temp dir");
1232        let pkg_a = dir.path().join("packages").join("a");
1233        let pkg_b = dir.path().join("packages").join("b");
1234        std::fs::create_dir_all(&pkg_a).unwrap();
1235        std::fs::create_dir_all(&pkg_b).unwrap();
1236
1237        std::fs::write(
1238            dir.path().join("package.json"),
1239            r#"{"workspaces": ["packages/*"]}"#,
1240        )
1241        .unwrap();
1242        std::fs::write(
1243            pkg_a.join("package.json"),
1244            r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
1245        )
1246        .unwrap();
1247        std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
1248
1249        let workspaces = discover_workspaces(dir.path());
1250        assert_eq!(workspaces.len(), 2);
1251
1252        let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
1253        assert!(!ws_a.is_internal_dependency);
1254
1255        let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
1256        assert!(ws_b.is_internal_dependency, "b is depended on by a");
1257    }
1258
1259    #[test]
1260    fn configured_patterns_add_workspaces_beyond_the_manifest() {
1261        let dir = tempfile::tempdir().expect("create temp dir");
1262        let app = dir.path().join("apps").join("deep").join("one");
1263        std::fs::create_dir_all(&app).unwrap();
1264        std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
1265        std::fs::write(app.join("package.json"), r#"{"name": "@test/one"}"#).unwrap();
1266
1267        assert!(
1268            discover_workspaces(dir.path()).is_empty(),
1269            "the manifest declares no workspace"
1270        );
1271
1272        let patterns = vec!["apps/deep/*".to_string()];
1273        let workspaces = discover_workspaces_with_patterns(dir.path(), &patterns);
1274        assert_eq!(workspaces.len(), 1);
1275        assert_eq!(workspaces[0].name, "@test/one");
1276    }
1277
1278    #[test]
1279    fn configured_patterns_and_manifest_patterns_add_up_without_duplicates() {
1280        let dir = tempfile::tempdir().expect("create temp dir");
1281        for name in ["a", "b"] {
1282            let pkg = dir.path().join("packages").join(name);
1283            std::fs::create_dir_all(&pkg).unwrap();
1284            std::fs::write(
1285                pkg.join("package.json"),
1286                format!(r#"{{"name": "@test/{name}"}}"#),
1287            )
1288            .unwrap();
1289        }
1290        std::fs::write(
1291            dir.path().join("package.json"),
1292            r#"{"workspaces": ["packages/a"]}"#,
1293        )
1294        .unwrap();
1295
1296        let patterns = vec!["packages/*".to_string()];
1297        let workspaces = discover_workspaces_with_patterns(dir.path(), &patterns);
1298        let mut names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1299        names.sort_unstable();
1300        assert_eq!(names, vec!["@test/a", "@test/b"]);
1301    }
1302
1303    #[test]
1304    fn configured_patterns_keep_the_shallow_package_fallback() {
1305        let dir = tempfile::tempdir().expect("create temp dir");
1306        let shallow = dir.path().join("packages").join("a");
1307        let deep = dir.path().join("apps").join("deep").join("one");
1308        std::fs::create_dir_all(&shallow).unwrap();
1309        std::fs::create_dir_all(&deep).unwrap();
1310        std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
1311        std::fs::write(shallow.join("package.json"), r#"{"name": "@test/a"}"#).unwrap();
1312        std::fs::write(deep.join("package.json"), r#"{"name": "@test/one"}"#).unwrap();
1313
1314        let patterns = vec!["apps/deep/*".to_string()];
1315        let workspaces = discover_workspaces_with_patterns(dir.path(), &patterns);
1316        let mut names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1317        names.sort_unstable();
1318        assert_eq!(
1319            names,
1320            vec!["@test/a", "@test/one"],
1321            "a package found without any manifest declaration stays found"
1322        );
1323    }
1324
1325    #[test]
1326    fn discover_workspaces_empty_project() {
1327        let dir = tempfile::tempdir().expect("create temp dir");
1328        let workspaces = discover_workspaces(dir.path());
1329        assert!(workspaces.is_empty());
1330    }
1331
1332    #[test]
1333    fn discover_workspaces_falls_back_to_shallow_packages_without_workspace_config() {
1334        let dir = tempfile::tempdir().expect("create temp dir");
1335        let benchmarks = dir.path().join("benchmarks");
1336        let vscode = dir.path().join("editors").join("vscode");
1337        let deep = dir.path().join("tests").join("fixtures").join("demo");
1338        std::fs::create_dir_all(&benchmarks).unwrap();
1339        std::fs::create_dir_all(&vscode).unwrap();
1340        std::fs::create_dir_all(&deep).unwrap();
1341
1342        std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1343        std::fs::write(vscode.join("package.json"), r#"{"name": "fallow-vscode"}"#).unwrap();
1344        std::fs::write(deep.join("package.json"), r#"{"name": "deep-fixture"}"#).unwrap();
1345
1346        let workspaces = discover_workspaces(dir.path());
1347        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1348
1349        assert!(
1350            names.contains(&"benchmarks"),
1351            "top-level nested package should be discovered: {workspaces:?}"
1352        );
1353        assert!(
1354            names.contains(&"fallow-vscode"),
1355            "second-level nested package should be discovered: {workspaces:?}"
1356        );
1357        assert!(
1358            !names.contains(&"deep-fixture"),
1359            "fallback should stay shallow and skip deep fixtures: {workspaces:?}"
1360        );
1361    }
1362
1363    #[test]
1364    fn discover_workspaces_with_negated_patterns() {
1365        let dir = tempfile::tempdir().expect("create temp dir");
1366        let pkg_a = dir.path().join("packages").join("a");
1367        let pkg_test = dir.path().join("packages").join("test-utils");
1368        std::fs::create_dir_all(&pkg_a).unwrap();
1369        std::fs::create_dir_all(&pkg_test).unwrap();
1370
1371        std::fs::write(
1372            dir.path().join("package.json"),
1373            r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
1374        )
1375        .unwrap();
1376        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1377        std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
1378
1379        let workspaces = discover_workspaces(dir.path());
1380        assert_eq!(workspaces.len(), 1);
1381        assert_eq!(workspaces[0].name, "a");
1382    }
1383
1384    #[test]
1385    fn discover_workspaces_skips_root_as_workspace() {
1386        let dir = tempfile::tempdir().expect("create temp dir");
1387        std::fs::write(
1388            dir.path().join("pnpm-workspace.yaml"),
1389            "packages:\n  - '.'\n",
1390        )
1391        .unwrap();
1392        std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
1393
1394        let workspaces = discover_workspaces(dir.path());
1395        assert!(
1396            workspaces.is_empty(),
1397            "root directory should not be added as workspace"
1398        );
1399    }
1400
1401    #[test]
1402    fn discover_workspaces_name_fallback_to_dir_name() {
1403        let dir = tempfile::tempdir().expect("create temp dir");
1404        let pkg_a = dir.path().join("packages").join("my-app");
1405        std::fs::create_dir_all(&pkg_a).unwrap();
1406
1407        std::fs::write(
1408            dir.path().join("package.json"),
1409            r#"{"workspaces": ["packages/*"]}"#,
1410        )
1411        .unwrap();
1412        std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
1413
1414        let workspaces = discover_workspaces(dir.path());
1415        assert_eq!(workspaces.len(), 1);
1416        assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
1417    }
1418
1419    #[test]
1420    fn discover_workspaces_explicit_patterns_disable_shallow_fallback() {
1421        let dir = tempfile::tempdir().expect("create temp dir");
1422        let pkg_a = dir.path().join("packages").join("a");
1423        let benchmarks = dir.path().join("benchmarks");
1424        std::fs::create_dir_all(&pkg_a).unwrap();
1425        std::fs::create_dir_all(&benchmarks).unwrap();
1426
1427        std::fs::write(
1428            dir.path().join("package.json"),
1429            r#"{"workspaces": ["packages/*"]}"#,
1430        )
1431        .unwrap();
1432        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1433        std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1434
1435        let workspaces = discover_workspaces(dir.path());
1436        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1437
1438        assert_eq!(workspaces.len(), 1);
1439        assert!(names.contains(&"a"));
1440        assert!(
1441            !names.contains(&"benchmarks"),
1442            "explicit workspace config should keep undeclared packages out: {workspaces:?}"
1443        );
1444    }
1445
1446    #[test]
1447    fn discover_workspaces_recovers_package_under_bare_glob_intermediate() {
1448        // Issue #842 (reporter metrists/metrists): root declares
1449        // `["./packages/*", "./themes/*"]`, but the real package lives two levels
1450        // deep at `packages/themes/metrists-theme-next` while `packages/themes`
1451        // itself has no package.json. The single-level glob only matches the bare
1452        // `packages/themes`; without recovery the deep package is never discovered,
1453        // its files fall back to the root manifest, and its declared deps (react)
1454        // are reported as unlisted. Discovery must recover the named deep package.
1455        let dir = tempfile::tempdir().expect("create temp dir");
1456        let theme = dir
1457            .path()
1458            .join("packages")
1459            .join("themes")
1460            .join("metrists-theme-next");
1461        std::fs::create_dir_all(&theme).unwrap();
1462        std::fs::write(
1463            dir.path().join("package.json"),
1464            r#"{"name": "metrists-monorepo", "workspaces": ["./packages/*", "./themes/*"]}"#,
1465        )
1466        .unwrap();
1467        // packages/themes intentionally has NO package.json (bare grouping dir).
1468        std::fs::write(
1469            theme.join("package.json"),
1470            r#"{"name": "metrists-theme-next", "dependencies": {"react": "^18"}}"#,
1471        )
1472        .unwrap();
1473
1474        let workspaces = discover_workspaces(dir.path());
1475        assert!(
1476            workspaces.iter().any(|ws| ws.name == "metrists-theme-next"),
1477            "deep package under a bare glob-matched intermediate must be discovered: {workspaces:?}"
1478        );
1479    }
1480
1481    fn write_package(dir: &Path, manifest: &str) {
1482        std::fs::create_dir_all(dir).unwrap();
1483        std::fs::write(dir.join("package.json"), manifest).unwrap();
1484    }
1485
1486    #[test]
1487    fn discover_workspaces_follows_deep_link_dependency_targets() {
1488        // A yarn-era monorepo (for example older Kibana) has no `workspaces`
1489        // field. The root lists each package as a `link:` or `file:`
1490        // dependency, three to five directories deep.
1491        let dir = tempfile::tempdir().expect("create temp dir");
1492        let root = dir.path();
1493        std::fs::write(
1494            root.join("package.json"),
1495            r#"{
1496              "name": "kibana",
1497              "dependencies": {
1498                "@kbn/foo": "link:src/platform/packages/shared/kbn-foo",
1499                "@kbn/app": "link:./x-pack/solutions/search/plugins/app"
1500              },
1501              "devDependencies": {
1502                "@kbn/test": "file:src/platform/packages/private/kbn-test"
1503              }
1504            }"#,
1505        )
1506        .unwrap();
1507        write_package(
1508            &root.join("src/platform/packages/shared/kbn-foo"),
1509            r#"{"name": "@kbn/foo"}"#,
1510        );
1511        write_package(
1512            &root.join("x-pack/solutions/search/plugins/app"),
1513            r#"{"name": "@kbn/app", "dependencies": {"@kbn/foo": "link:../../../../../src/platform/packages/shared/kbn-foo"}}"#,
1514        );
1515        write_package(
1516            &root.join("src/platform/packages/private/kbn-test"),
1517            r#"{"name": "@kbn/test"}"#,
1518        );
1519
1520        let workspaces = discover_workspaces(root);
1521        let mut names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1522        names.sort_unstable();
1523        assert_eq!(names, ["@kbn/app", "@kbn/foo", "@kbn/test"]);
1524        let foo = workspaces.iter().find(|ws| ws.name == "@kbn/foo").unwrap();
1525        assert!(foo.is_internal_dependency, "{workspaces:?}");
1526    }
1527
1528    #[test]
1529    fn discover_workspaces_skips_link_targets_outside_root_or_without_manifest() {
1530        let dir = tempfile::tempdir().expect("create temp dir");
1531        let root = dir.path().join("repo");
1532        std::fs::create_dir_all(&root).unwrap();
1533        write_package(&dir.path().join("sibling"), r#"{"name": "sibling"}"#);
1534        std::fs::create_dir_all(root.join("packages/no-manifest")).unwrap();
1535        std::fs::write(root.join("vendor.tgz"), "").unwrap();
1536        std::fs::write(
1537            root.join("package.json"),
1538            r#"{
1539              "dependencies": {
1540                "sibling": "link:../sibling",
1541                "absolute": "file:/definitely/not/here",
1542                "missing": "link:packages/missing",
1543                "no-manifest": "link:packages/no-manifest",
1544                "tarball": "file:./vendor.tgz",
1545                "self": "link:.",
1546                "react": "^18.0.0"
1547              }
1548            }"#,
1549        )
1550        .unwrap();
1551
1552        let workspaces = discover_workspaces(&root);
1553        assert!(workspaces.is_empty(), "{workspaces:?}");
1554    }
1555
1556    #[test]
1557    fn discover_workspaces_skips_link_targets_that_source_discovery_skips() {
1558        let dir = tempfile::tempdir().expect("create temp dir");
1559        let root = dir.path();
1560        std::fs::write(
1561            root.join("package.json"),
1562            r#"{
1563              "dependencies": {
1564                "ylib": "file:.yalc/ylib",
1565                "nm": "link:node_modules/nm",
1566                "blib": "link:build/blib",
1567                "dlib": "link:dist/dlib",
1568                "clib": "link:packages/coverage/clib",
1569                "vlib": "link:vendor/deep/vlib",
1570                "kept": "link:libs/deep/kept"
1571              }
1572            }"#,
1573        )
1574        .unwrap();
1575        for (path, name) in [
1576            (".yalc/ylib", "ylib"),
1577            ("node_modules/nm", "nm"),
1578            ("build/blib", "blib"),
1579            ("dist/dlib", "dlib"),
1580            ("packages/coverage/clib", "clib"),
1581            ("vendor/deep/vlib", "vlib"),
1582            ("libs/deep/kept", "kept"),
1583        ] {
1584            write_package(&root.join(path), &format!(r#"{{"name": "{name}"}}"#));
1585        }
1586        let mut builder = globset::GlobSetBuilder::new();
1587        builder.add(globset::Glob::new("vendor/**").unwrap());
1588        let ignore = crate::IgnorePatternSet::from(builder.build().unwrap());
1589
1590        let (workspaces, _) = discover_workspaces_with_diagnostics(root, &ignore).unwrap();
1591        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1592        assert_eq!(names, ["kept"]);
1593    }
1594
1595    fn write_gitignored_link_fixture(root: &Path) {
1596        std::fs::write(
1597            root.join("package.json"),
1598            r#"{
1599              "dependencies": {
1600                "vlib": "file:vendor/deep/vlib",
1601                "plib": "link:libs/private/plib",
1602                "kept": "link:libs/deep/kept"
1603              }
1604            }"#,
1605        )
1606        .unwrap();
1607        for (path, name) in [
1608            ("vendor/deep/vlib", "vlib"),
1609            ("libs/private/plib", "plib"),
1610            ("libs/deep/kept", "kept"),
1611        ] {
1612            write_package(&root.join(path), &format!(r#"{{"name": "{name}"}}"#));
1613        }
1614        std::fs::write(root.join(".gitignore"), "vendor/\n").unwrap();
1615        std::fs::write(root.join("libs/.gitignore"), "private/\n").unwrap();
1616    }
1617
1618    fn link_workspace_names(root: &Path) -> Vec<String> {
1619        let ignore = crate::IgnorePatternSet::default();
1620        let (workspaces, _) = discover_workspaces_with_diagnostics(root, &ignore).unwrap();
1621        let mut names: Vec<String> = workspaces.into_iter().map(|ws| ws.name).collect();
1622        names.sort_unstable();
1623        names
1624    }
1625
1626    #[test]
1627    fn discover_workspaces_skips_gitignored_link_targets() {
1628        let dir = tempfile::tempdir().expect("create temp dir");
1629        let root = dir.path();
1630        std::fs::create_dir_all(root.join(".git")).unwrap();
1631        write_gitignored_link_fixture(root);
1632
1633        assert_eq!(link_workspace_names(root), ["kept"]);
1634    }
1635
1636    #[test]
1637    fn discover_workspaces_keeps_link_targets_when_gitignore_does_not_apply() {
1638        // Source discovery honors `.gitignore` only inside a git repository.
1639        let dir = tempfile::tempdir().expect("create temp dir");
1640        let root = dir.path();
1641        write_gitignored_link_fixture(root);
1642
1643        assert_eq!(link_workspace_names(root), ["kept", "plib", "vlib"]);
1644    }
1645
1646    #[test]
1647    fn discover_workspaces_dedupes_link_targets_with_declared_workspaces() {
1648        let dir = tempfile::tempdir().expect("create temp dir");
1649        let root = dir.path();
1650        std::fs::write(
1651            root.join("package.json"),
1652            r#"{
1653              "workspaces": ["packages/*"],
1654              "dependencies": {
1655                "a": "link:packages/a",
1656                "tool": "link:tools/deep/tool"
1657              }
1658            }"#,
1659        )
1660        .unwrap();
1661        write_package(&root.join("packages/a"), r#"{"name": "a"}"#);
1662        write_package(&root.join("tools/deep/tool"), r#"{"name": "tool"}"#);
1663
1664        let workspaces = discover_workspaces(root);
1665        let mut names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1666        names.sort_unstable();
1667        assert_eq!(names, ["a", "tool"]);
1668        assert!(
1669            find_undeclared_workspaces(root, &workspaces).is_empty(),
1670            "a linked package is declared, not undeclared"
1671        );
1672    }
1673
1674    #[test]
1675    fn undeclared_workspace_detected() {
1676        let dir = tempfile::tempdir().expect("create temp dir");
1677        let pkg_a = dir.path().join("packages").join("a");
1678        let pkg_b = dir.path().join("packages").join("b");
1679        std::fs::create_dir_all(&pkg_a).unwrap();
1680        std::fs::create_dir_all(&pkg_b).unwrap();
1681
1682        std::fs::write(
1683            dir.path().join("package.json"),
1684            r#"{"workspaces": ["packages/a"]}"#,
1685        )
1686        .unwrap();
1687        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1688        std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1689
1690        let declared = discover_workspaces(dir.path());
1691        assert_eq!(declared.len(), 1);
1692
1693        let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1694        assert_eq!(undeclared.len(), 1);
1695        assert!(
1696            undeclared[0]
1697                .path
1698                .to_string_lossy()
1699                .replace('\\', "/")
1700                .contains("packages/b"),
1701            "should detect packages/b as undeclared: {:?}",
1702            undeclared[0].path
1703        );
1704    }
1705
1706    #[test]
1707    fn no_undeclared_when_all_covered() {
1708        let dir = tempfile::tempdir().expect("create temp dir");
1709        let pkg_a = dir.path().join("packages").join("a");
1710        std::fs::create_dir_all(&pkg_a).unwrap();
1711
1712        std::fs::write(
1713            dir.path().join("package.json"),
1714            r#"{"workspaces": ["packages/*"]}"#,
1715        )
1716        .unwrap();
1717        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1718
1719        let declared = discover_workspaces(dir.path());
1720        let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1721        assert!(undeclared.is_empty());
1722    }
1723
1724    #[test]
1725    fn no_undeclared_when_no_workspace_patterns() {
1726        let dir = tempfile::tempdir().expect("create temp dir");
1727        let sub = dir.path().join("lib");
1728        std::fs::create_dir_all(&sub).unwrap();
1729
1730        std::fs::write(dir.path().join("package.json"), r#"{"name": "app"}"#).unwrap();
1731        std::fs::write(sub.join("package.json"), r#"{"name": "lib"}"#).unwrap();
1732
1733        let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1734        assert!(
1735            undeclared.is_empty(),
1736            "should skip check when no workspace patterns exist"
1737        );
1738    }
1739
1740    #[test]
1741    fn undeclared_skips_node_modules_and_hidden_dirs() {
1742        let dir = tempfile::tempdir().expect("create temp dir");
1743        let nm = dir.path().join("node_modules").join("some-pkg");
1744        let hidden = dir.path().join(".hidden");
1745        std::fs::create_dir_all(&nm).unwrap();
1746        std::fs::create_dir_all(&hidden).unwrap();
1747
1748        std::fs::write(
1749            dir.path().join("package.json"),
1750            r#"{"workspaces": ["packages/*"]}"#,
1751        )
1752        .unwrap();
1753        std::fs::write(nm.join("package.json"), r#"{"name": "nm-pkg"}"#).unwrap();
1754        std::fs::write(hidden.join("package.json"), r#"{"name": "hidden"}"#).unwrap();
1755
1756        let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1757        assert!(
1758            undeclared.is_empty(),
1759            "should not flag node_modules or hidden directories"
1760        );
1761    }
1762
1763    fn build_globset(patterns: &[&str]) -> crate::IgnorePatternSet {
1764        let mut builder = globset::GlobSetBuilder::new();
1765        for pattern in patterns {
1766            builder.add(globset::Glob::new(pattern).expect("valid glob"));
1767        }
1768        crate::IgnorePatternSet::from(builder.build().expect("build globset"))
1769    }
1770
1771    #[test]
1772    fn undeclared_skips_dirs_matching_ignore_patterns() {
1773        let dir = tempfile::tempdir().expect("create temp dir");
1774        let pkg_a = dir.path().join("packages").join("a");
1775        let vitest_ref = dir.path().join("references").join("vitest");
1776        let tanstack_ref = dir.path().join("references").join("tanstack-router");
1777        std::fs::create_dir_all(&pkg_a).unwrap();
1778        std::fs::create_dir_all(&vitest_ref).unwrap();
1779        std::fs::create_dir_all(&tanstack_ref).unwrap();
1780
1781        std::fs::write(
1782            dir.path().join("package.json"),
1783            r#"{"workspaces": ["packages/*"]}"#,
1784        )
1785        .unwrap();
1786        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1787        std::fs::write(
1788            vitest_ref.join("package.json"),
1789            r#"{"name": "vitest-reference"}"#,
1790        )
1791        .unwrap();
1792        std::fs::write(
1793            tanstack_ref.join("package.json"),
1794            r#"{"name": "tanstack-reference"}"#,
1795        )
1796        .unwrap();
1797
1798        let declared = discover_workspaces(dir.path());
1799        let ignore = build_globset(&["references/*"]);
1800        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1801        assert!(
1802            undeclared.is_empty(),
1803            "references/* should be ignored: {undeclared:?}"
1804        );
1805    }
1806
1807    #[test]
1808    fn undeclared_still_reported_when_ignore_does_not_match() {
1809        let dir = tempfile::tempdir().expect("create temp dir");
1810        let pkg_b = dir.path().join("packages").join("b");
1811        std::fs::create_dir_all(&pkg_b).unwrap();
1812
1813        std::fs::write(
1814            dir.path().join("package.json"),
1815            r#"{"workspaces": ["packages/a"]}"#,
1816        )
1817        .unwrap();
1818        std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1819
1820        let declared = discover_workspaces(dir.path());
1821        let ignore = build_globset(&["references/*"]);
1822        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1823        assert_eq!(
1824            undeclared.len(),
1825            1,
1826            "non-matching ignore patterns should not silence other undeclared dirs"
1827        );
1828    }
1829
1830    #[test]
1831    fn undeclared_skips_dirs_matching_package_json_glob() {
1832        let dir = tempfile::tempdir().expect("create temp dir");
1833        let pkg_a = dir.path().join("packages").join("a");
1834        let vitest_ref = dir.path().join("references").join("vitest");
1835        std::fs::create_dir_all(&pkg_a).unwrap();
1836        std::fs::create_dir_all(&vitest_ref).unwrap();
1837
1838        std::fs::write(
1839            dir.path().join("package.json"),
1840            r#"{"workspaces": ["packages/*"]}"#,
1841        )
1842        .unwrap();
1843        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1844        std::fs::write(
1845            vitest_ref.join("package.json"),
1846            r#"{"name": "vitest-reference"}"#,
1847        )
1848        .unwrap();
1849
1850        let declared = discover_workspaces(dir.path());
1851        let ignore = build_globset(&["references/*/package.json"]);
1852        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1853        assert!(
1854            undeclared.is_empty(),
1855            "package.json-suffixed glob should silence the warning: {undeclared:?}"
1856        );
1857    }
1858
1859    #[test]
1860    fn undeclared_skips_dirs_matching_doublestar_ignore() {
1861        let dir = tempfile::tempdir().expect("create temp dir");
1862        let pkg_a = dir.path().join("packages").join("a");
1863        let nested_ref = dir.path().join("references").join("vitest");
1864        std::fs::create_dir_all(&pkg_a).unwrap();
1865        std::fs::create_dir_all(&nested_ref).unwrap();
1866
1867        std::fs::write(
1868            dir.path().join("package.json"),
1869            r#"{"workspaces": ["packages/*"]}"#,
1870        )
1871        .unwrap();
1872        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1873        std::fs::write(
1874            nested_ref.join("package.json"),
1875            r#"{"name": "vitest-reference"}"#,
1876        )
1877        .unwrap();
1878
1879        let declared = discover_workspaces(dir.path());
1880        let ignore = build_globset(&["**/references/**"]);
1881        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1882        assert!(
1883            undeclared.is_empty(),
1884            "**/references/** should ignore nested package.json dirs: {undeclared:?}"
1885        );
1886    }
1887
1888    #[test]
1889    fn malformed_workspace_package_json_emits_diagnostic() {
1890        let dir = tempfile::tempdir().expect("create temp dir");
1891        let pkg_a = dir.path().join("packages").join("a");
1892        let pkg_bad = dir.path().join("packages").join("bad");
1893        std::fs::create_dir_all(&pkg_a).unwrap();
1894        std::fs::create_dir_all(&pkg_bad).unwrap();
1895        std::fs::write(
1896            dir.path().join("package.json"),
1897            r#"{"workspaces": ["packages/*"]}"#,
1898        )
1899        .unwrap();
1900        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1901        std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad",}"#).unwrap();
1902
1903        let (result, captured) = capture_workspace_warnings(|| {
1904            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
1905        });
1906        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1907
1908        assert_eq!(workspaces.len(), 1, "the valid workspace still discovers");
1909        assert_eq!(workspaces[0].name, "a");
1910        assert_eq!(diagnostics.len(), 1);
1911        assert!(matches!(
1912            diagnostics[0].kind,
1913            WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1914        ));
1915        assert!(
1916            captured
1917                .iter()
1918                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1919        );
1920    }
1921
1922    #[test]
1923    fn malformed_bridge_deno_config_emits_recoverable_member_diagnostic() {
1924        let dir = tempfile::tempdir().expect("create temp dir");
1925        let pkg_good = dir.path().join("packages/good");
1926        let pkg_bad = dir.path().join("packages/bad");
1927        std::fs::create_dir_all(&pkg_good).unwrap();
1928        std::fs::create_dir_all(&pkg_bad).unwrap();
1929        std::fs::write(
1930            dir.path().join("package.json"),
1931            r#"{"workspaces": ["packages/*"]}"#,
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#"{"name": "bad"}"#).unwrap();
1936        std::fs::write(pkg_bad.join("deno.jsonc"), "{ imports: [ }").unwrap();
1937
1938        let result =
1939            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
1940        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1941
1942        assert_eq!(workspaces.len(), 1, "valid sibling should still discover");
1943        assert_eq!(workspaces[0].name, "good");
1944        assert_eq!(diagnostics.len(), 1);
1945        assert!(matches!(
1946            diagnostics[0].kind,
1947            WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1948        ));
1949        assert!(diagnostics[0].message.contains("deno.jsonc"));
1950    }
1951
1952    /// A member reached through two workspace sources (npm glob + tsconfig
1953    /// reference) is diagnosed, and diagnosed ONCE: the two sources produce
1954    /// byte-identical diagnostics, so the discovery fold reports the member
1955    /// once where every envelope used to report it twice (issue #2366).
1956    ///
1957    /// That the tsconfig leg replayed the memoized `Err` rather than silently
1958    /// resolving is NOT observable here (both `Ok(None)` and `Err` fall back
1959    /// to the directory name, and the fold hides a second diagnostic);
1960    /// [`manifest_cache_replays_the_same_err_on_every_visit`] pins it.
1961    #[test]
1962    fn malformed_member_reached_via_two_sources_is_diagnosed_once() {
1963        let dir = tempfile::tempdir().expect("create temp dir");
1964        let pkg_good = dir.path().join("packages").join("good");
1965        let pkg_bad = dir.path().join("packages").join("bad");
1966        std::fs::create_dir_all(&pkg_good).unwrap();
1967        std::fs::create_dir_all(&pkg_bad).unwrap();
1968        std::fs::write(
1969            dir.path().join("package.json"),
1970            r#"{"workspaces": ["packages/*"]}"#,
1971        )
1972        .unwrap();
1973        std::fs::write(
1974            dir.path().join("tsconfig.json"),
1975            r#"{"references": [{"path": "./packages/bad"}]}"#,
1976        )
1977        .unwrap();
1978        std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1979        std::fs::write(pkg_bad.join("package.json"), r"{,}").unwrap();
1980
1981        let (result, _) = capture_workspace_warnings(|| {
1982            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
1983        });
1984        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1985
1986        assert!(
1987            workspaces.iter().any(|w| w.name == "good"),
1988            "valid sibling should still discover: {workspaces:?}"
1989        );
1990        assert!(
1991            workspaces.iter().any(|w| w.name == "bad"),
1992            "tsconfig reference falls back to the directory name on manifest error"
1993        );
1994        let malformed: Vec<_> = diagnostics
1995            .iter()
1996            .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1997            .collect();
1998        assert_eq!(
1999            malformed.len(),
2000            1,
2001            "the replayed Err is one diagnostic, not one per source: {diagnostics:?}"
2002        );
2003        assert!(
2004            malformed[0].path.ends_with("bad"),
2005            "the diagnostic points at the malformed member"
2006        );
2007    }
2008
2009    /// The per-discovery [`ManifestCache`] memoizes `Err` outcomes and every
2010    /// repeat visit must get the same `Err` back. Both callers that can reach
2011    /// one member ([`register_matched_workspace`] for an npm glob and
2012    /// [`load_tsconfig_workspace_package`] for a tsconfig reference) diagnose
2013    /// from the outcome the cache hands them, so a replay that degraded to
2014    /// `Ok(None)` would silently drop the diagnostic on whichever source is
2015    /// second. No end-to-end assertion can witness that: `Ok(None)` and `Err`
2016    /// both fall back to the directory name for the workspace, and the
2017    /// discovery fold collapses the two byte-identical diagnostics into one
2018    /// either way.
2019    #[test]
2020    fn manifest_cache_replays_the_same_err_on_every_visit() {
2021        let dir = tempfile::tempdir().expect("create temp dir");
2022        let member = dir.path().join("packages").join("bad");
2023        std::fs::create_dir_all(&member).unwrap();
2024        std::fs::write(member.join("package.json"), r"{,}").unwrap();
2025
2026        let mut cache = ManifestCache::default();
2027        let first = load_member_package_manifest_cached(&member, &mut cache)
2028            .expect_err("a malformed member manifest is an Err");
2029        assert_eq!(cache.len(), 1, "the outcome is memoized: {cache:?}");
2030
2031        std::fs::remove_file(member.join("package.json")).unwrap();
2032        let replayed = load_member_package_manifest_cached(&member, &mut cache)
2033            .expect_err("the memoized Err replays instead of re-reading the directory");
2034        assert_eq!(
2035            replayed, first,
2036            "the replayed outcome is byte-identical to the first, so both \
2037             workspace sources diagnose the same member the same way"
2038        );
2039    }
2040
2041    #[test]
2042    fn multiple_malformed_workspace_package_jsons_all_diagnosed() {
2043        let dir = tempfile::tempdir().expect("create temp dir");
2044        for name in ["a", "b", "c"] {
2045            let pkg = dir.path().join("packages").join(name);
2046            std::fs::create_dir_all(&pkg).unwrap();
2047            std::fs::write(pkg.join("package.json"), r"{,}").unwrap();
2048        }
2049        std::fs::write(
2050            dir.path().join("package.json"),
2051            r#"{"workspaces": ["packages/*"]}"#,
2052        )
2053        .unwrap();
2054
2055        let (result, _) = capture_workspace_warnings(|| {
2056            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
2057        });
2058        let (workspaces, diagnostics) = result.expect("root package.json is valid");
2059
2060        assert!(workspaces.is_empty(), "all three malformed; nothing valid");
2061        assert_eq!(diagnostics.len(), 3, "each malformed workspace surfaces");
2062        assert!(
2063            diagnostics
2064                .iter()
2065                .all(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
2066            "every diagnostic should be malformed-package-json"
2067        );
2068    }
2069
2070    #[test]
2071    fn malformed_root_package_json_returns_load_error() {
2072        let dir = tempfile::tempdir().expect("create temp dir");
2073        std::fs::write(dir.path().join("package.json"), "this is not json").unwrap();
2074
2075        let result =
2076            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2077
2078        match result {
2079            Err(WorkspaceLoadError::MalformedRootPackageJson { path, error }) => {
2080                assert!(path.ends_with("package.json"));
2081                assert!(!error.is_empty(), "underlying parse error is preserved");
2082            }
2083            other => panic!("expected MalformedRootPackageJson, got {other:?}"),
2084        }
2085    }
2086
2087    #[test]
2088    fn glob_match_without_package_json_emits_diagnostic_unless_skip_listed() {
2089        let dir = tempfile::tempdir().expect("create temp dir");
2090        let pkg_a = dir.path().join("packages").join("a");
2091        let cache_dir = dir.path().join("packages").join(".cache");
2092        let scratch_dir = dir.path().join("packages").join("scratch");
2093        std::fs::create_dir_all(&pkg_a).unwrap();
2094        std::fs::create_dir_all(&cache_dir).unwrap();
2095        std::fs::create_dir_all(&scratch_dir).unwrap();
2096        std::fs::write(
2097            dir.path().join("package.json"),
2098            r#"{"workspaces": ["packages/*"]}"#,
2099        )
2100        .unwrap();
2101        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
2102
2103        let result =
2104            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2105        let (workspaces, diagnostics) = result.expect("root package.json is valid");
2106
2107        assert_eq!(workspaces.len(), 1);
2108        let kinds: Vec<&str> = diagnostics.iter().map(|d| d.kind.id()).collect();
2109        assert!(
2110            kinds.contains(&"glob-matched-no-package-json"),
2111            "scratch should diagnose: {kinds:?}"
2112        );
2113        assert!(
2114            !diagnostics.iter().any(|d| d.path.ends_with(".cache")),
2115            ".cache must be skip-listed: {diagnostics:?}"
2116        );
2117    }
2118
2119    #[test]
2120    fn glob_match_without_package_json_honors_ignore_patterns() {
2121        let dir = tempfile::tempdir().expect("create temp dir");
2122        let pkg_a = dir.path().join("packages").join("a");
2123        let legacy_dir = dir.path().join("packages").join("legacy");
2124        std::fs::create_dir_all(&pkg_a).unwrap();
2125        std::fs::create_dir_all(&legacy_dir).unwrap();
2126        std::fs::write(
2127            dir.path().join("package.json"),
2128            r#"{"workspaces": ["packages/*"]}"#,
2129        )
2130        .unwrap();
2131        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
2132
2133        let mut builder = globset::GlobSetBuilder::new();
2134        builder.add(globset::Glob::new("packages/legacy").unwrap());
2135        let ignore = crate::IgnorePatternSet::from(builder.build().unwrap());
2136
2137        let result = discover_workspaces_with_diagnostics(dir.path(), &ignore);
2138        let (workspaces, diagnostics) = result.expect("root package.json is valid");
2139
2140        assert_eq!(workspaces.len(), 1);
2141        assert!(
2142            diagnostics.is_empty(),
2143            "user-excluded path must not produce a diagnostic: {diagnostics:?}"
2144        );
2145    }
2146
2147    #[test]
2148    fn malformed_tsconfig_emits_diagnostic() {
2149        let dir = tempfile::tempdir().expect("create temp dir");
2150        std::fs::write(
2151            dir.path().join("package.json"),
2152            r#"{"workspaces": ["packages/*"]}"#,
2153        )
2154        .unwrap();
2155        std::fs::write(dir.path().join("tsconfig.json"), r#"{"references": [,,,]}"#).unwrap();
2156
2157        let result =
2158            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2159        let (_, diagnostics) = result.expect("root package.json is valid");
2160
2161        assert!(
2162            diagnostics
2163                .iter()
2164                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
2165            "expected MalformedTsconfig diagnostic; got: {diagnostics:?}"
2166        );
2167    }
2168
2169    #[test]
2170    fn tsconfig_missing_reference_dir_emits_diagnostic() {
2171        let dir = tempfile::tempdir().expect("create temp dir");
2172        std::fs::write(
2173            dir.path().join("tsconfig.json"),
2174            r#"{"references": [{"path": "./packages/missing"}]}"#,
2175        )
2176        .unwrap();
2177
2178        let result =
2179            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2180        let (_, diagnostics) = result.expect("no package.json at root is OK");
2181
2182        assert!(
2183            diagnostics
2184                .iter()
2185                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::TsconfigReferenceDirMissing)),
2186            "expected TsconfigReferenceDirMissing; got: {diagnostics:?}"
2187        );
2188    }
2189
2190    #[test]
2191    fn missing_tsconfig_is_silent() {
2192        let dir = tempfile::tempdir().expect("create temp dir");
2193
2194        let result =
2195            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2196        let (_, diagnostics) = result.expect("no root package.json is OK");
2197
2198        assert!(
2199            !diagnostics
2200                .iter()
2201                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
2202            "missing tsconfig must not produce MalformedTsconfig: {diagnostics:?}"
2203        );
2204    }
2205
2206    #[test]
2207    fn shallow_scan_malformed_package_json_stays_silent() {
2208        let dir = tempfile::tempdir().expect("create temp dir");
2209        let scratch = dir.path().join("scratch");
2210        std::fs::create_dir_all(&scratch).unwrap();
2211        std::fs::write(scratch.join("package.json"), r"{not valid json}").unwrap();
2212
2213        let result =
2214            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2215        let (_, diagnostics) = result.expect("no root package.json is OK");
2216
2217        assert!(
2218            !diagnostics
2219                .iter()
2220                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
2221            "shallow-scan malformed must stay silent: {diagnostics:?}"
2222        );
2223    }
2224
2225    #[test]
2226    fn mixed_valid_and_malformed_workspaces_partial_recovery() {
2227        let dir = tempfile::tempdir().expect("create temp dir");
2228        let pkg_good = dir.path().join("packages").join("good");
2229        let pkg_bad = dir.path().join("packages").join("bad");
2230        std::fs::create_dir_all(&pkg_good).unwrap();
2231        std::fs::create_dir_all(&pkg_bad).unwrap();
2232        std::fs::write(
2233            dir.path().join("package.json"),
2234            r#"{"workspaces": ["packages/*"]}"#,
2235        )
2236        .unwrap();
2237        std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
2238        std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
2239
2240        let result =
2241            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2242        let (workspaces, diagnostics) = result.expect("root package.json is valid");
2243
2244        assert_eq!(workspaces.len(), 1);
2245        assert_eq!(workspaces[0].name, "good");
2246        assert_eq!(diagnostics.len(), 1);
2247        assert_eq!(diagnostics[0].kind.id(), "malformed-package-json");
2248    }
2249
2250    #[test]
2251    fn discover_workspaces_back_compat_drops_diagnostics_and_errors() {
2252        let dir = tempfile::tempdir().expect("create temp dir");
2253        std::fs::write(dir.path().join("package.json"), r"{bad json").unwrap();
2254
2255        let workspaces = discover_workspaces(dir.path());
2256        assert!(
2257            workspaces.is_empty(),
2258            "back-compat wrapper returns empty on root-malformed: {workspaces:?}"
2259        );
2260    }
2261
2262    #[test]
2263    fn discovers_deno_workspace_members_without_package_json() {
2264        let dir = tempfile::tempdir().expect("create temp dir");
2265        let core = dir.path().join("packages").join("core");
2266        let app = dir.path().join("apps").join("desktop");
2267        std::fs::create_dir_all(&core).unwrap();
2268        std::fs::create_dir_all(&app).unwrap();
2269
2270        std::fs::write(
2271            dir.path().join("deno.json"),
2272            r#"{
2273              "workspace": ["./apps/*", "./packages/*"],
2274              "imports": { "@std/assert": "jsr:@std/assert@1" }
2275            }"#,
2276        )
2277        .unwrap();
2278        std::fs::write(
2279            core.join("deno.json"),
2280            r#"{
2281              "name": "@fallow/core",
2282              "exports": { ".": "./mod.ts", "./result": "./result.ts" }
2283            }"#,
2284        )
2285        .unwrap();
2286        std::fs::write(
2287            app.join("deno.json"),
2288            r#"{ "name": "@fallow/desktop", "exports": { ".": "./main.ts" } }"#,
2289        )
2290        .unwrap();
2291
2292        let workspaces = discover_workspaces(dir.path());
2293        let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
2294        names.sort_unstable();
2295        assert_eq!(names, vec!["@fallow/core", "@fallow/desktop"]);
2296        assert!(
2297            workspaces
2298                .iter()
2299                .all(|w| !w.root.join("package.json").exists())
2300        );
2301        assert!(
2302            workspaces.iter().all(|w| !w.is_internal_dependency),
2303            "Deno packages need dependency evidence before inventory marks them internal"
2304        );
2305    }
2306
2307    #[test]
2308    fn malformed_root_deno_config_returns_load_error() {
2309        let dir = tempfile::tempdir().expect("create temp dir");
2310        std::fs::write(dir.path().join("deno.jsonc"), "{ workspace: [ }").unwrap();
2311
2312        let result =
2313            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2314
2315        match result {
2316            Err(WorkspaceLoadError::MalformedRootDenoConfig { path, error }) => {
2317                assert!(path.ends_with("deno.jsonc"));
2318                assert!(!error.is_empty(), "underlying parse error is preserved");
2319            }
2320            other => panic!("expected MalformedRootDenoConfig, got {other:?}"),
2321        }
2322    }
2323
2324    #[test]
2325    fn deno_and_npm_workspace_patterns_are_additive() {
2326        let dir = tempfile::tempdir().expect("create temp dir");
2327        let a = dir.path().join("packages").join("a");
2328        let b = dir.path().join("packages").join("b");
2329        std::fs::create_dir_all(&a).unwrap();
2330        std::fs::create_dir_all(&b).unwrap();
2331
2332        std::fs::write(
2333            dir.path().join("package.json"),
2334            r#"{"workspaces": ["packages/a"]}"#,
2335        )
2336        .unwrap();
2337        std::fs::write(a.join("package.json"), r#"{"name": "a"}"#).unwrap();
2338        std::fs::write(
2339            dir.path().join("deno.json"),
2340            r#"{"workspace": ["./packages/b"]}"#,
2341        )
2342        .unwrap();
2343        std::fs::write(b.join("deno.json"), r#"{"name": "b"}"#).unwrap();
2344
2345        let workspaces = discover_workspaces(dir.path());
2346        let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
2347        names.sort_unstable();
2348        assert_eq!(names, vec!["a", "b"]);
2349    }
2350
2351    /// Issue #2366: `package.json` `workspaces` and `pnpm-workspace.yaml`
2352    /// `packages` are additive sources, so one glob declared in both is walked
2353    /// twice. Deduplicating at the discovery choke point is what lets the JSON
2354    /// envelopes report one entry per distinct matching pattern and lets the
2355    /// aggregated stderr warning count the directories it actually found.
2356    #[test]
2357    fn one_glob_declared_in_two_manifests_reports_each_directory_once() {
2358        let dir = tempfile::tempdir().expect("create temp dir");
2359        write_two_manifest_glob_project(dir.path());
2360
2361        let (_, diagnostics) =
2362            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
2363                .expect("root package.json is valid");
2364
2365        let mut reported: Vec<(String, String)> = diagnostics
2366            .iter()
2367            .filter_map(|diagnostic| match &diagnostic.kind {
2368                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => Some((
2369                    pattern.clone(),
2370                    diagnostic
2371                        .path
2372                        .strip_prefix(dir.path())
2373                        .unwrap_or(&diagnostic.path)
2374                        .display()
2375                        .to_string()
2376                        .replace('\\', "/"),
2377                )),
2378                _ => None,
2379            })
2380            .collect();
2381        reported.sort();
2382
2383        assert_eq!(
2384            reported,
2385            vec![
2386                ("pkgs/*".to_owned(), "pkgs/aaa".to_owned()),
2387                ("pkgs/*".to_owned(), "pkgs/bbb".to_owned()),
2388            ],
2389            "one glob spelled two ways is one diagnostic per directory: {diagnostics:?}"
2390        );
2391    }
2392
2393    /// The control for the fold above: the key is the WHOLE kind, payload
2394    /// included. Two overlapping globs each report the same package-less
2395    /// directory with their own `pattern`, and both survive.
2396    #[test]
2397    fn overlapping_globs_still_report_one_directory_once_per_pattern() {
2398        let dir = tempfile::tempdir().expect("create temp dir");
2399        std::fs::create_dir_all(dir.path().join("pkgs/aaa")).unwrap();
2400        std::fs::write(
2401            dir.path().join("package.json"),
2402            r#"{"name":"overlap-root","private":true,"workspaces":["pkgs/*","pkgs/a*"]}"#,
2403        )
2404        .unwrap();
2405        std::fs::write(dir.path().join("pkgs/aaa/readme.txt"), "no package.json\n").unwrap();
2406
2407        let (_, diagnostics) =
2408            discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
2409                .expect("root package.json is valid");
2410
2411        let patterns: Vec<&str> = diagnostics
2412            .iter()
2413            .filter_map(|diagnostic| match &diagnostic.kind {
2414                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
2415                    Some(pattern.as_str())
2416                }
2417                _ => None,
2418            })
2419            .collect();
2420
2421        assert_eq!(
2422            patterns,
2423            ["pkgs/*", "pkgs/a*"],
2424            "distinct patterns are distinct diagnostics: {diagnostics:?}"
2425        );
2426    }
2427
2428    /// A workspace entry that is absolute or whose `..` segments lead above the
2429    /// root can name
2430    /// directories outside the project root. Discovery skips it before any
2431    /// disk walk and reports the entry and the file that declares it, never a
2432    /// path outside the root. The in-root entry still finds its package.
2433    #[test]
2434    fn workspace_entries_outside_the_root_are_skipped_before_the_walk() {
2435        let sandbox = tempfile::tempdir().expect("create temp dir");
2436        let outside = sandbox.path().join("outside");
2437        std::fs::create_dir_all(outside.join("no-manifest")).unwrap();
2438        std::fs::create_dir_all(outside.join("pkg-out")).unwrap();
2439        std::fs::write(
2440            outside.join("pkg-out/package.json"),
2441            r#"{"name":"pkg-out"}"#,
2442        )
2443        .unwrap();
2444        let root = sandbox.path().join("repo");
2445        std::fs::create_dir_all(root.join("packages/pkg-in")).unwrap();
2446        std::fs::write(
2447            root.join("packages/pkg-in/package.json"),
2448            r#"{"name":"pkg-in"}"#,
2449        )
2450        .unwrap();
2451        let absolute = format!("{}/*", outside.display()).replace('\\', "/");
2452        std::fs::write(
2453            root.join("package.json"),
2454            serde_json::json!({
2455                "name": "root",
2456                "private": true,
2457                "workspaces": ["packages/*", "../outside/*", absolute],
2458            })
2459            .to_string(),
2460        )
2461        .unwrap();
2462        std::fs::write(
2463            root.join("pnpm-workspace.yaml"),
2464            "packages:\n  - packages/*\n  - ../outside/**\n",
2465        )
2466        .unwrap();
2467
2468        let (workspaces, diagnostics) =
2469            discover_workspaces_with_diagnostics(&root, &crate::IgnorePatternSet::empty())
2470                .expect("root package.json is valid");
2471
2472        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
2473        assert_eq!(names, ["pkg-in"], "only the in-root package is a workspace");
2474
2475        let canonical_root = dunce::canonicalize(&root).unwrap();
2476        for diagnostic in &diagnostics {
2477            let path =
2478                dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
2479            assert!(
2480                path.starts_with(&canonical_root),
2481                "no diagnostic names a path outside the root: {diagnostic:?}"
2482            );
2483            assert!(
2484                !diagnostic.message.contains("no-manifest")
2485                    && !diagnostic.message.contains("pkg-out"),
2486                "no message names an outside directory: {diagnostic:?}"
2487            );
2488        }
2489
2490        let mut skipped: Vec<(String, String)> = diagnostics
2491            .iter()
2492            .filter(|diagnostic| diagnostic.kind.id() == "workspace-glob-outside-root")
2493            .map(|diagnostic| {
2494                (
2495                    fallow_types::path_util::display_relative(&root, &diagnostic.path),
2496                    diagnostic.message.clone(),
2497                )
2498            })
2499            .collect();
2500        skipped.sort();
2501        let files: Vec<&str> = skipped.iter().map(|(file, _)| file.as_str()).collect();
2502        assert_eq!(
2503            files,
2504            ["package.json", "package.json", "pnpm-workspace.yaml"],
2505            "one diagnostic per skipped entry, anchored at its file: {diagnostics:?}"
2506        );
2507        assert!(
2508            skipped
2509                .iter()
2510                .any(|(_, message)| message.contains("'../outside/*'")),
2511            "the message names the entry: {skipped:?}"
2512        );
2513    }
2514
2515    /// A Deno workspace entry gets the same skip. A `..` that stays inside the
2516    /// root and a negated outside entry read no outside directory, so they get
2517    /// no diagnostic, and the in-root package is still found.
2518    #[test]
2519    fn deno_entries_and_in_root_dot_dot_segments_follow_the_root_boundary() {
2520        let sandbox = tempfile::tempdir().expect("create temp dir");
2521        std::fs::create_dir_all(sandbox.path().join("outside/no-manifest")).unwrap();
2522        let root = sandbox.path().join("repo");
2523        std::fs::create_dir_all(root.join("packages/pkg-in")).unwrap();
2524        std::fs::write(
2525            root.join("packages/pkg-in/deno.json"),
2526            r#"{"name":"@x/pkg-in"}"#,
2527        )
2528        .unwrap();
2529        std::fs::write(
2530            root.join("deno.json"),
2531            r#"{"workspace":["packages/../packages/*","../outside/*","!../outside/no-manifest"]}"#,
2532        )
2533        .unwrap();
2534
2535        let (workspaces, diagnostics) =
2536            discover_workspaces_with_diagnostics(&root, &crate::IgnorePatternSet::empty())
2537                .expect("root deno.json is valid");
2538
2539        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
2540        assert_eq!(
2541            names,
2542            ["@x/pkg-in"],
2543            "the in-root `..` entry still finds its package"
2544        );
2545        let [diagnostic] = diagnostics.as_slice() else {
2546            panic!("expected one diagnostic: {diagnostics:?}");
2547        };
2548        assert_eq!(diagnostic.path, root.join("deno.json"));
2549        assert_eq!(
2550            diagnostic.kind,
2551            WorkspaceDiagnosticKind::WorkspaceGlobOutsideRoot {
2552                pattern: "../outside/*".to_owned()
2553            }
2554        );
2555    }
2556
2557    /// A fallow config `workspaces.patterns` entry has no manifest, so its
2558    /// diagnostic is anchored at the root and its message names the setting.
2559    #[test]
2560    fn configured_pattern_outside_the_root_names_the_config_setting() {
2561        let dir = tempfile::tempdir().expect("create temp dir");
2562        std::fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#).unwrap();
2563
2564        let (_, diagnostics) = discover_workspaces_with_diagnostics_and_patterns(
2565            dir.path(),
2566            &crate::IgnorePatternSet::empty(),
2567            &["../shared/*".to_owned()],
2568        )
2569        .expect("root package.json is valid");
2570
2571        let [diagnostic] = diagnostics.as_slice() else {
2572            panic!("expected one diagnostic: {diagnostics:?}");
2573        };
2574        assert_eq!(diagnostic.path, Path::new("."));
2575        assert_eq!(
2576            diagnostic.kind,
2577            WorkspaceDiagnosticKind::WorkspaceGlobOutsideRoot {
2578                pattern: "../shared/*".to_owned()
2579            }
2580        );
2581        assert!(
2582            diagnostic
2583                .message
2584                .contains("fallow config `workspaces.patterns`"),
2585            "{diagnostic:?}"
2586        );
2587    }
2588    mod layout_tests;
2589}