Skip to main content

fallow_config/workspace/
mod.rs

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