diff --git a/Cargo.lock b/Cargo.lock index e800887e..f358cc3d 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" @@ -2262,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" @@ -3930,7 +3927,6 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "clap", "monostate", "petgraph", "pretty_assertions", @@ -3939,7 +3935,6 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "ts-rs", - "vec1", "vite_graph_ser", "vite_path", "vite_str", @@ -4006,6 +4001,8 @@ dependencies = [ name = "vite_workspace" version = "0.0.0" dependencies = [ + "clap", + "path-clean", "petgraph", "rustc-hash", "serde", @@ -4165,16 +4162,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..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"] } @@ -145,7 +146,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_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/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 bdb21736..dca722db 100644 --- a/crates/vite_task/src/cli/mod.rs +++ b/crates/vite_task/src/cli/mod.rs @@ -1,10 +1,11 @@ -use std::{iter, sync::Arc}; +use std::sync::Arc; use clap::Parser; use vite_path::AbsolutePath; use vite_str::Str; -use vite_task_graph::{TaskSpecifier, query::TaskQueryKind}; +use vite_task_graph::{TaskSpecifier, query::TaskQuery}; use vite_task_plan::plan_request::{PlanOptions, QueryPlanRequest}; +use vite_workspace::package_filter::{PackageQueryArgs, PackageQueryError}; #[derive(Debug, Clone, clap::Subcommand)] pub enum CacheSubcommand { @@ -13,19 +14,10 @@ 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)] -#[expect(clippy::struct_excessive_bools, reason = "CLI flags are naturally boolean")] +#[derive(Debug, Clone, clap::Args)] 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)] @@ -145,11 +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(transparent)] + PackageQuery(#[from] PackageQueryError), } impl ResolvedRunCommand { @@ -157,45 +146,26 @@ 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, .. }, - additional_args, - } = self; - - let task_specifier = task_specifier.ok_or(CLITaskQueryError::MissingTaskSpecifier)?; - - let include_explicit_deps = !ignore_depends_on; - - let query_kind = if recursive { - if transitive { - return Err(CLITaskQueryError::RecursiveTransitiveConflict); - } - let task_name = if let Some(package_name) = task_specifier.package_name { - return Err(CLITaskQueryError::PackageNameSpecifiedWithRecursive { - package_name, - task_name: task_specifier.task_name, - }); - } else { - task_specifier.task_name - }; - TaskQueryKind::Recursive { task_names: iter::once(task_name).collect() } - } else { - TaskQueryKind::Normal { - task_specifiers: iter::once(task_specifier).collect(), - cwd: Arc::clone(cwd), - include_topological_deps: transitive, - } - }; + let task_specifier = self.task_specifier.ok_or(CLITaskQueryError::MissingTaskSpecifier)?; + + 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; + Ok(QueryPlanRequest { - query: vite_task_graph::query::TaskQuery { kind: query_kind, include_explicit_deps }, - plan_options: PlanOptions { extra_args: additional_args.into() }, + query: TaskQuery { + package_query, + task_name: task_specifier.task_name, + include_explicit_deps, + }, + plan_options: PlanOptions { extra_args: self.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/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_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_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_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_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/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..1f3c811a 100644 --- a/crates/vite_task_graph/src/query/mod.rs +++ b/crates/vite_task_graph/src/query/mod.rs @@ -1,195 +1,200 @@ -pub mod cli; - -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, PackageQueryResolveError}; -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 task specifiers and options. - /// The tasks will be searched in packages in task specifiers, or located from cwd. - Normal { - task_specifiers: FxHashSet, - /// 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. - /// The whole workspace will be searched, so cwd is not relevant. - Recursive { task_names: FxHashSet }, -} +/// 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, + + /// 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, } 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 /// - /// Returns [`TaskQueryError::SpecifierLookupError`] if a task specifier cannot be resolved - /// to a task in the graph. - pub fn query_tasks(&self, query: TaskQuery) -> Result { + /// 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`). + pub fn query_tasks( + &self, + query: &TaskQuery, + ) -> Result { 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_specifiers, 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 - return Err(TaskQueryError::SpecifierLookupError { - 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()); + } + + Ok(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_names } => { - // Add all tasks matching the names 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) { - 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..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] @@ -113,13 +116,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 +140,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 +161,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..3c8535de 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,17 @@ 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. + // 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 = 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..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" - ], - "Explicit" + "/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 13301c92..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" - ], - "Explicit" + "/", + "task-b" ] ] }, @@ -68,11 +65,8 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cycle-dependency }, "neighbors": [ [ - [ - "/", - "task-a" - ], - "Explicit" + "/", + "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 3e12ca09..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" - ], - "Both" + "/packages/b", + "build" ] ] }, 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..3a695001 --- /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..05e375f0 --- /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_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..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,25 +32,12 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-te }, "neighbors": [ [ - [ - "/packages/another-empty", - "lint" - ], - "Explicit" + "/packages/another-empty", + "lint" ], [ - [ - "/packages/normal-package", - "build" - ], - "Topological" - ], - [ - [ - "/packages/normal-package", - "test" - ], - "Explicit" + "/packages/normal-package", + "test" ] ] }, @@ -75,18 +62,12 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-te }, "neighbors": [ [ - [ - "/packages/another-empty", - "build" - ], - "Explicit" + "/packages/another-empty", + "build" ], [ - [ - "/packages/another-empty", - "test" - ], - "Explicit" + "/packages/another-empty", + "test" ] ] }, @@ -144,15 +125,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-te } } }, - "neighbors": [ - [ - [ - "/packages/normal-package", - "test" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -182,11 +155,8 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-te }, "neighbors": [ [ - [ - "/packages/empty-name", - "test" - ], - "Explicit" + "/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 d74402a6..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 @@ -30,15 +30,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo } } }, - "neighbors": [ - [ - [ - "/packages/utils", - "build" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -61,25 +53,16 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo }, "neighbors": [ [ - [ - "/packages/app", - "build" - ], - "Explicit" + "/packages/app", + "build" ], [ - [ - "/packages/app", - "test" - ], - "Explicit" + "/packages/app", + "test" ], [ - [ - "/packages/utils", - "lint" - ], - "Explicit" + "/packages/utils", + "lint" ] ] }, @@ -137,15 +120,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo } } }, - "neighbors": [ - [ - [ - "/packages/utils", - "test" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -231,11 +206,8 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo }, "neighbors": [ [ - [ - "/packages/core", - "clean" - ], - "Explicit" + "/packages/core", + "clean" ] ] }, @@ -293,15 +265,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo } } }, - "neighbors": [ - [ - [ - "/packages/core", - "build" - ], - "Topological" - ] - ] + "neighbors": [] }, { "key": [ @@ -331,25 +295,12 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo }, "neighbors": [ [ - [ - "/packages/core", - "build" - ], - "Explicit" - ], - [ - [ - "/packages/core", - "lint" - ], - "Topological" + "/packages/core", + "build" ], [ - [ - "/packages/utils", - "build" - ], - "Explicit" + "/packages/utils", + "build" ] ] }, @@ -379,14 +330,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..e859d763 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/package.json @@ -0,0 +1,10 @@ +{ + "name": "test-workspace", + "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 new file mode 100644 index 00000000..28207377 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/app/package.json @@ -0,0 +1,16 @@ +{ + "name": "@test/app", + "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" + }, + "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..3b5bd74e --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/packages/core/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/core", + "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/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..ad3d632e --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots.toml @@ -0,0 +1,227 @@ +# 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: 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 +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" — exact path, no glob +[[plan]] +compact = true +name = "filter by directory" +args = ["run", "--filter", "./packages/app", "build"] + +# 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 ignores trailing dots" +args = ["run", "--filter", "./packages/app...", "build"] + +# `....` = `.` + `...` — traversal discarded on unbraced path, equivalent to `--filter .` +[[plan]] +compact = true +name = "filter dot ignores trailing dots from package" +args = ["run", "--filter", "....", "build"] +cwd = "packages/app" + +# `.....` = `..` + `...` — traversal discarded on unbraced path, equivalent to `--filter ..` +[[plan]] +compact = true +name = "filter dotdot ignores trailing dots" +args = ["run", "--filter", ".....", "build"] +cwd = "packages/app/src" + +# 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"] + +# -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" +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" + +# -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 +name = "recursive flag" +args = ["run", "-r", "build"] + +# 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/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. +[[plan]] +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 +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). +# 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", "check"] + +# -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 - 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..54be2e45 --- /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,19 @@ +--- +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 +--- +{ + "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 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 - 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..06b4dd87 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - exclude only filter.snap @@ -0,0 +1,20 @@ +--- +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 +--- +{ + "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..e88d60fd --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter both deps and dependents.snap @@ -0,0 +1,22 @@ +--- +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 +--- +{ + "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 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 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..b803c2a0 --- /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,25 @@ +--- +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 +--- +{ + "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..acb90629 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory glob star.snap @@ -0,0 +1,25 @@ +--- +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 +--- +{ + "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 ignores trailing dots.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory ignores trailing dots.snap new file mode 100644 index 00000000..c63ae72d --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory ignores trailing dots.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 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..a7e2ac9e --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by directory.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 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..39dd7de2 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by exact name.snap @@ -0,0 +1,14 @@ +--- +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 +--- +{ + "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..8ede9303 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter by glob.snap @@ -0,0 +1,25 @@ +--- +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 +--- +{ + "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 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 - 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..1dd1570e --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dependencies only exclude self.snap @@ -0,0 +1,18 @@ +--- +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 +--- +{ + "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..885bc6bc --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dependents only exclude self.snap @@ -0,0 +1,18 @@ +--- +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 +--- +{ + "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..13639a07 --- /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,20 @@ +--- +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 +--- +{ + "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 dot ignores trailing dots from package.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dot ignores trailing dots from package.snap new file mode 100644 index 00000000..af2acf36 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dot ignores trailing dots from package.snap @@ -0,0 +1,15 @@ +--- +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 +--- +{ + "packages/app#build": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dotdot ignores trailing dots.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dotdot ignores trailing dots.snap new file mode 100644 index 00000000..cd3146c6 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter dotdot ignores trailing dots.snap @@ -0,0 +1,15 @@ +--- +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 +--- +{ + "packages/app#build": [] +} 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..f987328b --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter include and exclude.snap @@ -0,0 +1,22 @@ +--- +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 +--- +{ + "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 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..2827cc22 --- /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,15 @@ +--- +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 +--- +{ + "packages/app#build": [], + "packages/cli#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..8e0d9be8 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter with dependencies.snap @@ -0,0 +1,22 @@ +--- +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 +--- +{ + "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..5576ed45 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - filter with dependents.snap @@ -0,0 +1,23 @@ +--- +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 +--- +{ + "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..a8cecdcc --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - mixed traversal filters.snap @@ -0,0 +1,22 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "--filter" + - "@test/lib..." + - "--filter" + - "@test/cli" + - build +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "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 - 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": [] +} 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..64f5f14c --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - multiple filters union.snap @@ -0,0 +1,17 @@ +--- +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 +--- +{ + "packages/app#build": [], + "packages/cli#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..b05cd427 --- /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,21 @@ +--- +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 +--- +{ + "packages/app#deploy": { + "items": [ + { + "packages/app#build": [] + } + ], + "neighbors": [] + } +} 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..6be57072 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - recursive flag.snap @@ -0,0 +1,24 @@ +--- +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 +--- +{ + "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..fa64212e --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive flag from package subfolder.snap @@ -0,0 +1,22 @@ +--- +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 +--- +{ + "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..cc10050d --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive flag.snap @@ -0,0 +1,22 @@ +--- +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 +--- +{ + "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 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..3adf1aa0 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - transitive with package specifier.snap @@ -0,0 +1,21 @@ +--- +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 +--- +{ + "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 - 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..21418d25 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/query - workspace root with recursive.snap @@ -0,0 +1,20 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: "&compact_plan" +info: + args: + - run + - "-w" + - "-r" + - check +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace +--- +{ + "#check": [ + "packages/core#check" + ], + "packages/app#check": [ + "packages/core#check" + ], + "packages/core#check": [] +} 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 new file mode 100644 index 00000000..940f7cb9 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/task graph.snap @@ -0,0 +1,371 @@ +--- +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": [ + "/", + "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", + "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", + "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", + "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", + "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", + "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", + "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..c5f5f8cb --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots.toml @@ -0,0 +1,42 @@ +# 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" + +# -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 - 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..ebd35c8c --- /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,14 @@ +--- +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 +--- +{ + "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..384b5b11 --- /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,17 @@ +--- +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 +--- +{ + "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..396cec4e --- /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,17 @@ +--- +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 +--- +{ + "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..6dd0d91b --- /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,14 @@ +--- +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 +--- +{ + "packages/bottom#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..0c93f028 --- /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,13 @@ +--- +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 +--- +{ + "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_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(); diff --git a/crates/vite_workspace/Cargo.toml b/crates/vite_workspace/Cargo.toml index f11ce44a..8eb18aa2 100644 --- a/crates/vite_workspace/Cargo.toml +++ b/crates/vite_workspace/Cargo.toml @@ -8,6 +8,8 @@ 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 } serde = { workspace = true, features = ["derive"] } 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 31da612e..fbe5f0cf 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}; @@ -11,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, @@ -76,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 new file mode 100644 index 00000000..a4dc414a --- /dev/null +++ b/crates/vite_workspace/src/package_filter.rs @@ -0,0 +1,1400 @@ +//! 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). +//! +//! [`parsePackageSelector`]: https://github.com/pnpm/pnpm/blob/05dd45ea82fff9c0b687cdc8f478a1027077d343/workspace/filter-workspace-packages/src/parsePackageSelector.ts#L14-L61 + +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(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 + /// but exactly one `@*/bar` package exists, that package is matched. + /// pnpm ref: + /// + /// 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. + /// + /// Only `*` and `?` wildcards are supported (pnpm semantics). + /// Stored as an owned `Glob<'static>` so the filter can outlive the input string. + 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(crate) 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 +/// with all optional fields (as in pnpm's independent optional fields). +#[derive(Debug, Clone)] +pub(crate) 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}`. + /// + /// 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). + /// + /// 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: 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. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) 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...`. + /// pnpm ref: + 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(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(crate) 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(crate) struct PackageFilter { + /// When `true`, packages matching this filter are **excluded** from the result. + /// Produced by a leading `!` in the filter string. + pub(crate) exclude: bool, + + /// Which packages to initially match. + pub(crate) selector: PackageSelector, + + /// 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, +} + +// ──────────────────────────────────────────────────────────────────────────── +// 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), +} + +// ──────────────────────────────────────────────────────────────────────────── +// 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("cannot specify package name with --workspace-root")] + PackageNameWithWorkspaceRoot { package_name: Str }, + + #[error("--filter value is empty")] + 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 { + /// Select all packages in the workspace. + #[clap(default_value = "false", short, long)] + recursive: bool, + + /// Select the current package and its transitive dependencies. + #[clap(default_value = "false", short, long)] + transitive: bool, + + /// Select the workspace root package. + #[clap(default_value = "false", short = 'w', long = "workspace-root")] + workspace_root: bool, + + /// Match packages by name, directory, or glob pattern. + #[clap( + short = 'F', + long = "filter", + 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" + )] + filters: 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). + /// + /// 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 + /// is specified with `--recursive` or `--filter`, or a filter expression is invalid. + #[expect(clippy::too_many_lines, reason = "single exhaustive match")] + pub fn into_package_query( + self, + package_name: Option, + cwd: &Arc, + ) -> Result<(PackageQuery, bool), PackageQueryError> { + let Self { recursive, transitive, workspace_root, filters } = self; + + // Collect filter tokens from all `--filter` arguments, splitting on whitespace. + let mut filter_tokens = Vec::::with_capacity(filters.len()); + for filter in filters { + let mut is_empty = true; + for filter_token in filter.split_ascii_whitespace() { + is_empty = false; + filter_tokens.push(filter_token.into()); + } + // Error if any --filter value is empty or whitespace-only. + if is_empty { + return Err(PackageQueryError::EmptyFilter); + } + } + // We have checked that filter_tokens is non-empty if any filters were provided, + // If no tokens are collected, it means no filters were provided. + let filter_tokens: Option> = Vec1::try_from_vec(filter_tokens).ok(); + + // 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, _, _, Some(package_name)) => { + Err(PackageQueryError::PackageNameWithRecursive { package_name }) + } + // --transitive --filter + (false, true, _, Some(_), _) => Err(PackageQueryError::FilterWithTransitive), + // --filter # + (_, _, _, 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, false, workspace_root, Some(filter_tokens), None) => { + let mut parsed: Vec1 = + filter_tokens.try_mapped(|f| parse_filter(&f, cwd))?; + if workspace_root { + parsed.push(PackageFilter { + exclude: false, + selector: PackageSelector::WorkspaceRoot, + traversal: None, + source: None, + }); + } + Ok((PackageQuery::filters(parsed), false)) + } + // --workspace-root [--transitive] + (false, transitive, true, None, None) => { + let traversal = if transitive { + Some(GraphTraversal { + direction: TraversalDirection::Dependencies, + exclude_self: false, + }) + } else { + None + }; + Ok(( + PackageQuery::filters(Vec1::new(PackageFilter { + exclude: false, + selector: PackageSelector::WorkspaceRoot, + traversal, + source: None, + })), + false, + )) + } + // [--transitive] # + (false, transitive, false, None, Some(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::Name(PackageNamePattern::Exact { + name, + unique: true, + }), + traversal, + source: None, + })), + 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, + )), + } + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// 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. +/// +/// [`parsePackageSelector`]: https://github.com/pnpm/pnpm/blob/05dd45ea82fff9c0b687cdc8f478a1027077d343/workspace/filter-workspace-packages/src/parsePackageSelector.ts#L14-L61 +pub(crate) 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, 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, source: Some(Str::from(input)) }) +} + +/// Parse the core selector string (after stripping `!` and `...` markers). +/// +/// 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. +/// 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). +/// +/// 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<(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('}') + && 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_directory_pattern(dir_inner, cwd)?; + + return if name_part.is_empty() { + // Only a directory selector: `{./foo}` or `{packages/app}`. + Ok((PackageSelector::Directory(directory), true)) + } else { + // Name and directory combined: `foo{./bar}`. + let name = build_name_pattern(name_part)?; + Ok((PackageSelector::NameAndDirectory { name, directory }, true)) + }; + } + // 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`, `./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), false)); + } + + // 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)?), true)) +} + +/// Resolve a directory selector string into a [`DirectoryPattern`]. +/// +/// 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 { + 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); + + match pattern { + Some(pattern) => Ok(DirectoryPattern::Glob { base, pattern: Box::new(pattern) }), + None => Ok(DirectoryPattern::Exact(base)), + } +} + +/// Resolve a path string relative to `cwd`, normalising away `.` and `..`. +/// +/// `path_str` may be `"."`, `".."`, `"./foo"`, `"../foo"`, or a bare name like `"packages/app"`. +/// +/// 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. +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() +} + +/// Build a [`PackageNamePattern`] from a name or glob string. +/// +/// 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 { + 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: name.into(), unique: false }) + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// Unit tests +// ──────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// Construct an [`AbsolutePath`] from a Unix-style literal (test helper). + /// + /// On Windows, a `C:` prefix is prepended so `/workspace` becomes `C:/workspace`. + #[cfg_attr( + windows, + expect( + clippy::disallowed_macros, + reason = "test helper constructs Windows paths from Unix-style literals" + ) + )] + fn abs(path: &'static str) -> &'static AbsolutePath { + #[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 ─────────────────────────────────── + + fn assert_exact_name(filter: &PackageFilter, expected: &str) { + match &filter.selector { + PackageSelector::Name(PackageNamePattern::Exact { name: 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(DirectoryPattern::Exact(dir)) => { + assert_eq!(dir.as_ref(), expected_path, "directory mismatch"); + } + 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:?}" + ), + } + } + + fn assert_name_and_directory( + filter: &PackageFilter, + expected_name: &str, + expected_dir: &AbsolutePath, + ) { + match &filter.selector { + PackageSelector::NameAndDirectory { + name: PackageNamePattern::Exact { name: 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 { name: n, .. }, + directory: DirectoryPattern::Glob { base, pattern }, + } => { + assert_eq!(n.as_str(), expected_name, "name 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:?}), Glob {{ base: {expected_base:?}, pattern: {expected_pattern:?} }}), 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")); + } + + // ── 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"), "*"); + } + + // ── 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(), "*"); + } + DirectoryPattern::Exact(p) => panic!("expected Glob, got Exact({p:?})"), + } + } + + #[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(), "**"); + } + DirectoryPattern::Exact(p) => panic!("expected Glob, got Exact({p:?})"), + } + } + + #[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(), "*"); + } + DirectoryPattern::Exact(p) => panic!("expected Glob, got Exact({p:?})"), + } + } + + #[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(), "*"); + } + DirectoryPattern::Exact(p) => panic!("expected Glob, got Exact({p:?})"), + } + } + + #[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"); + } + 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); + } + + // ── -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, + filters: 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]); + } + crate::package_graph::PackageQueryKind::All => panic!("expected Filters, got All"), + } + } + + #[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, + filters: 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, + filters: 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); + } + crate::package_graph::PackageQueryKind::All => panic!("expected Filters, got All"), + } + } + + #[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, + filters: 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 + ); + } + crate::package_graph::PackageQueryKind::All => panic!("expected Filters, got All"), + } + } + + #[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, + filters: Vec::new(), + }; + assert!(matches!( + args.into_package_query(Some(Str::from("app")), &cwd), + 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, + filters: 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")); + } + crate::package_graph::PackageQueryKind::All => panic!("expected Filters, got All"), + } + } + + #[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, + filters: 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()); + } + crate::package_graph::PackageQueryKind::All => panic!("expected Filters, got All"), + } + } + + #[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, + filters: 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()); + } + 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); + } +} diff --git a/crates/vite_workspace/src/package_graph.rs b/crates/vite_workspace/src/package_graph.rs new file mode 100644 index 00000000..6c4aec88 --- /dev/null +++ b/crates/vite_workspace/src/package_graph.rs @@ -0,0 +1,510 @@ +//! 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::{ + DirectoryPattern, GraphTraversal, PackageFilter, PackageNamePattern, PackageSelector, + TraversalDirection, + }, +}; + +// ──────────────────────────────────────────────────────────────────────────── +// PackageQuery +// ──────────────────────────────────────────────────────────────────────────── + +/// Specifies which packages a task query applies to. +/// +/// 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(crate) enum PackageQueryKind { + /// 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 ref: + Filters(Vec1), + + /// All packages in the workspace, in topological dependency order. + /// + /// Produced by `--recursive` / `-r`. + 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 +// ──────────────────────────────────────────────────────────────────────────── + +/// 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, + + /// Original `--filter` strings for inclusion selectors that matched no packages. + /// + /// Omits synthetic filters (implicit cwd, `-w`) since the user didn't type them. + /// Empty when `PackageQuery::All` is used. + 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.iter().map(|p| p.as_path().display().to_string()).collect::>().join(", ") + )] + AmbiguousPackageName { package_name: Str, package_paths: Box<[Arc]> }, +} + +// ──────────────────────────────────────────────────────────────────────────── +// 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. + /// + /// # 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 => Ok(FilterResolution { + package_subgraph: self.full_subgraph(), + unmatched_selectors: Vec::new(), + }), + PackageQueryKind::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`]) + /// + /// [`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). + /// 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], + ) -> Result { + let mut unmatched_selectors = Vec::new(); + + 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() { + self.graph.node_indices().collect() + } else { + FxHashSet::default() + }; + + // Apply inclusions: union each filter's resolved set into `selected`. + for filter in &inclusions { + let matched = self.resolve_selector_entries(&filter.selector)?; + 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 { + 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); + 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, + ) -> Result, PackageQueryResolveError> { + let mut matched = FxHashSet::default(); + + match selector { + PackageSelector::Name(pattern) => { + self.match_by_name_pattern(pattern, &mut matched)?; + } + + PackageSelector::Directory(dir_pattern) => { + self.match_by_directory_pattern(dir_pattern, &mut matched); + } + + 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)?; + let mut by_dir = FxHashSet::default(); + 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; + } + } + } + } + + Ok(matched) + } + + /// Match packages by a name pattern, inserting into `out`. + /// + /// 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( + &self, + pattern: &PackageNamePattern, + out: &mut FxHashSet, + ) -> Result<(), PackageQueryResolveError> { + match pattern { + 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. + 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(glob) => { + use wax::Program as _; + for (pkg_name, indices) in &self.indices_by_name { + if glob.is_match(pkg_name.as_str()) { + out.extend(indices.iter().copied()); + } + } + } + } + Ok(()) + } + + /// 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::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()) + && 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 + /// `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 => { + // Walk dependents first, then walk dependencies of ALL dependents found + // (including the original seeds). + // pnpm ref: + let mut dependents = FxHashSet::default(); + self.bfs_incoming(&seeds, &mut dependents); + let all_dep_seeds: FxHashSet<_> = + seeds.iter().chain(dependents.iter()).copied().collect(); + self.bfs_outgoing(&all_dep_seeds, &mut reachable); + reachable.extend(dependents); + } + } + + 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 + } +}