Skip to content

Commit 3d2e41a

Browse files
committed
fix(plan): preserve task command array boundaries
Normalize resolved task commands to arrays so the planner parses each configured command item independently. Include command_item_index in UserTask cache keys and update snapshots for the new resolved config shape.
1 parent 57986aa commit 3d2e41a

90 files changed

Lines changed: 1126 additions & 350 deletions

File tree

Some content is hidden

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

crates/vite_task/docs/terminologies.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
{
88
"name": "app",
99
"scripts": {
10-
"build": "echo build1 && echo build2"
11-
}
10+
"build": "echo build1 && echo build2",
11+
},
1212
}
1313
```
1414

@@ -17,8 +17,8 @@
1717
{
1818
"tasks": {
1919
"lint": "echo lint",
20-
"check": ["eslint .", "tsc --noEmit", "prettier --check ."]
21-
}
20+
"check": ["eslint .", "tsc --noEmit", "prettier --check ."],
21+
},
2222
}
2323
```
2424

crates/vite_task_graph/run-config.ts

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,57 @@ export type InputBase = "package" | "workspace";
2020

2121
export type Task = {
2222
/**
23-
* Command string or sequence of command strings to run for the task.
23+
* Command string to run for the task.
2424
*/
25-
command: TaskCommand,
25+
command: string,
26+
/**
27+
* The working directory for the task, relative to the package root (not workspace root).
28+
*/
29+
cwd?: string,
30+
/**
31+
* Dependencies of this task. Use `package-name#task-name` to refer to tasks in other packages.
32+
*/
33+
dependsOn?: Array<string>, } & ({
34+
/**
35+
* Whether to cache the task
36+
*/
37+
cache?: true,
38+
/**
39+
* Environment variable names to be fingerprinted and passed to the task.
40+
*/
41+
env?: Array<string>,
42+
/**
43+
* Environment variable names to be passed to the task without fingerprinting.
44+
*/
45+
untrackedEnv?: Array<string>,
46+
/**
47+
* Files to include in the cache fingerprint.
48+
*
49+
* - Omitted: automatically tracks which files the task reads
50+
* - `[]` (empty): disables file tracking entirely
51+
* - Glob patterns (e.g. `"src/**"`) select specific files, relative to the package directory
52+
* - `{pattern: "...", base: "workspace" | "package"}` specifies a glob with an explicit base directory
53+
* - `{auto: true}` enables automatic file tracking
54+
* - Negative patterns (e.g. `"!dist/**"`) exclude matched files
55+
*/
56+
input?: Array<string | GlobWithBase | AutoInput>,
57+
/**
58+
* Output files to archive after a successful run and restore on cache hit.
59+
*
60+
* - Omitted or `[]` (empty): no output archiving (default)
61+
* - Glob patterns (e.g. `"dist/**"`) select specific output files, relative to the package directory
62+
* - `{pattern: "...", base: "workspace" | "package"}` specifies a glob with an explicit base directory
63+
* - Negative patterns (e.g. `"!dist/cache/**"`) exclude matched files
64+
*/
65+
output?: Array<string | GlobWithBase>, } | {
66+
/**
67+
* Whether to cache the task
68+
*/
69+
cache: false, }) | {
70+
/**
71+
* Command strings to run for the task.
72+
*/
73+
command: Array<string>,
2674
/**
2775
* The working directory for the task, relative to the package root (not workspace root).
2876
*/
@@ -68,9 +116,7 @@ output?: Array<string | GlobWithBase>, } | {
68116
*/
69117
cache: false, });
70118

71-
export type TaskCommand = string | Array<string>;
72-
73-
export type TaskDefinition = Task | TaskCommand;
119+
export type TaskDefinition = Task | string | Array<string>;
74120

75121
export type UserGlobalCacheConfig = boolean | {
76122
/**

crates/vite_task_graph/src/config/mod.rs

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use monostate::MustBe;
66
use rustc_hash::FxHashSet;
77
use serde::Serialize;
88
pub use user::{
9-
AutoInput, EnabledCacheConfig, GlobWithBase, InputBase, ResolvedGlobalCacheConfig, TaskCommand,
9+
AutoInput, EnabledCacheConfig, GlobWithBase, InputBase, ResolvedGlobalCacheConfig,
1010
UserCacheConfig, UserGlobalCacheConfig, UserInputEntry, UserInputsConfig, UserOutputEntry,
1111
UserRunConfig, UserTaskConfig, UserTaskDefinition,
1212
};
@@ -31,7 +31,7 @@ pub struct ResolvedTaskConfig {
3131
/// The command or commands to run for this task.
3232
///
3333
/// Commands may contain environment variables that need to be expanded later.
34-
pub command: TaskCommand,
34+
pub commands: Arc<[Str]>,
3535

3636
pub resolved_options: ResolvedTaskOptions,
3737
}
@@ -360,7 +360,7 @@ impl ResolvedTaskConfig {
360360
workspace_root: &AbsolutePath,
361361
) -> Result<Self, ResolveTaskConfigError> {
362362
Ok(Self {
363-
command: TaskCommand::String(package_json_script.into()),
363+
commands: vec![package_json_script.into()].into(),
364364
resolved_options: ResolvedTaskOptions::resolve(
365365
UserTaskOptions::default(),
366366
package_dir,
@@ -379,13 +379,10 @@ impl ResolvedTaskConfig {
379379
package_dir: &Arc<AbsolutePath>,
380380
workspace_root: &AbsolutePath,
381381
) -> Result<Self, ResolveTaskConfigError> {
382+
let (commands, options) = user_config.into_parts();
382383
Ok(Self {
383-
command: user_config.command,
384-
resolved_options: ResolvedTaskOptions::resolve(
385-
user_config.options,
386-
package_dir,
387-
workspace_root,
388-
)?,
384+
commands,
385+
resolved_options: ResolvedTaskOptions::resolve(options, package_dir, workspace_root)?,
389386
})
390387
}
391388
}

crates/vite_task_graph/src/config/user.rs

Lines changed: 56 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::sync::Arc;
44

55
use monostate::MustBe;
66
use rustc_hash::FxHashMap;
7-
use serde::{Deserialize, Serialize};
7+
use serde::Deserialize;
88
#[cfg(all(test, not(clippy)))]
99
use ts_rs::TS;
1010
use vite_path::RelativePathBuf;
@@ -193,42 +193,47 @@ impl Default for UserTaskOptions {
193193
}
194194
}
195195

196-
/// Task command: a command string or a sequence of command strings.
197-
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
196+
/// Full user-defined task configuration in `vite.config.*`, including the command and options.
197+
#[derive(Debug, Deserialize, PartialEq, Eq)]
198198
// TS derive macro generates code using std types that clippy disallows; skip derive during linting
199-
#[cfg_attr(all(test, not(clippy)), derive(TS))]
200-
#[serde(untagged)]
201-
pub enum TaskCommand {
202-
/// A single command string.
203-
String(Str),
204-
/// Command strings to run in order.
205-
Array(Vec<Str>),
206-
}
199+
#[cfg_attr(all(test, not(clippy)), derive(TS), ts(optional_fields, rename = "Task"))]
200+
#[serde(untagged, rename_all = "camelCase")]
201+
pub enum UserTaskConfig {
202+
/// Task object form with a single command string.
203+
String {
204+
/// Command string to run for the task.
205+
command: Str,
206+
207+
/// Fields other than the command
208+
#[serde(flatten)]
209+
options: UserTaskOptions,
210+
},
211+
/// Task object form with a sequence of command strings.
212+
Array {
213+
/// Command strings to run for the task.
214+
command: Arc<[Str]>,
207215

208-
impl From<&str> for TaskCommand {
209-
fn from(value: &str) -> Self {
210-
Self::String(value.into())
211-
}
216+
/// Fields other than the command
217+
#[serde(flatten)]
218+
options: UserTaskOptions,
219+
},
212220
}
213221

214-
impl From<Str> for TaskCommand {
215-
fn from(value: Str) -> Self {
216-
Self::String(value)
222+
impl UserTaskConfig {
223+
#[must_use]
224+
pub const fn options(&self) -> &UserTaskOptions {
225+
match self {
226+
Self::String { options, .. } | Self::Array { options, .. } => options,
227+
}
217228
}
218-
}
219-
220-
/// Full user-defined task configuration in `vite.config.*`, including the command and options.
221-
#[derive(Debug, Deserialize, PartialEq, Eq)]
222-
// TS derive macro generates code using std types that clippy disallows; skip derive during linting
223-
#[cfg_attr(all(test, not(clippy)), derive(TS), ts(optional_fields, rename = "Task"))]
224-
#[serde(rename_all = "camelCase")]
225-
pub struct UserTaskConfig {
226-
/// Command string or sequence of command strings to run for the task.
227-
pub command: TaskCommand,
228229

229-
/// Fields other than the command
230-
#[serde(flatten)]
231-
pub options: UserTaskOptions,
230+
#[must_use]
231+
pub fn into_parts(self) -> (Arc<[Str]>, UserTaskOptions) {
232+
match self {
233+
Self::String { command, options } => (vec![command].into(), options),
234+
Self::Array { command, options } => (command, options),
235+
}
236+
}
232237
}
233238

234239
/// User-defined task configuration or command-only shorthand in `vite.config.*`.
@@ -240,7 +245,9 @@ pub enum UserTaskDefinition {
240245
/// Full task object form.
241246
Config(UserTaskConfig),
242247
/// Command-only shorthand form using default task options.
243-
Command(TaskCommand),
248+
CommandString(Str),
249+
/// Command sequence shorthand form using default task options.
250+
CommandArray(Arc<[Str]>),
244251
}
245252

246253
/// Root-level cache configuration.
@@ -449,7 +456,10 @@ mod tests {
449456
let user_config: UserTaskConfig = serde_json::from_value(user_config_json).unwrap();
450457
assert_eq!(
451458
user_config,
452-
UserTaskConfig { command: "echo hello".into(), options: UserTaskOptions::default() }
459+
UserTaskConfig::String {
460+
command: "echo hello".into(),
461+
options: UserTaskOptions::default()
462+
}
453463
);
454464
}
455465

@@ -459,11 +469,12 @@ mod tests {
459469
"command": ["echo one", "echo two", "echo three"]
460470
});
461471
let user_config: UserTaskConfig = serde_json::from_value(user_config_json).unwrap();
472+
let (commands, options) = user_config.into_parts();
462473
assert_eq!(
463-
user_config.command,
464-
TaskCommand::Array(vec!["echo one".into(), "echo two".into(), "echo three".into()])
474+
commands,
475+
Arc::from(["echo one".into(), "echo two".into(), "echo three".into()])
465476
);
466-
assert_eq!(user_config.options, UserTaskOptions::default());
477+
assert_eq!(options, UserTaskOptions::default());
467478
}
468479

469480
#[test]
@@ -475,7 +486,7 @@ mod tests {
475486
});
476487
let mut user_config: UserRunConfig = serde_json::from_value(user_config_json).unwrap();
477488
let task = user_config.tasks.as_mut().unwrap().remove("build").unwrap();
478-
assert_eq!(task, UserTaskDefinition::Command(TaskCommand::String("echo build".into())));
489+
assert_eq!(task, UserTaskDefinition::CommandString("echo build".into()));
479490
}
480491

481492
#[test]
@@ -489,7 +500,7 @@ mod tests {
489500
let task = user_config.tasks.as_mut().unwrap().remove("build").unwrap();
490501
assert_eq!(
491502
task,
492-
UserTaskDefinition::Command(TaskCommand::Array(vec![
503+
UserTaskDefinition::CommandArray(Arc::from([
493504
"echo one".into(),
494505
"echo two".into(),
495506
"echo three".into()
@@ -506,16 +517,11 @@ mod tests {
506517
"cache": false
507518
});
508519
let user_config: UserTaskConfig = serde_json::from_value(user_config_json).unwrap();
509-
assert_eq!(
510-
user_config.command,
511-
TaskCommand::Array(vec!["echo one".into(), "echo two".into()])
512-
);
513-
assert_eq!(user_config.options.cwd_relative_to_package.as_ref().unwrap().as_str(), "src");
514-
assert_eq!(user_config.options.depends_on.as_ref().unwrap().as_ref(), [Str::from("build")]);
515-
assert_eq!(
516-
user_config.options.cache_config,
517-
UserCacheConfig::Disabled { cache: MustBe!(false) }
518-
);
520+
let (commands, options) = user_config.into_parts();
521+
assert_eq!(commands, Arc::from(["echo one".into(), "echo two".into()]));
522+
assert_eq!(options.cwd_relative_to_package.as_ref().unwrap().as_str(), "src");
523+
assert_eq!(options.depends_on.as_ref().unwrap().as_ref(), [Str::from("build")]);
524+
assert_eq!(options.cache_config, UserCacheConfig::Disabled { cache: MustBe!(false) });
519525
}
520526

521527
#[test]
@@ -543,7 +549,7 @@ mod tests {
543549
"cwd": "src"
544550
});
545551
let user_config: UserTaskConfig = serde_json::from_value(user_config_json).unwrap();
546-
assert_eq!(user_config.options.cwd_relative_to_package.as_ref().unwrap().as_str(), "src");
552+
assert_eq!(user_config.options().cwd_relative_to_package.as_ref().unwrap().as_str(), "src");
547553
}
548554

549555
#[test]
@@ -554,7 +560,7 @@ mod tests {
554560
});
555561
let user_config: UserTaskConfig = serde_json::from_value(user_config_json).unwrap();
556562
assert_eq!(
557-
user_config.options.cache_config,
563+
user_config.options().cache_config,
558564
UserCacheConfig::Disabled { cache: MustBe!(false) }
559565
);
560566
}

crates/vite_task_graph/src/display.rs

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use serde::Serialize;
66
use vite_path::AbsolutePath;
77
use vite_str::Str;
88

9-
use crate::{IndexedTaskGraph, TaskNodeIndex, config::TaskCommand};
9+
use crate::{IndexedTaskGraph, TaskNodeIndex};
1010

1111
/// struct for printing a task in a human-readable way.
1212
#[derive(Debug, Clone, Serialize)]
@@ -50,27 +50,26 @@ impl IndexedTaskGraph {
5050
let node = &self.task_graph()[idx];
5151
TaskListEntry {
5252
task_display: node.task_display.clone(),
53-
command: format_command_for_task_list(&node.resolved_config.command),
53+
command: format_command_for_task_list(&node.resolved_config.commands),
5454
}
5555
})
5656
.collect()
5757
}
5858
}
5959

6060
// Display-only formatting for task list/selector descriptions. Execution planning keeps
61-
// `TaskCommand` structured and must not depend on this joined string.
62-
fn format_command_for_task_list(command: &TaskCommand) -> Str {
63-
match command {
64-
TaskCommand::String(command) => command.clone(),
65-
TaskCommand::Array(commands) => {
66-
let mut display = Str::default();
67-
for (index, command) in commands.iter().enumerate() {
68-
if index > 0 {
69-
display.push_str(" && ");
70-
}
71-
display.push_str(command.as_str());
61+
// command arrays structured and must not depend on this joined string.
62+
fn format_command_for_task_list(commands: &Arc<[Str]>) -> Str {
63+
if commands.len() == 1 {
64+
commands[0].clone()
65+
} else {
66+
let mut display = Str::default();
67+
for (index, command) in commands.iter().enumerate() {
68+
if index > 0 {
69+
display.push_str(" && ");
7270
}
73-
display
71+
display.push_str(command.as_str());
7472
}
73+
display
7574
}
7675
}

crates/vite_task_graph/src/lib.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -308,11 +308,14 @@ impl IndexedTaskGraph {
308308

309309
let task_user_config = match task_user_config {
310310
UserTaskDefinition::Config(config) => config,
311-
UserTaskDefinition::Command(command) => {
312-
UserTaskConfig { command, options: UserTaskOptions::default() }
311+
UserTaskDefinition::CommandString(command) => {
312+
UserTaskConfig::String { command, options: UserTaskOptions::default() }
313+
}
314+
UserTaskDefinition::CommandArray(command) => {
315+
UserTaskConfig::Array { command, options: UserTaskOptions::default() }
313316
}
314317
};
315-
let dependency_specifiers = task_user_config.options.depends_on.clone();
318+
let dependency_specifiers = task_user_config.options().depends_on.clone();
316319

317320
// Resolve the task configuration from the user config
318321
let resolved_config = ResolvedTaskConfig::resolve(

crates/vite_task_plan/src/cache_metadata.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ pub enum ExecutionCacheKey {
1515
UserTask {
1616
/// The name of the user-defined task.
1717
task_name: Str,
18-
/// The index of the execution item in the task's command split by `&&`.
18+
/// The index of the command item in the task's command array.
19+
command_item_index: usize,
20+
/// The index of the execution item within the command item split by `&&`.
1921
/// This is to distinguish multiple execution items from the same task.
2022
and_item_index: usize,
2123
/// Extra args provided when invoking the user-defined task (`vp [task_name] [extra_args...]`).

0 commit comments

Comments
 (0)