Skip to main content

fallow_config/workspace/
mod.rs

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