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: 1 addition & 1 deletion bench/config-performance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Use `--samples 2 --warmup 1` for a smoke run. A timing comparison requires at le

The cases cover root and package-directory `vp check --fix`, missing and minimal configs, `defineConfig`, root `lint`/`fmt` blocks, standalone tools, and `vp staged` with a check or a no-op task. Each sample starts a fresh process. Cases rotate between rounds. File and Git preparation happen outside the timed interval, and each sample receives the same three unformatted TypeScript files. Staged samples verify the formatted Git index. Command errors and timeouts fail the benchmark.

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 check, seven for a package check, and five for staged checks. Each Oxc child may evaluate its config at most once. These ceilings retain the current package-directory overhead until a separate optimization reduces it. Reduce the relevant ceiling with that optimization.
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.

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.

Expand Down
9 changes: 3 additions & 6 deletions bench/config-performance/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ const cases = [
package: false,
maxLoads: 4,
},
{ id: 'package/check/minimal', config: 'minimal', command: 'check', package: true, maxLoads: 7 },
{ id: 'package/check/blocks', config: 'blocks', command: 'check', package: true, maxLoads: 7 },
{ id: 'package/check/minimal', config: 'minimal', command: 'check', package: true, maxLoads: 4 },
{ id: 'package/check/blocks', config: 'blocks', command: 'check', package: true, maxLoads: 4 },
{ id: 'root/fmt/minimal', config: 'minimal', command: 'fmt', package: false, maxLoads: 1 },
{ id: 'root/lint/minimal', config: 'minimal', command: 'lint', package: false, maxLoads: 1 },
{ id: 'root/staged/minimal', config: 'minimal', command: 'staged', package: false, maxLoads: 5 },
Expand Down Expand Up @@ -249,10 +249,7 @@ try {
}
const loads = [...processes.values()];
report.results[index].configLoads = loads;
if (
entries.length > item.maxLoads ||
loads.some(({ role, count }) => count > (role === 'check' && item.package ? 4 : 1))
) {
if (entries.length > item.maxLoads || loads.some(({ count }) => count > 1)) {
throw new Error(`${item.id}: config evaluation budget exceeded: ${JSON.stringify(loads)}`);
}
}
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/binding/src/check/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ pub(crate) async fn execute_check(
envs,
cwd,
false,
&resolved_vite_config,
)
.await?;
let (fmt_status, combined_output) = combine_output(captured);
Expand Down Expand Up @@ -177,6 +178,7 @@ pub(crate) async fn execute_check(
envs,
cwd,
true,
&resolved_vite_config,
)
.await?;
let (lint_status, combined_output) = combine_output(captured);
Expand Down Expand Up @@ -261,6 +263,7 @@ pub(crate) async fn execute_check(
envs,
cwd,
false,
&resolved_vite_config,
)
.await?;
let (refmt_status, combined_output) = combine_output(captured);
Expand Down
17 changes: 12 additions & 5 deletions packages/cli/binding/src/cli/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ use vt_path::AbsolutePathBuf;

use super::{
resolver::SubcommandResolver,
types::{CapturedCommandOutput, SynthesizableSubcommand, exit_status_from},
types::{
CapturedCommandOutput, ResolvedUniversalViteConfig, SynthesizableSubcommand,
exit_status_from,
},
};

/// Resolve a subcommand into a prepared `tokio::process::Command`.
Expand All @@ -16,8 +19,10 @@ async fn resolve_and_build_command(
subcommand: SynthesizableSubcommand,
envs: &Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>>,
cwd: &AbsolutePathBuf,
resolved_config: Option<&ResolvedUniversalViteConfig>,
) -> Result<tokio::process::Command, Error> {
let resolved = resolver.resolve(subcommand, envs, cwd).await.map_err(Error::Anyhow)?;
let resolved =
resolver.resolve(subcommand, envs, cwd, resolved_config).await.map_err(Error::Anyhow)?;

// Resolve the program path using `which` to handle Windows .cmd/.bat files (PATHEXT)
let program_path = {
Expand Down Expand Up @@ -55,7 +60,7 @@ pub(super) async fn resolve_and_execute(
SynthesizableSubcommand::Dev { .. } | SynthesizableSubcommand::Preview { .. }
);

let mut cmd = resolve_and_build_command(resolver, subcommand, envs, cwd).await?;
let mut cmd = resolve_and_build_command(resolver, subcommand, envs, cwd, None).await?;

// For interactive commands (dev, preview), use terminal guard to restore terminal state on exit
let status = if is_interactive {
Expand All @@ -82,7 +87,7 @@ pub(super) async fn resolve_and_execute_with_filter(
stream: FilterStream,
filter: impl Fn(&str) -> Cow<'_, str>,
) -> Result<ExitStatus, Error> {
let mut cmd = resolve_and_build_command(resolver, subcommand, envs, cwd).await?;
let mut cmd = resolve_and_build_command(resolver, subcommand, envs, cwd, None).await?;
match stream {
FilterStream::Stdout => cmd.stdout(Stdio::piped()),
FilterStream::Stderr => cmd.stderr(Stdio::piped()),
Expand Down Expand Up @@ -112,8 +117,10 @@ pub(crate) async fn resolve_and_capture_output(
envs: &Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>>,
cwd: &AbsolutePathBuf,
force_color_if_terminal: bool,
resolved_config: &ResolvedUniversalViteConfig,
) -> Result<CapturedCommandOutput, Error> {
let mut cmd = resolve_and_build_command(resolver, subcommand, envs, cwd).await?;
let mut cmd =
resolve_and_build_command(resolver, subcommand, envs, cwd, Some(resolved_config)).await?;
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
// Capturing output hides the terminal from the child. Preserve colors only when
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/binding/src/cli/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ impl CommandHandler for VitePlusCommandHandler {
if super::app_target::needs_elicitation(&subcmd, &command.cwd) {
return Ok(HandledCommand::Verbatim);
}
let resolved = self.resolver.resolve(subcmd, &command.envs, &command.cwd).await?;
let resolved =
self.resolver.resolve(subcmd, &command.envs, &command.cwd, None).await?;
Ok(HandledCommand::Synthesized(resolved.into_synthetic_plan_request()))
}
CLIArgs::ViteTask(cmd) => Ok(HandledCommand::ViteTaskCommand(cmd)),
Expand Down
83 changes: 69 additions & 14 deletions packages/cli/binding/src/cli/resolver.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::{env, ffi::OsStr, iter, sync::Arc};
use std::{borrow::Cow, env, ffi::OsStr, iter, sync::Arc};

use rustc_hash::FxHashMap;
use vt::config::user::{
Expand Down Expand Up @@ -62,19 +62,25 @@ impl SubcommandResolver {
}

/// Resolve root settings for package runs unless the user selected another config.
async fn resolve_workspace_config(
async fn resolve_workspace_config<'a>(
&self,
cwd: &AbsolutePath,
args: &[String],
) -> anyhow::Result<Option<ResolvedUniversalViteConfig>> {
resolved_config: Option<&'a ResolvedUniversalViteConfig>,
) -> anyhow::Result<Option<Cow<'a, ResolvedUniversalViteConfig>>> {
let explicit_config = args
.iter()
.take_while(|arg| arg.as_str() != "--")
.any(|arg| arg.starts_with("-c") || arg == "--config" || arg.starts_with("--config="));
if cwd == self.workspace_path.as_ref() || explicit_config {
return Ok(None);
}
self.resolve_universal_vite_config().await.map(Some)
// Composite commands can reuse their config within this invocation.
// Do not cache it on the resolver: task runs may observe config changes.
if let Some(config) = resolved_config {
return Ok(Some(Cow::Borrowed(config)));
}
self.resolve_universal_vite_config().await.map(|config| Some(Cow::Owned(config)))
}

/// Resolve a synthesizable subcommand to a concrete program, args, cache config, and envs.
Expand All @@ -83,6 +89,7 @@ impl SubcommandResolver {
subcommand: SynthesizableSubcommand,
envs: &Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>>,
cwd: &AbsolutePath,
resolved_config: Option<&ResolvedUniversalViteConfig>,
) -> anyhow::Result<ResolvedSubcommand> {
match subcommand {
SynthesizableSubcommand::Lint { mut args } => {
Expand All @@ -93,12 +100,13 @@ impl SubcommandResolver {
.to_str()
.ok_or_else(|| anyhow::anyhow!("lint JS path is not valid UTF-8"))?;

if let Some(config) = self.resolve_workspace_config(cwd, &args).await?
if let Some(config) =
self.resolve_workspace_config(cwd, &args, resolved_config).await?
&& config.lint.is_some()
&& let Some(config_file) = config.config_file
&& let Some(config_file) = &config.config_file
{
args.insert(0, "-c".to_string());
args.insert(1, config_file);
args.insert(1, config_file.clone());
}

Ok(ResolvedSubcommand {
Expand Down Expand Up @@ -127,12 +135,13 @@ impl SubcommandResolver {
.to_str()
.ok_or_else(|| anyhow::anyhow!("fmt JS path is not valid UTF-8"))?;

if let Some(config) = self.resolve_workspace_config(cwd, &args).await?
if let Some(config) =
self.resolve_workspace_config(cwd, &args, resolved_config).await?
&& config.fmt.is_some()
&& let Some(config_file) = config.config_file
&& let Some(config_file) = &config.config_file
{
args.insert(0, "-c".to_string());
args.insert(1, config_file);
args.insert(1, config_file.clone());
}

Ok(ResolvedSubcommand {
Expand Down Expand Up @@ -403,7 +412,7 @@ mod tests {
SynthesizableSubcommand::Preview { args: vec![] },
SynthesizableSubcommand::Doc { args: vec![] },
] {
let resolved = resolver.resolve(command, &envs, &cwd).await.unwrap();
let resolved = resolver.resolve(command, &envs, &cwd, None).await.unwrap();
assert_eq!(resolved.program, runtime);
}
}
Expand Down Expand Up @@ -431,7 +440,7 @@ mod tests {
),
(SynthesizableSubcommand::Fmt { args: tool_args }, &["tool.js"]),
] {
let resolved = resolver.resolve(command, &envs, &cwd).await.unwrap();
let resolved = resolver.resolve(command, &envs, &cwd, None).await.unwrap();
let actual_args: Vec<&str> = resolved.args.iter().map(|arg| arg.as_str()).collect();
let expected_args = [prefix, args].concat();
assert_eq!(actual_args, expected_args);
Expand Down Expand Up @@ -475,7 +484,7 @@ mod tests {
),
(SynthesizableSubcommand::Fmt { args: tool_args }, &["tool.js"], has_fmt),
] {
let resolved = resolver.resolve(command, &envs, &cwd).await.unwrap();
let resolved = resolver.resolve(command, &envs, &cwd, None).await.unwrap();
let actual_args: Vec<&str> =
resolved.args.iter().map(|arg| arg.as_str()).collect();
let mut expected_args = prefix.to_vec();
Expand All @@ -489,6 +498,52 @@ mod tests {
}
}

#[tokio::test]
async fn check_phases_reuse_config_without_caching_later_commands() {
use std::sync::atomic::{AtomicUsize, Ordering};

let temp = tempfile::tempdir().unwrap();
let root = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap();
let cwd = root.join("packages/app");
let config_file = root.join("vite.config.ts").as_path().to_str().unwrap().to_string();
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);
let config_file = config_file.clone();
Arc::new(move |_| {
// A later command must observe removal of the root tool blocks.
let config = if loads.fetch_add(1, Ordering::SeqCst) == 0 {
serde_json::json!({ "configFile": config_file, "lint": {}, "fmt": {} })
} else {
serde_json::json!({ "configFile": config_file })
};
Box::pin(async move { Ok(config.to_string()) })
})
};
let resolver = SubcommandResolver::new(root.into()).with_cli_options(options);
let envs = Arc::new(FxHashMap::default());
let config = resolver.resolve_universal_vite_config().await.unwrap();

for command in [
SynthesizableSubcommand::Fmt { args: vec![] },
SynthesizableSubcommand::Lint { args: vec![] },
SynthesizableSubcommand::Fmt { args: vec![] },
] {
let resolved = resolver.resolve(command, &envs, &cwd, Some(&config)).await.unwrap();
let args: Vec<&str> = resolved.args.iter().map(|arg| arg.as_str()).collect();
assert!(args.ends_with(&["-c", &config_file]));
}
assert_eq!(loads.load(Ordering::SeqCst), 1);

let resolved = resolver
.resolve(SynthesizableSubcommand::Fmt { args: vec![] }, &envs, &cwd, None)
.await
.unwrap();
assert_eq!(loads.load(Ordering::SeqCst), 2);
assert_eq!(resolved.args.as_ref(), &[Str::from("tool.js")]);
}

#[tokio::test]
async fn explicit_lint_and_fmt_config_from_subdirectory_skips_root_config_loading() {
let temp = tempfile::tempdir().unwrap();
Expand All @@ -513,7 +568,7 @@ mod tests {
),
(SynthesizableSubcommand::Fmt { args: tool_args }, &["tool.js"]),
] {
let resolved = resolver.resolve(command, &envs, &cwd).await.unwrap();
let resolved = resolver.resolve(command, &envs, &cwd, None).await.unwrap();
let actual_args: Vec<&str> = resolved.args.iter().map(|arg| arg.as_str()).collect();
let expected_args = [prefix, args].concat();
assert_eq!(actual_args, expected_args);
Expand Down
74 changes: 74 additions & 0 deletions packages/cli/src/__tests__/resolve-universal-vite-config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import fs from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test';

import { resolveUniversalViteConfig } from '../resolve-vite-config.ts';
import { VITE_CONFIG_FILES } from '../utils/constants.ts';

describe('resolveUniversalViteConfig', () => {
let root: string;

beforeEach(() => {
root = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'vp-universal-config-')));
fs.writeFileSync(path.join(root, 'package.json'), '{"type":"module"}');
});

afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
vi.restoreAllMocks();
});

it('returns empty metadata without searching ancestors and observes a new config', async () => {
const cwd = path.join(root, 'app');
fs.mkdirSync(cwd);
fs.writeFileSync(path.join(root, 'vite.config.ts'), 'throw new Error("outside workspace");');
expect(JSON.parse(await resolveUniversalViteConfig(null, cwd))).toEqual({});

const configFile = path.join(cwd, 'vite.config.ts');
fs.writeFileSync(configFile, 'export default { check: { fmt: false } };');
expect(JSON.parse(await resolveUniversalViteConfig(null, cwd))).toMatchObject({
configFile: configFile.replaceAll('\\', '/'),
check: { fmt: false },
});
fs.unlinkSync(configFile);
expect(JSON.parse(await resolveUniversalViteConfig(null, cwd))).toEqual({});
});

it.each(VITE_CONFIG_FILES)(
'resolves %s with config functions and plugin hooks',
async (filename) => {
const configFile = path.join(root, filename);
const declaration =
filename.endsWith('.cjs') || filename.endsWith('.cts')
? 'module.exports ='
: 'export default';
fs.writeFileSync(
configFile,
`${declaration} async ({ command, mode }) => ({
lint: {}, fmt: {}, run: {}, staged: { '*.ts': 'vp check --fix' },
plugins: [{ name: 'metadata', config() {
return { check: { fmt: command !== 'build', lint: mode !== 'development' } };
} }],
});`,
);
expect(JSON.parse(await resolveUniversalViteConfig(null, root))).toEqual({
configFile: configFile.replaceAll('\\', '/'),
lint: {},
fmt: {},
run: {},
staged: { '*.ts': 'vp check --fix' },
check: { fmt: false, lint: false },
});
},
);

it('preserves callback and config errors', async () => {
const error = new Error('callback failed');
await expect(resolveUniversalViteConfig(error, root)).rejects.toBe(error);
vi.spyOn(console, 'error').mockImplementation(() => {});
fs.writeFileSync(path.join(root, 'vite.config.ts'), 'throw new Error("invalid config");');
await expect(resolveUniversalViteConfig(null, root)).rejects.toThrow('invalid config');
});
});
8 changes: 4 additions & 4 deletions packages/cli/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,6 @@ if (maybePrintCommandHelp(args)) {
} else {
// All other commands — delegate to Rust core via NAPI binding
try {
// This module imports `vitest/config` through define-config. Load it here
// so `migrate` and `config` can handle stale aliases before Vitest loads.
const { resolveUniversalViteConfig } = await import('./resolve-vite-config.js');
const initInspection = inspectInitCommand(command, args.slice(1));
if (
initInspection.handled &&
Expand All @@ -155,7 +152,10 @@ if (maybePrintCommandHelp(args)) {
doc,
toolchainManifestPath: path.join(cliDistDir, 'toolchain.json'),
vitePlusPackagePath,
resolveUniversalViteConfig,
async resolveUniversalViteConfig(err, cwd) {
const { resolveUniversalViteConfig } = await import('./resolve-vite-config.js');
return resolveUniversalViteConfig(err, cwd);
},
explicitChdir,
args: rustCliArgs,
});
Expand Down
Loading
Loading