Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bench/config-performance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
]
Original file line number Diff line number Diff line change
@@ -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 (<duration>, <n> 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.
```
10 changes: 10 additions & 0 deletions crates/vp_static_config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crates/vp_static_config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
184 changes: 184 additions & 0 deletions crates/vp_static_config/src/metadata.rs
Original file line number Diff line number Diff line change
@@ -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<StaticConfigMetadata> {
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<serde_json::Value> {
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());
}
}
Loading
Loading