Skip to main content

fallow_config/workspace/
mod.rs

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