Skip to content

Commit 18fa8ff

Browse files
branchseerclaude
andcommitted
feat: add pre/post lifecycle hooks for package.json scripts
When a package.json script `X` is run, automatically expand `preX` and `postX` scripts (if they exist in the same package) as inline execution items — matching npm's lifecycle hook behavior. - Hooks are expanded at plan time, not as graph dependency edges - Only applies to `PackageJsonScript` tasks; `TaskConfig` tasks are excluded - Extra CLI args are not forwarded to hook scripts - Configurable via `enablePrePostScripts` in the workspace root `vite-task.json` (defaults to `true`) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7e28617 commit 18fa8ff

21 files changed

+987
-0
lines changed

crates/vite_task_graph/run-config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,15 @@ export type RunConfig = {
9292
* Task definitions
9393
*/
9494
tasks?: { [key in string]?: Task };
95+
/**
96+
* Whether to automatically run `preX`/`postX` package.json scripts as
97+
* lifecycle hooks when script `X` is executed.
98+
*
99+
* When `true` (the default), running script `test` will automatically
100+
* run `pretest` before and `posttest` after, if they exist.
101+
*
102+
* This option can only be set in the workspace root's config file.
103+
* Setting it in a package's config will result in an error.
104+
*/
105+
enablePrePostScripts?: boolean;
95106
};

crates/vite_task_graph/src/config/user.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,16 @@ pub struct UserRunConfig {
224224

225225
/// Task definitions
226226
pub tasks: Option<FxHashMap<Str, UserTaskConfig>>,
227+
228+
/// Whether to automatically run `preX`/`postX` package.json scripts as
229+
/// lifecycle hooks when script `X` is executed.
230+
///
231+
/// When `true` (the default), running script `test` will automatically
232+
/// run `pretest` before and `posttest` after, if they exist.
233+
///
234+
/// This option can only be set in the workspace root's config file.
235+
/// Setting it in a package's config will result in an error.
236+
pub enable_pre_post_scripts: Option<bool>,
227237
}
228238

229239
impl UserRunConfig {

crates/vite_task_graph/src/lib.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ pub enum TaskGraphLoadError {
118118

119119
#[error("`cache` can only be set in the workspace root config, but found in {package_path}")]
120120
CacheInNonRootPackage { package_path: Arc<AbsolutePath> },
121+
122+
#[error(
123+
"`enablePrePostScripts` can only be set in the workspace root config, but found in {package_path}"
124+
)]
125+
PrePostScriptsInNonRootPackage { package_path: Arc<AbsolutePath> },
121126
}
122127

123128
/// Error when looking up a task by its specifier.
@@ -185,8 +190,14 @@ pub struct IndexedTaskGraph {
185190
/// task indices by task id for quick lookup
186191
pub(crate) node_indices_by_task_id: FxHashMap<TaskId, TaskNodeIndex>,
187192

193+
/// Reverse map: task node index → task id (for hook lookup)
194+
task_ids_by_node_index: FxHashMap<TaskNodeIndex, TaskId>,
195+
188196
/// Global cache configuration resolved from the workspace root config.
189197
resolved_global_cache: ResolvedGlobalCacheConfig,
198+
199+
/// Whether pre/post script hooks are enabled (from `enablePrePostScripts` in workspace root config).
200+
pre_post_scripts_enabled: bool,
190201
}
191202

192203
pub type TaskGraph = DiGraph<TaskNode, TaskDependencyType, TaskIx>;
@@ -222,9 +233,12 @@ impl IndexedTaskGraph {
222233
// index tasks by ids
223234
let mut node_indices_by_task_id: FxHashMap<TaskId, TaskNodeIndex> =
224235
FxHashMap::with_capacity_and_hasher(task_graph.node_count(), FxBuildHasher);
236+
let mut task_ids_by_node_index: FxHashMap<TaskNodeIndex, TaskId> =
237+
FxHashMap::with_capacity_and_hasher(task_graph.node_count(), FxBuildHasher);
225238

226239
// First pass: load all configs, extract root cache config, validate
227240
let mut root_cache = None;
241+
let mut root_pre_post_scripts_enabled = None;
228242
let mut package_configs: Vec<(PackageNodeIndex, Arc<AbsolutePath>, UserRunConfig)> =
229243
Vec::with_capacity(package_graph.node_count());
230244

@@ -252,6 +266,16 @@ impl IndexedTaskGraph {
252266
}
253267
}
254268

269+
if let Some(val) = user_config.enable_pre_post_scripts {
270+
if is_workspace_root {
271+
root_pre_post_scripts_enabled = Some(val);
272+
} else {
273+
return Err(TaskGraphLoadError::PrePostScriptsInNonRootPackage {
274+
package_path: package_dir.clone(),
275+
});
276+
}
277+
}
278+
255279
package_configs.push((package_index, package_dir, user_config));
256280
}
257281

@@ -312,6 +336,7 @@ impl IndexedTaskGraph {
312336

313337
let node_index = task_graph.add_node(task_node);
314338
task_ids_with_dependency_specifiers.push((task_id.clone(), dependency_specifiers));
339+
task_ids_by_node_index.insert(node_index, task_id.clone());
315340
node_indices_by_task_id.insert(task_id, node_index);
316341
}
317342

@@ -340,6 +365,7 @@ impl IndexedTaskGraph {
340365
resolved_config,
341366
source: TaskSource::PackageJsonScript,
342367
});
368+
task_ids_by_node_index.insert(node_index, task_id.clone());
343369
node_indices_by_task_id.insert(task_id, node_index);
344370
}
345371
}
@@ -349,7 +375,9 @@ impl IndexedTaskGraph {
349375
task_graph,
350376
indexed_package_graph: IndexedPackageGraph::index(package_graph),
351377
node_indices_by_task_id,
378+
task_ids_by_node_index,
352379
resolved_global_cache,
380+
pre_post_scripts_enabled: root_pre_post_scripts_enabled.unwrap_or(true),
353381
};
354382

355383
// Add explicit dependencies
@@ -459,4 +487,32 @@ impl IndexedTaskGraph {
459487
pub const fn global_cache_config(&self) -> &ResolvedGlobalCacheConfig {
460488
&self.resolved_global_cache
461489
}
490+
491+
/// Whether pre/post script hooks are enabled workspace-wide.
492+
#[must_use]
493+
pub const fn pre_post_scripts_enabled(&self) -> bool {
494+
self.pre_post_scripts_enabled
495+
}
496+
497+
/// Returns the `TaskNodeIndex` of the pre/post hook for a `PackageJsonScript` task.
498+
///
499+
/// Given a task named `X` and `prefix = "pre"`, looks up `preX` in the same package.
500+
/// Given a task named `X` and `prefix = "post"`, looks up `postX` in the same package.
501+
///
502+
/// Returns `None` if:
503+
/// - The task is not a `PackageJsonScript`
504+
/// - No `{prefix}{name}` script exists in the same package
505+
/// - The hook is not itself a `PackageJsonScript`
506+
#[must_use]
507+
pub fn get_script_hook(&self, task_idx: TaskNodeIndex, prefix: &str) -> Option<TaskNodeIndex> {
508+
let task_node = &self.task_graph[task_idx];
509+
if task_node.source != TaskSource::PackageJsonScript {
510+
return None;
511+
}
512+
let task_id = self.task_ids_by_node_index.get(&task_idx)?;
513+
let hook_name = vite_str::format!("{prefix}{}", task_node.task_display.task_name);
514+
let hook_id = TaskId { package_index: task_id.package_index, task_name: hook_name };
515+
let &hook_idx = self.node_indices_by_task_id.get(&hook_id)?;
516+
(self.task_graph[hook_idx].source == TaskSource::PackageJsonScript).then_some(hook_idx)
517+
}
462518
}

crates/vite_task_plan/src/plan.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,23 @@ async fn plan_task_as_execution_node(
100100

101101
let mut items = Vec::<ExecutionItem>::new();
102102

103+
// Expand pre/post hooks (`preX`/`postX`) for package.json scripts.
104+
// Resolve the flag once before any mutable borrow of `context` (duplicate() needs &mut).
105+
let pre_post_scripts_enabled = context.indexed_task_graph().pre_post_scripts_enabled();
106+
let pre_hook_idx = if pre_post_scripts_enabled {
107+
context.indexed_task_graph().get_script_hook(task_node_index, "pre")
108+
} else {
109+
None
110+
};
111+
if let Some(pre_hook_idx) = pre_hook_idx {
112+
let mut pre_context = context.duplicate();
113+
// Extra args (e.g. `vt run test --coverage`) must not be forwarded to hooks.
114+
pre_context.set_extra_args(Arc::new([]));
115+
let pre_execution =
116+
Box::pin(plan_task_as_execution_node(pre_hook_idx, pre_context)).await?;
117+
items.extend(pre_execution.items);
118+
}
119+
103120
// Use task's resolved cwd for display (from task config's cwd option)
104121
let mut cwd = Arc::clone(&task_node.resolved_config.resolved_options.cwd);
105122

@@ -357,6 +374,21 @@ async fn plan_task_as_execution_node(
357374
});
358375
}
359376

377+
// Expand post-hook (`postX`) for package.json scripts.
378+
let post_hook_idx = if pre_post_scripts_enabled {
379+
context.indexed_task_graph().get_script_hook(task_node_index, "post")
380+
} else {
381+
None
382+
};
383+
if let Some(post_hook_idx) = post_hook_idx {
384+
let mut post_context = context.duplicate();
385+
// Extra args must not be forwarded to hooks.
386+
post_context.set_extra_args(Arc::new([]));
387+
let post_execution =
388+
Box::pin(plan_task_as_execution_node(post_hook_idx, post_context)).await?;
389+
items.extend(post_execution.items);
390+
}
391+
360392
Ok(TaskExecution { task_display: task_node.task_display.clone(), items })
361393
}
362394

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "@test/script-hooks-disabled",
3+
"scripts": {
4+
"pretest": "echo pretest",
5+
"test": "echo test",
6+
"posttest": "echo posttest"
7+
}
8+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Tests that pre/post hooks are NOT expanded when enablePrePostScripts is false.
2+
3+
[[plan]]
4+
name = "test runs without hooks when disabled"
5+
args = ["run", "test"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
source: crates/vite_task_plan/tests/plan_snapshots/main.rs
3+
expression: "&plan_json"
4+
info:
5+
args:
6+
- run
7+
- test
8+
input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/script-hooks-disabled
9+
---
10+
[
11+
{
12+
"key": [
13+
"<workspace>/",
14+
"test"
15+
],
16+
"node": {
17+
"task_display": {
18+
"package_name": "@test/script-hooks-disabled",
19+
"task_name": "test",
20+
"package_path": "<workspace>/"
21+
},
22+
"items": [
23+
{
24+
"execution_item_display": {
25+
"task_display": {
26+
"package_name": "@test/script-hooks-disabled",
27+
"task_name": "test",
28+
"package_path": "<workspace>/"
29+
},
30+
"command": "echo test",
31+
"and_item_index": null,
32+
"cwd": "<workspace>/"
33+
},
34+
"kind": {
35+
"Leaf": {
36+
"InProcess": {
37+
"kind": {
38+
"Echo": {
39+
"strings": [
40+
"test"
41+
],
42+
"trailing_newline": true
43+
}
44+
}
45+
}
46+
}
47+
}
48+
}
49+
]
50+
},
51+
"neighbors": []
52+
}
53+
]
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
---
2+
source: crates/vite_task_plan/tests/plan_snapshots/main.rs
3+
expression: task_graph_json
4+
input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/script-hooks-disabled
5+
---
6+
[
7+
{
8+
"key": [
9+
"<workspace>/",
10+
"posttest"
11+
],
12+
"node": {
13+
"task_display": {
14+
"package_name": "@test/script-hooks-disabled",
15+
"task_name": "posttest",
16+
"package_path": "<workspace>/"
17+
},
18+
"resolved_config": {
19+
"command": "echo posttest",
20+
"resolved_options": {
21+
"cwd": "<workspace>/",
22+
"cache_config": {
23+
"env_config": {
24+
"fingerprinted_envs": [],
25+
"untracked_env": [
26+
"<default untracked envs>"
27+
]
28+
},
29+
"input_config": {
30+
"includes_auto": true,
31+
"positive_globs": [],
32+
"negative_globs": []
33+
}
34+
}
35+
}
36+
},
37+
"source": "PackageJsonScript"
38+
},
39+
"neighbors": []
40+
},
41+
{
42+
"key": [
43+
"<workspace>/",
44+
"pretest"
45+
],
46+
"node": {
47+
"task_display": {
48+
"package_name": "@test/script-hooks-disabled",
49+
"task_name": "pretest",
50+
"package_path": "<workspace>/"
51+
},
52+
"resolved_config": {
53+
"command": "echo pretest",
54+
"resolved_options": {
55+
"cwd": "<workspace>/",
56+
"cache_config": {
57+
"env_config": {
58+
"fingerprinted_envs": [],
59+
"untracked_env": [
60+
"<default untracked envs>"
61+
]
62+
},
63+
"input_config": {
64+
"includes_auto": true,
65+
"positive_globs": [],
66+
"negative_globs": []
67+
}
68+
}
69+
}
70+
},
71+
"source": "PackageJsonScript"
72+
},
73+
"neighbors": []
74+
},
75+
{
76+
"key": [
77+
"<workspace>/",
78+
"test"
79+
],
80+
"node": {
81+
"task_display": {
82+
"package_name": "@test/script-hooks-disabled",
83+
"task_name": "test",
84+
"package_path": "<workspace>/"
85+
},
86+
"resolved_config": {
87+
"command": "echo test",
88+
"resolved_options": {
89+
"cwd": "<workspace>/",
90+
"cache_config": {
91+
"env_config": {
92+
"fingerprinted_envs": [],
93+
"untracked_env": [
94+
"<default untracked envs>"
95+
]
96+
},
97+
"input_config": {
98+
"includes_auto": true,
99+
"positive_globs": [],
100+
"negative_globs": []
101+
}
102+
}
103+
}
104+
},
105+
"source": "PackageJsonScript"
106+
},
107+
"neighbors": []
108+
}
109+
]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"enablePrePostScripts": false
3+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "@test/script-hooks-task-no-hook",
3+
"scripts": {
4+
"pretest": "echo pretest-script"
5+
}
6+
}

0 commit comments

Comments
 (0)