From 28a39b5795a8f03850df3028a2a7c9668e3ce271 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 17:49:21 +0000 Subject: [PATCH 1/9] docs: update cache decision tree to match --cache implementation --cache acts as cache: true, overriding both cache.tasks and cache.scripts (not just scripts). Restructure the decision tree to reflect that --cache/--no-cache are resolved first as global overrides, then per-task cache: false is checked, then the task-vs-script distinction determines which global switch applies. https://claude.ai/code/session_01AYbt3E5j8Adk9NB7Sprkah --- docs/task-configuration.md | 175 +++++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 docs/task-configuration.md diff --git a/docs/task-configuration.md b/docs/task-configuration.md new file mode 100644 index 00000000..f72438e8 --- /dev/null +++ b/docs/task-configuration.md @@ -0,0 +1,175 @@ +# Task Configuration + +Tasks are configured in the `run` section of your `vite.config.ts`. There are two ways tasks can exist: **explicit task definitions** and **package.json scripts**. + +## Configuration Location + +Each package can have its own `vite.config.ts` that configures tasks for that package: + +```ts +// packages/app/vite.config.ts +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + run: { + tasks: { + build: { + command: 'tsc', + dependsOn: ['lint'], + cache: true, + envs: ['NODE_ENV'], + passThroughEnvs: ['CI'], + }, + lint: { + command: 'vp lint', + }, + deploy: { + command: 'deploy-script --prod', + cache: false, + }, + }, + }, +}); +``` + +## Task Definition Schema + +Each task supports these fields: + +| Field | Type | Default | Description | +| ----------------- | ---------- | ------------------- | ---------------------------------------------------------------------------------------------- | +| `command` | `string` | — | The shell command to run. If omitted, falls back to the package.json script of the same name. | +| `cwd` | `string` | package root | Working directory relative to the package root. | +| `dependsOn` | `string[]` | `[]` | Explicit task dependencies. See [Task Orchestration](./task-orchestration.md). | +| `cache` | `boolean` | `true` | Whether to cache this task's output. | +| `envs` | `string[]` | `[]` | Environment variables to include in the cache fingerprint. | +| `passThroughEnvs` | `string[]` | (built-in defaults) | Environment variables passed to the process but NOT included in the cache fingerprint. | +| `inputs`\* | `Array` | auto-inferred | Which files to track for cache invalidation. See [Caching — Inputs](./caching.md#task-inputs). | + +## Scripts vs Tasks + +Vite Task recognizes two sources of runnable commands: + +### 1. Package.json Scripts + +Any `"scripts"` entry in a package's `package.json` is automatically available: + +```json +// packages/app/package.json +{ + "name": "@my/app", + "scripts": { + "build": "tsc", + "test": "vitest run", + "dev": "vite dev" + } +} +``` + +These scripts can be run directly with `vp run build` (from within the `packages/app` directory). + +### 2. Explicit Task Definitions + +Tasks defined in a package's `vite.config.ts` only affect that package. A task definition applies when: + +- The package has a matching script in `package.json`, or +- The task itself specifies a `command` + +```ts +// packages/app/vite.config.ts +export default defineConfig({ + run: { + tasks: { + build: { + // No command — uses this package's "build" script from package.json + dependsOn: ['lint'], + envs: ['NODE_ENV'], + }, + }, + }, +}); +``` + +In this example, `build` runs `@my/app`'s own `package.json` `"build"` script but with the added `dependsOn` and cache configuration. + +**Conflict rule:** If both the task definition and the `package.json` script specify a command, it's an error. Either define the command in `vite.config.ts` or in `package.json` — not both. + +## Global Cache Configuration\* + +The `cache` field is only allowed in the **workspace root** `vite.config.ts` and controls workspace-wide cache behavior: + +```ts +// vite.config.ts (workspace root only) +export default defineConfig({ + run: { + cache: { scripts: true, tasks: true }, + }, +}); +``` + +| Setting | Type | Default | Meaning | +| --------------- | ------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `cache` | `boolean \| { scripts, tasks }` | `{ scripts: false, tasks: true }` | Global cache toggle | +| `cache.tasks` | `boolean` | `true` | When `true`, respects individual task cache config. When `false`, disables all task caching globally. | +| `cache.scripts` | `boolean` | `false` | When `true`, caches `package.json` scripts even without explicit task entries. | + +Shorthands: + +- `cache: true` → `{ scripts: true, tasks: true }` +- `cache: false` → `{ scripts: false, tasks: false }` + +### CLI Overrides\* + +You can override the global cache config per invocation: + +```bash +vp run build --cache # Force all caching on (scripts + tasks) +vp run build --no-cache # Force all caching off +``` + +### When Is Caching Enabled? + +A command run by `vp run` is either a **task** (has an entry in `vite.config.ts`) or a **script** (only exists in `package.json` with no corresponding task entry). A script that has a matching task entry is treated as a task. + +``` +--no-cache on the command line? +├─ YES → NOT CACHED (overrides everything) +└─ NO + │ + --cache on the command line? + ├─ YES → acts as cache: true (sets scripts: true, tasks: true) + └─ NO → uses workspace config + │ + Per-task cache set to false? + ├─ YES → NOT CACHED (--cache does NOT override this) + └─ NO or not set + │ + Does the command have a task entry in vite.config.ts? + │ + ├─ YES (task) ────────────────────────────────────────── + │ │ + │ Global cache.tasks enabled? (default: true, or true via --cache) + │ ├─ NO → NOT CACHED + │ └─ YES → CACHED <----- this is the default for tasks + │ + └─ NO (script) ───────────────────────────────────────── + │ + Global cache.scripts enabled? (default: false, or true via --cache) + ├─ YES → CACHED + └─ NO → NOT CACHED <----- this is the default for scripts +``` + +In short: **tasks are cached by default, scripts are not.** `--no-cache` turns off caching for everything. `--cache` is equivalent to `cache: true` — it enables both `cache.tasks` and `cache.scripts`, but cannot override a task's per-task `cache: false`. + +## Compound Commands and Nested `vp run` + +Commands joined with `&&` are split into independently-cached sub-tasks. Commands containing `vp run` calls are expanded at plan time into the execution graph. Both features work in task `command` fields and `package.json` scripts. See [Task Orchestration](./task-orchestration.md#compound-commands) for details. + +## Environment Variables + +See [Caching — Environment Variables](./caching.md#environment-variables) for full details on how `envs` and `passThroughEnvs` work with the cache system. + +Quick summary: + +- **`envs`**: Included in the cache fingerprint. Changing a value here invalidates the cache. +- **`passThroughEnvs`**: Passed to the process but NOT fingerprinted. Changing values here does NOT invalidate the cache. From 231c23bd739857657b36160dd0690562ce7d4c35 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 17:52:34 +0000 Subject: [PATCH 2/9] docs: move per-task cache check under task branch in decision tree Per-task cache: false only applies to tasks (scripts can't have it), so it belongs under the task branch, not before the task/script split. https://claude.ai/code/session_01AYbt3E5j8Adk9NB7Sprkah --- docs/task-configuration.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/task-configuration.md b/docs/task-configuration.md index f72438e8..f82f7bea 100644 --- a/docs/task-configuration.md +++ b/docs/task-configuration.md @@ -140,23 +140,23 @@ A command run by `vp run` is either a **task** (has an entry in `vite.config.ts` ├─ YES → acts as cache: true (sets scripts: true, tasks: true) └─ NO → uses workspace config │ - Per-task cache set to false? - ├─ YES → NOT CACHED (--cache does NOT override this) - └─ NO or not set - │ - Does the command have a task entry in vite.config.ts? - │ - ├─ YES (task) ────────────────────────────────────────── - │ │ - │ Global cache.tasks enabled? (default: true, or true via --cache) - │ ├─ NO → NOT CACHED - │ └─ YES → CACHED <----- this is the default for tasks + Does the command have a task entry in vite.config.ts? + │ + ├─ YES (task) ────────────────────────────────────────── + │ │ + │ Global cache.tasks enabled? (default: true, or true via --cache) + │ ├─ NO → NOT CACHED + │ └─ YES + │ │ + │ Per-task cache set to false? + │ ├─ YES → NOT CACHED (--cache does NOT override this) + │ └─ NO or not set → CACHED <----- this is the default for tasks + │ + └─ NO (script) ───────────────────────────────────────── │ - └─ NO (script) ───────────────────────────────────────── - │ - Global cache.scripts enabled? (default: false, or true via --cache) - ├─ YES → CACHED - └─ NO → NOT CACHED <----- this is the default for scripts + Global cache.scripts enabled? (default: false, or true via --cache) + ├─ YES → CACHED + └─ NO → NOT CACHED <----- this is the default for scripts ``` In short: **tasks are cached by default, scripts are not.** `--no-cache` turns off caching for everything. `--cache` is equivalent to `cache: true` — it enables both `cache.tasks` and `cache.scripts`, but cannot override a task's per-task `cache: false`. From a37cda696df5c2f59d28ab76b99cbd3e70bf3791 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 17:54:19 +0000 Subject: [PATCH 3/9] chore: remove docs file from feature branch This file belongs on the docs branch, not here. https://claude.ai/code/session_01AYbt3E5j8Adk9NB7Sprkah --- docs/task-configuration.md | 175 ------------------------------------- 1 file changed, 175 deletions(-) delete mode 100644 docs/task-configuration.md diff --git a/docs/task-configuration.md b/docs/task-configuration.md deleted file mode 100644 index f82f7bea..00000000 --- a/docs/task-configuration.md +++ /dev/null @@ -1,175 +0,0 @@ -# Task Configuration - -Tasks are configured in the `run` section of your `vite.config.ts`. There are two ways tasks can exist: **explicit task definitions** and **package.json scripts**. - -## Configuration Location - -Each package can have its own `vite.config.ts` that configures tasks for that package: - -```ts -// packages/app/vite.config.ts -import { defineConfig } from 'vite-plus'; - -export default defineConfig({ - run: { - tasks: { - build: { - command: 'tsc', - dependsOn: ['lint'], - cache: true, - envs: ['NODE_ENV'], - passThroughEnvs: ['CI'], - }, - lint: { - command: 'vp lint', - }, - deploy: { - command: 'deploy-script --prod', - cache: false, - }, - }, - }, -}); -``` - -## Task Definition Schema - -Each task supports these fields: - -| Field | Type | Default | Description | -| ----------------- | ---------- | ------------------- | ---------------------------------------------------------------------------------------------- | -| `command` | `string` | — | The shell command to run. If omitted, falls back to the package.json script of the same name. | -| `cwd` | `string` | package root | Working directory relative to the package root. | -| `dependsOn` | `string[]` | `[]` | Explicit task dependencies. See [Task Orchestration](./task-orchestration.md). | -| `cache` | `boolean` | `true` | Whether to cache this task's output. | -| `envs` | `string[]` | `[]` | Environment variables to include in the cache fingerprint. | -| `passThroughEnvs` | `string[]` | (built-in defaults) | Environment variables passed to the process but NOT included in the cache fingerprint. | -| `inputs`\* | `Array` | auto-inferred | Which files to track for cache invalidation. See [Caching — Inputs](./caching.md#task-inputs). | - -## Scripts vs Tasks - -Vite Task recognizes two sources of runnable commands: - -### 1. Package.json Scripts - -Any `"scripts"` entry in a package's `package.json` is automatically available: - -```json -// packages/app/package.json -{ - "name": "@my/app", - "scripts": { - "build": "tsc", - "test": "vitest run", - "dev": "vite dev" - } -} -``` - -These scripts can be run directly with `vp run build` (from within the `packages/app` directory). - -### 2. Explicit Task Definitions - -Tasks defined in a package's `vite.config.ts` only affect that package. A task definition applies when: - -- The package has a matching script in `package.json`, or -- The task itself specifies a `command` - -```ts -// packages/app/vite.config.ts -export default defineConfig({ - run: { - tasks: { - build: { - // No command — uses this package's "build" script from package.json - dependsOn: ['lint'], - envs: ['NODE_ENV'], - }, - }, - }, -}); -``` - -In this example, `build` runs `@my/app`'s own `package.json` `"build"` script but with the added `dependsOn` and cache configuration. - -**Conflict rule:** If both the task definition and the `package.json` script specify a command, it's an error. Either define the command in `vite.config.ts` or in `package.json` — not both. - -## Global Cache Configuration\* - -The `cache` field is only allowed in the **workspace root** `vite.config.ts` and controls workspace-wide cache behavior: - -```ts -// vite.config.ts (workspace root only) -export default defineConfig({ - run: { - cache: { scripts: true, tasks: true }, - }, -}); -``` - -| Setting | Type | Default | Meaning | -| --------------- | ------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `cache` | `boolean \| { scripts, tasks }` | `{ scripts: false, tasks: true }` | Global cache toggle | -| `cache.tasks` | `boolean` | `true` | When `true`, respects individual task cache config. When `false`, disables all task caching globally. | -| `cache.scripts` | `boolean` | `false` | When `true`, caches `package.json` scripts even without explicit task entries. | - -Shorthands: - -- `cache: true` → `{ scripts: true, tasks: true }` -- `cache: false` → `{ scripts: false, tasks: false }` - -### CLI Overrides\* - -You can override the global cache config per invocation: - -```bash -vp run build --cache # Force all caching on (scripts + tasks) -vp run build --no-cache # Force all caching off -``` - -### When Is Caching Enabled? - -A command run by `vp run` is either a **task** (has an entry in `vite.config.ts`) or a **script** (only exists in `package.json` with no corresponding task entry). A script that has a matching task entry is treated as a task. - -``` ---no-cache on the command line? -├─ YES → NOT CACHED (overrides everything) -└─ NO - │ - --cache on the command line? - ├─ YES → acts as cache: true (sets scripts: true, tasks: true) - └─ NO → uses workspace config - │ - Does the command have a task entry in vite.config.ts? - │ - ├─ YES (task) ────────────────────────────────────────── - │ │ - │ Global cache.tasks enabled? (default: true, or true via --cache) - │ ├─ NO → NOT CACHED - │ └─ YES - │ │ - │ Per-task cache set to false? - │ ├─ YES → NOT CACHED (--cache does NOT override this) - │ └─ NO or not set → CACHED <----- this is the default for tasks - │ - └─ NO (script) ───────────────────────────────────────── - │ - Global cache.scripts enabled? (default: false, or true via --cache) - ├─ YES → CACHED - └─ NO → NOT CACHED <----- this is the default for scripts -``` - -In short: **tasks are cached by default, scripts are not.** `--no-cache` turns off caching for everything. `--cache` is equivalent to `cache: true` — it enables both `cache.tasks` and `cache.scripts`, but cannot override a task's per-task `cache: false`. - -## Compound Commands and Nested `vp run` - -Commands joined with `&&` are split into independently-cached sub-tasks. Commands containing `vp run` calls are expanded at plan time into the execution graph. Both features work in task `command` fields and `package.json` scripts. See [Task Orchestration](./task-orchestration.md#compound-commands) for details. - -## Environment Variables - -See [Caching — Environment Variables](./caching.md#environment-variables) for full details on how `envs` and `passThroughEnvs` work with the cache system. - -Quick summary: - -- **`envs`**: Included in the cache fingerprint. Changing a value here invalidates the cache. -- **`passThroughEnvs`**: Passed to the process but NOT fingerprinted. Changing values here does NOT invalidate the cache. From 63e033543202307dbe7287069eb4510f3449fc12 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 18:50:04 +0000 Subject: [PATCH 4/9] feat: handle workspace root self-recursion in task planning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a workspace root task's command contains a `vp run` invocation that expands back to include itself, the planner now handles this gracefully instead of treating it as a fatal recursion error. Two rules are implemented: - Rule 1 (Skip): When a nested `vp run` query matches the parent query exactly, the subcommand is skipped (e.g., root's "build": "vp run -r build" when invoked via `vp run -r build`). - Rule 2 (Prune): When the expanding task appears in a different query's expansion result, it is pruned from the graph and edges are reconnected through it (e.g., root's "build": "vp run -r build" when invoked via `vp run build` from root directory). Mutual recursion (A→B→A) remains a fatal error via check_recursion. To enable TaskQuery comparison for Rule 1, a GlobPattern wrapper type is introduced that implements PartialEq by comparing source strings, and PartialEq is derived through the entire type chain from PackageFilter up to TaskQuery. https://claude.ai/code/session_01QdDRLrGeycdzQ4g1FWX4pZ --- crates/vite_task_graph/src/query/mod.rs | 2 +- crates/vite_task_plan/src/context.rs | 25 +++- crates/vite_task_plan/src/lib.rs | 5 +- crates/vite_task_plan/src/plan.rs | 81 ++++++++++-- .../package.json | 8 ++ .../packages/a/package.json | 8 ++ .../pnpm-workspace.yaml | 2 + .../snapshots.toml | 8 ++ ...uery - depends on through passthrough.snap | 17 +++ .../snapshots/task graph.snap | 124 ++++++++++++++++++ .../vite-task.json | 8 ++ .../workspace-root-multi-command/package.json | 7 + .../packages/a/package.json | 7 + .../pnpm-workspace.yaml | 2 + .../snapshots.toml | 7 + ... - multi command skips recursive part.snap | 14 ++ .../snapshots/task graph.snap | 63 +++++++++ .../vite-task.json | 3 + .../package.json | 8 ++ .../packages/a/package.json | 8 ++ .../pnpm-workspace.yaml | 2 + .../snapshots.toml | 6 + .../query - mutual recursion error.snap | 11 ++ .../snapshots/task graph.snap | 119 +++++++++++++++++ .../vite-task.json | 3 + .../package.json | 7 + .../packages/a/package.json | 7 + .../packages/b/package.json | 10 ++ .../pnpm-workspace.yaml | 2 + .../snapshots.toml | 18 +++ ... build from root expands without self.snap | 22 ++++ .../query - recursive build skips self.snap | 17 +++ .../snapshots/task graph.snap | 91 +++++++++++++ .../vite-task.json | 3 + crates/vite_workspace/src/package_filter.rs | 53 ++++++-- crates/vite_workspace/src/package_graph.rs | 4 +- 36 files changed, 753 insertions(+), 29 deletions(-) create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/packages/a/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/pnpm-workspace.yaml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/snapshots.toml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/snapshots/query - depends on through passthrough.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/snapshots/task graph.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/vite-task.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/packages/a/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/pnpm-workspace.yaml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/snapshots.toml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/snapshots/query - multi command skips recursive part.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/snapshots/task graph.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/vite-task.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/packages/a/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/pnpm-workspace.yaml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/snapshots.toml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/snapshots/query - mutual recursion error.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/snapshots/task graph.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/vite-task.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/packages/a/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/packages/b/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/pnpm-workspace.yaml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots.toml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/query - build from root expands without self.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/query - recursive build skips self.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/task graph.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/vite-task.json diff --git a/crates/vite_task_graph/src/query/mod.rs b/crates/vite_task_graph/src/query/mod.rs index 1f3c811a..93c825ab 100644 --- a/crates/vite_task_graph/src/query/mod.rs +++ b/crates/vite_task_graph/src/query/mod.rs @@ -31,7 +31,7 @@ use crate::{IndexedTaskGraph, TaskDependencyType, TaskId, TaskNodeIndex}; pub type TaskExecutionGraph = DiGraphMap; /// A query for which tasks to run. -#[derive(Debug)] +#[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..61f65c09 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,27 @@ 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)) => { + // Rule 1: 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. + 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 +565,16 @@ 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. +/// +/// **Rule 2 (prune self):** 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 build"` from infinitely re-expanding +/// themselves when a different query reaches them. #[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 +582,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 +594,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 +609,12 @@ pub async fn plan_query_request( let task_node_index_graph = task_query_result.execution_graph; + // Rule 2: 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 +627,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 +651,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-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..373dba20 --- /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"]. +# Rule 1 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..a6f91e90 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/snapshots/task graph.snap @@ -0,0 +1,124 @@ +--- +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": [ + "" + ] + } + } + } + } + }, + "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": [ + "" + ] + } + } + } + } + }, + "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": [ + "" + ] + } + } + } + } + }, + "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": [ + "" + ] + } + } + } + } + }, + "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..30aa2578 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/vite-task.json @@ -0,0 +1,8 @@ +{ + "cacheScripts": 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..f3493f82 --- /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" +# Rule 1 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..fcc3e363 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/snapshots/task graph.snap @@ -0,0 +1,63 @@ +--- +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": [ + "" + ] + } + } + } + } + }, + "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": [ + "" + ] + } + } + } + } + }, + "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..1d0fe9f2 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/vite-task.json @@ -0,0 +1,3 @@ +{ + "cacheScripts": 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..1a3f4294 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/snapshots/task graph.snap @@ -0,0 +1,119 @@ +--- +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": [ + "" + ] + } + } + } + } + }, + "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": [ + "" + ] + } + } + } + } + }, + "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": [ + "" + ] + } + } + } + } + }, + "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": [ + "" + ] + } + } + } + } + }, + "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..1d0fe9f2 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/vite-task.json @@ -0,0 +1,3 @@ +{ + "cacheScripts": 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..176e368b --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots.toml @@ -0,0 +1,18 @@ +# Tests workspace root self-reference handling (Rule 1 and Rule 2) + +# Rule 1: root's build command is `vp run -r build`, which is the same query +# as the top-level `vp run -r build`. The nested `vp run -r build` is skipped, +# so root#build becomes a passthrough (no items expand). Siblings a and b run. +[[plan]] +name = "recursive build skips self" +args = ["run", "-r", "build"] +compact = true + +# Rule 2: root's build command is `vp run -r build`, but top-level query is +# `vp run build` (current package = root, no -r). The nested query differs +# (All vs ContainingPackage), so it expands. Root appears in the expansion +# result and is pruned — only a and b remain in the nested graph. +[[plan]] +name = "build from root expands without self" +args = ["run", "build"] +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 - 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/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..f85a7ec6 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/task graph.snap @@ -0,0 +1,91 @@ +--- +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": [ + "" + ] + } + } + } + } + }, + "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": [ + "" + ] + } + } + } + } + }, + "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": [ + "" + ] + } + } + } + } + }, + "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..1d0fe9f2 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/vite-task.json @@ -0,0 +1,3 @@ +{ + "cacheScripts": 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. /// From 0a7653dd70dd76f21f82dd55589413d995e6c800 Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 5 Mar 2026 10:17:30 +0800 Subject: [PATCH 5/9] refactor: rename Rule 1/Rule 2 to skip rule/prune rule Co-Authored-By: Claude Opus 4.6 --- crates/vite_task_plan/src/plan.rs | 6 +++--- .../workspace-root-depends-on-passthrough/snapshots.toml | 2 +- .../fixtures/workspace-root-multi-command/snapshots.toml | 2 +- .../fixtures/workspace-root-self-reference/snapshots.toml | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index 61f65c09..77926de9 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -200,7 +200,7 @@ 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)) => { - // Rule 1: skip if this nested query is the same as the parent expansion. + // 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. if query_plan_request.query == *context.parent_query() { @@ -566,7 +566,7 @@ 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. /// -/// **Rule 2 (prune self):** If the expanding task (the task whose command triggered +/// **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 build"` from infinitely re-expanding @@ -609,7 +609,7 @@ pub async fn plan_query_request( let task_node_index_graph = task_query_result.execution_graph; - // Rule 2: if the expanding task appears in the expansion, prune it. + // 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. 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 index 373dba20..d240f5b1 100644 --- 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 @@ -1,6 +1,6 @@ # Tests that dependsOn works through a passthrough root task. # Root build = "vp run -r build", with dependsOn: ["lint"]. -# Rule 1 skips the recursive part, but the dependsOn lint tasks still run. +# The skip rule skips the recursive part, but the dependsOn lint tasks still run. [[plan]] name = "depends on through passthrough" 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 index f3493f82..fdda13ff 100644 --- 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 @@ -1,5 +1,5 @@ # Tests multi-command with self-referencing: root build = "echo pre && vp run -r build" -# Rule 1 skips the recursive `vp run -r build` part, echo pre still runs. +# The skip rule skips the recursive `vp run -r build` part, echo pre still runs. [[plan]] name = "multi command skips recursive part" 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 index 176e368b..cc6dd5a8 100644 --- 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 @@ -1,6 +1,6 @@ -# Tests workspace root self-reference handling (Rule 1 and Rule 2) +# Tests workspace root self-reference handling (skip rule and prune rule) -# Rule 1: root's build command is `vp run -r build`, which is the same query +# Skip rule: root's build command is `vp run -r build`, which is the same query # as the top-level `vp run -r build`. The nested `vp run -r build` is skipped, # so root#build becomes a passthrough (no items expand). Siblings a and b run. [[plan]] @@ -8,7 +8,7 @@ name = "recursive build skips self" args = ["run", "-r", "build"] compact = true -# Rule 2: root's build command is `vp run -r build`, but top-level query is +# Prune rule: root's build command is `vp run -r build`, but top-level query is # `vp run build` (current package = root, no -r). The nested query differs # (All vs ContainingPackage), so it expands. Root appears in the expansion # result and is pruned — only a and b remain in the nested graph. From 70292868ad1fd1f0b697eda9e8e4c1d9f670408f Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 5 Mar 2026 10:27:33 +0800 Subject: [PATCH 6/9] test: add plan cases for extra_arg and cd interaction with skip/prune rules - Extra args don't affect skip/prune (they're in PlanOptions, not TaskQuery) - `cd` before `vp run` changes the cwd, producing a different ContainingPackage query, so the skip rule correctly does not fire Co-Authored-By: Claude Opus 4.6 --- crates/vite_task_plan/src/plan.rs | 14 +++++- .../workspace-root-cd-no-skip/package.json | 7 +++ .../packages/a/package.json | 7 +++ .../pnpm-workspace.yaml | 2 + .../workspace-root-cd-no-skip/snapshots.toml | 13 +++++ ...hanges cwd so skip rule does not fire.snap | 19 +++++++ .../snapshots/task graph.snap | 49 +++++++++++++++++++ .../workspace-root-cd-no-skip/vite-task.json | 3 ++ .../snapshots/task graph.snap | 27 ++-------- .../snapshots/task graph.snap | 18 +------ .../snapshots/task graph.snap | 36 ++------------ .../snapshots.toml | 34 ++++++++++--- ...t with extra arg expands without self.snap | 23 +++++++++ ...rsive build with extra arg skips self.snap | 18 +++++++ .../snapshots/task graph.snap | 27 ++-------- 15 files changed, 191 insertions(+), 106 deletions(-) create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/packages/a/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/pnpm-workspace.yaml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/snapshots.toml create mode 100644 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 create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/snapshots/task graph.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/vite-task.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/query - build from root with extra arg expands without self.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/query - recursive build with extra arg skips self.snap diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index 77926de9..60df5435 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -203,6 +203,13 @@ async fn plan_task_as_execution_node( // 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; } @@ -569,8 +576,11 @@ fn plan_spawn_execution( /// **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 build"` from infinitely re-expanding -/// themselves when a different query reaches them. +/// 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: Arc, 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..383eb844 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/snapshots/task graph.snap @@ -0,0 +1,49 @@ +--- +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": null + } + } + }, + "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": null + } + } + }, + "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..1d0fe9f2 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/vite-task.json @@ -0,0 +1,3 @@ +{ + "cacheScripts": true +} 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 index a6f91e90..a046ecf6 100644 --- 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 @@ -52,14 +52,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-d "command": "echo linting", "resolved_options": { "cwd": "/", - "cache_config": { - "env_config": { - "fingerprinted_envs": [], - "pass_through_envs": [ - "" - ] - } - } + "cache_config": null } } }, @@ -80,14 +73,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-d "command": "echo building-a", "resolved_options": { "cwd": "/packages/a", - "cache_config": { - "env_config": { - "fingerprinted_envs": [], - "pass_through_envs": [ - "" - ] - } - } + "cache_config": null } } }, @@ -108,14 +94,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-d "command": "echo linting-a", "resolved_options": { "cwd": "/packages/a", - "cache_config": { - "env_config": { - "fingerprinted_envs": [], - "pass_through_envs": [ - "" - ] - } - } + "cache_config": null } } }, 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 index fcc3e363..72bbf108 100644 --- 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 @@ -19,14 +19,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-m "command": "echo pre && vp run -r build", "resolved_options": { "cwd": "/", - "cache_config": { - "env_config": { - "fingerprinted_envs": [], - "pass_through_envs": [ - "" - ] - } - } + "cache_config": null } } }, @@ -47,14 +40,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-m "command": "echo building-a", "resolved_options": { "cwd": "/packages/a", - "cache_config": { - "env_config": { - "fingerprinted_envs": [], - "pass_through_envs": [ - "" - ] - } - } + "cache_config": null } } }, 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 index 1a3f4294..1764151b 100644 --- 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 @@ -19,14 +19,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-m "command": "vp run -r test", "resolved_options": { "cwd": "/", - "cache_config": { - "env_config": { - "fingerprinted_envs": [], - "pass_through_envs": [ - "" - ] - } - } + "cache_config": null } } }, @@ -47,14 +40,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-m "command": "vp run -r build", "resolved_options": { "cwd": "/", - "cache_config": { - "env_config": { - "fingerprinted_envs": [], - "pass_through_envs": [ - "" - ] - } - } + "cache_config": null } } }, @@ -75,14 +61,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-m "command": "echo building-a", "resolved_options": { "cwd": "/packages/a", - "cache_config": { - "env_config": { - "fingerprinted_envs": [], - "pass_through_envs": [ - "" - ] - } - } + "cache_config": null } } }, @@ -103,14 +82,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-m "command": "echo testing-a", "resolved_options": { "cwd": "/packages/a", - "cache_config": { - "env_config": { - "fingerprinted_envs": [], - "pass_through_envs": [ - "" - ] - } - } + "cache_config": null } } }, 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 index cc6dd5a8..2006d05d 100644 --- 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 @@ -1,18 +1,36 @@ -# Tests workspace root self-reference handling (skip rule and prune rule) +# 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: root's build command is `vp run -r build`, which is the same query -# as the top-level `vp run -r build`. The nested `vp run -r build` is skipped, -# so root#build becomes a passthrough (no items expand). Siblings a and b run. +# 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 -# Prune rule: root's build command is `vp run -r build`, but top-level query is -# `vp run build` (current package = root, no -r). The nested query differs -# (All vs ContainingPackage), so it expands. Root appears in the expansion -# result and is pruned — only a and b remain in the nested graph. +# 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 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 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 index f85a7ec6..0ee8a67f 100644 --- 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 @@ -19,14 +19,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-s "command": "vp run -r build", "resolved_options": { "cwd": "/", - "cache_config": { - "env_config": { - "fingerprinted_envs": [], - "pass_through_envs": [ - "" - ] - } - } + "cache_config": null } } }, @@ -47,14 +40,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-s "command": "echo building-a", "resolved_options": { "cwd": "/packages/a", - "cache_config": { - "env_config": { - "fingerprinted_envs": [], - "pass_through_envs": [ - "" - ] - } - } + "cache_config": null } } }, @@ -75,14 +61,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-s "command": "echo building-b", "resolved_options": { "cwd": "/packages/b", - "cache_config": { - "env_config": { - "fingerprinted_envs": [], - "pass_through_envs": [ - "" - ] - } - } + "cache_config": null } } }, From a64f39c6c2234925025b20fae4ba4f1056940aa1 Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 5 Mar 2026 10:33:33 +0800 Subject: [PATCH 7/9] feat: reject unknown fields in vite-task.json Add `deny_unknown_fields` to `UserRunConfig` so typos and removed fields (like `cacheScripts`) produce a clear error at load time. Task-level unknown fields were already rejected via the flatten chain to `UserCacheConfig`. Also fix 5 test fixtures still using the old `cacheScripts` field. Co-Authored-By: Claude Opus 4.6 --- crates/vite_task_graph/src/config/user.rs | 15 +++++- crates/vite_task_plan/src/plan.rs | 2 +- .../snapshots/task graph.snap | 24 ++++++++-- .../workspace-root-cd-no-skip/vite-task.json | 2 +- .../snapshots/task graph.snap | 39 ++++++++++++--- .../vite-task.json | 2 +- .../snapshots/task graph.snap | 24 ++++++++-- .../vite-task.json | 2 +- .../snapshots/task graph.snap | 48 +++++++++++++++---- .../vite-task.json | 2 +- .../snapshots/task graph.snap | 36 +++++++++++--- .../vite-task.json | 2 +- 12 files changed, 162 insertions(+), 36 deletions(-) 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_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index 60df5435..0311f6e4 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -580,7 +580,7 @@ fn plan_spawn_execution( /// 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. +/// 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: Arc, 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 index 383eb844..49028ec3 100644 --- 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 @@ -19,9 +19,17 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-c "command": "cd packages/a && vp run deploy", "resolved_options": { "cwd": "/", - "cache_config": null + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } } - } + }, + "source": "PackageJsonScript" }, "neighbors": [] }, @@ -40,9 +48,17 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-c "command": "echo deploying-a", "resolved_options": { "cwd": "/packages/a", - "cache_config": null + "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 index 1d0fe9f2..d548edfa 100644 --- 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 @@ -1,3 +1,3 @@ { - "cacheScripts": true + "cache": true } 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 index a046ecf6..045c90df 100644 --- 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 @@ -28,7 +28,8 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-d } } } - } + }, + "source": "TaskConfig" }, "neighbors": [ [ @@ -52,9 +53,17 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-d "command": "echo linting", "resolved_options": { "cwd": "/", - "cache_config": null + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } } - } + }, + "source": "PackageJsonScript" }, "neighbors": [] }, @@ -73,9 +82,17 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-d "command": "echo building-a", "resolved_options": { "cwd": "/packages/a", - "cache_config": null + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } } - } + }, + "source": "PackageJsonScript" }, "neighbors": [] }, @@ -94,9 +111,17 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-d "command": "echo linting-a", "resolved_options": { "cwd": "/packages/a", - "cache_config": null + "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 index 30aa2578..f329d36f 100644 --- 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 @@ -1,5 +1,5 @@ { - "cacheScripts": true, + "cache": true, "tasks": { "build": { "dependsOn": ["lint"] 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 index 72bbf108..778e1be9 100644 --- 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 @@ -19,9 +19,17 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-m "command": "echo pre && vp run -r build", "resolved_options": { "cwd": "/", - "cache_config": null + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } } - } + }, + "source": "PackageJsonScript" }, "neighbors": [] }, @@ -40,9 +48,17 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-m "command": "echo building-a", "resolved_options": { "cwd": "/packages/a", - "cache_config": null + "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 index 1d0fe9f2..d548edfa 100644 --- 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 @@ -1,3 +1,3 @@ { - "cacheScripts": true + "cache": true } 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 index 1764151b..9afec801 100644 --- 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 @@ -19,9 +19,17 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-m "command": "vp run -r test", "resolved_options": { "cwd": "/", - "cache_config": null + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } } - } + }, + "source": "PackageJsonScript" }, "neighbors": [] }, @@ -40,9 +48,17 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-m "command": "vp run -r build", "resolved_options": { "cwd": "/", - "cache_config": null + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } } - } + }, + "source": "PackageJsonScript" }, "neighbors": [] }, @@ -61,9 +77,17 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-m "command": "echo building-a", "resolved_options": { "cwd": "/packages/a", - "cache_config": null + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } } - } + }, + "source": "PackageJsonScript" }, "neighbors": [] }, @@ -82,9 +106,17 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-m "command": "echo testing-a", "resolved_options": { "cwd": "/packages/a", - "cache_config": null + "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 index 1d0fe9f2..d548edfa 100644 --- 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 @@ -1,3 +1,3 @@ { - "cacheScripts": true + "cache": true } 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 index 0ee8a67f..e8b45c72 100644 --- 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 @@ -19,9 +19,17 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-s "command": "vp run -r build", "resolved_options": { "cwd": "/", - "cache_config": null + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } } - } + }, + "source": "PackageJsonScript" }, "neighbors": [] }, @@ -40,9 +48,17 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-s "command": "echo building-a", "resolved_options": { "cwd": "/packages/a", - "cache_config": null + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } } - } + }, + "source": "PackageJsonScript" }, "neighbors": [] }, @@ -61,9 +77,17 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-s "command": "echo building-b", "resolved_options": { "cwd": "/packages/b", - "cache_config": null + "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 index 1d0fe9f2..d548edfa 100644 --- 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 @@ -1,3 +1,3 @@ { - "cacheScripts": true + "cache": true } From b5bbfdbab154b706941e172a02a4ce7b6d083d5c Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 5 Mar 2026 10:55:39 +0800 Subject: [PATCH 8/9] docs: document self-containment invariant on `TaskQuery` Co-Authored-By: Claude Opus 4.6 --- crates/vite_task_graph/src/query/mod.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/vite_task_graph/src/query/mod.rs b/crates/vite_task_graph/src/query/mod.rs index 93c825ab..8d99ec9d 100644 --- a/crates/vite_task_graph/src/query/mod.rs +++ b/crates/vite_task_graph/src/query/mod.rs @@ -31,6 +31,19 @@ use crate::{IndexedTaskGraph, TaskDependencyType, TaskId, TaskNodeIndex}; pub type TaskExecutionGraph = DiGraphMap; /// A query for which tasks to run. +/// +/// 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. From 5f663f066f9c71053d6522c274f4ef0505498941 Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 5 Mar 2026 11:00:29 +0800 Subject: [PATCH 9/9] test: add e2e snapshots for workspace root skip and prune rules Co-Authored-By: Claude Opus 4.6 --- .../package.json | 7 ++++++ .../packages/a/package.json | 7 ++++++ .../packages/b/package.json | 10 ++++++++ .../pnpm-workspace.yaml | 2 ++ .../snapshots.toml | 23 +++++++++++++++++++ ...oot prunes root from nested expansion.snap | 13 +++++++++++ ...rsive build skips root self-reference.snap | 13 +++++++++++ .../vite-task.json | 3 +++ 8 files changed, 78 insertions(+) create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/packages/a/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/packages/b/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/pnpm-workspace.yaml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/snapshots.toml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/snapshots/build from root prunes root from nested expansion.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/snapshots/recursive build skips root self-reference.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/workspace-root-self-reference/vite-task.json 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 +}