From 04e6b5d654fb2f15c4e2b18a8808927d0aee5d2a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 08:44:56 +0000 Subject: [PATCH] feat(task-graph): materialize object dependsOn edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object-form `dependsOn` entries — `{ "task": "build", "from": "dependencies" }` — run a task in the direct workspace packages listed under a package.json dependency field (`dependencies`, `devDependencies`, and/or `peerDependencies`). The earlier implementation expanded these selections at query time, so they never appeared in the global task graph. This reimplements the feature at graph-load time: each object entry is resolved against the package dependency graph and the matching `package#task` selections are added as ordinary task graph edges (`add_package_dependency_edges`). Only direct dependencies are followed, and an edge is added only when the dependency package defines the task. Because the selections are now plain task graph edges, they appear in the global graph and flow through the existing dependency machinery for free — including `--ignore-depends-on`, which drops them at query time like any other `dependsOn` edge. The fixture asserts edge construction through the rendered global `task_graph.md` (every `from` variant, recursive cross-package chains, and the exclusion of peer-only and missing-task dependencies). The only behavior the static graph cannot express — `--ignore-depends-on` removing the materialized edges at query time — is kept as the single per-case plan snapshot. Co-authored-by: GPT-5 Codex Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QowxsN8vDKKbQdaSMdxL67 --- CHANGELOG.md | 1 + Cargo.lock | 1 + crates/vite_task/docs/task-query.md | 31 +- crates/vite_task_graph/Cargo.toml | 1 + crates/vite_task_graph/run-config.ts | 27 +- crates/vite_task_graph/src/config/mod.rs | 5 +- crates/vite_task_graph/src/config/user.rs | 141 +++++- crates/vite_task_graph/src/lib.rs | 146 ++++++- .../package.json | 7 + .../packages/app/package.json | 14 + .../packages/app/vite-task.json | 20 + .../packages/dev-a/package.json | 10 + .../packages/dev-a/vite-task.json | 8 + .../packages/peer-a/package.json | 7 + .../packages/prod-a/package.json | 7 + .../packages/prod-missing-task/package.json | 7 + .../packages/shared/package.json | 7 + .../pnpm-workspace.yaml | 2 + .../snapshots.toml | 11 + ...ignore_depends_on_omits_object_edges.jsonc | 4 + .../snapshots/task_graph.md | 412 ++++++++++++++++++ .../vite-task.json | 3 + 22 files changed, 839 insertions(+), 33 deletions(-) create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/app/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/app/vite-task.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/dev-a/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/dev-a/vite-task.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/peer-a/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/prod-a/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/prod-missing-task/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/shared/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/pnpm-workspace.yaml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/snapshots.toml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/snapshots/query_ignore_depends_on_omits_object_edges.jsonc create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/snapshots/task_graph.md create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/vite-task.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 92e936298..19d0ebe7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog - **Added** First-party support for caching `vite build` with zero cache config, giving Vite projects correct cache hits out of the box ([vitejs/vite#22453](https://github.com/vitejs/vite/pull/22453)). +- **Added** Object-form `dependsOn` entries for direct workspace dependencies ([#479](https://github.com/voidzero-dev/vite-task/pull/479)). - **Added** [`@voidzero-dev/vite-task-client`](https://npmx.dev/package/@voidzero-dev/vite-task-client), allowing tools to report cache information to Vite Task at runtime so users do not need to configure it manually ([#441](https://github.com/voidzero-dev/vite-task/pull/441), [#454](https://github.com/voidzero-dev/vite-task/pull/454), [#449](https://github.com/voidzero-dev/vite-task/pull/449), [#450](https://github.com/voidzero-dev/vite-task/pull/450), [#458](https://github.com/voidzero-dev/vite-task/pull/458), [#431](https://github.com/voidzero-dev/vite-task/pull/431), [#459](https://github.com/voidzero-dev/vite-task/pull/459), [#472](https://github.com/voidzero-dev/vite-task/pull/472)). - **Changed** Cached tasks now restore automatically tracked output files by default; use `output: []` to disable restoration ([#460](https://github.com/voidzero-dev/vite-task/pull/460), [#461](https://github.com/voidzero-dev/vite-task/pull/461)). - **Changed** Environment values in task cache fingerprints are now stored only as SHA-256 digests, and env-related cache miss details report names without values ([#455](https://github.com/voidzero-dev/vite-task/pull/455)). diff --git a/Cargo.lock b/Cargo.lock index 1b5a3e483..f31c68a5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4356,6 +4356,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "ts-rs", + "vec1", "vite_path", "vite_str", "vite_workspace", diff --git a/crates/vite_task/docs/task-query.md b/crates/vite_task/docs/task-query.md index b2fcfe37c..3544d5be1 100644 --- a/crates/vite_task/docs/task-query.md +++ b/crates/vite_task/docs/task-query.md @@ -13,7 +13,7 @@ Both are built once and reused for every query, including nested `vp run` calls ### 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: +The task graph contains a node for every task in every package, and edges only for explicit `dependsOn` declarations. ```jsonc // packages/app/vite.config.* @@ -38,6 +38,33 @@ Task graph: Package dependency ordering (app depends on lib) is NOT stored as edges in the task graph. Why not is explained below. +Object-form `dependsOn` entries are also explicit task dependencies. At startup, +they are resolved against the declaring package's direct `package.json` +dependency fields and materialized as task graph edges: + +```jsonc +// packages/app/vite.config.* +{ + "tasks": { + "test": { + "command": "vitest run", + "dependsOn": [{ "task": "build", "from": ["dependencies", "devDependencies"] }], + }, + }, +} +``` + +If `app` directly depends on `ui` and `shared`, and both packages have `build`, +the task graph contains: + +``` +app#test ──dependsOn──> ui#build +app#test ──dependsOn──> shared#build +``` + +Dependency packages without the requested task are skipped. Recursive expansion +comes from dependency tasks declaring their own `dependsOn` entries. + ## What happens when you run a query Every `vp run` command goes through two stages: @@ -188,7 +215,7 @@ If you run `vp run --filter app build`, the package subgraph contains only `app` 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. +The expansion follows explicit `dependsOn` edges, including edges materialized from object-form entries. It does not follow topological package edges. Topological ordering comes from the package subgraph — it's already baked into the task execution graph by Stage 2. ## Nested `vp run` diff --git a/crates/vite_task_graph/Cargo.toml b/crates/vite_task_graph/Cargo.toml index 8cc7e55ee..5d18848ab 100644 --- a/crates/vite_task_graph/Cargo.toml +++ b/crates/vite_task_graph/Cargo.toml @@ -18,6 +18,7 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +vec1 = { workspace = true, features = ["serde"] } vite_path = { workspace = true } vite_str = { workspace = true } vite_workspace = { workspace = true } diff --git a/crates/vite_task_graph/run-config.ts b/crates/vite_task_graph/run-config.ts index 280b9adc1..cd780fce5 100644 --- a/crates/vite_task_graph/run-config.ts +++ b/crates/vite_task_graph/run-config.ts @@ -8,6 +8,12 @@ auto: boolean, }; export type Command = string | Array; +export type DependencyType = "dependencies" | "devDependencies" | "peerDependencies"; + +export type DependsOnEntry = string | UserPackageDependency; + +export type DependsOnFrom = DependencyType | Array; + export type GlobWithBase = { /** * The glob pattern (positive or negative starting with `!`) @@ -30,9 +36,16 @@ command: Command, */ cwd?: string, /** - * Dependencies of this task. Use `package-name#task-name` to refer to tasks in other packages. + * Tasks that must run before this task. + * + * - A string runs one named task, such as `"build"` in the same package or + * `"package-name#build"` in another package. + * - An object runs a task in direct workspace dependency packages selected + * from package.json fields. For example, + * `{ "task": "build", "from": "dependencies" }` runs `build` in each + * direct workspace dependency that defines a `build` task. */ -dependsOn?: Array, } & ({ +dependsOn?: Array, } & ({ /** * Whether to cache the task */ @@ -95,6 +108,16 @@ scripts?: boolean, */ tasks?: boolean, }; +export type UserPackageDependency = { +/** + * Task name to run in dependency packages. + */ +task: string, +/** + * Package.json dependency field or fields to use when selecting direct dependency packages. + */ +from: DependsOnFrom, }; + export type RunConfig = { /** * Root-level cache configuration. diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index a36ca1def..61358ec5b 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -7,8 +7,9 @@ use rustc_hash::FxHashSet; use serde::Serialize; pub use user::{ AutoTracking, Command, EnabledCacheConfig, GlobWithBase, InputBase, ResolvedGlobalCacheConfig, - UserCacheConfig, UserGlobalCacheConfig, UserInputEntry, UserInputsConfig, UserOutputEntry, - UserRunConfig, UserTaskConfig, UserTaskDefinition, + UserCacheConfig, UserDependencyType, UserDependsOnEntry, UserDependsOnFrom, + UserGlobalCacheConfig, UserInputEntry, UserInputsConfig, UserOutputEntry, + UserPackageDependency, UserRunConfig, UserTaskConfig, UserTaskDefinition, }; use vite_path::AbsolutePath; use vite_str::Str; diff --git a/crates/vite_task_graph/src/config/user.rs b/crates/vite_task_graph/src/config/user.rs index d7bb938c6..487ccec2a 100644 --- a/crates/vite_task_graph/src/config/user.rs +++ b/crates/vite_task_graph/src/config/user.rs @@ -7,6 +7,7 @@ use rustc_hash::FxHashMap; use serde::Deserialize; #[cfg(all(test, not(clippy)))] use ts_rs::TS; +use vec1::Vec1; use vite_path::RelativePathBuf; use vite_str::Str; @@ -65,6 +66,70 @@ pub enum UserInputEntry { /// Default (when field omitted): `[{auto: true}]` - infer from file accesses. pub type UserInputsConfig = Vec; +/// A supported package.json dependency field for package dependency selection. +#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy)] +// TS derive macro generates code using std types that clippy disallows; skip derive during linting +#[cfg_attr(all(test, not(clippy)), derive(TS), ts(rename = "DependencyType"))] +#[serde(rename_all = "camelCase")] +pub enum UserDependencyType { + /// Traverse dependencies declared in the package.json `dependencies` field. + Dependencies, + /// Traverse dependencies declared in the package.json `devDependencies` field. + DevDependencies, + /// Traverse dependencies declared in the package.json `peerDependencies` field. + PeerDependencies, +} + +/// The `from` selector for object-form `dependsOn` entries. +#[derive(Debug, Deserialize, PartialEq, Eq, Clone)] +// TS derive macro generates code using std types that clippy disallows; skip derive during linting +#[cfg_attr(all(test, not(clippy)), derive(TS), ts(rename = "DependsOnFrom"))] +#[serde(untagged)] +pub enum UserDependsOnFrom { + /// Select one package.json dependency field. + Single(UserDependencyType), + /// Select the union of multiple package.json dependency fields. + Multiple( + #[cfg_attr(all(test, not(clippy)), ts(as = "Vec"))] + Vec1, + ), +} + +impl UserDependsOnFrom { + #[must_use] + pub fn as_slice(&self) -> &[UserDependencyType] { + match self { + Self::Single(dependency_type) => std::slice::from_ref(dependency_type), + Self::Multiple(dependency_types) => dependency_types, + } + } +} + +/// Object form for `dependsOn` entries that select direct workspace package dependencies. +#[derive(Debug, Deserialize, PartialEq, Eq, Clone)] +// TS derive macro generates code using std types that clippy disallows; skip derive during linting +#[cfg_attr(all(test, not(clippy)), derive(TS))] +#[serde(deny_unknown_fields)] +pub struct UserPackageDependency { + /// Task name to run in dependency packages. + pub task: Str, + + /// Package.json dependency field or fields to use when selecting direct dependency packages. + pub from: UserDependsOnFrom, +} + +/// A single `dependsOn` entry. +#[derive(Debug, Deserialize, PartialEq, Eq, Clone)] +// TS derive macro generates code using std types that clippy disallows; skip derive during linting +#[cfg_attr(all(test, not(clippy)), derive(TS), ts(rename = "DependsOnEntry"))] +#[serde(untagged)] +pub enum UserDependsOnEntry { + /// Same-package task or `package#task` specifier. + Task(Str), + /// Direct package dependency selection entry. + Package(UserPackageDependency), +} + /// A single output entry in the `output` array. /// /// Outputs can be: @@ -168,8 +233,15 @@ pub struct UserTaskOptions { #[serde(rename = "cwd")] pub cwd_relative_to_package: Option, - /// Dependencies of this task. Use `package-name#task-name` to refer to tasks in other packages. - pub depends_on: Option>, + /// Tasks that must run before this task. + /// + /// - A string runs one named task, such as `"build"` in the same package or + /// `"package-name#build"` in another package. + /// - An object runs a task in direct workspace dependency packages selected + /// from package.json fields. For example, + /// `{ "task": "build", "from": "dependencies" }` runs `build` in each + /// direct workspace dependency that defines a `build` task. + pub depends_on: Option>, /// Cache-related fields #[serde(flatten)] @@ -510,10 +582,73 @@ mod tests { ); let options = user_config.options; assert_eq!(options.cwd_relative_to_package.as_ref().unwrap().as_str(), "src"); - assert_eq!(options.depends_on.as_ref().unwrap().as_ref(), [Str::from("build")]); + assert_eq!( + options.depends_on.as_ref().unwrap().as_ref(), + [UserDependsOnEntry::Task(Str::from("build"))] + ); assert_eq!(options.cache_config, UserCacheConfig::Disabled { cache: MustBe!(false) }); } + #[test] + fn test_depends_on_package_dependency_single_from() { + let user_config_json = json!({ + "command": "echo test", + "dependsOn": [{ "task": "build", "from": "dependencies" }] + }); + let user_config: UserTaskConfig = serde_json::from_value(user_config_json).unwrap(); + assert_eq!( + user_config.options.depends_on.as_ref().unwrap().as_ref(), + [UserDependsOnEntry::Package(UserPackageDependency { + task: "build".into(), + from: UserDependsOnFrom::Single(UserDependencyType::Dependencies), + })] + ); + } + + #[test] + fn test_depends_on_package_dependency_array_from() { + let user_config_json = json!({ + "command": "echo test", + "dependsOn": [{ + "task": "build", + "from": ["dependencies", "devDependencies", "peerDependencies"] + }] + }); + let user_config: UserTaskConfig = serde_json::from_value(user_config_json).unwrap(); + assert_eq!( + user_config.options.depends_on.as_ref().unwrap().as_ref(), + [UserDependsOnEntry::Package(UserPackageDependency { + task: "build".into(), + from: UserDependsOnFrom::Multiple( + Vec1::try_from_vec(vec![ + UserDependencyType::Dependencies, + UserDependencyType::DevDependencies, + UserDependencyType::PeerDependencies, + ]) + .unwrap() + ), + })] + ); + } + + #[test] + fn test_depends_on_package_dependency_empty_from_error() { + let user_config_json = json!({ + "command": "echo test", + "dependsOn": [{ "task": "build", "from": [] }] + }); + assert!(serde_json::from_value::(user_config_json).is_err()); + } + + #[test] + fn test_depends_on_package_dependency_unknown_from_error() { + let user_config_json = json!({ + "command": "echo test", + "dependsOn": [{ "task": "build", "from": "optionalDependencies" }] + }); + assert!(serde_json::from_value::(user_config_json).is_err()); + } + #[test] fn test_task_invalid_shorthand_error() { let user_config_json = json!({ diff --git a/crates/vite_task_graph/src/lib.rs b/crates/vite_task_graph/src/lib.rs index 2985bd9cb..72dc950ac 100644 --- a/crates/vite_task_graph/src/lib.rs +++ b/crates/vite_task_graph/src/lib.rs @@ -10,21 +10,32 @@ use config::{ ResolvedGlobalCacheConfig, ResolvedTaskConfig, UserRunConfig, UserTaskConfig, UserTaskDefinition, }; -use petgraph::graph::{DefaultIx, DiGraph, EdgeIndex, IndexType, NodeIndex}; +use petgraph::{ + graph::{DefaultIx, DiGraph, EdgeIndex, IndexType, NodeIndex}, + visit::EdgeRef as _, +}; 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, package_graph::IndexedPackageGraph}; +use vite_workspace::{ + DependencyType, PackageNodeIndex, WorkspaceRoot, package_graph::IndexedPackageGraph, +}; -use crate::{config::user::UserTaskOptions, display::TaskDisplay}; +use crate::{ + config::user::{ + UserDependencyType, UserDependsOnEntry, UserPackageDependency, UserTaskOptions, + }, + display::TaskDisplay, +}; /// The type of a task dependency edge in the task graph. /// -/// Currently only `Explicit` is produced (from `dependsOn` in the task config). -/// Topological ordering is handled at query time via the package subgraph rather -/// than by pre-computing edges in the task graph. +/// All edges are produced from `dependsOn` in task config, including string-form +/// task specifiers and object-form package dependency selections. Topological +/// ordering is handled at query time via the package subgraph rather than by +/// pre-computing package edges in the task graph. #[derive(Debug, Clone, Copy, Serialize)] pub struct TaskDependencyType; @@ -168,6 +179,37 @@ unsafe impl IndexType for TaskIx { pub type TaskNodeIndex = NodeIndex; pub type TaskEdgeIndex = EdgeIndex; +/// A `dependsOn` entry that selects direct package dependencies from a source task. +struct PackageDependencyEntry { + task_name: Str, + dependency_types: Box<[DependencyType]>, +} + +impl PackageDependencyEntry { + fn from_user_config(entry: &UserPackageDependency) -> Self { + Self { + task_name: entry.task.clone(), + dependency_types: entry + .from + .as_slice() + .iter() + .copied() + .map(DependencyType::from) + .collect(), + } + } +} + +impl From for DependencyType { + fn from(value: UserDependencyType) -> Self { + match value { + UserDependencyType::Dependencies => Self::Normal, + UserDependencyType::DevDependencies => Self::Dev, + UserDependencyType::PeerDependencies => Self::Peer, + } + } +} + /// Full task graph of a workspace, with necessary hash maps for quick task lookup /// /// It's immutable after created. The task nodes contain resolved task configurations and their dependencies. @@ -217,8 +259,9 @@ impl IndexedTaskGraph { let package_graph = vite_workspace::load_package_graph(workspace_root)?; - // Record dependency specifiers for each task node to add explicit dependencies later - let mut task_ids_with_dependency_specifiers: Vec<(TaskId, Option>)> = Vec::new(); + // Record dependency declarations for each task node to add explicit dependencies later. + let mut task_ids_with_depends_on_entries: Vec<(TaskId, Option>)> = + Vec::new(); // index tasks by ids let mut node_indices_by_task_id: FxHashMap = @@ -303,7 +346,7 @@ impl IndexedTaskGraph { UserTaskConfig { command, options: UserTaskOptions::default() } } }; - let dependency_specifiers = task_user_config.options.depends_on.clone(); + let depends_on_entries = task_user_config.options.depends_on.clone(); // Resolve the task configuration from the user config let resolved_config = ResolvedTaskConfig::resolve( @@ -331,7 +374,7 @@ impl IndexedTaskGraph { }; let node_index = task_graph.add_node(task_node); - task_ids_with_dependency_specifiers.push((task_id.clone(), dependency_specifiers)); + task_ids_with_depends_on_entries.push((task_id.clone(), depends_on_entries)); task_ids_by_node_index.insert(node_index, task_id.clone()); node_indices_by_task_id.insert(task_id, node_index); } @@ -376,21 +419,37 @@ impl IndexedTaskGraph { pre_post_scripts_enabled: root_pre_post_scripts_enabled.unwrap_or(true), }; - // Add explicit dependencies - for (from_task_id, dependency_specifiers) in task_ids_with_dependency_specifiers { + // Add explicit dependencies. String-form entries resolve to fixed task + // specifier edges. Object-form entries select direct package dependency + // tasks and materialize those selections as global task graph edges. + for (from_task_id, depends_on_entries) in task_ids_with_depends_on_entries { let from_node_index = me.node_indices_by_task_id[&from_task_id]; - for specifier in dependency_specifiers.iter().flat_map(|s| s.iter()).cloned() { - let to_node_index = me - .get_task_index_by_specifier::( - TaskSpecifier::parse_raw(&specifier), - || Ok(from_task_id.package_index), - ) - .map_err(|error| TaskGraphLoadError::DependencySpecifierLookupError { - error, - specifier, - task_display: me.display_task(from_node_index), - })?; - me.task_graph.update_edge(from_node_index, to_node_index, TaskDependencyType); + for entry in depends_on_entries.iter().flat_map(|entries| entries.iter()) { + match entry { + UserDependsOnEntry::Task(specifier) => { + let to_node_index = me + .get_task_index_by_specifier::( + TaskSpecifier::parse_raw(specifier), + || Ok(from_task_id.package_index), + ) + .map_err(|error| { + TaskGraphLoadError::DependencySpecifierLookupError { + error, + specifier: specifier.clone(), + task_display: me.display_task(from_node_index), + } + })?; + me.task_graph.update_edge( + from_node_index, + to_node_index, + TaskDependencyType, + ); + } + UserDependsOnEntry::Package(entry) => { + let entry = PackageDependencyEntry::from_user_config(entry); + me.add_package_dependency_edges(from_node_index, &from_task_id, &entry); + } + } } } @@ -451,6 +510,45 @@ impl IndexedTaskGraph { Ok(*node_index) } + /// Materialize one object-form `dependsOn` entry as ordinary task graph edges. + /// + /// Object entries such as `{ "task": "build", "from": "dependencies" }` + /// are anchored to the package that declares the source task. During graph + /// loading we resolve the selected direct package dependencies and add + /// `source_task -> dependency_package#task` edges, so later query planning + /// can treat them exactly like string-form `dependsOn` edges. + fn add_package_dependency_edges( + &mut self, + from_node_index: TaskNodeIndex, + from_task_id: &TaskId, + entry: &PackageDependencyEntry, + ) { + let package_graph = self.indexed_package_graph.package_graph(); + let dependency_tasks = package_graph + .edges(from_task_id.package_index) + // Only use package.json dependency fields requested by `from`. + .filter(|edge| entry.dependency_types.contains(edge.weight())) + // Missing tasks are intentionally ignored: selecting + // `{ task: "build", from: "dependencies" }` means "run build in + // direct dependency packages that define build", not "require every + // selected package to define build". + .filter_map(|edge| { + self.node_indices_by_task_id + .get(&TaskId { + package_index: edge.target(), + task_name: entry.task_name.clone(), + }) + .copied() + }) + .collect::>(); + + // Keep discovery separate from insertion so the package-graph walk and + // task-id lookups finish before we take a mutable borrow of task_graph. + for to_node_index in dependency_tasks { + self.task_graph.update_edge(from_node_index, to_node_index, TaskDependencyType); + } + } + #[must_use] pub const fn task_graph(&self) -> &TaskGraph { &self.task_graph diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/package.json new file mode 100644 index 000000000..73b7c3ebe --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/object-depends-on-global-graph", + "version": "1.0.0", + "workspaces": [ + "packages/*" + ] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/app/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/app/package.json new file mode 100644 index 000000000..04137006d --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/app/package.json @@ -0,0 +1,14 @@ +{ + "name": "@test/app", + "version": "1.0.0", + "dependencies": { + "@test/prod-a": "workspace:*", + "@test/prod-missing-task": "workspace:*" + }, + "devDependencies": { + "@test/dev-a": "workspace:*" + }, + "peerDependencies": { + "@test/peer-a": "workspace:*" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/app/vite-task.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/app/vite-task.json new file mode 100644 index 000000000..05008ba16 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/app/vite-task.json @@ -0,0 +1,20 @@ +{ + "tasks": { + "test_dependencies": { + "command": "vtt test dependencies", + "dependsOn": [{ "task": "build", "from": "dependencies" }] + }, + "test_dev": { + "command": "vtt test dev", + "dependsOn": [{ "task": "build", "from": "devDependencies" }] + }, + "test_union": { + "command": "vtt test union", + "dependsOn": [{ "task": "build", "from": ["dependencies", "devDependencies"] }] + }, + "test_recursive": { + "command": "vtt test recursive", + "dependsOn": [{ "task": "build_recursive", "from": "devDependencies" }] + } + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/dev-a/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/dev-a/package.json new file mode 100644 index 000000000..0d34156a7 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/dev-a/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/dev-a", + "version": "1.0.0", + "scripts": { + "build": "vtt build dev-a" + }, + "devDependencies": { + "@test/shared": "workspace:*" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/dev-a/vite-task.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/dev-a/vite-task.json new file mode 100644 index 000000000..2410dd913 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/dev-a/vite-task.json @@ -0,0 +1,8 @@ +{ + "tasks": { + "build_recursive": { + "command": "vtt build recursive dev-a", + "dependsOn": [{ "task": "build_recursive", "from": "devDependencies" }] + } + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/peer-a/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/peer-a/package.json new file mode 100644 index 000000000..6cd065a8c --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/peer-a/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/peer-a", + "version": "1.0.0", + "scripts": { + "build": "vtt build peer-a" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/prod-a/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/prod-a/package.json new file mode 100644 index 000000000..40f1f23e8 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/prod-a/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/prod-a", + "version": "1.0.0", + "scripts": { + "build": "vtt build prod-a" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/prod-missing-task/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/prod-missing-task/package.json new file mode 100644 index 000000000..9659e3961 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/prod-missing-task/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/prod-missing-task", + "version": "1.0.0", + "scripts": { + "lint": "vtt lint prod-missing-task" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/shared/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/shared/package.json new file mode 100644 index 000000000..37434e0bc --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/packages/shared/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/shared", + "version": "1.0.0", + "scripts": { + "build_recursive": "vtt build recursive shared" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/pnpm-workspace.yaml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/pnpm-workspace.yaml new file mode 100644 index 000000000..18ec407ef --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/snapshots.toml new file mode 100644 index 000000000..9c6a0b6b1 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/snapshots.toml @@ -0,0 +1,11 @@ +# Object-form `dependsOn` selections are materialized as global task graph +# edges, so the edge structure for every `from` variant (dependencies, +# devDependencies, unions, recursive chains, and excluded peer/missing-task +# deps) is asserted by `snapshots/task_graph.md`. The only behavior the static +# graph cannot express is query-time `--ignore-depends-on` dropping those +# materialized edges, so that is the one plan case kept here. +[[plan]] +compact = true +name = "ignore_depends_on_omits_object_edges" +args = ["run", "--ignore-depends-on", "test_union"] +cwd = "packages/app" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/snapshots/query_ignore_depends_on_omits_object_edges.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/snapshots/query_ignore_depends_on_omits_object_edges.jsonc new file mode 100644 index 000000000..001458484 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/snapshots/query_ignore_depends_on_omits_object_edges.jsonc @@ -0,0 +1,4 @@ +// run --ignore-depends-on test_union +{ + "packages/app#test_union": [] +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/snapshots/task_graph.md b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/snapshots/task_graph.md new file mode 100644 index 000000000..a889d36c3 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/snapshots/task_graph.md @@ -0,0 +1,412 @@ +# task graph + +```mermaid +flowchart TD + task_0["/packages/app#test_dependencies"] + task_0 --> task_7 + task_1["/packages/app#test_dev"] + task_1 --> task_4 + task_2["/packages/app#test_recursive"] + task_2 --> task_5 + task_3["/packages/app#test_union"] + task_3 --> task_4 + task_3 --> task_7 + task_4["/packages/dev-a#build"] + task_5["/packages/dev-a#build_recursive"] + task_5 --> task_9 + task_6["/packages/peer-a#build"] + task_7["/packages/prod-a#build"] + task_8["/packages/prod-missing-task#lint"] + task_9["/packages/shared#build_recursive"] +``` + +## `/packages/app#test_dependencies` + +```json +{ + "task_display": { + "package_name": "@test/app", + "task_name": "test_dependencies", + "package_path": "/packages/app" + }, + "resolved_config": { + "commands": [ + "vtt test dependencies" + ], + "resolved_options": { + "cwd": "/packages/app", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "output_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "TaskConfig" +} +``` + +## `/packages/app#test_dev` + +```json +{ + "task_display": { + "package_name": "@test/app", + "task_name": "test_dev", + "package_path": "/packages/app" + }, + "resolved_config": { + "commands": [ + "vtt test dev" + ], + "resolved_options": { + "cwd": "/packages/app", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "output_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "TaskConfig" +} +``` + +## `/packages/app#test_recursive` + +```json +{ + "task_display": { + "package_name": "@test/app", + "task_name": "test_recursive", + "package_path": "/packages/app" + }, + "resolved_config": { + "commands": [ + "vtt test recursive" + ], + "resolved_options": { + "cwd": "/packages/app", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "output_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "TaskConfig" +} +``` + +## `/packages/app#test_union` + +```json +{ + "task_display": { + "package_name": "@test/app", + "task_name": "test_union", + "package_path": "/packages/app" + }, + "resolved_config": { + "commands": [ + "vtt test union" + ], + "resolved_options": { + "cwd": "/packages/app", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "output_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "TaskConfig" +} +``` + +## `/packages/dev-a#build` + +```json +{ + "task_display": { + "package_name": "@test/dev-a", + "task_name": "build", + "package_path": "/packages/dev-a" + }, + "resolved_config": { + "commands": [ + "vtt build dev-a" + ], + "resolved_options": { + "cwd": "/packages/dev-a", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "output_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "PackageJsonScript" +} +``` + +## `/packages/dev-a#build_recursive` + +```json +{ + "task_display": { + "package_name": "@test/dev-a", + "task_name": "build_recursive", + "package_path": "/packages/dev-a" + }, + "resolved_config": { + "commands": [ + "vtt build recursive dev-a" + ], + "resolved_options": { + "cwd": "/packages/dev-a", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "output_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "TaskConfig" +} +``` + +## `/packages/peer-a#build` + +```json +{ + "task_display": { + "package_name": "@test/peer-a", + "task_name": "build", + "package_path": "/packages/peer-a" + }, + "resolved_config": { + "commands": [ + "vtt build peer-a" + ], + "resolved_options": { + "cwd": "/packages/peer-a", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "output_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "PackageJsonScript" +} +``` + +## `/packages/prod-a#build` + +```json +{ + "task_display": { + "package_name": "@test/prod-a", + "task_name": "build", + "package_path": "/packages/prod-a" + }, + "resolved_config": { + "commands": [ + "vtt build prod-a" + ], + "resolved_options": { + "cwd": "/packages/prod-a", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "output_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "PackageJsonScript" +} +``` + +## `/packages/prod-missing-task#lint` + +```json +{ + "task_display": { + "package_name": "@test/prod-missing-task", + "task_name": "lint", + "package_path": "/packages/prod-missing-task" + }, + "resolved_config": { + "commands": [ + "vtt lint prod-missing-task" + ], + "resolved_options": { + "cwd": "/packages/prod-missing-task", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "output_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "PackageJsonScript" +} +``` + +## `/packages/shared#build_recursive` + +```json +{ + "task_display": { + "package_name": "@test/shared", + "task_name": "build_recursive", + "package_path": "/packages/shared" + }, + "resolved_config": { + "commands": [ + "vtt build recursive shared" + ], + "resolved_options": { + "cwd": "/packages/shared", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "output_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "PackageJsonScript" +} +``` + diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/vite-task.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/vite-task.json new file mode 100644 index 000000000..d548edfac --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/object_depends_on_global_graph/vite-task.json @@ -0,0 +1,3 @@ +{ + "cache": true +}