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