diff --git a/bench/config-performance/README.md b/bench/config-performance/README.md index 8167347b97..b7ebbf2577 100644 --- a/bench/config-performance/README.md +++ b/bench/config-performance/README.md @@ -18,6 +18,8 @@ The cases cover root and package-directory `vp check --fix`, missing and minimal Config evaluations are measured in separate runs. Synchronous log writes do not affect the timing samples. The current ceilings are four evaluations for a root or package check and five for staged checks. Each process may evaluate its config at most once. The check process reuses its resolved config when it selects the config for each tool phase. +Timing samples for plain metadata exports exercise the Rust static path in the check process. The `defineConfig` case retains JavaScript resolution. Evaluation probes add an executable log statement, which deliberately requires runtime resolution; their counts describe that instrumented config, not the static timing fixture. Rust resolver tests verify that eligible configs do not call the JavaScript resolver and that later calls observe file changes. Tool processes still resolve their own configs. + The benchmark uses temporary projects outside the checkout so ancestor config discovery cannot find the repository's config. It runs the same Node executable in staged tasks and tool children. It uses a separate Node compile-cache directory and removes fixtures after completion. Warmups make this a measurement of fresh-process startup with warm filesystem and compile caches, not a cold-disk benchmark. The [Config Performance workflow](../../.github/workflows/config-performance.yml) runs only when a relevant PR opens, updates, or reopens. Draft PRs run normally. diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/check_config_no_lint/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/check_config_no_lint/snapshots.toml index a595563e6f..292062f23b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/check_config_no_lint/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/check_config_no_lint/snapshots.toml @@ -4,3 +4,16 @@ vp = "local" steps = [ { argv = ["vp", "check"], continue-on-failure = true }, ] + +[[case]] +name = "static_metadata_and_runtime_fallback" +vp = "local" +steps = [ + { argv = ["vp", "check"], comment = "A literal metadata export preserves the configured check phases." }, + { argv = ["vpt", "write-file", "vite.config.ts", "export default { check: { fmt: false, lint: false } };\n"], snapshot = false }, + { argv = ["vp", "check"], comment = "The next command observes the changed metadata.", continue-on-failure = true }, + { argv = ["vpt", "write-file", "vite.config.ts", "export default async () => ({ plugins: [{ name: 'metadata', config() { return { check: { fmt: false, lint: false } }; } }] });\n"], snapshot = false }, + { argv = ["vp", "check"], comment = "Config functions and plugin hooks still run through Vite.", continue-on-failure = true }, + { argv = ["vpt", "write-file", "vite.config.ts", "console.log('config side effect'); export default { check: { fmt: false, lint: false } };\n"], snapshot = false }, + { argv = ["vp", "check"], comment = "A module with executable statements must retain its side effects.", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/check_config_no_lint/snapshots/static_metadata_and_runtime_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/check_config_no_lint/snapshots/static_metadata_and_runtime_fallback.md new file mode 100644 index 0000000000..b815e16a2c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/check_config_no_lint/snapshots/static_metadata_and_runtime_fallback.md @@ -0,0 +1,65 @@ +# static_metadata_and_runtime_fallback + +## `vp check` + +A literal metadata export preserves the configured check phases. + +``` +note: Lint skipped (check.lint: false in vite.config.ts) +pass: All 3 files are correctly formatted (, threads) +``` + +## `vpt write-file vite.config.ts 'export default { check: { fmt: false, lint: false } }; +'` + + +## `vp check` + +The next command observes the changed metadata. + +**Exit code:** 1 + +``` +note: Format skipped (check.fmt: false in vite.config.ts) +note: Lint skipped (check.lint: false in vite.config.ts) +error: No checks enabled + +Enable `lint.options.typeCheck` in vite.config.ts for type-check only, drop a `--no-fmt`/`--no-lint` flag, or re-enable `check.fmt`/`check.lint` in vite.config.ts. +``` + +## `vpt write-file vite.config.ts 'export default async () => ({ plugins: [{ name: '\''metadata'\'', config() { return { check: { fmt: false, lint: false } }; } }] }); +'` + + +## `vp check` + +Config functions and plugin hooks still run through Vite. + +**Exit code:** 1 + +``` +note: Format skipped (check.fmt: false in vite.config.ts) +note: Lint skipped (check.lint: false in vite.config.ts) +error: No checks enabled + +Enable `lint.options.typeCheck` in vite.config.ts for type-check only, drop a `--no-fmt`/`--no-lint` flag, or re-enable `check.fmt`/`check.lint` in vite.config.ts. +``` + +## `vpt write-file vite.config.ts 'console.log('\''config side effect'\''); export default { check: { fmt: false, lint: false } }; +'` + + +## `vp check` + +A module with executable statements must retain its side effects. + +**Exit code:** 1 + +``` +config side effect +note: Format skipped (check.fmt: false in vite.config.ts) +note: Lint skipped (check.lint: false in vite.config.ts) +error: No checks enabled + +Enable `lint.options.typeCheck` in vite.config.ts for type-check only, drop a `--no-fmt`/`--no-lint` flag, or re-enable `check.fmt`/`check.lint` in vite.config.ts. +``` diff --git a/crates/vp_static_config/README.md b/crates/vp_static_config/README.md index 950649567d..a9caab747f 100644 --- a/crates/vp_static_config/README.md +++ b/crates/vp_static_config/README.md @@ -2,6 +2,16 @@ Statically extracts configuration from `vite.config.*` files without executing JavaScript. +`resolve_static_metadata` provides a stricter path for the CLI's universal config resolver. +It accepts only a single `export default` object containing literal `lint`, `fmt`, `check`, +`run`, or `staged` fields. It rejects imports, executable statements, Vite settings, and +values with dynamic behavior. Unsupported, missing, or unreadable configs return `None` +so the CLI uses its existing JavaScript resolver. Each call reads the file again. + +This differs from field extraction: a statically extractable field does not prove that +the rest of the module can be skipped. Prototype setters and unsupported string values +also require runtime resolution. + ## What it does Parses vite config files using [oxc_parser](https://crates.io/crates/oxc_parser) and extracts diff --git a/crates/vp_static_config/src/lib.rs b/crates/vp_static_config/src/lib.rs index 2af9103163..418f855e5c 100644 --- a/crates/vp_static_config/src/lib.rs +++ b/crates/vp_static_config/src/lib.rs @@ -14,6 +14,9 @@ use oxc_span::SourceType; use rustc_hash::FxHashMap; use vt_path::AbsolutePath; +mod metadata; +pub use metadata::{StaticConfigMetadata, resolve_static_metadata}; + /// Packages whose `defineConfig` helpers preserve top-level config fields. const TRUSTED_DEFINE_CONFIG_PACKAGES: &[&str] = &["vite-plus", "vite"]; /// The name of the config helper static extraction trusts. diff --git a/crates/vp_static_config/src/metadata.rs b/crates/vp_static_config/src/metadata.rs new file mode 100644 index 0000000000..18a55f0363 --- /dev/null +++ b/crates/vp_static_config/src/metadata.rs @@ -0,0 +1,184 @@ +use oxc_allocator::Allocator; +use oxc_ast::ast::{Expression, ObjectPropertyKind, PropertyKey, PropertyKind, Statement}; +use oxc_parser::Parser; +use oxc_span::SourceType; +use vt_path::{AbsolutePath, AbsolutePathBuf}; + +use super::{expr_to_json, resolve_config_path}; + +/// Metadata from a config whose entire module can be read without execution. +pub struct StaticConfigMetadata { + pub config_file: AbsolutePathBuf, + pub metadata: serde_json::Value, +} + +/// Read a plain metadata export without initializing Vite. +/// +/// Unlike field extraction, this requires the whole module to be a single +/// literal export. Vite settings, imports, executable code, and unsupported +/// syntax return `None`, so the caller retains runtime validation and effects. +/// Missing or unreadable files also use the caller's existing resolution path. +pub fn resolve_static_metadata(dir: &AbsolutePath) -> Option { + let config_file = resolve_config_path(dir)?; + let source = std::fs::read_to_string(&config_file).ok()?; + let source_type = SourceType::from_path(config_file.as_path()).ok()?; + let metadata = parse_metadata(&source, source_type)?; + Some(StaticConfigMetadata { config_file, metadata }) +} + +fn parse_metadata(source: &str, source_type: SourceType) -> Option { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, source_type).parse(); + if parsed.fatal_error || !parsed.diagnostics.is_empty() || !parsed.program.directives.is_empty() + { + return None; + } + let [Statement::ExportDefaultDeclaration(export)] = parsed.program.body.as_slice() else { + return None; + }; + let expression = export.declaration.as_expression()?; + if !is_json_literal(expression) { + return None; + } + let metadata = expr_to_json(expression)?; + let fields = metadata.as_object()?; + // Even literal Vite settings can trigger validation, environment changes, + // or plugin integration. Only these unmodified metadata fields are safe. + if fields.keys().any(|key| !matches!(key.as_str(), "lint" | "fmt" | "check" | "run" | "staged")) + { + return None; + } + Some(metadata) +} + +fn is_json_literal(expression: &Expression<'_>) -> bool { + match expression.get_inner_expression() { + Expression::NullLiteral(_) | Expression::BooleanLiteral(_) => true, + Expression::NumericLiteral(value) => value.value.is_finite(), + Expression::StringLiteral(value) => !value.lone_surrogates, + Expression::UnaryExpression(value) => { + value.operator == oxc_ast::ast::UnaryOperator::UnaryNegation + && matches!(&value.argument, Expression::NumericLiteral(number) if number.value.is_finite()) + } + Expression::ArrayExpression(array) => array + .elements + .iter() + .all(|element| element.as_expression().is_some_and(is_json_literal)), + Expression::ObjectExpression(object) => object.properties.iter().all(|property| { + let ObjectPropertyKind::ObjectProperty(property) = property else { + return false; + }; + let key = match &property.key { + PropertyKey::StaticIdentifier(key) => key.name.as_str(), + PropertyKey::StringLiteral(key) if !key.lone_surrogates => key.value.as_str(), + _ => return false, + }; + // __proto__ changes inheritance instead of creating a JSON field. + key != "__proto__" + && property.kind == PropertyKind::Init + && !property.computed + && !property.method + && !property.shorthand + && is_json_literal(&property.value) + }), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn literal_metadata_preserves_values_and_last_duplicate_key() { + let fields = parse_metadata( + r#"// A plain, typed metadata export. + export default ({ + lint: { rules: { 'no-debugger': 'error' }, options: { typeAware: true } }, + fmt: { singleQuote: true, printWidth: 90 }, + check: { lint: false }, check: { fmt: false }, + run: { tasks: { build: { command: 'echo ok', values: [null, -2, 1.5] } } }, + staged: { '*.ts': 'vp check --fix' } + } as const);"#, + SourceType::ts(), + ) + .unwrap(); + assert_eq!( + fields, + serde_json::json!({ + "lint": { "rules": { "no-debugger": "error" }, "options": { "typeAware": true } }, + "fmt": { "singleQuote": true, "printWidth": 90 }, + "check": { "fmt": false }, + "run": { "tasks": { "build": { "command": "echo ok", "values": [null, -2, 1.5] } } }, + "staged": { "*.ts": "vp check --fix" } + }) + ); + } + + #[test] + fn runtime_features_and_vite_settings_require_fallback() { + for source in [ + "export default {};\nthrow new Error('must run');", + "throw new Error('must run'); export default {};", + "import './side-effect.js'; export default {};", + "import { defineConfig } from 'vite-plus'; export default defineConfig({ lint: {} });", + "import type { UserConfig } from 'vite'; export default {} satisfies UserConfig;", + "export default () => ({ lint: {} });", + "export default async () => ({ lint: {} });", + "const config = {}; export default config;", + "export default { plugins: [] };", + "export default { root: 'app', lint: {} };", + "export default { envPrefix: '', lint: {} };", + "export default { devtools: true };", + "export default { configFile: false };", + "export default { ...base, lint: {} };", + "export default { ['lint']: {} };", + "export default { lint: { get rules() { return {}; } } };", + "export default { lint: { rules() {} } };", + "export default { lint };", + "export default { lint: JSON.parse('{}') };", + "export default { lint: { __proto__: { options: { typeAware: true } } } };", + "export default { __proto__: { check: { fmt: false } } };", + "export default { lint: { ['__proto__']: {} } };", + "export default { lint: { rules: { '\\ud800': 'error' } } };", + "export default { fmt: { value: '\\ud800' } };", + "export default { fmt: { value: 1e400 } };", + "export default { fmt: { value: [, 1] } };", + "export default { fmt: { value: [...items] } };", + "export default { fmt: { value: /pattern/ } };", + "export default { fmt: { value: undefined } };", + "export default { fmt: { value: 1n } };", + "module.exports = { lint: {} };", + "export default null;", + "export default [];", + "export default { lint:", + "export default {}; export default {};", + ] { + assert!(parse_metadata(source, SourceType::ts()).is_none(), "{source}"); + } + } + + #[test] + fn respects_extensions_precedence_and_file_changes() { + let temp = tempfile::tempdir().unwrap(); + let root = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + assert!(resolve_static_metadata(&root).is_none()); + for name in super::super::CONFIG_FILE_NAMES { + let file = root.join(name); + std::fs::write(&file, "export default { lint: {} };").unwrap(); + let metadata = resolve_static_metadata(&root).unwrap(); + assert_eq!(metadata.config_file, file); + assert_eq!(metadata.metadata["lint"], serde_json::json!({})); + std::fs::write(&file, "export default { check: { fmt: false } };").unwrap(); + assert!(resolve_static_metadata(&root).unwrap().metadata.get("lint").is_none()); + std::fs::remove_file(&file).unwrap(); + assert!(resolve_static_metadata(&root).is_none()); + } + std::fs::write(root.join("vite.config.ts"), "export default {};").unwrap(); + std::fs::write(root.join("vite.config.js"), "throw new Error('higher priority');").unwrap(); + assert!(resolve_static_metadata(&root).is_none()); + std::fs::remove_file(root.join("vite.config.js")).unwrap(); + std::fs::create_dir(root.join("vite.config.js")).unwrap(); + assert!(resolve_static_metadata(&root).is_none()); + } +} diff --git a/packages/cli/binding/src/cli/resolver.rs b/packages/cli/binding/src/cli/resolver.rs index a0c43d723f..1eebda14af 100644 --- a/packages/cli/binding/src/cli/resolver.rs +++ b/packages/cli/binding/src/cli/resolver.rs @@ -1,5 +1,6 @@ use std::{borrow::Cow, env, ffi::OsStr, iter, sync::Arc}; +use cow_utils::CowUtils; use rustc_hash::FxHashMap; use vt::config::user::{ AutoTracking, EnabledCacheConfig, GlobWithBase, InputBase, UserCacheConfig, UserInputEntry, @@ -53,6 +54,14 @@ impl SubcommandResolver { .as_path() .to_str() .ok_or_else(|| anyhow::anyhow!("workspace path is not valid UTF-8"))?; + if let Some(config) = vp_static_config::resolve_static_metadata(&self.workspace_path) + && let Some(config_file) = config.config_file.as_path().to_str() + { + let mut resolved: ResolvedUniversalViteConfig = + serde_json::from_value(config.metadata)?; + resolved.config_file = Some(config_file.cow_replace('\\', "/").into_owned()); + return Ok(resolved); + } let vite_config_json = (cli_options.resolve_universal_vite_config)(workspace_path_str.to_string()).await?; @@ -544,6 +553,106 @@ mod tests { assert_eq!(resolved.args.as_ref(), &[Str::from("tool.js")]); } + #[tokio::test] + async fn literal_metadata_skips_javascript_and_observes_changes() { + let temp = tempfile::tempdir().unwrap(); + let root = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let file = root.join("vite.config.ts"); + let resolver = SubcommandResolver::new(root.clone().into()) + .with_cli_options(cli_options(Arc::from(OsStr::new("node")))); + std::fs::write(&file, "export default { lint: {}, fmt: {}, check: { fmt: false } };") + .unwrap(); + + // The callback in cli_options fails if JavaScript resolution is used. + let config = resolver.resolve_universal_vite_config().await.unwrap(); + assert_eq!(config.lint, Some(serde_json::json!({}))); + assert_eq!(config.fmt, Some(serde_json::json!({}))); + assert_eq!(config.check, Some(serde_json::json!({ "fmt": false }))); + assert_eq!( + config.config_file.as_deref(), + Some(file.as_path().to_str().unwrap().cow_replace('\\', "/").as_ref()) + ); + + let envs = Arc::new(FxHashMap::default()); + let cwd = root.join("packages/app"); + let resolved = resolver + .resolve( + SynthesizableSubcommand::Fmt { args: vec!["src".to_string()] }, + &envs, + &cwd, + Some(&config), + ) + .await + .unwrap(); + assert!(resolved.args.ends_with(&[ + Str::from("-c"), + Str::from(config.config_file.unwrap()), + Str::from("src") + ])); + + std::fs::write(&file, "export default { check: { lint: false } };").unwrap(); + let config = resolver.resolve_universal_vite_config().await.unwrap(); + assert!(config.lint.is_none()); + assert!(config.fmt.is_none()); + assert_eq!(config.check, Some(serde_json::json!({ "lint": false }))); + + std::fs::remove_file(file).unwrap(); + // Removal must return to the existing JavaScript no-config path. + assert!(resolver.resolve_universal_vite_config().await.is_err()); + } + + #[tokio::test] + async fn dynamic_metadata_and_vite_options_use_runtime_resolution() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let temp = tempfile::tempdir().unwrap(); + let root = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let file = root.join("vite.config.ts"); + let loads = Arc::new(AtomicUsize::new(0)); + let mut options = cli_options(Arc::from(OsStr::new("node"))); + options.resolve_universal_vite_config = { + let loads = Arc::clone(&loads); + Arc::new(move |_| { + loads.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(r#"{"check":{"fmt":false}}"#.to_string()) }) + }) + }; + let resolver = SubcommandResolver::new(root.into()).with_cli_options(options); + for (index, source) in [ + "export default async () => ({ check: { fmt: false } });", + "export default { plugins: [{ name: 'metadata', config() { return { check: { fmt: false } }; } }] };", + "import { defineConfig } from 'vite-plus'; export default defineConfig({});", + "export default {}; console.log('side effect');", + "export default { envPrefix: '' };", + "export default { __proto__: { check: { fmt: false } } };", + "export default {", + ].iter().enumerate() { + std::fs::write(&file, source).unwrap(); + let config = resolver.resolve_universal_vite_config().await.unwrap(); + assert_eq!(config.check, Some(serde_json::json!({ "fmt": false }))); + assert_eq!(loads.load(Ordering::SeqCst), index + 1); + } + } + + #[tokio::test] + async fn runtime_errors_are_not_hidden_by_static_extraction() { + let temp = tempfile::tempdir().unwrap(); + let root = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + std::fs::write( + root.join("vite.config.ts"), + "export default {}; throw new Error('bad config');", + ) + .unwrap(); + let mut options = cli_options(Arc::from(OsStr::new("node"))); + options.resolve_universal_vite_config = + Arc::new(|_| Box::pin(async { anyhow::bail!("bad config") })); + let resolver = SubcommandResolver::new(root.into()).with_cli_options(options); + assert_eq!( + resolver.resolve_universal_vite_config().await.unwrap_err().to_string(), + "bad config" + ); + } + #[tokio::test] async fn explicit_lint_and_fmt_config_from_subdirectory_skips_root_config_loading() { let temp = tempfile::tempdir().unwrap();