Skip to content

Commit 4212c17

Browse files
authored
feat(task-graph): materialize object dependsOn edges (#479)
## Motivation Stacked on #478 (the revert of the query-time implementation). Object-form `dependsOn` entries — `{ "task": "build", "from": ["dependencies", "devDependencies"] }` — run a task in the direct workspace packages listed under a package.json dependency field. The previous implementation (#467/#469, reverted in #478) expanded these at query time, so the selections 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 become ordinary task graph edges (`add_package_dependency_edges`). Because they are now plain edges: - they appear in the global task graph, and - they flow through the existing dependency machinery for free — including `--ignore-depends-on`, which drops them at query time like any other `dependsOn` edge. Only direct dependencies are followed, and an edge is added only when the dependency package actually defines the task. Supported fields are `dependencies`, `devDependencies`, and `peerDependencies`. ## Snapshot coverage Because the edges now live in the global task graph, the fixture asserts edge construction through the rendered `task_graph.md`: every `from` variant, recursive cross-package chains, and the exclusion of peer-only and missing-task dependencies are all visible there. 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. The other four per-case snapshots in the original change were redundant re-assertions of edges already shown in `task_graph.md`, so they were dropped. ## Behavior note vs #467/#469 The reverted query-stage impl also preserved ordering *among* the sibling dependency tasks selected by a single object `dependsOn` (e.g. `ui#build → shared#build` when `app#test` selected both and `ui` depends on `shared`), scoped to that one query's execution graph. The graph-stage model intentionally does not: every edge here is global, and a global `ui#build → shared#build` edge would make a plain `vp run ui#build` also build `shared`, contradicting vp's rule that topological ordering applies only with `-r`/`-t`. Ordering among selected dependencies instead comes from each task declaring its own `dependsOn` (the recursive-expansion path documented in `task-query.md`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QowxsN8vDKKbQdaSMdxL67
1 parent 2502ab1 commit 4212c17

22 files changed

Lines changed: 839 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Changelog
22

33
- **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)).
4+
- **Added** Object-form `dependsOn` entries for direct workspace dependencies ([#479](https://github.com/voidzero-dev/vite-task/pull/479)).
45
- **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)).
56
- **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)).
67
- **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)).

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vite_task/docs/task-query.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Both are built once and reused for every query, including nested `vp run` calls
1313

1414
### What goes into the task graph
1515

16-
The task graph contains a node for every task in every package, and edges only for explicit `dependsOn` declarations:
16+
The task graph contains a node for every task in every package, and edges only for explicit `dependsOn` declarations.
1717

1818
```jsonc
1919
// packages/app/vite.config.*
@@ -38,6 +38,33 @@ Task graph:
3838

3939
Package dependency ordering (app depends on lib) is NOT stored as edges in the task graph. Why not is explained below.
4040

41+
Object-form `dependsOn` entries are also explicit task dependencies. At startup,
42+
they are resolved against the declaring package's direct `package.json`
43+
dependency fields and materialized as task graph edges:
44+
45+
```jsonc
46+
// packages/app/vite.config.*
47+
{
48+
"tasks": {
49+
"test": {
50+
"command": "vitest run",
51+
"dependsOn": [{ "task": "build", "from": ["dependencies", "devDependencies"] }],
52+
},
53+
},
54+
}
55+
```
56+
57+
If `app` directly depends on `ui` and `shared`, and both packages have `build`,
58+
the task graph contains:
59+
60+
```
61+
app#test ──dependsOn──> ui#build
62+
app#test ──dependsOn──> shared#build
63+
```
64+
65+
Dependency packages without the requested task are skipped. Recursive expansion
66+
comes from dependency tasks declaring their own `dependsOn` entries.
67+
4168
## What happens when you run a query
4269

4370
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`
188215

189216
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`.)
190217

191-
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.
218+
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.
192219

193220
## Nested `vp run`
194221

crates/vite_task_graph/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ serde = { workspace = true, features = ["derive"] }
1818
serde_json = { workspace = true }
1919
thiserror = { workspace = true }
2020
tracing = { workspace = true }
21+
vec1 = { workspace = true, features = ["serde"] }
2122
vite_path = { workspace = true }
2223
vite_str = { workspace = true }
2324
vite_workspace = { workspace = true }

crates/vite_task_graph/run-config.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ auto: boolean, };
88

99
export type Command = string | Array<string>;
1010

11+
export type DependencyType = "dependencies" | "devDependencies" | "peerDependencies";
12+
13+
export type DependsOnEntry = string | UserPackageDependency;
14+
15+
export type DependsOnFrom = DependencyType | Array<DependencyType>;
16+
1117
export type GlobWithBase = {
1218
/**
1319
* The glob pattern (positive or negative starting with `!`)
@@ -30,9 +36,16 @@ command: Command,
3036
*/
3137
cwd?: string,
3238
/**
33-
* Dependencies of this task. Use `package-name#task-name` to refer to tasks in other packages.
39+
* Tasks that must run before this task.
40+
*
41+
* - A string runs one named task, such as `"build"` in the same package or
42+
* `"package-name#build"` in another package.
43+
* - An object runs a task in direct workspace dependency packages selected
44+
* from package.json fields. For example,
45+
* `{ "task": "build", "from": "dependencies" }` runs `build` in each
46+
* direct workspace dependency that defines a `build` task.
3447
*/
35-
dependsOn?: Array<string>, } & ({
48+
dependsOn?: Array<DependsOnEntry>, } & ({
3649
/**
3750
* Whether to cache the task
3851
*/
@@ -95,6 +108,16 @@ scripts?: boolean,
95108
*/
96109
tasks?: boolean, };
97110

111+
export type UserPackageDependency = {
112+
/**
113+
* Task name to run in dependency packages.
114+
*/
115+
task: string,
116+
/**
117+
* Package.json dependency field or fields to use when selecting direct dependency packages.
118+
*/
119+
from: DependsOnFrom, };
120+
98121
export type RunConfig = {
99122
/**
100123
* Root-level cache configuration.

crates/vite_task_graph/src/config/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ use rustc_hash::FxHashSet;
77
use serde::Serialize;
88
pub use user::{
99
AutoTracking, Command, EnabledCacheConfig, GlobWithBase, InputBase, ResolvedGlobalCacheConfig,
10-
UserCacheConfig, UserGlobalCacheConfig, UserInputEntry, UserInputsConfig, UserOutputEntry,
11-
UserRunConfig, UserTaskConfig, UserTaskDefinition,
10+
UserCacheConfig, UserDependencyType, UserDependsOnEntry, UserDependsOnFrom,
11+
UserGlobalCacheConfig, UserInputEntry, UserInputsConfig, UserOutputEntry,
12+
UserPackageDependency, UserRunConfig, UserTaskConfig, UserTaskDefinition,
1213
};
1314
use vite_path::AbsolutePath;
1415
use vite_str::Str;

crates/vite_task_graph/src/config/user.rs

Lines changed: 138 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use rustc_hash::FxHashMap;
77
use serde::Deserialize;
88
#[cfg(all(test, not(clippy)))]
99
use ts_rs::TS;
10+
use vec1::Vec1;
1011
use vite_path::RelativePathBuf;
1112
use vite_str::Str;
1213

@@ -65,6 +66,70 @@ pub enum UserInputEntry {
6566
/// Default (when field omitted): `[{auto: true}]` - infer from file accesses.
6667
pub type UserInputsConfig = Vec<UserInputEntry>;
6768

69+
/// A supported package.json dependency field for package dependency selection.
70+
#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
71+
// TS derive macro generates code using std types that clippy disallows; skip derive during linting
72+
#[cfg_attr(all(test, not(clippy)), derive(TS), ts(rename = "DependencyType"))]
73+
#[serde(rename_all = "camelCase")]
74+
pub enum UserDependencyType {
75+
/// Traverse dependencies declared in the package.json `dependencies` field.
76+
Dependencies,
77+
/// Traverse dependencies declared in the package.json `devDependencies` field.
78+
DevDependencies,
79+
/// Traverse dependencies declared in the package.json `peerDependencies` field.
80+
PeerDependencies,
81+
}
82+
83+
/// The `from` selector for object-form `dependsOn` entries.
84+
#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
85+
// TS derive macro generates code using std types that clippy disallows; skip derive during linting
86+
#[cfg_attr(all(test, not(clippy)), derive(TS), ts(rename = "DependsOnFrom"))]
87+
#[serde(untagged)]
88+
pub enum UserDependsOnFrom {
89+
/// Select one package.json dependency field.
90+
Single(UserDependencyType),
91+
/// Select the union of multiple package.json dependency fields.
92+
Multiple(
93+
#[cfg_attr(all(test, not(clippy)), ts(as = "Vec<UserDependencyType>"))]
94+
Vec1<UserDependencyType>,
95+
),
96+
}
97+
98+
impl UserDependsOnFrom {
99+
#[must_use]
100+
pub fn as_slice(&self) -> &[UserDependencyType] {
101+
match self {
102+
Self::Single(dependency_type) => std::slice::from_ref(dependency_type),
103+
Self::Multiple(dependency_types) => dependency_types,
104+
}
105+
}
106+
}
107+
108+
/// Object form for `dependsOn` entries that select direct workspace package dependencies.
109+
#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
110+
// TS derive macro generates code using std types that clippy disallows; skip derive during linting
111+
#[cfg_attr(all(test, not(clippy)), derive(TS))]
112+
#[serde(deny_unknown_fields)]
113+
pub struct UserPackageDependency {
114+
/// Task name to run in dependency packages.
115+
pub task: Str,
116+
117+
/// Package.json dependency field or fields to use when selecting direct dependency packages.
118+
pub from: UserDependsOnFrom,
119+
}
120+
121+
/// A single `dependsOn` entry.
122+
#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
123+
// TS derive macro generates code using std types that clippy disallows; skip derive during linting
124+
#[cfg_attr(all(test, not(clippy)), derive(TS), ts(rename = "DependsOnEntry"))]
125+
#[serde(untagged)]
126+
pub enum UserDependsOnEntry {
127+
/// Same-package task or `package#task` specifier.
128+
Task(Str),
129+
/// Direct package dependency selection entry.
130+
Package(UserPackageDependency),
131+
}
132+
68133
/// A single output entry in the `output` array.
69134
///
70135
/// Outputs can be:
@@ -168,8 +233,15 @@ pub struct UserTaskOptions {
168233
#[serde(rename = "cwd")]
169234
pub cwd_relative_to_package: Option<RelativePathBuf>,
170235

171-
/// Dependencies of this task. Use `package-name#task-name` to refer to tasks in other packages.
172-
pub depends_on: Option<Arc<[Str]>>,
236+
/// Tasks that must run before this task.
237+
///
238+
/// - A string runs one named task, such as `"build"` in the same package or
239+
/// `"package-name#build"` in another package.
240+
/// - An object runs a task in direct workspace dependency packages selected
241+
/// from package.json fields. For example,
242+
/// `{ "task": "build", "from": "dependencies" }` runs `build` in each
243+
/// direct workspace dependency that defines a `build` task.
244+
pub depends_on: Option<Arc<[UserDependsOnEntry]>>,
173245

174246
/// Cache-related fields
175247
#[serde(flatten)]
@@ -510,10 +582,73 @@ mod tests {
510582
);
511583
let options = user_config.options;
512584
assert_eq!(options.cwd_relative_to_package.as_ref().unwrap().as_str(), "src");
513-
assert_eq!(options.depends_on.as_ref().unwrap().as_ref(), [Str::from("build")]);
585+
assert_eq!(
586+
options.depends_on.as_ref().unwrap().as_ref(),
587+
[UserDependsOnEntry::Task(Str::from("build"))]
588+
);
514589
assert_eq!(options.cache_config, UserCacheConfig::Disabled { cache: MustBe!(false) });
515590
}
516591

592+
#[test]
593+
fn test_depends_on_package_dependency_single_from() {
594+
let user_config_json = json!({
595+
"command": "echo test",
596+
"dependsOn": [{ "task": "build", "from": "dependencies" }]
597+
});
598+
let user_config: UserTaskConfig = serde_json::from_value(user_config_json).unwrap();
599+
assert_eq!(
600+
user_config.options.depends_on.as_ref().unwrap().as_ref(),
601+
[UserDependsOnEntry::Package(UserPackageDependency {
602+
task: "build".into(),
603+
from: UserDependsOnFrom::Single(UserDependencyType::Dependencies),
604+
})]
605+
);
606+
}
607+
608+
#[test]
609+
fn test_depends_on_package_dependency_array_from() {
610+
let user_config_json = json!({
611+
"command": "echo test",
612+
"dependsOn": [{
613+
"task": "build",
614+
"from": ["dependencies", "devDependencies", "peerDependencies"]
615+
}]
616+
});
617+
let user_config: UserTaskConfig = serde_json::from_value(user_config_json).unwrap();
618+
assert_eq!(
619+
user_config.options.depends_on.as_ref().unwrap().as_ref(),
620+
[UserDependsOnEntry::Package(UserPackageDependency {
621+
task: "build".into(),
622+
from: UserDependsOnFrom::Multiple(
623+
Vec1::try_from_vec(vec![
624+
UserDependencyType::Dependencies,
625+
UserDependencyType::DevDependencies,
626+
UserDependencyType::PeerDependencies,
627+
])
628+
.unwrap()
629+
),
630+
})]
631+
);
632+
}
633+
634+
#[test]
635+
fn test_depends_on_package_dependency_empty_from_error() {
636+
let user_config_json = json!({
637+
"command": "echo test",
638+
"dependsOn": [{ "task": "build", "from": [] }]
639+
});
640+
assert!(serde_json::from_value::<UserTaskConfig>(user_config_json).is_err());
641+
}
642+
643+
#[test]
644+
fn test_depends_on_package_dependency_unknown_from_error() {
645+
let user_config_json = json!({
646+
"command": "echo test",
647+
"dependsOn": [{ "task": "build", "from": "optionalDependencies" }]
648+
});
649+
assert!(serde_json::from_value::<UserTaskConfig>(user_config_json).is_err());
650+
}
651+
517652
#[test]
518653
fn test_task_invalid_shorthand_error() {
519654
let user_config_json = json!({

0 commit comments

Comments
 (0)