From 4d479bdb0e04fc60be0e322072fff9efb4f4546f Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 25 Oct 2026 22:12:50 +0800 Subject: [PATCH 1/3] fix(run): restore task configuration from Vite plugin hooks --- .../vite_config_task_from_plugin/package.json | 4 ++ .../snapshots.toml | 14 ++++++ .../snapshots/task_added_by_plugin.md | 10 ++++ .../snapshots/task_overridden_by_plugin.md | 13 ++++++ .../vite.config.ts | 18 ++++++++ packages/cli/binding/src/cli/handler.rs | 46 +++++++++++-------- 6 files changed, 86 insertions(+), 19 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots/task_added_by_plugin.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots/task_overridden_by_plugin.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/vite.config.ts diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/package.json new file mode 100644 index 0000000000..2e126e5e5d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/package.json @@ -0,0 +1,4 @@ +{ + "name": "vite-config-task-from-plugin-test", + "private": true +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots.toml new file mode 100644 index 0000000000..ba8cea58db --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots.toml @@ -0,0 +1,14 @@ +[[case]] +name = "task_added_by_plugin" +vp = "local" +comment = "A plugin config hook can provide a task when the config has no run field." +steps = [["vp", "run", "build:prod"]] + +[[case]] +name = "task_overridden_by_plugin" +vp = "local" +comment = "A plugin config hook can override a statically analyzable task." +steps = [ + { argv = ["vpt", "replace-file-content", "vite.config.ts", "export default {", "export default { run: { tasks: { 'build:prod': { command: 'vpt exit 1' } } },"], snapshot = false }, + ["vp", "run", "build:prod"], +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots/task_added_by_plugin.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots/task_added_by_plugin.md new file mode 100644 index 0000000000..afd1b23437 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots/task_added_by_plugin.md @@ -0,0 +1,10 @@ +# task_added_by_plugin + +A plugin config hook can provide a task when the config has no run field. + +## `vp run build:prod` + +``` +$ vpt print build:prod from plugin config hook +build:prod from plugin config hook +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots/task_overridden_by_plugin.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots/task_overridden_by_plugin.md new file mode 100644 index 0000000000..48ae163518 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/snapshots/task_overridden_by_plugin.md @@ -0,0 +1,13 @@ +# task_overridden_by_plugin + +A plugin config hook can override a statically analyzable task. + +## `vpt replace-file-content vite.config.ts 'export default {' 'export default { run: { tasks: { '\''build:prod'\'': { command: '\''vpt exit 1'\'' } } },'` + + +## `vp run build:prod` + +``` +$ vpt print build:prod from plugin config hook +build:prod from plugin config hook +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/vite.config.ts new file mode 100644 index 0000000000..c7c7df34bc --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_config_task_from_plugin/vite.config.ts @@ -0,0 +1,18 @@ +export default { + plugins: [ + { + name: 'task-plugin', + config() { + return { + run: { + tasks: { + 'build:prod': { + command: 'vpt print build:prod from plugin config hook', + }, + }, + }, + }; + }, + }, + ], +}; diff --git a/packages/cli/binding/src/cli/handler.rs b/packages/cli/binding/src/cli/handler.rs index e49b5c9988..d4eb6c81bb 100644 --- a/packages/cli/binding/src/cli/handler.rs +++ b/packages/cli/binding/src/cli/handler.rs @@ -166,25 +166,33 @@ impl UserConfigLoader for VitePlusConfigLoader { ) -> anyhow::Result> { // Try static config extraction first (no JS runtime needed) let static_fields = vp_static_config::resolve_static_config(package_path); - match static_fields.get("run") { - Some(vp_static_config::FieldValue::Json(run_value)) => { - tracing::debug!( - "Using statically extracted run config for {}", - package_path.as_path().display() - ); - let run_config: UserRunConfig = serde_json::from_value(run_value)?; - return Ok(Some(run_config)); - } - Some(vp_static_config::FieldValue::NonStatic) => { - // `run` field exists (or may exist via a spread) — fall back to NAPI - tracing::debug!( - "run config is not statically analyzable for {}, falling back to NAPI", - package_path.as_path().display() - ); - } - None => { - // Config was analyzed successfully and `run` field is definitively absent - return Ok(None); + // Plugin config hooks may add or modify `run`, even when it is statically analyzable. + if static_fields.get("plugins").is_some() { + tracing::debug!( + "plugins detected for {}, falling back to NAPI to run config hooks", + package_path.as_path().display() + ); + } else { + match static_fields.get("run") { + Some(vp_static_config::FieldValue::Json(run_value)) => { + tracing::debug!( + "Using statically extracted run config for {}", + package_path.as_path().display() + ); + let run_config: UserRunConfig = serde_json::from_value(run_value)?; + return Ok(Some(run_config)); + } + Some(vp_static_config::FieldValue::NonStatic) => { + // `run` field exists (or may exist via a spread) — fall back to NAPI + tracing::debug!( + "run config is not statically analyzable for {}, falling back to NAPI", + package_path.as_path().display() + ); + } + None => { + // Config was analyzed successfully and `run` field is definitively absent + return Ok(None); + } } } From 39e6579b2083345262021242342eebeaf54d4275 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 25 Oct 2026 22:19:28 +0800 Subject: [PATCH 2/3] refactor(run): simplify plugin config fallback --- packages/cli/binding/src/cli/handler.rs | 53 ++++++++++++------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/packages/cli/binding/src/cli/handler.rs b/packages/cli/binding/src/cli/handler.rs index d4eb6c81bb..f4b7a56cfb 100644 --- a/packages/cli/binding/src/cli/handler.rs +++ b/packages/cli/binding/src/cli/handler.rs @@ -166,33 +166,32 @@ impl UserConfigLoader for VitePlusConfigLoader { ) -> anyhow::Result> { // Try static config extraction first (no JS runtime needed) let static_fields = vp_static_config::resolve_static_config(package_path); - // Plugin config hooks may add or modify `run`, even when it is statically analyzable. - if static_fields.get("plugins").is_some() { - tracing::debug!( - "plugins detected for {}, falling back to NAPI to run config hooks", - package_path.as_path().display() - ); - } else { - match static_fields.get("run") { - Some(vp_static_config::FieldValue::Json(run_value)) => { - tracing::debug!( - "Using statically extracted run config for {}", - package_path.as_path().display() - ); - let run_config: UserRunConfig = serde_json::from_value(run_value)?; - return Ok(Some(run_config)); - } - Some(vp_static_config::FieldValue::NonStatic) => { - // `run` field exists (or may exist via a spread) — fall back to NAPI - tracing::debug!( - "run config is not statically analyzable for {}, falling back to NAPI", - package_path.as_path().display() - ); - } - None => { - // Config was analyzed successfully and `run` field is definitively absent - return Ok(None); - } + match static_fields.get("run") { + // Plugin config hooks may add or override `run`, so only Vite can resolve it. + _ if static_fields.get("plugins").is_some() => { + tracing::debug!( + "plugins detected for {}, falling back to NAPI to run config hooks", + package_path.as_path().display() + ); + } + Some(vp_static_config::FieldValue::Json(run_value)) => { + tracing::debug!( + "Using statically extracted run config for {}", + package_path.as_path().display() + ); + let run_config: UserRunConfig = serde_json::from_value(run_value)?; + return Ok(Some(run_config)); + } + Some(vp_static_config::FieldValue::NonStatic) => { + // `run` field exists (or may exist via a spread) — fall back to NAPI + tracing::debug!( + "run config is not statically analyzable for {}, falling back to NAPI", + package_path.as_path().display() + ); + } + None => { + // Config was analyzed successfully and `run` field is definitively absent + return Ok(None); } } From 4c5e1b2087b2c1d7ed7b9326d31bd4df05cda0e1 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 25 Oct 2026 22:45:24 +0800 Subject: [PATCH 3/3] refactor(static-config): treat configs with dynamic plugins as dynamic --- crates/vp_static_config/README.md | 23 ++++---- crates/vp_static_config/src/lib.rs | 78 ++++++++++++++++++++----- packages/cli/binding/src/cli/handler.rs | 7 --- 3 files changed, 77 insertions(+), 31 deletions(-) diff --git a/crates/vp_static_config/README.md b/crates/vp_static_config/README.md index 950649567d..1112312e56 100644 --- a/crates/vp_static_config/README.md +++ b/crates/vp_static_config/README.md @@ -38,16 +38,19 @@ Searches for config files in the same order as Vite's ## Return type -`resolve_static_config` returns `Option, FieldValue>>`: - -- **`None`** — config is not statically analyzable (no config file, parse error, no - `export default`/`module.exports`, or the exported value is not an object literal). - Caller should fall back to runtime evaluation (e.g. NAPI). -- **`Some(map)`** — config object was successfully located: - - `FieldValue::Json(value)` — field value extracted as pure JSON - - `FieldValue::NonStatic` — field exists but contains non-JSON expressions - (function calls, variables, template literals with interpolation, etc.) - - Key absent — field does not exist in the config object +`resolve_static_config` returns a `FieldMap`. `FieldMap::get` answers what a field is at runtime: + +- `Some(FieldValue::Json(value))` — field value extracted as pure JSON +- `Some(FieldValue::NonStatic)` — field contains non-JSON expressions, may come from a spread or + computed key, or the config is not statically analyzable. Caller should fall back to runtime + evaluation (e.g. NAPI). +- `None` — field does not exist in the config object + +When `plugins` is non-static, or may exist in a spread, `get` returns `NonStatic` for every field: +plugin `config` hooks can add or override any field at runtime. + +`FieldMap::get_declared` only reports fields written in the config object, ignoring spreads and +plugins. Use it for heuristics where a field that merely might exist must not change behavior. ## Limitations diff --git a/crates/vp_static_config/src/lib.rs b/crates/vp_static_config/src/lib.rs index d79c2d1a06..6abf9d8af6 100644 --- a/crates/vp_static_config/src/lib.rs +++ b/crates/vp_static_config/src/lib.rs @@ -46,6 +46,9 @@ pub enum FieldValue { /// values). Absent keys may exist via the spread and are treated as /// [`FieldValue::NonStatic`] by [`FieldMap::get`], but not by /// [`FieldMap::get_declared`]. +/// +/// Either way, [`FieldMap::get`] treats every field as +/// [`FieldValue::NonStatic`] when `plugins` may be non-static. pub struct FieldMap { map: FxHashMap, FieldValue>, open: bool, @@ -64,8 +67,11 @@ impl FieldMap { Self { map: FxHashMap::default(), open: false } } - /// Look up a field by name. + /// Look up a field's runtime value. /// + /// - non-static `plugins` (including ones that may exist in a spread): + /// returns `Some(NonStatic)` for every key, because plugin `config` + /// hooks can add or override any field. /// - closed map: returns the stored value, or `None` if the field is /// definitively absent. /// - open map: returns the stored value if explicitly declared after the @@ -73,15 +79,23 @@ impl FieldMap { /// may exist in the spread). #[must_use] pub fn get(&self, key: &str) -> Option { + match self.get_ignoring_plugins("plugins") { + Some(FieldValue::NonStatic) => Some(FieldValue::NonStatic), + _ => self.get_ignoring_plugins(key), + } + } + + fn get_ignoring_plugins(&self, key: &str) -> Option { self.map.get(key).cloned().or_else(|| self.open.then_some(FieldValue::NonStatic)) } /// Like [`get`](Self::get), but only for fields the config explicitly /// declares. Unlike `get`, an open map (spread, computed keys, or an /// unanalyzable config) does NOT report undeclared keys as `NonStatic`: - /// they return `None`. Use this when a merely-possible field must not - /// change behavior (e.g. `defaultPackage`, where erroring on every - /// unanalyzable config would break unrelated commands). + /// they return `None`. Plugins do not affect the result either. Use this + /// when a merely-possible field must not change behavior (e.g. + /// `defaultPackage`, where erroring on every unanalyzable config would + /// break unrelated commands). #[must_use] pub fn get_declared(&self, key: &str) -> Option { self.map.get(key).cloned() @@ -426,8 +440,8 @@ fn count_returns_in_stmt(stmt: &Statement<'_>) -> usize { /// Fields declared after the last spread/computed key are still extractable: /// /// ```js -/// { a: 1, ...x, b: 2 } // open{ b: Json(2) }; get("a") = NonStatic, get("b") = Json(2) -/// { a: 1, [k]: 2, b: 3 } // open{ b: Json(3) }; get("a") = NonStatic, get("b") = Json(3) +/// { a: 1, ...x, b: 2 } // open{ b: Json(2) }; get_declared("a") = None, get_declared("b") = Json(2) +/// { a: 1, [k]: 2, b: 3 } // open{ b: Json(3) }; get_declared("a") = None, get_declared("b") = Json(3) /// { a: 1, b: 2 } // closed{ a: Json(1), b: Json(2) }; get("c") = None /// ``` fn extract_object_fields(obj: &oxc_ast::ast::ObjectExpression<'_>) -> FieldMap { @@ -586,6 +600,11 @@ mod tests { assert_eq!(map.get_declared("defaultPackage"), None); } + /// Shorthand for asserting a field is declared as JSON, ignoring spreads and plugins. + fn assert_declared_json(map: &FieldMap, key: &str, expected: serde_json::Value) { + assert_eq!(map.get_declared(key), Some(FieldValue::Json(expected))); + } + /// Shorthand for asserting a field is `NonStatic`. fn assert_non_static(map: &FieldMap, key: &str) { assert_eq!( @@ -947,10 +966,32 @@ mod tests { plugins: [myPlugin()], }", ); - assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_declared_json(&result, "run", serde_json::json!({ "cacheScripts": true })); assert_non_static(&result, "plugins"); } + #[test] + fn non_static_plugins_make_every_field_non_static() { + // Plugin `config` hooks can add or override any field. + let result = parse( + r"export default { + run: { cacheScripts: true }, + plugins: [myPlugin()], + }", + ); + assert_non_static(&result, "run"); + assert_non_static(&result, "lint"); + assert_declared_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + } + + #[test] + fn static_plugins_keep_fields_static() { + // JSON values cannot contain hooks, so a static `plugins` field changes nothing. + let result = parse(r"export default { run: { cacheScripts: true }, plugins: [] }"); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert!(result.get("lint").is_none()); + } + #[test] fn non_static_identifier_values() { let result = parse( @@ -1005,7 +1046,7 @@ mod tests { ", ); assert_non_static(&result, "x"); - assert_json(&result, "b", serde_json::json!("ok")); + assert_declared_json(&result, "b", serde_json::json!("ok")); } #[test] @@ -1021,7 +1062,7 @@ mod tests { assert_non_static(&result, "a"); assert_non_static(&result, "run"); assert_non_static(&result, "x"); - assert_json(&result, "b", serde_json::json!("ok")); + assert_declared_json(&result, "b", serde_json::json!("ok")); } #[test] @@ -1038,13 +1079,22 @@ mod tests { #[test] fn spread_then_explicit_run() { - // run is explicitly declared after the spread and is still extractable as Json. + // The spread may contain plugins, so `run` is only static once `plugins` is declared after it. let result = parse( r" const base = { plugins: [] }; export default { ...base, run: { cacheScripts: true } } ", ); + assert_non_static(&result, "run"); + assert_declared_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + + let result = parse( + r" + const base = { plugins: [] }; + export default { ...base, plugins: [], run: { cacheScripts: true } } + ", + ); assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); } @@ -1065,7 +1115,7 @@ mod tests { ", ); assert_non_static(&result, "dynamic"); - assert_json(&result, "plain", serde_json::json!("ok")); + assert_declared_json(&result, "plain", serde_json::json!("ok")); } #[test] @@ -1080,7 +1130,7 @@ mod tests { ); assert_non_static(&result, "a"); assert_non_static(&result, "run"); - assert_json(&result, "b", serde_json::json!(2)); + assert_declared_json(&result, "b", serde_json::json!(2)); } #[test] @@ -1210,7 +1260,7 @@ mod tests { }); ", ); - assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_declared_json(&result, "run", serde_json::json!({ "cacheScripts": true })); assert_non_static(&result, "plugins"); } @@ -1244,7 +1294,7 @@ mod tests { }); ", ); - assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_declared_json(&result, "run", serde_json::json!({ "cacheScripts": true })); assert_non_static(&result, "plugins"); } diff --git a/packages/cli/binding/src/cli/handler.rs b/packages/cli/binding/src/cli/handler.rs index f4b7a56cfb..e49b5c9988 100644 --- a/packages/cli/binding/src/cli/handler.rs +++ b/packages/cli/binding/src/cli/handler.rs @@ -167,13 +167,6 @@ impl UserConfigLoader for VitePlusConfigLoader { // Try static config extraction first (no JS runtime needed) let static_fields = vp_static_config::resolve_static_config(package_path); match static_fields.get("run") { - // Plugin config hooks may add or override `run`, so only Vite can resolve it. - _ if static_fields.get("plugins").is_some() => { - tracing::debug!( - "plugins detected for {}, falling back to NAPI to run config hooks", - package_path.as_path().display() - ); - } Some(vp_static_config::FieldValue::Json(run_value)) => { tracing::debug!( "Using statically extracted run config for {}",