diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/package.json new file mode 100644 index 00000000..20e8e426 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/package.json @@ -0,0 +1,7 @@ +{ + "name": "test-workspace", + "private": true, + "scripts": { + "build": "vp run -r build" + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/packages/a/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/packages/a/package.json new file mode 100644 index 00000000..e0c6c3f2 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/packages/a/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/a", + "version": "1.0.0", + "scripts": { + "build": "echo building-a" + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/packages/b/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/packages/b/package.json new file mode 100644 index 00000000..aa937a79 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/packages/b/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/b", + "version": "1.0.0", + "scripts": { + "build": "echo building-b" + }, + "dependencies": { + "@test/a": "workspace:*" + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/pnpm-workspace.yaml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/snapshots.toml new file mode 100644 index 00000000..2b9d90a9 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/snapshots.toml @@ -0,0 +1,23 @@ +# Tests that workspace root self-referencing tasks don't cause infinite recursion. +# Root build = `vp run -r build` (delegates to all packages recursively). +# +# Skip rule: `vp run -r build` from root produces the same query as the +# nested `vp run -r build` in root's script, so root's expansion is skipped. +# Only packages a and b actually run. +# +# Prune rule: `vp run build` from root produces a ContainingPackage query, +# but root's script `vp run -r build` produces an All query. The queries +# differ so the skip rule doesn't fire. Instead the prune rule removes root +# from the nested result, leaving only a and b. + +[[e2e]] +name = "recursive build skips root self-reference" +steps = [ + "vp run -r build # only a and b run, root is skipped", +] + +[[e2e]] +name = "build from root prunes root from nested expansion" +steps = [ + "vp run build # only a and b run under root, root is pruned", +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/snapshots/build from root prunes root from nested expansion.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/snapshots/build from root prunes root from nested expansion.snap new file mode 100644 index 00000000..0f9548ab --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/snapshots/build from root prunes root from nested expansion.snap @@ -0,0 +1,13 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run build # only a and b run under root, root is pruned +~/packages/a$ echo building-a ⊘ cache disabled +building-a + +~/packages/b$ echo building-b ⊘ cache disabled +building-b + +--- +[vp run] 0/2 cache hit (0%). (Run `vp run --last-details` for full details) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/snapshots/recursive build skips root self-reference.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/snapshots/recursive build skips root self-reference.snap new file mode 100644 index 00000000..ca5942f3 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/snapshots/recursive build skips root self-reference.snap @@ -0,0 +1,13 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run -r build # only a and b run, root is skipped +~/packages/a$ echo building-a ⊘ cache disabled +building-a + +~/packages/b$ echo building-b ⊘ cache disabled +building-b + +--- +[vp run] 0/2 cache hit (0%). (Run `vp run --last-details` for full details) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/vite-task.json new file mode 100644 index 00000000..d548edfa --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/vite-task.json @@ -0,0 +1,3 @@ +{ + "cache": true +} diff --git a/crates/vite_task_graph/src/config/user.rs b/crates/vite_task_graph/src/config/user.rs index 8a54ad58..1e8c552a 100644 --- a/crates/vite_task_graph/src/config/user.rs +++ b/crates/vite_task_graph/src/config/user.rs @@ -177,7 +177,7 @@ impl ResolvedGlobalCacheConfig { #[derive(Debug, Default, Deserialize)] // TS derive macro generates code using std types that clippy disallows; skip derive during linting #[cfg_attr(all(test, not(clippy)), derive(TS), ts(optional_fields, rename = "RunConfig"))] -#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct UserRunConfig { /// Root-level cache configuration. /// @@ -454,4 +454,17 @@ mod tests { serde_json::from_value::(json!({ "unknown": true })).is_err() ); } + + #[test] + fn test_run_config_unknown_top_level_field() { + assert!(serde_json::from_value::(json!({ "unknown": true })).is_err()); + } + + #[test] + fn test_task_config_unknown_field() { + assert!( + serde_json::from_value::(json!({ "command": "echo", "unknown": true })) + .is_err() + ); + } } diff --git a/crates/vite_task_graph/src/query/mod.rs b/crates/vite_task_graph/src/query/mod.rs index 1f3c811a..8d99ec9d 100644 --- a/crates/vite_task_graph/src/query/mod.rs +++ b/crates/vite_task_graph/src/query/mod.rs @@ -31,7 +31,20 @@ use crate::{IndexedTaskGraph, TaskDependencyType, TaskId, TaskNodeIndex}; pub type TaskExecutionGraph = DiGraphMap; /// A query for which tasks to run. -#[derive(Debug)] +/// +/// A `TaskQuery` must be **self-contained**: it fully describes which tasks +/// will be selected, without relying on ambient state such as cwd or +/// environment variables. For example, the implicit cwd is captured as a +/// `ContainingPackage(path)` selector inside [`PackageQuery`], so two +/// queries from different directories compare as unequal even though the +/// user typed the same CLI arguments. +/// +/// This property is essential for the **skip rule** in task planning, which +/// compares the nested query against the parent query with `==`. If any +/// external context leaked into the comparison (or was excluded from it), +/// the skip rule would either miss legitimate recursion or incorrectly +/// suppress distinct expansions. +#[derive(Debug, PartialEq)] pub struct TaskQuery { /// Which packages to select. pub package_query: PackageQuery, diff --git a/crates/vite_task_plan/src/context.rs b/crates/vite_task_plan/src/context.rs index 3f2ec1fe..119d68af 100644 --- a/crates/vite_task_plan/src/context.rs +++ b/crates/vite_task_plan/src/context.rs @@ -3,7 +3,9 @@ use std::{env::JoinPathsError, ffi::OsStr, ops::Range, sync::Arc}; use rustc_hash::FxHashMap; use vite_path::AbsolutePath; use vite_str::Str; -use vite_task_graph::{IndexedTaskGraph, TaskNodeIndex, config::ResolvedGlobalCacheConfig}; +use vite_task_graph::{ + IndexedTaskGraph, TaskNodeIndex, config::ResolvedGlobalCacheConfig, query::TaskQuery, +}; use crate::{PlanRequestParser, path_env::prepend_path_env}; @@ -42,6 +44,10 @@ pub struct PlanContext<'a> { /// Final resolved global cache config, combining the graph's config with any CLI override. resolved_global_cache: ResolvedGlobalCacheConfig, + + /// The query that caused the current expansion. + /// Used by the skip rule to detect and skip duplicate nested expansions. + parent_query: Arc, } impl<'a> PlanContext<'a> { @@ -52,6 +58,7 @@ impl<'a> PlanContext<'a> { callbacks: &'a mut (dyn PlanRequestParser + 'a), indexed_task_graph: &'a IndexedTaskGraph, resolved_global_cache: ResolvedGlobalCacheConfig, + parent_query: Arc, ) -> Self { Self { workspace_path, @@ -62,6 +69,7 @@ impl<'a> PlanContext<'a> { indexed_task_graph, extra_args: Arc::default(), resolved_global_cache, + parent_query, } } @@ -128,6 +136,20 @@ impl<'a> PlanContext<'a> { self.resolved_global_cache = config; } + pub fn parent_query(&self) -> &TaskQuery { + &self.parent_query + } + + pub fn set_parent_query(&mut self, query: Arc) { + self.parent_query = query; + } + + /// Returns the task currently being expanded (whose command triggered a nested query). + /// This is the last task on the call stack, or `None` at the top level. + pub fn expanding_task(&self) -> Option { + self.task_call_stack.last().map(|(idx, _)| *idx) + } + pub fn duplicate(&mut self) -> PlanContext<'_> { PlanContext { workspace_path: self.workspace_path, @@ -138,6 +160,7 @@ impl<'a> PlanContext<'a> { indexed_task_graph: self.indexed_task_graph, extra_args: Arc::clone(&self.extra_args), resolved_global_cache: self.resolved_global_cache, + parent_query: Arc::clone(&self.parent_query), } } } diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index 61178566..974dead0 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -205,6 +205,8 @@ pub async fn plan_query( query_plan_request.plan_options.cache_override, ); + let QueryPlanRequest { query, plan_options } = query_plan_request; + let query = Arc::new(query); let context = PlanContext::new( workspace_path, Arc::clone(cwd), @@ -212,8 +214,9 @@ pub async fn plan_query( plan_request_parser, indexed_task_graph, resolved_global_cache, + Arc::clone(&query), ); - plan_query_request(query_plan_request, context).await + plan_query_request(query, plan_options, context).await } const fn resolve_cache_with_override( diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index 78495e0f..0311f6e4 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -12,6 +12,7 @@ use std::{ }; use futures_util::FutureExt; +use petgraph::Direction; use rustc_hash::FxHashMap; use vite_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf, relative::InvalidPathDataError}; use vite_shell::try_parse_as_and_list; @@ -22,6 +23,7 @@ use vite_task_graph::{ CacheConfig, ResolvedGlobalCacheConfig, ResolvedTaskOptions, user::{UserCacheConfig, UserTaskOptions}, }, + query::TaskQuery, }; use crate::{ @@ -34,7 +36,8 @@ use crate::{ in_process::InProcessExecution, path_env::get_path_env, plan_request::{ - CacheOverride, PlanRequest, QueryPlanRequest, ScriptCommand, SyntheticPlanRequest, + CacheOverride, PlanOptions, PlanRequest, QueryPlanRequest, ScriptCommand, + SyntheticPlanRequest, }, resolve_cache_with_override, }; @@ -197,17 +200,34 @@ async fn plan_task_as_execution_node( let execution_item_kind: ExecutionItemKind = match plan_request { // Expand task query like `vp run -r build` Some(PlanRequest::Query(query_plan_request)) => { + // Skip rule: skip if this nested query is the same as the parent expansion. + // This handles workspace root tasks like `"build": "vp run -r build"` — + // re-entering the same query would just re-expand the same tasks. + // + // The comparison is on TaskQuery only (package_query + task_name + + // include_explicit_deps). Extra args live in PlanOptions, so + // `vp run -r build extra_arg` still matches `vp run -r build`. + // Conversely, `cd packages/a && vp run build` does NOT match a + // parent `vp run build` from root because `cd` changes the cwd, + // producing a different ContainingPackage in the PackageQuery. + if query_plan_request.query == *context.parent_query() { + continue; + } + // Save task name before consuming the request let task_name = query_plan_request.query.task_name.clone(); // Add prefix envs to the context context.add_envs(and_item.envs.iter()); - let execution_graph = plan_query_request(query_plan_request, context) - .await - .map_err(|error| Error::NestPlan { - task_display: task_node.task_display.clone(), - command: Str::from(&command_str[add_item_span.clone()]), - error: Box::new(error), - })?; + let QueryPlanRequest { query, plan_options } = query_plan_request; + let query = Arc::new(query); + let execution_graph = + plan_query_request(Arc::clone(&query), plan_options, context) + .await + .map_err(|error| Error::NestPlan { + task_display: task_node.task_display.clone(), + command: Str::from(&command_str[add_item_span.clone()]), + error: Box::new(error), + })?; // An empty execution graph means no tasks matched the query. // At the top level the session shows the task selector UI, // but in a nested context there is no UI — propagate as an error. @@ -552,9 +572,19 @@ fn plan_spawn_execution( /// /// Builds a `DiGraph` of task executions, then validates it is acyclic via /// `ExecutionGraph::try_from_graph`. Returns `CycleDependencyDetected` if a cycle is found. +/// +/// **Prune rule:** If the expanding task (the task whose command triggered +/// this nested query) appears in the expansion result, it is pruned from the graph +/// and its predecessors are wired directly to its successors. This prevents +/// workspace root tasks like `"build": "vp run -r build"` from infinitely +/// re-expanding themselves when a different query reaches them (e.g., +/// `vp run build` produces a different query than the script's `vp run -r build`, +/// so the skip rule doesn't fire, but the prune rule catches root in the result). +/// Like the skip rule, extra args don't affect this — only the `TaskQuery` matters. #[expect(clippy::future_not_send, reason = "PlanContext contains !Send dyn PlanRequestParser")] pub async fn plan_query_request( - query_plan_request: QueryPlanRequest, + query: Arc, + plan_options: PlanOptions, mut context: PlanContext<'_>, ) -> Result { // Apply cache override from `--cache` / `--no-cache` flags on this request. @@ -562,7 +592,7 @@ pub async fn plan_query_request( // When `None`, we skip the update so the context keeps whatever the parent // resolved — this is how `vp run --cache outer` propagates to a nested // `vp run inner` that has no flags of its own. - let cache_override = query_plan_request.plan_options.cache_override; + let cache_override = plan_options.cache_override; if cache_override != CacheOverride::None { // Override is relative to the *workspace* config, not the parent's // resolved config. This means `vp run --no-cache outer` where outer @@ -574,11 +604,13 @@ pub async fn plan_query_request( ); context.set_resolved_global_cache(final_cache); } - context.set_extra_args(Arc::clone(&query_plan_request.plan_options.extra_args)); + context.set_extra_args(plan_options.extra_args); + context.set_parent_query(Arc::clone(&query)); + // Query matching tasks from the task graph. // An empty graph means no tasks matched; the caller (session) handles // empty graphs by showing the task selector. - let task_query_result = context.indexed_task_graph().query_tasks(&query_plan_request.query)?; + let task_query_result = context.indexed_task_graph().query_tasks(&query)?; #[expect(clippy::print_stderr, reason = "user-facing warning for typos in --filter")] for selector in &task_query_result.unmatched_selectors { @@ -587,6 +619,12 @@ pub async fn plan_query_request( let task_node_index_graph = task_query_result.execution_graph; + // Prune rule: if the expanding task appears in the expansion, prune it. + // This handles cases like root `"build": "vp run build"` — the root's build + // task is in the result but expanding it would recurse, so we remove it and + // reconnect its predecessors directly to its successors. + let pruned_task = context.expanding_task().filter(|t| task_node_index_graph.contains_node(*t)); + let mut execution_node_indices_by_task_index = FxHashMap::::with_capacity_and_hasher( task_node_index_graph.node_count(), @@ -599,16 +637,23 @@ pub async fn plan_query_request( task_node_index_graph.edge_count(), ); - // Plan each task node as execution nodes + // Plan each task node as execution nodes, skipping the pruned task for task_index in task_node_index_graph.nodes() { + if Some(task_index) == pruned_task { + continue; + } let task_execution = plan_task_as_execution_node(task_index, context.duplicate()).boxed_local().await?; execution_node_indices_by_task_index .insert(task_index, inner_graph.add_node(task_execution)); } - // Add edges between execution nodes according to task dependencies + // Add edges between execution nodes according to task dependencies, + // skipping edges involving the pruned task. for (from_task_index, to_task_index, ()) in task_node_index_graph.all_edges() { + if Some(from_task_index) == pruned_task || Some(to_task_index) == pruned_task { + continue; + } inner_graph.add_edge( execution_node_indices_by_task_index[&from_task_index], execution_node_indices_by_task_index[&to_task_index], @@ -616,6 +661,24 @@ pub async fn plan_query_request( ); } + // Reconnect through the pruned node: wire each predecessor directly to each successor. + if let Some(pruned) = pruned_task { + let preds: Vec<_> = + task_node_index_graph.neighbors_directed(pruned, Direction::Incoming).collect(); + let succs: Vec<_> = + task_node_index_graph.neighbors_directed(pruned, Direction::Outgoing).collect(); + for &pred in &preds { + for &succ in &succs { + if let (Some(&pe), Some(&se)) = ( + execution_node_indices_by_task_index.get(&pred), + execution_node_indices_by_task_index.get(&succ), + ) { + inner_graph.add_edge(pe, se, ()); + } + } + } + } + // Validate the graph is acyclic. // `try_from_graph` performs a DFS; if a cycle is found, it returns // `CycleError` containing the full cycle path as node indices. diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/package.json new file mode 100644 index 00000000..17e94546 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/package.json @@ -0,0 +1,7 @@ +{ + "name": "test-workspace", + "private": true, + "scripts": { + "deploy": "cd packages/a && vp run deploy" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/packages/a/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/packages/a/package.json new file mode 100644 index 00000000..08a9ce0d --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/packages/a/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/a", + "version": "1.0.0", + "scripts": { + "deploy": "echo deploying-a" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/pnpm-workspace.yaml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/snapshots.toml new file mode 100644 index 00000000..1e913f3a --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/snapshots.toml @@ -0,0 +1,13 @@ +# Tests that `cd` before `vp run` prevents the skip rule from firing. +# Root deploy = `cd packages/a && vp run deploy`. +# +# The skip rule compares TaskQuery, which includes ContainingPackage(cwd). +# After `cd packages/a`, the cwd changes, so the nested query resolves to +# ContainingPackage(packages/a) — different from the parent's +# ContainingPackage(root). The queries don't match and the skip rule +# does NOT fire, allowing the nested expansion to proceed normally. + +[[plan]] +name = "cd changes cwd so skip rule does not fire" +args = ["run", "deploy"] +compact = true diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/snapshots/query - cd changes cwd so skip rule does not fire.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/snapshots/query - cd changes cwd so skip rule does not fire.snap new file mode 100644 index 00000000..e3e3e0e2 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/snapshots/query - cd changes cwd so skip rule does not fire.snap @@ -0,0 +1,19 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - deploy +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip +--- +{ + "#deploy": { + "items": [ + { + "packages/a#deploy": [] + } + ], + "neighbors": [] + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/snapshots/task graph.snap new file mode 100644 index 00000000..49028ec3 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/snapshots/task graph.snap @@ -0,0 +1,65 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: task_graph_json +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip +--- +[ + { + "key": [ + "/", + "deploy" + ], + "node": { + "task_display": { + "package_name": "test-workspace", + "task_name": "deploy", + "package_path": "/" + }, + "resolved_config": { + "command": "cd packages/a && vp run deploy", + "resolved_options": { + "cwd": "/", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + }, + { + "key": [ + "/packages/a", + "deploy" + ], + "node": { + "task_display": { + "package_name": "@test/a", + "task_name": "deploy", + "package_path": "/packages/a" + }, + "resolved_config": { + "command": "echo deploying-a", + "resolved_options": { + "cwd": "/packages/a", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + } +] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/vite-task.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/vite-task.json new file mode 100644 index 00000000..d548edfa --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/vite-task.json @@ -0,0 +1,3 @@ +{ + "cache": true +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/package.json new file mode 100644 index 00000000..72fb1dec --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/package.json @@ -0,0 +1,8 @@ +{ + "name": "test-workspace", + "private": true, + "scripts": { + "build": "vp run -r build", + "lint": "echo linting" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/packages/a/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/packages/a/package.json new file mode 100644 index 00000000..259f240c --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/packages/a/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/a", + "version": "1.0.0", + "scripts": { + "build": "echo building-a", + "lint": "echo linting-a" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/pnpm-workspace.yaml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/snapshots.toml new file mode 100644 index 00000000..d240f5b1 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/snapshots.toml @@ -0,0 +1,8 @@ +# Tests that dependsOn works through a passthrough root task. +# Root build = "vp run -r build", with dependsOn: ["lint"]. +# The skip rule skips the recursive part, but the dependsOn lint tasks still run. + +[[plan]] +name = "depends on through passthrough" +args = ["run", "-r", "build"] +compact = true diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/snapshots/query - depends on through passthrough.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/snapshots/query - depends on through passthrough.snap new file mode 100644 index 00000000..5a399f4d --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/snapshots/query - depends on through passthrough.snap @@ -0,0 +1,17 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-r" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough +--- +{ + "#build": [ + "#lint" + ], + "#lint": [], + "packages/a#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/snapshots/task graph.snap new file mode 100644 index 00000000..045c90df --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/snapshots/task graph.snap @@ -0,0 +1,128 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: task_graph_json +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough +--- +[ + { + "key": [ + "/", + "build" + ], + "node": { + "task_display": { + "package_name": "test-workspace", + "task_name": "build", + "package_path": "/" + }, + "resolved_config": { + "command": "vp run -r build", + "resolved_options": { + "cwd": "/", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + }, + "source": "TaskConfig" + }, + "neighbors": [ + [ + "/", + "lint" + ] + ] + }, + { + "key": [ + "/", + "lint" + ], + "node": { + "task_display": { + "package_name": "test-workspace", + "task_name": "lint", + "package_path": "/" + }, + "resolved_config": { + "command": "echo linting", + "resolved_options": { + "cwd": "/", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + }, + { + "key": [ + "/packages/a", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/a", + "task_name": "build", + "package_path": "/packages/a" + }, + "resolved_config": { + "command": "echo building-a", + "resolved_options": { + "cwd": "/packages/a", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + }, + { + "key": [ + "/packages/a", + "lint" + ], + "node": { + "task_display": { + "package_name": "@test/a", + "task_name": "lint", + "package_path": "/packages/a" + }, + "resolved_config": { + "command": "echo linting-a", + "resolved_options": { + "cwd": "/packages/a", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + } +] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/vite-task.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/vite-task.json new file mode 100644 index 00000000..f329d36f --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/vite-task.json @@ -0,0 +1,8 @@ +{ + "cache": true, + "tasks": { + "build": { + "dependsOn": ["lint"] + } + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/package.json new file mode 100644 index 00000000..1f7300d5 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/package.json @@ -0,0 +1,7 @@ +{ + "name": "test-workspace", + "private": true, + "scripts": { + "build": "echo pre && vp run -r build" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/packages/a/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/packages/a/package.json new file mode 100644 index 00000000..e0c6c3f2 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/packages/a/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/a", + "version": "1.0.0", + "scripts": { + "build": "echo building-a" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/pnpm-workspace.yaml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/snapshots.toml new file mode 100644 index 00000000..fdda13ff --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/snapshots.toml @@ -0,0 +1,7 @@ +# Tests multi-command with self-referencing: root build = "echo pre && vp run -r build" +# The skip rule skips the recursive `vp run -r build` part, echo pre still runs. + +[[plan]] +name = "multi command skips recursive part" +args = ["run", "-r", "build"] +compact = true diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/snapshots/query - multi command skips recursive part.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/snapshots/query - multi command skips recursive part.snap new file mode 100644 index 00000000..cc995ad3 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/snapshots/query - multi command skips recursive part.snap @@ -0,0 +1,14 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-r" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command +--- +{ + "#build": [], + "packages/a#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/snapshots/task graph.snap new file mode 100644 index 00000000..778e1be9 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/snapshots/task graph.snap @@ -0,0 +1,65 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: task_graph_json +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command +--- +[ + { + "key": [ + "/", + "build" + ], + "node": { + "task_display": { + "package_name": "test-workspace", + "task_name": "build", + "package_path": "/" + }, + "resolved_config": { + "command": "echo pre && vp run -r build", + "resolved_options": { + "cwd": "/", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + }, + { + "key": [ + "/packages/a", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/a", + "task_name": "build", + "package_path": "/packages/a" + }, + "resolved_config": { + "command": "echo building-a", + "resolved_options": { + "cwd": "/packages/a", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + } +] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/vite-task.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/vite-task.json new file mode 100644 index 00000000..d548edfa --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/vite-task.json @@ -0,0 +1,3 @@ +{ + "cache": true +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/package.json new file mode 100644 index 00000000..26a2aa6f --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/package.json @@ -0,0 +1,8 @@ +{ + "name": "test-workspace", + "private": true, + "scripts": { + "build": "vp run -r test", + "test": "vp run -r build" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/packages/a/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/packages/a/package.json new file mode 100644 index 00000000..6fafd4d1 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/packages/a/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/a", + "version": "1.0.0", + "scripts": { + "build": "echo building-a", + "test": "echo testing-a" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/pnpm-workspace.yaml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/snapshots.toml new file mode 100644 index 00000000..90860234 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/snapshots.toml @@ -0,0 +1,6 @@ +# Tests mutual recursion detection: root#build → vp run -r test → root#test → vp run -r build → root#build +# This is NOT self-recursion — it's caught by check_recursion as mutual recursion. + +[[plan]] +name = "mutual recursion error" +args = ["run", "-r", "build"] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/snapshots/query - mutual recursion error.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/snapshots/query - mutual recursion error.snap new file mode 100644 index 00000000..6202acc1 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/snapshots/query - mutual recursion error.snap @@ -0,0 +1,11 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: err_str.as_ref() +info: + args: + - run + - "-r" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion +--- +Failed to plan tasks from `vp run -r test` in task test-workspace#build: Failed to plan tasks from `vp run -r build` in task test-workspace#test: Detected a recursion in task call stack: the last frame calls the 1th frame diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/snapshots/task graph.snap new file mode 100644 index 00000000..9afec801 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/snapshots/task graph.snap @@ -0,0 +1,123 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: task_graph_json +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion +--- +[ + { + "key": [ + "/", + "build" + ], + "node": { + "task_display": { + "package_name": "test-workspace", + "task_name": "build", + "package_path": "/" + }, + "resolved_config": { + "command": "vp run -r test", + "resolved_options": { + "cwd": "/", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + }, + { + "key": [ + "/", + "test" + ], + "node": { + "task_display": { + "package_name": "test-workspace", + "task_name": "test", + "package_path": "/" + }, + "resolved_config": { + "command": "vp run -r build", + "resolved_options": { + "cwd": "/", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + }, + { + "key": [ + "/packages/a", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/a", + "task_name": "build", + "package_path": "/packages/a" + }, + "resolved_config": { + "command": "echo building-a", + "resolved_options": { + "cwd": "/packages/a", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + }, + { + "key": [ + "/packages/a", + "test" + ], + "node": { + "task_display": { + "package_name": "@test/a", + "task_name": "test", + "package_path": "/packages/a" + }, + "resolved_config": { + "command": "echo testing-a", + "resolved_options": { + "cwd": "/packages/a", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + } +] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/vite-task.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/vite-task.json new file mode 100644 index 00000000..d548edfa --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/vite-task.json @@ -0,0 +1,3 @@ +{ + "cache": true +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/package.json new file mode 100644 index 00000000..20e8e426 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/package.json @@ -0,0 +1,7 @@ +{ + "name": "test-workspace", + "private": true, + "scripts": { + "build": "vp run -r build" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/packages/a/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/packages/a/package.json new file mode 100644 index 00000000..e0c6c3f2 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/packages/a/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/a", + "version": "1.0.0", + "scripts": { + "build": "echo building-a" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/packages/b/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/packages/b/package.json new file mode 100644 index 00000000..aa937a79 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/packages/b/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/b", + "version": "1.0.0", + "scripts": { + "build": "echo building-b" + }, + "dependencies": { + "@test/a": "workspace:*" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/pnpm-workspace.yaml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots.toml new file mode 100644 index 00000000..2006d05d --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots.toml @@ -0,0 +1,36 @@ +# Tests workspace root self-reference handling (skip rule and prune rule). +# Root build = `vp run -r build`. +# +# The skip rule compares TaskQuery (package_query + task_name + include_explicit_deps). +# Extra args are in PlanOptions, not TaskQuery, so they don't affect skip/prune. + +# Skip rule: the nested `vp run -r build` produces the same query as the +# top-level `vp run -r build`, so the expansion is skipped. Root#build +# becomes a passthrough (no items). Siblings a and b run normally. +[[plan]] +name = "recursive build skips self" +args = ["run", "-r", "build"] +compact = true + +# Skip rule with extra_arg: same as above — extra_arg goes into PlanOptions, +# not TaskQuery, so the query still matches and the skip rule fires. +[[plan]] +name = "recursive build with extra arg skips self" +args = ["run", "-r", "build", "extra_arg"] +compact = true + +# Prune rule: top-level query is `vp run build` (ContainingPackage), but root's +# script produces `vp run -r build` (All). The queries differ, so the skip rule +# does not fire. The nested All query finds root + a + b; the prune rule removes +# root (the expanding task) from the result, leaving a and b. +[[plan]] +name = "build from root expands without self" +args = ["run", "build"] +compact = true + +# Prune rule with extra_arg: same as above — extra_arg doesn't affect the query, +# so the prune rule still fires. +[[plan]] +name = "build from root with extra arg expands without self" +args = ["run", "build", "extra_arg"] +compact = true diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/query - build from root expands without self.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/query - build from root expands without self.snap new file mode 100644 index 00000000..6a5f1a90 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/query - build from root expands without self.snap @@ -0,0 +1,22 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference +--- +{ + "#build": { + "items": [ + { + "packages/a#build": [], + "packages/b#build": [ + "packages/a#build" + ] + } + ], + "neighbors": [] + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/query - build from root with extra arg expands without self.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/query - build from root with extra arg expands without self.snap new file mode 100644 index 00000000..9009d2c5 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/query - build from root with extra arg expands without self.snap @@ -0,0 +1,23 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - build + - extra_arg +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference +--- +{ + "#build": { + "items": [ + { + "packages/a#build": [], + "packages/b#build": [ + "packages/a#build" + ] + } + ], + "neighbors": [] + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/query - recursive build skips self.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/query - recursive build skips self.snap new file mode 100644 index 00000000..0a7ad813 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/query - recursive build skips self.snap @@ -0,0 +1,17 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-r" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference +--- +{ + "#build": [], + "packages/a#build": [], + "packages/b#build": [ + "packages/a#build" + ] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/query - recursive build with extra arg skips self.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/query - recursive build with extra arg skips self.snap new file mode 100644 index 00000000..38205f59 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/query - recursive build with extra arg skips self.snap @@ -0,0 +1,18 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-r" + - build + - extra_arg +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference +--- +{ + "#build": [], + "packages/a#build": [], + "packages/b#build": [ + "packages/a#build" + ] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/task graph.snap new file mode 100644 index 00000000..e8b45c72 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/task graph.snap @@ -0,0 +1,94 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: task_graph_json +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference +--- +[ + { + "key": [ + "/", + "build" + ], + "node": { + "task_display": { + "package_name": "test-workspace", + "task_name": "build", + "package_path": "/" + }, + "resolved_config": { + "command": "vp run -r build", + "resolved_options": { + "cwd": "/", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + }, + { + "key": [ + "/packages/a", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/a", + "task_name": "build", + "package_path": "/packages/a" + }, + "resolved_config": { + "command": "echo building-a", + "resolved_options": { + "cwd": "/packages/a", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + }, + { + "key": [ + "/packages/b", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/b", + "task_name": "build", + "package_path": "/packages/b" + }, + "resolved_config": { + "command": "echo building-b", + "resolved_options": { + "cwd": "/packages/b", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + } +] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/vite-task.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/vite-task.json new file mode 100644 index 00000000..d548edfa --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/vite-task.json @@ -0,0 +1,3 @@ +{ + "cache": true +} diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index 7cc9a41b..e5c3a02e 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -29,7 +29,7 @@ //! //! [`parsePackageSelector`]: https://github.com/pnpm/pnpm/blob/05dd45ea82fff9c0b687cdc8f478a1027077d343/workspace/filter-workspace-packages/src/parsePackageSelector.ts#L14-L61 -use std::sync::Arc; +use std::{ops::Deref, sync::Arc}; use vec1::Vec1; use vite_path::{AbsolutePath, AbsolutePathBuf}; @@ -37,12 +37,42 @@ use vite_str::Str; use crate::package_graph::PackageQuery; +/// Compiled glob pattern with its source string preserved for equality comparison. +/// +/// `wax::Glob` does not implement `PartialEq`, so this wrapper compares +/// patterns by their source string representation. +#[derive(Debug, Clone)] +pub(crate) struct GlobPattern { + glob: wax::Glob<'static>, + source: Str, +} + +impl GlobPattern { + pub(crate) const fn new(glob: wax::Glob<'static>, source: Str) -> Self { + Self { glob, source } + } +} + +impl PartialEq for GlobPattern { + fn eq(&self, other: &Self) -> bool { + self.source == other.source + } +} + +impl Deref for GlobPattern { + type Target = wax::Glob<'static>; + + fn deref(&self) -> &Self::Target { + &self.glob + } +} + // ──────────────────────────────────────────────────────────────────────────── // Types // ──────────────────────────────────────────────────────────────────────────── /// Exact name or glob pattern for matching package names. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub(crate) enum PackageNamePattern { /// Exact name (e.g. `foo`, `@scope/pkg`). O(1) hash lookup. /// @@ -58,7 +88,7 @@ pub(crate) enum PackageNamePattern { /// /// Only `*` and `?` wildcards are supported (pnpm semantics). /// Stored as an owned `Glob<'static>` so the filter can outlive the input string. - Glob(Box>), + Glob(Box), } /// Directory matching pattern for `--filter` selectors. @@ -67,7 +97,7 @@ pub(crate) enum PackageNamePattern { /// `*` / `**` opt in to descendant matching. /// /// pnpm ref: -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub(crate) enum DirectoryPattern { /// Exact path match (no glob metacharacters in selector). Exact(Arc), @@ -78,14 +108,14 @@ pub(crate) enum DirectoryPattern { /// against `pattern`. For example, `./packages/*` with cwd `/ws` produces /// `base = /ws/packages`, `pattern = *`, which matches `/ws/packages/app` /// (remainder `app` matches `*`). - Glob { base: Arc, pattern: Box> }, + Glob { base: Arc, pattern: Box }, } /// What packages to initially match. /// /// The enum prevents the all-`None` invalid state that would arise from a struct /// with all optional fields (as in pnpm's independent optional fields). -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub(crate) enum PackageSelector { /// Match by name only. Produced by `--filter foo` or `--filter "@scope/*"`. Name(PackageNamePattern), @@ -131,7 +161,7 @@ pub(crate) enum TraversalDirection { /// /// Only present when `...` appears in the filter. The absence of this struct prevents /// the invalid state of `exclude_self = true` without any expansion. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub(crate) struct GraphTraversal { pub(crate) direction: TraversalDirection, @@ -146,7 +176,7 @@ pub(crate) struct GraphTraversal { /// /// Multiple filters are composed at the `PackageQuery` level: /// inclusions are unioned, then exclusions are subtracted. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub(crate) struct PackageFilter { /// When `true`, packages matching this filter are **excluded** from the result. /// Produced by a leading `!` in the filter string. @@ -544,7 +574,10 @@ fn resolve_directory_pattern( let base = resolve_filter_path(if base_str.is_empty() { "." } else { base_str }, cwd); match pattern { - Some(pattern) => Ok(DirectoryPattern::Glob { base, pattern: Box::new(pattern) }), + Some(pattern) => Ok(DirectoryPattern::Glob { + base, + pattern: Box::new(GlobPattern::new(pattern, path_str.into())), + }), None => Ok(DirectoryPattern::Exact(base)), } } @@ -570,7 +603,7 @@ fn resolve_filter_path(path_str: &str, cwd: &AbsolutePath) -> Arc fn build_name_pattern(name: &str) -> Result { let glob = wax::Glob::new(name)?.into_owned(); if glob.clone().partition().1.is_some() { - Ok(PackageNamePattern::Glob(Box::new(glob))) + Ok(PackageNamePattern::Glob(Box::new(GlobPattern::new(glob, name.into())))) } else { Ok(PackageNamePattern::Exact { name: name.into(), unique: false }) } diff --git a/crates/vite_workspace/src/package_graph.rs b/crates/vite_workspace/src/package_graph.rs index 6c4aec88..24c66011 100644 --- a/crates/vite_workspace/src/package_graph.rs +++ b/crates/vite_workspace/src/package_graph.rs @@ -39,10 +39,10 @@ use crate::{ /// (from `package_filter`). /// /// [`PackageQueryArgs::into_package_query`]: crate::package_filter::PackageQueryArgs::into_package_query -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct PackageQuery(pub(crate) PackageQueryKind); -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub(crate) enum PackageQueryKind { /// One or more `--filter` expressions. ///