Skip to content

Commit af0900f

Browse files
authored
feat: handle self-recursion in task planning (#197)
## Summary - Add skip rule and prune rule to prevent infinite recursion when workspace root tasks reference themselves (e.g., `"build": "vp run -r build"`) - Reject unknown fields in `vite-task.json` with `deny_unknown_fields` - Test coverage for extra_arg interaction and `cd` changing the cwd ## Stack - #196 ## Test plan - [x] Plan snapshot tests for self-reference, multi-command, mutual recursion, depends-on passthrough, cd-no-skip fixtures - [x] Unit tests for unknown field rejection at top-level and task-level - [x] All plan snapshot tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent db49c4f commit af0900f

File tree

54 files changed

+1056
-30
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+1056
-30
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "test-workspace",
3+
"private": true,
4+
"scripts": {
5+
"build": "vp run -r build"
6+
}
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "@test/a",
3+
"version": "1.0.0",
4+
"scripts": {
5+
"build": "echo building-a"
6+
}
7+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "@test/b",
3+
"version": "1.0.0",
4+
"scripts": {
5+
"build": "echo building-b"
6+
},
7+
"dependencies": {
8+
"@test/a": "workspace:*"
9+
}
10+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
packages:
2+
- 'packages/*'
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Tests that workspace root self-referencing tasks don't cause infinite recursion.
2+
# Root build = `vp run -r build` (delegates to all packages recursively).
3+
#
4+
# Skip rule: `vp run -r build` from root produces the same query as the
5+
# nested `vp run -r build` in root's script, so root's expansion is skipped.
6+
# Only packages a and b actually run.
7+
#
8+
# Prune rule: `vp run build` from root produces a ContainingPackage query,
9+
# but root's script `vp run -r build` produces an All query. The queries
10+
# differ so the skip rule doesn't fire. Instead the prune rule removes root
11+
# from the nested result, leaving only a and b.
12+
13+
[[e2e]]
14+
name = "recursive build skips root self-reference"
15+
steps = [
16+
"vp run -r build # only a and b run, root is skipped",
17+
]
18+
19+
[[e2e]]
20+
name = "build from root prunes root from nested expansion"
21+
steps = [
22+
"vp run build # only a and b run under root, root is pruned",
23+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
source: crates/vite_task_bin/tests/e2e_snapshots/main.rs
3+
expression: e2e_outputs
4+
---
5+
> vp run build # only a and b run under root, root is pruned
6+
~/packages/a$ echo building-acache disabled
7+
building-a
8+
9+
~/packages/b$ echo building-bcache disabled
10+
building-b
11+
12+
---
13+
[vp run] 0/2 cache hit (0%). (Run `vp run --last-details` for full details)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
source: crates/vite_task_bin/tests/e2e_snapshots/main.rs
3+
expression: e2e_outputs
4+
---
5+
> vp run -r build # only a and b run, root is skipped
6+
~/packages/a$ echo building-acache disabled
7+
building-a
8+
9+
~/packages/b$ echo building-bcache disabled
10+
building-b
11+
12+
---
13+
[vp run] 0/2 cache hit (0%). (Run `vp run --last-details` for full details)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"cache": true
3+
}

crates/vite_task_graph/src/config/user.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ impl ResolvedGlobalCacheConfig {
177177
#[derive(Debug, Default, Deserialize)]
178178
// TS derive macro generates code using std types that clippy disallows; skip derive during linting
179179
#[cfg_attr(all(test, not(clippy)), derive(TS), ts(optional_fields, rename = "RunConfig"))]
180-
#[serde(rename_all = "camelCase")]
180+
#[serde(deny_unknown_fields, rename_all = "camelCase")]
181181
pub struct UserRunConfig {
182182
/// Root-level cache configuration.
183183
///
@@ -454,4 +454,17 @@ mod tests {
454454
serde_json::from_value::<UserGlobalCacheConfig>(json!({ "unknown": true })).is_err()
455455
);
456456
}
457+
458+
#[test]
459+
fn test_run_config_unknown_top_level_field() {
460+
assert!(serde_json::from_value::<UserRunConfig>(json!({ "unknown": true })).is_err());
461+
}
462+
463+
#[test]
464+
fn test_task_config_unknown_field() {
465+
assert!(
466+
serde_json::from_value::<UserTaskConfig>(json!({ "command": "echo", "unknown": true }))
467+
.is_err()
468+
);
469+
}
457470
}

crates/vite_task_graph/src/query/mod.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,20 @@ use crate::{IndexedTaskGraph, TaskDependencyType, TaskId, TaskNodeIndex};
3131
pub type TaskExecutionGraph = DiGraphMap<TaskNodeIndex, ()>;
3232

3333
/// A query for which tasks to run.
34-
#[derive(Debug)]
34+
///
35+
/// A `TaskQuery` must be **self-contained**: it fully describes which tasks
36+
/// will be selected, without relying on ambient state such as cwd or
37+
/// environment variables. For example, the implicit cwd is captured as a
38+
/// `ContainingPackage(path)` selector inside [`PackageQuery`], so two
39+
/// queries from different directories compare as unequal even though the
40+
/// user typed the same CLI arguments.
41+
///
42+
/// This property is essential for the **skip rule** in task planning, which
43+
/// compares the nested query against the parent query with `==`. If any
44+
/// external context leaked into the comparison (or was excluded from it),
45+
/// the skip rule would either miss legitimate recursion or incorrectly
46+
/// suppress distinct expansions.
47+
#[derive(Debug, PartialEq)]
3548
pub struct TaskQuery {
3649
/// Which packages to select.
3750
pub package_query: PackageQuery,

0 commit comments

Comments
 (0)