diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs index 7a255b8d..6415355b 100644 --- a/crates/vite_task_plan/src/error.rs +++ b/crates/vite_task_plan/src/error.rs @@ -119,7 +119,7 @@ pub enum Error { #[error(transparent)] TaskRecursionDetected(#[from] TaskRecursionError), - #[error("Invalid vite task command: {program} with args {args:?} under cwd {cwd:?}")] + #[error("Invalid vite task command: {program} with args {args:?} under cwd \"{cwd}\"")] ParsePlanRequest { program: Str, args: Arc<[Str]>, diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/package.json new file mode 100644 index 00000000..b65cafad --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/package.json @@ -0,0 +1,4 @@ +{ + "name": "test-workspace", + "private": true +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/app/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/app/package.json new file mode 100644 index 00000000..3632bbf6 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/app/package.json @@ -0,0 +1,13 @@ +{ + "name": "@test/app", + "version": "1.0.0", + "scripts": { + "build": "echo 'Building @test/app'" + }, + "dependencies": { + "@test/utils": "workspace:*" + }, + "devDependencies": { + "@test/core": "workspace:*" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/core/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/core/package.json new file mode 100644 index 00000000..5fa64938 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/core/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/core", + "version": "1.0.0", + "scripts": { + "build": "echo 'Building @test/core'" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/utils/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/utils/package.json new file mode 100644 index 00000000..95e18952 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/packages/utils/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/utils", + "version": "1.0.0", + "scripts": { + "build": "echo 'Building @test/utils'" + }, + "dependencies": { + "@test/core": "workspace:*" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/pnpm-workspace.yaml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots.toml new file mode 100644 index 00000000..a52a797a --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots.toml @@ -0,0 +1,61 @@ +# Tests --direct / -d flag: select direct dependencies only (one hop). +# Topology: app → utils (dependency), app → core (devDependency), utils → core +# Both dependency types are traversed. + +# -d from app: should select utils (dep) and core (devDep), NOT app itself. +[[plan]] +compact = true +name = "dependencies from app" +args = ["run", "-d", "build"] +cwd = "packages/app" + +# -d from utils: should select core only (direct dep), NOT utils itself. +[[plan]] +compact = true +name = "dependencies from utils" +args = ["run", "-d", "build"] +cwd = "packages/utils" + +# -d from core: core has no deps, so no packages selected. +[[plan]] +compact = true +name = "dependencies from leaf package" +args = ["run", "-d", "build"] +cwd = "packages/core" + +# Contrast with -t from app: should select app, utils, AND core (full transitive). +[[plan]] +compact = true +name = "transitive from app for comparison" +args = ["run", "-t", "build"] +cwd = "packages/app" + +# -d with package specifier: direct deps of @test/app. +[[plan]] +compact = true +name = "dependencies with package specifier" +args = ["run", "-d", "@test/app#build"] + +# -d with --workspace-root: direct deps of the workspace root package. +[[plan]] +compact = true +name = "dependencies with workspace root" +args = ["run", "-d", "-w", "build"] + +# Error: -d and -r conflict. +[[plan]] +compact = true +name = "direct with recursive conflict" +args = ["run", "-d", "-r", "build"] + +# Error: -d and -t conflict. +[[plan]] +compact = true +name = "direct with transitive conflict" +args = ["run", "-d", "-t", "build"] + +# Error: -d and --filter conflict. +[[plan]] +compact = true +name = "direct with filter conflict" +args = ["run", "-d", "--filter", "@test/app", "build"] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from app.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from app.snap new file mode 100644 index 00000000..59b4f44e --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from app.snap @@ -0,0 +1,17 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-d" + - build + cwd: packages/app +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +{ + "packages/core#build": [], + "packages/utils#build": [ + "packages/core#build" + ] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from leaf package.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from leaf package.snap new file mode 100644 index 00000000..6662ac66 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from leaf package.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-d" + - build + cwd: packages/core +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +{} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from utils.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from utils.snap new file mode 100644 index 00000000..7ceb02c5 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies from utils.snap @@ -0,0 +1,14 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-d" + - build + cwd: packages/utils +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +{ + "packages/core#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies with package specifier.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies with package specifier.snap new file mode 100644 index 00000000..8fb198b9 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies with package specifier.snap @@ -0,0 +1,16 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-d" + - "@test/app#build" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +{ + "packages/core#build": [], + "packages/utils#build": [ + "packages/core#build" + ] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies with workspace root.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies with workspace root.snap new file mode 100644 index 00000000..fa253f2c --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - dependencies with workspace root.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-d" + - "-w" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +{} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with filter conflict.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with filter conflict.snap new file mode 100644 index 00000000..acc0ec88 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with filter conflict.snap @@ -0,0 +1,13 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: err_str.as_ref() +info: + args: + - run + - "-d" + - "--filter" + - "@test/app" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +Invalid vite task command: vt with args [] under cwd "/": --filter and --direct cannot be used together diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with recursive conflict.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with recursive conflict.snap new file mode 100644 index 00000000..c63778bb --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with recursive conflict.snap @@ -0,0 +1,16 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: err +info: + args: + - run + - "-d" + - "-r" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +error: the argument '--direct' cannot be used with '--recursive' + +Usage: vt run --direct ... + +For more information, try '--help'. diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with transitive conflict.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with transitive conflict.snap new file mode 100644 index 00000000..3a5df9f0 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - direct with transitive conflict.snap @@ -0,0 +1,16 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: err +info: + args: + - run + - "-d" + - "-t" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +error: the argument '--direct' cannot be used with '--transitive' + +Usage: vt run --direct ... + +For more information, try '--help'. diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - transitive from app for comparison.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - transitive from app for comparison.snap new file mode 100644 index 00000000..08fd0429 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/query - transitive from app for comparison.snap @@ -0,0 +1,21 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-t" + - build + cwd: packages/app +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +{ + "packages/app#build": [ + "packages/core#build", + "packages/utils#build" + ], + "packages/core#build": [], + "packages/utils#build": [ + "packages/core#build" + ] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/task graph.snap new file mode 100644 index 00000000..8ce12eef --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies/snapshots/task graph.snap @@ -0,0 +1,109 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: task_graph_json +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/direct-dependencies +--- +[ + { + "key": [ + "/packages/app", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/app", + "task_name": "build", + "package_path": "/packages/app" + }, + "resolved_config": { + "command": "echo 'Building @test/app'", + "resolved_options": { + "cwd": "/packages/app", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + }, + { + "key": [ + "/packages/core", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/core", + "task_name": "build", + "package_path": "/packages/core" + }, + "resolved_config": { + "command": "echo 'Building @test/core'", + "resolved_options": { + "cwd": "/packages/core", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + }, + { + "key": [ + "/packages/utils", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/utils", + "task_name": "build", + "package_path": "/packages/utils" + }, + "resolved_config": { + "command": "echo 'Building @test/utils'", + "resolved_options": { + "cwd": "/packages/utils", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + } +] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots.toml index c5f5f8cb..c8bd97a2 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots.toml +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots.toml @@ -40,3 +40,18 @@ cwd = "packages/middle" compact = true name = "transitive with package specifier lacking task" args = ["run", "-t", "@test/middle#build"] + +# -d from top: direct deps are {middle}. middle lacks build → empty. +# Unlike -t which walks transitively through middle to find bottom. +[[plan]] +compact = true +name = "direct from top stops at direct deps" +args = ["run", "-d", "build"] +cwd = "packages/top" + +# -d from middle: direct dep is {bottom}. bottom has build → bottom#build. +[[plan]] +compact = true +name = "direct from middle finds direct dep" +args = ["run", "-d", "build"] +cwd = "packages/middle" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - direct from middle finds direct dep.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - direct from middle finds direct dep.snap new file mode 100644 index 00000000..1fc8b795 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - direct from middle finds direct dep.snap @@ -0,0 +1,14 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-d" + - build + cwd: packages/middle +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate +--- +{ + "packages/bottom#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - direct from top stops at direct deps.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - direct from top stops at direct deps.snap new file mode 100644 index 00000000..27e90860 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - direct from top stops at direct deps.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-d" + - build + cwd: packages/top +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate +--- +{} diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index 07fc7fde..cda77981 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -170,6 +170,12 @@ pub(crate) struct GraphTraversal { /// Produced by `^` in `foo^...` (keep dependencies, drop foo) /// or `...^foo` (keep dependents, drop foo). pub(crate) exclude_self: bool, + + /// When `true`, only traverse one hop (direct dependencies/dependents). + /// When `false`, traverse the full transitive closure. + /// + /// Produced by `--dependencies` / `-d`. + pub(crate) direct_only: bool, } /// A single package filter, corresponding to one `--filter` argument. @@ -215,8 +221,8 @@ pub enum PackageFilterParseError { /// Errors that can occur when converting [`PackageQueryArgs`] into a [`PackageQuery`]. #[derive(Debug, thiserror::Error)] pub enum PackageQueryError { - #[error("--recursive and --transitive cannot be used together")] - RecursiveTransitiveConflict, + #[error("--recursive, --transitive, and --direct are mutually exclusive")] + ConflictingTraversalModes, #[error("--filter and --transitive cannot be used together")] FilterWithTransitive, @@ -224,6 +230,9 @@ pub enum PackageQueryError { #[error("--filter and --recursive cannot be used together")] FilterWithRecursive, + #[error("--filter and --direct cannot be used together")] + DirectWithFilter, + #[error("cannot specify package name with --recursive")] PackageNameWithRecursive { package_name: Str }, @@ -240,20 +249,65 @@ pub enum PackageQueryError { InvalidFilter(#[from] PackageFilterParseError), } +/// How to traverse the package dependency graph when selecting packages. +/// +/// These modes are mutually exclusive at the CLI level (`-r`, `-t`, `-d`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TraversalMode { + /// `--recursive` / `-r`: select all packages in the workspace. + Recursive, + /// `--transitive` / `-t`: select the current package and its full transitive dependencies. + Transitive, + /// `--direct` / `-d`: select only the direct dependencies of the current package (one hop, excluding self). + Direct, +} + +impl TraversalMode { + /// Convert to the internal graph traversal specification. + /// + /// `Recursive` and `Transitive` produce the same graph traversal; they differ + /// in how seeds are chosen (all packages vs. the current package), which is + /// decided by the caller in [`PackageQueryArgs::into_package_query`]. + pub(crate) const fn to_graph_traversal(self) -> GraphTraversal { + match self { + Self::Recursive | Self::Transitive => GraphTraversal { + direction: TraversalDirection::Dependencies, + exclude_self: false, + direct_only: false, + }, + Self::Direct => GraphTraversal { + direction: TraversalDirection::Dependencies, + exclude_self: true, + direct_only: true, + }, + } + } +} + /// CLI arguments for selecting which packages a command applies to. /// /// Use `#[clap(flatten)]` to embed these in a parent clap struct. /// Call [`into_package_query`](Self::into_package_query) to convert into an opaque [`PackageQuery`]. +#[expect( + clippy::struct_excessive_bools, + reason = "clap requires separate bool fields for the mutually-exclusive traversal flags \ + (-r/-t/-d) plus the independent -w flag; they are resolved into `TraversalMode` \ + via `TraversalMode::from_flags`" +)] #[derive(Debug, Clone, PartialEq, Eq, clap::Args)] pub struct PackageQueryArgs { /// Select all packages in the workspace. - #[clap(default_value = "false", short, long)] + #[clap(default_value = "false", short, long, group = "traversal")] recursive: bool, /// Select the current package and its transitive dependencies. - #[clap(default_value = "false", short, long)] + #[clap(default_value = "false", short, long, group = "traversal")] transitive: bool, + /// Select the direct dependencies of the current package. + #[clap(default_value = "false", short, long, group = "traversal")] + direct: bool, + /// Select the workspace root package. #[clap(default_value = "false", short = 'w', long = "workspace-root")] workspace_root: bool, @@ -277,6 +331,27 @@ Match packages by name, directory, or glob pattern. filters: Vec, } +impl TraversalMode { + /// Resolve three mutually exclusive traversal flags into an `Option`. + /// + /// Clap's `group = "traversal"` enforces mutual exclusivity at parse time. + /// This function also handles direct struct construction (e.g. in tests). + const fn from_flags( + recursive: bool, + transitive: bool, + direct: bool, + ) -> Result, PackageQueryError> { + match (recursive, transitive, direct) { + (false, false, false) => Ok(None), + (true, false, false) => Ok(Some(Self::Recursive)), + (false, true, false) => Ok(Some(Self::Transitive)), + (false, false, true) => Ok(Some(Self::Direct)), + // Clap group prevents this from CLI; guard for direct construction. + _ => Err(PackageQueryError::ConflictingTraversalModes), + } + } +} + impl PackageQueryArgs { /// Convert CLI arguments into an opaque [`PackageQuery`]. /// @@ -284,68 +359,60 @@ impl PackageQueryArgs { /// `cwd` is the working directory (used as fallback when no package name or filter is given). /// /// Returns `(query, is_cwd_only)` where `is_cwd_only` is `true` when the query - /// falls through to the implicit-cwd path (no `-r`, `-t`, `-w`, `--filter`, + /// falls through to the implicit-cwd path (no `-r`, `-t`, `-d`, `-w`, `--filter`, /// or explicit package name). /// /// # Errors /// /// Returns [`PackageQueryError`] if conflicting flags are set, a package name /// is specified with `--recursive` or `--filter`, or a filter expression is invalid. - #[expect(clippy::too_many_lines, reason = "single exhaustive match")] pub fn into_package_query( self, package_name: Option, cwd: &Arc, ) -> Result<(PackageQuery, bool), PackageQueryError> { - let Self { recursive, transitive, workspace_root, filters } = self; - - // Collect filter tokens from all `--filter` arguments, splitting on whitespace. - let mut filter_tokens = Vec::::with_capacity(filters.len()); - for filter in filters { - let mut is_empty = true; - for filter_token in filter.split_ascii_whitespace() { - is_empty = false; - filter_tokens.push(filter_token.into()); - } - // Error if any --filter value is empty or whitespace-only. - if is_empty { - return Err(PackageQueryError::EmptyFilter); - } - } - // We have checked that filter_tokens is non-empty if any filters were provided, - // If no tokens are collected, it means no filters were provided. - let filter_tokens: Option> = Vec1::try_from_vec(filter_tokens).ok(); + let Self { recursive, transitive, direct, workspace_root, filters } = self; + let traversal_mode = TraversalMode::from_flags(recursive, transitive, direct)?; + let filter_tokens = collect_filter_tokens(filters)?; // Error arms only match the conflicting fields (wildcards for the rest). // Success arms explicitly match every field — no wildcards. - match (recursive, transitive, workspace_root, filter_tokens, package_name) { + match (traversal_mode, workspace_root, filter_tokens, package_name) { // ------------------------- error cases -------------------------------- - // --recursive --transitive - (true, true, _, _, _) => Err(PackageQueryError::RecursiveTransitiveConflict), // --recursive --filter - (true, _, _, Some(_), _) => Err(PackageQueryError::FilterWithRecursive), + (Some(TraversalMode::Recursive), _, Some(_), _) => { + Err(PackageQueryError::FilterWithRecursive) + } // --recursive # - (true, false, _, _, Some(package_name)) => { + (Some(TraversalMode::Recursive), _, _, Some(package_name)) => { Err(PackageQueryError::PackageNameWithRecursive { package_name }) } // --transitive --filter - (false, true, _, Some(_), _) => Err(PackageQueryError::FilterWithTransitive), + (Some(TraversalMode::Transitive), _, Some(_), _) => { + Err(PackageQueryError::FilterWithTransitive) + } + // --direct --filter + (Some(TraversalMode::Direct), _, Some(_), _) => { + Err(PackageQueryError::DirectWithFilter) + } // --filter # - (_, _, _, Some(_), Some(package_name)) => { + (_, _, Some(_), Some(package_name)) => { Err(PackageQueryError::PackageNameWithFilter { package_name }) } // --workspace-root # - (_, _, true, _, Some(package_name)) => { + (_, true, _, Some(package_name)) => { Err(PackageQueryError::PackageNameWithWorkspaceRoot { package_name }) } // ------------------------ success cases ------------------------------- // --recursive (--workspace-root is redundant) - (true, false, true | false, None, None) => Ok((PackageQuery::all(), false)), + (Some(TraversalMode::Recursive), true | false, None, None) => { + Ok((PackageQuery::all(), false)) + } // --filter [--workspace-root] - (false, false, workspace_root, Some(filter_tokens), None) => { + (None, workspace_root, Some(filter_tokens), None) => { let mut parsed: Vec1 = filter_tokens.try_mapped(|f| parse_filter(&f, cwd))?; if workspace_root { @@ -358,16 +425,14 @@ impl PackageQueryArgs { } Ok((PackageQuery::filters(parsed), false)) } - // --workspace-root [--transitive] - (false, transitive, true, None, None) => { - let traversal = if transitive { - Some(GraphTraversal { - direction: TraversalDirection::Dependencies, - exclude_self: false, - }) - } else { - None - }; + // --workspace-root [--transitive|--direct] + ( + mode @ (None | Some(TraversalMode::Transitive | TraversalMode::Direct)), + true, + None, + None, + ) => { + let traversal = mode.map(TraversalMode::to_graph_traversal); Ok(( PackageQuery::filters(Vec1::new(PackageFilter { exclude: false, @@ -378,16 +443,14 @@ impl PackageQueryArgs { false, )) } - // [--transitive] # - (false, transitive, false, None, Some(name)) => { - let traversal = if transitive { - Some(GraphTraversal { - direction: TraversalDirection::Dependencies, - exclude_self: false, - }) - } else { - None - }; + // [--transitive|--direct] # + ( + mode @ (None | Some(TraversalMode::Transitive | TraversalMode::Direct)), + false, + None, + Some(name), + ) => { + let traversal = mode.map(TraversalMode::to_graph_traversal); Ok(( PackageQuery::filters(Vec1::new(PackageFilter { exclude: false, @@ -401,21 +464,23 @@ impl PackageQueryArgs { false, )) } - // --transitive - (false, true, false, None, None) => Ok(( + // --transitive | --direct (from cwd) + ( + Some(mode @ (TraversalMode::Transitive | TraversalMode::Direct)), + false, + None, + None, + ) => Ok(( PackageQuery::filters(Vec1::new(PackageFilter { exclude: false, selector: PackageSelector::ContainingPackage(Arc::clone(cwd)), - traversal: Some(GraphTraversal { - direction: TraversalDirection::Dependencies, - exclude_self: false, - }), + traversal: Some(mode.to_graph_traversal()), source: None, })), false, )), // (no flags, implicit cwd) - (false, false, false, None, None) => Ok(( + (None, false, None, None) => Ok(( PackageQuery::filters(Vec1::new(PackageFilter { exclude: false, selector: PackageSelector::ContainingPackage(Arc::clone(cwd)), @@ -428,6 +493,27 @@ impl PackageQueryArgs { } } +/// Collect filter tokens from all `--filter` arguments, splitting each on whitespace. +/// +/// Returns `None` if no filters were provided, `Some` if at least one token was collected. +/// Errors with [`PackageQueryError::EmptyFilter`] if any `--filter` value is empty or +/// whitespace-only. +fn collect_filter_tokens(filters: Vec) -> Result>, PackageQueryError> { + let mut filter_tokens = Vec::::with_capacity(filters.len()); + for filter in filters { + let mut is_empty = true; + for filter_token in filter.split_ascii_whitespace() { + is_empty = false; + filter_tokens.push(filter_token.into()); + } + if is_empty { + return Err(PackageQueryError::EmptyFilter); + } + } + // If no tokens were collected, no filters were provided. + Ok(Vec1::try_from_vec(filter_tokens).ok()) +} + // ──────────────────────────────────────────────────────────────────────────── // Parsing // ──────────────────────────────────────────────────────────────────────────── @@ -476,15 +562,24 @@ pub(crate) fn parse_filter( let exclude_self = deps_exclude_self || dependents_exclude_self; // Step 4–5: build the traversal descriptor. + // Filter-based traversals are always transitive (not direct_only). let traversal = match (include_dependencies, include_dependents) { (false, false) => None, - (true, false) => { - Some(GraphTraversal { direction: TraversalDirection::Dependencies, exclude_self }) - } - (false, true) => { - Some(GraphTraversal { direction: TraversalDirection::Dependents, exclude_self }) - } - (true, true) => Some(GraphTraversal { direction: TraversalDirection::Both, exclude_self }), + (true, false) => Some(GraphTraversal { + direction: TraversalDirection::Dependencies, + exclude_self, + direct_only: false, + }), + (false, true) => Some(GraphTraversal { + direction: TraversalDirection::Dependents, + exclude_self, + direct_only: false, + }), + (true, true) => Some(GraphTraversal { + direction: TraversalDirection::Both, + exclude_self, + direct_only: false, + }), }; // Step 6–9: parse the remaining core selector. @@ -1116,6 +1211,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: true, filters: Vec::new(), }; @@ -1142,6 +1238,7 @@ mod tests { let args = PackageQueryArgs { recursive: true, transitive: false, + direct: false, workspace_root: true, filters: Vec::new(), }; @@ -1160,6 +1257,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: true, + direct: false, workspace_root: true, filters: Vec::new(), }; @@ -1185,6 +1283,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: true, filters: vec![Str::from("foo")], }; @@ -1209,6 +1308,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: true, filters: Vec::new(), }; @@ -1233,6 +1333,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: vec![Str::from("a b")], }; @@ -1253,6 +1354,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: true, filters: vec![Str::from("foo")], }; @@ -1273,6 +1375,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: Vec::new(), }; @@ -1294,6 +1397,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: vec![Str::from("")], }; @@ -1306,6 +1410,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: vec![Str::from(" ")], }; @@ -1318,6 +1423,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: vec![Str::from("foo"), Str::from("")], }; @@ -1330,6 +1436,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: vec![Str::from(""), Str::from("foo")], }; @@ -1342,6 +1449,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: vec![Str::from("foo"), Str::from(" \t ")], }; @@ -1356,6 +1464,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: Vec::new(), }; @@ -1369,6 +1478,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: Vec::new(), }; @@ -1382,6 +1492,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: true, + direct: false, workspace_root: false, filters: Vec::new(), }; @@ -1395,6 +1506,7 @@ mod tests { let args = PackageQueryArgs { recursive: true, transitive: false, + direct: false, workspace_root: false, filters: Vec::new(), }; @@ -1408,6 +1520,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: false, filters: vec![Str::from("foo")], }; @@ -1421,6 +1534,7 @@ mod tests { let args = PackageQueryArgs { recursive: false, transitive: false, + direct: false, workspace_root: true, filters: Vec::new(), }; diff --git a/crates/vite_workspace/src/package_graph.rs b/crates/vite_workspace/src/package_graph.rs index 69908d62..0bdb7617 100644 --- a/crates/vite_workspace/src/package_graph.rs +++ b/crates/vite_workspace/src/package_graph.rs @@ -431,23 +431,39 @@ impl IndexedPackageGraph { let mut reachable = FxHashSet::default(); - match traversal.direction { - TraversalDirection::Dependencies => { - self.bfs_outgoing(&seeds, &mut reachable); - } - TraversalDirection::Dependents => { - self.bfs_incoming(&seeds, &mut reachable); + if traversal.direct_only { + // One-hop traversal: only direct neighbors, no recursive expansion. + match traversal.direction { + TraversalDirection::Dependencies => { + self.direct_outgoing(&seeds, &mut reachable); + } + TraversalDirection::Dependents => { + self.direct_incoming(&seeds, &mut reachable); + } + TraversalDirection::Both => { + self.direct_outgoing(&seeds, &mut reachable); + self.direct_incoming(&seeds, &mut reachable); + } } - TraversalDirection::Both => { - // Walk dependents first, then walk dependencies of ALL dependents found - // (including the original seeds). - // pnpm ref: - let mut dependents = FxHashSet::default(); - self.bfs_incoming(&seeds, &mut dependents); - let all_dep_seeds: FxHashSet<_> = - seeds.iter().chain(dependents.iter()).copied().collect(); - self.bfs_outgoing(&all_dep_seeds, &mut reachable); - reachable.extend(dependents); + } else { + match traversal.direction { + TraversalDirection::Dependencies => { + self.bfs_outgoing(&seeds, &mut reachable); + } + TraversalDirection::Dependents => { + self.bfs_incoming(&seeds, &mut reachable); + } + TraversalDirection::Both => { + // Walk dependents first, then walk dependencies of ALL dependents found + // (including the original seeds). + // pnpm ref: + let mut dependents = FxHashSet::default(); + self.bfs_incoming(&seeds, &mut dependents); + let all_dep_seeds: FxHashSet<_> = + seeds.iter().chain(dependents.iter()).copied().collect(); + self.bfs_outgoing(&all_dep_seeds, &mut reachable); + reachable.extend(dependents); + } } } @@ -462,6 +478,36 @@ impl IndexedPackageGraph { reachable } + /// Collect direct (one-hop) outgoing neighbors of `seeds`. + /// + /// Seeds are NOT added to `out`. + fn direct_outgoing( + &self, + seeds: &FxHashSet, + out: &mut FxHashSet, + ) { + for &node in seeds { + for edge in self.graph.edges(node) { + out.insert(edge.target()); + } + } + } + + /// Collect direct (one-hop) incoming neighbors of `seeds`. + /// + /// Seeds are NOT added to `out`. + fn direct_incoming( + &self, + seeds: &FxHashSet, + out: &mut FxHashSet, + ) { + for &node in seeds { + for edge in self.graph.edges_directed(node, Direction::Incoming) { + out.insert(edge.source()); + } + } + } + /// BFS along outgoing (dependency) edges from `seeds`, collecting all reachable nodes. /// /// Seeds are NOT added to `out`; the caller decides inclusion based on `exclude_self`. diff --git a/docs/depends-on.md b/docs/depends-on.md new file mode 100644 index 00000000..de2c7b77 --- /dev/null +++ b/docs/depends-on.md @@ -0,0 +1,83 @@ +# RFC: Enhanced `dependsOn` Syntax + +## Background + +Today, `dependsOn` entries can only refer to a single task by name (`"build"`) or by package-qualified name (`"pkg#build"`). A common pattern in monorepo task runners is "run `build` in all transitive dependencies first" — tools like Nx (`^build`) and Turborepo (`^build`) support this, but each introduces its own symbol with its own meaning. + +The CLI already supports package selection through flags like `--recursive`, `--transitive`, and `--filter`. Rather than invent yet another DSL with new symbols, we reuse the exact same mental model and syntax from `vp run`. + +### Design principle + +**No new mental models.** If you know how to write `vp run`, you know how to write a `dependsOn` entry. The flag names, filter syntax, and task specifier format are identical. + +## Current Syntax + +```jsonc +{ + "tasks": { + "test": { + "dependsOn": [ + "build", // same-package task + "utils#build", // task in a specific package + ], + }, + }, +} +``` + +These simple forms remain valid and unchanged. + +## Proposed Syntax + +### Object syntax + +Each `dependsOn` element can be an object whose keys mirror the CLI flag names: + +```jsonc +{ + "tasks": { + "test": { + "dependsOn": [ + // Existing syntax — still works as plain strings + "build", + "utils#build", + + // Run `build` across all workspace packages + { "recursive": "build" }, + + // Run `build` in current package and its transitive dependencies + { "transitive": "build" }, + + // Run `build` in packages matching a filter + { "filter": "@myorg/core", "task": "build" }, + { "filter": "@myorg/core...", "task": "build" }, + + // Multiple filters + { "filter": ["@myorg/core", "@myorg/utils"], "task": "build" }, + + // Workspace root + { "workspaceRoot": "build" }, + ], + }, + }, +} +``` + +**Object forms:** + +| Form | Meaning | +| -------------------------------------------------- | ---------------------------------------------------------------- | +| `{ "recursive": "" }` | Run `` across all workspace packages. | +| `{ "transitive": "" }` | Run `` in current package and its transitive dependencies. | +| `{ "filter": "", "task": "" }` | Run `` in packages matching a filter expression. | +| `{ "filter": ["", ""], "task": "" }` | Run `` in packages matching multiple filters. | +| `{ "workspaceRoot": "" }` | Run `` in the workspace root package. | + +The same validation rules from the CLI apply: + +- `recursive`, `transitive`, `filter`, and `workspaceRoot` are mutually exclusive. +- When using `filter`, the task name goes in a separate `task` field (since `filter` takes a pattern as its value). + +## Context: "Current Package" + +When `--transitive` or a filter with traversal suffixes (e.g. `@myorg/core...`) resolves packages, "current package" means the package that owns the task containing this `dependsOn` entry — the same package that would be inferred from an unqualified `"build"` dependency today.