From 3dcf72bded6cc4406e82256581cca44214687153 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Feb 2026 16:49:40 +0800 Subject: [PATCH 01/35] feat: add cwd and args info to snapshot headers Co-Authored-By: Claude Opus 4.6 From ba58a8fb1fe936c1b9ae90f216eeaaf738d0e4b9 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Feb 2026 17:07:07 +0800 Subject: [PATCH 02/35] feat: enforce snapshot metadata matching with require_full_match Ensures snapshots fail when the info header (cwd/args) doesn't match, preventing silently stale metadata after rebases. Co-Authored-By: Claude Opus 4.6 --- crates/vite_task_bin/tests/e2e_snapshots/main.rs | 3 +++ crates/vite_task_plan/tests/plan_snapshots/main.rs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 07d2ab14..47ac04ee 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -463,6 +463,9 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture } fn main() { + // SAFETY: Called before any threads are spawned; insta reads this lazily on first assertion. + unsafe { std::env::set_var("INSTA_REQUIRE_FULL_MATCH", "1") }; + let filter = std::env::args().nth(1); let tmp_dir = tempfile::tempdir().unwrap(); diff --git a/crates/vite_task_plan/tests/plan_snapshots/main.rs b/crates/vite_task_plan/tests/plan_snapshots/main.rs index eedace30..133ca597 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/main.rs +++ b/crates/vite_task_plan/tests/plan_snapshots/main.rs @@ -305,6 +305,9 @@ fn run_case_inner( reason = "current_dir needed because insta::glob! requires std PathBuf" )] fn main() { + // SAFETY: Called before any threads are spawned; insta reads this lazily on first assertion. + unsafe { std::env::set_var("INSTA_REQUIRE_FULL_MATCH", "1") }; + let filter = std::env::args().nth(1); let tokio_runtime = Runtime::new().unwrap(); From 5dc4bd08bfe3d8786729db644fa2ae217749d765 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 25 Feb 2026 17:47:38 +0800 Subject: [PATCH 03/35] refactor: remove unused multiple task specifier support from query system The CLI only accepts a single task specifier, but the underlying TaskQueryKind still used FxHashSet for multiple specifiers. Simplify to singular fields and delete the unused CLITaskQuery module. Co-Authored-By: Claude Opus 4.6 --- crates/vite_task/src/cli/mod.rs | 6 +- crates/vite_task_graph/src/query/cli.rs | 79 ------------------ crates/vite_task_graph/src/query/mod.rs | 105 +++++++++++------------- 3 files changed, 52 insertions(+), 138 deletions(-) delete mode 100644 crates/vite_task_graph/src/query/cli.rs diff --git a/crates/vite_task/src/cli/mod.rs b/crates/vite_task/src/cli/mod.rs index bdb21736..48241c9c 100644 --- a/crates/vite_task/src/cli/mod.rs +++ b/crates/vite_task/src/cli/mod.rs @@ -1,4 +1,4 @@ -use std::{iter, sync::Arc}; +use std::sync::Arc; use clap::Parser; use vite_path::AbsolutePath; @@ -185,10 +185,10 @@ impl ResolvedRunCommand { } else { task_specifier.task_name }; - TaskQueryKind::Recursive { task_names: iter::once(task_name).collect() } + TaskQueryKind::Recursive { task_name } } else { TaskQueryKind::Normal { - task_specifiers: iter::once(task_specifier).collect(), + task_specifier, cwd: Arc::clone(cwd), include_topological_deps: transitive, } diff --git a/crates/vite_task_graph/src/query/cli.rs b/crates/vite_task_graph/src/query/cli.rs deleted file mode 100644 index 75e9cb8b..00000000 --- a/crates/vite_task_graph/src/query/cli.rs +++ /dev/null @@ -1,79 +0,0 @@ -use std::sync::Arc; - -use rustc_hash::FxHashSet; -use serde::Serialize; -use vite_path::AbsolutePath; -use vite_str::Str; - -use super::TaskQueryKind; -use crate::{query::TaskQuery, specifier::TaskSpecifier}; - -/// Represents task query args of `vp run` -/// It will be converted to `TaskQuery`, but may be invalid (contains conflicting options), -/// if so the error is returned early before loading the task graph. -#[derive(Debug, clap::Parser)] -pub struct CLITaskQuery { - /// Specifies one or multiple tasks to run, in form of `packageName#taskName` or `taskName`. - tasks: Vec, - - /// Run tasks found in all packages in the workspace, in topological order based on package dependencies. - #[clap(default_value = "false", short, long)] - recursive: bool, - - /// Run tasks found in the current package and all its transitive dependencies, in topological order based on package dependencies. - #[clap(default_value = "false", short, long)] - transitive: bool, - - /// Do not run dependencies specified in `dependsOn` fields. - #[clap(default_value = "false", long)] - ignore_depends_on: bool, -} - -#[derive(thiserror::Error, Debug, Serialize)] -pub enum CLITaskQueryError { - #[error("--recursive and --transitive cannot be used together")] - RecursiveTransitiveConflict, - - #[error("cannot specify package '{package_name}' for task '{task_name}' with --recursive")] - PackageNameSpecifiedWithRecursive { package_name: Str, task_name: Str }, -} - -impl CLITaskQuery { - /// Convert to `TaskQuery`, or return an error if invalid. - /// - /// # Errors - /// - /// Returns [`CLITaskQueryError::RecursiveTransitiveConflict`] if both `--recursive` and - /// `--transitive` are set, or [`CLITaskQueryError::PackageNameSpecifiedWithRecursive`] - /// if a package name is specified with `--recursive`. - pub fn into_task_query(self, cwd: &Arc) -> Result { - let include_explicit_deps = !self.ignore_depends_on; - - let kind = if self.recursive { - if self.transitive { - return Err(CLITaskQueryError::RecursiveTransitiveConflict); - } - let task_names: FxHashSet = self - .tasks - .into_iter() - .map(|s| { - if let Some(package_name) = s.package_name { - return Err(CLITaskQueryError::PackageNameSpecifiedWithRecursive { - package_name, - task_name: s.task_name, - }); - } - Ok(s.task_name) - }) - .collect::>()?; - TaskQueryKind::Recursive { task_names } - } else { - TaskQueryKind::Normal { - task_specifiers: self.tasks.into_iter().collect(), - cwd: Arc::clone(cwd), - include_topological_deps: self.transitive, - } - }; - Ok(TaskQuery { kind, include_explicit_deps }) - } -} diff --git a/crates/vite_task_graph/src/query/mod.rs b/crates/vite_task_graph/src/query/mod.rs index c96dddd4..8a183871 100644 --- a/crates/vite_task_graph/src/query/mod.rs +++ b/crates/vite_task_graph/src/query/mod.rs @@ -1,5 +1,3 @@ -pub mod cli; - use std::sync::Arc; use petgraph::{prelude::DiGraphMap, visit::EdgeRef}; @@ -16,19 +14,19 @@ use crate::{ /// Different kinds of task queries. #[derive(Debug)] pub enum TaskQueryKind { - /// A normal task query specifying task specifiers and options. - /// The tasks will be searched in packages in task specifiers, or located from cwd. + /// A normal task query specifying a task specifier and options. + /// The task will be searched in the package in the task specifier, or located from cwd. Normal { - task_specifiers: FxHashSet, + task_specifier: TaskSpecifier, /// Where the query is being run from. cwd: Arc, /// Whether to include topological dependencies include_topological_deps: bool, }, - /// A recursive task query specifying one or multiple task names. - /// It will match all tasks with the given names across all packages with topological ordering. + /// A recursive task query specifying a task name. + /// It will match all tasks with the given name across all packages with topological ordering. /// The whole workspace will be searched, so cwd is not relevant. - Recursive { task_names: FxHashSet }, + Recursive { task_name: Str }, } /// Represents a valid query for a task and its dependencies, usually issued from a CLI command `vp run ...`. @@ -80,65 +78,60 @@ impl IndexedTaskGraph { // Add starting tasks without dependencies match query.kind { - TaskQueryKind::Normal { task_specifiers, cwd, include_topological_deps } => { + TaskQueryKind::Normal { task_specifier, cwd, include_topological_deps } => { let package_index_from_cwd = self.indexed_package_graph.get_package_index_from_cwd(&cwd); - // For every task specifier, add matching tasks - for specifier in task_specifiers { - // Find the starting task - let starting_task_result = - self.get_task_index_by_specifier(specifier.clone(), || { - package_index_from_cwd - .ok_or_else(|| PackageUnknownError { cwd: Arc::clone(&cwd) }) - }); - - match starting_task_result { - Ok(starting_task) => { - // Found it, add to execution graph - execution_graph.add_node(starting_task); - } - // Task not found, but package located, and the query requests topological deps - // This happens when running `vp run --transitive taskName` in a package without `taskName`, but its dependencies have it. - Err(err @ SpecifierLookupError::TaskNameNotFound { package_index, .. }) - if include_topological_deps => - { - // try to find nearest task - let mut nearest_topological_tasks = Vec::::new(); - self.find_nearest_topological_tasks( - &specifier.task_name, - package_index, - &mut nearest_topological_tasks, - ); - if nearest_topological_tasks.is_empty() { - // No nearest task found, return original error - return Err(TaskQueryError::SpecifierLookupError { - specifier, - lookup_error: err, - }); - } - // Add nearest tasks to execution graph - // Topological dependencies of nearest tasks will be added later - for nearest_task in nearest_topological_tasks { - execution_graph.add_node(nearest_task); - } - } - Err(err) => { - // Not recoverable by finding nearest package, return error + // Find the starting task + let starting_task_result = + self.get_task_index_by_specifier(task_specifier.clone(), || { + package_index_from_cwd + .ok_or_else(|| PackageUnknownError { cwd: Arc::clone(&cwd) }) + }); + + match starting_task_result { + Ok(starting_task) => { + // Found it, add to execution graph + execution_graph.add_node(starting_task); + } + // Task not found, but package located, and the query requests topological deps + // This happens when running `vp run --transitive taskName` in a package without `taskName`, but its dependencies have it. + Err(err @ SpecifierLookupError::TaskNameNotFound { package_index, .. }) + if include_topological_deps => + { + // try to find nearest task + let mut nearest_topological_tasks = Vec::::new(); + self.find_nearest_topological_tasks( + &task_specifier.task_name, + package_index, + &mut nearest_topological_tasks, + ); + if nearest_topological_tasks.is_empty() { + // No nearest task found, return original error return Err(TaskQueryError::SpecifierLookupError { - specifier, + specifier: task_specifier, lookup_error: err, }); } + // Add nearest tasks to execution graph + // Topological dependencies of nearest tasks will be added later + for nearest_task in nearest_topological_tasks { + execution_graph.add_node(nearest_task); + } + } + Err(err) => { + // Not recoverable by finding nearest package, return error + return Err(TaskQueryError::SpecifierLookupError { + specifier: task_specifier, + lookup_error: err, + }); } } } - TaskQueryKind::Recursive { task_names } => { - // Add all tasks matching the names across all packages + TaskQueryKind::Recursive { task_name } => { + // Add all tasks matching the name across all packages for task_index in self.task_graph.node_indices() { - let current_task_name = - self.task_graph[task_index].task_display.task_name.as_str(); - if task_names.contains(current_task_name) { + if self.task_graph[task_index].task_display.task_name == task_name { execution_graph.add_node(task_index); } } From ed343cd14cd1a160ec586851e375e37085661742 Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 26 Feb 2026 11:21:15 +0800 Subject: [PATCH 04/35] add pnpm-filter docs --- crates/vite_task/docs/pnpm-filter.md | 322 +++++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 crates/vite_task/docs/pnpm-filter.md diff --git a/crates/vite_task/docs/pnpm-filter.md b/crates/vite_task/docs/pnpm-filter.md new file mode 100644 index 00000000..81af8e43 --- /dev/null +++ b/crates/vite_task/docs/pnpm-filter.md @@ -0,0 +1,322 @@ +# Specification: `pnpm run --filter` / `--filter-prod` with Exact and Glob Task Names + +## Context + +This document describes the end-to-end behavior of `pnpm run` when combined with `--filter` or `--filter-prod` (also written `--prod-filter`). The flow is split into two independent stages: **package selection** (which workspace packages to consider) and **task matching** (which scripts to run within each selected package). + +This spec is based on pnpm@dcd16c7b36cf95dc2abb9b09a81d66e87cd3fe97. + +--- + +## Stage 1: Package Selection + +### 1.1 CLI Parsing + +When the CLI receives `--filter ` or `--filter-prod `: + +1. [parse-cli-args](cli/parse-cli-args/src/index.ts) detects the `--filter` / `--filter-prod` option via `nopt`. If either is present, the command is implicitly made **recursive** (`options.recursive = true`). + +2. [config](config/config/src/index.ts) normalizes the option: if the value is a single string, it is **split by spaces** into an array. So `--filter "a b"` becomes `["a", "b"]`. + +3. [main.ts](pnpm/src/main.ts:200-203) wraps each filter string into a `WorkspaceFilter` object: + - Strings from `--filter` get `{ filter: , followProdDepsOnly: false }`. + - Strings from `--filter-prod` get `{ filter: , followProdDepsOnly: true }`. + +### 1.2 Selector Parsing + +Each filter string is parsed by [`parsePackageSelector`](workspace/filter-workspace-packages/src/parsePackageSelector.ts) into a `PackageSelector`: + +``` +interface PackageSelector { + namePattern?: string // e.g. "a", "@scope/pkg", "foo*" + parentDir?: string // e.g. resolved absolute path from "./packages" + diff?: string // e.g. "master" from "[master]" + exclude?: boolean // leading "!" in the original string + excludeSelf?: boolean // "^" modifier + includeDependencies?: boolean // trailing "..." + includeDependents?: boolean // leading "..." + followProdDepsOnly?: boolean // from --filter-prod +} +``` + +Parsing rules (applied in order on the raw string): + +1. Leading `!` → set `exclude: true`, strip. +2. Trailing `...` → set `includeDependencies: true`, strip. Then trailing `^` → set `excludeSelf: true`, strip. +3. Leading `...` → set `includeDependents: true`, strip. Then leading `^` → set `excludeSelf: true`, strip. +4. Remainder is matched against regex: `name{dir}[diff]` extracting the three optional parts. +5. If the regex doesn't match and the string looks like a relative path (starts with `.` or `..`), it becomes `parentDir`. + +### 1.3 Building the Dependency Graph + +[`filterPkgsBySelectorObjects`](workspace/filter-workspace-packages/src/index.ts:90-147) splits selectors into two groups: + +- **prod selectors** (`followProdDepsOnly: true`) +- **all selectors** (`followProdDepsOnly: false`) + +For each group, a **separate package graph** is built via [`createPkgGraph`](workspace/pkgs-graph/src/index.ts): + +- The **all** graph includes: `dependencies`, `devDependencies`, `optionalDependencies`, `peerDependencies`. +- The **prod** graph includes: `dependencies`, `optionalDependencies`, `peerDependencies` (i.e. `devDependencies` are excluded via `ignoreDevDeps: true`). + +Each graph is a map from `ProjectRootDir` → `{ package, dependencies: ProjectRootDir[] }`. A dependency edge exists only when it resolves to another workspace package (via version/range matching or `workspace:` protocol). + +### 1.4 Selecting Packages from the Graph + +[`filterWorkspacePackages`](workspace/filter-workspace-packages/src/index.ts:149-178) applies the selectors to the graph: + +1. Selectors are partitioned into **include** selectors and **exclude** selectors (those with `exclude: true`). +2. If there are no include selectors, **all** packages in the graph are initially selected. +3. For each include selector, `_filterGraph` is called. +4. For exclude selectors, `_filterGraph` is called similarly. +5. Final result = `include.selected − exclude.selected`. + +### 1.5 `_filterGraph` — The Core Selection Algorithm + +[`_filterGraph`](workspace/filter-workspace-packages/src/index.ts:180-273) maintains three sets and one list: + +- `cherryPickedPackages` — directly matched, no graph traversal +- `walkedDependencies` — collected by walking dependency edges +- `walkedDependents` — collected by walking reverse-dependency edges +- `walkedDependentsDependencies` — dependencies of walked dependents + +For each selector: + +**Step A — Find entry packages:** + +- If `namePattern` is set: match package names using [`createMatcher`](config/matcher/src/index.ts) (a glob matcher that converts `*` to `.*` regex; no `*` means exact match). + - Bonus: if a non-scoped pattern yields zero matches, it retries as `@*/` and accepts the result only if exactly one package matches. +- If `parentDir` is set: match packages whose root dir is under that path. + +**Step B — Expand via graph traversal (`selectEntries`):** + +- If `includeDependencies` is true: walk the dependency graph forward from entry packages (DFS), adding all reachable nodes to `walkedDependencies`. If `excludeSelf` is true, the entry package itself is not added (but its dependencies still are). +- If `includeDependents` is true: walk the **reversed** graph from entry packages, adding all reachable nodes to `walkedDependents`. Same `excludeSelf` logic. +- If **both** `includeDependencies` and `includeDependents`: additionally walk forward from all walked dependents into `walkedDependentsDependencies`. +- If **neither**: simply push entry packages into `cherryPickedPackages`. + +**Step C — Combine:** The final selected set is the union of all four collections. + +### 1.6 Merging Results + +If both `--filter` and `--filter-prod` selectors exist, their selected graphs are merged (union). The result is stored as `config.selectedProjectsGraph` — a subset of the full workspace graph. + +--- + +## Stage 2: Task Matching and Execution + +Package selection (Stage 1) is completely independent of task names. It doesn't look at `scripts` at all. Task matching happens later, within the `run` command. + +### 2.1 Entry to `runRecursive` + +The [`run` handler](exec/plugin-commands-script-runners/src/run.ts:189-305) checks `opts.recursive`. If true and there's a script name (or more than one selected package), it delegates to [`runRecursive`](exec/plugin-commands-script-runners/src/runRecursive.ts:43-201), passing the full `selectedProjectsGraph`. + +### 2.2 Topological Sorting + +If `opts.sort` is true (the default), packages in `selectedProjectsGraph` are topologically sorted via [`sortPackages`](workspace/sort-packages/src/index.ts:18-21). The sort only considers edges **within the selected graph** (edges to non-selected packages are ignored). The result is an array of "chunks" — each chunk is a group of packages with no inter-dependencies that can run in parallel. + +### 2.3 Per-Package Script Matching + +For each package in each chunk, [`getSpecifiedScripts`](exec/plugin-commands-script-runners/src/runRecursive.ts:217-232) determines which scripts to run: + +1. **Exact match first:** If `scripts[scriptName]` exists, return `[scriptName]`. +2. **Regex match:** If the script name has the form `/pattern/` (a regex literal), [`tryBuildRegExpFromCommand`](exec/plugin-commands-script-runners/src/regexpCommand.ts) extracts the pattern and builds a `RegExp`. All script keys in the package's `scripts` that match this regex are returned. Regex flags (e.g. `/pattern/i`) are **not supported** and throw an error. +3. **No match:** Return `[]`. + +Note: this is **not** glob matching. Task name patterns use **regex literal syntax** (`/pattern/`), while package name patterns in `--filter` use **glob syntax** (`*`). They are different systems. + +### 2.4 Handling Packages Without Matching Scripts + +When `getSpecifiedScripts` returns an empty array for a package: + +- The package's result status is set to `'skipped'`. +- The package is simply not executed — no error is raised for that individual package. + +### 2.5 Error Conditions + +After iterating through all packages: + +- If **zero** packages had a matching script (`hasCommand === 0`), **and** the script name is not `"test"`, **and** `--if-present` was not passed: + - Error: `RECURSIVE_RUN_NO_SCRIPT` — "None of the selected packages has a `` script." +- Additionally, if `requiredScripts` config includes the script name, **all** selected packages must have it, or an error is thrown before execution begins. + +### 2.6 Execution Order + +The topological sort and chunking operate at the **package** level, not the individual script level. The execution loop ([runRecursive.ts:90-183](exec/plugin-commands-script-runners/src/runRecursive.ts#L90-L183)) processes one chunk at a time: + +1. For each chunk, collect all `(package, scriptName)` pairs by running `getSpecifiedScripts` on every package in the chunk, then flatten. +2. Run all collected scripts in the chunk **concurrently** (up to `workspaceConcurrency` limit) via `Promise.all`. +3. **Await** the entire chunk before proceeding to the next. + +This means: if package b is in chunk N and package a is in chunk N+1, **all** of b's matched scripts finish before **any** of a's matched scripts start — regardless of whether the matched script names are the same or different. Within a single chunk, scripts from different packages (and even multiple matched scripts from the same package) run concurrently. + +--- + +## Worked Examples + +### Example 1: `pnpm run --filter "a..." build` + +**Setup:** a (has `build`) → depends on b (no `build`) → depends on c (has `build`) + +**Stage 1 — Package Selection:** + +1. Parse `"a..."` → `{ namePattern: "a", includeDependencies: true }` +2. Build full dependency graph: a→b, b→c +3. Entry packages: match name "a" → `[a]` +4. Walk dependencies from a: a→b→c. `walkedDependencies = {a, b, c}` +5. `selectedProjectsGraph = { a, b, c }` + +**Stage 2 — Task Matching:** + +1. Topological sort of {a, b, c} within selected graph: chunks = `[[c], [b], [a]]` (dependencies first) +2. Chunk [c]: `getSpecifiedScripts(c.scripts, "build")` → `["build"]` → run c's build. `hasCommand = 1` +3. Chunk [b]: `getSpecifiedScripts(b.scripts, "build")` → `[]` → skip. b is marked `'skipped'`. +4. Chunk [a]: `getSpecifiedScripts(a.scripts, "build")` → `["build"]` → run a's build. `hasCommand = 2` +5. `hasCommand > 0`, no error. + +**Result:** c's `build` runs first, b is skipped, then a's `build` runs. + +### Example 2: `pnpm run --filter "a..." build` + +**Setup:** a (no `build`) → depends on b (has `build`) + +**Stage 1 — Package Selection:** + +1. Parse `"a..."` → `{ namePattern: "a", includeDependencies: true }` +2. Entry packages: `[a]` +3. Walk dependencies: a→b. `walkedDependencies = {a, b}` +4. `selectedProjectsGraph = { a, b }` + +**Stage 2 — Task Matching:** + +1. Topological sort: chunks = `[[b], [a]]` +2. Chunk [b]: `getSpecifiedScripts(b.scripts, "build")` → `["build"]` → run. `hasCommand = 1` +3. Chunk [a]: `getSpecifiedScripts(a.scripts, "build")` → `[]` → skip. +4. `hasCommand > 0`, no error. + +**Result:** b's `build` runs, a is skipped. + +### Example 3: `pnpm run --filter "a..." /glob/` + +**Setup:** a → depends on b. Package a has script `taskA` matching `/glob/`. Package b has script `taskB` matching `/glob/`. Neither has the other's task. + +**Stage 1 — Package Selection:** + +1. Parse `"a..."` → `{ namePattern: "a", includeDependencies: true }` +2. Entry packages: `[a]` +3. Walk dependencies: a→b. `walkedDependencies = {a, b}` +4. `selectedProjectsGraph = { a, b }` + +**Stage 2 — Task Matching:** + +1. `scriptName = "/glob/"`. This is a regex literal. +2. `tryBuildRegExpFromCommand("/glob/")` → `RegExp("glob")` +3. Topological sort: chunks = `[[b], [a]]` +4. Chunk [b]: `getSpecifiedScripts(b.scripts, "/glob/")`: + - No exact match for literal `"/glob/"` in scripts + - Regex match: filter b's script keys by `RegExp("glob")` → finds `taskB` → `["taskB"]` + - Run b's `taskB`. `hasCommand = 1` +5. Chunk [a]: `getSpecifiedScripts(a.scripts, "/glob/")`: + - No exact match + - Regex match: filter a's script keys by `RegExp("glob")` → finds `taskA` → `["taskA"]` + - Run a's `taskA`. `hasCommand = 2` +6. `hasCommand > 0`, no error. + +**Result:** b's `taskB` runs first, then a's `taskA`. Each package independently matches its own scripts against the regex. The regex is applied per-package, so different packages can match different script names. + +**Ordering note:** Even though `taskA` and `taskB` have different names, b's `taskB` still runs before a's `taskA` because the ordering is at the package level. Package b is in an earlier topological chunk than a (since a depends on b). All scripts matched in a package inherit that package's position in the execution order. + +### Example 4: `pnpm run --filter 'app...' --filter 'cli' build` + +**Setup:** Workspace has packages: app, lib, core, utils, cli. app→lib→core→utils (chain). cli→core (separate). All have `build`. + +**Stage 1 — Package Selection:** + +1. Two `--filter` flags produce two `WorkspaceFilter` objects, both with `followProdDepsOnly: false`. They share the **same** graph (the full "all" graph). +2. Parse selectors: + - `"app..."` → `{ namePattern: "app", includeDependencies: true }` + - `"cli"` → `{ namePattern: "cli" }` (no `...`, no `!`, no `^`) +3. Both are include selectors (no `exclude`). `_filterGraph` processes them sequentially: + - **Selector 1 (app...):** Entry = [app]. Walk dependencies: app→lib→core→utils. `walkedDependencies = {app, lib, core, utils}`. + - **Selector 2 (cli):** Entry = [cli]. Neither `includeDependencies` nor `includeDependents` → push to `cherryPickedPackages = [cli]`. +4. Combine: union of all collections → `{app, lib, core, utils, cli}`. +5. `selectedProjectsGraph = { app, lib, core, utils, cli }` — **all five** packages, each retaining their original dependency edges from the full graph. + +**Stage 2 — Task Matching:** + +1. Topological sort over the selected graph. Edges within the selected set: app→lib, lib→core, core→utils, cli→core. + - Chunks: `[[utils], [core], [lib, cli], [app]]` + - Note: `lib` and `cli` are in the **same chunk** — they both depend on `core` but not on each other, so they can run in parallel. +2. Chunk [utils]: run `build`. Chunk [core]: run `build`. Chunk [lib, cli]: run both `build` scripts concurrently. Chunk [app]: run `build`. + +**Key insight:** Even though `cli` was selected without `...` (cherry-picked, not graph-expanded), the topological sort still respects its dependency on `core` because the sort operates on the `selectedProjectsGraph` which retains the original dependency edges. Multiple `--filter` flags with the same `followProdDepsOnly` value contribute to the same `_filterGraph` call and their results are unioned together. + +--- + +## Key Design Insight + +The two stages are fully decoupled: + +- **Stage 1 (package selection)** answers: "Which workspace packages should be considered?" It uses the dependency graph and filter patterns, and knows nothing about scripts. +- **Stage 2 (task matching)** answers: "Within each selected package, which scripts should run?" It uses the script name (exact or regex) against each package's `scripts` field, and skips packages without matches. + +This means `--filter "a..."` always selects a and all its (transitive) dependencies regardless of whether they have the requested script. Packages without the script are silently skipped (unless `requiredScripts` is configured or no package at all has the script). + +--- + +## Key Source Files + +| Component | Path | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| CLI arg parsing | [cli/parse-cli-args/src/index.ts](cli/parse-cli-args/src/index.ts) | +| Filter wiring in main | [pnpm/src/main.ts:194-251](pnpm/src/main.ts#L194-L251) | +| Selector parsing | [workspace/filter-workspace-packages/src/parsePackageSelector.ts](workspace/filter-workspace-packages/src/parsePackageSelector.ts) | +| Package filtering core | [workspace/filter-workspace-packages/src/index.ts](workspace/filter-workspace-packages/src/index.ts) | +| Dependency graph construction | [workspace/pkgs-graph/src/index.ts](workspace/pkgs-graph/src/index.ts) | +| Name pattern matcher | [config/matcher/src/index.ts](config/matcher/src/index.ts) | +| Topological sort | [workspace/sort-packages/src/index.ts](workspace/sort-packages/src/index.ts) | +| Run command handler | [exec/plugin-commands-script-runners/src/run.ts](exec/plugin-commands-script-runners/src/run.ts) | +| Recursive runner + script matching | [exec/plugin-commands-script-runners/src/runRecursive.ts](exec/plugin-commands-script-runners/src/runRecursive.ts) | +| Regex command parser | [exec/plugin-commands-script-runners/src/regexpCommand.ts](exec/plugin-commands-script-runners/src/regexpCommand.ts) | + +--- + +## Relevant Tests + +### Selector Parsing + +- [workspace/filter-workspace-packages/test/parsePackageSelector.ts](workspace/filter-workspace-packages/test/parsePackageSelector.ts) — 16 fixture-driven tests covering all selector syntax: name, `...`, `^`, `!`, `{dir}`, `[diff]`, and combinations. + +### Package Filtering (graph traversal) + +- [workspace/filter-workspace-packages/test/index.ts](workspace/filter-workspace-packages/test/index.ts) — Tests for `filterWorkspacePackages`: dependencies, dependents, combined deps+dependents, self-exclusion, by-name, by-directory (exact and glob), git-diff filtering, exclusion patterns, unmatched filter reporting. + +### Dependency Graph Construction + +- [workspace/pkgs-graph/test/index.ts](workspace/pkgs-graph/test/index.ts) — Tests for `createPkgGraph`: basic deps, peer deps, local directory deps, `workspace:` protocol, `ignoreDevDeps: true`, `linkWorkspacePackages: false`, prerelease version matching. + +### Name Pattern Matcher + +- [config/matcher/test/index.ts](config/matcher/test/index.ts) — Tests for `createMatcher`: wildcard (`*`), glob patterns, exact match, negation (`!`), multiple patterns. + +### Topological Sort + +- [deps/graph-sequencer/test/index.ts](deps/graph-sequencer/test/index.ts) — 18+ tests for `graphSequencer`: cycles, subgraph sequencing, independent nodes, multi-dependency chains. + +### Run Command (unit) + +- [exec/plugin-commands-script-runners/test/runRecursive.ts](exec/plugin-commands-script-runners/test/runRecursive.ts) — 29 tests: basic recursive run, reversed, concurrent, filtering, `--if-present`, `--bail`, `requiredScripts`, `--resume-from`, RegExp selectors, report summary. +- [exec/plugin-commands-script-runners/test/index.ts](exec/plugin-commands-script-runners/test/index.ts) — 21 tests: single-package run, exit codes, RegExp script selectors (including invalid flags), `--if-present`, command suggestions. + +### Regex Script Matching + +- Covered in both test files above. Key tests: `pnpm run with RegExp script selector should work` and 8 tests for invalid regex flags. + +### `--filter-prod` + +- [pnpm/test/filterProd.test.ts](pnpm/test/filterProd.test.ts) — E2E tests comparing `--filter` vs `--filter-prod` with a 4-project graph, verifying devDependencies inclusion/exclusion. + +### E2E / Integration + +- [pnpm/test/recursive/run.ts](pnpm/test/recursive/run.ts) — CLI-level integration tests for `pnpm run` in recursive mode. +- [pnpm/test/recursive/filter.ts](pnpm/test/recursive/filter.ts) — CLI-level integration tests for recursive filtering. From 7f415b44bcc321753a18604189f141b2efff8730 Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 26 Feb 2026 17:34:13 +0800 Subject: [PATCH 05/35] update pnpm-filter docs --- crates/vite_task/docs/pnpm-filter.md | 69 +++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/crates/vite_task/docs/pnpm-filter.md b/crates/vite_task/docs/pnpm-filter.md index 81af8e43..6dfeaa78 100644 --- a/crates/vite_task/docs/pnpm-filter.md +++ b/crates/vite_task/docs/pnpm-filter.md @@ -99,7 +99,13 @@ For each selector: ### 1.6 Merging Results -If both `--filter` and `--filter-prod` selectors exist, their selected graphs are merged (union). The result is stored as `config.selectedProjectsGraph` — a subset of the full workspace graph. +If both `--filter` and `--filter-prod` selectors exist, their selected graphs are merged. The merge is ([filterPkgsBySelectorObjects](workspace/filter-workspace-packages/src/index.ts#L132-L137)): + +``` +selectedProjectsGraph = { ...prodFilteredGraph, ...filteredGraph } +``` + +This is a JS object spread, so for any package that appears in **both** graphs, the node from `filteredGraph` (full graph, with devDep edges) **overwrites** the node from `prodFilteredGraph` (prod graph, without devDep edges). This creates an asymmetry: the **graph origin of each node** determines which dependency edges it carries into the final `selectedProjectsGraph`. See Examples 6-8 for the implications. --- @@ -226,7 +232,30 @@ This means: if package b is in chunk N and package a is in chunk N+1, **all** of **Ordering note:** Even though `taskA` and `taskB` have different names, b's `taskB` still runs before a's `taskA` because the ordering is at the package level. Package b is in an earlier topological chunk than a (since a depends on b). All scripts matched in a package inherit that package's position in the execution order. -### Example 4: `pnpm run --filter 'app...' --filter 'cli' build` +### Example 4: `pnpm run --filter a --filter b build` + +**Setup:** a depends on b. Both have `build`. + +**Stage 1 — Package Selection:** + +1. Two `--filter` flags → two `WorkspaceFilter` objects, both `followProdDepsOnly: false`. +2. Parse selectors: + - `"a"` → `{ namePattern: "a" }` (no `...`) + - `"b"` → `{ namePattern: "b" }` (no `...`) +3. Both are include selectors. `_filterGraph` processes them sequentially: + - **Selector 1 (a):** Entry = [a]. Neither `includeDependencies` nor `includeDependents` → `cherryPickedPackages = [a]`. + - **Selector 2 (b):** Entry = [b]. Same → `cherryPickedPackages = [a, b]`. +4. No graph walking occurs — both packages are cherry-picked. +5. `selectedProjectsGraph = { a, b }` — both retain their original dependency edges from the full graph (a→b edge is preserved). + +**Stage 2 — Task Matching:** + +1. Topological sort over {a, b}. Edge a→b is within the selected set. Chunks: `[[b], [a]]`. +2. Chunk [b]: run `build`. Chunk [a]: run `build`. + +**Key insight:** Even though neither filter used `...` (no dependency expansion), the topological sort still respects the a→b dependency edge. Cherry-picking packages does not remove their dependency relationships — the `selectedProjectsGraph` retains the original edges from the full graph, and [`sequenceGraph`](workspace/sort-packages/src/index.ts#L5-L16) filters edges to only those between selected packages. So b's `build` runs before a's `build`. + +### Example 5: `pnpm run --filter 'app...' --filter 'cli' build` **Setup:** Workspace has packages: app, lib, core, utils, cli. app→lib→core→utils (chain). cli→core (separate). All have `build`. @@ -251,6 +280,42 @@ This means: if package b is in chunk N and package a is in chunk N+1, **all** of **Key insight:** Even though `cli` was selected without `...` (cherry-picked, not graph-expanded), the topological sort still respects its dependency on `core` because the sort operates on the `selectedProjectsGraph` which retains the original dependency edges. Multiple `--filter` flags with the same `followProdDepsOnly` value contribute to the same `_filterGraph` call and their results are unioned together. +### Examples 6-8: `--filter` / `--filter-prod` mix with devDependencies + +**Common setup for all three:** b is a **devDependency** of a. Both have `build`. + +Each selected package's node comes from the graph of whichever filter type selected it. The a→b edge only exists in the full graph (from `--filter`), not the prod graph (from `--filter-prod`). So the edge in the final `selectedProjectsGraph` depends on which graph **a's node** came from — since a is the package that _declares_ the dependency. + +### Example 6: `pnpm run --filter-prod a --filter-prod b build` + +**Stage 1:** Both selectors have `followProdDepsOnly: true`. A single prod graph is built (`ignoreDevDeps: true`). a's node has no edge to b. Both cherry-picked into `selectedProjectsGraph`. + +**Stage 2:** `sortPackages` sees no edge → **1 chunk** containing both. They run concurrently. + +### Example 7: `pnpm run --filter a --filter-prod b build` + +**Stage 1:** The selectors are split: + +- `"a"` → `allPackageSelectors` (full graph). a's node comes from the full graph → its `dependencies` array **includes** b (devDep edge present). +- `"b"` → `prodPackageSelectors` (prod graph). b's node comes from the prod graph. + +Merge: `{ ...prodGraph(b), ...fullGraph(a) }`. No overlap, so a's node (with devDep edge) and b's node are both present. + +**Stage 2:** `sortPackages` sees edge a→b → **2 chunks**: `[[b], [a]]`. b's `build` runs first. + +### Example 8: `pnpm run --filter-prod a --filter b build` + +**Stage 1:** The selectors are split: + +- `"a"` → `prodPackageSelectors` (prod graph). a's node comes from the prod graph → its `dependencies` array **excludes** b (devDep edge absent). +- `"b"` → `allPackageSelectors` (full graph). b's node comes from the full graph. + +Merge: `{ ...prodGraph(a), ...fullGraph(b) }`. a's node has no edge to b. + +**Stage 2:** `sortPackages` sees no edge → **1 chunk** containing both. They run concurrently. + +**Key insight across 6-8:** The dependency edge a→b exists in the final graph **only when a's node comes from the full graph** (i.e. a was selected via `--filter`, not `--filter-prod`). It does not matter which flag selected b. This asymmetry comes from the merge in `filterPkgsBySelectorObjects`: each node retains the edges from whichever graph (full vs prod) it was selected from. + --- ## Key Design Insight From 7d8388691300e4ef9c00bdb690f286cd07f33c28 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Feb 2026 10:08:39 +0800 Subject: [PATCH 06/35] feat: filter packages with --filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pnpm-style --filter/-F flag for fine-grained package selection. Topological ordering is now computed at query time from the package subgraph rather than pre-computed in the task graph, which correctly handles per-query skip-intermediate reconnection. - Add package_filter module (types, parsing, unit tests) to vite_workspace - Move IndexedPackageGraph to vite_workspace with resolve_query/resolve_filters - Replace TaskQueryKind with two-stage model: PackageQuery → package subgraph → task mapping - Simplify TaskDependencyType to explicit-only (topological edges removed from task graph) - Add --filter/-F to CLI with conflict validation against -r/-t - Add filter-workspace and transitive-skip-intermediate test fixtures - Add task-query.md documenting the data flow and design decisions Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + crates/vite_task/Cargo.toml | 1 + crates/vite_task/docs/task-query.md | 235 +++++++ crates/vite_task/src/cli/mod.rs | 87 ++- crates/vite_task/src/session/mod.rs | 17 +- ...ypo in task script fails without list.snap | 4 +- crates/vite_task_graph/src/lib.rs | 143 +---- crates/vite_task_graph/src/package_graph.rs | 70 --- crates/vite_task_graph/src/query/mod.rs | 296 ++++----- crates/vite_task_plan/src/error.rs | 31 +- crates/vite_task_plan/src/plan.rs | 20 +- .../snapshots/task graph.snap | 159 +---- .../conflict-test/snapshots/task graph.snap | 2 +- .../snapshots/task graph.snap | 4 +- .../snapshots/task graph.snap | 2 +- .../snapshots/task graph.snap | 27 +- .../snapshots/task graph.snap | 59 +- .../fixtures/filter-workspace/package.json | 4 + .../packages/app/package.json | 14 + .../packages/cli/package.json | 11 + .../packages/core/package.json | 8 + .../packages/lib/package.json | 11 + .../packages/utils/package.json | 7 + .../filter-workspace/pnpm-workspace.yaml | 2 + .../fixtures/filter-workspace/snapshots.toml | 110 ++++ ...cked filters respect dependency order.snap | 11 + .../query - exclude only filter.snap | 14 + ...ery - filter both deps and dependents.snap | 16 + .../query - filter by directory.snap | 8 + .../query - filter by exact name.snap | 8 + .../snapshots/query - filter by glob.snap | 19 + ...filter dependencies only exclude self.snap | 12 + ...- filter dependents only exclude self.snap | 12 + ...lter deps skips packages without task.snap | 14 + .../query - filter include and exclude.snap | 14 + .../query - filter with dependencies.snap | 16 + .../query - filter with dependents.snap | 17 + .../query - mixed traversal filters.snap | 19 + .../query - multiple filters union.snap | 9 + .../snapshots/query - recursive flag.snap | 19 + ...ransitive flag from package subfolder.snap | 16 + .../snapshots/query - transitive flag.snap | 16 + .../snapshots/task graph.snap | 259 ++++++++ .../fixtures/filter-workspace/vite-task.json | 3 + .../snapshots/task graph.snap | 57 +- .../transitive-skip-intermediate/package.json | 4 + .../packages/bottom/package.json | 7 + .../packages/middle/package.json | 10 + .../packages/top/package.json | 10 + .../pnpm-workspace.yaml | 2 + .../snapshots.toml | 34 ++ ...er entry lacks task dependency has it.snap | 8 + ...er intermediate lacks task is skipped.snap | 11 + ...build skips intermediate without task.snap | 11 + ... transitive from package without task.snap | 8 + .../snapshots/task graph.snap | 70 +++ crates/vite_workspace/src/lib.rs | 2 + crates/vite_workspace/src/package_filter.rs | 576 ++++++++++++++++++ crates/vite_workspace/src/package_graph.rs | 418 +++++++++++++ 59 files changed, 2378 insertions(+), 677 deletions(-) create mode 100644 crates/vite_task/docs/task-query.md delete mode 100644 crates/vite_task_graph/src/package_graph.rs create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/app/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/cli/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/core/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/lib/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/utils/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/pnpm-workspace.yaml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - cherry picked filters respect dependency order.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - exclude only filter.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter both deps and dependents.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by exact name.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by glob.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dependencies only exclude self.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dependents only exclude self.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter deps skips packages without task.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter include and exclude.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter with dependencies.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter with dependents.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - mixed traversal filters.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - multiple filters union.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - recursive flag.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive flag from package subfolder.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive flag.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/task graph.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/vite-task.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/packages/bottom/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/packages/middle/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/packages/top/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/pnpm-workspace.yaml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots.toml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - filter entry lacks task dependency has it.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - filter intermediate lacks task is skipped.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive build skips intermediate without task.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive from package without task.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/task graph.snap create mode 100644 crates/vite_workspace/src/package_filter.rs create mode 100644 crates/vite_workspace/src/package_graph.rs diff --git a/Cargo.lock b/Cargo.lock index e800887e..61779d15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3887,6 +3887,7 @@ dependencies = [ "tokio", "tracing", "twox-hash", + "vec1", "vite_glob", "vite_path", "vite_select", diff --git a/crates/vite_task/Cargo.toml b/crates/vite_task/Cargo.toml index 58f61818..404a5cf6 100644 --- a/crates/vite_task/Cargo.toml +++ b/crates/vite_task/Cargo.toml @@ -33,6 +33,7 @@ thiserror = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "io-std", "io-util", "macros", "sync"] } tracing = { workspace = true } twox-hash = { workspace = true } +vec1 = { workspace = true } vite_glob = { workspace = true } vite_path = { workspace = true } vite_select = { workspace = true } diff --git a/crates/vite_task/docs/task-query.md b/crates/vite_task/docs/task-query.md new file mode 100644 index 00000000..ce70fc13 --- /dev/null +++ b/crates/vite_task/docs/task-query.md @@ -0,0 +1,235 @@ +# Task Query + +How `vp run` decides which tasks to run and in what order. + +## The two things we build + +When `vp` starts, it builds two data structures from the workspace: + +1. **Package graph** — which packages depend on which. Built from `package.json` dependency fields. +2. **Task graph** — which tasks exist and their explicit `dependsOn` relationships. Built from `vite-task.json` and `package.json` scripts. + +Both are built once and reused for every query, including nested `vp run` calls inside task scripts. + +### What goes into the task graph + +The task graph contains a node for every task in every package, and edges only for explicit `dependsOn` declarations: + +```jsonc +// packages/app/vite-task.json +{ + "tasks": { + "build": { + "command": "vite build", + "dependsOn": ["@shared/lib#build"] // ← this becomes an edge + } + } +} +``` + +``` +Task graph: + + app#build ──dependsOn──> lib#build + app#test + lib#build + lib#test +``` + +Package dependency ordering (app depends on lib) is NOT stored as edges in the task graph. Why not is explained below. + +## What happens when you run a query + +Every `vp run` command goes through two stages: + +``` +Stage 1: Which packages? Stage 2: Which tasks? + + package graph task graph + + CLI flags ──> + package subgraph + ───────────── + task name + = package subgraph ───────────────── + = execution plan +``` + +### Stage 1: Package selection + +The CLI flags determine which packages participate: + +| Command | What it selects | +| ---------------------------------- | --------------------------------------------- | +| `vp run build` | Just the current package | +| `vp run -r build` | All packages | +| `vp run -t build` | Current package + its transitive dependencies | +| `vp run --filter app... build` | `app` + its transitive dependencies | +| `vp run --filter '!core' -r build` | All packages except `core` | + +The result is a **package subgraph** — the selected packages plus all the dependency edges between them. This subgraph is a subset of the full package graph. + +### Stage 2: Task mapping + +Given the package subgraph and a task name, we build the execution plan: + +1. Find which selected packages have the requested task. +2. For packages that don't have it, reconnect their predecessors to their successors (skip-intermediate, explained below). +3. Map the remaining package nodes to task nodes — this gives us topological ordering. +4. Follow explicit `dependsOn` edges outward from these tasks (may pull in tasks from outside the selected packages). + +The result is the execution plan: which tasks to run and in what order. + +## Why topological edges aren't stored in the task graph + +Consider this workspace: + +``` +Package graph: Tasks each package has: + + app ──> lib ──> core app: build, test + lib: build, test + core: build, test +``` + +If we pre-computed topological edges for `build`, the task graph would have: + +``` +app#build ──> lib#build ──> core#build +``` + +This looks fine for `vp run -r build`. But what about `vp run --filter app --filter core build` (selecting just app and core, skipping lib)? + +The pre-computed edges say `app#build → lib#build → core#build`. But lib isn't selected — so we'd need `app#build → core#build`. That edge doesn't exist in the pre-computed graph. We'd have to recompute it anyway. + +It gets worse. If lib didn't have a `build` task at all, the pre-computed edges would already skip it: `app#build → core#build`. But if you ran `vp run --filter app --filter lib build`, you'd want `app#build → lib#build` — which conflicts with the pre-computed skip. + +The problem is that "which packages are selected" is a per-query decision, and skip-intermediate reconnection depends on that selection. Pre-computed topological edges encode a single global answer that doesn't work for all queries. + +Instead, we compute topological ordering at query time from the package subgraph. The package subgraph already has the right set of packages and edges for the specific query. We just need to map packages to tasks and handle the ones that lack the requested task. + +## Skip-intermediate reconnection + +When a selected package doesn't have the requested task, we bridge across it. + +### Example: middle package lacks the task + +``` +Package subgraph (from --filter top...): + + top ──> middle ──> bottom + +Tasks: + top: has "build" + middle: no "build" + bottom: has "build" +``` + +Step by step: + +1. `top` has `build` → keep it. +2. `middle` has no `build` → connect its predecessors (`top`) directly to its successors (`bottom`), then remove `middle`. +3. `bottom` has `build` → keep it. + +``` +Before reconnection: After reconnection: + + top ──> middle ──> bottom top ──> bottom + +Task execution plan: + + top#build ──> bottom#build +``` + +`bottom#build` runs first, then `top#build`. + +### Example: entry package lacks the task + +``` +Package subgraph (from --filter middle...): + + middle ──> bottom + +Tasks: + middle: no "build" + bottom: has "build" +``` + +`middle` lacks `build`, so we reconnect. It has no predecessors, so there's nothing to bridge. We just remove it. + +``` +Task execution plan: + + bottom#build +``` + +Only `bottom#build` runs. + +### Mutating the subgraph during reconnection + +The package subgraph is already a lightweight `DiGraphMap` — just node indices and edges, not a copy of the full package graph. But reconnection adds bridge edges and removes nodes, and we need those edits to be visible within the same pass. If two consecutive packages lack the task, the second removal needs to see the bridge edge from the first. + +So we clone the `DiGraphMap` once and mutate the clone. We iterate the original (stable node order) while modifying the clone. + +## Explicit dependency expansion + +After mapping the package subgraph to tasks, we follow explicit `dependsOn` edges from the task graph. This can pull in tasks from packages outside the selected set. + +```jsonc +// packages/app/vite-task.json +{ + "tasks": { + "build": { + "dependsOn": ["codegen#generate"] + } + } +} +``` + +If you run `vp run --filter app build`, the package subgraph contains only `app`. But `app#build` has a `dependsOn` pointing to `codegen#generate`. The expansion step follows this edge and adds `codegen#generate` to the execution plan, even though `codegen` wasn't in the filter. + +This is intentional — `dependsOn` is an explicit declaration that a task can't run without its dependency. Ignoring it would break the build. (Users can skip this with `--ignore-depends-on`.) + +The expansion only follows explicit edges, not topological ones. Topological ordering comes from the package subgraph — it's already baked into the task execution graph by Stage 2. + +## Nested `vp run` + +A task script can contain `vp run` calls: + +```jsonc +{ + "tasks": { + "ci": { + "command": "vp run -r build && vp run -r test" + } + } +} +``` + +Each nested `vp run` goes through the same two stages. It reuses the same package graph and task graph that were built at startup — no reloading. + +The nested query produces its own execution subgraph, which gets embedded inside the parent task's execution plan as an expanded item. + +## Putting it all together + +``` +Startup (once): + workspace files ──> package graph ──> task graph + (dependencies) (tasks + dependsOn edges) + +Per query: + CLI flags ──> PackageQuery + │ + ▼ + package graph ──> package subgraph (selected packages + edges) + │ + ▼ + task graph ────> task execution graph + (map packages to tasks, + skip-intermediate reconnection, + explicit dep expansion) + │ + ▼ + execution plan + (resolve env vars, commands, cwd, + expand nested vp run calls) +``` + +The package graph and task graph are stable. They don't change between queries. Everything query-specific is derived from them on the fly. diff --git a/crates/vite_task/src/cli/mod.rs b/crates/vite_task/src/cli/mod.rs index 48241c9c..3b400ccc 100644 --- a/crates/vite_task/src/cli/mod.rs +++ b/crates/vite_task/src/cli/mod.rs @@ -1,10 +1,18 @@ use std::sync::Arc; use clap::Parser; +use vec1::Vec1; use vite_path::AbsolutePath; use vite_str::Str; -use vite_task_graph::{TaskSpecifier, query::TaskQueryKind}; +use vite_task_graph::{ + TaskSpecifier, + query::{PackageQuery, TaskQuery}, +}; use vite_task_plan::plan_request::{PlanOptions, QueryPlanRequest}; +use vite_workspace::package_filter::{ + GraphTraversal, PackageFilter, PackageFilterParseError, PackageNamePattern, PackageSelector, + TraversalDirection, parse_filter, +}; #[derive(Debug, Clone, clap::Subcommand)] pub enum CacheSubcommand { @@ -13,10 +21,7 @@ pub enum CacheSubcommand { } /// Flags that control how a `run` command selects tasks. -/// -/// Extracted as a separate struct so they can be cheaply `Copy`-ed -/// before `RunCommand` is consumed. -#[derive(Debug, Clone, Copy, clap::Args)] +#[derive(Debug, Clone, clap::Args)] #[expect(clippy::struct_excessive_bools, reason = "CLI flags are naturally boolean")] pub struct RunFlags { /// Run tasks found in all packages in the workspace, in topological order based on package dependencies. @@ -34,6 +39,10 @@ pub struct RunFlags { /// Show full detailed summary after execution. #[clap(default_value = "false", short = 'v', long)] pub verbose: bool, + + /// Filter packages (pnpm --filter syntax). Can be specified multiple times. + #[clap(short = 'F', long, num_args = 1)] + pub filter: Vec, } // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -150,6 +159,18 @@ pub enum CLITaskQueryError { #[error("cannot specify package '{package_name}' for task '{task_name}' with --recursive")] PackageNameSpecifiedWithRecursive { package_name: Str, task_name: Str }, + + #[error("--filter and --transitive cannot be used together")] + FilterWithTransitive, + + #[error("--filter and --recursive cannot be used together")] + FilterWithRecursive, + + #[error("cannot specify package '{package_name}' for task '{task_name}' with --filter")] + PackageNameSpecifiedWithFilter { package_name: Str, task_name: Str }, + + #[error("invalid --filter expression")] + InvalidFilter(#[from] PackageFilterParseError), } impl ResolvedRunCommand { @@ -157,15 +178,15 @@ impl ResolvedRunCommand { /// /// # Errors /// - /// Returns an error if `--recursive` and `--transitive` are both set, - /// or if a package name is specified with `--recursive`. + /// Returns an error if conflicting flags are set or if a `--filter` expression + /// cannot be parsed. pub fn into_query_plan_request( self, cwd: &Arc, ) -> Result { let Self { task_specifier, - flags: RunFlags { recursive, transitive, ignore_depends_on, .. }, + flags: RunFlags { recursive, transitive, ignore_depends_on, filter, .. }, additional_args, } = self; @@ -173,28 +194,56 @@ impl ResolvedRunCommand { let include_explicit_deps = !ignore_depends_on; - let query_kind = if recursive { + let package_query = if recursive { if transitive { return Err(CLITaskQueryError::RecursiveTransitiveConflict); } - let task_name = if let Some(package_name) = task_specifier.package_name { + if !filter.is_empty() { + return Err(CLITaskQueryError::FilterWithRecursive); + } + if let Some(package_name) = task_specifier.package_name { return Err(CLITaskQueryError::PackageNameSpecifiedWithRecursive { package_name, task_name: task_specifier.task_name, }); + } + PackageQuery::All + } else if let Ok(raw_filters) = Vec1::try_from_vec(filter) { + // `raw_filters: Vec1` — at least one --filter was specified. + if transitive { + return Err(CLITaskQueryError::FilterWithTransitive); + } + if let Some(package_name) = task_specifier.package_name { + return Err(CLITaskQueryError::PackageNameSpecifiedWithFilter { + package_name, + task_name: task_specifier.task_name, + }); + } + let parsed: Vec1 = raw_filters.try_mapped(|f| parse_filter(&f, cwd))?; + PackageQuery::Filters(parsed) + } else { + // No --filter, no --recursive: implicit cwd or package-name specifier. + let selector = task_specifier.package_name.map_or_else( + || PackageSelector::ContainingPackage(Arc::clone(cwd)), + |name| PackageSelector::Name(PackageNamePattern::Exact(name)), + ); + let traversal = if transitive { + Some(GraphTraversal { + direction: TraversalDirection::Dependencies, + exclude_self: false, + }) } else { - task_specifier.task_name + None }; - TaskQueryKind::Recursive { task_name } - } else { - TaskQueryKind::Normal { - task_specifier, - cwd: Arc::clone(cwd), - include_topological_deps: transitive, - } + PackageQuery::Filters(Vec1::new(PackageFilter { exclude: false, selector, traversal })) }; + Ok(QueryPlanRequest { - query: vite_task_graph::query::TaskQuery { kind: query_kind, include_explicit_deps }, + query: TaskQuery { + package_query, + task_name: task_specifier.task_name, + include_explicit_deps, + }, plan_options: PlanOptions { extra_args: additional_args.into() }, }) } diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index eb0c2b52..7c5a9f55 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -238,7 +238,7 @@ impl<'a> Session<'a> { // Save task name and flags before consuming run_command let task_name = run_command.task_specifier.as_ref().map(|s| s.task_name.clone()); let show_details = run_command.flags.verbose; - let flags = run_command.flags; + let flags = run_command.flags.clone(); let additional_args = run_command.additional_args.clone(); match self.plan_from_cli_run_resolved(cwd, run_command).await { @@ -268,20 +268,7 @@ impl<'a> Session<'a> { Err(err) if err.is_missing_task_specifier() => { self.handle_no_task(is_interactive, None, flags, additional_args).await } - Err(err) => { - if let Some(task_name) = err.task_not_found_name() { - let task_name = task_name.to_owned(); - self.handle_no_task( - is_interactive, - Some(&task_name), - flags, - additional_args, - ) - .await - } else { - Err(err.into()) - } - } + Err(err) => Err(err.into()), } } } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/typo in task script fails without list.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/typo in task script fails without list.snap index a0820dc1..51c5d7aa 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/typo in task script fails without list.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/typo in task script fails without list.snap @@ -6,6 +6,4 @@ expression: e2e_outputs Error: Failed to plan tasks from `vp run nonexistent-xyz` in task task-select-test#run-typo-task Caused by: - 0: Failed to query tasks from task graph - 1: Failed to look up task from specifier: nonexistent-xyz - 2: Task 'nonexistent-xyz' not found in package task-select-test + no tasks matched the query diff --git a/crates/vite_task_graph/src/lib.rs b/crates/vite_task_graph/src/lib.rs index 1c8698fe..7a5bb184 100644 --- a/crates/vite_task_graph/src/lib.rs +++ b/crates/vite_task_graph/src/lib.rs @@ -1,59 +1,45 @@ pub mod config; pub mod display; pub mod loader; -mod package_graph; pub mod query; mod specifier; use std::{convert::Infallible, sync::Arc}; use config::{ResolvedTaskConfig, UserRunConfig}; -use package_graph::IndexedPackageGraph; -use petgraph::{ - graph::{DefaultIx, DiGraph, EdgeIndex, IndexType, NodeIndex}, - visit::{Control, DfsEvent, depth_first_search}, -}; +use petgraph::graph::{DefaultIx, DiGraph, EdgeIndex, IndexType, NodeIndex}; use rustc_hash::{FxBuildHasher, FxHashMap}; use serde::Serialize; pub use specifier::TaskSpecifier; use vite_path::AbsolutePath; use vite_str::Str; -use vite_workspace::{PackageNodeIndex, WorkspaceRoot}; +use vite_workspace::{PackageNodeIndex, WorkspaceRoot, package_graph::IndexedPackageGraph}; use crate::display::TaskDisplay; +/// The type of a task dependency edge in the task graph. +/// +/// Currently only `Explicit` is produced (from `dependsOn` in `vite-task.json`). +/// Topological ordering is handled at query time via the package subgraph rather +/// than by pre-computing edges in the task graph. #[derive(Debug, Clone, Copy, Serialize)] -enum TaskDependencyTypeInner { - /// The dependency is explicitly declared by user in `dependsOn`. - Explicit, - /// The dependency is added due to topological ordering based on package dependencies. - Topological, - /// The dependency is explicitly declared by user in `dependsOn` and also added due to topological ordering. - Both, -} - -/// The type of a task dependency, explaining why it's introduced. -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(transparent)] -pub struct TaskDependencyType(TaskDependencyTypeInner); +pub struct TaskDependencyType; -// It hides `TaskDependencyTypeInner` and only expose `is_explicit`/`is_topological` -// to avoid incorrectly matching only Explicit variant to check if it's explicit. impl TaskDependencyType { + /// Returns `true` — all task graph edges are explicit `dependsOn` dependencies. + /// + /// Kept as an associated function for use as a filter predicate in + /// `add_dependencies`. Always returns `true` since `TaskDependencyType` + /// only represents explicit edges now. #[must_use] - pub const fn is_explicit(self) -> bool { - matches!(self.0, TaskDependencyTypeInner::Explicit | TaskDependencyTypeInner::Both) - } - - #[must_use] - pub const fn is_topological(self) -> bool { - matches!(self.0, TaskDependencyTypeInner::Topological | TaskDependencyTypeInner::Both) + pub const fn is_explicit() -> bool { + true } } /// Uniquely identifies a task, by its name and the package where it's defined. #[derive(Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord)] -struct TaskId { +pub(crate) struct TaskId { /// The index of the package where the task is defined. pub package_index: PackageNodeIndex, @@ -181,7 +167,7 @@ pub struct IndexedTaskGraph { indexed_package_graph: IndexedPackageGraph, /// task indices by task id for quick lookup - node_indices_by_task_id: FxHashMap, + pub(crate) node_indices_by_task_id: FxHashMap, } pub type TaskGraph = DiGraph; @@ -339,103 +325,16 @@ impl IndexedTaskGraph { specifier, task_display: me.display_task(from_node_index), })?; - me.task_graph.update_edge( - from_node_index, - to_node_index, - TaskDependencyType(TaskDependencyTypeInner::Explicit), - ); + me.task_graph.update_edge(from_node_index, to_node_index, TaskDependencyType); } } - // Add topological dependencies based on package dependencies - for (task_id, task_index) in &me.node_indices_by_task_id { - let task_name = task_id.task_name.as_str(); - let package_index = task_id.package_index; - - // For every task, find nearest tasks with the same name. - // If there is a task with the same name in a deeper dependency, it will be connected via that nearer dependency's task. - let mut nearest_topological_tasks = Vec::::new(); - me.find_nearest_topological_tasks( - task_name, - package_index, - &mut nearest_topological_tasks, - ); - for nearest_task_index in nearest_topological_tasks { - if let Some(existing_edge_index) = - me.task_graph.find_edge(*task_index, nearest_task_index) - { - // If an edge already exists, - let existing_edge = &mut me.task_graph[existing_edge_index]; - match existing_edge.0 { - TaskDependencyTypeInner::Explicit => { - // upgrade from Explicit to Both - existing_edge.0 = TaskDependencyTypeInner::Both; - } - TaskDependencyTypeInner::Topological | TaskDependencyTypeInner::Both => { - // already has topological dependency, do nothing - } - } - } else { - // add new topological edge if not exists - me.task_graph.add_edge( - *task_index, - nearest_task_index, - TaskDependencyType(TaskDependencyTypeInner::Topological), - ); - } - } - } + // Topological dependency edges are no longer pre-computed here. + // Ordering is now handled at query time via the package subgraph induced by + // `IndexedPackageGraph::resolve_query` in `query/mod.rs`. Ok(me) } - /// Find the nearest tasks with the given name starting from the given package node index. - /// This method only considers the package graph topology. It doesn't rely on existing topological edges of - /// the task graph, because the starting package itself may not contain a task with the given name - /// - /// The task with the given name in the starting package won't be included in the result even if there's one. - /// - /// This performs a BFS on the package graph starting from `starting_from`, - /// and collects the first found tasks with the given name in each branch. - /// - /// For example, if the package graph is A -> B -> C and A -> D -> C, - /// and we are looking for task "build" starting from A, - /// - /// - No matter A contains "build" or not, it's not included in the result. - /// - If B and D both have a "build" task, both will be returned, but C's "build" task will not be returned - /// because it's not the nearest in either branch. - /// - If B or D doesn't have a "build" task, then C's "build" task will be returned. - fn find_nearest_topological_tasks( - &self, - task_name: &str, - starting_from: PackageNodeIndex, - out: &mut Vec, - ) { - // DFS the package graph starting from `starting_from`, - depth_first_search( - self.indexed_package_graph.package_graph(), - Some(starting_from), - |event| { - let DfsEvent::TreeEdge(_, dependency_package_index) = event else { - return Control::<()>::Continue; - }; - - self.node_indices_by_task_id - .get(&TaskId { - package_index: dependency_package_index, - task_name: task_name.into(), - }) - .map_or(Control::Continue, |dependency_task_index| { - // Encountered a package containing the task with the same name - // collect the task index - out.push(*dependency_task_index); - - // And stop searching further down this branch - Control::Prune - }) - }, - ); - } - /// Lookup the node index of a task by a specifier. /// /// The specifier can be either 'packageName#taskName' or just 'taskName' (in which case the task in the origin package is looked up). diff --git a/crates/vite_task_graph/src/package_graph.rs b/crates/vite_task_graph/src/package_graph.rs deleted file mode 100644 index fd7b6298..00000000 --- a/crates/vite_task_graph/src/package_graph.rs +++ /dev/null @@ -1,70 +0,0 @@ -use std::sync::Arc; - -use petgraph::graph::DiGraph; -use rustc_hash::FxHashMap; -use vec1::smallvec_v1::SmallVec1; -use vite_path::AbsolutePath; -use vite_str::Str; -use vite_workspace::{DependencyType, PackageInfo, PackageIx, PackageNodeIndex}; - -/// Package graph with additional hash maps for quick task lookup -#[derive(Debug)] -pub struct IndexedPackageGraph { - graph: DiGraph, - - /// Grouping package indices by their package names. - /// Due to rare but possible name conflicts in monorepos, we use `SmallVec1` to store multiple dirs for same name. - indices_by_name: FxHashMap>, - - /// package indices by their absolute paths for quick lookup based on cwd - indices_by_path: FxHashMap, PackageNodeIndex>, -} - -impl IndexedPackageGraph { - pub fn index(package_graph: DiGraph) -> Self { - // Index package indices by their absolute paths for quick lookup based on cwd - let indices_by_path: FxHashMap, PackageNodeIndex> = package_graph - .node_indices() - .map(|package_index| { - let absolute_path: Arc = - Arc::clone(&package_graph[package_index].absolute_path); - (absolute_path, package_index) - }) - .collect(); - - // Grouping package indices by their package names. - let mut indices_by_name: FxHashMap> = - FxHashMap::default(); - for package_index in package_graph.node_indices() { - let package = &package_graph[package_index]; - indices_by_name - .entry(package.package_json.name.clone()) - .and_modify(|indices| indices.push(package_index)) - .or_insert_with(|| SmallVec1::new(package_index)); - } - Self { graph: package_graph, indices_by_name, indices_by_path } - } - - /// Get package index from a given current working directory by traversing up the directory tree. - pub fn get_package_index_from_cwd(&self, cwd: &AbsolutePath) -> Option { - let mut cur_path = cwd; - loop { - if let Some(package_index) = self.indices_by_path.get(cur_path) { - return Some(*package_index); - } - cur_path = cur_path.parent()?; - } - } - - /// Get package indices by package name. - pub fn get_package_indices_by_name( - &self, - package_name: &Str, - ) -> Option<&SmallVec1<[PackageNodeIndex; 1]>> { - self.indices_by_name.get(package_name) - } - - pub const fn package_graph(&self) -> &DiGraph { - &self.graph - } -} diff --git a/crates/vite_task_graph/src/query/mod.rs b/crates/vite_task_graph/src/query/mod.rs index 8a183871..ae8d6d69 100644 --- a/crates/vite_task_graph/src/query/mod.rs +++ b/crates/vite_task_graph/src/query/mod.rs @@ -1,188 +1,194 @@ -use std::sync::Arc; - -use petgraph::{prelude::DiGraphMap, visit::EdgeRef}; -use rustc_hash::FxHashSet; -use serde::Serialize; -use vite_path::AbsolutePath; +//! Task query: map a `PackageQuery` to a `TaskExecutionGraph`. +//! +//! # Two-stage model +//! +//! Stage 1 — package selection — is handled by `IndexedPackageGraph::resolve_query` +//! and produces a `DiGraphMap` (the *package subgraph*). +//! +//! Stage 2 — task mapping — is handled by `map_subgraph_to_tasks`: +//! - Packages that **have** the requested task are mapped to their `TaskNodeIndex`. +//! - Packages that **lack** the task are *reconnected*: each predecessor is wired +//! directly to each successor, then the task-lacking node is removed. This preserves +//! transitive ordering even when intermediate packages miss the task. +//! +//! After all task-lacking nodes have been removed, the remaining package subgraph +//! contains only task-having packages; edges map directly to task dependency edges. +//! +//! Explicit `dependsOn` dependencies are then added on top by `add_dependencies`. + +use petgraph::{Direction, prelude::DiGraphMap, visit::EdgeRef}; +use rustc_hash::{FxHashMap, FxHashSet}; use vite_str::Str; +use vite_workspace::PackageNodeIndex; +pub use vite_workspace::package_graph::PackageQuery; -use crate::{ - IndexedTaskGraph, SpecifierLookupError, TaskDependencyType, TaskNodeIndex, - specifier::TaskSpecifier, -}; +use crate::{IndexedTaskGraph, TaskDependencyType, TaskId, TaskNodeIndex}; -/// Different kinds of task queries. -#[derive(Debug)] -pub enum TaskQueryKind { - /// A normal task query specifying a task specifier and options. - /// The task will be searched in the package in the task specifier, or located from cwd. - Normal { - task_specifier: TaskSpecifier, - /// Where the query is being run from. - cwd: Arc, - /// Whether to include topological dependencies - include_topological_deps: bool, - }, - /// A recursive task query specifying a task name. - /// It will match all tasks with the given name across all packages with topological ordering. - /// The whole workspace will be searched, so cwd is not relevant. - Recursive { task_name: Str }, -} +/// A task execution graph queried from a `TaskQuery`. +/// +/// Nodes are `TaskNodeIndex` values into the full `TaskGraph`. +/// Edges represent the final dependency relationships between tasks (no weights). +pub type TaskExecutionGraph = DiGraphMap; -/// Represents a valid query for a task and its dependencies, usually issued from a CLI command `vp run ...`. -/// A query represented by this struct is always valid, but still may result in no tasks found. +/// A query for which tasks to run. #[derive(Debug)] pub struct TaskQuery { - /// The kind of task query - pub kind: TaskQueryKind, - /// Whether to include explicit dependencies - pub include_explicit_deps: bool, -} + /// Which packages to select. + pub package_query: PackageQuery, -/// A task execution graph queried from a `TaskQuery`. -/// -/// The nodes are task indices for `TaskGraph`. -/// The edges represent the final dependency relationships between tasks. No edge weights. -pub type TaskExecutionGraph = DiGraphMap; + /// The task name to run within each selected package. + pub task_name: Str, -#[derive(Debug, thiserror::Error, Serialize)] -#[error("The current working directory {cwd:?} is in not any package")] -pub struct PackageUnknownError { - pub cwd: Arc, + /// Whether to include explicit `dependsOn` dependencies from `vite-task.json`. + pub include_explicit_deps: bool, } -#[derive(Debug, thiserror::Error, Serialize)] -pub enum TaskQueryError { - #[error("Failed to look up task from specifier: {specifier}")] - SpecifierLookupError { - specifier: TaskSpecifier, - #[source] - lookup_error: SpecifierLookupError, - }, +/// The result of [`IndexedTaskGraph::query_tasks`]. +#[derive(Debug)] +pub struct TaskQueryResult { + /// The final execution graph for the selected tasks. + /// + /// May be empty if no selected packages have the requested task, or if no + /// packages matched the filters. The caller uses `node_count() == 0` to + /// decide whether to show task-not-found UI. + pub execution_graph: TaskExecutionGraph, + + /// Indices into the original `PackageQuery::Filters` slice for selectors that + /// matched no packages. The caller maps each index back to the original + /// `--filter` string for typo warnings. + /// + /// Always empty when `PackageQuery::All` was used. + pub unmatched_selectors: Vec, } impl IndexedTaskGraph { - /// Queries the task graph based on the given [`TaskQuery`] and returns the execution graph. + /// Query the task graph based on the given [`TaskQuery`]. + /// + /// Returns a [`TaskQueryResult`] containing the execution graph and any + /// unmatched selectors. The execution graph may be empty — the caller decides + /// what to do in that case (show task selector, emit warnings, etc.). /// - /// # Errors + /// # Order of operations /// - /// Returns [`TaskQueryError::SpecifierLookupError`] if a task specifier cannot be resolved - /// to a task in the graph. - pub fn query_tasks(&self, query: TaskQuery) -> Result { + /// 1. Resolve `PackageQuery` → package subgraph (Stage 1). + /// 2. Map package subgraph → task execution graph, reconnecting task-lacking + /// packages (Stage 2). + /// 3. Expand explicit `dependsOn` edges (if `include_explicit_deps`). + #[must_use] + pub fn query_tasks(&self, query: &TaskQuery) -> TaskQueryResult { let mut execution_graph = TaskExecutionGraph::default(); - let include_topologicial_deps = match &query.kind { - TaskQueryKind::Normal { include_topological_deps, .. } => *include_topological_deps, - TaskQueryKind::Recursive { .. } => true, // recursive means topological across all packages - }; - - // Add starting tasks without dependencies - match query.kind { - TaskQueryKind::Normal { task_specifier, cwd, include_topological_deps } => { - let package_index_from_cwd = - self.indexed_package_graph.get_package_index_from_cwd(&cwd); - - // Find the starting task - let starting_task_result = - self.get_task_index_by_specifier(task_specifier.clone(), || { - package_index_from_cwd - .ok_or_else(|| PackageUnknownError { cwd: Arc::clone(&cwd) }) - }); - - match starting_task_result { - Ok(starting_task) => { - // Found it, add to execution graph - execution_graph.add_node(starting_task); - } - // Task not found, but package located, and the query requests topological deps - // This happens when running `vp run --transitive taskName` in a package without `taskName`, but its dependencies have it. - Err(err @ SpecifierLookupError::TaskNameNotFound { package_index, .. }) - if include_topological_deps => - { - // try to find nearest task - let mut nearest_topological_tasks = Vec::::new(); - self.find_nearest_topological_tasks( - &task_specifier.task_name, - package_index, - &mut nearest_topological_tasks, - ); - if nearest_topological_tasks.is_empty() { - // No nearest task found, return original error - return Err(TaskQueryError::SpecifierLookupError { - specifier: task_specifier, - lookup_error: err, - }); - } - // Add nearest tasks to execution graph - // Topological dependencies of nearest tasks will be added later - for nearest_task in nearest_topological_tasks { - execution_graph.add_node(nearest_task); - } - } - Err(err) => { - // Not recoverable by finding nearest package, return error - return Err(TaskQueryError::SpecifierLookupError { - specifier: task_specifier, - lookup_error: err, - }); - } - } + // Stage 1: resolve package selection. + let resolution = self.indexed_package_graph.resolve_query(&query.package_query); + + // Stage 2: map each selected package to its task node (with reconnection). + self.map_subgraph_to_tasks( + &resolution.package_subgraph, + &query.task_name, + &mut execution_graph, + ); + + // Expand explicit dependsOn edges (may add new task nodes from outside the subgraph). + if query.include_explicit_deps { + self.add_dependencies(&mut execution_graph, |_| TaskDependencyType::is_explicit()); + } + + TaskQueryResult { execution_graph, unmatched_selectors: resolution.unmatched_selectors } + } + + /// Map a package subgraph to a task execution graph. + /// + /// For packages **with** the task: add the corresponding `TaskNodeIndex`. + /// + /// For packages **without** the task: wire each predecessor directly to each + /// successor (skip-intermediate reconnection), then remove the node. Working on + /// a *mutable clone* of the subgraph ensures that reconnected edges are visible + /// when processing subsequent task-lacking nodes in the same pass — transitive + /// task-lacking chains are resolved correctly regardless of iteration order. + /// + /// After all task-lacking nodes are removed, every remaining node in `subgraph` + /// is guaranteed to be in `pkg_to_task`. The index operator panics on a missing + /// key — a panic here indicates a bug in the reconnection loop above. + fn map_subgraph_to_tasks( + &self, + package_subgraph: &DiGraphMap, + task_name: &Str, + execution_graph: &mut TaskExecutionGraph, + ) { + // Build the task-lookup map for all packages that have the requested task. + let pkg_to_task: FxHashMap = package_subgraph + .nodes() + .filter_map(|pkg| { + self.node_indices_by_task_id + .get(&TaskId { package_index: pkg, task_name: task_name.clone() }) + .map(|&task_idx| (pkg, task_idx)) + }) + .collect(); + + // Clone the subgraph so that reconnection edits are visible in subsequent iterations. + let mut subgraph = package_subgraph.clone(); + + // Reconnect and remove each task-lacking node. + for pkg in package_subgraph.nodes() { + if pkg_to_task.contains_key(&pkg) { + continue; // this package has the task — leave it in } - TaskQueryKind::Recursive { task_name } => { - // Add all tasks matching the name across all packages - for task_index in self.task_graph.node_indices() { - if self.task_graph[task_index].task_display.task_name == task_name { - execution_graph.add_node(task_index); - } + // Read pred/succ from the live (possibly already-modified) subgraph. + let preds: Vec<_> = subgraph.neighbors_directed(pkg, Direction::Incoming).collect(); + let succs: Vec<_> = subgraph.neighbors_directed(pkg, Direction::Outgoing).collect(); + // Bridge: every predecessor connects directly to every successor. + for &pred in &preds { + for &succ in &succs { + subgraph.add_edge(pred, succ, ()); } } + subgraph.remove_node(pkg); } - // Add dependencies as requested - // The order matters: add topological dependencies first, then explicit dependencies. - // We don't want to include topological dependencies of explicit dependencies even both types are requested. - if include_topologicial_deps { - self.add_dependencies(&mut execution_graph, TaskDependencyType::is_topological); + // Map remaining nodes and their edges to task nodes. + // Every node still in `subgraph` is in `pkg_to_task`; the index operator + // panics on a missing key — that would be a bug in the loop above. + for &task_idx in pkg_to_task.values() { + execution_graph.add_node(task_idx); } - if query.include_explicit_deps { - self.add_dependencies(&mut execution_graph, TaskDependencyType::is_explicit); + for (src, dst, ()) in subgraph.all_edges() { + let st = pkg_to_task[&src]; + let dt = pkg_to_task[&dst]; + execution_graph.add_edge(st, dt, ()); } - - Ok(execution_graph) } - /// Recursively add dependencies to the execution graph based on filtered edges in the task graph + /// Recursively add dependencies to the execution graph based on filtered edges. + /// + /// Starts from the current nodes in `execution_graph` and follows outgoing edges + /// that match `filter_edge`, adding new nodes to the frontier until no new nodes + /// are discovered. fn add_dependencies( &self, execution_graph: &mut TaskExecutionGraph, mut filter_edge: impl FnMut(TaskDependencyType) -> bool, ) { - let mut current_starting_node_indices: FxHashSet = - execution_graph.nodes().collect(); + let mut frontier: FxHashSet = execution_graph.nodes().collect(); - // Continue until no new nodes are added - while !current_starting_node_indices.is_empty() { - // Record newly added nodes in this iteration as starting nodes for next iteration - let mut next_starting_node_indices = FxHashSet::::default(); + // Continue until no new nodes are added to the frontier. + while !frontier.is_empty() { + let mut next_frontier = FxHashSet::::default(); - for from_node in current_starting_node_indices { - // For each starting node, traverse its outgoing edges + for from_node in frontier { for edge_ref in self.task_graph.edges(from_node) { let to_node = edge_ref.target(); - let dependency_type = edge_ref.weight(); - if filter_edge(*dependency_type) { - let is_to_node_new = !execution_graph.contains_node(to_node); - // Add the dependency edge + let dep_type = *edge_ref.weight(); + if filter_edge(dep_type) { + let is_new = !execution_graph.contains_node(to_node); execution_graph.add_edge(from_node, to_node, ()); - - // Add to_node for next iteration if it's newly added. - if is_to_node_new { - next_starting_node_indices.insert(to_node); + if is_new { + next_frontier.insert(to_node); } } } } - current_starting_node_indices = next_starting_node_indices; + + frontier = next_frontier; } } } diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs index 8bba1aac..d118cd00 100644 --- a/crates/vite_task_plan/src/error.rs +++ b/crates/vite_task_plan/src/error.rs @@ -113,13 +113,6 @@ pub enum Error { #[error(transparent)] PathFingerprint(#[from] PathFingerprintError), - #[error("Failed to query tasks from task graph")] - TaskQuery( - #[source] - #[from] - vite_task_graph::query::TaskQueryError, - ), - #[error(transparent)] TaskRecursionDetected(#[from] TaskRecursionError), @@ -144,6 +137,13 @@ pub enum Error { #[error("No task specifier provided for 'run' command")] MissingTaskSpecifier, + /// No tasks matched the query in a nested `vp run` call inside a task script. + /// + /// At the top level, an empty execution graph triggers the task selector UI. + /// In a nested context there is no UI, so this is returned as an error instead. + #[error("no tasks matched the query")] + NoTasksMatched, + /// A cycle was detected in the task dependency graph during planning. /// /// This is caught by `AcyclicGraph::try_from_graph`, which validates that the @@ -158,21 +158,4 @@ impl Error { pub const fn is_missing_task_specifier(&self) -> bool { matches!(self, Self::MissingTaskSpecifier) } - - /// If this error represents a top-level task-not-found lookup failure, - /// returns the task name that the user typed. - /// - /// Returns `None` if the error occurred in a nested task (wrapped in `NestPlan`), - /// since nested task errors should propagate as-is rather than triggering - /// interactive task selection. - #[must_use] - pub fn task_not_found_name(&self) -> Option<&str> { - match self { - Self::TaskQuery(vite_task_graph::query::TaskQueryError::SpecifierLookupError { - specifier, - .. - }) => Some(specifier.task_name.as_str()), - _ => None, - } - } } diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index 615a89d4..a218815e 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -186,6 +186,16 @@ async fn plan_task_as_execution_node( 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. + if execution_graph.node_count() == 0 { + return Err(Error::NestPlan { + task_display: task_node.task_display.clone(), + command: Str::from(&command_str[add_item_span]), + error: Box::new(Error::NoTasksMatched), + }); + } ExecutionItemKind::Expanded(execution_graph) } // Synthetic task (from CommandHandler) @@ -508,11 +518,11 @@ pub async fn plan_query_request( mut context: PlanContext<'_>, ) -> Result { context.set_extra_args(Arc::clone(&query_plan_request.plan_options.extra_args)); - // Query matching tasks from the task graph - let task_node_index_graph = context - .indexed_task_graph() - .query_tasks(query_plan_request.query) - .map_err(Error::TaskQuery)?; + // Query matching tasks from the task graph. + // `query_tasks` is infallible — 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_node_index_graph = task_query_result.execution_graph; let mut execution_node_indices_by_task_index = FxHashMap::::with_capacity_and_hasher( diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-task-graph/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-task-graph/snapshots/task graph.snap index 3f4d4f73..47530078 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-task-graph/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-task-graph/snapshots/task graph.snap @@ -30,22 +30,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta } } }, - "neighbors": [ - [ - [ - "/packages/config", - "build" - ], - "Topological" - ], - [ - [ - "/packages/shared", - "build" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -129,15 +114,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta } } }, - "neighbors": [ - [ - [ - "/packages/shared", - "test" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -165,36 +142,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta } } }, - "neighbors": [ - [ - [ - "/packages/api", - "build" - ], - "Topological" - ], - [ - [ - "/packages/pkg#special", - "build" - ], - "Topological" - ], - [ - [ - "/packages/shared", - "build" - ], - "Topological" - ], - [ - [ - "/packages/ui", - "build" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -250,15 +198,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta } } }, - "neighbors": [ - [ - [ - "/packages/api", - "dev" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -314,36 +254,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta } } }, - "neighbors": [ - [ - [ - "/packages/api", - "test" - ], - "Topological" - ], - [ - [ - "/packages/pkg#special", - "test" - ], - "Topological" - ], - [ - [ - "/packages/shared", - "test" - ], - "Topological" - ], - [ - [ - "/packages/ui", - "test" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -427,15 +338,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta } } }, - "neighbors": [ - [ - [ - "/packages/shared", - "build" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -463,15 +366,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta } } }, - "neighbors": [ - [ - [ - "/packages/shared", - "test" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -639,15 +534,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta } } }, - "neighbors": [ - [ - [ - "/packages/config", - "validate" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -675,15 +562,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta } } }, - "neighbors": [ - [ - [ - "/packages/shared", - "build" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -711,15 +590,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta } } }, - "neighbors": [ - [ - [ - "/packages/shared", - "lint" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -775,14 +646,6 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta } } }, - "neighbors": [ - [ - [ - "/packages/shared", - "test" - ], - "Topological" - ] - ] + "neighbors": [] } ] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/conflict-test/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/conflict-test/snapshots/task graph.snap index 479c328a..d29aad6f 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/conflict-test/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/conflict-test/snapshots/task graph.snap @@ -92,7 +92,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/conflict-test "/packages/scope-a-b", "c" ], - "Explicit" + null ] ] } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cycle-dependency/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cycle-dependency/snapshots/task graph.snap index 13301c92..6f10cfd8 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cycle-dependency/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cycle-dependency/snapshots/task graph.snap @@ -36,7 +36,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cycle-dependency "/", "task-b" ], - "Explicit" + null ] ] }, @@ -72,7 +72,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cycle-dependency "/", "task-a" ], - "Explicit" + null ] ] } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/dependency-both-topo-and-explicit/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/dependency-both-topo-and-explicit/snapshots/task graph.snap index 3e12ca09..7bec33e1 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/dependency-both-topo-and-explicit/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/dependency-both-topo-and-explicit/snapshots/task graph.snap @@ -36,7 +36,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/dependency-both- "/packages/b", "build" ], - "Both" + null ] ] }, diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-test/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-test/snapshots/task graph.snap index a7b5bb04..7eedbaeb 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-test/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-test/snapshots/task graph.snap @@ -36,21 +36,14 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-te "/packages/another-empty", "lint" ], - "Explicit" - ], - [ - [ - "/packages/normal-package", - "build" - ], - "Topological" + null ], [ [ "/packages/normal-package", "test" ], - "Explicit" + null ] ] }, @@ -79,14 +72,14 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-te "/packages/another-empty", "build" ], - "Explicit" + null ], [ [ "/packages/another-empty", "test" ], - "Explicit" + null ] ] }, @@ -144,15 +137,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-te } } }, - "neighbors": [ - [ - [ - "/packages/normal-package", - "test" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -186,7 +171,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-te "/packages/empty-name", "test" ], - "Explicit" + null ] ] }, diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-workspace/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-workspace/snapshots/task graph.snap index d74402a6..b097b1ba 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-workspace/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-workspace/snapshots/task graph.snap @@ -30,15 +30,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo } } }, - "neighbors": [ - [ - [ - "/packages/utils", - "build" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -65,21 +57,21 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo "/packages/app", "build" ], - "Explicit" + null ], [ [ "/packages/app", "test" ], - "Explicit" + null ], [ [ "/packages/utils", "lint" ], - "Explicit" + null ] ] }, @@ -137,15 +129,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo } } }, - "neighbors": [ - [ - [ - "/packages/utils", - "test" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -235,7 +219,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo "/packages/core", "clean" ], - "Explicit" + null ] ] }, @@ -293,15 +277,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo } } }, - "neighbors": [ - [ - [ - "/packages/core", - "build" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -335,21 +311,14 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo "/packages/core", "build" ], - "Explicit" - ], - [ - [ - "/packages/core", - "lint" - ], - "Topological" + null ], [ [ "/packages/utils", "build" ], - "Explicit" + null ] ] }, @@ -379,14 +348,6 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo } } }, - "neighbors": [ - [ - [ - "/packages/core", - "test" - ], - "Topological" - ] - ] + "neighbors": [] } ] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/package.json new file mode 100644 index 00000000..b65cafad --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/package.json @@ -0,0 +1,4 @@ +{ + "name": "test-workspace", + "private": true +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/app/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/app/package.json new file mode 100644 index 00000000..a9800c99 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/app/package.json @@ -0,0 +1,14 @@ +{ + "name": "@test/app", + "version": "1.0.0", + "scripts": { + "build": "echo 'Building @test/app'", + "test": "echo 'Testing @test/app'" + }, + "dependencies": { + "@test/lib": "workspace:*" + }, + "devDependencies": { + "@test/utils": "workspace:*" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/cli/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/cli/package.json new file mode 100644 index 00000000..7256d667 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/cli/package.json @@ -0,0 +1,11 @@ +{ + "name": "@test/cli", + "version": "1.0.0", + "scripts": { + "build": "echo 'Building @test/cli'", + "test": "echo 'Testing @test/cli'" + }, + "dependencies": { + "@test/core": "workspace:*" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/core/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/core/package.json new file mode 100644 index 00000000..c2864056 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/core/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/core", + "version": "1.0.0", + "scripts": { + "build": "echo 'Building @test/core'", + "test": "echo 'Testing @test/core'" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/lib/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/lib/package.json new file mode 100644 index 00000000..91f1ff79 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/lib/package.json @@ -0,0 +1,11 @@ +{ + "name": "@test/lib", + "version": "1.0.0", + "scripts": { + "build": "echo 'Building @test/lib'", + "test": "echo 'Testing @test/lib'" + }, + "dependencies": { + "@test/core": "workspace:*" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/utils/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/utils/package.json new file mode 100644 index 00000000..a544e0c8 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/utils/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/utils", + "version": "1.0.0", + "scripts": { + "build": "echo 'Building @test/utils'" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/pnpm-workspace.yaml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml new file mode 100644 index 00000000..8dc0b1f8 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml @@ -0,0 +1,110 @@ +# Tests pnpm-style --filter package selection + +# pnpm: "select just a package by name" +[[plan]] +compact = true +name = "filter by exact name" +args = ["run", "--filter", "@test/app", "build"] + +# pnpm: name pattern matching with * +[[plan]] +compact = true +name = "filter by glob" +args = ["run", "--filter", "@test/*", "build"] + +# pnpm: "select package with dependencies" +[[plan]] +compact = true +name = "filter with dependencies" +args = ["run", "--filter", "@test/app...", "build"] + +# pnpm: "select only package dependencies (excluding the package itself)" +[[plan]] +compact = true +name = "filter dependencies only exclude self" +args = ["run", "--filter", "@test/app^...", "build"] + +# pnpm: "select package with dependents" +[[plan]] +compact = true +name = "filter with dependents" +args = ["run", "--filter", "...@test/core", "build"] + +# pnpm: "select dependents excluding package itself" +[[plan]] +compact = true +name = "filter dependents only exclude self" +args = ["run", "--filter", "...^@test/core", "build"] + +# pnpm: "select package with dependencies and dependents, including dependent dependencies" +[[plan]] +compact = true +name = "filter both deps and dependents" +args = ["run", "--filter", "...@test/lib...", "build"] + +# pnpm Example 4: two cherry-picked packages with a dependency between them. +# app depends on lib. Both cherry-picked (no "..."). The induced subgraph +# still preserves the app→lib edge — lib#build runs before app#build. +[[plan]] +compact = true +name = "cherry picked filters respect dependency order" +args = ["run", "--filter", "@test/app", "--filter", "@test/lib", "build"] + +# pnpm: "filter using two selectors" (independent packages) +[[plan]] +compact = true +name = "multiple filters union" +args = ["run", "--filter", "@test/app", "--filter", "@test/cli", "build"] + +# pnpm: "select by parentDir and exclude one package by pattern" +[[plan]] +compact = true +name = "filter include and exclude" +args = ["run", "--filter", "@test/app...", "--filter", "!@test/utils", "build"] + +# pnpm: "select all packages except one" +[[plan]] +compact = true +name = "exclude only filter" +args = ["run", "--filter", "!@test/core", "build"] + +# pnpm: "select by parentDir" +[[plan]] +compact = true +name = "filter by directory" +args = ["run", "--filter", "./packages/app", "build"] + +# transitive flag = --filter .… +[[plan]] +compact = true +name = "transitive flag" +args = ["run", "-t", "build"] +cwd = "packages/app" + +# -t from a subfolder inside a package — ContainingPackage walks up to find app. +[[plan]] +compact = true +name = "transitive flag from package subfolder" +args = ["run", "-t", "build"] +cwd = "packages/app/src/components" + +# recursive flag = All +[[plan]] +compact = true +name = "recursive flag" +args = ["run", "-r", "build"] + +# pnpm Example 5: app... selects {app,lib,core,utils}. cli is cherry-picked. +# ALL original edges between selected packages are preserved (induced subgraph). +# cli→core edge IS present — cli#build waits for core#build. +[[plan]] +compact = true +name = "mixed traversal filters" +args = ["run", "--filter", "@test/app...", "--filter", "@test/cli", "build"] + +# pnpm Example 1 (partial): packages without the task are silently skipped. +# @test/utils has no "test" — silently skipped; app, lib, core run in order. +[[plan]] +compact = true +name = "filter deps skips packages without task" +args = ["run", "--filter", "@test/app...", "test"] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - cherry picked filters respect dependency order.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - cherry picked filters respect dependency order.snap new file mode 100644 index 00000000..75f88e7d --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - cherry picked filters respect dependency order.snap @@ -0,0 +1,11 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build" + ], + "packages/lib#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - exclude only filter.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - exclude only filter.snap new file mode 100644 index 00000000..72eb3601 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - exclude only filter.snap @@ -0,0 +1,14 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build", + "packages/utils#build" + ], + "packages/cli#build": [], + "packages/lib#build": [], + "packages/utils#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter both deps and dependents.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter both deps and dependents.snap new file mode 100644 index 00000000..4806b35d --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter both deps and dependents.snap @@ -0,0 +1,16 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build", + "packages/utils#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ], + "packages/utils#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory.snap new file mode 100644 index 00000000..62c7a613 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory.snap @@ -0,0 +1,8 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by exact name.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by exact name.snap new file mode 100644 index 00000000..62c7a613 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by exact name.snap @@ -0,0 +1,8 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by glob.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by glob.snap new file mode 100644 index 00000000..5aa8fad6 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by glob.snap @@ -0,0 +1,19 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build", + "packages/utils#build" + ], + "packages/cli#build": [ + "packages/core#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ], + "packages/utils#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dependencies only exclude self.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dependencies only exclude self.snap new file mode 100644 index 00000000..3493f491 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dependencies only exclude self.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ], + "packages/utils#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dependents only exclude self.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dependents only exclude self.snap new file mode 100644 index 00000000..9afa8f01 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dependents only exclude self.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build" + ], + "packages/cli#build": [], + "packages/lib#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter deps skips packages without task.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter deps skips packages without task.snap new file mode 100644 index 00000000..e5a29035 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter deps skips packages without task.snap @@ -0,0 +1,14 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#test": [ + "packages/lib#test" + ], + "packages/core#test": [], + "packages/lib#test": [ + "packages/core#test" + ] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter include and exclude.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter include and exclude.snap new file mode 100644 index 00000000..0ac97023 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter include and exclude.snap @@ -0,0 +1,14 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter with dependencies.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter with dependencies.snap new file mode 100644 index 00000000..4806b35d --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter with dependencies.snap @@ -0,0 +1,16 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build", + "packages/utils#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ], + "packages/utils#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter with dependents.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter with dependents.snap new file mode 100644 index 00000000..fe5e425a --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter with dependents.snap @@ -0,0 +1,17 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build" + ], + "packages/cli#build": [ + "packages/core#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - mixed traversal filters.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - mixed traversal filters.snap new file mode 100644 index 00000000..5aa8fad6 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - mixed traversal filters.snap @@ -0,0 +1,19 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build", + "packages/utils#build" + ], + "packages/cli#build": [ + "packages/core#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ], + "packages/utils#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - multiple filters union.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - multiple filters union.snap new file mode 100644 index 00000000..03d0b0b7 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - multiple filters union.snap @@ -0,0 +1,9 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [], + "packages/cli#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - recursive flag.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - recursive flag.snap new file mode 100644 index 00000000..5aa8fad6 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - recursive flag.snap @@ -0,0 +1,19 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build", + "packages/utils#build" + ], + "packages/cli#build": [ + "packages/core#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ], + "packages/utils#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive flag from package subfolder.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive flag from package subfolder.snap new file mode 100644 index 00000000..4806b35d --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive flag from package subfolder.snap @@ -0,0 +1,16 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build", + "packages/utils#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ], + "packages/utils#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive flag.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive flag.snap new file mode 100644 index 00000000..4806b35d --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive flag.snap @@ -0,0 +1,16 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build", + "packages/utils#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ], + "packages/utils#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/task graph.snap new file mode 100644 index 00000000..53eddd64 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/task graph.snap @@ -0,0 +1,259 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: task_graph_json +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +[ + { + "key": [ + "/packages/app", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/app", + "task_name": "build", + "package_path": "/packages/app" + }, + "resolved_config": { + "command": "echo 'Building @test/app'", + "resolved_options": { + "cwd": "/packages/app", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + } + }, + "neighbors": [] + }, + { + "key": [ + "/packages/app", + "test" + ], + "node": { + "task_display": { + "package_name": "@test/app", + "task_name": "test", + "package_path": "/packages/app" + }, + "resolved_config": { + "command": "echo 'Testing @test/app'", + "resolved_options": { + "cwd": "/packages/app", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + } + }, + "neighbors": [] + }, + { + "key": [ + "/packages/cli", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/cli", + "task_name": "build", + "package_path": "/packages/cli" + }, + "resolved_config": { + "command": "echo 'Building @test/cli'", + "resolved_options": { + "cwd": "/packages/cli", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + } + }, + "neighbors": [] + }, + { + "key": [ + "/packages/cli", + "test" + ], + "node": { + "task_display": { + "package_name": "@test/cli", + "task_name": "test", + "package_path": "/packages/cli" + }, + "resolved_config": { + "command": "echo 'Testing @test/cli'", + "resolved_options": { + "cwd": "/packages/cli", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + } + }, + "neighbors": [] + }, + { + "key": [ + "/packages/core", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/core", + "task_name": "build", + "package_path": "/packages/core" + }, + "resolved_config": { + "command": "echo 'Building @test/core'", + "resolved_options": { + "cwd": "/packages/core", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + } + }, + "neighbors": [] + }, + { + "key": [ + "/packages/core", + "test" + ], + "node": { + "task_display": { + "package_name": "@test/core", + "task_name": "test", + "package_path": "/packages/core" + }, + "resolved_config": { + "command": "echo 'Testing @test/core'", + "resolved_options": { + "cwd": "/packages/core", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + } + }, + "neighbors": [] + }, + { + "key": [ + "/packages/lib", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/lib", + "task_name": "build", + "package_path": "/packages/lib" + }, + "resolved_config": { + "command": "echo 'Building @test/lib'", + "resolved_options": { + "cwd": "/packages/lib", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + } + }, + "neighbors": [] + }, + { + "key": [ + "/packages/lib", + "test" + ], + "node": { + "task_display": { + "package_name": "@test/lib", + "task_name": "test", + "package_path": "/packages/lib" + }, + "resolved_config": { + "command": "echo 'Testing @test/lib'", + "resolved_options": { + "cwd": "/packages/lib", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + } + }, + "neighbors": [] + }, + { + "key": [ + "/packages/utils", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/utils", + "task_name": "build", + "package_path": "/packages/utils" + }, + "resolved_config": { + "command": "echo 'Building @test/utils'", + "resolved_options": { + "cwd": "/packages/utils", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + } + }, + "neighbors": [] + } +] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/vite-task.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/vite-task.json new file mode 100644 index 00000000..1d0fe9f2 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/vite-task.json @@ -0,0 +1,3 @@ +{ + "cacheScripts": true +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topological-workspace/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topological-workspace/snapshots/task graph.snap index 4baeee4b..078a225b 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topological-workspace/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topological-workspace/snapshots/task graph.snap @@ -30,22 +30,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topolo } } }, - "neighbors": [ - [ - [ - "/packages/app", - "build" - ], - "Topological" - ], - [ - [ - "/packages/core", - "build" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -101,15 +86,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topolo } } }, - "neighbors": [ - [ - [ - "/packages/utils", - "build" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -137,15 +114,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topolo } } }, - "neighbors": [ - [ - [ - "/packages/utils", - "test" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -229,15 +198,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topolo } } }, - "neighbors": [ - [ - [ - "/packages/core", - "build" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -265,14 +226,6 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topolo } } }, - "neighbors": [ - [ - [ - "/packages/core", - "test" - ], - "Topological" - ] - ] + "neighbors": [] } ] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/package.json new file mode 100644 index 00000000..b65cafad --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/package.json @@ -0,0 +1,4 @@ +{ + "name": "test-workspace", + "private": true +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/packages/bottom/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/packages/bottom/package.json new file mode 100644 index 00000000..de5f557e --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/packages/bottom/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/bottom", + "version": "1.0.0", + "scripts": { + "build": "echo 'Building @test/bottom'" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/packages/middle/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/packages/middle/package.json new file mode 100644 index 00000000..5a3584d1 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/packages/middle/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/middle", + "version": "1.0.0", + "scripts": { + "lint": "echo 'Linting @test/middle'" + }, + "dependencies": { + "@test/bottom": "workspace:*" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/packages/top/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/packages/top/package.json new file mode 100644 index 00000000..c41f45ab --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/packages/top/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/top", + "version": "1.0.0", + "scripts": { + "build": "echo 'Building @test/top'" + }, + "dependencies": { + "@test/middle": "workspace:*" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/pnpm-workspace.yaml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots.toml new file mode 100644 index 00000000..fdb6e03a --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots.toml @@ -0,0 +1,34 @@ +# Tests skip-intermediate reconnection when a package lacks the requested task. +# Topology: top → middle → bottom +# top and bottom have `build`; middle does NOT. + +# pnpm Example 1: top... selects {top, middle, bottom}. +# middle lacks build — reconnected (top→bottom). Result: bottom#build → top#build. +[[plan]] +compact = true +name = "filter intermediate lacks task is skipped" +args = ["run", "--filter", "@test/top...", "build"] + +# Same scenario via -t flag (ContainingPackage traversal). +[[plan]] +compact = true +name = "transitive build skips intermediate without task" +args = ["run", "-t", "build"] +cwd = "packages/top" + +# pnpm Example 2: middle... selects {middle, bottom}. middle lacks build. +# middle is reconnected (no preds → bottom runs independently). +# Result: bottom#build only. +[[plan]] +compact = true +name = "filter entry lacks task dependency has it" +args = ["run", "--filter", "@test/middle...", "build"] + +# Same as above but via -t flag from the package that lacks the task. +# middle has no build, but its dependency bottom does. +# Result: bottom#build only. +[[plan]] +compact = true +name = "transitive from package without task" +args = ["run", "-t", "build"] +cwd = "packages/middle" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - filter entry lacks task dependency has it.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - filter entry lacks task dependency has it.snap new file mode 100644 index 00000000..82ec7abf --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - filter entry lacks task dependency has it.snap @@ -0,0 +1,8 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate +--- +{ + "packages/bottom#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - filter intermediate lacks task is skipped.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - filter intermediate lacks task is skipped.snap new file mode 100644 index 00000000..b364d5bd --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - filter intermediate lacks task is skipped.snap @@ -0,0 +1,11 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate +--- +{ + "packages/bottom#build": [], + "packages/top#build": [ + "packages/bottom#build" + ] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive build skips intermediate without task.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive build skips intermediate without task.snap new file mode 100644 index 00000000..b364d5bd --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive build skips intermediate without task.snap @@ -0,0 +1,11 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate +--- +{ + "packages/bottom#build": [], + "packages/top#build": [ + "packages/bottom#build" + ] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive from package without task.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive from package without task.snap new file mode 100644 index 00000000..82ec7abf --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive from package without task.snap @@ -0,0 +1,8 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate +--- +{ + "packages/bottom#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/task graph.snap new file mode 100644 index 00000000..0bf76c85 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/task graph.snap @@ -0,0 +1,70 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: task_graph_json +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate +--- +[ + { + "key": [ + "/packages/bottom", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/bottom", + "task_name": "build", + "package_path": "/packages/bottom" + }, + "resolved_config": { + "command": "echo 'Building @test/bottom'", + "resolved_options": { + "cwd": "/packages/bottom", + "cache_config": null + } + } + }, + "neighbors": [] + }, + { + "key": [ + "/packages/middle", + "lint" + ], + "node": { + "task_display": { + "package_name": "@test/middle", + "task_name": "lint", + "package_path": "/packages/middle" + }, + "resolved_config": { + "command": "echo 'Linting @test/middle'", + "resolved_options": { + "cwd": "/packages/middle", + "cache_config": null + } + } + }, + "neighbors": [] + }, + { + "key": [ + "/packages/top", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/top", + "task_name": "build", + "package_path": "/packages/top" + }, + "resolved_config": { + "command": "echo 'Building @test/top'", + "resolved_options": { + "cwd": "/packages/top", + "cache_config": null + } + } + }, + "neighbors": [] + } +] diff --git a/crates/vite_workspace/src/lib.rs b/crates/vite_workspace/src/lib.rs index 31da612e..74caab58 100644 --- a/crates/vite_workspace/src/lib.rs +++ b/crates/vite_workspace/src/lib.rs @@ -1,5 +1,7 @@ mod error; pub mod package; +pub mod package_filter; +pub mod package_graph; mod package_manager; use std::{collections::hash_map::Entry, fs, io, sync::Arc}; diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs new file mode 100644 index 00000000..a250617b --- /dev/null +++ b/crates/vite_workspace/src/package_filter.rs @@ -0,0 +1,576 @@ +//! Package filter types and parsing for pnpm-style `--filter` selectors. +//! +//! # Design +//! +//! Package selection is deliberately separated from task matching (two-stage model). +//! This module handles only Stage 1: which packages to include/exclude. +//! Stage 2 (which tasks to run in those packages) lives in `vite_task_graph`. +//! +//! # Filter syntax +//! +//! Follows pnpm's `--filter` specification: +//! +//! - `foo` → exact package name +//! - `@scope/*` → glob pattern +//! - `./path` → packages whose root is at or under this directory (one-way) +//! - `{./path}` → same, brace syntax +//! - `name{./dir}` → name AND directory (intersection) +//! - `foo...` → foo + its transitive dependencies +//! - `...foo` → foo + its transitive dependents +//! - `foo^...` → foo's dependencies only (exclude foo itself) +//! - `...^foo` → foo's dependents only (exclude foo itself) +//! - `...foo...` → foo + dependencies + dependents +//! - `!foo` → exclude foo from results +//! +//! The `ContainingPackage` selector variant is NOT produced by `parse_filter`. +//! It is synthesized internally for `vp run build` (implicit cwd) and `vp run -t build` +//! to walk up the directory tree and find the package that contains the given path. +//! This mirrors pnpm's `findPrefix` behaviour (not `parsePackageSelector` behaviour). + +use std::{path::Component, sync::Arc}; + +use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_str::Str; + +// ──────────────────────────────────────────────────────────────────────────── +// Types +// ──────────────────────────────────────────────────────────────────────────── + +/// Exact name or glob pattern for matching package names. +#[derive(Debug, Clone)] +pub enum PackageNamePattern { + /// Exact name (e.g. `foo`, `@scope/pkg`). O(1) hash lookup. + /// + /// Scoped auto-completion applies during resolution: if `"bar"` has no exact match + /// but exactly one `@*/bar` package exists, that package is matched. + /// This mirrors pnpm's matcher lines 303–306. + Exact(Str), + + /// Glob pattern (e.g. `@scope/*`, `*-utils`). Iterates all packages. + /// + /// Only `*` and `?` wildcards are supported (pnpm semantics). + /// Stored as an owned `Glob<'static>` so the filter can outlive the input string. + Glob(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)] +pub enum PackageSelector { + /// Match by name only. Produced by `--filter foo` or `--filter "@scope/*"`. + Name(PackageNamePattern), + + /// Match by directory. Produced by `--filter .`, `--filter ./path`, `--filter {dir}`. + /// + /// One-way matching (pnpm `isSubdir` semantics): selects packages whose root is + /// **at or under** this path. Does NOT walk up — `--filter .` from + /// `packages/app/src/` matches nothing (no package root is inside `src/`). + Directory(Arc), + + /// Find the package that **contains** this path (walks up the directory tree). + /// + /// Produced internally for `vp run build` (implicit cwd) and `vp run -t build`. + /// Uses `IndexedPackageGraph::get_package_index_from_cwd` semantics. + /// Never produced by `parse_filter`. + ContainingPackage(Arc), + + /// Match by name AND directory (intersection). + /// Produced by `--filter "pattern{./dir}"`. + NameAndDirectory { name: PackageNamePattern, directory: Arc }, +} + +/// Direction to traverse the package dependency graph from the initially matched packages. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TraversalDirection { + /// Transitive dependencies (outgoing edges). Produced by `foo...`. + Dependencies, + + /// Transitive dependents (incoming edges). Produced by `...foo`. + Dependents, + + /// Both: walk dependents first, then walk all dependencies of every found dependent. + /// Produced by `...foo...`. Follows pnpm index.ts lines 265–267. + Both, +} + +/// Graph traversal specification: how to expand from the initially matched packages. +/// +/// 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)] +pub struct GraphTraversal { + pub direction: TraversalDirection, + + /// Exclude the initially matched packages from the result. + /// + /// Produced by `^` in `foo^...` (keep dependencies, drop foo) + /// or `...^foo` (keep dependents, drop foo). + pub exclude_self: bool, +} + +/// A single package filter, corresponding to one `--filter` argument. +/// +/// Multiple filters are composed at the `PackageQuery` level: +/// inclusions are unioned, then exclusions are subtracted. +#[derive(Debug, Clone)] +pub struct PackageFilter { + /// When `true`, packages matching this filter are **excluded** from the result. + /// Produced by a leading `!` in the filter string. + pub exclude: bool, + + /// Which packages to initially match. + pub selector: PackageSelector, + + /// Optional graph expansion from the initial match. + /// `None` = exact match only (no traversal). + pub traversal: Option, +} + +// ──────────────────────────────────────────────────────────────────────────── +// Error +// ──────────────────────────────────────────────────────────────────────────── + +/// Errors that can occur when parsing a `--filter` string. +#[derive(Debug, thiserror::Error)] +pub enum PackageFilterParseError { + #[error("Empty filter selector")] + EmptySelector, + + #[error("Invalid glob pattern: {0}")] + InvalidGlob(#[from] wax::BuildError), +} + +// ──────────────────────────────────────────────────────────────────────────── +// Parsing +// ──────────────────────────────────────────────────────────────────────────── + +/// Parse a `--filter` string into a [`PackageFilter`]. +/// +/// `cwd` is used to resolve relative paths (`.`, `./path`, `{./path}`). +/// +/// # Errors +/// +/// Returns [`PackageFilterParseError::EmptySelector`] if the core selector is empty, +/// or [`PackageFilterParseError::InvalidGlob`] if the pattern contains an invalid glob. +/// +/// # Syntax +/// +/// Follows pnpm's `parsePackageSelector` algorithm. See module-level docs for examples. +pub fn parse_filter( + input: &str, + cwd: &AbsolutePath, +) -> Result { + // Step 1: strip leading `!` → exclusion filter. + let (exclude, rest) = + input.strip_prefix('!').map_or((false, input), |without_bang| (true, without_bang)); + + // Step 2: strip trailing `...` → transitive dependencies. + // Check for `^` immediately before `...` → exclude the seed packages themselves. + let (include_dependencies, deps_exclude_self, rest) = + rest.strip_suffix("...").map_or((false, false, rest), |before_dots| { + before_dots + .strip_suffix('^') + .map_or((true, false, before_dots), |before_caret| (true, true, before_caret)) + }); + + // Step 3: strip leading `...` → transitive dependents. + // Check for `^` immediately after `...` → exclude the seed packages themselves. + let (include_dependents, dependents_exclude_self, core) = + rest.strip_prefix("...").map_or((false, false, rest), |after_dots| { + after_dots + .strip_prefix('^') + .map_or((true, false, after_dots), |after_caret| (true, true, after_caret)) + }); + + // exclude_self is true if either direction had `^`. + let exclude_self = deps_exclude_self || dependents_exclude_self; + + // Step 4–5: build the traversal descriptor. + let traversal = match (include_dependencies, include_dependents) { + (false, false) => None, + (true, false) => { + Some(GraphTraversal { direction: TraversalDirection::Dependencies, exclude_self }) + } + (false, true) => { + Some(GraphTraversal { direction: TraversalDirection::Dependents, exclude_self }) + } + (true, true) => Some(GraphTraversal { direction: TraversalDirection::Both, exclude_self }), + }; + + // Step 6–9: parse the remaining core selector. + let selector = parse_core_selector(core, cwd)?; + + Ok(PackageFilter { exclude, selector, traversal }) +} + +/// Parse the core selector string (after stripping `!` and `...` markers). +/// +/// Implements pnpm's `SELECTOR_REGEX` logic: `^([^.][^{}[\]]*)?(\{[^}]+\})?$` +/// +/// Decision tree: +/// 1. If the string ends with `}` and contains a `{`, split name and brace-directory. +/// The name part must not start with `.` for the brace split to be valid +/// (per the regex rule that Group 1 must not start with `.`). +/// 2. If the string starts with `.`, treat the whole thing as a relative path. +/// 3. Otherwise treat as a name pattern (exact or glob). +fn parse_core_selector( + core: &str, + cwd: &AbsolutePath, +) -> Result { + // Try to extract a brace-enclosed directory suffix: `{...}`. + // The name part before the brace must not start with `.` (pnpm regex Group 1 constraint). + if let Some(without_closing) = core.strip_suffix('}') + && let Some(brace_pos) = without_closing.rfind('{') + { + let name_part = &without_closing[..brace_pos]; + let dir_inner = &without_closing[brace_pos + 1..]; + + // Per pnpm's regex: Group 1 (`[^.][^{}[\]]*`) must NOT start with `.`. + // If name_part starts with `.`, fall through to the `.`-prefix check. + if !name_part.starts_with('.') { + let directory = resolve_filter_path(dir_inner, cwd); + + return if name_part.is_empty() { + // Only a directory selector: `{./foo}` or `{packages/app}`. + Ok(PackageSelector::Directory(directory)) + } else { + // Name and directory combined: `foo{./bar}`. + let name = build_name_pattern(name_part)?; + Ok(PackageSelector::NameAndDirectory { name, directory }) + }; + } + // name_part starts with `.`: fall through — treat entire core as a relative path. + } + + // If the core starts with `.`, it's a relative path to a directory. + // This handles `.`, `..`, `./foo`, `../foo`. + if core.starts_with('.') { + let directory = resolve_filter_path(core, cwd); + return Ok(PackageSelector::Directory(directory)); + } + + // Guard against an empty selector reaching here. + if core.is_empty() { + return Err(PackageFilterParseError::EmptySelector); + } + + // Plain name or glob pattern. + Ok(PackageSelector::Name(build_name_pattern(core)?)) +} + +/// Resolve a path string relative to `cwd`, normalising away `.` and `..`. +/// +/// `path_str` may be `"."`, `".."`, `"./foo"`, `"../foo"`, or a bare name like `"packages/app"`. +fn resolve_filter_path(path_str: &str, cwd: &AbsolutePath) -> Arc { + // `AbsolutePath::join` delegates to `PathBuf::push`, which does not normalise + // `.` or `..` components. We must normalise manually so that the resulting path + // compares equal to the package root paths stored in `IndexedPackageGraph`. + let raw = cwd.join(path_str); + normalize_absolute_path(&raw).into() +} + +/// Normalise an [`AbsolutePath`] by resolving `.` and `..` components lexically. +/// +/// Does NOT hit the filesystem. If `..` would go above the root, +/// the pop is silently ignored (empty stack stays empty). +#[expect( + clippy::disallowed_types, + reason = "PathBuf used as temporary builder; only AbsolutePathBuf is returned" +)] +fn normalize_absolute_path(path: &AbsolutePath) -> AbsolutePathBuf { + let mut result = std::path::PathBuf::new(); + // Collect normal components into an owned Vec so we can pop `..` safely + // without worrying about borrowing `path` while mutating `result`. + let mut normal_parts: Vec = Vec::new(); + + for component in path.as_path().components() { + match component { + // Root and prefix always appear first; push them directly. + Component::RootDir | Component::Prefix(_) => result.push(component), + Component::Normal(name) => normal_parts.push(name.to_os_string()), + Component::ParentDir => { + // Pop the last normal component; never underflow past root. + let _ = normal_parts.pop(); + } + Component::CurDir => {} // skip `.` + } + } + + for name in normal_parts { + result.push(name); + } + + // SAFETY: The input was an AbsolutePathBuf (is_absolute() = true). + // Removing `.` and `..` from an absolute path cannot make it relative. + AbsolutePathBuf::new(result) + .expect("invariant: normalising an absolute path preserves absoluteness") +} + +impl PackageNamePattern { + /// Returns `true` if this pattern matches the given package name. + #[must_use] + pub fn matches_name(&self, name: &str) -> bool { + match self { + Self::Exact(n) => n.as_str() == name, + Self::Glob(glob) => { + use wax::Pattern as _; + glob.is_match(name) + } + } + } +} + +/// Build a [`PackageNamePattern`] from a name or glob string. +/// +/// A string containing `*`, `?`, or `[` is treated as a glob; otherwise exact. +fn build_name_pattern(name: &str) -> Result { + if name.contains(['*', '?', '[']) { + // Validate and compile the glob, then make it owned (lifetime: 'static). + let glob = wax::Glob::new(name)?.into_owned(); + Ok(PackageNamePattern::Glob(Box::new(glob))) + } else { + Ok(PackageNamePattern::Exact(name.into())) + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// Unit tests +// ──────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// Construct an [`AbsolutePath`] from a literal string (test helper). + fn abs(path: &'static str) -> &'static AbsolutePath { + AbsolutePath::new(path).expect("test path must be absolute") + } + + // ── Helpers to assert selector shapes ─────────────────────────────────── + + fn assert_exact_name(filter: &PackageFilter, expected: &str) { + match &filter.selector { + PackageSelector::Name(PackageNamePattern::Exact(n)) => { + assert_eq!(n.as_str(), expected, "exact name mismatch"); + } + other => panic!("expected Name(Exact({expected:?})), got {other:?}"), + } + } + + fn assert_glob_name(filter: &PackageFilter, expected_pattern: &str) { + match &filter.selector { + PackageSelector::Name(PackageNamePattern::Glob(g)) => { + assert_eq!(g.to_string(), expected_pattern, "glob pattern mismatch"); + } + other => panic!("expected Name(Glob({expected_pattern:?})), got {other:?}"), + } + } + + fn assert_directory(filter: &PackageFilter, expected_path: &AbsolutePath) { + match &filter.selector { + PackageSelector::Directory(dir) => { + assert_eq!(dir.as_ref(), expected_path, "directory mismatch"); + } + other => panic!("expected Directory({expected_path:?}), got {other:?}"), + } + } + + fn assert_name_and_directory( + filter: &PackageFilter, + expected_name: &str, + expected_dir: &AbsolutePath, + ) { + match &filter.selector { + PackageSelector::NameAndDirectory { name: PackageNamePattern::Exact(n), directory } => { + assert_eq!(n.as_str(), expected_name, "name mismatch"); + assert_eq!(directory.as_ref(), expected_dir, "directory mismatch"); + } + other => panic!( + "expected NameAndDirectory(Exact({expected_name:?}), {expected_dir:?}), got {other:?}" + ), + } + } + + fn assert_no_traversal(filter: &PackageFilter) { + assert!(filter.traversal.is_none(), "expected no traversal, got {:?}", filter.traversal); + } + + fn assert_traversal(filter: &PackageFilter, direction: TraversalDirection, exclude_self: bool) { + match &filter.traversal { + Some(t) => { + assert_eq!(t.direction, direction, "direction mismatch"); + assert_eq!(t.exclude_self, exclude_self, "exclude_self mismatch"); + } + None => panic!("expected traversal {direction:?}/{exclude_self}, got None"), + } + } + + // ── Tests ported from pnpm parsePackageSelector.ts ────────────────────── + + #[test] + fn exact_name() { + let cwd = abs("/workspace"); + let f = parse_filter("foo", cwd).unwrap(); + assert!(!f.exclude); + assert_exact_name(&f, "foo"); + assert_no_traversal(&f); + } + + #[test] + fn name_with_dependencies() { + let cwd = abs("/workspace"); + let f = parse_filter("foo...", cwd).unwrap(); + assert!(!f.exclude); + assert_exact_name(&f, "foo"); + assert_traversal(&f, TraversalDirection::Dependencies, false); + } + + #[test] + fn name_with_dependents() { + let cwd = abs("/workspace"); + let f = parse_filter("...foo", cwd).unwrap(); + assert!(!f.exclude); + assert_exact_name(&f, "foo"); + assert_traversal(&f, TraversalDirection::Dependents, false); + } + + #[test] + fn name_with_both_directions() { + let cwd = abs("/workspace"); + let f = parse_filter("...foo...", cwd).unwrap(); + assert!(!f.exclude); + assert_exact_name(&f, "foo"); + assert_traversal(&f, TraversalDirection::Both, false); + } + + #[test] + fn name_with_dependencies_exclude_self() { + let cwd = abs("/workspace"); + let f = parse_filter("foo^...", cwd).unwrap(); + assert!(!f.exclude); + assert_exact_name(&f, "foo"); + assert_traversal(&f, TraversalDirection::Dependencies, true); + } + + #[test] + fn name_with_dependents_exclude_self() { + let cwd = abs("/workspace"); + let f = parse_filter("...^foo", cwd).unwrap(); + assert!(!f.exclude); + assert_exact_name(&f, "foo"); + assert_traversal(&f, TraversalDirection::Dependents, true); + } + + #[test] + fn relative_path_dot_slash_foo() { + let cwd = abs("/workspace"); + let f = parse_filter("./foo", cwd).unwrap(); + assert!(!f.exclude); + assert_directory(&f, abs("/workspace/foo")); + assert_no_traversal(&f); + } + + #[test] + fn relative_path_dot() { + let cwd = abs("/workspace/packages/app"); + let f = parse_filter(".", cwd).unwrap(); + assert!(!f.exclude); + assert_directory(&f, abs("/workspace/packages/app")); + assert_no_traversal(&f); + } + + #[test] + fn relative_path_dotdot() { + let cwd = abs("/workspace/packages/app"); + let f = parse_filter("..", cwd).unwrap(); + assert!(!f.exclude); + assert_directory(&f, abs("/workspace/packages")); + assert_no_traversal(&f); + } + + #[test] + fn exclusion_prefix() { + let cwd = abs("/workspace"); + let f = parse_filter("!foo", cwd).unwrap(); + assert!(f.exclude); + assert_exact_name(&f, "foo"); + assert_no_traversal(&f); + } + + #[test] + fn brace_directory_relative_path() { + let cwd = abs("/workspace"); + let f = parse_filter("{./foo}", cwd).unwrap(); + assert!(!f.exclude); + assert_directory(&f, abs("/workspace/foo")); + assert_no_traversal(&f); + } + + #[test] + fn brace_directory_with_dependents() { + let cwd = abs("/workspace"); + let f = parse_filter("...{./foo}", cwd).unwrap(); + assert!(!f.exclude); + assert_directory(&f, abs("/workspace/foo")); + assert_traversal(&f, TraversalDirection::Dependents, false); + } + + #[test] + fn name_and_directory_combined() { + let cwd = abs("/workspace"); + let f = parse_filter("pattern{./dir}", cwd).unwrap(); + assert!(!f.exclude); + assert_name_and_directory(&f, "pattern", abs("/workspace/dir")); + assert_no_traversal(&f); + } + + #[test] + fn glob_pattern() { + let cwd = abs("/workspace"); + let f = parse_filter("@scope/*", cwd).unwrap(); + assert!(!f.exclude); + assert_glob_name(&f, "@scope/*"); + assert_no_traversal(&f); + } + + #[test] + fn empty_selector_error() { + let cwd = abs("/workspace"); + let err = parse_filter("", cwd).unwrap_err(); + assert!(matches!(err, PackageFilterParseError::EmptySelector)); + } + + /// A filter with only `!` (exclusion of empty selector) should also error. + #[test] + fn exclusion_with_empty_selector_error() { + let cwd = abs("/workspace"); + let err = parse_filter("!", cwd).unwrap_err(); + assert!(matches!(err, PackageFilterParseError::EmptySelector)); + } + + #[test] + fn scoped_package_name() { + let cwd = abs("/workspace"); + let f = parse_filter("@test/app", cwd).unwrap(); + assert_exact_name(&f, "@test/app"); + assert_no_traversal(&f); + } + + #[test] + fn path_normalisation_dotdot_in_middle() { + // `./foo/../bar` should normalise to `cwd/bar` + let cwd = abs("/workspace"); + let f = parse_filter("{./foo/../bar}", cwd).unwrap(); + assert_directory(&f, abs("/workspace/bar")); + } + + #[test] + fn path_normalisation_trailing_dot() { + // `./foo/.` should normalise to `cwd/foo` + let cwd = abs("/workspace"); + let f = parse_filter("{./foo/.}", cwd).unwrap(); + assert_directory(&f, abs("/workspace/foo")); + } +} diff --git a/crates/vite_workspace/src/package_graph.rs b/crates/vite_workspace/src/package_graph.rs new file mode 100644 index 00000000..d6c49f82 --- /dev/null +++ b/crates/vite_workspace/src/package_graph.rs @@ -0,0 +1,418 @@ +//! Package graph with indexed lookups and filter resolution. +//! +//! This module owns the `IndexedPackageGraph` (the read-only package dependency +//! graph enriched with hash-map indices for O(1) lookups) and the `PackageQuery` +//! type that expresses which packages a task query applies to. +//! +//! # Two-stage model +//! +//! Package selection (this module) is deliberately decoupled from task matching +//! (the task-query layer in `vite_task_graph`). `resolve_query` returns a *package +//! subgraph* — a `DiGraphMap` containing only the selected +//! packages and the original dependency edges between them. The task-query layer +//! then maps each selected package to its task node, reconnecting across +//! task-lacking nodes. + +use std::sync::Arc; + +use petgraph::{Direction, graph::DiGraph, prelude::DiGraphMap, visit::EdgeRef}; +use rustc_hash::{FxHashMap, FxHashSet}; +use vec1::{Vec1, smallvec_v1::SmallVec1}; +use vite_path::AbsolutePath; +use vite_str::Str; + +use crate::{ + DependencyType, PackageInfo, PackageIx, PackageNodeIndex, + package_filter::{ + GraphTraversal, PackageFilter, PackageNamePattern, PackageSelector, TraversalDirection, + }, +}; + +// ──────────────────────────────────────────────────────────────────────────── +// PackageQuery +// ──────────────────────────────────────────────────────────────────────────── + +/// Specifies which packages a task query applies to. +/// +/// The two variants prevent the invalid state where no packages are targeted: +/// - `Filters` carries at least one filter (enforced by `Vec1`). +/// - `All` is the explicit "run everywhere" variant, produced by `--recursive`. +#[derive(Debug)] +pub enum PackageQuery { + /// One or more `--filter` expressions. + /// + /// Inclusions are unioned; exclusions are subtracted from the union. + /// If all filters are exclusions, the starting set is the full workspace + /// (pnpm behaviour: exclude-only = full set minus exclusions). + Filters(Vec1), + + /// All packages in the workspace, in topological dependency order. + /// + /// Produced by `--recursive` / `-r`. + All, +} + +// ──────────────────────────────────────────────────────────────────────────── +// FilterResolution +// ──────────────────────────────────────────────────────────────────────────── + +/// The result of resolving a [`PackageQuery`] against the workspace. +pub struct FilterResolution { + /// Induced package subgraph: nodes = selected packages, edges = original dependency + /// ordering edges between them. + /// + /// All original edges between selected packages are preserved regardless of how each + /// package was selected (traversal or cherry-pick). A cherry-picked package still + /// respects its dependencies if they happen to be in the selected set. + /// + /// Future `--filter-prod` support would skip `DependencyType::Dev` edges at this + /// stage (construction time), keeping all downstream code edge-type-agnostic. + pub package_subgraph: DiGraphMap, + + /// Indices into the input `filters` slice for selectors that matched no packages. + /// + /// The caller maps each index back to the original `--filter` string to emit + /// typo warnings. Empty when `PackageQuery::All` is used. + pub unmatched_selectors: Vec, +} + +// ──────────────────────────────────────────────────────────────────────────── +// IndexedPackageGraph +// ──────────────────────────────────────────────────────────────────────────── + +/// Package graph with additional hash maps for quick task lookup. +#[derive(Debug)] +pub struct IndexedPackageGraph { + graph: DiGraph, + + /// Package indices grouped by name. + /// + /// `SmallVec1` avoids a heap allocation in the common case (one package per name) + /// while still supporting the rare monorepo name-collision scenario. + indices_by_name: FxHashMap>, + + /// Package indices by absolute path, for O(1) lookup given a cwd. + indices_by_path: FxHashMap, PackageNodeIndex>, +} + +impl IndexedPackageGraph { + /// Build the index from a raw package graph. + #[must_use] + pub fn index(package_graph: DiGraph) -> Self { + let indices_by_path: FxHashMap, PackageNodeIndex> = package_graph + .node_indices() + .map(|idx| (Arc::clone(&package_graph[idx].absolute_path), idx)) + .collect(); + + let mut indices_by_name: FxHashMap> = + FxHashMap::default(); + for idx in package_graph.node_indices() { + let name = package_graph[idx].package_json.name.clone(); + indices_by_name + .entry(name) + .and_modify(|v| v.push(idx)) + .or_insert_with(|| SmallVec1::new(idx)); + } + + Self { graph: package_graph, indices_by_name, indices_by_path } + } + + // ── Public accessors ───────────────────────────────────────────────────── + + /// Reference to the underlying package graph. + #[must_use] + pub const fn package_graph(&self) -> &DiGraph { + &self.graph + } + + /// Walk up the directory tree from `cwd` to find the nearest enclosing package. + /// + /// Returns `None` if no package root is found anywhere above `cwd`. + #[must_use] + pub fn get_package_index_from_cwd(&self, cwd: &AbsolutePath) -> Option { + let mut cur = cwd; + loop { + if let Some(&idx) = self.indices_by_path.get(cur) { + return Some(idx); + } + cur = cur.parent()?; + } + } + + /// Look up all package indices with the given name (exact, case-sensitive). + #[must_use] + pub fn get_package_indices_by_name( + &self, + name: &Str, + ) -> Option<&SmallVec1<[PackageNodeIndex; 1]>> { + self.indices_by_name.get(name) + } + + // ── Query resolution ───────────────────────────────────────────────────── + + /// Resolve a [`PackageQuery`] into a [`FilterResolution`]. + /// + /// For `All`, returns the full induced subgraph (every package, every edge). + /// For `Filters`, applies the filter algorithm and returns the induced subgraph + /// of the matching packages. + #[must_use] + pub fn resolve_query(&self, query: &PackageQuery) -> FilterResolution { + match query { + PackageQuery::All => FilterResolution { + package_subgraph: self.full_subgraph(), + unmatched_selectors: Vec::new(), + }, + PackageQuery::Filters(filters) => self.resolve_filters(filters.as_slice()), + } + } + + /// Build the full induced subgraph: every package node and every dependency edge. + /// + /// Used by `PackageQuery::All` (`--recursive`). + fn full_subgraph(&self) -> DiGraphMap { + let mut subgraph = DiGraphMap::new(); + for idx in self.graph.node_indices() { + subgraph.add_node(idx); + } + for edge in self.graph.edge_references() { + subgraph.add_edge(edge.source(), edge.target(), ()); + } + subgraph + } + + /// Resolve a slice of package filters into a `FilterResolution`. + /// + /// # Algorithm (follows pnpm's `filterWorkspacePackages`) + /// + /// 1. Partition filters into inclusions (`!exclude = false`) and exclusions. + /// 2. If there are no inclusions, start from ALL packages (exclude-only mode). + /// 3. For each inclusion: resolve its selector to entry packages, expand via + /// graph traversal if requested, and union into `selected`. + /// 4. For each exclusion: resolve + expand, then subtract from `selected`. + /// 5. Build the induced subgraph: every original edge whose both endpoints are + /// in `selected` is preserved, regardless of how each endpoint was selected. + fn resolve_filters(&self, filters: &[PackageFilter]) -> FilterResolution { + let mut unmatched_selectors = Vec::new(); + + let (inclusions, exclusions): (Vec<_>, Vec<_>) = + filters.iter().enumerate().partition(|(_, f)| !f.exclude); + + // Start from all packages when there are no inclusions (exclude-only mode). + let mut selected: FxHashSet = if inclusions.is_empty() { + self.graph.node_indices().collect() + } else { + FxHashSet::default() + }; + + // Apply inclusions: union each filter's resolved set into `selected`. + for (filter_idx, filter) in &inclusions { + let matched = self.resolve_selector_entries(&filter.selector); + if matched.is_empty() { + unmatched_selectors.push(*filter_idx); + } + let expanded = self.expand_traversal(matched, filter.traversal.as_ref()); + selected.extend(expanded); + } + + // Apply exclusions: subtract each filter's resolved set from `selected`. + for (_, filter) in &exclusions { + let matched = self.resolve_selector_entries(&filter.selector); + let to_remove = self.expand_traversal(matched, filter.traversal.as_ref()); + for pkg in to_remove { + selected.remove(&pkg); + } + } + + let package_subgraph = self.build_induced_subgraph(&selected); + FilterResolution { package_subgraph, unmatched_selectors } + } + + /// Resolve a `PackageSelector` to the set of directly matched packages + /// (before any graph traversal expansion). + fn resolve_selector_entries(&self, selector: &PackageSelector) -> FxHashSet { + let mut matched = FxHashSet::default(); + + match selector { + PackageSelector::Name(pattern) => { + self.match_by_name_pattern(pattern, &mut matched); + } + + PackageSelector::Directory(dir) => { + // One-way isSubdir: package root must be at or under `dir`. + for idx in self.graph.node_indices() { + if self.graph[idx].absolute_path.as_path().starts_with(dir.as_path()) { + matched.insert(idx); + } + } + } + + PackageSelector::ContainingPackage(path) => { + // Walk up the directory tree to find the enclosing package. + if let Some(idx) = self.get_package_index_from_cwd(path) { + matched.insert(idx); + } + } + + PackageSelector::NameAndDirectory { name, directory } => { + // Intersection: packages satisfying both name AND directory. + let mut by_name = FxHashSet::default(); + self.match_by_name_pattern(name, &mut by_name); + for idx in by_name { + if self.graph[idx].absolute_path.as_path().starts_with(directory.as_path()) { + matched.insert(idx); + } + } + } + } + + matched + } + + /// Match packages by a name pattern, inserting into `out`. + /// + /// For `Exact` names, scoped auto-completion applies (pnpm matcher lines 303–306): + /// if `"bar"` has no exact match but exactly one `@*/bar` package exists, + /// that package is used instead. + fn match_by_name_pattern( + &self, + pattern: &PackageNamePattern, + out: &mut FxHashSet, + ) { + match pattern { + PackageNamePattern::Exact(name) => { + if let Some(indices) = self.get_package_indices_by_name(name) { + out.extend(indices.iter().copied()); + } else { + // Scoped auto-completion: `"bar"` → `"@scope/bar"` if exactly one match. + let scoped_suffix = vite_str::format!("/{}", name); + let scoped: Vec<_> = self + .indices_by_name + .iter() + .filter(|(pkg_name, _)| { + pkg_name.starts_with('@') && pkg_name.ends_with(scoped_suffix.as_str()) + }) + .flat_map(|(_, indices)| indices.iter().copied()) + .collect(); + if scoped.len() == 1 { + out.insert(scoped[0]); + } + // 0 matches: nothing to insert. + // >1 matches: ambiguous; skip rather than guessing wrong. + } + } + + PackageNamePattern::Glob(_) => { + for (pkg_name, indices) in &self.indices_by_name { + if pattern.matches_name(pkg_name.as_str()) { + out.extend(indices.iter().copied()); + } + } + } + } + } + + /// Expand a seed set of packages according to a graph traversal specification. + /// + /// Returns the final set, including or excluding the original seeds depending on + /// `traversal.exclude_self`. `None` traversal → seeds returned unchanged. + fn expand_traversal( + &self, + seeds: FxHashSet, + traversal: Option<&GraphTraversal>, + ) -> FxHashSet { + let Some(traversal) = traversal else { + return seeds; + }; + + let mut reachable = FxHashSet::default(); + + match traversal.direction { + TraversalDirection::Dependencies => { + self.bfs_outgoing(&seeds, &mut reachable); + } + TraversalDirection::Dependents => { + self.bfs_incoming(&seeds, &mut reachable); + } + TraversalDirection::Both => { + // pnpm lines 265–267: walk dependents first, then walk dependencies of + // ALL dependents found (including the original seeds). + let mut dependents = FxHashSet::default(); + self.bfs_incoming(&seeds, &mut dependents); + let all_dep_seeds: FxHashSet<_> = + seeds.iter().chain(dependents.iter()).copied().collect(); + self.bfs_outgoing(&all_dep_seeds, &mut reachable); + reachable.extend(dependents); + } + } + + if traversal.exclude_self { + for seed in &seeds { + reachable.remove(seed); + } + } else { + reachable.extend(seeds); + } + + reachable + } + + /// BFS along outgoing (dependency) edges from `seeds`, collecting all reachable nodes. + /// + /// Seeds are NOT added to `out`; the caller decides inclusion based on `exclude_self`. + fn bfs_outgoing( + &self, + seeds: &FxHashSet, + out: &mut FxHashSet, + ) { + let mut queue: Vec = seeds.iter().copied().collect(); + while let Some(node) = queue.pop() { + for edge in self.graph.edges(node) { + let dep = edge.target(); + if out.insert(dep) { + queue.push(dep); + } + } + } + } + + /// BFS along incoming (dependent) edges from `seeds`, collecting all reachable nodes. + /// + /// Seeds are NOT added to `out`. + fn bfs_incoming( + &self, + seeds: &FxHashSet, + out: &mut FxHashSet, + ) { + let mut queue: Vec = seeds.iter().copied().collect(); + while let Some(node) = queue.pop() { + for edge in self.graph.edges_directed(node, Direction::Incoming) { + let dependent = edge.source(); + if out.insert(dependent) { + queue.push(dependent); + } + } + } + } + + /// Build the induced subgraph of `selected` packages. + /// + /// Includes every node in `selected` and every original edge `(a, b)` where + /// both `a` and `b` are in `selected`. Isolated nodes are also included. + fn build_induced_subgraph( + &self, + selected: &FxHashSet, + ) -> DiGraphMap { + let mut subgraph = DiGraphMap::new(); + for &pkg in selected { + subgraph.add_node(pkg); + } + for edge in self.graph.edge_references() { + let src = edge.source(); + let dst = edge.target(); + if selected.contains(&src) && selected.contains(&dst) { + subgraph.add_edge(src, dst, ()); + } + } + subgraph + } +} From 02e5c448e17283219a9e3d507c5f64ca4e9cb494 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Feb 2026 10:25:01 +0800 Subject: [PATCH 07/35] refactor: drop edge weights from task graph snapshot serialization SerializeByKey now serializes only neighbor keys, ignoring edge weights. This removes the redundant `null` that appeared for every TaskDependencyType edge in task graph snapshots. Co-Authored-By: Claude Opus 4.6 --- crates/vite_graph_ser/src/lib.rs | 39 ++++++++--------- .../conflict-test/snapshots/task graph.snap | 7 +--- .../snapshots/task graph.snap | 14 ++----- .../snapshots/task graph.snap | 7 +--- .../snapshots/task graph.snap | 35 +++++----------- .../snapshots/task graph.snap | 42 ++++++------------- 6 files changed, 48 insertions(+), 96 deletions(-) diff --git a/crates/vite_graph_ser/src/lib.rs b/crates/vite_graph_ser/src/lib.rs index 3dd6edd7..04470cb9 100644 --- a/crates/vite_graph_ser/src/lib.rs +++ b/crates/vite_graph_ser/src/lib.rs @@ -17,17 +17,19 @@ pub trait GetKey { } #[derive(Serialize)] -#[serde(bound = "E: Serialize, N: Serialize")] -struct DiGraphNodeItem<'a, N: GetKey, E> { +#[serde(bound = "N: Serialize")] +struct DiGraphNodeItem<'a, N: GetKey> { key: N::Key<'a>, node: &'a N, - neighbors: Vec<(N::Key<'a>, &'a E)>, + neighbors: Vec>, } /// A wrapper around `DiGraph` that serializes nodes by their keys. +/// +/// Only node connectivity is recorded — edge weights are ignored in the output. #[derive(Serialize)] #[serde(transparent)] -pub struct SerializeByKey<'a, N: GetKey + Serialize, E: Serialize, Ix: petgraph::graph::IndexType>( +pub struct SerializeByKey<'a, N: GetKey + Serialize, E, Ix: petgraph::graph::IndexType>( #[serde(serialize_with = "serialize_by_key")] pub &'a DiGraph, ); @@ -45,25 +47,20 @@ pub struct SerializeByKey<'a, N: GetKey + Serialize, E: Serialize, Ix: petgraph: /// /// # Panics /// Panics if an edge references a node index not present in the graph. -pub fn serialize_by_key< - N: GetKey + Serialize, - E: Serialize, - Ix: petgraph::graph::IndexType, - S: Serializer, ->( +pub fn serialize_by_key( graph: &DiGraph, serializer: S, ) -> Result { - let mut items = Vec::>::with_capacity(graph.node_count()); + let mut items = Vec::>::with_capacity(graph.node_count()); for (node_idx, node) in graph.node_references() { - let mut neighbors = Vec::<(N::Key<'_>, &E)>::new(); + let mut neighbors = Vec::>::new(); for edge in graph.edges(node_idx) { let target_idx = edge.target(); let target_node = graph.node_weight(target_idx).unwrap(); - neighbors.push((target_node.key().map_err(serde::ser::Error::custom)?, edge.weight())); + neighbors.push(target_node.key().map_err(serde::ser::Error::custom)?); } - neighbors.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + neighbors.sort_unstable(); items.push(DiGraphNodeItem { key: node.key().map_err(serde::ser::Error::custom)?, node, @@ -101,19 +98,19 @@ mod tests { #[derive(Serialize)] struct GraphWrapper { #[serde(serialize_with = "serialize_by_key")] - graph: DiGraph, + graph: DiGraph, } #[test] fn test_serialize_graph_happy_path() { - let mut graph = DiGraph::::new(); + let mut graph = DiGraph::::new(); let a = graph.add_node(TestNode { id: "a", value: 1 }); let b = graph.add_node(TestNode { id: "b", value: 2 }); let c = graph.add_node(TestNode { id: "c", value: 3 }); - graph.add_edge(a, b, "a->b"); - graph.add_edge(a, c, "a->c"); - graph.add_edge(b, c, "b->c"); + graph.add_edge(a, b, ()); + graph.add_edge(a, c, ()); + graph.add_edge(b, c, ()); let json = serde_json::to_value(GraphWrapper { graph }).unwrap(); assert_eq!( @@ -123,12 +120,12 @@ mod tests { { "key": "a", "node": {"id": "a", "value": 1}, - "neighbors": [["b", "a->b"], ["c", "a->c"]] + "neighbors": ["b", "c"] }, { "key": "b", "node": {"id": "b", "value": 2}, - "neighbors": [["c", "b->c"]] + "neighbors": ["c"] }, { "key": "c", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/conflict-test/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/conflict-test/snapshots/task graph.snap index d29aad6f..9dfc5dae 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/conflict-test/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/conflict-test/snapshots/task graph.snap @@ -88,11 +88,8 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/conflict-test }, "neighbors": [ [ - [ - "/packages/scope-a-b", - "c" - ], - null + "/packages/scope-a-b", + "c" ] ] } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cycle-dependency/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cycle-dependency/snapshots/task graph.snap index 6f10cfd8..41ec81de 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cycle-dependency/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cycle-dependency/snapshots/task graph.snap @@ -32,11 +32,8 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cycle-dependency }, "neighbors": [ [ - [ - "/", - "task-b" - ], - null + "/", + "task-b" ] ] }, @@ -68,11 +65,8 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cycle-dependency }, "neighbors": [ [ - [ - "/", - "task-a" - ], - null + "/", + "task-a" ] ] } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/dependency-both-topo-and-explicit/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/dependency-both-topo-and-explicit/snapshots/task graph.snap index 7bec33e1..79d4c1e1 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/dependency-both-topo-and-explicit/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/dependency-both-topo-and-explicit/snapshots/task graph.snap @@ -32,11 +32,8 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/dependency-both- }, "neighbors": [ [ - [ - "/packages/b", - "build" - ], - null + "/packages/b", + "build" ] ] }, diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-test/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-test/snapshots/task graph.snap index 7eedbaeb..1b3f0a3d 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-test/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-test/snapshots/task graph.snap @@ -32,18 +32,12 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-te }, "neighbors": [ [ - [ - "/packages/another-empty", - "lint" - ], - null + "/packages/another-empty", + "lint" ], [ - [ - "/packages/normal-package", - "test" - ], - null + "/packages/normal-package", + "test" ] ] }, @@ -68,18 +62,12 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-te }, "neighbors": [ [ - [ - "/packages/another-empty", - "build" - ], - null + "/packages/another-empty", + "build" ], [ - [ - "/packages/another-empty", - "test" - ], - null + "/packages/another-empty", + "test" ] ] }, @@ -167,11 +155,8 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-te }, "neighbors": [ [ - [ - "/packages/empty-name", - "test" - ], - null + "/packages/empty-name", + "test" ] ] }, diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-workspace/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-workspace/snapshots/task graph.snap index b097b1ba..f6ab0d3f 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-workspace/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-workspace/snapshots/task graph.snap @@ -53,25 +53,16 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo }, "neighbors": [ [ - [ - "/packages/app", - "build" - ], - null + "/packages/app", + "build" ], [ - [ - "/packages/app", - "test" - ], - null + "/packages/app", + "test" ], [ - [ - "/packages/utils", - "lint" - ], - null + "/packages/utils", + "lint" ] ] }, @@ -215,11 +206,8 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo }, "neighbors": [ [ - [ - "/packages/core", - "clean" - ], - null + "/packages/core", + "clean" ] ] }, @@ -307,18 +295,12 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo }, "neighbors": [ [ - [ - "/packages/core", - "build" - ], - null + "/packages/core", + "build" ], [ - [ - "/packages/utils", - "build" - ], - null + "/packages/utils", + "build" ] ] }, From 6e6975de36f442f085e2e9870fadaa04b517038d Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Feb 2026 10:41:07 +0800 Subject: [PATCH 08/35] test: add transitive flag with package#task specifier tests Test `-t` combined with `package#task` syntax for both cases: - package has the task (selects it + transitive deps) - package lacks the task (skip-intermediate, only deps run) Co-Authored-By: Claude Opus 4.6 --- .../fixtures/filter-workspace/snapshots.toml | 6 ++++++ ...uery - transitive with package specifier.snap | 16 ++++++++++++++++ .../transitive-skip-intermediate/snapshots.toml | 8 ++++++++ ...tive with package specifier lacking task.snap | 8 ++++++++ 4 files changed, 38 insertions(+) create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive with package specifier.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive with package specifier lacking task.snap diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml index 8dc0b1f8..fc787f3b 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml @@ -88,6 +88,12 @@ name = "transitive flag from package subfolder" args = ["run", "-t", "build"] cwd = "packages/app/src/components" +# -t with package#task specifier = --filter package... task +[[plan]] +compact = true +name = "transitive with package specifier" +args = ["run", "-t", "@test/app#build"] + # recursive flag = All [[plan]] compact = true diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive with package specifier.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive with package specifier.snap new file mode 100644 index 00000000..4806b35d --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive with package specifier.snap @@ -0,0 +1,16 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build", + "packages/utils#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ], + "packages/utils#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots.toml index fdb6e03a..c5f5f8cb 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots.toml +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots.toml @@ -32,3 +32,11 @@ compact = true name = "transitive from package without task" args = ["run", "-t", "build"] cwd = "packages/middle" + +# -t with package#task: middle has no build, but its dep bottom does. +# Equivalent to --filter @test/middle... build. +# Result: bottom#build only. +[[plan]] +compact = true +name = "transitive with package specifier lacking task" +args = ["run", "-t", "@test/middle#build"] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive with package specifier lacking task.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive with package specifier lacking task.snap new file mode 100644 index 00000000..82ec7abf --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive with package specifier lacking task.snap @@ -0,0 +1,8 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate +--- +{ + "packages/bottom#build": [] +} From 7a7c34e4555f9d67fe8fcfcef0b365f5597f3f71 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Feb 2026 15:09:52 +0800 Subject: [PATCH 09/35] feat: support directory glob patterns in --filter Implement pnpm v7+ glob-dir semantics: plain directory paths are exact-match only, `*`/`**` opt in to descendant matching. Adds DirectoryPattern enum (Exact/Glob) and splits glob paths into a resolved base + wax::Glob pattern for matching. Also adds pnpm GitHub permalink references to all existing pnpm comments and a test for `..` normalization in glob base paths. Co-Authored-By: Claude Opus 4.6 --- .../fixtures/filter-workspace/snapshots.toml | 14 +- ... filter by directory glob double star.snap | 19 ++ ...query - filter by directory glob star.snap | 19 ++ crates/vite_workspace/src/package_filter.rs | 202 ++++++++++++++++-- crates/vite_workspace/src/package_graph.rs | 63 ++++-- 5 files changed, 280 insertions(+), 37 deletions(-) create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory glob double star.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory glob star.snap diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml index fc787f3b..6ce9255c 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml @@ -68,12 +68,24 @@ compact = true name = "exclude only filter" args = ["run", "--filter", "!@test/core", "build"] -# pnpm: "select by parentDir" +# pnpm: "select by parentDir" — exact path, no glob [[plan]] compact = true name = "filter by directory" args = ["run", "--filter", "./packages/app", "build"] +# pnpm glob-dir: one level under packages/ +[[plan]] +compact = true +name = "filter by directory glob star" +args = ["run", "--filter", "./packages/*", "build"] + +# pnpm glob-dir: recursive under packages/ +[[plan]] +compact = true +name = "filter by directory glob double star" +args = ["run", "--filter", "./packages/**", "build"] + # transitive flag = --filter .… [[plan]] compact = true diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory glob double star.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory glob double star.snap new file mode 100644 index 00000000..5aa8fad6 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory glob double star.snap @@ -0,0 +1,19 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build", + "packages/utils#build" + ], + "packages/cli#build": [ + "packages/core#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ], + "packages/utils#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory glob star.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory glob star.snap new file mode 100644 index 00000000..5aa8fad6 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory glob star.snap @@ -0,0 +1,19 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build", + "packages/utils#build" + ], + "packages/cli#build": [ + "packages/core#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ], + "packages/utils#build": [] +} diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index a250617b..8b625c38 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -25,7 +25,9 @@ //! The `ContainingPackage` selector variant is NOT produced by `parse_filter`. //! It is synthesized internally for `vp run build` (implicit cwd) and `vp run -t build` //! to walk up the directory tree and find the package that contains the given path. -//! This mirrors pnpm's `findPrefix` behaviour (not `parsePackageSelector` behaviour). +//! This mirrors pnpm's `findPrefix` behaviour (not [`parsePackageSelector`] behaviour). +//! +//! [`parsePackageSelector`]: https://github.com/pnpm/pnpm/blob/05dd45ea82fff9c0b687cdc8f478a1027077d343/workspace/filter-workspace-packages/src/parsePackageSelector.ts#L14-L61 use std::{path::Component, sync::Arc}; @@ -43,7 +45,7 @@ pub enum PackageNamePattern { /// /// Scoped auto-completion applies during resolution: if `"bar"` has no exact match /// but exactly one `@*/bar` package exists, that package is matched. - /// This mirrors pnpm's matcher lines 303–306. + /// pnpm ref: Exact(Str), /// Glob pattern (e.g. `@scope/*`, `*-utils`). Iterates all packages. @@ -53,6 +55,26 @@ pub enum PackageNamePattern { Glob(Box>), } +/// Directory matching pattern for `--filter` selectors. +/// +/// Follows pnpm v7+ glob-dir semantics: plain paths are exact-only, +/// `*` / `**` opt in to descendant matching. +/// +/// pnpm ref: +#[derive(Debug, Clone)] +pub enum DirectoryPattern { + /// Exact path match (no glob metacharacters in selector). + Exact(Arc), + + /// Glob: resolved base directory (non-glob prefix) + relative glob pattern. + /// + /// Matching strips `base` from a candidate path, then tests the remainder + /// 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> }, +} + /// What packages to initially match. /// /// The enum prevents the all-`None` invalid state that would arise from a struct @@ -64,10 +86,9 @@ pub enum PackageSelector { /// Match by directory. Produced by `--filter .`, `--filter ./path`, `--filter {dir}`. /// - /// One-way matching (pnpm `isSubdir` semantics): selects packages whose root is - /// **at or under** this path. Does NOT walk up — `--filter .` from - /// `packages/app/src/` matches nothing (no package root is inside `src/`). - Directory(Arc), + /// Uses pnpm v7+ glob-dir semantics: plain paths are exact-match only, + /// `*` / `**` globs opt in to descendant matching. + Directory(DirectoryPattern), /// Find the package that **contains** this path (walks up the directory tree). /// @@ -78,7 +99,7 @@ pub enum PackageSelector { /// Match by name AND directory (intersection). /// Produced by `--filter "pattern{./dir}"`. - NameAndDirectory { name: PackageNamePattern, directory: Arc }, + NameAndDirectory { name: PackageNamePattern, directory: DirectoryPattern }, } /// Direction to traverse the package dependency graph from the initially matched packages. @@ -91,7 +112,8 @@ pub enum TraversalDirection { Dependents, /// Both: walk dependents first, then walk all dependencies of every found dependent. - /// Produced by `...foo...`. Follows pnpm index.ts lines 265–267. + /// Produced by `...foo...`. + /// pnpm ref: Both, } @@ -157,7 +179,9 @@ pub enum PackageFilterParseError { /// /// # Syntax /// -/// Follows pnpm's `parsePackageSelector` algorithm. See module-level docs for examples. +/// Follows pnpm's [`parsePackageSelector`] algorithm. See module-level docs for examples. +/// +/// [`parsePackageSelector`]: https://github.com/pnpm/pnpm/blob/05dd45ea82fff9c0b687cdc8f478a1027077d343/workspace/filter-workspace-packages/src/parsePackageSelector.ts#L14-L61 pub fn parse_filter( input: &str, cwd: &AbsolutePath, @@ -207,7 +231,9 @@ pub fn parse_filter( /// Parse the core selector string (after stripping `!` and `...` markers). /// -/// Implements pnpm's `SELECTOR_REGEX` logic: `^([^.][^{}[\]]*)?(\{[^}]+\})?$` +/// Implements pnpm's [`SELECTOR_REGEX`] logic: `^([^.][^{}[\]]*)?(\{[^}]+\})?$` +/// +/// [`SELECTOR_REGEX`]: https://github.com/pnpm/pnpm/blob/05dd45ea82fff9c0b687cdc8f478a1027077d343/workspace/filter-workspace-packages/src/parsePackageSelector.ts#L37 /// /// Decision tree: /// 1. If the string ends with `}` and contains a `{`, split name and brace-directory. @@ -230,7 +256,7 @@ fn parse_core_selector( // Per pnpm's regex: Group 1 (`[^.][^{}[\]]*`) must NOT start with `.`. // If name_part starts with `.`, fall through to the `.`-prefix check. if !name_part.starts_with('.') { - let directory = resolve_filter_path(dir_inner, cwd); + let directory = resolve_directory_pattern(dir_inner, cwd)?; return if name_part.is_empty() { // Only a directory selector: `{./foo}` or `{packages/app}`. @@ -245,9 +271,9 @@ fn parse_core_selector( } // If the core starts with `.`, it's a relative path to a directory. - // This handles `.`, `..`, `./foo`, `../foo`. + // This handles `.`, `..`, `./foo`, `../foo`, `./foo/*`, `./foo/**`. if core.starts_with('.') { - let directory = resolve_filter_path(core, cwd); + let directory = resolve_directory_pattern(core, cwd)?; return Ok(PackageSelector::Directory(directory)); } @@ -260,6 +286,42 @@ fn parse_core_selector( Ok(PackageSelector::Name(build_name_pattern(core)?)) } +/// Resolve a directory selector string into a [`DirectoryPattern`]. +/// +/// If the string contains glob metacharacters (`*`, `?`, `[`), it is split at the +/// first glob component into a resolved base path and a relative glob pattern. +/// Otherwise, the entire string is resolved as an exact path. +fn resolve_directory_pattern( + path_str: &str, + cwd: &AbsolutePath, +) -> Result { + if !path_str.contains(['*', '?', '[']) { + return Ok(DirectoryPattern::Exact(resolve_filter_path(path_str, cwd))); + } + + // Split into non-glob base components and glob suffix components. + let mut base_parts: Vec<&str> = Vec::new(); + let mut glob_parts: Vec<&str> = Vec::new(); + let mut found_glob = false; + + for part in path_str.split('/') { + if !found_glob && !part.contains(['*', '?', '[']) { + base_parts.push(part); + } else { + found_glob = true; + glob_parts.push(part); + } + } + + let base_str = if base_parts.is_empty() { "." } else { &base_parts.join("/") }; + let base = resolve_filter_path(base_str, cwd); + + let glob_str = glob_parts.join("/"); + let pattern = wax::Glob::new(&glob_str)?.into_owned(); + + Ok(DirectoryPattern::Glob { base, pattern: Box::new(pattern) }) +} + /// Resolve a path string relative to `cwd`, normalising away `.` and `..`. /// /// `path_str` may be `"."`, `".."`, `"./foo"`, `"../foo"`, or a bare name like `"packages/app"`. @@ -370,10 +432,26 @@ mod tests { fn assert_directory(filter: &PackageFilter, expected_path: &AbsolutePath) { match &filter.selector { - PackageSelector::Directory(dir) => { + PackageSelector::Directory(DirectoryPattern::Exact(dir)) => { assert_eq!(dir.as_ref(), expected_path, "directory mismatch"); } - other => panic!("expected Directory({expected_path:?}), got {other:?}"), + other => panic!("expected Directory(Exact({expected_path:?})), got {other:?}"), + } + } + + fn assert_directory_glob( + filter: &PackageFilter, + expected_base: &AbsolutePath, + expected_pattern: &str, + ) { + match &filter.selector { + PackageSelector::Directory(DirectoryPattern::Glob { base, pattern }) => { + assert_eq!(base.as_ref(), expected_base, "base mismatch"); + assert_eq!(pattern.to_string(), expected_pattern, "pattern mismatch"); + } + other => panic!( + "expected Directory(Glob {{ base: {expected_base:?}, pattern: {expected_pattern:?} }}), got {other:?}" + ), } } @@ -383,12 +461,36 @@ mod tests { expected_dir: &AbsolutePath, ) { match &filter.selector { - PackageSelector::NameAndDirectory { name: PackageNamePattern::Exact(n), directory } => { + PackageSelector::NameAndDirectory { + name: PackageNamePattern::Exact(n), + directory: DirectoryPattern::Exact(dir), + } => { + assert_eq!(n.as_str(), expected_name, "name mismatch"); + assert_eq!(dir.as_ref(), expected_dir, "directory mismatch"); + } + other => panic!( + "expected NameAndDirectory(Exact({expected_name:?}), Exact({expected_dir:?})), got {other:?}" + ), + } + } + + fn assert_name_and_directory_glob( + filter: &PackageFilter, + expected_name: &str, + expected_base: &AbsolutePath, + expected_pattern: &str, + ) { + match &filter.selector { + PackageSelector::NameAndDirectory { + name: PackageNamePattern::Exact(n), + directory: DirectoryPattern::Glob { base, pattern }, + } => { assert_eq!(n.as_str(), expected_name, "name mismatch"); - assert_eq!(directory.as_ref(), expected_dir, "directory mismatch"); + assert_eq!(base.as_ref(), expected_base, "base mismatch"); + assert_eq!(pattern.to_string(), expected_pattern, "pattern mismatch"); } other => panic!( - "expected NameAndDirectory(Exact({expected_name:?}), {expected_dir:?}), got {other:?}" + "expected NameAndDirectory(Exact({expected_name:?}), Glob {{ base: {expected_base:?}, pattern: {expected_pattern:?} }}), got {other:?}" ), } } @@ -573,4 +675,68 @@ mod tests { let f = parse_filter("{./foo/.}", cwd).unwrap(); assert_directory(&f, abs("/workspace/foo")); } + + // ── Directory glob tests ───────────────────────────────────────────────── + + #[test] + fn directory_glob_star() { + let cwd = abs("/workspace"); + let f = parse_filter("./packages/*", cwd).unwrap(); + assert!(!f.exclude); + assert_directory_glob(&f, abs("/workspace/packages"), "*"); + assert_no_traversal(&f); + } + + #[test] + fn directory_glob_double_star() { + let cwd = abs("/workspace"); + let f = parse_filter("./packages/**", cwd).unwrap(); + assert!(!f.exclude); + assert_directory_glob(&f, abs("/workspace/packages"), "**"); + assert_no_traversal(&f); + } + + #[test] + fn brace_directory_glob() { + let cwd = abs("/workspace"); + let f = parse_filter("{./packages/*}", cwd).unwrap(); + assert!(!f.exclude); + assert_directory_glob(&f, abs("/workspace/packages"), "*"); + assert_no_traversal(&f); + } + + #[test] + fn name_and_directory_glob_combined() { + let cwd = abs("/workspace"); + let f = parse_filter("app{./packages/*}", cwd).unwrap(); + assert!(!f.exclude); + assert_name_and_directory_glob(&f, "app", abs("/workspace/packages"), "*"); + assert_no_traversal(&f); + } + + #[test] + fn directory_glob_with_traversal() { + let cwd = abs("/workspace"); + let f = parse_filter("...{./packages/*}", cwd).unwrap(); + assert!(!f.exclude); + assert_directory_glob(&f, abs("/workspace/packages"), "*"); + assert_traversal(&f, TraversalDirection::Dependents, false); + } + + #[test] + fn directory_glob_parent_prefix() { + // `../*` from a subdirectory should resolve base to parent + let cwd = abs("/workspace/packages/app"); + let f = parse_filter("../*", cwd).unwrap(); + assert_directory_glob(&f, abs("/workspace/packages"), "*"); + } + + #[test] + fn directory_glob_dotdot_in_base() { + // `../foo/*` — `..` in the non-glob base is normalised before glob matching. + // Matches Node's path.join('/ws/packages/app', '../foo/*') → '/ws/packages/foo/*'. + let cwd = abs("/workspace/packages/app"); + let f = parse_filter("../foo/*", cwd).unwrap(); + assert_directory_glob(&f, abs("/workspace/packages/foo"), "*"); + } } diff --git a/crates/vite_workspace/src/package_graph.rs b/crates/vite_workspace/src/package_graph.rs index d6c49f82..f579e452 100644 --- a/crates/vite_workspace/src/package_graph.rs +++ b/crates/vite_workspace/src/package_graph.rs @@ -24,7 +24,8 @@ use vite_str::Str; use crate::{ DependencyType, PackageInfo, PackageIx, PackageNodeIndex, package_filter::{ - GraphTraversal, PackageFilter, PackageNamePattern, PackageSelector, TraversalDirection, + DirectoryPattern, GraphTraversal, PackageFilter, PackageNamePattern, PackageSelector, + TraversalDirection, }, }; @@ -43,7 +44,7 @@ pub enum PackageQuery { /// /// Inclusions are unioned; exclusions are subtracted from the union. /// If all filters are exclusions, the starting set is the full workspace - /// (pnpm behaviour: exclude-only = full set minus exclusions). + /// pnpm ref: Filters(Vec1), /// All packages in the workspace, in topological dependency order. @@ -182,7 +183,9 @@ impl IndexedPackageGraph { /// Resolve a slice of package filters into a `FilterResolution`. /// - /// # Algorithm (follows pnpm's `filterWorkspacePackages`) + /// # Algorithm (follows pnpm's [`filterWorkspacePackages`]) + /// + /// [`filterWorkspacePackages`]: https://github.com/pnpm/pnpm/blob/491a84fb26fa716408bf6bd361680f6a450c61fc/workspace/filter-workspace-packages/src/index.ts#L149-L178 /// /// 1. Partition filters into inclusions (`!exclude = false`) and exclusions. /// 2. If there are no inclusions, start from ALL packages (exclude-only mode). @@ -237,13 +240,8 @@ impl IndexedPackageGraph { self.match_by_name_pattern(pattern, &mut matched); } - PackageSelector::Directory(dir) => { - // One-way isSubdir: package root must be at or under `dir`. - for idx in self.graph.node_indices() { - if self.graph[idx].absolute_path.as_path().starts_with(dir.as_path()) { - matched.insert(idx); - } - } + PackageSelector::Directory(dir_pattern) => { + self.match_by_directory_pattern(dir_pattern, &mut matched); } PackageSelector::ContainingPackage(path) => { @@ -257,11 +255,9 @@ impl IndexedPackageGraph { // Intersection: packages satisfying both name AND directory. let mut by_name = FxHashSet::default(); self.match_by_name_pattern(name, &mut by_name); - for idx in by_name { - if self.graph[idx].absolute_path.as_path().starts_with(directory.as_path()) { - matched.insert(idx); - } - } + let mut by_dir = FxHashSet::default(); + self.match_by_directory_pattern(directory, &mut by_dir); + matched.extend(by_name.intersection(&by_dir)); } } @@ -270,7 +266,8 @@ impl IndexedPackageGraph { /// Match packages by a name pattern, inserting into `out`. /// - /// For `Exact` names, scoped auto-completion applies (pnpm matcher lines 303–306): + /// For `Exact` names, scoped auto-completion applies + /// (pnpm ref: ): /// if `"bar"` has no exact match but exactly one `@*/bar` package exists, /// that package is used instead. fn match_by_name_pattern( @@ -311,6 +308,35 @@ impl IndexedPackageGraph { } } + /// Match packages by a directory pattern, inserting into `out`. + /// + /// pnpm ref: + fn match_by_directory_pattern( + &self, + pattern: &DirectoryPattern, + out: &mut FxHashSet, + ) { + match pattern { + DirectoryPattern::Exact(dir) => { + // O(1) exact lookup by path. + if let Some(&idx) = self.indices_by_path.get(dir) { + out.insert(idx); + } + } + DirectoryPattern::Glob { base, pattern } => { + use wax::Pattern as _; + for idx in self.graph.node_indices() { + let pkg_path = &self.graph[idx].absolute_path; + if let Ok(remainder) = pkg_path.as_path().strip_prefix(base.as_path()) + && pattern.is_match(remainder) + { + out.insert(idx); + } + } + } + } + } + /// Expand a seed set of packages according to a graph traversal specification. /// /// Returns the final set, including or excluding the original seeds depending on @@ -334,8 +360,9 @@ impl IndexedPackageGraph { self.bfs_incoming(&seeds, &mut reachable); } TraversalDirection::Both => { - // pnpm lines 265–267: walk dependents first, then walk dependencies of - // ALL dependents found (including the original seeds). + // Walk dependents first, then walk dependencies of ALL dependents found + // (including the original seeds). + // pnpm ref: let mut dependents = FxHashSet::default(); self.bfs_incoming(&seeds, &mut dependents); let all_dep_seeds: FxHashSet<_> = From ad94ba3f498dabd0b6c4dbd5972f652d54f63219 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Feb 2026 15:20:19 +0800 Subject: [PATCH 10/35] feat: split --filter values by whitespace for pnpm compatibility `--filter "a b"` is now equivalent to `--filter a --filter b`, matching pnpm's behavior of treating space-separated tokens within a single --filter value as independent selectors. Co-Authored-By: Claude Opus 4.6 --- crates/vite_task/src/cli/mod.rs | 18 +++++++++++++++--- .../fixtures/filter-workspace/snapshots.toml | 6 ++++++ ...filter space-separated in single value.snap | 9 +++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter space-separated in single value.snap diff --git a/crates/vite_task/src/cli/mod.rs b/crates/vite_task/src/cli/mod.rs index 3b400ccc..c6c8735a 100644 --- a/crates/vite_task/src/cli/mod.rs +++ b/crates/vite_task/src/cli/mod.rs @@ -169,6 +169,9 @@ pub enum CLITaskQueryError { #[error("cannot specify package '{package_name}' for task '{task_name}' with --filter")] PackageNameSpecifiedWithFilter { package_name: Str, task_name: Str }, + #[error("--filter value contains no selectors (whitespace-only)")] + EmptyFilter, + #[error("invalid --filter expression")] InvalidFilter(#[from] PackageFilterParseError), } @@ -208,8 +211,8 @@ impl ResolvedRunCommand { }); } PackageQuery::All - } else if let Ok(raw_filters) = Vec1::try_from_vec(filter) { - // `raw_filters: Vec1` — at least one --filter was specified. + } else if !filter.is_empty() { + // At least one --filter was specified. if transitive { return Err(CLITaskQueryError::FilterWithTransitive); } @@ -219,7 +222,16 @@ impl ResolvedRunCommand { task_name: task_specifier.task_name, }); } - let parsed: Vec1 = raw_filters.try_mapped(|f| parse_filter(&f, cwd))?; + // Normalize: split each --filter value by whitespace into individual tokens. + // This makes `--filter "a b"` equivalent to `--filter a --filter b` (pnpm behaviour). + let tokens: Vec1 = Vec1::try_from_vec( + filter + .into_iter() + .flat_map(|f| f.split_ascii_whitespace().map(Str::from).collect::>()) + .collect(), + ) + .map_err(|_| CLITaskQueryError::EmptyFilter)?; + let parsed: Vec1 = tokens.try_mapped(|f| parse_filter(&f, cwd))?; PackageQuery::Filters(parsed) } else { // No --filter, no --recursive: implicit cwd or package-name specifier. diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml index 6ce9255c..cd03b882 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml @@ -56,6 +56,12 @@ compact = true name = "multiple filters union" args = ["run", "--filter", "@test/app", "--filter", "@test/cli", "build"] +# pnpm: space-separated selectors in a single --filter value +[[plan]] +compact = true +name = "filter space-separated in single value" +args = ["run", "--filter", "@test/app @test/cli", "build"] + # pnpm: "select by parentDir and exclude one package by pattern" [[plan]] compact = true diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter space-separated in single value.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter space-separated in single value.snap new file mode 100644 index 00000000..03d0b0b7 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter space-separated in single value.snap @@ -0,0 +1,9 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [], + "packages/cli#build": [] +} From 07e6d87e88e7b9572a3835dd6f313caea9a88889 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Feb 2026 15:53:12 +0800 Subject: [PATCH 11/35] refactor: upgrade wax to 0.7.0 and use Glob::partition for directory patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade wax 0.6.0 → 0.7.0 and adapt to breaking changes: Pattern → Program trait, WalkError moved to wax::walk, walk() takes Into, Entry trait methods require explicit import. Replace manual base/glob splitting in resolve_directory_pattern with wax::Glob::partition(), and use the Option return to decide between Exact and Glob variants instead of a manual metacharacter check. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 19 +++-------- Cargo.toml | 2 +- crates/vite_glob/src/lib.rs | 2 +- crates/vite_workspace/src/error.rs | 2 +- crates/vite_workspace/src/lib.rs | 4 +-- crates/vite_workspace/src/package_filter.rs | 38 ++++++--------------- crates/vite_workspace/src/package_graph.rs | 2 +- 7 files changed, 22 insertions(+), 47 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 61779d15..063fedc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1590,7 +1590,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1605,15 +1605,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -4166,16 +4157,16 @@ dependencies = [ [[package]] name = "wax" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d12a78aa0bab22d2f26ed1a96df7ab58e8a93506a3e20adb47c51a93b4e1357" +checksum = "1f8cbf8125142b9b30321ac8721f54c52fbcd6659f76cf863d5e2e38c07a3d7b" dependencies = [ "const_format", - "itertools 0.11.0", + "itertools 0.14.0", "nom", "pori", "regex", - "thiserror 1.0.69", + "thiserror 2.0.18", "walkdir", ] diff --git a/Cargo.toml b/Cargo.toml index d9fcb3f1..132fcc27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -145,7 +145,7 @@ vite_task_graph = { path = "crates/vite_task_graph" } vite_task_plan = { path = "crates/vite_task_plan" } vite_workspace = { path = "crates/vite_workspace" } vt100 = "0.16.2" -wax = "0.6.0" +wax = "0.7.0" which = "8.0.0" widestring = "1.2.0" winapi = "0.3.9" diff --git a/crates/vite_glob/src/lib.rs b/crates/vite_glob/src/lib.rs index dc8b03b8..46753812 100644 --- a/crates/vite_glob/src/lib.rs +++ b/crates/vite_glob/src/lib.rs @@ -4,7 +4,7 @@ mod error; use std::path::Path; pub use error::Error; -use wax::{Glob, Pattern}; +use wax::{Glob, Program}; /// If there are no negated patterns, it will follow the first match wins semantics. /// Otherwise, it will follow the last match wins semantics. diff --git a/crates/vite_workspace/src/error.rs b/crates/vite_workspace/src/error.rs index c923fed2..fcbc6da9 100644 --- a/crates/vite_workspace/src/error.rs +++ b/crates/vite_workspace/src/error.rs @@ -53,7 +53,7 @@ pub enum Error { WaxBuild(#[from] wax::BuildError), #[error(transparent)] - WaxWalk(#[from] wax::WalkError), + WaxWalk(#[from] wax::walk::WalkError), #[error(transparent)] Glob(#[from] vite_glob::Error), diff --git a/crates/vite_workspace/src/lib.rs b/crates/vite_workspace/src/lib.rs index 74caab58..fbe5f0cf 100644 --- a/crates/vite_workspace/src/lib.rs +++ b/crates/vite_workspace/src/lib.rs @@ -13,7 +13,7 @@ use vec1::smallvec_v1::SmallVec1; use vite_glob::GlobPatternSet; use vite_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf}; use vite_str::Str; -use wax::Glob; +use wax::{Glob, walk::Entry as _}; pub use crate::{ error::Error, @@ -78,7 +78,7 @@ impl WorkspaceMemberGlobs { // TODO: parallelize this for inclusion in inclusions { let glob = Glob::new(&inclusion)?; - for entry in glob.walk(workspace_root) { + for entry in glob.walk(workspace_root.as_path().to_path_buf()) { let Ok(entry) = entry else { continue; }; diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index 8b625c38..18ed843e 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -288,38 +288,22 @@ fn parse_core_selector( /// Resolve a directory selector string into a [`DirectoryPattern`]. /// -/// If the string contains glob metacharacters (`*`, `?`, `[`), it is split at the -/// first glob component into a resolved base path and a relative glob pattern. -/// Otherwise, the entire string is resolved as an exact path. +/// Uses [`wax::Glob::partition`] to split into an invariant base path and an +/// optional glob pattern. If `partition` yields no pattern, the result is an +/// exact path match; otherwise it is a glob match. fn resolve_directory_pattern( path_str: &str, cwd: &AbsolutePath, ) -> Result { - if !path_str.contains(['*', '?', '[']) { - return Ok(DirectoryPattern::Exact(resolve_filter_path(path_str, cwd))); - } - - // Split into non-glob base components and glob suffix components. - let mut base_parts: Vec<&str> = Vec::new(); - let mut glob_parts: Vec<&str> = Vec::new(); - let mut found_glob = false; + let glob = wax::Glob::new(path_str)?.into_owned(); + let (base_pathbuf, pattern) = glob.partition(); + let base_str = base_pathbuf.to_str().expect("filter paths are always valid UTF-8"); + let base = resolve_filter_path(if base_str.is_empty() { "." } else { base_str }, cwd); - for part in path_str.split('/') { - if !found_glob && !part.contains(['*', '?', '[']) { - base_parts.push(part); - } else { - found_glob = true; - glob_parts.push(part); - } + match pattern { + Some(pattern) => Ok(DirectoryPattern::Glob { base, pattern: Box::new(pattern) }), + None => Ok(DirectoryPattern::Exact(base)), } - - let base_str = if base_parts.is_empty() { "." } else { &base_parts.join("/") }; - let base = resolve_filter_path(base_str, cwd); - - let glob_str = glob_parts.join("/"); - let pattern = wax::Glob::new(&glob_str)?.into_owned(); - - Ok(DirectoryPattern::Glob { base, pattern: Box::new(pattern) }) } /// Resolve a path string relative to `cwd`, normalising away `.` and `..`. @@ -377,7 +361,7 @@ impl PackageNamePattern { match self { Self::Exact(n) => n.as_str() == name, Self::Glob(glob) => { - use wax::Pattern as _; + use wax::Program as _; glob.is_match(name) } } diff --git a/crates/vite_workspace/src/package_graph.rs b/crates/vite_workspace/src/package_graph.rs index f579e452..85aab35f 100644 --- a/crates/vite_workspace/src/package_graph.rs +++ b/crates/vite_workspace/src/package_graph.rs @@ -324,7 +324,7 @@ impl IndexedPackageGraph { } } DirectoryPattern::Glob { base, pattern } => { - use wax::Pattern as _; + use wax::Program as _; for idx in self.graph.node_indices() { let pkg_path = &self.graph[idx].absolute_path; if let Ok(remainder) = pkg_path.as_path().strip_prefix(base.as_path()) From f2fc4e63854d47619a9dddd9982388d6e90d922d Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Feb 2026 16:05:38 +0800 Subject: [PATCH 12/35] test: add directory filter plan tests for traversal and nested scripts Add plan snapshot tests for: - directory filter with dependency expansion (./packages/app...) - dot with deps from package cwd (.... from packages/app) - dotdot with deps from subdir (..... from packages/app/src) - nested vp run --filter in package script (deploy expands inline) Co-Authored-By: Claude Opus 4.6 --- .../packages/app/package.json | 3 +- .../fixtures/filter-workspace/snapshots.toml | 26 +++++++++++++++++ ...filter by directory with dependencies.snap | 16 +++++++++++ ...er dot with dependencies from package.snap | 16 +++++++++++ ...ery - filter dotdot with dependencies.snap | 16 +++++++++++ ...- nested vp run with filter in script.snap | 23 +++++++++++++++ .../snapshots/task graph.snap | 28 +++++++++++++++++++ 7 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory with dependencies.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dot with dependencies from package.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dotdot with dependencies.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - nested vp run with filter in script.snap diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/app/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/app/package.json index a9800c99..4e0b7507 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/app/package.json +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/app/package.json @@ -3,7 +3,8 @@ "version": "1.0.0", "scripts": { "build": "echo 'Building @test/app'", - "test": "echo 'Testing @test/app'" + "test": "echo 'Testing @test/app'", + "deploy": "vp run --filter .... build" }, "dependencies": { "@test/lib": "workspace:*" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml index cd03b882..697b9639 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml @@ -80,6 +80,26 @@ compact = true name = "filter by directory" args = ["run", "--filter", "./packages/app", "build"] +# directory filter with dependency expansion +[[plan]] +compact = true +name = "filter by directory with dependencies" +args = ["run", "--filter", "./packages/app...", "build"] + +# --filter .... = . (cwd) + ... (deps) — from inside a package directory +[[plan]] +compact = true +name = "filter dot with dependencies from package" +args = ["run", "--filter", "....", "build"] +cwd = "packages/app" + +# --filter ..... = .. (parent dir) + ... (deps) — from inside a package subdir +[[plan]] +compact = true +name = "filter dotdot with dependencies" +args = ["run", "--filter", ".....", "build"] +cwd = "packages/app/src" + # pnpm glob-dir: one level under packages/ [[plan]] compact = true @@ -132,3 +152,9 @@ args = ["run", "--filter", "@test/app...", "--filter", "@test/cli", "build"] compact = true name = "filter deps skips packages without task" args = ["run", "--filter", "@test/app...", "test"] + +# script containing "vp run --filter .... build" — expanded in plan +[[plan]] +compact = true +name = "nested vp run with filter in script" +args = ["run", "--filter", "@test/app", "deploy"] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory with dependencies.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory with dependencies.snap new file mode 100644 index 00000000..4806b35d --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory with dependencies.snap @@ -0,0 +1,16 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build", + "packages/utils#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ], + "packages/utils#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dot with dependencies from package.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dot with dependencies from package.snap new file mode 100644 index 00000000..4806b35d --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dot with dependencies from package.snap @@ -0,0 +1,16 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build", + "packages/utils#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ], + "packages/utils#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dotdot with dependencies.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dotdot with dependencies.snap new file mode 100644 index 00000000..4806b35d --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dotdot with dependencies.snap @@ -0,0 +1,16 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build", + "packages/utils#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ], + "packages/utils#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - nested vp run with filter in script.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - nested vp run with filter in script.snap new file mode 100644 index 00000000..dab393e9 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - nested vp run with filter in script.snap @@ -0,0 +1,23 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#deploy": { + "items": [ + { + "packages/app#build": [ + "packages/lib#build", + "packages/utils#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ], + "packages/utils#build": [] + } + ], + "neighbors": [] + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/task graph.snap index 53eddd64..55372bf2 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/task graph.snap @@ -32,6 +32,34 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace }, "neighbors": [] }, + { + "key": [ + "/packages/app", + "deploy" + ], + "node": { + "task_display": { + "package_name": "@test/app", + "task_name": "deploy", + "package_path": "/packages/app" + }, + "resolved_config": { + "command": "vp run --filter .... build", + "resolved_options": { + "cwd": "/packages/app", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + } + }, + "neighbors": [] + }, { "key": [ "/packages/app", From ab715943235f7a2509d40fee02cfe2ff603da3b6 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Feb 2026 16:25:57 +0800 Subject: [PATCH 13/35] refactor: replace manual path normalization with path-clean crate Use `path_clean::clean()` (Plan 9 cleanname / Go path.Clean port) for lexical `.`/`..` resolution instead of the manual `normalize_absolute_path` function. Add direct unit tests for `resolve_directory_pattern`. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 7 + Cargo.toml | 1 + crates/vite_workspace/Cargo.toml | 1 + crates/vite_workspace/src/package_filter.rs | 152 ++++++++++++++------ 4 files changed, 121 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 063fedc2..8b5e1f3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2253,6 +2253,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "path-clean" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef" + [[package]] name = "pathdiff" version = "0.2.3" @@ -3998,6 +4004,7 @@ dependencies = [ name = "vite_workspace" version = "0.0.0" dependencies = [ + "path-clean", "petgraph", "rustc-hash", "serde", diff --git a/Cargo.toml b/Cargo.toml index 132fcc27..70c6321e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,7 @@ os_str_bytes = "7.1.1" ouroboros = "0.18.5" owo-colors = { version = "4.1.0", features = ["supports-colors"] } passfd = { git = "https://github.com/polachok/passfd", rev = "d55881752c16aced1a49a75f9c428d38d3767213", default-features = false } +path-clean = "1.0.1" pathdiff = "0.2.3" petgraph = "0.8.2" phf = { version = "0.11.3", features = ["macros"] } diff --git a/crates/vite_workspace/Cargo.toml b/crates/vite_workspace/Cargo.toml index f11ce44a..ef2aae6d 100644 --- a/crates/vite_workspace/Cargo.toml +++ b/crates/vite_workspace/Cargo.toml @@ -8,6 +8,7 @@ publish = false rust-version.workspace = true [dependencies] +path-clean = { workspace = true } petgraph = { workspace = true, features = ["serde-1"] } rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index 18ed843e..d28007a7 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::{path::Component, sync::Arc}; +use std::sync::Arc; use vite_path::{AbsolutePath, AbsolutePathBuf}; use vite_str::Str; @@ -309,49 +309,19 @@ fn resolve_directory_pattern( /// Resolve a path string relative to `cwd`, normalising away `.` and `..`. /// /// `path_str` may be `"."`, `".."`, `"./foo"`, `"../foo"`, or a bare name like `"packages/app"`. -fn resolve_filter_path(path_str: &str, cwd: &AbsolutePath) -> Arc { - // `AbsolutePath::join` delegates to `PathBuf::push`, which does not normalise - // `.` or `..` components. We must normalise manually so that the resulting path - // compares equal to the package root paths stored in `IndexedPackageGraph`. - let raw = cwd.join(path_str); - normalize_absolute_path(&raw).into() -} - -/// Normalise an [`AbsolutePath`] by resolving `.` and `..` components lexically. /// -/// Does NOT hit the filesystem. If `..` would go above the root, -/// the pop is silently ignored (empty stack stays empty). +/// Uses lexical normalization (no filesystem access), which can produce incorrect +/// results when symlinks are involved (e.g. `/a/symlink/../b` → `/a/b`). This +/// matches pnpm's behaviour. #[expect( clippy::disallowed_types, - reason = "PathBuf used as temporary builder; only AbsolutePathBuf is returned" + reason = "PathBuf returned by path_clean::clean; only AbsolutePathBuf is kept" )] -fn normalize_absolute_path(path: &AbsolutePath) -> AbsolutePathBuf { - let mut result = std::path::PathBuf::new(); - // Collect normal components into an owned Vec so we can pop `..` safely - // without worrying about borrowing `path` while mutating `result`. - let mut normal_parts: Vec = Vec::new(); - - for component in path.as_path().components() { - match component { - // Root and prefix always appear first; push them directly. - Component::RootDir | Component::Prefix(_) => result.push(component), - Component::Normal(name) => normal_parts.push(name.to_os_string()), - Component::ParentDir => { - // Pop the last normal component; never underflow past root. - let _ = normal_parts.pop(); - } - Component::CurDir => {} // skip `.` - } - } - - for name in normal_parts { - result.push(name); - } - - // SAFETY: The input was an AbsolutePathBuf (is_absolute() = true). - // Removing `.` and `..` from an absolute path cannot make it relative. - AbsolutePathBuf::new(result) - .expect("invariant: normalising an absolute path preserves absoluteness") +fn resolve_filter_path(path_str: &str, cwd: &AbsolutePath) -> Arc { + let cleaned = path_clean::clean(cwd.join(path_str).as_path()); + let normalized = AbsolutePathBuf::new(cleaned) + .expect("invariant: cleaning an absolute path preserves absoluteness"); + normalized.into() } impl PackageNamePattern { @@ -723,4 +693,106 @@ mod tests { let f = parse_filter("../foo/*", cwd).unwrap(); assert_directory_glob(&f, abs("/workspace/packages/foo"), "*"); } + + // ── Direct resolve_directory_pattern tests ────────────────────────────── + + #[test] + fn dir_pattern_plain_path() { + let cwd = abs("/workspace"); + let dp = resolve_directory_pattern("./packages/app", cwd).unwrap(); + assert!( + matches!(&dp, DirectoryPattern::Exact(p) if p.as_ref() == abs("/workspace/packages/app")) + ); + } + + #[test] + fn dir_pattern_dot() { + let cwd = abs("/workspace/packages/app"); + let dp = resolve_directory_pattern(".", cwd).unwrap(); + assert!( + matches!(&dp, DirectoryPattern::Exact(p) if p.as_ref() == abs("/workspace/packages/app")) + ); + } + + #[test] + fn dir_pattern_dotdot() { + let cwd = abs("/workspace/packages/app"); + let dp = resolve_directory_pattern("..", cwd).unwrap(); + assert!( + matches!(&dp, DirectoryPattern::Exact(p) if p.as_ref() == abs("/workspace/packages")) + ); + } + + #[test] + fn dir_pattern_normalises_dotdot_in_middle() { + let cwd = abs("/workspace"); + let dp = resolve_directory_pattern("./foo/../bar", cwd).unwrap(); + assert!(matches!(&dp, DirectoryPattern::Exact(p) if p.as_ref() == abs("/workspace/bar"))); + } + + #[test] + fn dir_pattern_glob_star() { + let cwd = abs("/workspace"); + let dp = resolve_directory_pattern("./packages/*", cwd).unwrap(); + match &dp { + DirectoryPattern::Glob { base, pattern } => { + assert_eq!(base.as_ref(), abs("/workspace/packages")); + assert_eq!(pattern.to_string(), "*"); + } + other => panic!("expected Glob, got {other:?}"), + } + } + + #[test] + fn dir_pattern_glob_double_star() { + let cwd = abs("/workspace"); + let dp = resolve_directory_pattern("./packages/**", cwd).unwrap(); + match &dp { + DirectoryPattern::Glob { base, pattern } => { + assert_eq!(base.as_ref(), abs("/workspace/packages")); + assert_eq!(pattern.to_string(), "**"); + } + other => panic!("expected Glob, got {other:?}"), + } + } + + #[test] + fn dir_pattern_bare_glob_star() { + // `*` with no path prefix — base should resolve to cwd + let cwd = abs("/workspace"); + let dp = resolve_directory_pattern("*", cwd).unwrap(); + match &dp { + DirectoryPattern::Glob { base, pattern } => { + assert_eq!(base.as_ref(), abs("/workspace")); + assert_eq!(pattern.to_string(), "*"); + } + other => panic!("expected Glob, got {other:?}"), + } + } + + #[test] + fn dir_pattern_dotdot_before_glob() { + let cwd = abs("/workspace/packages/app"); + let dp = resolve_directory_pattern("../*", cwd).unwrap(); + match &dp { + DirectoryPattern::Glob { base, pattern } => { + assert_eq!(base.as_ref(), abs("/workspace/packages")); + assert_eq!(pattern.to_string(), "*"); + } + other => panic!("expected Glob, got {other:?}"), + } + } + + #[test] + fn dir_pattern_nested_glob() { + let cwd = abs("/workspace"); + let dp = resolve_directory_pattern("./packages/*/src", cwd).unwrap(); + match &dp { + DirectoryPattern::Glob { base, pattern } => { + assert_eq!(base.as_ref(), abs("/workspace/packages")); + assert_eq!(pattern.to_string(), "*/src"); + } + other => panic!("expected Glob, got {other:?}"), + } + } } From 47eea2f5d006d1195c013a703e93d48ddba97815 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Feb 2026 16:31:10 +0800 Subject: [PATCH 14/35] refactor: remove unused PackageNamePattern::matches_name, inline glob match Co-Authored-By: Claude Opus 4.6 --- crates/vite_workspace/src/package_filter.rs | 14 -------------- crates/vite_workspace/src/package_graph.rs | 5 +++-- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index d28007a7..e711bf89 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -324,20 +324,6 @@ fn resolve_filter_path(path_str: &str, cwd: &AbsolutePath) -> Arc normalized.into() } -impl PackageNamePattern { - /// Returns `true` if this pattern matches the given package name. - #[must_use] - pub fn matches_name(&self, name: &str) -> bool { - match self { - Self::Exact(n) => n.as_str() == name, - Self::Glob(glob) => { - use wax::Program as _; - glob.is_match(name) - } - } - } -} - /// Build a [`PackageNamePattern`] from a name or glob string. /// /// A string containing `*`, `?`, or `[` is treated as a glob; otherwise exact. diff --git a/crates/vite_workspace/src/package_graph.rs b/crates/vite_workspace/src/package_graph.rs index 85aab35f..384eea6e 100644 --- a/crates/vite_workspace/src/package_graph.rs +++ b/crates/vite_workspace/src/package_graph.rs @@ -298,9 +298,10 @@ impl IndexedPackageGraph { } } - PackageNamePattern::Glob(_) => { + PackageNamePattern::Glob(glob) => { + use wax::Program as _; for (pkg_name, indices) in &self.indices_by_name { - if pattern.matches_name(pkg_name.as_str()) { + if glob.is_match(pkg_name.as_str()) { out.extend(indices.iter().copied()); } } From 13d7fd25ca828a8c6d79c0f0189391f1d995be4b Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Feb 2026 17:14:49 +0800 Subject: [PATCH 15/35] test: update filter snapshots for new info headers Co-Authored-By: Claude Opus 4.6 --- ... - cherry picked filters respect dependency order.snap | 8 ++++++++ .../snapshots/query - exclude only filter.snap | 6 ++++++ .../query - filter both deps and dependents.snap | 6 ++++++ .../query - filter by directory glob double star.snap | 6 ++++++ .../snapshots/query - filter by directory glob star.snap | 6 ++++++ .../query - filter by directory with dependencies.snap | 6 ++++++ .../snapshots/query - filter by directory.snap | 6 ++++++ .../snapshots/query - filter by exact name.snap | 6 ++++++ .../snapshots/query - filter by glob.snap | 6 ++++++ .../query - filter dependencies only exclude self.snap | 6 ++++++ .../query - filter dependents only exclude self.snap | 6 ++++++ .../query - filter deps skips packages without task.snap | 6 ++++++ ...query - filter dot with dependencies from package.snap | 7 +++++++ .../query - filter dotdot with dependencies.snap | 7 +++++++ .../snapshots/query - filter include and exclude.snap | 8 ++++++++ .../query - filter space-separated in single value.snap | 6 ++++++ .../snapshots/query - filter with dependencies.snap | 6 ++++++ .../snapshots/query - filter with dependents.snap | 6 ++++++ .../snapshots/query - mixed traversal filters.snap | 8 ++++++++ .../snapshots/query - multiple filters union.snap | 8 ++++++++ .../query - nested vp run with filter in script.snap | 6 ++++++ .../snapshots/query - recursive flag.snap | 5 +++++ .../query - transitive flag from package subfolder.snap | 6 ++++++ .../snapshots/query - transitive flag.snap | 6 ++++++ .../query - transitive with package specifier.snap | 5 +++++ ...query - filter entry lacks task dependency has it.snap | 6 ++++++ ...query - filter intermediate lacks task is skipped.snap | 6 ++++++ ... transitive build skips intermediate without task.snap | 6 ++++++ .../query - transitive from package without task.snap | 6 ++++++ ... - transitive with package specifier lacking task.snap | 5 +++++ 30 files changed, 187 insertions(+) diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - cherry picked filters respect dependency order.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - cherry picked filters respect dependency order.snap index 75f88e7d..54be2e45 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - cherry picked filters respect dependency order.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - cherry picked filters respect dependency order.snap @@ -1,6 +1,14 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "@test/app" + - "--filter" + - "@test/lib" + - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - exclude only filter.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - exclude only filter.snap index 72eb3601..06b4dd87 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - exclude only filter.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - exclude only filter.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "!@test/core" + - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter both deps and dependents.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter both deps and dependents.snap index 4806b35d..e88d60fd 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter both deps and dependents.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter both deps and dependents.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "...@test/lib..." + - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory glob double star.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory glob double star.snap index 5aa8fad6..b803c2a0 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory glob double star.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory glob double star.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "./packages/**" + - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory glob star.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory glob star.snap index 5aa8fad6..acb90629 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory glob star.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory glob star.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "./packages/*" + - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory with dependencies.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory with dependencies.snap index 4806b35d..5fbe76ad 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory with dependencies.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory with dependencies.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "./packages/app..." + - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory.snap index 62c7a613..a7e2ac9e 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "./packages/app" + - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by exact name.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by exact name.snap index 62c7a613..39dd7de2 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by exact name.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by exact name.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "@test/app" + - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by glob.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by glob.snap index 5aa8fad6..8ede9303 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by glob.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by glob.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "@test/*" + - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dependencies only exclude self.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dependencies only exclude self.snap index 3493f491..1dd1570e 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dependencies only exclude self.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dependencies only exclude self.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "@test/app^..." + - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dependents only exclude self.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dependents only exclude self.snap index 9afa8f01..885bc6bc 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dependents only exclude self.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dependents only exclude self.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "...^@test/core" + - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter deps skips packages without task.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter deps skips packages without task.snap index e5a29035..13639a07 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter deps skips packages without task.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter deps skips packages without task.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "@test/app..." + - test input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dot with dependencies from package.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dot with dependencies from package.snap index 4806b35d..815afccc 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dot with dependencies from package.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dot with dependencies from package.snap @@ -1,6 +1,13 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "...." + - build + cwd: packages/app input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dotdot with dependencies.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dotdot with dependencies.snap index 4806b35d..46dab52e 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dotdot with dependencies.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dotdot with dependencies.snap @@ -1,6 +1,13 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "....." + - build + cwd: packages/app/src input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter include and exclude.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter include and exclude.snap index 0ac97023..f987328b 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter include and exclude.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter include and exclude.snap @@ -1,6 +1,14 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "@test/app..." + - "--filter" + - "!@test/utils" + - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter space-separated in single value.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter space-separated in single value.snap index 03d0b0b7..2827cc22 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter space-separated in single value.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter space-separated in single value.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "@test/app @test/cli" + - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter with dependencies.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter with dependencies.snap index 4806b35d..8e0d9be8 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter with dependencies.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter with dependencies.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "@test/app..." + - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter with dependents.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter with dependents.snap index fe5e425a..5576ed45 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter with dependents.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter with dependents.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "...@test/core" + - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - mixed traversal filters.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - mixed traversal filters.snap index 5aa8fad6..4a2ac5d9 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - mixed traversal filters.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - mixed traversal filters.snap @@ -1,6 +1,14 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "@test/app..." + - "--filter" + - "@test/cli" + - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - multiple filters union.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - multiple filters union.snap index 03d0b0b7..64f5f14c 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - multiple filters union.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - multiple filters union.snap @@ -1,6 +1,14 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "@test/app" + - "--filter" + - "@test/cli" + - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - nested vp run with filter in script.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - nested vp run with filter in script.snap index dab393e9..ed6e7486 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - nested vp run with filter in script.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - nested vp run with filter in script.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "@test/app" + - deploy input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - recursive flag.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - recursive flag.snap index 5aa8fad6..6be57072 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - recursive flag.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - recursive flag.snap @@ -1,6 +1,11 @@ --- 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/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive flag from package subfolder.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive flag from package subfolder.snap index 4806b35d..fa64212e 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive flag from package subfolder.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive flag from package subfolder.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "-t" + - build + cwd: packages/app/src/components input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive flag.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive flag.snap index 4806b35d..cc10050d 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive flag.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive flag.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "-t" + - build + cwd: packages/app input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive with package specifier.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive with package specifier.snap index 4806b35d..3adf1aa0 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive with package specifier.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive with package specifier.snap @@ -1,6 +1,11 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "-t" + - "@test/app#build" input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - filter entry lacks task dependency has it.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - filter entry lacks task dependency has it.snap index 82ec7abf..ebd35c8c 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - filter entry lacks task dependency has it.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - filter entry lacks task dependency has it.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "@test/middle..." + - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - filter intermediate lacks task is skipped.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - filter intermediate lacks task is skipped.snap index b364d5bd..384b5b11 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - filter intermediate lacks task is skipped.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - filter intermediate lacks task is skipped.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "@test/top..." + - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive build skips intermediate without task.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive build skips intermediate without task.snap index b364d5bd..396cec4e 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive build skips intermediate without task.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive build skips intermediate without task.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "-t" + - build + cwd: packages/top input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive from package without task.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive from package without task.snap index 82ec7abf..6dd0d91b 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive from package without task.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive from package without task.snap @@ -1,6 +1,12 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "-t" + - build + cwd: packages/middle input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate --- { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive with package specifier lacking task.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive with package specifier lacking task.snap index 82ec7abf..0c93f028 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive with package specifier lacking task.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/query - transitive with package specifier lacking task.snap @@ -1,6 +1,11 @@ --- source: crates/vite_task_plan/tests/plan_snapshots/main.rs expression: "&compact_plan" +info: + args: + - run + - "-t" + - "@test/middle#build" input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate --- { From c540e79afc752be092c866ca02481d2968a0d58e Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Feb 2026 22:21:47 +0800 Subject: [PATCH 16/35] fix: resolve CI failures in package_filter tests and clippy - Remove unused `vec1` and `clap` deps from vite_task_graph (cargo shear) - Remove unfulfilled `#[expect(clippy::disallowed_types)]` on resolve_filter_path - Replace wildcard match arms with explicit `DirectoryPattern::Exact` variant - Make test `abs()` helper cross-platform by prepending `C:` on Windows Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 2 -- crates/vite_task_graph/Cargo.toml | 2 -- crates/vite_workspace/src/package_filter.rs | 35 ++++++++++++++------- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b5e1f3b..8d480327 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3928,7 +3928,6 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "clap", "monostate", "petgraph", "pretty_assertions", @@ -3937,7 +3936,6 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "ts-rs", - "vec1", "vite_graph_ser", "vite_path", "vite_str", diff --git a/crates/vite_task_graph/Cargo.toml b/crates/vite_task_graph/Cargo.toml index 9d0332cb..b8552acf 100644 --- a/crates/vite_task_graph/Cargo.toml +++ b/crates/vite_task_graph/Cargo.toml @@ -9,14 +9,12 @@ rust-version.workspace = true [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } -clap = { workspace = true, features = ["derive"] } monostate = { workspace = true } petgraph = { workspace = true } rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } -vec1 = { workspace = true, features = ["smallvec-v1"] } vite_graph_ser = { workspace = true } vite_path = { workspace = true } vite_str = { workspace = true } diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index e711bf89..4a0982df 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -313,10 +313,6 @@ fn resolve_directory_pattern( /// Uses lexical normalization (no filesystem access), which can produce incorrect /// results when symlinks are involved (e.g. `/a/symlink/../b` → `/a/b`). This /// matches pnpm's behaviour. -#[expect( - clippy::disallowed_types, - reason = "PathBuf returned by path_clean::clean; only AbsolutePathBuf is kept" -)] fn resolve_filter_path(path_str: &str, cwd: &AbsolutePath) -> Arc { let cleaned = path_clean::clean(cwd.join(path_str).as_path()); let normalized = AbsolutePathBuf::new(cleaned) @@ -345,9 +341,26 @@ fn build_name_pattern(name: &str) -> Result &'static AbsolutePath { - AbsolutePath::new(path).expect("test path must be absolute") + #[cfg(unix)] + { + AbsolutePath::new(path).expect("test path must be absolute") + } + #[cfg(windows)] + { + let leaked = Box::leak(std::format!("C:{path}").into_boxed_str()); + AbsolutePath::new(leaked).expect("test path must be absolute") + } } // ── Helpers to assert selector shapes ─────────────────────────────────── @@ -725,7 +738,7 @@ mod tests { assert_eq!(base.as_ref(), abs("/workspace/packages")); assert_eq!(pattern.to_string(), "*"); } - other => panic!("expected Glob, got {other:?}"), + DirectoryPattern::Exact(p) => panic!("expected Glob, got Exact({p:?})"), } } @@ -738,7 +751,7 @@ mod tests { assert_eq!(base.as_ref(), abs("/workspace/packages")); assert_eq!(pattern.to_string(), "**"); } - other => panic!("expected Glob, got {other:?}"), + DirectoryPattern::Exact(p) => panic!("expected Glob, got Exact({p:?})"), } } @@ -752,7 +765,7 @@ mod tests { assert_eq!(base.as_ref(), abs("/workspace")); assert_eq!(pattern.to_string(), "*"); } - other => panic!("expected Glob, got {other:?}"), + DirectoryPattern::Exact(p) => panic!("expected Glob, got Exact({p:?})"), } } @@ -765,7 +778,7 @@ mod tests { assert_eq!(base.as_ref(), abs("/workspace/packages")); assert_eq!(pattern.to_string(), "*"); } - other => panic!("expected Glob, got {other:?}"), + DirectoryPattern::Exact(p) => panic!("expected Glob, got Exact({p:?})"), } } @@ -778,7 +791,7 @@ mod tests { assert_eq!(base.as_ref(), abs("/workspace/packages")); assert_eq!(pattern.to_string(), "*/src"); } - other => panic!("expected Glob, got {other:?}"), + DirectoryPattern::Exact(p) => panic!("expected Glob, got Exact({p:?})"), } } } From b18c2fa97120ffe5c41d5ad140eecc9541f25419 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sat, 28 Feb 2026 12:35:56 +0800 Subject: [PATCH 17/35] refactor: extract PackageQueryArgs from CLI into vite_workspace Move package-query CLI fields (recursive, transitive, filter) and their validation logic from vite_task::cli::RunFlags into a new PackageQueryArgs struct in vite_workspace::package_filter. This makes the package-query concept self-contained next to PackageQuery and PackageFilter. Also make PackageQuery opaque (pub struct wrapping pub(crate) enum) and make PackageFilter, PackageSelector, and related sub-types pub(crate) so they are no longer part of the public API. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + crates/vite_task/src/cli/mod.rs | 113 ++-------------- crates/vite_workspace/Cargo.toml | 1 + crates/vite_workspace/src/package_filter.rs | 142 ++++++++++++++++++-- crates/vite_workspace/src/package_graph.rs | 30 ++++- 5 files changed, 166 insertions(+), 121 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8d480327..56cedfb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4002,6 +4002,7 @@ dependencies = [ name = "vite_workspace" version = "0.0.0" dependencies = [ + "clap", "path-clean", "petgraph", "rustc-hash", diff --git a/crates/vite_task/src/cli/mod.rs b/crates/vite_task/src/cli/mod.rs index c6c8735a..c6dc5ab2 100644 --- a/crates/vite_task/src/cli/mod.rs +++ b/crates/vite_task/src/cli/mod.rs @@ -1,18 +1,11 @@ use std::sync::Arc; use clap::Parser; -use vec1::Vec1; use vite_path::AbsolutePath; use vite_str::Str; -use vite_task_graph::{ - TaskSpecifier, - query::{PackageQuery, TaskQuery}, -}; +use vite_task_graph::{TaskSpecifier, query::TaskQuery}; use vite_task_plan::plan_request::{PlanOptions, QueryPlanRequest}; -use vite_workspace::package_filter::{ - GraphTraversal, PackageFilter, PackageFilterParseError, PackageNamePattern, PackageSelector, - TraversalDirection, parse_filter, -}; +use vite_workspace::package_filter::{PackageQueryArgs, PackageQueryError}; #[derive(Debug, Clone, clap::Subcommand)] pub enum CacheSubcommand { @@ -22,15 +15,9 @@ pub enum CacheSubcommand { /// Flags that control how a `run` command selects tasks. #[derive(Debug, Clone, clap::Args)] -#[expect(clippy::struct_excessive_bools, reason = "CLI flags are naturally boolean")] pub struct RunFlags { - /// Run tasks found in all packages in the workspace, in topological order based on package dependencies. - #[clap(default_value = "false", short, long)] - pub recursive: bool, - - /// Run tasks found in the current package and all its transitive dependencies, in topological order based on package dependencies. - #[clap(default_value = "false", short, long)] - pub transitive: bool, + #[clap(flatten)] + pub package_query: PackageQueryArgs, /// Do not run dependencies specified in `dependsOn` fields. #[clap(default_value = "false", long)] @@ -39,10 +26,6 @@ pub struct RunFlags { /// Show full detailed summary after execution. #[clap(default_value = "false", short = 'v', long)] pub verbose: bool, - - /// Filter packages (pnpm --filter syntax). Can be specified multiple times. - #[clap(short = 'F', long, num_args = 1)] - pub filter: Vec, } // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -154,26 +137,8 @@ pub enum CLITaskQueryError { #[error("no task specifier provided")] MissingTaskSpecifier, - #[error("--recursive and --transitive cannot be used together")] - RecursiveTransitiveConflict, - - #[error("cannot specify package '{package_name}' for task '{task_name}' with --recursive")] - PackageNameSpecifiedWithRecursive { package_name: Str, task_name: Str }, - - #[error("--filter and --transitive cannot be used together")] - FilterWithTransitive, - - #[error("--filter and --recursive cannot be used together")] - FilterWithRecursive, - - #[error("cannot specify package '{package_name}' for task '{task_name}' with --filter")] - PackageNameSpecifiedWithFilter { package_name: Str, task_name: Str }, - - #[error("--filter value contains no selectors (whitespace-only)")] - EmptyFilter, - - #[error("invalid --filter expression")] - InvalidFilter(#[from] PackageFilterParseError), + #[error(transparent)] + PackageQuery(#[from] PackageQueryError), } impl ResolvedRunCommand { @@ -187,68 +152,12 @@ impl ResolvedRunCommand { self, cwd: &Arc, ) -> Result { - let Self { - task_specifier, - flags: RunFlags { recursive, transitive, ignore_depends_on, filter, .. }, - additional_args, - } = self; - - let task_specifier = task_specifier.ok_or(CLITaskQueryError::MissingTaskSpecifier)?; + let task_specifier = self.task_specifier.ok_or(CLITaskQueryError::MissingTaskSpecifier)?; - let include_explicit_deps = !ignore_depends_on; + let package_query = + self.flags.package_query.into_package_query(task_specifier.package_name, cwd)?; - let package_query = if recursive { - if transitive { - return Err(CLITaskQueryError::RecursiveTransitiveConflict); - } - if !filter.is_empty() { - return Err(CLITaskQueryError::FilterWithRecursive); - } - if let Some(package_name) = task_specifier.package_name { - return Err(CLITaskQueryError::PackageNameSpecifiedWithRecursive { - package_name, - task_name: task_specifier.task_name, - }); - } - PackageQuery::All - } else if !filter.is_empty() { - // At least one --filter was specified. - if transitive { - return Err(CLITaskQueryError::FilterWithTransitive); - } - if let Some(package_name) = task_specifier.package_name { - return Err(CLITaskQueryError::PackageNameSpecifiedWithFilter { - package_name, - task_name: task_specifier.task_name, - }); - } - // Normalize: split each --filter value by whitespace into individual tokens. - // This makes `--filter "a b"` equivalent to `--filter a --filter b` (pnpm behaviour). - let tokens: Vec1 = Vec1::try_from_vec( - filter - .into_iter() - .flat_map(|f| f.split_ascii_whitespace().map(Str::from).collect::>()) - .collect(), - ) - .map_err(|_| CLITaskQueryError::EmptyFilter)?; - let parsed: Vec1 = tokens.try_mapped(|f| parse_filter(&f, cwd))?; - PackageQuery::Filters(parsed) - } else { - // No --filter, no --recursive: implicit cwd or package-name specifier. - let selector = task_specifier.package_name.map_or_else( - || PackageSelector::ContainingPackage(Arc::clone(cwd)), - |name| PackageSelector::Name(PackageNamePattern::Exact(name)), - ); - let traversal = if transitive { - Some(GraphTraversal { - direction: TraversalDirection::Dependencies, - exclude_self: false, - }) - } else { - None - }; - PackageQuery::Filters(Vec1::new(PackageFilter { exclude: false, selector, traversal })) - }; + let include_explicit_deps = !self.flags.ignore_depends_on; Ok(QueryPlanRequest { query: TaskQuery { @@ -256,7 +165,7 @@ impl ResolvedRunCommand { task_name: task_specifier.task_name, include_explicit_deps, }, - plan_options: PlanOptions { extra_args: additional_args.into() }, + plan_options: PlanOptions { extra_args: self.additional_args.into() }, }) } } diff --git a/crates/vite_workspace/Cargo.toml b/crates/vite_workspace/Cargo.toml index ef2aae6d..8eb18aa2 100644 --- a/crates/vite_workspace/Cargo.toml +++ b/crates/vite_workspace/Cargo.toml @@ -8,6 +8,7 @@ publish = false rust-version.workspace = true [dependencies] +clap = { workspace = true, features = ["derive"] } path-clean = { workspace = true } petgraph = { workspace = true, features = ["serde-1"] } rustc-hash = { workspace = true } diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index 4a0982df..c417a932 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -31,16 +31,19 @@ use std::sync::Arc; +use vec1::Vec1; use vite_path::{AbsolutePath, AbsolutePathBuf}; use vite_str::Str; +use crate::package_graph::PackageQuery; + // ──────────────────────────────────────────────────────────────────────────── // Types // ──────────────────────────────────────────────────────────────────────────── /// Exact name or glob pattern for matching package names. #[derive(Debug, Clone)] -pub enum PackageNamePattern { +pub(crate) enum PackageNamePattern { /// Exact name (e.g. `foo`, `@scope/pkg`). O(1) hash lookup. /// /// Scoped auto-completion applies during resolution: if `"bar"` has no exact match @@ -62,7 +65,7 @@ pub enum PackageNamePattern { /// /// pnpm ref: #[derive(Debug, Clone)] -pub enum DirectoryPattern { +pub(crate) enum DirectoryPattern { /// Exact path match (no glob metacharacters in selector). Exact(Arc), @@ -80,7 +83,7 @@ pub enum DirectoryPattern { /// 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)] -pub enum PackageSelector { +pub(crate) enum PackageSelector { /// Match by name only. Produced by `--filter foo` or `--filter "@scope/*"`. Name(PackageNamePattern), @@ -104,7 +107,7 @@ pub enum PackageSelector { /// Direction to traverse the package dependency graph from the initially matched packages. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TraversalDirection { +pub(crate) enum TraversalDirection { /// Transitive dependencies (outgoing edges). Produced by `foo...`. Dependencies, @@ -122,14 +125,14 @@ pub 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)] -pub struct GraphTraversal { - pub direction: TraversalDirection, +pub(crate) struct GraphTraversal { + pub(crate) direction: TraversalDirection, /// Exclude the initially matched packages from the result. /// /// Produced by `^` in `foo^...` (keep dependencies, drop foo) /// or `...^foo` (keep dependents, drop foo). - pub exclude_self: bool, + pub(crate) exclude_self: bool, } /// A single package filter, corresponding to one `--filter` argument. @@ -137,17 +140,17 @@ pub struct GraphTraversal { /// Multiple filters are composed at the `PackageQuery` level: /// inclusions are unioned, then exclusions are subtracted. #[derive(Debug, Clone)] -pub struct PackageFilter { +pub(crate) struct PackageFilter { /// When `true`, packages matching this filter are **excluded** from the result. /// Produced by a leading `!` in the filter string. - pub exclude: bool, + pub(crate) exclude: bool, /// Which packages to initially match. - pub selector: PackageSelector, + pub(crate) selector: PackageSelector, /// Optional graph expansion from the initial match. /// `None` = exact match only (no traversal). - pub traversal: Option, + pub(crate) traversal: Option, } // ──────────────────────────────────────────────────────────────────────────── @@ -164,6 +167,121 @@ pub enum PackageFilterParseError { InvalidGlob(#[from] wax::BuildError), } +// ──────────────────────────────────────────────────────────────────────────── +// CLI package query +// ──────────────────────────────────────────────────────────────────────────── + +/// Errors that can occur when converting [`PackageQueryArgs`] into a [`PackageQuery`]. +#[derive(Debug, thiserror::Error)] +pub enum PackageQueryError { + #[error("--recursive and --transitive cannot be used together")] + RecursiveTransitiveConflict, + + #[error("--filter and --transitive cannot be used together")] + FilterWithTransitive, + + #[error("--filter and --recursive cannot be used together")] + FilterWithRecursive, + + #[error("cannot specify package name with --recursive")] + PackageNameWithRecursive { package_name: Str }, + + #[error("cannot specify package name with --filter")] + PackageNameWithFilter { package_name: Str }, + + #[error("--filter value contains no selectors (whitespace-only)")] + EmptyFilter, + + #[error("invalid --filter expression")] + InvalidFilter(#[from] PackageFilterParseError), +} + +/// CLI arguments for selecting which packages a command applies to. +/// +/// Use `#[clap(flatten)]` to embed these in a parent clap struct. +/// Call [`into_package_query`](Self::into_package_query) to convert into an opaque [`PackageQuery`]. +#[derive(Debug, Clone, clap::Args)] +pub struct PackageQueryArgs { + /// Run tasks found in all packages in the workspace, in topological order based on package dependencies. + #[clap(default_value = "false", short, long)] + pub recursive: bool, + + /// Run tasks found in the current package and all its transitive dependencies, in topological order based on package dependencies. + #[clap(default_value = "false", short, long)] + pub transitive: bool, + + /// Filter packages (pnpm --filter syntax). Can be specified multiple times. + #[clap(short = 'F', long, num_args = 1)] + pub filter: Vec, +} + +impl PackageQueryArgs { + /// Convert CLI arguments into an opaque [`PackageQuery`]. + /// + /// `package_name` is the optional package name from a `package#task` specifier. + /// `cwd` is the working directory (used as fallback when no package name or filter is given). + /// + /// # Errors + /// + /// Returns [`PackageQueryError`] if conflicting flags are set, a package name + /// is specified with `--recursive` or `--filter`, or a filter expression is invalid. + pub fn into_package_query( + self, + package_name: Option, + cwd: &Arc, + ) -> Result { + let Self { recursive, transitive, filter } = self; + + if recursive { + if transitive { + return Err(PackageQueryError::RecursiveTransitiveConflict); + } + if !filter.is_empty() { + return Err(PackageQueryError::FilterWithRecursive); + } + if let Some(package_name) = package_name { + return Err(PackageQueryError::PackageNameWithRecursive { package_name }); + } + return Ok(PackageQuery::all()); + } + + if !filter.is_empty() { + if transitive { + return Err(PackageQueryError::FilterWithTransitive); + } + if let Some(package_name) = package_name { + return Err(PackageQueryError::PackageNameWithFilter { package_name }); + } + // Normalize: split each --filter value by whitespace into individual tokens. + // This makes `--filter "a b"` equivalent to `--filter a --filter b` (pnpm behaviour). + let tokens: Vec1 = Vec1::try_from_vec( + filter + .into_iter() + .flat_map(|f| f.split_ascii_whitespace().map(Str::from).collect::>()) + .collect(), + ) + .map_err(|_| PackageQueryError::EmptyFilter)?; + let parsed: Vec1 = tokens.try_mapped(|f| parse_filter(&f, cwd))?; + return Ok(PackageQuery::filters(parsed)); + } + + // No --filter, no --recursive: implicit cwd or package-name specifier. + let selector = package_name.map_or_else( + || PackageSelector::ContainingPackage(Arc::clone(cwd)), + |name| PackageSelector::Name(PackageNamePattern::Exact(name)), + ); + let traversal = if transitive { + Some(GraphTraversal { + direction: TraversalDirection::Dependencies, + exclude_self: false, + }) + } else { + None + }; + Ok(PackageQuery::filters(Vec1::new(PackageFilter { exclude: false, selector, traversal }))) + } +} + // ──────────────────────────────────────────────────────────────────────────── // Parsing // ──────────────────────────────────────────────────────────────────────────── @@ -182,7 +300,7 @@ pub enum PackageFilterParseError { /// Follows pnpm's [`parsePackageSelector`] algorithm. See module-level docs for examples. /// /// [`parsePackageSelector`]: https://github.com/pnpm/pnpm/blob/05dd45ea82fff9c0b687cdc8f478a1027077d343/workspace/filter-workspace-packages/src/parsePackageSelector.ts#L14-L61 -pub fn parse_filter( +pub(crate) fn parse_filter( input: &str, cwd: &AbsolutePath, ) -> Result { diff --git a/crates/vite_workspace/src/package_graph.rs b/crates/vite_workspace/src/package_graph.rs index 384eea6e..7c8836af 100644 --- a/crates/vite_workspace/src/package_graph.rs +++ b/crates/vite_workspace/src/package_graph.rs @@ -35,11 +35,15 @@ use crate::{ /// Specifies which packages a task query applies to. /// -/// The two variants prevent the invalid state where no packages are targeted: -/// - `Filters` carries at least one filter (enforced by `Vec1`). -/// - `All` is the explicit "run everywhere" variant, produced by `--recursive`. +/// This type is opaque — construct it via [`PackageQueryArgs::into_package_query`] +/// (from `package_filter`). +/// +/// [`PackageQueryArgs::into_package_query`]: crate::package_filter::PackageQueryArgs::into_package_query +#[derive(Debug)] +pub struct PackageQuery(pub(crate) PackageQueryKind); + #[derive(Debug)] -pub enum PackageQuery { +pub(crate) enum PackageQueryKind { /// One or more `--filter` expressions. /// /// Inclusions are unioned; exclusions are subtracted from the union. @@ -53,6 +57,18 @@ pub enum PackageQuery { All, } +impl PackageQuery { + /// All packages in the workspace. + pub(crate) const fn all() -> Self { + Self(PackageQueryKind::All) + } + + /// One or more filter expressions. + pub(crate) const fn filters(filters: Vec1) -> Self { + Self(PackageQueryKind::Filters(filters)) + } +} + // ──────────────────────────────────────────────────────────────────────────── // FilterResolution // ──────────────────────────────────────────────────────────────────────────── @@ -158,12 +174,12 @@ impl IndexedPackageGraph { /// of the matching packages. #[must_use] pub fn resolve_query(&self, query: &PackageQuery) -> FilterResolution { - match query { - PackageQuery::All => FilterResolution { + match &query.0 { + PackageQueryKind::All => FilterResolution { package_subgraph: self.full_subgraph(), unmatched_selectors: Vec::new(), }, - PackageQuery::Filters(filters) => self.resolve_filters(filters.as_slice()), + PackageQueryKind::Filters(filters) => self.resolve_filters(filters.as_slice()), } } From 36647525fe722874c882c1d15455764190eb3445 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sat, 28 Feb 2026 15:39:48 +0800 Subject: [PATCH 18/35] test: add plan snapshot tests for braced paths, name+dir intersection, and exclusion edge cases Co-Authored-By: Claude Opus 4.6 --- .../fixtures/filter-workspace/snapshots.toml | 37 +++++++++++++++++++ .../query - exclude nonexistent package.snap | 25 +++++++++++++ ...lter by braced path with dependencies.snap | 22 +++++++++++ ...filter by braced path with dependents.snap | 23 ++++++++++++ .../query - filter by braced path.snap | 14 +++++++ ...er by name and directory intersection.snap | 14 +++++++ ...ery - multiple exclusion-only filters.snap | 18 +++++++++ 7 files changed, 153 insertions(+) create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - exclude nonexistent package.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by braced path with dependencies.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by braced path with dependents.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by braced path.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by name and directory intersection.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - multiple exclusion-only filters.snap diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml index 697b9639..0cf47757 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml @@ -153,6 +153,43 @@ compact = true name = "filter deps skips packages without task" args = ["run", "--filter", "@test/app...", "test"] +# pnpm: braced path selector — `{./path}` is equivalent to `./path` for exact directories +[[plan]] +compact = true +name = "filter by braced path" +args = ["run", "--filter", "{./packages/app}", "build"] + +# braced path with dependency traversal — `{./path}...` +[[plan]] +compact = true +name = "filter by braced path with dependencies" +args = ["run", "--filter", "{./packages/app}...", "build"] + +# braced path with dependents traversal — `...{./path}` +[[plan]] +compact = true +name = "filter by braced path with dependents" +args = ["run", "--filter", "...{./packages/core}", "build"] + +# pnpm: name AND directory intersection — `pattern{./dir}` matches packages +# whose name matches the glob AND whose directory matches the path. +[[plan]] +compact = true +name = "filter by name and directory intersection" +args = ["run", "--filter", "@test/*{./packages/lib}", "build"] + +# pnpm: multiple exclusion-only filters — each exclusion subtracts from the full set. +[[plan]] +compact = true +name = "multiple exclusion-only filters" +args = ["run", "--filter", "!@test/app", "--filter", "!@test/core", "build"] + +# pnpm: excluding a package that doesn't exist — no-op, returns all packages. +[[plan]] +compact = true +name = "exclude nonexistent package" +args = ["run", "--filter", "!nonexistent", "build"] + # script containing "vp run --filter .... build" — expanded in plan [[plan]] compact = true diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - exclude nonexistent package.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - exclude nonexistent package.snap new file mode 100644 index 00000000..40a0e659 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - exclude nonexistent package.snap @@ -0,0 +1,25 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "!nonexistent" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build", + "packages/utils#build" + ], + "packages/cli#build": [ + "packages/core#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ], + "packages/utils#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by braced path with dependencies.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by braced path with dependencies.snap new file mode 100644 index 00000000..37be66f8 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by braced path with dependencies.snap @@ -0,0 +1,22 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "{./packages/app}..." + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build", + "packages/utils#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ], + "packages/utils#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by braced path with dependents.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by braced path with dependents.snap new file mode 100644 index 00000000..e0ac44d5 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by braced path with dependents.snap @@ -0,0 +1,23 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "...{./packages/core}" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build" + ], + "packages/cli#build": [ + "packages/core#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by braced path.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by braced path.snap new file mode 100644 index 00000000..9e340f05 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by braced path.snap @@ -0,0 +1,14 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "{./packages/app}" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by name and directory intersection.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by name and directory intersection.snap new file mode 100644 index 00000000..169f4f04 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by name and directory intersection.snap @@ -0,0 +1,14 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "@test/*{./packages/lib}" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/lib#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - multiple exclusion-only filters.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - multiple exclusion-only filters.snap new file mode 100644 index 00000000..639f2b2f --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - multiple exclusion-only filters.snap @@ -0,0 +1,18 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "!@test/app" + - "--filter" + - "!@test/core" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/cli#build": [], + "packages/lib#build": [], + "packages/utils#build": [] +} From 1486697beca690414d4177663687ced0fad1c923 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sat, 28 Feb 2026 16:53:26 +0800 Subject: [PATCH 19/35] remove pnpm-filter.md --- crates/vite_task/docs/pnpm-filter.md | 387 --------------------------- 1 file changed, 387 deletions(-) delete mode 100644 crates/vite_task/docs/pnpm-filter.md diff --git a/crates/vite_task/docs/pnpm-filter.md b/crates/vite_task/docs/pnpm-filter.md deleted file mode 100644 index 6dfeaa78..00000000 --- a/crates/vite_task/docs/pnpm-filter.md +++ /dev/null @@ -1,387 +0,0 @@ -# Specification: `pnpm run --filter` / `--filter-prod` with Exact and Glob Task Names - -## Context - -This document describes the end-to-end behavior of `pnpm run` when combined with `--filter` or `--filter-prod` (also written `--prod-filter`). The flow is split into two independent stages: **package selection** (which workspace packages to consider) and **task matching** (which scripts to run within each selected package). - -This spec is based on pnpm@dcd16c7b36cf95dc2abb9b09a81d66e87cd3fe97. - ---- - -## Stage 1: Package Selection - -### 1.1 CLI Parsing - -When the CLI receives `--filter ` or `--filter-prod `: - -1. [parse-cli-args](cli/parse-cli-args/src/index.ts) detects the `--filter` / `--filter-prod` option via `nopt`. If either is present, the command is implicitly made **recursive** (`options.recursive = true`). - -2. [config](config/config/src/index.ts) normalizes the option: if the value is a single string, it is **split by spaces** into an array. So `--filter "a b"` becomes `["a", "b"]`. - -3. [main.ts](pnpm/src/main.ts:200-203) wraps each filter string into a `WorkspaceFilter` object: - - Strings from `--filter` get `{ filter: , followProdDepsOnly: false }`. - - Strings from `--filter-prod` get `{ filter: , followProdDepsOnly: true }`. - -### 1.2 Selector Parsing - -Each filter string is parsed by [`parsePackageSelector`](workspace/filter-workspace-packages/src/parsePackageSelector.ts) into a `PackageSelector`: - -``` -interface PackageSelector { - namePattern?: string // e.g. "a", "@scope/pkg", "foo*" - parentDir?: string // e.g. resolved absolute path from "./packages" - diff?: string // e.g. "master" from "[master]" - exclude?: boolean // leading "!" in the original string - excludeSelf?: boolean // "^" modifier - includeDependencies?: boolean // trailing "..." - includeDependents?: boolean // leading "..." - followProdDepsOnly?: boolean // from --filter-prod -} -``` - -Parsing rules (applied in order on the raw string): - -1. Leading `!` → set `exclude: true`, strip. -2. Trailing `...` → set `includeDependencies: true`, strip. Then trailing `^` → set `excludeSelf: true`, strip. -3. Leading `...` → set `includeDependents: true`, strip. Then leading `^` → set `excludeSelf: true`, strip. -4. Remainder is matched against regex: `name{dir}[diff]` extracting the three optional parts. -5. If the regex doesn't match and the string looks like a relative path (starts with `.` or `..`), it becomes `parentDir`. - -### 1.3 Building the Dependency Graph - -[`filterPkgsBySelectorObjects`](workspace/filter-workspace-packages/src/index.ts:90-147) splits selectors into two groups: - -- **prod selectors** (`followProdDepsOnly: true`) -- **all selectors** (`followProdDepsOnly: false`) - -For each group, a **separate package graph** is built via [`createPkgGraph`](workspace/pkgs-graph/src/index.ts): - -- The **all** graph includes: `dependencies`, `devDependencies`, `optionalDependencies`, `peerDependencies`. -- The **prod** graph includes: `dependencies`, `optionalDependencies`, `peerDependencies` (i.e. `devDependencies` are excluded via `ignoreDevDeps: true`). - -Each graph is a map from `ProjectRootDir` → `{ package, dependencies: ProjectRootDir[] }`. A dependency edge exists only when it resolves to another workspace package (via version/range matching or `workspace:` protocol). - -### 1.4 Selecting Packages from the Graph - -[`filterWorkspacePackages`](workspace/filter-workspace-packages/src/index.ts:149-178) applies the selectors to the graph: - -1. Selectors are partitioned into **include** selectors and **exclude** selectors (those with `exclude: true`). -2. If there are no include selectors, **all** packages in the graph are initially selected. -3. For each include selector, `_filterGraph` is called. -4. For exclude selectors, `_filterGraph` is called similarly. -5. Final result = `include.selected − exclude.selected`. - -### 1.5 `_filterGraph` — The Core Selection Algorithm - -[`_filterGraph`](workspace/filter-workspace-packages/src/index.ts:180-273) maintains three sets and one list: - -- `cherryPickedPackages` — directly matched, no graph traversal -- `walkedDependencies` — collected by walking dependency edges -- `walkedDependents` — collected by walking reverse-dependency edges -- `walkedDependentsDependencies` — dependencies of walked dependents - -For each selector: - -**Step A — Find entry packages:** - -- If `namePattern` is set: match package names using [`createMatcher`](config/matcher/src/index.ts) (a glob matcher that converts `*` to `.*` regex; no `*` means exact match). - - Bonus: if a non-scoped pattern yields zero matches, it retries as `@*/` and accepts the result only if exactly one package matches. -- If `parentDir` is set: match packages whose root dir is under that path. - -**Step B — Expand via graph traversal (`selectEntries`):** - -- If `includeDependencies` is true: walk the dependency graph forward from entry packages (DFS), adding all reachable nodes to `walkedDependencies`. If `excludeSelf` is true, the entry package itself is not added (but its dependencies still are). -- If `includeDependents` is true: walk the **reversed** graph from entry packages, adding all reachable nodes to `walkedDependents`. Same `excludeSelf` logic. -- If **both** `includeDependencies` and `includeDependents`: additionally walk forward from all walked dependents into `walkedDependentsDependencies`. -- If **neither**: simply push entry packages into `cherryPickedPackages`. - -**Step C — Combine:** The final selected set is the union of all four collections. - -### 1.6 Merging Results - -If both `--filter` and `--filter-prod` selectors exist, their selected graphs are merged. The merge is ([filterPkgsBySelectorObjects](workspace/filter-workspace-packages/src/index.ts#L132-L137)): - -``` -selectedProjectsGraph = { ...prodFilteredGraph, ...filteredGraph } -``` - -This is a JS object spread, so for any package that appears in **both** graphs, the node from `filteredGraph` (full graph, with devDep edges) **overwrites** the node from `prodFilteredGraph` (prod graph, without devDep edges). This creates an asymmetry: the **graph origin of each node** determines which dependency edges it carries into the final `selectedProjectsGraph`. See Examples 6-8 for the implications. - ---- - -## Stage 2: Task Matching and Execution - -Package selection (Stage 1) is completely independent of task names. It doesn't look at `scripts` at all. Task matching happens later, within the `run` command. - -### 2.1 Entry to `runRecursive` - -The [`run` handler](exec/plugin-commands-script-runners/src/run.ts:189-305) checks `opts.recursive`. If true and there's a script name (or more than one selected package), it delegates to [`runRecursive`](exec/plugin-commands-script-runners/src/runRecursive.ts:43-201), passing the full `selectedProjectsGraph`. - -### 2.2 Topological Sorting - -If `opts.sort` is true (the default), packages in `selectedProjectsGraph` are topologically sorted via [`sortPackages`](workspace/sort-packages/src/index.ts:18-21). The sort only considers edges **within the selected graph** (edges to non-selected packages are ignored). The result is an array of "chunks" — each chunk is a group of packages with no inter-dependencies that can run in parallel. - -### 2.3 Per-Package Script Matching - -For each package in each chunk, [`getSpecifiedScripts`](exec/plugin-commands-script-runners/src/runRecursive.ts:217-232) determines which scripts to run: - -1. **Exact match first:** If `scripts[scriptName]` exists, return `[scriptName]`. -2. **Regex match:** If the script name has the form `/pattern/` (a regex literal), [`tryBuildRegExpFromCommand`](exec/plugin-commands-script-runners/src/regexpCommand.ts) extracts the pattern and builds a `RegExp`. All script keys in the package's `scripts` that match this regex are returned. Regex flags (e.g. `/pattern/i`) are **not supported** and throw an error. -3. **No match:** Return `[]`. - -Note: this is **not** glob matching. Task name patterns use **regex literal syntax** (`/pattern/`), while package name patterns in `--filter` use **glob syntax** (`*`). They are different systems. - -### 2.4 Handling Packages Without Matching Scripts - -When `getSpecifiedScripts` returns an empty array for a package: - -- The package's result status is set to `'skipped'`. -- The package is simply not executed — no error is raised for that individual package. - -### 2.5 Error Conditions - -After iterating through all packages: - -- If **zero** packages had a matching script (`hasCommand === 0`), **and** the script name is not `"test"`, **and** `--if-present` was not passed: - - Error: `RECURSIVE_RUN_NO_SCRIPT` — "None of the selected packages has a `` script." -- Additionally, if `requiredScripts` config includes the script name, **all** selected packages must have it, or an error is thrown before execution begins. - -### 2.6 Execution Order - -The topological sort and chunking operate at the **package** level, not the individual script level. The execution loop ([runRecursive.ts:90-183](exec/plugin-commands-script-runners/src/runRecursive.ts#L90-L183)) processes one chunk at a time: - -1. For each chunk, collect all `(package, scriptName)` pairs by running `getSpecifiedScripts` on every package in the chunk, then flatten. -2. Run all collected scripts in the chunk **concurrently** (up to `workspaceConcurrency` limit) via `Promise.all`. -3. **Await** the entire chunk before proceeding to the next. - -This means: if package b is in chunk N and package a is in chunk N+1, **all** of b's matched scripts finish before **any** of a's matched scripts start — regardless of whether the matched script names are the same or different. Within a single chunk, scripts from different packages (and even multiple matched scripts from the same package) run concurrently. - ---- - -## Worked Examples - -### Example 1: `pnpm run --filter "a..." build` - -**Setup:** a (has `build`) → depends on b (no `build`) → depends on c (has `build`) - -**Stage 1 — Package Selection:** - -1. Parse `"a..."` → `{ namePattern: "a", includeDependencies: true }` -2. Build full dependency graph: a→b, b→c -3. Entry packages: match name "a" → `[a]` -4. Walk dependencies from a: a→b→c. `walkedDependencies = {a, b, c}` -5. `selectedProjectsGraph = { a, b, c }` - -**Stage 2 — Task Matching:** - -1. Topological sort of {a, b, c} within selected graph: chunks = `[[c], [b], [a]]` (dependencies first) -2. Chunk [c]: `getSpecifiedScripts(c.scripts, "build")` → `["build"]` → run c's build. `hasCommand = 1` -3. Chunk [b]: `getSpecifiedScripts(b.scripts, "build")` → `[]` → skip. b is marked `'skipped'`. -4. Chunk [a]: `getSpecifiedScripts(a.scripts, "build")` → `["build"]` → run a's build. `hasCommand = 2` -5. `hasCommand > 0`, no error. - -**Result:** c's `build` runs first, b is skipped, then a's `build` runs. - -### Example 2: `pnpm run --filter "a..." build` - -**Setup:** a (no `build`) → depends on b (has `build`) - -**Stage 1 — Package Selection:** - -1. Parse `"a..."` → `{ namePattern: "a", includeDependencies: true }` -2. Entry packages: `[a]` -3. Walk dependencies: a→b. `walkedDependencies = {a, b}` -4. `selectedProjectsGraph = { a, b }` - -**Stage 2 — Task Matching:** - -1. Topological sort: chunks = `[[b], [a]]` -2. Chunk [b]: `getSpecifiedScripts(b.scripts, "build")` → `["build"]` → run. `hasCommand = 1` -3. Chunk [a]: `getSpecifiedScripts(a.scripts, "build")` → `[]` → skip. -4. `hasCommand > 0`, no error. - -**Result:** b's `build` runs, a is skipped. - -### Example 3: `pnpm run --filter "a..." /glob/` - -**Setup:** a → depends on b. Package a has script `taskA` matching `/glob/`. Package b has script `taskB` matching `/glob/`. Neither has the other's task. - -**Stage 1 — Package Selection:** - -1. Parse `"a..."` → `{ namePattern: "a", includeDependencies: true }` -2. Entry packages: `[a]` -3. Walk dependencies: a→b. `walkedDependencies = {a, b}` -4. `selectedProjectsGraph = { a, b }` - -**Stage 2 — Task Matching:** - -1. `scriptName = "/glob/"`. This is a regex literal. -2. `tryBuildRegExpFromCommand("/glob/")` → `RegExp("glob")` -3. Topological sort: chunks = `[[b], [a]]` -4. Chunk [b]: `getSpecifiedScripts(b.scripts, "/glob/")`: - - No exact match for literal `"/glob/"` in scripts - - Regex match: filter b's script keys by `RegExp("glob")` → finds `taskB` → `["taskB"]` - - Run b's `taskB`. `hasCommand = 1` -5. Chunk [a]: `getSpecifiedScripts(a.scripts, "/glob/")`: - - No exact match - - Regex match: filter a's script keys by `RegExp("glob")` → finds `taskA` → `["taskA"]` - - Run a's `taskA`. `hasCommand = 2` -6. `hasCommand > 0`, no error. - -**Result:** b's `taskB` runs first, then a's `taskA`. Each package independently matches its own scripts against the regex. The regex is applied per-package, so different packages can match different script names. - -**Ordering note:** Even though `taskA` and `taskB` have different names, b's `taskB` still runs before a's `taskA` because the ordering is at the package level. Package b is in an earlier topological chunk than a (since a depends on b). All scripts matched in a package inherit that package's position in the execution order. - -### Example 4: `pnpm run --filter a --filter b build` - -**Setup:** a depends on b. Both have `build`. - -**Stage 1 — Package Selection:** - -1. Two `--filter` flags → two `WorkspaceFilter` objects, both `followProdDepsOnly: false`. -2. Parse selectors: - - `"a"` → `{ namePattern: "a" }` (no `...`) - - `"b"` → `{ namePattern: "b" }` (no `...`) -3. Both are include selectors. `_filterGraph` processes them sequentially: - - **Selector 1 (a):** Entry = [a]. Neither `includeDependencies` nor `includeDependents` → `cherryPickedPackages = [a]`. - - **Selector 2 (b):** Entry = [b]. Same → `cherryPickedPackages = [a, b]`. -4. No graph walking occurs — both packages are cherry-picked. -5. `selectedProjectsGraph = { a, b }` — both retain their original dependency edges from the full graph (a→b edge is preserved). - -**Stage 2 — Task Matching:** - -1. Topological sort over {a, b}. Edge a→b is within the selected set. Chunks: `[[b], [a]]`. -2. Chunk [b]: run `build`. Chunk [a]: run `build`. - -**Key insight:** Even though neither filter used `...` (no dependency expansion), the topological sort still respects the a→b dependency edge. Cherry-picking packages does not remove their dependency relationships — the `selectedProjectsGraph` retains the original edges from the full graph, and [`sequenceGraph`](workspace/sort-packages/src/index.ts#L5-L16) filters edges to only those between selected packages. So b's `build` runs before a's `build`. - -### Example 5: `pnpm run --filter 'app...' --filter 'cli' build` - -**Setup:** Workspace has packages: app, lib, core, utils, cli. app→lib→core→utils (chain). cli→core (separate). All have `build`. - -**Stage 1 — Package Selection:** - -1. Two `--filter` flags produce two `WorkspaceFilter` objects, both with `followProdDepsOnly: false`. They share the **same** graph (the full "all" graph). -2. Parse selectors: - - `"app..."` → `{ namePattern: "app", includeDependencies: true }` - - `"cli"` → `{ namePattern: "cli" }` (no `...`, no `!`, no `^`) -3. Both are include selectors (no `exclude`). `_filterGraph` processes them sequentially: - - **Selector 1 (app...):** Entry = [app]. Walk dependencies: app→lib→core→utils. `walkedDependencies = {app, lib, core, utils}`. - - **Selector 2 (cli):** Entry = [cli]. Neither `includeDependencies` nor `includeDependents` → push to `cherryPickedPackages = [cli]`. -4. Combine: union of all collections → `{app, lib, core, utils, cli}`. -5. `selectedProjectsGraph = { app, lib, core, utils, cli }` — **all five** packages, each retaining their original dependency edges from the full graph. - -**Stage 2 — Task Matching:** - -1. Topological sort over the selected graph. Edges within the selected set: app→lib, lib→core, core→utils, cli→core. - - Chunks: `[[utils], [core], [lib, cli], [app]]` - - Note: `lib` and `cli` are in the **same chunk** — they both depend on `core` but not on each other, so they can run in parallel. -2. Chunk [utils]: run `build`. Chunk [core]: run `build`. Chunk [lib, cli]: run both `build` scripts concurrently. Chunk [app]: run `build`. - -**Key insight:** Even though `cli` was selected without `...` (cherry-picked, not graph-expanded), the topological sort still respects its dependency on `core` because the sort operates on the `selectedProjectsGraph` which retains the original dependency edges. Multiple `--filter` flags with the same `followProdDepsOnly` value contribute to the same `_filterGraph` call and their results are unioned together. - -### Examples 6-8: `--filter` / `--filter-prod` mix with devDependencies - -**Common setup for all three:** b is a **devDependency** of a. Both have `build`. - -Each selected package's node comes from the graph of whichever filter type selected it. The a→b edge only exists in the full graph (from `--filter`), not the prod graph (from `--filter-prod`). So the edge in the final `selectedProjectsGraph` depends on which graph **a's node** came from — since a is the package that _declares_ the dependency. - -### Example 6: `pnpm run --filter-prod a --filter-prod b build` - -**Stage 1:** Both selectors have `followProdDepsOnly: true`. A single prod graph is built (`ignoreDevDeps: true`). a's node has no edge to b. Both cherry-picked into `selectedProjectsGraph`. - -**Stage 2:** `sortPackages` sees no edge → **1 chunk** containing both. They run concurrently. - -### Example 7: `pnpm run --filter a --filter-prod b build` - -**Stage 1:** The selectors are split: - -- `"a"` → `allPackageSelectors` (full graph). a's node comes from the full graph → its `dependencies` array **includes** b (devDep edge present). -- `"b"` → `prodPackageSelectors` (prod graph). b's node comes from the prod graph. - -Merge: `{ ...prodGraph(b), ...fullGraph(a) }`. No overlap, so a's node (with devDep edge) and b's node are both present. - -**Stage 2:** `sortPackages` sees edge a→b → **2 chunks**: `[[b], [a]]`. b's `build` runs first. - -### Example 8: `pnpm run --filter-prod a --filter b build` - -**Stage 1:** The selectors are split: - -- `"a"` → `prodPackageSelectors` (prod graph). a's node comes from the prod graph → its `dependencies` array **excludes** b (devDep edge absent). -- `"b"` → `allPackageSelectors` (full graph). b's node comes from the full graph. - -Merge: `{ ...prodGraph(a), ...fullGraph(b) }`. a's node has no edge to b. - -**Stage 2:** `sortPackages` sees no edge → **1 chunk** containing both. They run concurrently. - -**Key insight across 6-8:** The dependency edge a→b exists in the final graph **only when a's node comes from the full graph** (i.e. a was selected via `--filter`, not `--filter-prod`). It does not matter which flag selected b. This asymmetry comes from the merge in `filterPkgsBySelectorObjects`: each node retains the edges from whichever graph (full vs prod) it was selected from. - ---- - -## Key Design Insight - -The two stages are fully decoupled: - -- **Stage 1 (package selection)** answers: "Which workspace packages should be considered?" It uses the dependency graph and filter patterns, and knows nothing about scripts. -- **Stage 2 (task matching)** answers: "Within each selected package, which scripts should run?" It uses the script name (exact or regex) against each package's `scripts` field, and skips packages without matches. - -This means `--filter "a..."` always selects a and all its (transitive) dependencies regardless of whether they have the requested script. Packages without the script are silently skipped (unless `requiredScripts` is configured or no package at all has the script). - ---- - -## Key Source Files - -| Component | Path | -| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| CLI arg parsing | [cli/parse-cli-args/src/index.ts](cli/parse-cli-args/src/index.ts) | -| Filter wiring in main | [pnpm/src/main.ts:194-251](pnpm/src/main.ts#L194-L251) | -| Selector parsing | [workspace/filter-workspace-packages/src/parsePackageSelector.ts](workspace/filter-workspace-packages/src/parsePackageSelector.ts) | -| Package filtering core | [workspace/filter-workspace-packages/src/index.ts](workspace/filter-workspace-packages/src/index.ts) | -| Dependency graph construction | [workspace/pkgs-graph/src/index.ts](workspace/pkgs-graph/src/index.ts) | -| Name pattern matcher | [config/matcher/src/index.ts](config/matcher/src/index.ts) | -| Topological sort | [workspace/sort-packages/src/index.ts](workspace/sort-packages/src/index.ts) | -| Run command handler | [exec/plugin-commands-script-runners/src/run.ts](exec/plugin-commands-script-runners/src/run.ts) | -| Recursive runner + script matching | [exec/plugin-commands-script-runners/src/runRecursive.ts](exec/plugin-commands-script-runners/src/runRecursive.ts) | -| Regex command parser | [exec/plugin-commands-script-runners/src/regexpCommand.ts](exec/plugin-commands-script-runners/src/regexpCommand.ts) | - ---- - -## Relevant Tests - -### Selector Parsing - -- [workspace/filter-workspace-packages/test/parsePackageSelector.ts](workspace/filter-workspace-packages/test/parsePackageSelector.ts) — 16 fixture-driven tests covering all selector syntax: name, `...`, `^`, `!`, `{dir}`, `[diff]`, and combinations. - -### Package Filtering (graph traversal) - -- [workspace/filter-workspace-packages/test/index.ts](workspace/filter-workspace-packages/test/index.ts) — Tests for `filterWorkspacePackages`: dependencies, dependents, combined deps+dependents, self-exclusion, by-name, by-directory (exact and glob), git-diff filtering, exclusion patterns, unmatched filter reporting. - -### Dependency Graph Construction - -- [workspace/pkgs-graph/test/index.ts](workspace/pkgs-graph/test/index.ts) — Tests for `createPkgGraph`: basic deps, peer deps, local directory deps, `workspace:` protocol, `ignoreDevDeps: true`, `linkWorkspacePackages: false`, prerelease version matching. - -### Name Pattern Matcher - -- [config/matcher/test/index.ts](config/matcher/test/index.ts) — Tests for `createMatcher`: wildcard (`*`), glob patterns, exact match, negation (`!`), multiple patterns. - -### Topological Sort - -- [deps/graph-sequencer/test/index.ts](deps/graph-sequencer/test/index.ts) — 18+ tests for `graphSequencer`: cycles, subgraph sequencing, independent nodes, multi-dependency chains. - -### Run Command (unit) - -- [exec/plugin-commands-script-runners/test/runRecursive.ts](exec/plugin-commands-script-runners/test/runRecursive.ts) — 29 tests: basic recursive run, reversed, concurrent, filtering, `--if-present`, `--bail`, `requiredScripts`, `--resume-from`, RegExp selectors, report summary. -- [exec/plugin-commands-script-runners/test/index.ts](exec/plugin-commands-script-runners/test/index.ts) — 21 tests: single-package run, exit codes, RegExp script selectors (including invalid flags), `--if-present`, command suggestions. - -### Regex Script Matching - -- Covered in both test files above. Key tests: `pnpm run with RegExp script selector should work` and 8 tests for invalid regex flags. - -### `--filter-prod` - -- [pnpm/test/filterProd.test.ts](pnpm/test/filterProd.test.ts) — E2E tests comparing `--filter` vs `--filter-prod` with a 4-project graph, verifying devDependencies inclusion/exclusion. - -### E2E / Integration - -- [pnpm/test/recursive/run.ts](pnpm/test/recursive/run.ts) — CLI-level integration tests for `pnpm run` in recursive mode. -- [pnpm/test/recursive/filter.ts](pnpm/test/recursive/filter.ts) — CLI-level integration tests for recursive filtering. From deed31aff2a4a1bb81b5123bf1889c719477a82a Mon Sep 17 00:00:00 2001 From: branchseer Date: Sat, 28 Feb 2026 17:14:07 +0800 Subject: [PATCH 20/35] refactor: use wax::Glob::partition to detect glob patterns in name filters Replace manual `contains(['*', '?', '['])` check with wax's own partition method, consistent with how DirectoryPattern already works. Co-Authored-By: Claude Opus 4.6 --- crates/vite_workspace/src/package_filter.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index c417a932..979cfa7d 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -440,11 +440,11 @@ fn resolve_filter_path(path_str: &str, cwd: &AbsolutePath) -> Arc /// Build a [`PackageNamePattern`] from a name or glob string. /// -/// A string containing `*`, `?`, or `[` is treated as a glob; otherwise exact. +/// Uses [`wax::Glob::partition`] to determine if the pattern contains variant +/// (non-literal) components. If it does, the pattern is a glob; otherwise exact. fn build_name_pattern(name: &str) -> Result { - if name.contains(['*', '?', '[']) { - // Validate and compile the glob, then make it owned (lifetime: 'static). - let glob = wax::Glob::new(name)?.into_owned(); + let glob = wax::Glob::new(name)?.into_owned(); + if glob.clone().partition().1.is_some() { Ok(PackageNamePattern::Glob(Box::new(glob))) } else { Ok(PackageNamePattern::Exact(name.into())) From f24e35d90f8949ba294d1ac522dbab6f8c10e87c Mon Sep 17 00:00:00 2001 From: branchseer Date: Sat, 28 Feb 2026 17:23:40 +0800 Subject: [PATCH 21/35] fix: discard traversal on unbraced path selectors to match pnpm pnpm silently discards `...` traversal modifiers on unbraced path selectors like `./packages/app...` due to syntactic ambiguity between `..` (parent dir) and `...` (traversal). Only braced paths like `{./packages/app}...` preserve traversal (pnpm issue #1651, PR #2254). `parse_core_selector` now returns a `supports_traversal` flag that is `false` for unbraced `.`-prefix paths, causing `parse_filter` to discard any stripped `...`. Co-Authored-By: Claude Opus 4.6 --- .../fixtures/filter-workspace/snapshots.toml | 17 +++--- ...r by directory ignores trailing dots.snap} | 10 +-- ...t ignores trailing dots from package.snap} | 10 +-- ... filter dotdot ignores trailing dots.snap} | 10 +-- ...- nested vp run with filter in script.snap | 10 +-- crates/vite_workspace/src/package_filter.rs | 61 +++++++++++++++++-- 6 files changed, 69 insertions(+), 49 deletions(-) rename crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/{query - filter by directory with dependencies.snap => query - filter by directory ignores trailing dots.snap} (56%) rename crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/{query - filter dot with dependencies from package.snap => query - filter dot ignores trailing dots from package.snap} (57%) rename crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/{query - filter dotdot with dependencies.snap => query - filter dotdot ignores trailing dots.snap} (57%) diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml index 0cf47757..e675ab55 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml @@ -80,23 +80,25 @@ compact = true name = "filter by directory" args = ["run", "--filter", "./packages/app", "build"] -# directory filter with dependency expansion +# pnpm silently discards `...` on unbraced path selectors (ambiguity with `..`). +# `./packages/app...` is equivalent to `./packages/app`. Use `{./packages/app}...` for traversal. +# Ref: https://github.com/pnpm/pnpm/issues/1651 [[plan]] compact = true -name = "filter by directory with dependencies" +name = "filter by directory ignores trailing dots" args = ["run", "--filter", "./packages/app...", "build"] -# --filter .... = . (cwd) + ... (deps) — from inside a package directory +# `....` = `.` + `...` — traversal discarded on unbraced path, equivalent to `--filter .` [[plan]] compact = true -name = "filter dot with dependencies from package" +name = "filter dot ignores trailing dots from package" args = ["run", "--filter", "....", "build"] cwd = "packages/app" -# --filter ..... = .. (parent dir) + ... (deps) — from inside a package subdir +# `.....` = `..` + `...` — traversal discarded on unbraced path, equivalent to `--filter ..` [[plan]] compact = true -name = "filter dotdot with dependencies" +name = "filter dotdot ignores trailing dots" args = ["run", "--filter", ".....", "build"] cwd = "packages/app/src" @@ -112,7 +114,8 @@ compact = true name = "filter by directory glob double star" args = ["run", "--filter", "./packages/**", "build"] -# transitive flag = --filter .… +# -t flag: current package + transitive deps (NOT equivalent to `--filter ....` +# because pnpm discards traversal on unbraced paths; -t bypasses parse_filter). [[plan]] compact = true name = "transitive flag" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory with dependencies.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory ignores trailing dots.snap similarity index 56% rename from crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory with dependencies.snap rename to crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory ignores trailing dots.snap index 5fbe76ad..c63ae72d 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory with dependencies.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory ignores trailing dots.snap @@ -10,13 +10,5 @@ info: input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { - "packages/app#build": [ - "packages/lib#build", - "packages/utils#build" - ], - "packages/core#build": [], - "packages/lib#build": [ - "packages/core#build" - ], - "packages/utils#build": [] + "packages/app#build": [] } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dot with dependencies from package.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dot ignores trailing dots from package.snap similarity index 57% rename from crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dot with dependencies from package.snap rename to crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dot ignores trailing dots from package.snap index 815afccc..af2acf36 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dot with dependencies from package.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dot ignores trailing dots from package.snap @@ -11,13 +11,5 @@ info: input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { - "packages/app#build": [ - "packages/lib#build", - "packages/utils#build" - ], - "packages/core#build": [], - "packages/lib#build": [ - "packages/core#build" - ], - "packages/utils#build": [] + "packages/app#build": [] } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dotdot with dependencies.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dotdot ignores trailing dots.snap similarity index 57% rename from crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dotdot with dependencies.snap rename to crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dotdot ignores trailing dots.snap index 46dab52e..cd3146c6 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dotdot with dependencies.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dotdot ignores trailing dots.snap @@ -11,13 +11,5 @@ info: input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { - "packages/app#build": [ - "packages/lib#build", - "packages/utils#build" - ], - "packages/core#build": [], - "packages/lib#build": [ - "packages/core#build" - ], - "packages/utils#build": [] + "packages/app#build": [] } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - nested vp run with filter in script.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - nested vp run with filter in script.snap index ed6e7486..b05cd427 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - nested vp run with filter in script.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - nested vp run with filter in script.snap @@ -13,15 +13,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace "packages/app#deploy": { "items": [ { - "packages/app#build": [ - "packages/lib#build", - "packages/utils#build" - ], - "packages/core#build": [], - "packages/lib#build": [ - "packages/core#build" - ], - "packages/utils#build": [] + "packages/app#build": [] } ], "neighbors": [] diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index 979cfa7d..5d65d22f 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -342,7 +342,12 @@ pub(crate) fn parse_filter( }; // Step 6–9: parse the remaining core selector. - let selector = parse_core_selector(core, cwd)?; + let (selector, supports_traversal) = parse_core_selector(core, cwd)?; + + // pnpm discards traversal on unbraced path selectors — `..` (parent dir) + // and `...` (traversal) are ambiguous. Braces disambiguate: `{./path}...`. + // Ref: https://github.com/pnpm/pnpm/issues/1651 + let traversal = if supports_traversal { traversal } else { None }; Ok(PackageFilter { exclude, selector, traversal }) } @@ -359,10 +364,13 @@ pub(crate) fn parse_filter( /// (per the regex rule that Group 1 must not start with `.`). /// 2. If the string starts with `.`, treat the whole thing as a relative path. /// 3. Otherwise treat as a name pattern (exact or glob). +/// +/// Returns `(selector, supports_traversal)`. Unbraced `.`-prefix path selectors +/// return `false` because pnpm discards `...` traversal on them (ambiguity with `..`). fn parse_core_selector( core: &str, cwd: &AbsolutePath, -) -> Result { +) -> Result<(PackageSelector, bool), PackageFilterParseError> { // Try to extract a brace-enclosed directory suffix: `{...}`. // The name part before the brace must not start with `.` (pnpm regex Group 1 constraint). if let Some(without_closing) = core.strip_suffix('}') @@ -378,11 +386,11 @@ fn parse_core_selector( return if name_part.is_empty() { // Only a directory selector: `{./foo}` or `{packages/app}`. - Ok(PackageSelector::Directory(directory)) + Ok((PackageSelector::Directory(directory), true)) } else { // Name and directory combined: `foo{./bar}`. let name = build_name_pattern(name_part)?; - Ok(PackageSelector::NameAndDirectory { name, directory }) + Ok((PackageSelector::NameAndDirectory { name, directory }, true)) }; } // name_part starts with `.`: fall through — treat entire core as a relative path. @@ -390,9 +398,10 @@ fn parse_core_selector( // If the core starts with `.`, it's a relative path to a directory. // This handles `.`, `..`, `./foo`, `../foo`, `./foo/*`, `./foo/**`. + // Traversal is NOT supported — pnpm discards `...` on unbraced path selectors. if core.starts_with('.') { let directory = resolve_directory_pattern(core, cwd)?; - return Ok(PackageSelector::Directory(directory)); + return Ok((PackageSelector::Directory(directory), false)); } // Guard against an empty selector reaching here. @@ -401,7 +410,7 @@ fn parse_core_selector( } // Plain name or glob pattern. - Ok(PackageSelector::Name(build_name_pattern(core)?)) + Ok((PackageSelector::Name(build_name_pattern(core)?), true)) } /// Resolve a directory selector string into a [`DirectoryPattern`]. @@ -912,4 +921,44 @@ mod tests { DirectoryPattern::Exact(p) => panic!("expected Glob, got Exact({p:?})"), } } + + // ── Unbraced path selectors discard traversal (pnpm compat) ───────── + + #[test] + fn unbraced_path_discards_trailing_dots() { + // `./foo...` — `...` is stripped but traversal is discarded for unbraced paths. + let cwd = abs("/workspace"); + let f = parse_filter("./foo...", cwd).unwrap(); + assert_directory(&f, abs("/workspace/foo")); + assert_no_traversal(&f); + } + + #[test] + fn unbraced_dot_discards_trailing_dots() { + // `....` = `.` (cwd) + `...` — traversal discarded. + let cwd = abs("/workspace/packages/app"); + let f = parse_filter("....", cwd).unwrap(); + assert_directory(&f, abs("/workspace/packages/app")); + assert_no_traversal(&f); + } + + #[test] + fn unbraced_dotdot_discards_leading_dots() { + // `......` = `...` (dependents) + `...` (remaining = `...`) + // After stripping both `...` markers, core = empty → error? No: + // `...../foo` = `...` (dependents) + `../foo` — traversal discarded. + let cwd = abs("/workspace/packages/app"); + let f = parse_filter("...../foo", cwd).unwrap(); + assert_directory(&f, abs("/workspace/packages/foo")); + assert_no_traversal(&f); + } + + #[test] + fn braced_path_preserves_traversal() { + // `{./foo}...` — braces make traversal work. + let cwd = abs("/workspace"); + let f = parse_filter("{./foo}...", cwd).unwrap(); + assert_directory(&f, abs("/workspace/foo")); + assert_traversal(&f, TraversalDirection::Dependencies, false); + } } From f24504ad2f1288ba13297c99998703dab493e507 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sat, 28 Feb 2026 18:20:54 +0800 Subject: [PATCH 22/35] feat: add -w/--workspace-root flag to select workspace root package Add a new PackageSelector::WorkspaceRoot variant that targets the package with empty relative path (workspace root). The flag follows pnpm's conflict rules: - -w alone: selects only the workspace root - -w --filter: additive (workspace root unioned with filter matches) - -w -r: redundant (all packages already includes root) - -w -t: workspace root with transitive dependencies - -w with package name: error (conflicting target) Co-Authored-By: Claude Opus 4.6 --- .../fixtures/filter-workspace/package.json | 8 +- .../packages/app/package.json | 1 + .../packages/core/package.json | 1 + .../fixtures/filter-workspace/snapshots.toml | 26 +++ .../query - workspace root flag.snap | 13 ++ .../query - workspace root with filter.snap | 16 ++ ...query - workspace root with recursive.snap | 25 +++ ...uery - workspace root with transitive.snap | 17 ++ .../snapshots/task graph.snap | 84 ++++++++++ crates/vite_workspace/src/package_filter.rs | 158 +++++++++++++++++- crates/vite_workspace/src/package_graph.rs | 10 ++ 11 files changed, 355 insertions(+), 4 deletions(-) create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root flag.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root with filter.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root with recursive.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root with transitive.snap diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/package.json index b65cafad..e859d763 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/package.json +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/package.json @@ -1,4 +1,10 @@ { "name": "test-workspace", - "private": true + "private": true, + "scripts": { + "check": "echo 'Checking workspace'" + }, + "dependencies": { + "@test/core": "workspace:*" + } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/app/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/app/package.json index 4e0b7507..28207377 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/app/package.json +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/app/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "scripts": { "build": "echo 'Building @test/app'", + "check": "echo 'Checking @test/app'", "test": "echo 'Testing @test/app'", "deploy": "vp run --filter .... build" }, diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/core/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/core/package.json index c2864056..3b5bd74e 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/core/package.json +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/core/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "scripts": { "build": "echo 'Building @test/core'", + "check": "echo 'Checking @test/core'", "test": "echo 'Testing @test/core'" } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml index e675ab55..57d8d0f5 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml @@ -198,3 +198,29 @@ args = ["run", "--filter", "!nonexistent", "build"] compact = true name = "nested vp run with filter in script" args = ["run", "--filter", "@test/app", "deploy"] + +# -w / --workspace-root: run task on workspace root package only +[[plan]] +compact = true +name = "workspace root flag" +args = ["run", "-w", "check"] + +# pnpm: -w is additive — workspace root is unioned with filtered packages. +# Both root and app have "check", so both appear (no dependency edge between them). +[[plan]] +compact = true +name = "workspace root with filter" +args = ["run", "-w", "--filter", "@test/app", "check"] + +# -w with -r: redundant (all packages already includes root). Same as -r alone. +[[plan]] +compact = true +name = "workspace root with recursive" +args = ["run", "-w", "-r", "build"] + +# -w with -t: workspace root with transitive dependencies. +# Root depends on @test/core; both have "check", so core#check → root#check. +[[plan]] +compact = true +name = "workspace root with transitive" +args = ["run", "-w", "-t", "check"] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root flag.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root flag.snap new file mode 100644 index 00000000..34faca5c --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root flag.snap @@ -0,0 +1,13 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-w" + - check +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "#check": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root with filter.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root with filter.snap new file mode 100644 index 00000000..018a3ded --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root with filter.snap @@ -0,0 +1,16 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-w" + - "--filter" + - "@test/app" + - check +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "#check": [], + "packages/app#check": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root with recursive.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root with recursive.snap new file mode 100644 index 00000000..9be6e327 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root with recursive.snap @@ -0,0 +1,25 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-w" + - "-r" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "packages/app#build": [ + "packages/lib#build", + "packages/utils#build" + ], + "packages/cli#build": [ + "packages/core#build" + ], + "packages/core#build": [], + "packages/lib#build": [ + "packages/core#build" + ], + "packages/utils#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root with transitive.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root with transitive.snap new file mode 100644 index 00000000..9c5d27da --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root with transitive.snap @@ -0,0 +1,17 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-w" + - "-t" + - check +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "#check": [ + "packages/core#check" + ], + "packages/core#check": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/task graph.snap index 55372bf2..940f7cb9 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/task graph.snap @@ -4,6 +4,34 @@ expression: task_graph_json input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- [ + { + "key": [ + "/", + "check" + ], + "node": { + "task_display": { + "package_name": "test-workspace", + "task_name": "check", + "package_path": "/" + }, + "resolved_config": { + "command": "echo 'Checking workspace'", + "resolved_options": { + "cwd": "/", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + } + }, + "neighbors": [] + }, { "key": [ "/packages/app", @@ -32,6 +60,34 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace }, "neighbors": [] }, + { + "key": [ + "/packages/app", + "check" + ], + "node": { + "task_display": { + "package_name": "@test/app", + "task_name": "check", + "package_path": "/packages/app" + }, + "resolved_config": { + "command": "echo 'Checking @test/app'", + "resolved_options": { + "cwd": "/packages/app", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + } + }, + "neighbors": [] + }, { "key": [ "/packages/app", @@ -172,6 +228,34 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace }, "neighbors": [] }, + { + "key": [ + "/packages/core", + "check" + ], + "node": { + "task_display": { + "package_name": "@test/core", + "task_name": "check", + "package_path": "/packages/core" + }, + "resolved_config": { + "command": "echo 'Checking @test/core'", + "resolved_options": { + "cwd": "/packages/core", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + } + } + } + } + }, + "neighbors": [] + }, { "key": [ "/packages/core", diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index 5d65d22f..feadf19a 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -103,6 +103,10 @@ pub(crate) enum PackageSelector { /// Match by name AND directory (intersection). /// Produced by `--filter "pattern{./dir}"`. NameAndDirectory { name: PackageNamePattern, directory: DirectoryPattern }, + + /// Select the workspace root package (the package with empty relative path). + /// Produced by `-w` / `--workspace-root`. + WorkspaceRoot, } /// Direction to traverse the package dependency graph from the initially matched packages. @@ -189,6 +193,9 @@ pub enum PackageQueryError { #[error("cannot specify package name with --filter")] PackageNameWithFilter { package_name: Str }, + #[error("cannot specify package name with --workspace-root")] + PackageNameWithWorkspaceRoot { package_name: Str }, + #[error("--filter value contains no selectors (whitespace-only)")] EmptyFilter, @@ -210,6 +217,10 @@ pub struct PackageQueryArgs { #[clap(default_value = "false", short, long)] pub transitive: bool, + /// Run task in the workspace root package. + #[clap(default_value = "false", short = 'w', long = "workspace-root")] + pub workspace_root: bool, + /// Filter packages (pnpm --filter syntax). Can be specified multiple times. #[clap(short = 'F', long, num_args = 1)] pub filter: Vec, @@ -230,7 +241,7 @@ impl PackageQueryArgs { package_name: Option, cwd: &Arc, ) -> Result { - let Self { recursive, transitive, filter } = self; + let Self { recursive, transitive, workspace_root, filter } = self; if recursive { if transitive { @@ -242,6 +253,7 @@ impl PackageQueryArgs { if let Some(package_name) = package_name { return Err(PackageQueryError::PackageNameWithRecursive { package_name }); } + // -w is redundant with -r (all packages already includes root). return Ok(PackageQuery::all()); } @@ -261,11 +273,41 @@ impl PackageQueryArgs { .collect(), ) .map_err(|_| PackageQueryError::EmptyFilter)?; - let parsed: Vec1 = tokens.try_mapped(|f| parse_filter(&f, cwd))?; + let mut parsed: Vec1 = tokens.try_mapped(|f| parse_filter(&f, cwd))?; + // pnpm: `-w` adds workspace root to the filter list (additive). + if workspace_root { + parsed.push(PackageFilter { + exclude: false, + selector: PackageSelector::WorkspaceRoot, + traversal: None, + }); + } return Ok(PackageQuery::filters(parsed)); } - // No --filter, no --recursive: implicit cwd or package-name specifier. + // No --filter, no --recursive. + // -w replaces the implicit cwd target with the workspace root. + // -w -t: workspace root with transitive dependencies. + if workspace_root { + if let Some(package_name) = package_name { + return Err(PackageQueryError::PackageNameWithWorkspaceRoot { package_name }); + } + let traversal = if transitive { + Some(GraphTraversal { + direction: TraversalDirection::Dependencies, + exclude_self: false, + }) + } else { + None + }; + return Ok(PackageQuery::filters(Vec1::new(PackageFilter { + exclude: false, + selector: PackageSelector::WorkspaceRoot, + traversal, + }))); + } + + // No --filter, no --recursive, no -w: implicit cwd or package-name specifier. let selector = package_name.map_or_else( || PackageSelector::ContainingPackage(Arc::clone(cwd)), |name| PackageSelector::Name(PackageNamePattern::Exact(name)), @@ -961,4 +1003,114 @@ mod tests { assert_directory(&f, abs("/workspace/foo")); assert_traversal(&f, TraversalDirection::Dependencies, false); } + + // ── -w / --workspace-root flag ────────────────────────────────────────── + + #[test] + fn workspace_root_produces_selector() { + let cwd: Arc = Arc::from(abs("/workspace/packages/app")); + let args = PackageQueryArgs { + recursive: false, + transitive: false, + workspace_root: true, + filter: Vec::new(), + }; + let query = args.into_package_query(None, &cwd).unwrap(); + match &query.0 { + crate::package_graph::PackageQueryKind::Filters(filters) => { + assert_eq!(filters.len(), 1); + assert!(!filters[0].exclude); + assert!( + matches!(&filters[0].selector, PackageSelector::WorkspaceRoot), + "expected WorkspaceRoot, got {:?}", + filters[0].selector + ); + assert_no_traversal(&filters[0]); + } + other => panic!("expected Filters, got {other:?}"), + } + } + + #[test] + fn workspace_root_with_recursive_returns_all() { + // -w is redundant with -r (all packages already includes root). + let cwd: Arc = Arc::from(abs("/workspace")); + let args = PackageQueryArgs { + recursive: true, + transitive: false, + workspace_root: true, + filter: Vec::new(), + }; + let query = args.into_package_query(None, &cwd).unwrap(); + assert!( + matches!(&query.0, crate::package_graph::PackageQueryKind::All), + "expected All, got {:?}", + query.0 + ); + } + + #[test] + fn workspace_root_with_transitive() { + // -w -t: workspace root with transitive dependencies. + let cwd: Arc = Arc::from(abs("/workspace/packages/app")); + let args = PackageQueryArgs { + recursive: false, + transitive: true, + workspace_root: true, + filter: Vec::new(), + }; + let query = args.into_package_query(None, &cwd).unwrap(); + match &query.0 { + crate::package_graph::PackageQueryKind::Filters(filters) => { + assert_eq!(filters.len(), 1); + assert!( + matches!(&filters[0].selector, PackageSelector::WorkspaceRoot), + "expected WorkspaceRoot, got {:?}", + filters[0].selector + ); + assert_traversal(&filters[0], TraversalDirection::Dependencies, false); + } + other => panic!("expected Filters, got {other:?}"), + } + } + + #[test] + fn workspace_root_with_filter_unions() { + // -w --filter foo: workspace root + parsed filter. + let cwd: Arc = Arc::from(abs("/workspace")); + let args = PackageQueryArgs { + recursive: false, + transitive: false, + workspace_root: true, + filter: vec![Str::from("foo")], + }; + let query = args.into_package_query(None, &cwd).unwrap(); + match &query.0 { + crate::package_graph::PackageQueryKind::Filters(filters) => { + assert_eq!(filters.len(), 2); + assert_exact_name(&filters[0], "foo"); + assert!( + matches!(&filters[1].selector, PackageSelector::WorkspaceRoot), + "expected WorkspaceRoot, got {:?}", + filters[1].selector + ); + } + other => panic!("expected Filters, got {other:?}"), + } + } + + #[test] + fn workspace_root_conflicts_with_package_name() { + let cwd: Arc = Arc::from(abs("/workspace")); + let args = PackageQueryArgs { + recursive: false, + transitive: false, + workspace_root: true, + filter: Vec::new(), + }; + assert!(matches!( + args.into_package_query(Some(Str::from("app")), &cwd), + Err(PackageQueryError::PackageNameWithWorkspaceRoot { .. }) + )); + } } diff --git a/crates/vite_workspace/src/package_graph.rs b/crates/vite_workspace/src/package_graph.rs index 7c8836af..fa701d15 100644 --- a/crates/vite_workspace/src/package_graph.rs +++ b/crates/vite_workspace/src/package_graph.rs @@ -275,6 +275,16 @@ impl IndexedPackageGraph { self.match_by_directory_pattern(directory, &mut by_dir); matched.extend(by_name.intersection(&by_dir)); } + + PackageSelector::WorkspaceRoot => { + // The workspace root package has an empty relative path. + for idx in self.graph.node_indices() { + if self.graph[idx].path.as_str().is_empty() { + matched.insert(idx); + break; + } + } + } } matched From a6da491f9de26fb57a87bc1bcc89dde567a9eda5 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sat, 28 Feb 2026 18:30:50 +0800 Subject: [PATCH 23/35] test: fix filter snapshots that didn't demonstrate claimed behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mixed traversal filters: change @test/app... to @test/lib... so result ({lib, core, cli}) is distinct from recursive and shows cross-boundary cli→core edge - workspace root with recursive: change build→check so root appears in output, proving -r already covers it Co-Authored-By: Claude Opus 4.6 --- .../fixtures/filter-workspace/snapshots.toml | 9 +++++---- .../query - mixed traversal filters.snap | 9 ++------- .../query - workspace root with recursive.snap | 17 ++++++----------- 3 files changed, 13 insertions(+), 22 deletions(-) diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml index 57d8d0f5..ad3d632e 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml @@ -141,13 +141,13 @@ compact = true name = "recursive flag" args = ["run", "-r", "build"] -# pnpm Example 5: app... selects {app,lib,core,utils}. cli is cherry-picked. +# pnpm Example 5: lib... selects {lib,core}. cli is cherry-picked. # ALL original edges between selected packages are preserved (induced subgraph). # cli→core edge IS present — cli#build waits for core#build. [[plan]] compact = true name = "mixed traversal filters" -args = ["run", "--filter", "@test/app...", "--filter", "@test/cli", "build"] +args = ["run", "--filter", "@test/lib...", "--filter", "@test/cli", "build"] # pnpm Example 1 (partial): packages without the task are silently skipped. # @test/utils has no "test" — silently skipped; app, lib, core run in order. @@ -212,11 +212,12 @@ compact = true name = "workspace root with filter" args = ["run", "-w", "--filter", "@test/app", "check"] -# -w with -r: redundant (all packages already includes root). Same as -r alone. +# -w with -r: redundant (all packages already includes root). +# Root, app, and core all have "check" — root appears, proving -r already covers it. [[plan]] compact = true name = "workspace root with recursive" -args = ["run", "-w", "-r", "build"] +args = ["run", "-w", "-r", "check"] # -w with -t: workspace root with transitive dependencies. # Root depends on @test/core; both have "check", so core#check → root#check. diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - mixed traversal filters.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - mixed traversal filters.snap index 4a2ac5d9..a8cecdcc 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - mixed traversal filters.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - mixed traversal filters.snap @@ -5,23 +5,18 @@ info: args: - run - "--filter" - - "@test/app..." + - "@test/lib..." - "--filter" - "@test/cli" - build input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { - "packages/app#build": [ - "packages/lib#build", - "packages/utils#build" - ], "packages/cli#build": [ "packages/core#build" ], "packages/core#build": [], "packages/lib#build": [ "packages/core#build" - ], - "packages/utils#build": [] + ] } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root with recursive.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root with recursive.snap index 9be6e327..21418d25 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root with recursive.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root with recursive.snap @@ -6,20 +6,15 @@ info: - run - "-w" - "-r" - - build + - check input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace --- { - "packages/app#build": [ - "packages/lib#build", - "packages/utils#build" + "#check": [ + "packages/core#check" ], - "packages/cli#build": [ - "packages/core#build" + "packages/app#check": [ + "packages/core#check" ], - "packages/core#build": [], - "packages/lib#build": [ - "packages/core#build" - ], - "packages/utils#build": [] + "packages/core#check": [] } From 0d1679a39770254be78749ed14e70fc7c0309ee0 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sat, 28 Feb 2026 18:33:10 +0800 Subject: [PATCH 24/35] cargo shear --fix --- Cargo.lock | 1 - crates/vite_task/Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 56cedfb5..f358cc3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3884,7 +3884,6 @@ dependencies = [ "tokio", "tracing", "twox-hash", - "vec1", "vite_glob", "vite_path", "vite_select", diff --git a/crates/vite_task/Cargo.toml b/crates/vite_task/Cargo.toml index 404a5cf6..58f61818 100644 --- a/crates/vite_task/Cargo.toml +++ b/crates/vite_task/Cargo.toml @@ -33,7 +33,6 @@ thiserror = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "io-std", "io-util", "macros", "sync"] } tracing = { workspace = true } twox-hash = { workspace = true } -vec1 = { workspace = true } vite_glob = { workspace = true } vite_path = { workspace = true } vite_select = { workspace = true } From ef7ca99340a9346360c133a1b5bc5968f6aa156c Mon Sep 17 00:00:00 2001 From: branchseer Date: Sat, 28 Feb 2026 18:59:19 +0800 Subject: [PATCH 25/35] refactor: store original filter strings for unmatched_selectors `unmatched_selectors` was `Vec` (indices into the internal filter list), but those indices don't map back to CLI args after whitespace splitting and synthetic filter injection. Change to `Vec` by storing the original `--filter` token as `source: Option` in each `PackageFilter`. Synthetic filters (implicit cwd, `-w`) get `None` so they are never reported as unmatched. Co-Authored-By: Claude Opus 4.6 --- crates/vite_task_graph/src/query/mod.rs | 7 +- crates/vite_workspace/src/package_filter.rs | 83 ++++++++++++++++++++- crates/vite_workspace/src/package_graph.rs | 21 +++--- 3 files changed, 95 insertions(+), 16 deletions(-) diff --git a/crates/vite_task_graph/src/query/mod.rs b/crates/vite_task_graph/src/query/mod.rs index ae8d6d69..12677665 100644 --- a/crates/vite_task_graph/src/query/mod.rs +++ b/crates/vite_task_graph/src/query/mod.rs @@ -53,12 +53,11 @@ pub struct TaskQueryResult { /// decide whether to show task-not-found UI. pub execution_graph: TaskExecutionGraph, - /// Indices into the original `PackageQuery::Filters` slice for selectors that - /// matched no packages. The caller maps each index back to the original - /// `--filter` string for typo warnings. + /// Original `--filter` strings for inclusion selectors that matched no packages. /// + /// Omits synthetic filters (implicit cwd, `-w`) since the user didn't type them. /// Always empty when `PackageQuery::All` was used. - pub unmatched_selectors: Vec, + pub unmatched_selectors: Vec, } impl IndexedTaskGraph { diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index feadf19a..cb0df3eb 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -155,6 +155,10 @@ pub(crate) struct PackageFilter { /// Optional graph expansion from the initial match. /// `None` = exact match only (no traversal). pub(crate) traversal: Option, + + /// Original `--filter` token that produced this filter. + /// `None` for synthetic filters (implicit cwd, package name, `-w`). + pub(crate) source: Option, } // ──────────────────────────────────────────────────────────────────────────── @@ -280,6 +284,7 @@ impl PackageQueryArgs { exclude: false, selector: PackageSelector::WorkspaceRoot, traversal: None, + source: None, }); } return Ok(PackageQuery::filters(parsed)); @@ -304,6 +309,7 @@ impl PackageQueryArgs { exclude: false, selector: PackageSelector::WorkspaceRoot, traversal, + source: None, }))); } @@ -320,7 +326,12 @@ impl PackageQueryArgs { } else { None }; - Ok(PackageQuery::filters(Vec1::new(PackageFilter { exclude: false, selector, traversal }))) + Ok(PackageQuery::filters(Vec1::new(PackageFilter { + exclude: false, + selector, + traversal, + source: None, + }))) } } @@ -391,7 +402,7 @@ pub(crate) fn parse_filter( // Ref: https://github.com/pnpm/pnpm/issues/1651 let traversal = if supports_traversal { traversal } else { None }; - Ok(PackageFilter { exclude, selector, traversal }) + Ok(PackageFilter { exclude, selector, traversal, source: Some(Str::from(input)) }) } /// Parse the core selector string (after stripping `!` and `...` markers). @@ -1113,4 +1124,72 @@ mod tests { Err(PackageQueryError::PackageNameWithWorkspaceRoot { .. }) )); } + + // ── source field ─────────────────────────────────────────────────────── + + #[test] + fn parse_filter_sets_source() { + let cwd = abs("/workspace"); + let f = parse_filter("@test/app...", cwd).unwrap(); + assert_eq!(f.source.as_deref(), Some("@test/app...")); + } + + #[test] + fn filter_source_preserved_after_whitespace_split() { + let cwd: Arc = Arc::from(abs("/workspace")); + let args = PackageQueryArgs { + recursive: false, + transitive: false, + workspace_root: false, + filter: vec![Str::from("a b")], + }; + let query = args.into_package_query(None, &cwd).unwrap(); + match &query.0 { + crate::package_graph::PackageQueryKind::Filters(filters) => { + assert_eq!(filters.len(), 2); + assert_eq!(filters[0].source.as_deref(), Some("a")); + assert_eq!(filters[1].source.as_deref(), Some("b")); + } + other => panic!("expected Filters, got {other:?}"), + } + } + + #[test] + fn synthetic_workspace_root_filter_has_no_source() { + let cwd: Arc = Arc::from(abs("/workspace")); + let args = PackageQueryArgs { + recursive: false, + transitive: false, + workspace_root: true, + filter: vec![Str::from("foo")], + }; + let query = args.into_package_query(None, &cwd).unwrap(); + match &query.0 { + crate::package_graph::PackageQueryKind::Filters(filters) => { + assert_eq!(filters.len(), 2); + assert_eq!(filters[0].source.as_deref(), Some("foo")); + assert!(filters[1].source.is_none()); + } + other => panic!("expected Filters, got {other:?}"), + } + } + + #[test] + fn implicit_cwd_filter_has_no_source() { + let cwd: Arc = Arc::from(abs("/workspace/packages/app")); + let args = PackageQueryArgs { + recursive: false, + transitive: false, + workspace_root: false, + filter: Vec::new(), + }; + let query = args.into_package_query(None, &cwd).unwrap(); + match &query.0 { + crate::package_graph::PackageQueryKind::Filters(filters) => { + assert_eq!(filters.len(), 1); + assert!(filters[0].source.is_none()); + } + other => panic!("expected Filters, got {other:?}"), + } + } } diff --git a/crates/vite_workspace/src/package_graph.rs b/crates/vite_workspace/src/package_graph.rs index fa701d15..f4aa7dea 100644 --- a/crates/vite_workspace/src/package_graph.rs +++ b/crates/vite_workspace/src/package_graph.rs @@ -86,11 +86,11 @@ pub struct FilterResolution { /// stage (construction time), keeping all downstream code edge-type-agnostic. pub package_subgraph: DiGraphMap, - /// Indices into the input `filters` slice for selectors that matched no packages. + /// Original `--filter` strings for inclusion selectors that matched no packages. /// - /// The caller maps each index back to the original `--filter` string to emit - /// typo warnings. Empty when `PackageQuery::All` is used. - pub unmatched_selectors: Vec, + /// Omits synthetic filters (implicit cwd, `-w`) since the user didn't type them. + /// Empty when `PackageQuery::All` is used. + pub unmatched_selectors: Vec, } // ──────────────────────────────────────────────────────────────────────────── @@ -213,8 +213,7 @@ impl IndexedPackageGraph { fn resolve_filters(&self, filters: &[PackageFilter]) -> FilterResolution { let mut unmatched_selectors = Vec::new(); - let (inclusions, exclusions): (Vec<_>, Vec<_>) = - filters.iter().enumerate().partition(|(_, f)| !f.exclude); + let (inclusions, exclusions): (Vec<_>, Vec<_>) = filters.iter().partition(|f| !f.exclude); // Start from all packages when there are no inclusions (exclude-only mode). let mut selected: FxHashSet = if inclusions.is_empty() { @@ -224,17 +223,19 @@ impl IndexedPackageGraph { }; // Apply inclusions: union each filter's resolved set into `selected`. - for (filter_idx, filter) in &inclusions { + for filter in &inclusions { let matched = self.resolve_selector_entries(&filter.selector); - if matched.is_empty() { - unmatched_selectors.push(*filter_idx); + if matched.is_empty() + && let Some(source) = &filter.source + { + unmatched_selectors.push(source.clone()); } let expanded = self.expand_traversal(matched, filter.traversal.as_ref()); selected.extend(expanded); } // Apply exclusions: subtract each filter's resolved set from `selected`. - for (_, filter) in &exclusions { + for filter in &exclusions { let matched = self.resolve_selector_entries(&filter.selector); let to_remove = self.expand_traversal(matched, filter.traversal.as_ref()); for pkg in to_remove { From 942c9cc52cba736462f5bdc51be36870875ee89f Mon Sep 17 00:00:00 2001 From: branchseer Date: Sat, 28 Feb 2026 19:12:23 +0800 Subject: [PATCH 26/35] feat: warn on stderr when --filter matches no packages Print "No packages matched the filter: " to stderr for each inclusion filter that resolves to zero packages. Exclusion filters and synthetic filters (implicit cwd, -w) are not reported. Add e2e snapshot tests covering: partial match, multiple unmatched, whitespace-split tokens, exclusion filters, glob filters, and directory filters. Co-Authored-By: Claude Opus 4.6 --- .../fixtures/filter-unmatched/package.json | 4 ++ .../packages/app/package.json | 9 ++++ .../packages/lib/package.json | 6 +++ .../filter-unmatched/pnpm-workspace.yaml | 2 + .../fixtures/filter-unmatched/snapshots.toml | 43 +++++++++++++++++++ ...e unmatched filters warn individually.snap | 9 ++++ ...tial match warns for unmatched filter.snap | 8 ++++ .../unmatched directory filter warns.snap | 8 ++++ ...atched exclusion filter does not warn.snap | 7 +++ .../unmatched glob filter warns.snap | 8 ++++ ...plit filter warns for unmatched token.snap | 8 ++++ .../fixtures/filter-unmatched/vite-task.json | 3 ++ crates/vite_task_plan/src/plan.rs | 6 +++ 13 files changed, 121 insertions(+) create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/packages/app/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/packages/lib/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/pnpm-workspace.yaml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots.toml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/multiple unmatched filters warn individually.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/partial match warns for unmatched filter.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/unmatched directory filter warns.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/unmatched exclusion filter does not warn.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/unmatched glob filter warns.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/whitespace split filter warns for unmatched token.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/vite-task.json diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/package.json new file mode 100644 index 00000000..f90a3ecd --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/package.json @@ -0,0 +1,4 @@ +{ + "name": "filter-unmatched-test", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/packages/app/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/packages/app/package.json new file mode 100644 index 00000000..674c580d --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/packages/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/app", + "dependencies": { + "@test/lib": "workspace:*" + }, + "scripts": { + "build": "print built-app" + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/packages/lib/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/packages/lib/package.json new file mode 100644 index 00000000..59d51c85 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/packages/lib/package.json @@ -0,0 +1,6 @@ +{ + "name": "@test/lib", + "scripts": { + "build": "print built-lib" + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/pnpm-workspace.yaml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/pnpm-workspace.yaml new file mode 100644 index 00000000..924b55f4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots.toml new file mode 100644 index 00000000..94554265 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots.toml @@ -0,0 +1,43 @@ +# Tests for unmatched --filter warnings on stderr + +# One filter matches, one doesn't → warning for the unmatched one, task still runs +[[e2e]] +name = "partial match warns for unmatched filter" +steps = [ + "vp run --filter @test/app --filter nonexistent build", +] + +# Multiple unmatched alongside a match → one warning per unmatched filter +[[e2e]] +name = "multiple unmatched filters warn individually" +steps = [ + "vp run --filter @test/app --filter nope1 --filter nope2 build", +] + +# Whitespace-split filter with one unmatched token +[[e2e]] +name = "whitespace split filter warns for unmatched token" +steps = [ + "vp run --filter '@test/app nope' build", +] + +# Exclusion filter that matches nothing does NOT warn (only inclusions warn) +[[e2e]] +name = "unmatched exclusion filter does not warn" +steps = [ + "vp run --filter @test/app --filter '!nonexistent' build", +] + +# Glob filter that matches nothing alongside a match +[[e2e]] +name = "unmatched glob filter warns" +steps = [ + "vp run --filter @test/app --filter @nope/* build", +] + +# Directory filter that matches nothing +[[e2e]] +name = "unmatched directory filter warns" +steps = [ + "vp run --filter @test/app --filter ./packages/nope build", +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/multiple unmatched filters warn individually.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/multiple unmatched filters warn individually.snap new file mode 100644 index 00000000..2689f8c9 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/multiple unmatched filters warn individually.snap @@ -0,0 +1,9 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run --filter @test/app --filter nope1 --filter nope2 build +No packages matched the filter: nope1 +No packages matched the filter: nope2 +~/packages/app$ print built-app +built-app diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/partial match warns for unmatched filter.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/partial match warns for unmatched filter.snap new file mode 100644 index 00000000..b56377e0 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/partial match warns for unmatched filter.snap @@ -0,0 +1,8 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run --filter @test/app --filter nonexistent build +No packages matched the filter: nonexistent +~/packages/app$ print built-app +built-app diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/unmatched directory filter warns.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/unmatched directory filter warns.snap new file mode 100644 index 00000000..af55332e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/unmatched directory filter warns.snap @@ -0,0 +1,8 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run --filter @test/app --filter ./packages/nope build +No packages matched the filter: ./packages/nope +~/packages/app$ print built-app +built-app diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/unmatched exclusion filter does not warn.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/unmatched exclusion filter does not warn.snap new file mode 100644 index 00000000..8632cdc6 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/unmatched exclusion filter does not warn.snap @@ -0,0 +1,7 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run --filter @test/app --filter '!nonexistent' build +~/packages/app$ print built-app +built-app diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/unmatched glob filter warns.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/unmatched glob filter warns.snap new file mode 100644 index 00000000..64a36912 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/unmatched glob filter warns.snap @@ -0,0 +1,8 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run --filter @test/app --filter @nope/* build +No packages matched the filter: @nope/* +~/packages/app$ print built-app +built-app diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/whitespace split filter warns for unmatched token.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/whitespace split filter warns for unmatched token.snap new file mode 100644 index 00000000..b141cbd4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/snapshots/whitespace split filter warns for unmatched token.snap @@ -0,0 +1,8 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run --filter '@test/app nope' build +No packages matched the filter: nope +~/packages/app$ print built-app +built-app diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/vite-task.json new file mode 100644 index 00000000..1d0fe9f2 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/filter-unmatched/vite-task.json @@ -0,0 +1,3 @@ +{ + "cacheScripts": true +} diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index a218815e..322b78df 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -522,6 +522,12 @@ pub async fn plan_query_request( // `query_tasks` is infallible — 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); + + #[expect(clippy::print_stderr, reason = "user-facing warning for typos in --filter")] + for selector in &task_query_result.unmatched_selectors { + eprintln!("No packages matched the filter: {selector}"); + } + let task_node_index_graph = task_query_result.execution_graph; let mut execution_node_indices_by_task_index = From 8d72ff4a4b0d8b5082a21aa8266c505691599e6c Mon Sep 17 00:00:00 2001 From: branchseer Date: Sat, 28 Feb 2026 19:14:07 +0800 Subject: [PATCH 27/35] fix: resolve CI failures in package_filter tests and clippy Replace wildcard match arms in test code with explicit `PackageQueryKind::All` to satisfy `match_wildcard_for_single_variants`. Co-Authored-By: Claude Opus 4.6 --- crates/vite_workspace/src/package_filter.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index cb0df3eb..073554c1 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -1038,7 +1038,7 @@ mod tests { ); assert_no_traversal(&filters[0]); } - other => panic!("expected Filters, got {other:?}"), + crate::package_graph::PackageQueryKind::All => panic!("expected Filters, got All"), } } @@ -1081,7 +1081,7 @@ mod tests { ); assert_traversal(&filters[0], TraversalDirection::Dependencies, false); } - other => panic!("expected Filters, got {other:?}"), + crate::package_graph::PackageQueryKind::All => panic!("expected Filters, got All"), } } @@ -1106,7 +1106,7 @@ mod tests { filters[1].selector ); } - other => panic!("expected Filters, got {other:?}"), + crate::package_graph::PackageQueryKind::All => panic!("expected Filters, got All"), } } @@ -1150,7 +1150,7 @@ mod tests { assert_eq!(filters[0].source.as_deref(), Some("a")); assert_eq!(filters[1].source.as_deref(), Some("b")); } - other => panic!("expected Filters, got {other:?}"), + crate::package_graph::PackageQueryKind::All => panic!("expected Filters, got All"), } } @@ -1170,7 +1170,7 @@ mod tests { assert_eq!(filters[0].source.as_deref(), Some("foo")); assert!(filters[1].source.is_none()); } - other => panic!("expected Filters, got {other:?}"), + crate::package_graph::PackageQueryKind::All => panic!("expected Filters, got All"), } } @@ -1189,7 +1189,7 @@ mod tests { assert_eq!(filters.len(), 1); assert!(filters[0].source.is_none()); } - other => panic!("expected Filters, got {other:?}"), + crate::package_graph::PackageQueryKind::All => panic!("expected Filters, got All"), } } } From 6fce91e969eb5de4eefe397260eb5fef5ced471f Mon Sep 17 00:00:00 2001 From: branchseer Date: Sat, 28 Feb 2026 21:23:07 +0800 Subject: [PATCH 28/35] refactor: make PackageQueryArgs fields private Remove `pub` from all fields. External crates interact only through `#[clap(flatten)]` (parsing) and `into_package_query()` (conversion). Co-Authored-By: Claude Opus 4.6 --- crates/vite_workspace/src/package_filter.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index 073554c1..7697ea4c 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -215,19 +215,19 @@ pub enum PackageQueryError { pub struct PackageQueryArgs { /// Run tasks found in all packages in the workspace, in topological order based on package dependencies. #[clap(default_value = "false", short, long)] - pub recursive: bool, + recursive: bool, /// Run tasks found in the current package and all its transitive dependencies, in topological order based on package dependencies. #[clap(default_value = "false", short, long)] - pub transitive: bool, + transitive: bool, /// Run task in the workspace root package. #[clap(default_value = "false", short = 'w', long = "workspace-root")] - pub workspace_root: bool, + workspace_root: bool, /// Filter packages (pnpm --filter syntax). Can be specified multiple times. #[clap(short = 'F', long, num_args = 1)] - pub filter: Vec, + filter: Vec, } impl PackageQueryArgs { From 86cf4931afeb43965d12b34cafc9e02105d62970 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sat, 28 Feb 2026 21:27:09 +0800 Subject: [PATCH 29/35] docs: reword PackageQueryArgs help to be command-agnostic The flags are shared by `vp run` and future commands like `vp exec`, so the help text should not mention "tasks". Co-Authored-By: Claude Opus 4.6 --- crates/vite_workspace/src/package_filter.rs | 24 ++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index 7697ea4c..6309d54a 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -213,20 +213,34 @@ pub enum PackageQueryError { /// Call [`into_package_query`](Self::into_package_query) to convert into an opaque [`PackageQuery`]. #[derive(Debug, Clone, clap::Args)] pub struct PackageQueryArgs { - /// Run tasks found in all packages in the workspace, in topological order based on package dependencies. + /// Select all packages in the workspace. #[clap(default_value = "false", short, long)] recursive: bool, - /// Run tasks found in the current package and all its transitive dependencies, in topological order based on package dependencies. + /// Select the current package and its transitive dependencies. #[clap(default_value = "false", short, long)] transitive: bool, - /// Run task in the workspace root package. + /// Select the workspace root package. #[clap(default_value = "false", short = 'w', long = "workspace-root")] workspace_root: bool, - /// Filter packages (pnpm --filter syntax). Can be specified multiple times. - #[clap(short = 'F', long, num_args = 1)] + /// Match packages by name, directory, or glob pattern. + #[clap( + short = 'F', + long, + num_args = 1, + long_help = "\ +Match packages by name, directory, or glob pattern. + + --filter Select by package name (e.g. foo, @scope/*) + --filter ./ Select packages under a directory + --filter {} Same as ./, but allows traversal suffixes + --filter ... Select package and its dependencies + --filter ... Select package and its dependents + --filter ^... Select only the dependencies (exclude the package itself) + --filter ! Exclude packages matching the pattern" + )] filter: Vec, } From 42c822fc8e8e1ba5abfd4e7b461d8be04fec10a1 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 1 Mar 2026 11:15:25 +0800 Subject: [PATCH 30/35] add is_cwd_only --- crates/vite_task/src/cli/mod.rs | 2 +- crates/vite_workspace/src/package_filter.rs | 55 ++++++++++++--------- 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/crates/vite_task/src/cli/mod.rs b/crates/vite_task/src/cli/mod.rs index c6dc5ab2..dca722db 100644 --- a/crates/vite_task/src/cli/mod.rs +++ b/crates/vite_task/src/cli/mod.rs @@ -154,7 +154,7 @@ impl ResolvedRunCommand { ) -> Result { let task_specifier = self.task_specifier.ok_or(CLITaskQueryError::MissingTaskSpecifier)?; - let package_query = + let (package_query, _is_cwd_only) = self.flags.package_query.into_package_query(task_specifier.package_name, cwd)?; let include_explicit_deps = !self.flags.ignore_depends_on; diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index 6309d54a..342aa1b8 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -250,6 +250,10 @@ impl PackageQueryArgs { /// `package_name` is the optional package name from a `package#task` specifier. /// `cwd` is the working directory (used as fallback when no package name or filter is given). /// + /// Returns `(query, is_cwd_only)` where `is_cwd_only` is `true` when the query + /// falls through to the implicit-cwd path (no `-r`, `-t`, `-w`, `--filter`, + /// or explicit package name). + /// /// # Errors /// /// Returns [`PackageQueryError`] if conflicting flags are set, a package name @@ -258,7 +262,7 @@ impl PackageQueryArgs { self, package_name: Option, cwd: &Arc, - ) -> Result { + ) -> Result<(PackageQuery, bool), PackageQueryError> { let Self { recursive, transitive, workspace_root, filter } = self; if recursive { @@ -272,7 +276,7 @@ impl PackageQueryArgs { return Err(PackageQueryError::PackageNameWithRecursive { package_name }); } // -w is redundant with -r (all packages already includes root). - return Ok(PackageQuery::all()); + return Ok((PackageQuery::all(), false)); } if !filter.is_empty() { @@ -301,7 +305,7 @@ impl PackageQueryArgs { source: None, }); } - return Ok(PackageQuery::filters(parsed)); + return Ok((PackageQuery::filters(parsed), false)); } // No --filter, no --recursive. @@ -319,15 +323,19 @@ impl PackageQueryArgs { } else { None }; - return Ok(PackageQuery::filters(Vec1::new(PackageFilter { - exclude: false, - selector: PackageSelector::WorkspaceRoot, - traversal, - source: None, - }))); + return Ok(( + PackageQuery::filters(Vec1::new(PackageFilter { + exclude: false, + selector: PackageSelector::WorkspaceRoot, + traversal, + source: None, + })), + false, + )); } // No --filter, no --recursive, no -w: implicit cwd or package-name specifier. + let is_cwd_only = !transitive && package_name.is_none(); let selector = package_name.map_or_else( || PackageSelector::ContainingPackage(Arc::clone(cwd)), |name| PackageSelector::Name(PackageNamePattern::Exact(name)), @@ -340,12 +348,15 @@ impl PackageQueryArgs { } else { None }; - Ok(PackageQuery::filters(Vec1::new(PackageFilter { - exclude: false, - selector, - traversal, - source: None, - }))) + Ok(( + PackageQuery::filters(Vec1::new(PackageFilter { + exclude: false, + selector, + traversal, + source: None, + })), + is_cwd_only, + )) } } @@ -1040,7 +1051,7 @@ mod tests { workspace_root: true, filter: Vec::new(), }; - let query = args.into_package_query(None, &cwd).unwrap(); + let (query, _) = args.into_package_query(None, &cwd).unwrap(); match &query.0 { crate::package_graph::PackageQueryKind::Filters(filters) => { assert_eq!(filters.len(), 1); @@ -1066,7 +1077,7 @@ mod tests { workspace_root: true, filter: Vec::new(), }; - let query = args.into_package_query(None, &cwd).unwrap(); + let (query, _) = args.into_package_query(None, &cwd).unwrap(); assert!( matches!(&query.0, crate::package_graph::PackageQueryKind::All), "expected All, got {:?}", @@ -1084,7 +1095,7 @@ mod tests { workspace_root: true, filter: Vec::new(), }; - let query = args.into_package_query(None, &cwd).unwrap(); + let (query, _) = args.into_package_query(None, &cwd).unwrap(); match &query.0 { crate::package_graph::PackageQueryKind::Filters(filters) => { assert_eq!(filters.len(), 1); @@ -1109,7 +1120,7 @@ mod tests { workspace_root: true, filter: vec![Str::from("foo")], }; - let query = args.into_package_query(None, &cwd).unwrap(); + let (query, _) = args.into_package_query(None, &cwd).unwrap(); match &query.0 { crate::package_graph::PackageQueryKind::Filters(filters) => { assert_eq!(filters.len(), 2); @@ -1157,7 +1168,7 @@ mod tests { workspace_root: false, filter: vec![Str::from("a b")], }; - let query = args.into_package_query(None, &cwd).unwrap(); + let (query, _) = args.into_package_query(None, &cwd).unwrap(); match &query.0 { crate::package_graph::PackageQueryKind::Filters(filters) => { assert_eq!(filters.len(), 2); @@ -1177,7 +1188,7 @@ mod tests { workspace_root: true, filter: vec![Str::from("foo")], }; - let query = args.into_package_query(None, &cwd).unwrap(); + let (query, _) = args.into_package_query(None, &cwd).unwrap(); match &query.0 { crate::package_graph::PackageQueryKind::Filters(filters) => { assert_eq!(filters.len(), 2); @@ -1197,7 +1208,7 @@ mod tests { workspace_root: false, filter: Vec::new(), }; - let query = args.into_package_query(None, &cwd).unwrap(); + let (query, _) = args.into_package_query(None, &cwd).unwrap(); match &query.0 { crate::package_graph::PackageQueryKind::Filters(filters) => { assert_eq!(filters.len(), 1); From 50e76ea80fa12fa010ff31613c4d23696dd3eee5 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 1 Mar 2026 16:06:53 +0800 Subject: [PATCH 31/35] refactor: use exhaustive match in into_package_query and validate empty filters Replace nested if-chains with a single match on (recursive, transitive, workspace_root, Option>) to ensure no flag combination is missed. Rename `filter` field to `filters`, reject empty/whitespace-only --filter values with EmptyFilter error, and add tests for the new validation. Co-Authored-By: Claude Opus 4.6 --- crates/vite_workspace/src/package_filter.rs | 335 ++++++++++++++------ 1 file changed, 240 insertions(+), 95 deletions(-) diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index 342aa1b8..02d6005d 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -200,7 +200,7 @@ pub enum PackageQueryError { #[error("cannot specify package name with --workspace-root")] PackageNameWithWorkspaceRoot { package_name: Str }, - #[error("--filter value contains no selectors (whitespace-only)")] + #[error("--filter value is empty")] EmptyFilter, #[error("invalid --filter expression")] @@ -241,7 +241,7 @@ Match packages by name, directory, or glob pattern. --filter ^... Select only the dependencies (exclude the package itself) --filter ! Exclude packages matching the pattern" )] - filter: Vec, + filters: Vec, } impl PackageQueryArgs { @@ -263,100 +263,103 @@ impl PackageQueryArgs { package_name: Option, cwd: &Arc, ) -> Result<(PackageQuery, bool), PackageQueryError> { - let Self { recursive, transitive, workspace_root, filter } = self; - - if recursive { - if transitive { - return Err(PackageQueryError::RecursiveTransitiveConflict); - } - if !filter.is_empty() { - return Err(PackageQueryError::FilterWithRecursive); + let Self { recursive, transitive, workspace_root, filters } = self; + + // Collect filter tokens from all `--filter` arguments, splitting on whitespace. + let mut filter_tokens = Vec::::with_capacity(filters.len()); + for filter in filters { + let mut is_empty = true; + for filter_token in filter.split_ascii_whitespace() { + is_empty = false; + filter_tokens.push(filter_token.into()); } - if let Some(package_name) = package_name { - return Err(PackageQueryError::PackageNameWithRecursive { package_name }); + // Error if any --filter value is empty or whitespace-only. + if is_empty { + return Err(PackageQueryError::EmptyFilter); } - // -w is redundant with -r (all packages already includes root). - return Ok((PackageQuery::all(), false)); } + // We have checked that filter_tokens is non-empty if any filters were provided, + // If no tokens are collected, it means no filters were provided. + let filter_tokens: Option> = Vec1::try_from_vec(filter_tokens).ok(); - if !filter.is_empty() { - if transitive { - return Err(PackageQueryError::FilterWithTransitive); + match (recursive, transitive, workspace_root, filter_tokens) { + (true, true, _, _) => Err(PackageQueryError::RecursiveTransitiveConflict), + (true, _, _, Some(_)) => Err(PackageQueryError::FilterWithRecursive), + // -w is redundant with -r (all packages already includes root). + (true, false, _, None) => { + if let Some(package_name) = package_name { + return Err(PackageQueryError::PackageNameWithRecursive { package_name }); + } + Ok((PackageQuery::all(), false)) } - if let Some(package_name) = package_name { - return Err(PackageQueryError::PackageNameWithFilter { package_name }); + (false, true, _, Some(_)) => Err(PackageQueryError::FilterWithTransitive), + (false, _, _, Some(filter_tokens)) => { + if let Some(package_name) = package_name { + return Err(PackageQueryError::PackageNameWithFilter { package_name }); + } + let mut parsed: Vec1 = + filter_tokens.try_mapped(|f| parse_filter(&f, cwd))?; + // pnpm: `-w` adds workspace root to the filter list (additive). + if workspace_root { + parsed.push(PackageFilter { + exclude: false, + selector: PackageSelector::WorkspaceRoot, + traversal: None, + source: None, + }); + } + Ok((PackageQuery::filters(parsed), false)) } - // Normalize: split each --filter value by whitespace into individual tokens. - // This makes `--filter "a b"` equivalent to `--filter a --filter b` (pnpm behaviour). - let tokens: Vec1 = Vec1::try_from_vec( - filter - .into_iter() - .flat_map(|f| f.split_ascii_whitespace().map(Str::from).collect::>()) - .collect(), - ) - .map_err(|_| PackageQueryError::EmptyFilter)?; - let mut parsed: Vec1 = tokens.try_mapped(|f| parse_filter(&f, cwd))?; - // pnpm: `-w` adds workspace root to the filter list (additive). - if workspace_root { - parsed.push(PackageFilter { - exclude: false, - selector: PackageSelector::WorkspaceRoot, - traversal: None, - source: None, - }); + // -w replaces the implicit cwd target with the workspace root. + // -w -t: workspace root with transitive dependencies. + (false, _, true, None) => { + if let Some(package_name) = package_name { + return Err(PackageQueryError::PackageNameWithWorkspaceRoot { package_name }); + } + let traversal = if transitive { + Some(GraphTraversal { + direction: TraversalDirection::Dependencies, + exclude_self: false, + }) + } else { + None + }; + Ok(( + PackageQuery::filters(Vec1::new(PackageFilter { + exclude: false, + selector: PackageSelector::WorkspaceRoot, + traversal, + source: None, + })), + false, + )) } - return Ok((PackageQuery::filters(parsed), false)); - } - - // No --filter, no --recursive. - // -w replaces the implicit cwd target with the workspace root. - // -w -t: workspace root with transitive dependencies. - if workspace_root { - if let Some(package_name) = package_name { - return Err(PackageQueryError::PackageNameWithWorkspaceRoot { package_name }); + // No flags: implicit cwd or package-name specifier. + (false, _, false, None) => { + let is_cwd_only = !transitive && package_name.is_none(); + let selector = package_name.map_or_else( + || PackageSelector::ContainingPackage(Arc::clone(cwd)), + |name| PackageSelector::Name(PackageNamePattern::Exact(name)), + ); + let traversal = if transitive { + Some(GraphTraversal { + direction: TraversalDirection::Dependencies, + exclude_self: false, + }) + } else { + None + }; + Ok(( + PackageQuery::filters(Vec1::new(PackageFilter { + exclude: false, + selector, + traversal, + source: None, + })), + is_cwd_only, + )) } - let traversal = if transitive { - Some(GraphTraversal { - direction: TraversalDirection::Dependencies, - exclude_self: false, - }) - } else { - None - }; - return Ok(( - PackageQuery::filters(Vec1::new(PackageFilter { - exclude: false, - selector: PackageSelector::WorkspaceRoot, - traversal, - source: None, - })), - false, - )); } - - // No --filter, no --recursive, no -w: implicit cwd or package-name specifier. - let is_cwd_only = !transitive && package_name.is_none(); - let selector = package_name.map_or_else( - || PackageSelector::ContainingPackage(Arc::clone(cwd)), - |name| PackageSelector::Name(PackageNamePattern::Exact(name)), - ); - let traversal = if transitive { - Some(GraphTraversal { - direction: TraversalDirection::Dependencies, - exclude_self: false, - }) - } else { - None - }; - Ok(( - PackageQuery::filters(Vec1::new(PackageFilter { - exclude: false, - selector, - traversal, - source: None, - })), - is_cwd_only, - )) } } @@ -1049,7 +1052,7 @@ mod tests { recursive: false, transitive: false, workspace_root: true, - filter: Vec::new(), + filters: Vec::new(), }; let (query, _) = args.into_package_query(None, &cwd).unwrap(); match &query.0 { @@ -1075,7 +1078,7 @@ mod tests { recursive: true, transitive: false, workspace_root: true, - filter: Vec::new(), + filters: Vec::new(), }; let (query, _) = args.into_package_query(None, &cwd).unwrap(); assert!( @@ -1093,7 +1096,7 @@ mod tests { recursive: false, transitive: true, workspace_root: true, - filter: Vec::new(), + filters: Vec::new(), }; let (query, _) = args.into_package_query(None, &cwd).unwrap(); match &query.0 { @@ -1118,7 +1121,7 @@ mod tests { recursive: false, transitive: false, workspace_root: true, - filter: vec![Str::from("foo")], + filters: vec![Str::from("foo")], }; let (query, _) = args.into_package_query(None, &cwd).unwrap(); match &query.0 { @@ -1142,7 +1145,7 @@ mod tests { recursive: false, transitive: false, workspace_root: true, - filter: Vec::new(), + filters: Vec::new(), }; assert!(matches!( args.into_package_query(Some(Str::from("app")), &cwd), @@ -1166,7 +1169,7 @@ mod tests { recursive: false, transitive: false, workspace_root: false, - filter: vec![Str::from("a b")], + filters: vec![Str::from("a b")], }; let (query, _) = args.into_package_query(None, &cwd).unwrap(); match &query.0 { @@ -1186,7 +1189,7 @@ mod tests { recursive: false, transitive: false, workspace_root: true, - filter: vec![Str::from("foo")], + filters: vec![Str::from("foo")], }; let (query, _) = args.into_package_query(None, &cwd).unwrap(); match &query.0 { @@ -1206,7 +1209,7 @@ mod tests { recursive: false, transitive: false, workspace_root: false, - filter: Vec::new(), + filters: Vec::new(), }; let (query, _) = args.into_package_query(None, &cwd).unwrap(); match &query.0 { @@ -1217,4 +1220,146 @@ mod tests { crate::package_graph::PackageQueryKind::All => panic!("expected Filters, got All"), } } + + // ── empty filter validation ───────────────────────────────────────────── + + #[test] + fn empty_filter_string_errors() { + let cwd: Arc = Arc::from(abs("/workspace")); + let args = PackageQueryArgs { + recursive: false, + transitive: false, + workspace_root: false, + filters: vec![Str::from("")], + }; + assert!(matches!(args.into_package_query(None, &cwd), Err(PackageQueryError::EmptyFilter))); + } + + #[test] + fn whitespace_only_filter_errors() { + let cwd: Arc = Arc::from(abs("/workspace")); + let args = PackageQueryArgs { + recursive: false, + transitive: false, + workspace_root: false, + filters: vec![Str::from(" ")], + }; + assert!(matches!(args.into_package_query(None, &cwd), Err(PackageQueryError::EmptyFilter))); + } + + #[test] + fn second_filter_empty_errors() { + let cwd: Arc = Arc::from(abs("/workspace")); + let args = PackageQueryArgs { + recursive: false, + transitive: false, + workspace_root: false, + filters: vec![Str::from("foo"), Str::from("")], + }; + assert!(matches!(args.into_package_query(None, &cwd), Err(PackageQueryError::EmptyFilter))); + } + + #[test] + fn first_filter_empty_with_valid_second_errors() { + let cwd: Arc = Arc::from(abs("/workspace")); + let args = PackageQueryArgs { + recursive: false, + transitive: false, + workspace_root: false, + filters: vec![Str::from(""), Str::from("foo")], + }; + assert!(matches!(args.into_package_query(None, &cwd), Err(PackageQueryError::EmptyFilter))); + } + + #[test] + fn valid_filter_with_whitespace_only_second_errors() { + let cwd: Arc = Arc::from(abs("/workspace")); + let args = PackageQueryArgs { + recursive: false, + transitive: false, + workspace_root: false, + filters: vec![Str::from("foo"), Str::from(" \t ")], + }; + assert!(matches!(args.into_package_query(None, &cwd), Err(PackageQueryError::EmptyFilter))); + } + + // ── is_cwd_only flag ───────────────────────────────────────────────────── + + #[test] + fn is_cwd_only_true_for_bare_invocation() { + let cwd: Arc = Arc::from(abs("/workspace/packages/app")); + let args = PackageQueryArgs { + recursive: false, + transitive: false, + workspace_root: false, + filters: Vec::new(), + }; + let (_, is_cwd_only) = args.into_package_query(None, &cwd).unwrap(); + assert!(is_cwd_only); + } + + #[test] + fn is_cwd_only_false_with_package_name() { + let cwd: Arc = Arc::from(abs("/workspace")); + let args = PackageQueryArgs { + recursive: false, + transitive: false, + workspace_root: false, + filters: Vec::new(), + }; + let (_, is_cwd_only) = args.into_package_query(Some(Str::from("app")), &cwd).unwrap(); + assert!(!is_cwd_only); + } + + #[test] + fn is_cwd_only_false_with_transitive() { + let cwd: Arc = Arc::from(abs("/workspace/packages/app")); + let args = PackageQueryArgs { + recursive: false, + transitive: true, + workspace_root: false, + filters: Vec::new(), + }; + let (_, is_cwd_only) = args.into_package_query(None, &cwd).unwrap(); + assert!(!is_cwd_only); + } + + #[test] + fn is_cwd_only_false_with_recursive() { + let cwd: Arc = Arc::from(abs("/workspace")); + let args = PackageQueryArgs { + recursive: true, + transitive: false, + workspace_root: false, + filters: Vec::new(), + }; + let (_, is_cwd_only) = args.into_package_query(None, &cwd).unwrap(); + assert!(!is_cwd_only); + } + + #[test] + fn is_cwd_only_false_with_filter() { + let cwd: Arc = Arc::from(abs("/workspace")); + let args = PackageQueryArgs { + recursive: false, + transitive: false, + workspace_root: false, + filters: vec![Str::from("foo")], + }; + let (_, is_cwd_only) = args.into_package_query(None, &cwd).unwrap(); + assert!(!is_cwd_only); + } + + #[test] + fn is_cwd_only_false_with_workspace_root() { + let cwd: Arc = Arc::from(abs("/workspace")); + let args = PackageQueryArgs { + recursive: false, + transitive: false, + workspace_root: true, + filters: Vec::new(), + }; + let (_, is_cwd_only) = args.into_package_query(None, &cwd).unwrap(); + assert!(!is_cwd_only); + } } From 30bd66a39c24eb3c0abf74f8fb8984cdb3d5f1d3 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 1 Mar 2026 16:38:54 +0800 Subject: [PATCH 32/35] refactor: include package_name in exhaustive match tuple Add package_name as the 5th element of the match tuple, replacing inner `if let Some(package_name)` checks with dedicated match arms. Each arm is commented with its CLI args. The last combined arm is split into three: `#`, `--transitive`, and bare invocation. Co-Authored-By: Claude Opus 4.6 --- crates/vite_workspace/src/package_filter.rs | 81 +++++++++++++-------- 1 file changed, 51 insertions(+), 30 deletions(-) diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index 02d6005d..4229f1f7 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -282,24 +282,27 @@ impl PackageQueryArgs { // If no tokens are collected, it means no filters were provided. let filter_tokens: Option> = Vec1::try_from_vec(filter_tokens).ok(); - match (recursive, transitive, workspace_root, filter_tokens) { - (true, true, _, _) => Err(PackageQueryError::RecursiveTransitiveConflict), - (true, _, _, Some(_)) => Err(PackageQueryError::FilterWithRecursive), - // -w is redundant with -r (all packages already includes root). - (true, false, _, None) => { - if let Some(package_name) = package_name { - return Err(PackageQueryError::PackageNameWithRecursive { package_name }); - } - Ok((PackageQuery::all(), false)) + match (recursive, transitive, workspace_root, filter_tokens, package_name) { + // --recursive --transitive + (true, true, _, _, _) => Err(PackageQueryError::RecursiveTransitiveConflict), + // --recursive --filter + (true, _, _, Some(_), _) => Err(PackageQueryError::FilterWithRecursive), + // --recursive # + (true, false, _, None, Some(package_name)) => { + Err(PackageQueryError::PackageNameWithRecursive { package_name }) } - (false, true, _, Some(_)) => Err(PackageQueryError::FilterWithTransitive), - (false, _, _, Some(filter_tokens)) => { - if let Some(package_name) = package_name { - return Err(PackageQueryError::PackageNameWithFilter { package_name }); - } + // --recursive (--workspace-root is redundant) + (true, false, _, None, None) => Ok((PackageQuery::all(), false)), + // --transitive --filter + (false, true, _, Some(_), _) => Err(PackageQueryError::FilterWithTransitive), + // --filter # + (false, _, _, Some(_), Some(package_name)) => { + Err(PackageQueryError::PackageNameWithFilter { package_name }) + } + // --filter [--workspace-root] + (false, _, _, Some(filter_tokens), None) => { let mut parsed: Vec1 = filter_tokens.try_mapped(|f| parse_filter(&f, cwd))?; - // pnpm: `-w` adds workspace root to the filter list (additive). if workspace_root { parsed.push(PackageFilter { exclude: false, @@ -310,12 +313,12 @@ impl PackageQueryArgs { } Ok((PackageQuery::filters(parsed), false)) } - // -w replaces the implicit cwd target with the workspace root. - // -w -t: workspace root with transitive dependencies. - (false, _, true, None) => { - if let Some(package_name) = package_name { - return Err(PackageQueryError::PackageNameWithWorkspaceRoot { package_name }); - } + // --workspace-root # + (false, _, true, None, Some(package_name)) => { + Err(PackageQueryError::PackageNameWithWorkspaceRoot { package_name }) + } + // --workspace-root [--transitive] + (false, _, true, None, None) => { let traversal = if transitive { Some(GraphTraversal { direction: TraversalDirection::Dependencies, @@ -334,13 +337,8 @@ impl PackageQueryArgs { false, )) } - // No flags: implicit cwd or package-name specifier. - (false, _, false, None) => { - let is_cwd_only = !transitive && package_name.is_none(); - let selector = package_name.map_or_else( - || PackageSelector::ContainingPackage(Arc::clone(cwd)), - |name| PackageSelector::Name(PackageNamePattern::Exact(name)), - ); + // [--transitive] # + (false, _, false, None, Some(name)) => { let traversal = if transitive { Some(GraphTraversal { direction: TraversalDirection::Dependencies, @@ -352,13 +350,36 @@ impl PackageQueryArgs { Ok(( PackageQuery::filters(Vec1::new(PackageFilter { exclude: false, - selector, + selector: PackageSelector::Name(PackageNamePattern::Exact(name)), traversal, source: None, })), - is_cwd_only, + false, )) } + // --transitive + (false, true, false, None, None) => Ok(( + PackageQuery::filters(Vec1::new(PackageFilter { + exclude: false, + selector: PackageSelector::ContainingPackage(Arc::clone(cwd)), + traversal: Some(GraphTraversal { + direction: TraversalDirection::Dependencies, + exclude_self: false, + }), + source: None, + })), + false, + )), + // (no flags, implicit cwd) + (false, false, false, None, None) => Ok(( + PackageQuery::filters(Vec1::new(PackageFilter { + exclude: false, + selector: PackageSelector::ContainingPackage(Arc::clone(cwd)), + traversal: None, + source: None, + })), + true, + )), } } } From 8f8436819f05473feebeed25912f652f32d17158 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 1 Mar 2026 16:53:01 +0800 Subject: [PATCH 33/35] refactor: tighten match arm patterns in into_package_query Error arms now only match the conflicting fields (wildcards for the rest); success arms explicitly match every field with no wildcards. Adds section-header comments separating the two groups. Co-Authored-By: Claude Opus 4.6 --- crates/vite_workspace/src/package_filter.rs | 29 +++++++++++++-------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index 4229f1f7..05ee4485 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -282,25 +282,36 @@ impl PackageQueryArgs { // If no tokens are collected, it means no filters were provided. let filter_tokens: Option> = Vec1::try_from_vec(filter_tokens).ok(); + // Error arms only match the conflicting fields (wildcards for the rest). + // Success arms explicitly match every field — no wildcards. match (recursive, transitive, workspace_root, filter_tokens, package_name) { + // ------------------------- error cases -------------------------------- + // --recursive --transitive (true, true, _, _, _) => Err(PackageQueryError::RecursiveTransitiveConflict), // --recursive --filter (true, _, _, Some(_), _) => Err(PackageQueryError::FilterWithRecursive), // --recursive # - (true, false, _, None, Some(package_name)) => { + (true, false, _, _, Some(package_name)) => { Err(PackageQueryError::PackageNameWithRecursive { package_name }) } - // --recursive (--workspace-root is redundant) - (true, false, _, None, None) => Ok((PackageQuery::all(), false)), // --transitive --filter (false, true, _, Some(_), _) => Err(PackageQueryError::FilterWithTransitive), // --filter # - (false, _, _, Some(_), Some(package_name)) => { + (_, _, _, Some(_), Some(package_name)) => { Err(PackageQueryError::PackageNameWithFilter { package_name }) } + // --workspace-root # + (_, _, true, _, Some(package_name)) => { + Err(PackageQueryError::PackageNameWithWorkspaceRoot { package_name }) + } + + // ------------------------ success cases ------------------------------- + + // --recursive (--workspace-root is redundant) + (true, false, true | false, None, None) => Ok((PackageQuery::all(), false)), // --filter [--workspace-root] - (false, _, _, Some(filter_tokens), None) => { + (false, false, workspace_root, Some(filter_tokens), None) => { let mut parsed: Vec1 = filter_tokens.try_mapped(|f| parse_filter(&f, cwd))?; if workspace_root { @@ -313,12 +324,8 @@ impl PackageQueryArgs { } Ok((PackageQuery::filters(parsed), false)) } - // --workspace-root # - (false, _, true, None, Some(package_name)) => { - Err(PackageQueryError::PackageNameWithWorkspaceRoot { package_name }) - } // --workspace-root [--transitive] - (false, _, true, None, None) => { + (false, transitive, true, None, None) => { let traversal = if transitive { Some(GraphTraversal { direction: TraversalDirection::Dependencies, @@ -338,7 +345,7 @@ impl PackageQueryArgs { )) } // [--transitive] # - (false, _, false, None, Some(name)) => { + (false, transitive, false, None, Some(name)) => { let traversal = if transitive { Some(GraphTraversal { direction: TraversalDirection::Dependencies, From ee9a08be9549a1c5ccd3d0b985a3e02353af5581 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 1 Mar 2026 17:42:35 +0800 Subject: [PATCH 34/35] feat: error on ambiguous package name in `pkg#task` specifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `vp run pkg#task` resolves to multiple packages sharing the same name, emit an `AmbiguousPackageName` error instead of silently running all of them. `--filter` with the same name continues to match all packages. Adds a `unique: bool` flag to `PackageNamePattern::Exact` — true for `pkg#task` specifiers, false for `--filter`. The resolution chain (`resolve_query` → `resolve_filters` → `match_by_name_pattern`) is now fallible, propagating through `query_tasks` to `plan_query_request`. Also renames the CLI flag from `--filters` to `--filter` (field name stays `filters`), and adds plan snapshot tests for the duplicate package name scenario. Co-Authored-By: Claude Opus 4.6 --- crates/vite_task_graph/src/query/mod.rs | 17 +++-- crates/vite_task_plan/src/error.rs | 3 + crates/vite_task_plan/src/plan.rs | 6 +- .../duplicate-package-names/package.json | 4 ++ .../packages/pkg-a/package.json | 7 ++ .../packages/pkg-b/package.json | 7 ++ .../pnpm-workspace.yaml | 2 + .../duplicate-package-names/snapshots.toml | 17 +++++ ...mbiguous package name with transitive.snap | 11 ++++ .../query - ambiguous package name.snap | 10 +++ ...uery - filter matches both duplicates.snap | 15 +++++ .../snapshots/task graph.snap | 49 ++++++++++++++ crates/vite_workspace/src/package_filter.rs | 21 ++++-- crates/vite_workspace/src/package_graph.rs | 64 +++++++++++++++---- 14 files changed, 204 insertions(+), 29 deletions(-) create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/packages/pkg-a/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/packages/pkg-b/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/pnpm-workspace.yaml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots.toml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - ambiguous package name with transitive.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - ambiguous package name.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - filter matches both duplicates.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/task graph.snap diff --git a/crates/vite_task_graph/src/query/mod.rs b/crates/vite_task_graph/src/query/mod.rs index 12677665..1f3c811a 100644 --- a/crates/vite_task_graph/src/query/mod.rs +++ b/crates/vite_task_graph/src/query/mod.rs @@ -20,7 +20,7 @@ use petgraph::{Direction, prelude::DiGraphMap, visit::EdgeRef}; use rustc_hash::{FxHashMap, FxHashSet}; use vite_str::Str; use vite_workspace::PackageNodeIndex; -pub use vite_workspace::package_graph::PackageQuery; +pub use vite_workspace::package_graph::{PackageQuery, PackageQueryResolveError}; use crate::{IndexedTaskGraph, TaskDependencyType, TaskId, TaskNodeIndex}; @@ -67,18 +67,25 @@ impl IndexedTaskGraph { /// unmatched selectors. The execution graph may be empty — the caller decides /// what to do in that case (show task selector, emit warnings, etc.). /// + /// # Errors + /// + /// Returns [`PackageQueryResolveError::AmbiguousPackageName`] when an exact + /// package name (from a `pkg#task` specifier) matches multiple packages. + /// /// # Order of operations /// /// 1. Resolve `PackageQuery` → package subgraph (Stage 1). /// 2. Map package subgraph → task execution graph, reconnecting task-lacking /// packages (Stage 2). /// 3. Expand explicit `dependsOn` edges (if `include_explicit_deps`). - #[must_use] - pub fn query_tasks(&self, query: &TaskQuery) -> TaskQueryResult { + pub fn query_tasks( + &self, + query: &TaskQuery, + ) -> Result { let mut execution_graph = TaskExecutionGraph::default(); // Stage 1: resolve package selection. - let resolution = self.indexed_package_graph.resolve_query(&query.package_query); + let resolution = self.indexed_package_graph.resolve_query(&query.package_query)?; // Stage 2: map each selected package to its task node (with reconnection). self.map_subgraph_to_tasks( @@ -92,7 +99,7 @@ impl IndexedTaskGraph { self.add_dependencies(&mut execution_graph, |_| TaskDependencyType::is_explicit()); } - TaskQueryResult { execution_graph, unmatched_selectors: resolution.unmatched_selectors } + Ok(TaskQueryResult { execution_graph, unmatched_selectors: resolution.unmatched_selectors }) } /// Map a package subgraph to a task execution graph. diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs index d118cd00..cd5d9e2a 100644 --- a/crates/vite_task_plan/src/error.rs +++ b/crates/vite_task_plan/src/error.rs @@ -93,6 +93,9 @@ pub enum Error { error: Box, }, + #[error(transparent)] + PackageQueryResolve(#[from] vite_task_graph::query::PackageQueryResolveError), + #[error("Failed to load task graph")] TaskGraphLoad( #[source] diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index 322b78df..3c8535de 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -519,9 +519,9 @@ pub async fn plan_query_request( ) -> Result { context.set_extra_args(Arc::clone(&query_plan_request.plan_options.extra_args)); // Query matching tasks from the task graph. - // `query_tasks` is infallible — 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); + // 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)?; #[expect(clippy::print_stderr, reason = "user-facing warning for typos in --filter")] for selector in &task_query_result.unmatched_selectors { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/package.json new file mode 100644 index 00000000..b65cafad --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/package.json @@ -0,0 +1,4 @@ +{ + "name": "test-workspace", + "private": true +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/packages/pkg-a/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/packages/pkg-a/package.json new file mode 100644 index 00000000..59cfc476 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/packages/pkg-a/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/duplicate", + "version": "1.0.0", + "scripts": { + "build": "echo build-a" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/packages/pkg-b/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/packages/pkg-b/package.json new file mode 100644 index 00000000..aaf60658 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/packages/pkg-b/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/duplicate", + "version": "1.0.0", + "scripts": { + "build": "echo build-b" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/pnpm-workspace.yaml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots.toml new file mode 100644 index 00000000..0cfd74a7 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots.toml @@ -0,0 +1,17 @@ +# Tests that running pkg#task errors when multiple packages share the same name, +# while --filter with the same name still matches all packages. + +[[plan]] +compact = true +name = "ambiguous package name" +args = ["run", "@test/duplicate#build"] + +[[plan]] +compact = true +name = "ambiguous package name with transitive" +args = ["run", "-t", "@test/duplicate#build"] + +[[plan]] +compact = true +name = "filter matches both duplicates" +args = ["run", "--filter", "@test/duplicate", "build"] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - ambiguous package name with transitive.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - ambiguous package name with transitive.snap new file mode 100644 index 00000000..7d330945 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - ambiguous package name with transitive.snap @@ -0,0 +1,11 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: err_str.as_ref() +info: + args: + - run + - "-t" + - "@test/duplicate#build" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names +--- +Package name '@test/duplicate' is ambiguous; found in multiple locations: ["/packages/pkg-a", "/packages/pkg-b"] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - ambiguous package name.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - ambiguous package name.snap new file mode 100644 index 00000000..4b969913 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - ambiguous package name.snap @@ -0,0 +1,10 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: err_str.as_ref() +info: + args: + - run + - "@test/duplicate#build" +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names +--- +Package name '@test/duplicate' is ambiguous; found in multiple locations: ["/packages/pkg-a", "/packages/pkg-b"] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - filter matches both duplicates.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - filter matches both duplicates.snap new file mode 100644 index 00000000..17b77c66 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - filter matches both duplicates.snap @@ -0,0 +1,15 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "@test/duplicate" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names +--- +{ + "packages/pkg-a#build": [], + "packages/pkg-b#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/task graph.snap new file mode 100644 index 00000000..4a80eea2 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/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/duplicate-package-names +--- +[ + { + "key": [ + "/packages/pkg-a", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/duplicate", + "task_name": "build", + "package_path": "/packages/pkg-a" + }, + "resolved_config": { + "command": "echo build-a", + "resolved_options": { + "cwd": "/packages/pkg-a", + "cache_config": null + } + } + }, + "neighbors": [] + }, + { + "key": [ + "/packages/pkg-b", + "build" + ], + "node": { + "task_display": { + "package_name": "@test/duplicate", + "task_name": "build", + "package_path": "/packages/pkg-b" + }, + "resolved_config": { + "command": "echo build-b", + "resolved_options": { + "cwd": "/packages/pkg-b", + "cache_config": null + } + } + }, + "neighbors": [] + } +] diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index 05ee4485..a4dc414a 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -49,7 +49,10 @@ pub(crate) enum PackageNamePattern { /// Scoped auto-completion applies during resolution: if `"bar"` has no exact match /// but exactly one `@*/bar` package exists, that package is matched. /// pnpm ref: - Exact(Str), + /// + /// When `unique` is true, resolution errors if multiple packages share the + /// name. Set for `pkg#task` CLI specifiers; false for `--filter`. + Exact { name: Str, unique: bool }, /// Glob pattern (e.g. `@scope/*`, `*-utils`). Iterates all packages. /// @@ -228,7 +231,7 @@ pub struct PackageQueryArgs { /// Match packages by name, directory, or glob pattern. #[clap( short = 'F', - long, + long = "filter", num_args = 1, long_help = "\ Match packages by name, directory, or glob pattern. @@ -258,6 +261,7 @@ impl PackageQueryArgs { /// /// Returns [`PackageQueryError`] if conflicting flags are set, a package name /// is specified with `--recursive` or `--filter`, or a filter expression is invalid. + #[expect(clippy::too_many_lines, reason = "single exhaustive match")] pub fn into_package_query( self, package_name: Option, @@ -357,7 +361,10 @@ impl PackageQueryArgs { Ok(( PackageQuery::filters(Vec1::new(PackageFilter { exclude: false, - selector: PackageSelector::Name(PackageNamePattern::Exact(name)), + selector: PackageSelector::Name(PackageNamePattern::Exact { + name, + unique: true, + }), traversal, source: None, })), @@ -565,7 +572,7 @@ fn build_name_pattern(name: &str) -> Result { + PackageSelector::Name(PackageNamePattern::Exact { name: n, .. }) => { assert_eq!(n.as_str(), expected, "exact name mismatch"); } other => panic!("expected Name(Exact({expected:?})), got {other:?}"), @@ -651,7 +658,7 @@ mod tests { ) { match &filter.selector { PackageSelector::NameAndDirectory { - name: PackageNamePattern::Exact(n), + name: PackageNamePattern::Exact { name: n, .. }, directory: DirectoryPattern::Exact(dir), } => { assert_eq!(n.as_str(), expected_name, "name mismatch"); @@ -671,7 +678,7 @@ mod tests { ) { match &filter.selector { PackageSelector::NameAndDirectory { - name: PackageNamePattern::Exact(n), + name: PackageNamePattern::Exact { name: n, .. }, directory: DirectoryPattern::Glob { base, pattern }, } => { assert_eq!(n.as_str(), expected_name, "name mismatch"); diff --git a/crates/vite_workspace/src/package_graph.rs b/crates/vite_workspace/src/package_graph.rs index f4aa7dea..f3749941 100644 --- a/crates/vite_workspace/src/package_graph.rs +++ b/crates/vite_workspace/src/package_graph.rs @@ -93,6 +93,19 @@ pub struct FilterResolution { pub unmatched_selectors: Vec, } +// ──────────────────────────────────────────────────────────────────────────── +// PackageQueryResolveError +// ──────────────────────────────────────────────────────────────────────────── + +/// Errors that can occur when resolving a [`PackageQuery`] against the workspace. +#[derive(Debug, thiserror::Error)] +pub enum PackageQueryResolveError { + #[error( + "Package name '{package_name}' is ambiguous; found in multiple locations: {package_paths:?}" + )] + AmbiguousPackageName { package_name: Str, package_paths: Box<[Arc]> }, +} + // ──────────────────────────────────────────────────────────────────────────── // IndexedPackageGraph // ──────────────────────────────────────────────────────────────────────────── @@ -172,13 +185,20 @@ impl IndexedPackageGraph { /// For `All`, returns the full induced subgraph (every package, every edge). /// For `Filters`, applies the filter algorithm and returns the induced subgraph /// of the matching packages. - #[must_use] - pub fn resolve_query(&self, query: &PackageQuery) -> FilterResolution { + /// + /// # Errors + /// + /// Returns [`PackageQueryResolveError::AmbiguousPackageName`] when an exact + /// package name (from a `pkg#task` specifier) matches multiple packages. + pub fn resolve_query( + &self, + query: &PackageQuery, + ) -> Result { match &query.0 { - PackageQueryKind::All => FilterResolution { + PackageQueryKind::All => Ok(FilterResolution { package_subgraph: self.full_subgraph(), unmatched_selectors: Vec::new(), - }, + }), PackageQueryKind::Filters(filters) => self.resolve_filters(filters.as_slice()), } } @@ -210,7 +230,10 @@ impl IndexedPackageGraph { /// 4. For each exclusion: resolve + expand, then subtract from `selected`. /// 5. Build the induced subgraph: every original edge whose both endpoints are /// in `selected` is preserved, regardless of how each endpoint was selected. - fn resolve_filters(&self, filters: &[PackageFilter]) -> FilterResolution { + fn resolve_filters( + &self, + filters: &[PackageFilter], + ) -> Result { let mut unmatched_selectors = Vec::new(); let (inclusions, exclusions): (Vec<_>, Vec<_>) = filters.iter().partition(|f| !f.exclude); @@ -224,7 +247,7 @@ impl IndexedPackageGraph { // Apply inclusions: union each filter's resolved set into `selected`. for filter in &inclusions { - let matched = self.resolve_selector_entries(&filter.selector); + let matched = self.resolve_selector_entries(&filter.selector)?; if matched.is_empty() && let Some(source) = &filter.source { @@ -236,7 +259,7 @@ impl IndexedPackageGraph { // Apply exclusions: subtract each filter's resolved set from `selected`. for filter in &exclusions { - let matched = self.resolve_selector_entries(&filter.selector); + let matched = self.resolve_selector_entries(&filter.selector)?; let to_remove = self.expand_traversal(matched, filter.traversal.as_ref()); for pkg in to_remove { selected.remove(&pkg); @@ -244,17 +267,20 @@ impl IndexedPackageGraph { } let package_subgraph = self.build_induced_subgraph(&selected); - FilterResolution { package_subgraph, unmatched_selectors } + Ok(FilterResolution { package_subgraph, unmatched_selectors }) } /// Resolve a `PackageSelector` to the set of directly matched packages /// (before any graph traversal expansion). - fn resolve_selector_entries(&self, selector: &PackageSelector) -> FxHashSet { + fn resolve_selector_entries( + &self, + selector: &PackageSelector, + ) -> Result, PackageQueryResolveError> { let mut matched = FxHashSet::default(); match selector { PackageSelector::Name(pattern) => { - self.match_by_name_pattern(pattern, &mut matched); + self.match_by_name_pattern(pattern, &mut matched)?; } PackageSelector::Directory(dir_pattern) => { @@ -271,7 +297,7 @@ impl IndexedPackageGraph { PackageSelector::NameAndDirectory { name, directory } => { // Intersection: packages satisfying both name AND directory. let mut by_name = FxHashSet::default(); - self.match_by_name_pattern(name, &mut by_name); + self.match_by_name_pattern(name, &mut by_name)?; let mut by_dir = FxHashSet::default(); self.match_by_directory_pattern(directory, &mut by_dir); matched.extend(by_name.intersection(&by_dir)); @@ -288,7 +314,7 @@ impl IndexedPackageGraph { } } - matched + Ok(matched) } /// Match packages by a name pattern, inserting into `out`. @@ -301,10 +327,19 @@ impl IndexedPackageGraph { &self, pattern: &PackageNamePattern, out: &mut FxHashSet, - ) { + ) -> Result<(), PackageQueryResolveError> { match pattern { - PackageNamePattern::Exact(name) => { + PackageNamePattern::Exact { name, unique } => { if let Some(indices) = self.get_package_indices_by_name(name) { + if *unique && indices.len() > 1 { + return Err(PackageQueryResolveError::AmbiguousPackageName { + package_name: name.clone(), + package_paths: indices + .iter() + .map(|i| Arc::clone(&self.graph[*i].absolute_path)) + .collect(), + }); + } out.extend(indices.iter().copied()); } else { // Scoped auto-completion: `"bar"` → `"@scope/bar"` if exactly one match. @@ -334,6 +369,7 @@ impl IndexedPackageGraph { } } } + Ok(()) } /// Match packages by a directory pattern, inserting into `out`. From 462763a4966c1206e6532707ac2e457a9d6c10a0 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 1 Mar 2026 18:04:18 +0800 Subject: [PATCH 35/35] fix: display ambiguous package paths without quoting Use `Path::display()` instead of `Debug` formatting for package paths in the `AmbiguousPackageName` error message, avoiding platform-specific quoting differences that break Windows CI snapshots. Co-Authored-By: Claude Opus 4.6 --- .../query - ambiguous package name with transitive.snap | 2 +- .../snapshots/query - ambiguous package name.snap | 2 +- crates/vite_workspace/src/package_graph.rs | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - ambiguous package name with transitive.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - ambiguous package name with transitive.snap index 7d330945..3a695001 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - ambiguous package name with transitive.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - ambiguous package name with transitive.snap @@ -8,4 +8,4 @@ info: - "@test/duplicate#build" input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names --- -Package name '@test/duplicate' is ambiguous; found in multiple locations: ["/packages/pkg-a", "/packages/pkg-b"] +Package name '@test/duplicate' is ambiguous; found in multiple locations: /packages/pkg-a, /packages/pkg-b diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - ambiguous package name.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - ambiguous package name.snap index 4b969913..05e375f0 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - ambiguous package name.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/query - ambiguous package name.snap @@ -7,4 +7,4 @@ info: - "@test/duplicate#build" input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names --- -Package name '@test/duplicate' is ambiguous; found in multiple locations: ["/packages/pkg-a", "/packages/pkg-b"] +Package name '@test/duplicate' is ambiguous; found in multiple locations: /packages/pkg-a, /packages/pkg-b diff --git a/crates/vite_workspace/src/package_graph.rs b/crates/vite_workspace/src/package_graph.rs index f3749941..6c4aec88 100644 --- a/crates/vite_workspace/src/package_graph.rs +++ b/crates/vite_workspace/src/package_graph.rs @@ -101,7 +101,8 @@ pub struct FilterResolution { #[derive(Debug, thiserror::Error)] pub enum PackageQueryResolveError { #[error( - "Package name '{package_name}' is ambiguous; found in multiple locations: {package_paths:?}" + "Package name '{package_name}' is ambiguous; found in multiple locations: {}", + package_paths.iter().map(|p| p.as_path().display().to_string()).collect::>().join(", ") )] AmbiguousPackageName { package_name: Str, package_paths: Box<[Arc]> }, }